From 62d910ed6dec9851f2022f1426f9b10ee6b33a34 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 20 Jun 2026 16:57:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(acr,yaml):=20v5.6=20Foundation=20=E2=80=94?= =?UTF-8?q?=20load-pattern=20enumeration=20+=20block-seq=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8 --- CLAUDE.md | 22 ++- README.md | 4 +- STATE.md | 33 ++-- scanners/lib/active-config-reader.mjs | 191 +++++++++++++++++++++++- scanners/lib/yaml-parser.mjs | 54 ++++++- tests/lib/active-config-reader.test.mjs | 188 ++++++++++++++++++++++- tests/lib/yaml-parser.test.mjs | 59 ++++++++ tests/scanners/rules-validator.test.mjs | 49 ++++++ 8 files changed, 575 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c176ffc..dbbd249 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,27 @@ Default: auto-detects scope from git context. Override with `/config-audit full| 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 diff --git a/README.md b/README.md index 2c45248..c305321 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ![Commands](https://img.shields.io/badge/commands-18-green) ![Agents](https://img.shields.io/badge/agents-6-orange) ![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) 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' ``` -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`). --- diff --git a/STATE.md b/STATE.md index 35ad7a0..d609543 100644 --- a/STATE.md +++ b/STATE.md @@ -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._ -## Aktiv kontekst (v5.5.0 RELEASED — tag + catalog pushet) -**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). +## Aktiv kontekst (v5.6 Foundation LANDET på main — B neste) +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) -- **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. -- **Catalog (`5832c80`, separat repo, egen GO):** `marketplace.json` ref + README L38 version-tag → v5.5.0. JSON validert. «13 scanners»-prosa uendret (korrekt). -- (Bundlet de allerede-landede A `f3aadb5` + E `f75ed56` fra forrige økt — ingen ny kode denne økten, kun release-mekanikk.) +- **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. +- **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. +- **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) -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. -2. **v5.7 (GO):** D (mechanism-fit, heuristisk, precision-gated). -3. **llm-security KRITISK** (annet repo, F-1 RCE, disclosure-hold) + 3 aktive plugins + STATE-sveip — uendret. +## Åpne tråder / neste steg (prioritert, alle KREVER GO) +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.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. **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) -- **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). -- 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). -- Scanner-count 13. C ville gi 14 (v5.6). -- `post-session` IKKE settings.json-event (runner-hook). hooks.md = 30 events PascalCase. -- 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 20–23, helg fritt. +- 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). +- **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). +- Scanner-count 13. C gir 14 (v5.6). +- Path-guard blokkerer Write på `.claude-plugin/` + settings/hooks → hermetisk `mkdtemp` i tester. Push-vindu: hverdag 20–23, helg fritt. ## 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 Tre lag: STATE.md (tracked) + auto-memory + CLAUDE.md. Oppdater DENNE som siste handling. diff --git a/scanners/lib/active-config-reader.mjs b/scanners/lib/active-config-reader.mjs index 15ef8a1..b87f8e3 100644 --- a/scanners/lib/active-config-reader.mjs +++ b/scanners/lib/active-config-reader.mjs @@ -53,6 +53,56 @@ export function estimateTokens(bytes, kind = 'markdown', opts = {}) { 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 // ───────────────────────────────────────────────────────────────────────── @@ -499,6 +549,126 @@ export async function enumerateSkills(pluginList = []) { 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/`; 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: `/.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>} + */ +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: `/.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>} + */ +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: `/.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>} + */ +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) // ───────────────────────────────────────────────────────────────────────── @@ -841,11 +1011,14 @@ export async function readActiveConfig(repoPath, opts = {}) { readSettingsCascade(absRepoPath), ]); - // Skills depend on plugins - const [skills, hooks, mcpServers] = await Promise.all([ + // Skills, hooks, MCP, and the v5.6 enumerations all depend on plugins + const [skills, hooks, mcpServers, rules, agents, outputStyles] = await Promise.all([ enumerateSkills(plugins), readActiveHooks(absRepoPath, plugins), readActiveMcpServers(absRepoPath, claudeJsonSlice, plugins), + enumerateRules(absRepoPath, plugins), + enumerateAgents(absRepoPath, plugins), + enumerateOutputStyles(absRepoPath, plugins), ]); // Totals @@ -854,6 +1027,9 @@ export async function readActiveConfig(repoPath, opts = {}) { skills: skills.length, mcpServers: mcpServers.length, hooks: hooks.length, + rules: rules.length, + agents: agents.length, + outputStyles: outputStyles.length, claudeMdFiles: claudeMd.files.length, estimatedTokens: { claudeMd: claudeMd.estimatedTokens, @@ -861,6 +1037,9 @@ export async function readActiveConfig(repoPath, opts = {}) { skills: skills.reduce((s, k) => s + k.estimatedTokens, 0), mcpServers: mcpServers.reduce((s, m) => s + m.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, }, }; @@ -869,7 +1048,10 @@ export async function readActiveConfig(repoPath, opts = {}) { totals.estimatedTokens.plugins + totals.estimatedTokens.skills + totals.estimatedTokens.mcpServers + - totals.estimatedTokens.hooks; + totals.estimatedTokens.hooks + + totals.estimatedTokens.rules + + totals.estimatedTokens.agents + + totals.estimatedTokens.outputStyles; const warnings = []; @@ -898,6 +1080,9 @@ export async function readActiveConfig(repoPath, opts = {}) { skills, mcpServers, hooks, + rules, + agents, + outputStyles, settings: { cascade: settingsCascade }, totals, suggestDisables, diff --git a/scanners/lib/yaml-parser.mjs b/scanners/lib/yaml-parser.mjs index b20e461..45f18db 100644 --- a/scanners/lib/yaml-parser.mjs +++ b/scanners/lib/yaml-parser.mjs @@ -34,17 +34,51 @@ export function parseSimpleYaml(yaml) { let currentKey = null; let multiLineValue = ''; 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) { - // Skip comments and empty lines + // Skip comments and empty lines (these do NOT terminate a block sequence) if (line.trim().startsWith('#') || line.trim() === '') { if (inMultiLine) multiLineValue += '\n'; 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 const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)/); if (kvMatch && !inMultiLine) { + // A new key terminates any pending/active block sequence. + commitSeq(); if (currentKey && multiLineValue) { result[normalizeKey(currentKey)] = multiLineValue.trim(); } @@ -58,6 +92,15 @@ export function parseSimpleYaml(yaml) { 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); currentKey = null; continue; @@ -79,6 +122,13 @@ export function parseSimpleYaml(yaml) { 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) { result[normalizeKey(currentKey)] = multiLineValue.trim(); } + // Flush a trailing block sequence / empty-valued key + commitSeq(); // Normalize arrays for known list fields for (const field of ['allowed_tools', 'tools', 'paths', 'globs']) { diff --git a/tests/lib/active-config-reader.test.mjs b/tests/lib/active-config-reader.test.mjs index b977f9b..7993d47 100644 --- a/tests/lib/active-config-reader.test.mjs +++ b/tests/lib/active-config-reader.test.mjs @@ -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, []), []); + }); }); diff --git a/tests/lib/yaml-parser.test.mjs b/tests/lib/yaml-parser.test.mjs index f797db8..ea22d58 100644 --- a/tests/lib/yaml-parser.test.mjs +++ b/tests/lib/yaml-parser.test.mjs @@ -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"}'); diff --git a/tests/scanners/rules-validator.test.mjs b/tests/scanners/rules-validator.test.mjs index daf9da4..c3fedab 100644 --- a/tests/scanners/rules-validator.test.mjs +++ b/tests/scanners/rules-validator.test.mjs @@ -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'); + }); +});