config-audit/tests/lib/yaml-parser.test.mjs
Kjell Tore Guttormsen 62d910ed6d feat(acr,yaml): v5.6 Foundation — load-pattern enumeration + block-seq parser
Foundation chunk of v5.6 "steering-model II" (internal plumbing for B/C;
no command-output change, so --json/--raw/SC-5/6/7 stay byte-stable, count
stays 13).

active-config-reader.mjs:
- deriveLoadPattern(kind,{scoped}) — pure helper mapping each source kind to
  loadPattern {always,on-demand,external} + survivesCompaction {yes,no,n/a}
  + derivationConfidence {confirmed,inferred}, traced to the published
  loading model (V-rows in docs/v5.5-steering-model-plan.md).
- enumerateRules / enumerateAgents / enumerateOutputStyles — the three
  source kinds previously unenumerated (mirror enumerateSkills). Output-style
  discovery is direct (not a new file-discovery type) to keep the discovery
  surface stable.
- readActiveConfig now exposes rules/agents/outputStyles arrays + totals
  counts/subtotals (folded into grandTotal).

yaml-parser.mjs:
- parseSimpleYaml now reads YAML block sequences (paths:\n  - a), not just
  inline paths:. An empty-valued key with no `- ` items stays null
  (backcompat). Resolves a pre-existing RUL false-positive (a block-seq-scoped
  rule was misread as unscoped) — fix flows through unchanged RUL code.

Tests +35 (961 -> 996): block-seq parser cases, RUL block-seq regression
(no-misflag + durability-fires), deriveLoadPattern table, three enumerators
(positive+negative). Amended two existing ACR asserts (top-level key shape +
grandTotal sum). self-audit A/A, readmeCheck passed, mismatches []. tests
badge 961+->996+; README testing prose de-staled (635/36 -> 996/56);
CLAUDE.md Foundation note.

B (manifest/tokens render + snapshot regen) and C (CA-OST, count->14)
deferred to their own sessions/GO.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
2026-06-20 16:57:52 +02:00

206 lines
7.4 KiB
JavaScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { parseFrontmatter, parseSimpleYaml, parseJson, findImports, extractSections } from '../../scanners/lib/yaml-parser.mjs';
describe('parseFrontmatter', () => {
it('parses standard frontmatter', () => {
const content = '---\nname: test\nmodel: opus\n---\n\nBody here';
const { frontmatter, body } = parseFrontmatter(content);
assert.deepStrictEqual(frontmatter, { name: 'test', model: 'opus' });
assert.ok(body.includes('Body here'));
});
it('returns null frontmatter when none exists', () => {
const { frontmatter, body } = parseFrontmatter('Just body text');
assert.strictEqual(frontmatter, null);
assert.strictEqual(body, 'Just body text');
});
it('handles empty frontmatter', () => {
const { frontmatter } = parseFrontmatter('---\n---\nBody');
assert.deepStrictEqual(frontmatter, {});
});
it('calculates bodyStartLine correctly', () => {
const content = '---\na: 1\nb: 2\n---\nBody';
const { bodyStartLine } = parseFrontmatter(content);
assert.strictEqual(bodyStartLine, 5);
});
});
describe('parseSimpleYaml', () => {
it('parses key-value pairs', () => {
const result = parseSimpleYaml('name: test\nmodel: opus');
assert.strictEqual(result.name, 'test');
assert.strictEqual(result.model, 'opus');
});
it('parses boolean values', () => {
const result = parseSimpleYaml('enabled: true\ndisabled: false');
assert.strictEqual(result.enabled, true);
assert.strictEqual(result.disabled, false);
});
it('parses numeric values', () => {
const result = parseSimpleYaml('count: 42\nrate: 3.14');
assert.strictEqual(result.count, 42);
assert.strictEqual(result.rate, 3.14);
});
it('parses inline arrays', () => {
const result = parseSimpleYaml('tools: [Read, Write, Bash]');
assert.deepStrictEqual(result.tools, ['Read', 'Write', 'Bash']);
});
it('strips quotes from values', () => {
const result = parseSimpleYaml('name: "quoted value"');
assert.strictEqual(result.name, 'quoted value');
});
it('normalizes hyphens to underscores in keys', () => {
const result = parseSimpleYaml('allowed-tools: Read');
assert.ok('allowed_tools' in result);
});
it('normalizes comma-separated strings in list fields', () => {
const result = parseSimpleYaml('allowed-tools: Read, Write, Bash');
assert.deepStrictEqual(result.allowed_tools, ['Read', 'Write', 'Bash']);
});
it('handles null values', () => {
const result = parseSimpleYaml('value: null\ntilde: ~\nempty:');
assert.strictEqual(result.value, null);
assert.strictEqual(result.tilde, null);
assert.strictEqual(result.empty, null);
});
it('skips comments', () => {
const result = parseSimpleYaml('# comment\nname: test\n# another');
assert.strictEqual(result.name, 'test');
assert.strictEqual(Object.keys(result).length, 1);
});
it('handles multi-line pipe values', () => {
const result = parseSimpleYaml('description: |\n Line 1\n Line 2\nname: test');
assert.ok(result.description.includes('Line 1'));
assert.ok(result.description.includes('Line 2'));
assert.strictEqual(result.name, 'test');
});
});
describe('parseSimpleYaml — YAML block sequences', () => {
it('parses a block-sequence value for paths', () => {
const result = parseSimpleYaml('paths:\n - src/**/*.ts\n - tests/**');
assert.deepStrictEqual(result.paths, ['src/**/*.ts', 'tests/**']);
});
it('parses a block-sequence value for globs', () => {
const result = parseSimpleYaml('globs:\n - "*.md"\n - docs/**');
assert.deepStrictEqual(result.globs, ['*.md', 'docs/**']);
});
it('parses a block-sequence value for tools', () => {
const result = parseSimpleYaml('tools:\n - Read\n - Write');
assert.deepStrictEqual(result.tools, ['Read', 'Write']);
});
it('parses a block sequence for allowed-tools and normalizes the key', () => {
const result = parseSimpleYaml('allowed-tools:\n - Read\n - Bash');
assert.deepStrictEqual(result.allowed_tools, ['Read', 'Bash']);
});
it('terminates the sequence at the next key (mixed block + inline)', () => {
const result = parseSimpleYaml('paths:\n - a\n - b\nmodel: opus');
assert.deepStrictEqual(result.paths, ['a', 'b']);
assert.strictEqual(result.model, 'opus');
});
it('strips quotes from sequence items', () => {
const result = parseSimpleYaml("paths:\n - \"src/**/*.ts\"\n - 'tests/**'");
assert.deepStrictEqual(result.paths, ['src/**/*.ts', 'tests/**']);
});
it('skips a comment line inside the sequence', () => {
const result = parseSimpleYaml('paths:\n - a\n # skip me\n - b');
assert.deepStrictEqual(result.paths, ['a', 'b']);
});
it('treats an empty-valued key with no items as null (backcompat)', () => {
const result = parseSimpleYaml('paths:\n\nmodel: opus');
assert.strictEqual(result.paths, null);
assert.strictEqual(result.model, 'opus');
});
it('keeps inline array form unchanged (regression)', () => {
const result = parseSimpleYaml('tools: [Read, Write, Bash]');
assert.deepStrictEqual(result.tools, ['Read', 'Write', 'Bash']);
});
it('keeps comma-separated form unchanged (regression)', () => {
const result = parseSimpleYaml('allowed-tools: Read, Write, Bash');
assert.deepStrictEqual(result.allowed_tools, ['Read', 'Write', 'Bash']);
});
it('keeps a bare empty-valued key null (regression)', () => {
const result = parseSimpleYaml('value: null\ntilde: ~\nempty:');
assert.strictEqual(result.empty, null);
});
});
describe('parseJson', () => {
it('parses valid JSON', () => {
const result = parseJson('{"key": "value"}');
assert.deepStrictEqual(result, { key: 'value' });
});
it('returns null for invalid JSON', () => {
assert.strictEqual(parseJson('{invalid}'), null);
});
it('returns null for empty string', () => {
assert.strictEqual(parseJson(''), null);
});
});
describe('findImports', () => {
it('finds @import lines', () => {
const content = '# Title\n@path/to/file.md\nSome text\n@another/file.md';
const imports = findImports(content);
assert.strictEqual(imports.length, 2);
assert.strictEqual(imports[0].path, 'path/to/file.md');
assert.strictEqual(imports[0].line, 2);
assert.strictEqual(imports[1].path, 'another/file.md');
assert.strictEqual(imports[1].line, 4);
});
it('returns empty array when no imports', () => {
assert.deepStrictEqual(findImports('Just text'), []);
});
it('ignores @ in the middle of lines', () => {
const imports = findImports('Email me at user@example.com');
assert.strictEqual(imports.length, 0);
});
});
describe('extractSections', () => {
it('extracts markdown headings', () => {
const content = '# Title\n## Section 1\nText\n### Sub-section\n## Section 2';
const sections = extractSections(content);
assert.strictEqual(sections.length, 4);
assert.strictEqual(sections[0].heading, 'Title');
assert.strictEqual(sections[0].level, 1);
assert.strictEqual(sections[1].heading, 'Section 1');
assert.strictEqual(sections[1].level, 2);
});
it('returns empty for no headings', () => {
assert.deepStrictEqual(extractSections('Just plain text'), []);
});
it('includes line numbers', () => {
const content = 'line1\n## Heading\nline3';
const sections = extractSections(content);
assert.strictEqual(sections[0].line, 2);
});
});