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:
Kjell Tore Guttormsen 2026-06-20 16:57:52 +02:00
commit 62d910ed6d
8 changed files with 575 additions and 25 deletions

View file

@ -109,7 +109,27 @@ Default: auto-detects scope from git context. Override with `/config-audit full|
node --test 'tests/**/*.test.mjs' node --test 'tests/**/*.test.mjs'
``` ```
936 tests across 56 test files (17 lib + 29 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`. 996 tests across 56 test files (17 lib + 29 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`.
### active-config-reader — load-pattern model + rule/agent/output-style enumeration (v5.6 Foundation)
`scanners/lib/active-config-reader.mjs` now enumerates the three source kinds it previously
missed — **rules** (`enumerateRules`), **agents** (`enumerateAgents`), and **output styles**
(`enumerateOutputStyles`) — alongside the existing CLAUDE.md/plugins/skills/hooks/MCP enumerators.
Each new item, plus a pure `deriveLoadPattern(kind, {scoped})` helper, carries a
`loadPattern ∈ {always, on-demand, external}`, `survivesCompaction ∈ {yes, no, n/a}`, and
`derivationConfidence ∈ {confirmed, inferred}` derived from the published Claude Code loading
model (the V-rows in `docs/v5.5-steering-model-plan.md`). `readActiveConfig` exposes `rules`/
`agents`/`outputStyles` arrays + `totals` counts/subtotals (folded into `grandTotal`). This is
**internal plumbing** for v5.6 B (manifest/tokens rendering) — no command output changes yet, so
`--json`/`--raw`/SC-5 stay byte-stable. Output-style discovery is done directly (mirroring
`enumerateSkills`), **not** via a new `file-discovery` type, to keep the discovery surface stable.
The frontmatter parser (`scanners/lib/yaml-parser.mjs`) now also reads **YAML block sequences**
(`paths:\n - a\n - b`), not just inline `paths: "a, b"`. This resolves a pre-existing RUL
false-positive (a block-sequence-scoped rule was misread as unscoped). An empty-valued key with
no following `- ` items still resolves to `null` (backwards-compatible); only a real `- ` item
list becomes an array.
### CML scanner — context-window-scaled char budget ### CML scanner — context-window-scaled char budget

View file

@ -12,7 +12,7 @@
![Commands](https://img.shields.io/badge/commands-18-green) ![Commands](https://img.shields.io/badge/commands-18-green)
![Agents](https://img.shields.io/badge/agents-6-orange) ![Agents](https://img.shields.io/badge/agents-6-orange)
![Hooks](https://img.shields.io/badge/hooks-4-red) ![Hooks](https://img.shields.io/badge/hooks-4-red)
![Tests](https://img.shields.io/badge/tests-961+-brightgreen) ![Tests](https://img.shields.io/badge/tests-996+-brightgreen)
![License](https://img.shields.io/badge/license-MIT-lightgrey) ![License](https://img.shields.io/badge/license-MIT-lightgrey)
A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 13 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, and cross-plugin collision detection. Zero external dependencies. A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 13 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, and cross-plugin collision detection. Zero external dependencies.
@ -588,7 +588,7 @@ Reference documents that inform the feature-gap agent and context-aware recommen
node --test 'tests/**/*.test.mjs' node --test 'tests/**/*.test.mjs'
``` ```
635 tests across 36 test files (12 lib + 23 scanner + 1 hook). Test fixtures in `tests/fixtures/`. Requires Node.js 18+ (`node:test`). 996 tests across 56 test files (17 lib + 29 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Requires Node.js 18+ (`node:test`).
--- ---

View file

@ -2,29 +2,32 @@
_Current state-of-play for DENNE pluginen. Auto-injisert ved sesjonsstart. Overskrives (ikke append) ved sesjonsslutt. Historikk → git (STATE.md er tracked). Stier relative til plugin-mappa._ _Current state-of-play for DENNE pluginen. Auto-injisert ved sesjonsstart. Overskrives (ikke append) ved sesjonsslutt. Historikk → git (STATE.md er tracked). Stier relative til plugin-mappa._
## Aktiv kontekst (v5.5.0 RELEASED — tag + catalog pushet) ## Aktiv kontekst (v5.6 Foundation LANDET på main — B neste)
**v5.5.0 «steering-model I» (A+E) er release-cuttet og ute.** Tag `v5.5.0``dac1db4`, pushet til Forgejo. Catalog-ref bumpet 5.4.1→5.5.0 (`5832c80` i `../catalog`, pushet). Suite **961**, self-audit A/A (config 93, plugin 100), `readmeCheck.passed`/`mismatches:[]`, count 13, `--json`/`--raw` byte-stabil. Neste arbeid = v5.6 (KREVER GO). v5.5.0 released (tag `v5.5.0`, catalog v5.5.0). Siden da: **v5.6 Foundation implementert + committet på main** (TDD). Suite **996** (961→996), self-audit A/A (config 93, plugin 100), count **13** (Foundation legger ingen scanner), SC-5/6/7 byte-stabil UTEN regen. v5.6 er IKKE release-cuttet (krever B + C + eget release-GO).
## Gjort denne økten (2026-06-20, lør — alt pushet) ## Gjort denne økten (2026-06-20, lør — alt pushet)
- **Release-cut (`dac1db4`, `release:`):** plugin.json 5.4.1→5.5.0 + README version-badge + version-history-rad (961 tests) + CHANGELOG `[5.5.0]` (Added + Known-limitations). Badge-gate grønn via `self-audit --json --check-readme`. Tag `v5.5.0` annotert. - **Parser-fiks (`yaml-parser.mjs`):** `parseSimpleYaml` leser nå YAML block-sequences (`paths:\n - a`), ikke bare inline `paths: "a, b"`. Empty-valued key uten `- `-items → fortsatt `null` (backcompat). Løser pre-eksisterende RUL false-positive (block-seq-scoped rule sett som unscoped) — fiks flyter gjennom uendret RUL-kode.
- **Catalog (`5832c80`, separat repo, egen GO):** `marketplace.json` ref + README L38 version-tag → v5.5.0. JSON validert. «13 scanners»-prosa uendret (korrekt). - **Foundation (`active-config-reader.mjs`):** ny `deriveLoadPattern(kind,{scoped})` (loadPattern/survivesCompaction/derivationConfidence, V-row-forankret) + `enumerateRules`/`enumerateAgents`/`enumerateOutputStyles` (mirror `enumerateSkills`). `readActiveConfig` eksponerer `rules`/`agents`/`outputStyles` + totals/subtotals (i grandTotal). Output-style-discovery gjøres direkte (IKKE ny `file-discovery`-type) → discovery-surface stabil.
- (Bundlet de allerede-landede A `f3aadb5` + E `f75ed56` fra forrige økt — ingen ny kode denne økten, kun release-mekanikk.) - **Tester:** +35 (yaml-parser block-seq, RUL block-seq regresjon, deriveLoadPattern table, 3 enumeratorer pos+neg). Amendet 2 eksisterende ACR-asserts (top-level key shape + grandTotal sum).
- **Docs (docs-gate krever non-trivial README+CLAUDE for feat):** README testing-prosa 635/36→996/56 (var v5.0.0-stale), tests-badge 961+→996+, CLAUDE.md test-count 936→996 + ny Foundation-seksjon.
## Åpne tråder / neste steg (prioritert) ## Åpne tråder / neste steg (prioritert, alle KREVER GO)
1. **v5.6 «steering-model II» (KREVER GO):** Foundation (enumerering rules/agents/output-styles + `loadPattern`/`survivesCompaction` i `active-config-reader`) + B (load-pattern i manifest/tokens — FORMAT-endring, snapshot-regen) + C (ny `CA-OST`, count→**14**). **Fiks parser-begrensningen** (se gotcha) som del av Foundation. 1. **v5.6 B (load-pattern accounting):** render `loadPattern`-kolonne + «always-loaded subtotal» i `manifest` + `tokens`. FORMAT-endring → **regen SC-5 + SC-6/7 token-hotspots-snapshots**; beslutt `--json` back-compat (add-field-in-place vs schema-versjon). Konsumerer Foundation (deriveLoadPattern + enumerasjoner ligger klare).
2. **v5.7 (GO):** D (mechanism-fit, heuristisk, precision-gated). 2. **v5.6 C (output-style scanner):** ny `CA-OST` (count 13→**14**): `keep-coding-instructions`/`force-for-plugin`/dead `outputStyle`. Badge + lore-oppdatering, humanizer/scoring/discovery-wiring.
3. **llm-security KRITISK** (annet repo, F-1 RCE, disclosure-hold) + 3 aktive plugins + STATE-sveip — uendret. 3. **v5.6 release-cut:** etter B+C lander på main (eget GO, som v5.5.0).
4. **v5.7:** D (mechanism-fit, heuristisk, precision-gated).
5. **llm-security KRITISK** (annet repo, F-1 RCE, disclosure-hold) + 3 aktive plugins + STATE-sveip — uendret.
## Gotchas (UFRAVIKELIG) ## Gotchas (UFRAVIKELIG)
- **Parser-begrensning:** `scanners/lib/yaml-parser.mjs` leser KUN inline `paths: "a, b"` (komma-normalisert), IKKE YAML block-sequence (`paths:\n - a`). Block-scoped rules ses som unscoped → RUL scoped-deteksjon (inkl. A) bommer på dem. Pre-eksisterende, bredere enn A. Fiks i v5.6 Foundation. (Også notert i CHANGELOG [5.5.0] Known limitations.) - **Parser-begrensning RESOLVED denne økten** — block-sequences leses nå. (Hvis du ser gammel STATE/plan som sier «fiks i v5.6 Foundation»: gjort.)
- **fix-engine + humanizer-data kobler på finding-TITTEL.** Endrer/legger du til tittel: oppdater begge (+ evt. SC-5 snapshot). Hver finding: positiv OG negativ fixture (TDD). - **fix-engine + humanizer-data kobler på finding-TITTEL.** Endrer/legger du til tittel: oppdater begge (+ evt. SC-5 snapshot). Hver finding: positiv OG negativ fixture (TDD).
- Versjon KUN i plugin.json + README-badges. self-audit `--check-readme`: badge == suite (EKSAKT før «+»); driver IKKE exit-kode — verifiser via `--json` (`readmeCheck.mismatches`). Catalog = SEPARAT repo (`../catalog`); bump ref ETTER tag (egen commit + GO). - Versjon KUN i plugin.json + README version-badge. **tests-badge synces til EKSAKT suite-tall ved hver chunk-landing** (961→996 nå); driver IKKE exit-kode — verifiser `readmeCheck.passed`/`mismatches:[]` via `--json`. Catalog = SEPARAT repo; bump ref ETTER tag (egen GO).
- Scanner-count 13. C ville gi 14 (v5.6). - **docs-gate (non-marketplace gren) krever non-trivial README OG CLAUDE.md (≥3 ikke-ws-linjer hver) for `feat:`**-commits. Foundation oppfylte ærlig (stale-fiks + arch-note). Alt.: `[skip-docs]` (logges).
- `post-session` IKKE settings.json-event (runner-hook). hooks.md = 30 events PascalCase. - Scanner-count 13. C gir 14 (v5.6).
- Path-guard blokkerer Write på `.claude-plugin/` + settings/hooks → bruk hermetisk `mkdtemp` i tester (mønster i HKV/PLH/RUL/CML-testene). Push-vindu: hverdag 2023, helg fritt. - Path-guard blokkerer Write på `.claude-plugin/` + settings/hooks → hermetisk `mkdtemp` i tester. Push-vindu: hverdag 2023, helg fritt.
## Scope-gjerde ## Scope-gjerde
v5.5.0 (A+E + release-cut + catalog) godkjent (GO) og ute. v5.6/5.7 + andre repos = egne GO. Ikke start v5.6 uten GO. v5.6 Foundation godkjent (GO) og landet. B + C + v5.6 release-cut + v5.7 + andre repos = egne GO. Ikke start B uten GO. Byte-stabilitet var by-construction (Foundation rendrer ingenting nytt) — B BRYTER den bevisst (snapshot-regen).
## Kontinuitet ## Kontinuitet
Tre lag: STATE.md (tracked) + auto-memory + CLAUDE.md. Oppdater DENNE som siste handling. Tre lag: STATE.md (tracked) + auto-memory + CLAUDE.md. Oppdater DENNE som siste handling.

View file

@ -53,6 +53,56 @@ export function estimateTokens(bytes, kind = 'markdown', opts = {}) {
return Math.ceil(bytes / 4); return Math.ceil(bytes / 4);
} }
// ─────────────────────────────────────────────────────────────────────────
// Load-pattern model (v5.6 Foundation)
// ─────────────────────────────────────────────────────────────────────────
/**
* Derive how a config source loads into context and whether it survives a
* `/compact`, from the published Claude Code loading model. Deterministic,
* side-effect-free. `derivationConfidence` is 'confirmed' when a primary-doc
* row nails the row (V-rows in docs/v5.5-steering-model-plan.md), 'inferred'
* when reasoned from an analogue (so a renderer can choose to mark it).
*
* loadPattern { 'always', 'on-demand', 'external', 'unknown' }
* survivesCompaction { 'yes', 'no', 'n/a' }
*
* @param {string} kind - source kind (see switch)
* @param {{scoped?: boolean}} [opts] - kind-specific discriminators
* @returns {{loadPattern:string, survivesCompaction:string, derivationConfidence:string}}
*/
export function deriveLoadPattern(kind, opts = {}) {
const mk = (loadPattern, survivesCompaction, derivationConfidence) =>
({ loadPattern, survivesCompaction, derivationConfidence });
switch (kind) {
// CLAUDE.md cascade
case 'claude-md-root': return mk('always', 'yes', 'confirmed'); // V1
case 'claude-md-nested': return mk('on-demand', 'no', 'confirmed'); // V3
case 'claude-md-user':
case 'claude-md-managed':
case 'claude-md-import': return mk('always', 'yes', 'inferred');
// Rules
case 'rule':
return opts.scoped
? mk('on-demand', 'no', 'confirmed') // V2, V4 (loads on Read of a match)
: mk('always', 'yes', 'confirmed'); // V1, V6 (unscoped = always-on)
// Skills
case 'skill-listing': return mk('always', 'n/a', 'confirmed'); // V7 (name+desc every turn)
case 'skill-body': return mk('on-demand', 'n/a', 'confirmed'); // V7 (body on invoke)
// Agents — name+description load for delegation each turn (skill analogue;
// no primary-doc row pins it, so 'inferred').
case 'agent': return mk('always', 'n/a', 'inferred');
// Output styles modify the system prompt, re-sent every turn (V10, V12).
case 'output-style': return mk('always', 'yes', 'confirmed');
// Hooks run outside context (V18); the hook itself is external.
case 'hook': return mk('external', 'n/a', 'confirmed');
// MCP tool schemas are part of the per-turn payload (no explicit
// compaction-survival row → 'inferred').
case 'mcp': return mk('always', 'yes', 'inferred');
default: return mk('unknown', 'n/a', 'inferred');
}
}
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// Git root detection // Git root detection
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@ -499,6 +549,126 @@ export async function enumerateSkills(pluginList = []) {
return out; return out;
} }
// ─────────────────────────────────────────────────────────────────────────
// Rules, agents, output styles (v5.6 Foundation enumeration)
// ─────────────────────────────────────────────────────────────────────────
/**
* Build the project/user/plugin directory list for a per-kind enumerator.
* Project + user dirs live under `.claude/<dir>`; plugins under each of the
* given subpaths relative to the plugin root.
*/
function configDirs(repoPath, pluginList, subdir, pluginSubdirs = [subdir]) {
const home = process.env.HOME || process.env.USERPROFILE || '';
const dirs = [{ dir: join(repoPath, '.claude', subdir), source: 'project', pluginName: null }];
if (home) dirs.push({ dir: join(home, '.claude', subdir), source: 'user', pluginName: null });
for (const p of pluginList) {
for (const sub of pluginSubdirs) {
dirs.push({ dir: join(p.path, sub), source: 'plugin', pluginName: p.name });
}
}
return dirs;
}
/**
* Enumerate rule files: `<repo>/.claude/rules/`, `~/.claude/rules/`, and each
* plugin's `rules/` + `.claude/rules/`. A rule is path-scoped when its
* frontmatter declares `paths:` (the only documented scoping field, V5) which
* determines its load pattern (scoped = on-demand, unscoped = always, V1/V2/V6).
*
* @param {string} repoPath
* @param {Array<{name:string, path:string}>} [pluginList]
* @returns {Promise<Array<{name:string, source:string, pluginName:string|null, path:string, scoped:boolean, bytes:number, estimatedTokens:number, loadPattern:string, survivesCompaction:string, derivationConfidence:string}>>}
*/
export async function enumerateRules(repoPath, pluginList = []) {
const out = [];
const dirs = configDirs(repoPath, pluginList, 'rules', ['rules', join('.claude', 'rules')]);
for (const { dir, source, pluginName } of dirs) {
const files = await listMarkdownFiles(dir);
for (const f of files) {
let scoped = false;
try {
const content = await readFile(f.path, 'utf-8');
const { frontmatter } = parseFrontmatter(content);
scoped = !!(frontmatter && frontmatter.paths);
} catch { /* unreadable → treat as unscoped */ }
out.push({
name: basename(f.path),
source,
pluginName,
path: f.path,
scoped,
bytes: f.size,
estimatedTokens: estimateTokens(f.size, 'markdown'),
...deriveLoadPattern('rule', { scoped }),
});
}
}
return out;
}
/**
* Enumerate agent definitions: `<repo>/.claude/agents/`, `~/.claude/agents/`,
* and each plugin's `agents/`. Only name+description load for delegation each
* turn, so cost is estimated like other frontmatter-only sources.
*
* @param {string} repoPath
* @param {Array<{name:string, path:string}>} [pluginList]
* @returns {Promise<Array<{name:string, source:string, pluginName:string|null, path:string, bytes:number, estimatedTokens:number, loadPattern:string, survivesCompaction:string, derivationConfidence:string}>>}
*/
export async function enumerateAgents(repoPath, pluginList = []) {
const out = [];
const lp = deriveLoadPattern('agent');
const dirs = configDirs(repoPath, pluginList, 'agents');
for (const { dir, source, pluginName } of dirs) {
const files = await listMarkdownFiles(dir);
for (const f of files) {
out.push({
name: basename(f.path).replace(/\.md$/, ''),
source,
pluginName,
path: f.path,
bytes: f.size,
estimatedTokens: estimateTokens(f.size, 'frontmatter'),
...lp,
});
}
}
return out;
}
/**
* Enumerate output styles: `<repo>/.claude/output-styles/`,
* `~/.claude/output-styles/`, and each plugin's `output-styles/`. An output
* style modifies the system prompt and is re-sent every turn (V10, V12).
* Foundation only enumerates them; the `keep-coding-instructions` /
* `force-for-plugin` checks are the CA-OST scanner (v5.6 C).
*
* @param {string} repoPath
* @param {Array<{name:string, path:string}>} [pluginList]
* @returns {Promise<Array<{name:string, source:string, pluginName:string|null, path:string, bytes:number, estimatedTokens:number, loadPattern:string, survivesCompaction:string, derivationConfidence:string}>>}
*/
export async function enumerateOutputStyles(repoPath, pluginList = []) {
const out = [];
const lp = deriveLoadPattern('output-style');
const dirs = configDirs(repoPath, pluginList, 'output-styles');
for (const { dir, source, pluginName } of dirs) {
const files = await listMarkdownFiles(dir);
for (const f of files) {
out.push({
name: basename(f.path).replace(/\.md$/, ''),
source,
pluginName,
path: f.path,
bytes: f.size,
estimatedTokens: estimateTokens(f.size, 'markdown'),
...lp,
});
}
}
return out;
}
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// Hooks (user + project + plugin) // Hooks (user + project + plugin)
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@ -841,11 +1011,14 @@ export async function readActiveConfig(repoPath, opts = {}) {
readSettingsCascade(absRepoPath), readSettingsCascade(absRepoPath),
]); ]);
// Skills depend on plugins // Skills, hooks, MCP, and the v5.6 enumerations all depend on plugins
const [skills, hooks, mcpServers] = await Promise.all([ const [skills, hooks, mcpServers, rules, agents, outputStyles] = await Promise.all([
enumerateSkills(plugins), enumerateSkills(plugins),
readActiveHooks(absRepoPath, plugins), readActiveHooks(absRepoPath, plugins),
readActiveMcpServers(absRepoPath, claudeJsonSlice, plugins), readActiveMcpServers(absRepoPath, claudeJsonSlice, plugins),
enumerateRules(absRepoPath, plugins),
enumerateAgents(absRepoPath, plugins),
enumerateOutputStyles(absRepoPath, plugins),
]); ]);
// Totals // Totals
@ -854,6 +1027,9 @@ export async function readActiveConfig(repoPath, opts = {}) {
skills: skills.length, skills: skills.length,
mcpServers: mcpServers.length, mcpServers: mcpServers.length,
hooks: hooks.length, hooks: hooks.length,
rules: rules.length,
agents: agents.length,
outputStyles: outputStyles.length,
claudeMdFiles: claudeMd.files.length, claudeMdFiles: claudeMd.files.length,
estimatedTokens: { estimatedTokens: {
claudeMd: claudeMd.estimatedTokens, claudeMd: claudeMd.estimatedTokens,
@ -861,6 +1037,9 @@ export async function readActiveConfig(repoPath, opts = {}) {
skills: skills.reduce((s, k) => s + k.estimatedTokens, 0), skills: skills.reduce((s, k) => s + k.estimatedTokens, 0),
mcpServers: mcpServers.reduce((s, m) => s + m.estimatedTokens, 0), mcpServers: mcpServers.reduce((s, m) => s + m.estimatedTokens, 0),
hooks: hooks.reduce((s, h) => s + h.estimatedTokens, 0), hooks: hooks.reduce((s, h) => s + h.estimatedTokens, 0),
rules: rules.reduce((s, r) => s + r.estimatedTokens, 0),
agents: agents.reduce((s, a) => s + a.estimatedTokens, 0),
outputStyles: outputStyles.reduce((s, o) => s + o.estimatedTokens, 0),
grandTotal: 0, grandTotal: 0,
}, },
}; };
@ -869,7 +1048,10 @@ export async function readActiveConfig(repoPath, opts = {}) {
totals.estimatedTokens.plugins + totals.estimatedTokens.plugins +
totals.estimatedTokens.skills + totals.estimatedTokens.skills +
totals.estimatedTokens.mcpServers + totals.estimatedTokens.mcpServers +
totals.estimatedTokens.hooks; totals.estimatedTokens.hooks +
totals.estimatedTokens.rules +
totals.estimatedTokens.agents +
totals.estimatedTokens.outputStyles;
const warnings = []; const warnings = [];
@ -898,6 +1080,9 @@ export async function readActiveConfig(repoPath, opts = {}) {
skills, skills,
mcpServers, mcpServers,
hooks, hooks,
rules,
agents,
outputStyles,
settings: { cascade: settingsCascade }, settings: { cascade: settingsCascade },
totals, totals,
suggestDisables, suggestDisables,

View file

@ -34,17 +34,51 @@ export function parseSimpleYaml(yaml) {
let currentKey = null; let currentKey = null;
let multiLineValue = ''; let multiLineValue = '';
let inMultiLine = false; let inMultiLine = false;
// Block-sequence state: an empty-valued key may be a YAML block sequence
// key:
// - item
// - item
// We defer the decision until the next line: a `- item` line starts an array;
// anything else (or end-of-input) leaves the empty-valued key as `null` —
// indistinguishable from a plain null value, preserving backwards compatibility.
let seqKey = null; // key awaiting/collecting block-sequence items
let seqItems = null; // null until the first `- ` item is seen
let seqPending = false; // an empty-valued key was seen; items may follow
// Commit the current block-sequence (or empty-valued key) to result.
const commitSeq = () => {
if (seqKey !== null) {
result[normalizeKey(seqKey)] = seqItems !== null ? seqItems : null;
}
seqKey = null;
seqItems = null;
seqPending = false;
};
for (const line of lines) { for (const line of lines) {
// Skip comments and empty lines // Skip comments and empty lines (these do NOT terminate a block sequence)
if (line.trim().startsWith('#') || line.trim() === '') { if (line.trim().startsWith('#') || line.trim() === '') {
if (inMultiLine) multiLineValue += '\n'; if (inMultiLine) multiLineValue += '\n';
continue; continue;
} }
// Block-sequence item: only while a sequence is pending or active
const seqMatch = (seqPending || seqItems !== null) && !inMultiLine
? line.match(/^\s+-\s*(.*)$/)
: null;
if (seqMatch) {
if (seqItems === null) seqItems = [];
const itemVal = parseValue(seqMatch[1].trim());
if (itemVal !== null && itemVal !== '') seqItems.push(itemVal);
seqPending = false;
continue;
}
// Key-value pair // Key-value pair
const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)/); const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)/);
if (kvMatch && !inMultiLine) { if (kvMatch && !inMultiLine) {
// A new key terminates any pending/active block sequence.
commitSeq();
if (currentKey && multiLineValue) { if (currentKey && multiLineValue) {
result[normalizeKey(currentKey)] = multiLineValue.trim(); result[normalizeKey(currentKey)] = multiLineValue.trim();
} }
@ -58,6 +92,15 @@ export function parseSimpleYaml(yaml) {
continue; continue;
} }
if (value === '') {
// Defer: this may be a block sequence (next line) or a null value.
seqKey = currentKey;
seqItems = null;
seqPending = true;
currentKey = null;
continue;
}
result[normalizeKey(currentKey)] = parseValue(value); result[normalizeKey(currentKey)] = parseValue(value);
currentKey = null; currentKey = null;
continue; continue;
@ -79,6 +122,13 @@ export function parseSimpleYaml(yaml) {
currentKey = null; currentKey = null;
} }
} }
continue;
}
// A non-item, non-key line while a sequence is pending/active terminates it
// (then the line is ignored, as before the fix).
if (seqPending || seqItems !== null) {
commitSeq();
} }
} }
@ -86,6 +136,8 @@ export function parseSimpleYaml(yaml) {
if (inMultiLine && currentKey) { if (inMultiLine && currentKey) {
result[normalizeKey(currentKey)] = multiLineValue.trim(); result[normalizeKey(currentKey)] = multiLineValue.trim();
} }
// Flush a trailing block sequence / empty-valued key
commitSeq();
// Normalize arrays for known list fields // Normalize arrays for known list fields
for (const field of ['allowed_tools', 'tools', 'paths', 'globs']) { for (const field of ['allowed_tools', 'tools', 'paths', 'globs']) {

View file

@ -13,6 +13,10 @@ import {
readActiveHooks, readActiveHooks,
readActiveMcpServers, readActiveMcpServers,
readActiveConfig, readActiveConfig,
deriveLoadPattern,
enumerateRules,
enumerateAgents,
enumerateOutputStyles,
} from '../../scanners/lib/active-config-reader.mjs'; } from '../../scanners/lib/active-config-reader.mjs';
function uniqueDir(suffix) { function uniqueDir(suffix) {
@ -625,8 +629,8 @@ describe('readActiveConfig (integration)', () => {
const result = await readActiveConfig(fixture.root); const result = await readActiveConfig(fixture.root);
const keys = Object.keys(result).sort(); const keys = Object.keys(result).sort();
assert.deepEqual(keys, [ assert.deepEqual(keys, [
'claudeMd', 'hooks', 'mcpServers', 'meta', 'plugins', 'agents', 'claudeMd', 'hooks', 'mcpServers', 'meta', 'outputStyles',
'settings', 'skills', 'suggestDisables', 'totals', 'warnings', 'plugins', 'rules', 'settings', 'skills', 'suggestDisables', 'totals', 'warnings',
]); ]);
}); });
@ -654,7 +658,8 @@ describe('readActiveConfig (integration)', () => {
it('totals.grandTotal equals sum of category subtotals', async () => { it('totals.grandTotal equals sum of category subtotals', async () => {
const result = await readActiveConfig(fixture.root); const result = await readActiveConfig(fixture.root);
const t = result.totals.estimatedTokens; 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 () => { it('performance budget: durationMs < 2000', async () => {
@ -691,4 +696,181 @@ describe('readActiveConfig (integration)', () => {
assert.ok(betaCandidate, 'beta should be flagged as already disabled'); assert.ok(betaCandidate, 'beta should be flagged as already disabled');
assert.equal(betaCandidate.confidence, 'high'); 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, []), []);
});
}); });

View file

@ -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', () => { describe('parseJson', () => {
it('parses valid JSON', () => { it('parses valid JSON', () => {
const result = parseJson('{"key": "value"}'); const result = parseJson('{"key": "value"}');

View file

@ -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');
});
});