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
This commit is contained in:
parent
d03c3831bd
commit
62d910ed6d
8 changed files with 575 additions and 25 deletions
|
|
@ -13,6 +13,10 @@ import {
|
|||
readActiveHooks,
|
||||
readActiveMcpServers,
|
||||
readActiveConfig,
|
||||
deriveLoadPattern,
|
||||
enumerateRules,
|
||||
enumerateAgents,
|
||||
enumerateOutputStyles,
|
||||
} from '../../scanners/lib/active-config-reader.mjs';
|
||||
|
||||
function uniqueDir(suffix) {
|
||||
|
|
@ -625,8 +629,8 @@ describe('readActiveConfig (integration)', () => {
|
|||
const result = await readActiveConfig(fixture.root);
|
||||
const keys = Object.keys(result).sort();
|
||||
assert.deepEqual(keys, [
|
||||
'claudeMd', 'hooks', 'mcpServers', 'meta', 'plugins',
|
||||
'settings', 'skills', 'suggestDisables', 'totals', 'warnings',
|
||||
'agents', 'claudeMd', 'hooks', 'mcpServers', 'meta', 'outputStyles',
|
||||
'plugins', 'rules', 'settings', 'skills', 'suggestDisables', 'totals', 'warnings',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -654,7 +658,8 @@ describe('readActiveConfig (integration)', () => {
|
|||
it('totals.grandTotal equals sum of category subtotals', async () => {
|
||||
const result = await readActiveConfig(fixture.root);
|
||||
const t = result.totals.estimatedTokens;
|
||||
assert.equal(t.grandTotal, t.claudeMd + t.plugins + t.skills + t.mcpServers + t.hooks);
|
||||
assert.equal(t.grandTotal,
|
||||
t.claudeMd + t.plugins + t.skills + t.mcpServers + t.hooks + t.rules + t.agents + t.outputStyles);
|
||||
});
|
||||
|
||||
it('performance budget: durationMs < 2000', async () => {
|
||||
|
|
@ -691,4 +696,181 @@ describe('readActiveConfig (integration)', () => {
|
|||
assert.ok(betaCandidate, 'beta should be flagged as already disabled');
|
||||
assert.equal(betaCandidate.confidence, 'high');
|
||||
});
|
||||
|
||||
it('enumerates rules/agents/outputStyles into the result and totals', async () => {
|
||||
const result = await readActiveConfig(fixture.root);
|
||||
// buildRichRepo ships one unscoped project rule (.claude/rules/team.md)
|
||||
assert.ok(Array.isArray(result.rules) && result.rules.length >= 1);
|
||||
const team = result.rules.find(r => r.name === 'team.md');
|
||||
assert.ok(team, 'team.md rule should be enumerated');
|
||||
assert.equal(team.loadPattern, 'always');
|
||||
assert.ok(Array.isArray(result.agents));
|
||||
assert.ok(Array.isArray(result.outputStyles));
|
||||
assert.equal(result.totals.rules, result.rules.length);
|
||||
assert.equal(result.totals.agents, result.agents.length);
|
||||
assert.equal(result.totals.outputStyles, result.outputStyles.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// v5.6 Foundation — deriveLoadPattern + rule/agent/output-style enumeration
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deriveLoadPattern (v5.6)', () => {
|
||||
const cases = [
|
||||
['claude-md-root', {}, 'always', 'yes', 'confirmed'],
|
||||
['claude-md-nested', {}, 'on-demand', 'no', 'confirmed'],
|
||||
['claude-md-user', {}, 'always', 'yes', 'inferred'],
|
||||
['claude-md-managed', {}, 'always', 'yes', 'inferred'],
|
||||
['claude-md-import', {}, 'always', 'yes', 'inferred'],
|
||||
['rule', { scoped: true }, 'on-demand', 'no', 'confirmed'],
|
||||
['rule', { scoped: false }, 'always', 'yes', 'confirmed'],
|
||||
['skill-listing', {}, 'always', 'n/a', 'confirmed'],
|
||||
['skill-body', {}, 'on-demand', 'n/a', 'confirmed'],
|
||||
['agent', {}, 'always', 'n/a', 'inferred'],
|
||||
['output-style', {}, 'always', 'yes', 'confirmed'],
|
||||
['hook', {}, 'external', 'n/a', 'confirmed'],
|
||||
['mcp', {}, 'always', 'yes', 'inferred'],
|
||||
];
|
||||
for (const [kind, opts, loadPattern, survivesCompaction, derivationConfidence] of cases) {
|
||||
const label = `${kind}${opts.scoped !== undefined ? ` (scoped=${opts.scoped})` : ''}`;
|
||||
it(`${label} → ${loadPattern}/${survivesCompaction}/${derivationConfidence}`, () => {
|
||||
assert.deepEqual(deriveLoadPattern(kind, opts), {
|
||||
loadPattern, survivesCompaction, derivationConfidence,
|
||||
});
|
||||
});
|
||||
}
|
||||
it('falls back safely for an unknown kind', () => {
|
||||
assert.deepEqual(deriveLoadPattern('nope'), {
|
||||
loadPattern: 'unknown', survivesCompaction: 'n/a', derivationConfidence: 'inferred',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('enumerateRules (v5.6)', () => {
|
||||
let root, emptyHome, originalHome;
|
||||
beforeEach(async () => {
|
||||
root = uniqueDir('rules');
|
||||
emptyHome = uniqueDir('rules-home');
|
||||
await mkdir(emptyHome, { recursive: true });
|
||||
originalHome = process.env.HOME;
|
||||
process.env.HOME = emptyHome;
|
||||
});
|
||||
afterEach(async () => {
|
||||
process.env.HOME = originalHome;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(emptyHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('tags scoped vs unscoped project rules (block-sequence paths detected)', async () => {
|
||||
await mkdir(join(root, '.claude', 'rules'), { recursive: true });
|
||||
await writeFile(join(root, '.claude', 'rules', 'always.md'), '# Always-on rule\n');
|
||||
await writeFile(
|
||||
join(root, '.claude', 'rules', 'scoped.md'),
|
||||
'---\npaths:\n - src/**/*.ts\n---\n# Scoped rule\n',
|
||||
);
|
||||
const rules = await enumerateRules(root, []);
|
||||
const always = rules.find(r => r.name === 'always.md');
|
||||
const scoped = rules.find(r => r.name === 'scoped.md');
|
||||
assert.equal(always.scoped, false);
|
||||
assert.equal(always.loadPattern, 'always');
|
||||
assert.equal(always.survivesCompaction, 'yes');
|
||||
assert.equal(always.source, 'project');
|
||||
assert.equal(scoped.scoped, true);
|
||||
assert.equal(scoped.loadPattern, 'on-demand');
|
||||
assert.equal(scoped.survivesCompaction, 'no');
|
||||
});
|
||||
|
||||
it('discovers user-level rules', async () => {
|
||||
await mkdir(join(emptyHome, '.claude', 'rules'), { recursive: true });
|
||||
await writeFile(join(emptyHome, '.claude', 'rules', 'user.md'), '# User rule\n');
|
||||
const rules = await enumerateRules(root, []);
|
||||
const user = rules.find(r => r.name === 'user.md');
|
||||
assert.ok(user);
|
||||
assert.equal(user.source, 'user');
|
||||
});
|
||||
|
||||
it('returns [] when there are no rules', async () => {
|
||||
await mkdir(root, { recursive: true });
|
||||
assert.deepEqual(await enumerateRules(root, []), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enumerateAgents (v5.6)', () => {
|
||||
let root, emptyHome, originalHome;
|
||||
beforeEach(async () => {
|
||||
root = uniqueDir('agents');
|
||||
emptyHome = uniqueDir('agents-home');
|
||||
await mkdir(emptyHome, { recursive: true });
|
||||
originalHome = process.env.HOME;
|
||||
process.env.HOME = emptyHome;
|
||||
});
|
||||
afterEach(async () => {
|
||||
process.env.HOME = originalHome;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(emptyHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('enumerates project agents as always-loaded (inferred)', async () => {
|
||||
await mkdir(join(root, '.claude', 'agents'), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, '.claude', 'agents', 'reviewer.md'),
|
||||
'---\nname: reviewer\ndescription: reviews code\n---\nbody\n',
|
||||
);
|
||||
const agents = await enumerateAgents(root, []);
|
||||
const a = agents.find(x => x.name === 'reviewer');
|
||||
assert.ok(a);
|
||||
assert.equal(a.source, 'project');
|
||||
assert.equal(a.loadPattern, 'always');
|
||||
assert.equal(a.survivesCompaction, 'n/a');
|
||||
assert.equal(a.derivationConfidence, 'inferred');
|
||||
});
|
||||
|
||||
it('returns [] when there are no agents', async () => {
|
||||
await mkdir(root, { recursive: true });
|
||||
assert.deepEqual(await enumerateAgents(root, []), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enumerateOutputStyles (v5.6)', () => {
|
||||
let root, emptyHome, originalHome;
|
||||
beforeEach(async () => {
|
||||
root = uniqueDir('ost');
|
||||
emptyHome = uniqueDir('ost-home');
|
||||
await mkdir(emptyHome, { recursive: true });
|
||||
originalHome = process.env.HOME;
|
||||
process.env.HOME = emptyHome;
|
||||
});
|
||||
afterEach(async () => {
|
||||
process.env.HOME = originalHome;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(emptyHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('enumerates project + user output styles as always/yes', async () => {
|
||||
await mkdir(join(root, '.claude', 'output-styles'), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, '.claude', 'output-styles', 'custom.md'),
|
||||
'---\nname: Custom\ndescription: x\n---\nStyle instructions.\n',
|
||||
);
|
||||
await mkdir(join(emptyHome, '.claude', 'output-styles'), { recursive: true });
|
||||
await writeFile(
|
||||
join(emptyHome, '.claude', 'output-styles', 'user-style.md'),
|
||||
'---\nname: UserStyle\n---\nMore.\n',
|
||||
);
|
||||
const styles = await enumerateOutputStyles(root, []);
|
||||
const proj = styles.find(s => s.name === 'custom');
|
||||
const user = styles.find(s => s.name === 'user-style');
|
||||
assert.ok(proj && user);
|
||||
assert.equal(proj.source, 'project');
|
||||
assert.equal(proj.loadPattern, 'always');
|
||||
assert.equal(proj.survivesCompaction, 'yes');
|
||||
assert.equal(proj.derivationConfidence, 'confirmed');
|
||||
assert.equal(user.source, 'user');
|
||||
});
|
||||
|
||||
it('returns [] when there are no output styles', async () => {
|
||||
await mkdir(root, { recursive: true });
|
||||
assert.deepEqual(await enumerateOutputStyles(root, []), []);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -88,6 +88,65 @@ describe('parseSimpleYaml', () => {
|
|||
});
|
||||
});
|
||||
|
||||
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"}');
|
||||
|
|
|
|||
|
|
@ -151,3 +151,52 @@ describe('RUL — large path-scoped rule lost after compaction (A)', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('RUL — block-sequence-scoped rule is correctly scoped (parser regression)', () => {
|
||||
// A rule scoped with a YAML block sequence:
|
||||
// paths:
|
||||
// - src/**/*.ts
|
||||
// was previously misread as unscoped (the lightweight parser only read inline
|
||||
// `paths:`), so a large one wrongly fired the "unscoped" finding AND silently
|
||||
// skipped the compaction-durability finding. The parser block-sequence fix
|
||||
// flows through unchanged rules-validator code.
|
||||
let tmpRoot;
|
||||
let result;
|
||||
|
||||
async function writeBlockSeqProject(root, ruleBodyLines) {
|
||||
await mkdir(join(root, '.claude', 'rules'), { recursive: true });
|
||||
await mkdir(join(root, 'src'), { recursive: true });
|
||||
await writeFile(join(root, 'src', 'foo.ts'), 'export {};\n', 'utf8');
|
||||
const body = Array.from({ length: ruleBodyLines }, (_, i) => `- rule ${i + 1}`).join('\n');
|
||||
// YAML block-sequence paths: form (the regression target).
|
||||
await writeFile(
|
||||
join(root, '.claude', 'rules', 'scoped-seq.md'),
|
||||
`---\npaths:\n - "src/**/*.ts"\n---\n\n# Scoped rule\n${body}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
tmpRoot = await mkdtemp(join(tmpdir(), 'ca-rul-blockseq-'));
|
||||
await writeBlockSeqProject(tmpRoot, 60);
|
||||
const discovery = await discoverConfigFiles(tmpRoot);
|
||||
result = await scan(tmpRoot, discovery);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does NOT misflag a block-sequence-scoped rule as unscoped', () => {
|
||||
const f = result.findings.find(x => x.scanner === 'RUL' && /unscoped/i.test(x.title || ''));
|
||||
assert.equal(f, undefined,
|
||||
`block-sequence-scoped rule wrongly flagged unscoped; got: ${result.findings.map(x => x.title).join(' | ')}`);
|
||||
});
|
||||
|
||||
it('DOES flag the block-sequence-scoped large rule as lost after compaction (low)', () => {
|
||||
const f = result.findings.find(x => x.scanner === 'RUL' && /compaction/i.test(x.title || ''));
|
||||
assert.ok(f, `expected durability finding; got: ${result.findings.map(x => x.title).join(' | ')}`);
|
||||
assert.equal(f.severity, 'low');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue