From 55f83a3c9975276a5ac8f28ebe7730e6ed68971d Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 20 Jun 2026 22:37:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(knowledge):=20best-practices=20register=20?= =?UTF-8?q?foundation=20=E2=80=94=20v5.7=20Fase=201=20Chunk=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add knowledge/best-practices.json: a machine-readable, provenance-stamped, schema-validated best-practices register — the source of truth for the upcoming v5.7 optimization lens (CA-OPT). 13 seed entries migrated from the v5.5 V-rows (loading-model + compaction facts) and the Anthropic 'Steering Claude Code' blog (mechanism-fit rules); each entry carries source.url + verified date + confidence. Only confirmed claims are user-facing (Verifiseringsplikt). scanners/lib/best-practices-register.mjs: zero-dependency loader + validator (loadRegister/validateRegister/getEntry, native JSON.parse — not YAML, since the repo is zero-dep and yaml-parser.mjs can't parse arrays-of-objects). tests/lib/best-practices-register.test.mjs: 22 tests (schema, provenance integrity, negative cases, lookup). Byte-stable: no scanner consumes the register yet (Chunk 2), so all scanner output is unchanged. Suite 1023->1045, self-audit A/A, readmeCheck passed. Full design: docs/v5.7-optimization-lens-plan.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 13 +- README.md | 12 +- STATE.md | 41 +++--- knowledge/best-practices.json | 141 +++++++++++++++++++ scanners/lib/best-practices-register.mjs | 113 +++++++++++++++ tests/lib/best-practices-register.test.mjs | 156 +++++++++++++++++++++ 6 files changed, 452 insertions(+), 24 deletions(-) create mode 100644 knowledge/best-practices.json create mode 100644 scanners/lib/best-practices-register.mjs create mode 100644 tests/lib/best-practices-register.test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 46764f7..1a8920a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,7 @@ Default: auto-detects scope from git context. Override with `/config-audit full| node --test 'tests/**/*.test.mjs' ``` -1023 tests across 57 test files (17 lib + 30 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`. +1045 tests across 58 test files (18 lib + 30 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) @@ -349,6 +349,17 @@ finding titles); `scoring.mjs` `SCANNER_AREA_MAP` (`OST: 'Settings'` — keeps t byte-stable on zero-finding projects). Count badges: self-audit scanner count 13→14; humanizer-data TRANSLATIONS families 14→15 (PLH is a translation family but not orchestrated). +### best-practices register — machine-readable knowledge layer (v5.7 Fase 1 Chunk 1) + +`knowledge/best-practices.json`: provenance-stamped, schema-validated register (entry = +`id`/`claim`/`confidence`/`source` + optional `mechanism`/`lensCheck`/…). First runtime-consumed +file in `knowledge/` (the `*.md` stay human-only); source of truth for the v5.7 optimization lens +(`CA-OPT`); seeded from the v5.5 V-rows + the Anthropic "Steering Claude Code" blog. Only +**confirmed** entries are user-facing (Verifiseringsplikt). Loaded/validated by +`scanners/lib/best-practices-register.mjs` (`loadRegister`/`validateRegister`/`getEntry`; zero-dep +JSON, **not** YAML — `yaml-parser.mjs` can't do arrays-of-objects). Byte-stable until a scanner +consumes it (Chunk 2). Full design: `docs/v5.7-optimization-lens-plan.md`. + ## Gotchas - Session directories accumulate — use `/config-audit cleanup` to manage diff --git a/README.md b/README.md index fae7dfa..4a155c4 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-1023+-brightgreen) +![Tests](https://img.shields.io/badge/tests-1045+-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. 14 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, cross-plugin collision, and output-style detection. Zero external dependencies. @@ -581,6 +581,14 @@ Reference documents that inform the feature-gap agent and context-aware recommen | `prompt-cache-patterns.md` | Token-cost dynamics (prompt-cache patterns) — patterns powering the TOK scanner | | `cache-telemetry-recipe.md` | `jq` recipe for verifying prompt-cache hit rate from session transcripts | +**Machine-readable register (`best-practices.json`).** Alongside the human-readable documents +above, `knowledge/best-practices.json` is a provenance-stamped, schema-validated register of +best-practice claims and mechanism-fit rules — each entry carries `source.url`, a `verified` +date, and a `confidence`. It is the source of truth for the forthcoming optimization lens; the +Markdown files remain the human-readable mirror. Loaded and validated by +`scanners/lib/best-practices-register.mjs` (zero-dependency, native JSON). See +`docs/v5.7-optimization-lens-plan.md`. + --- ## Testing @@ -589,7 +597,7 @@ Reference documents that inform the feature-gap agent and context-aware recommen node --test 'tests/**/*.test.mjs' ``` -1012 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`). +1045 tests across 58 test files (18 lib + 30 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 36dabdb..9da7178 100644 --- a/STATE.md +++ b/STATE.md @@ -2,37 +2,36 @@ _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.6.0 RELEASED — visjon DISKUTERT, v5.7 Fase 1 DESIGNET & GO-ready) -**v5.6.0 released** (`7548a62`, tag `v5.6.0`, catalog-ref `dbbd1ff`). Suite **1023**, scanner **14** (`CA-OST`), self-audit **A/A**. +## Aktiv kontekst (v5.7 Fase 1 Chunk 1 DONE — neste: Chunk 2 = optimerings-linsen `CA-OPT`) +v5.6.0 released. **v5.7 Fase 1 Chunk 1 (register-fundament) ferdig & verifisert** denne økten. Suite **1045**, scanner **14**, self-audit **A/A** (plugin 100 / config 93), readmeCheck passed. -**Visjons-diskusjonen er gjennomført (2026-06-20).** Vi leste Anthropic-bloggen «Steering Claude Code» + kartla pluginens nåværende kunnskaps-/persistens-arkitektur. Resultat: en konkret, GO-ready plan → **`docs/v5.7-optimization-lens-plan.md`** (les den FØR implementering). +Visjons-diskusjonen (2026-06-20) ga GO-ready plan → **`docs/v5.7-optimization-lens-plan.md`** (les FØR videre arbeid). Skifte: korrekt? → optimal? («F1-tuning»). 4 byggeklosser, 2 faser. **Fase 1 = blokk 1+2; Fase 2 = blokk 3+4 (utsatt, eget GO).** -## v5.7 Fase 1 — låste beslutninger (2026-06-20) -Visjonen («F1-tuning»: korrekt? → optimal?) dekomponert i 4 byggeklosser. **Fase 1 = blokk 1+2; Fase 2 = blokk 3+4 (utsatt, eget GO).** Operatør-låst: +## Låste beslutninger (2026-06-20) - **Rekkefølge:** Fase 1 (linse + kunnskapsbase) FØRST, så Fase 2 (kampanje + backlog). -- **Kunnskaps-format:** strukturert register (YAML/JSON) linsen leser; markdown som lesbart speil. -- **Linse-motor:** ny finding-familie (`CA-OPT` foreslått) + hybrid motor (determ. pre-filter → opus-analyzer), presisjons-gated. = v5.7 D «mechanism-fit» løftet til familie, drevet av registeret. +- **Kunnskaps-format:** strukturert **JSON**-register (zero-dep avgjorde YAML vs JSON; `yaml-parser.mjs` kan ikke arrays-of-objects); markdown som speil. +- **Linse-motor:** ny familie (`CA-OPT`) + hybrid motor (determ. pre-filter → opus-analyzer), presisjons-gated. -## Foreslått chunking (ett GO'd-sesjon hver — neste sesjon = Chunk 1) -1. **Chunk 1 — Register-fundament:** strukturert register-format + schema-validering + migrer `knowledge/` + v5.5 V-rader inn + tester (byte-stabil, ingen output-endring). -2. **Chunk 2 — Linsen (`CA-OPT`):** pre-filter + opus-analyzer-agent + humanizer/scoring-wiring + fixtures + byte-stab strip (additiv familie). -3. **Chunk 3 — `knowledge-refresh`:** semi-auto poller (`--dry-run`, stale/candidate, human-godkjente skriv) — den «levende» delen. -Avhengighet: 1 → 2 (linse leser register) → 3 (holder register friskt). +## Chunking (ett GO'd-sesjon hver) +1. **Chunk 1 — Register-fundament: ✅ DONE.** `knowledge/best-practices.json` (13 entries: V-rader + blogg-regler, kildefestet) + `scanners/lib/best-practices-register.mjs` (loadRegister/validateRegister/getEntry) + `tests/lib/best-practices-register.test.mjs` (22 tester). Byte-stabil (ingen scanner konsumerer det ennå). +2. **Chunk 2 — Linsen (`CA-OPT`) — NESTE, KREVER GO:** determ. pre-filter + opus-analyzer-agent som leser registeret + dømmer mekanisme-fit + humanizer/scoring-wiring + fixtures (positiv + negativ, null false-positive) + byte-stab strip (følg CA-OST-presedens). lensCheck-feltene i seedet peker allerede på detektorene som skal bygges. +3. **Chunk 3 — `knowledge-refresh`:** semi-auto poller (`--dry-run`, stale/candidate, human-godkjente skriv). ## Åpne tråder / neste steg (prioritert) -1. **v5.7 Fase 1 Chunk 1 — KREVER GO:** start register-fundamentet. Les `docs/v5.7-optimization-lens-plan.md` først. -2. **Fase 2 (kampanje + backlog) — utsatt, eget GO** etter at linsen er validert på ett repo. -3. **Åpne design-detaljer** (i planen): endelig ID-prefix (`CA-OPT`?), YAML vs JSON, refresh-kilder/kadens, `/repo-init`-synergi (linsen som kvalitetsmåler). +1. **Chunk 2 (CA-OPT-linsen) — KREVER GO.** Les plan-doc-en + registeret først. +2. **Fase 2 (kampanje + backlog) — utsatt, eget GO** etter linse-validering på ett repo. +3. **Åpne design-detaljer** (i planen): endelig ID-prefix (`CA-OPT`?), refresh-kilder/kadens, `/repo-init`-synergi (linsen som kvalitetsmåler). 4. **llm-security KRITISK** (annet repo) + 3 aktive plugins + STATE-sveip — uendret. Egne GO. ## Gotchas (UFRAVIKELIG) -- **Scanner-/familie-tillegg = strip, IKKE re-seed (POST-B2).** Bevar frozen v5.0.0 via strip-helpere (`strip-hotspot-load-pattern` + `strip-added-scanner`); re-seed KUN SC-5. `CA-OPT` følger CA-OST-presedensen (bumper `scanners_ok` → strip). -- **Release-cut = 3 filer + annotert tag + catalog.** plugin.json + README (badge + 1 version-history-rad) + CHANGELOG; så `git tag -a`; så `../catalog` ref-bump. tests-badge = `ℹ tests N` (`countTestCases`), `readmeCheck.passed:true`. Nå: **1023**, scanner **14**. -- **Persistens (verifisert):** sesjoner i `~/.claude/config-audit/sessions/` ligger UTENFOR plugin-mappa → overlever reinstall/oppgradering. Fase 2 legger til versjons-felt + migrering. -- Push-vindu: hverdag 20–23, helg fritt. **I dag = lørdag → push tillatt.** docs-gate: `feat:` krever ≥3 ikke-ws-linjer i README+CLAUDE.md (ikke `release:`). +- **CLAUDE.md-lengde drar config-grade.** Lange architecture-tillegg i pluginens egen CLAUDE.md tipper CML «getting long»-gradienten (A93→B89 sett denne økten). Hold tillegg lean; full detalj → `docs/`. Mål alltid self-audit mot baseline FØR commit. +- **Scanner-/familie-tillegg = strip, IKKE re-seed (POST-B2).** Bevar frozen v5.0.0 via strip-helpere; re-seed KUN SC-5. `CA-OPT` (Chunk 2) følger CA-OST-presedensen (bumper `scanners_ok` → strip). +- **Release-cut = 3 filer + annotert tag + catalog.** plugin.json + README (badge + 1 version-history-rad) + CHANGELOG; så `git tag -a`; så `../catalog` ref-bump. tests-badge = `ℹ tests N` (`countTestCases`), `readmeCheck.passed:true`. Nå: **1045**, scanner **14**. Knowledge-badge teller kun `.md` (JSON-registeret påvirker den ikke). +- **Persistens (verifisert):** sesjoner i `~/.claude/config-audit/sessions/` ligger UTENFOR plugin-mappa → overlever reinstall/oppgradering. +- Push-vindu: hverdag 20–23, helg fritt. docs-gate: `feat:` krever ≥3 ikke-ws-linjer i README+CLAUDE.md. ## Scope-gjerde -Diskusjons-sesjonen er ferdig: visjon dekomponert, Fase 1 designet & låst, plan skrevet til `docs/`. **Ingen kode skrevet denne økten (bevisst).** Alt implementeringsarbeid (Chunk 1+) = eget GO. Denne økten: STATE + plan-doc + visjons-memory oppdatert. +Chunk 1 komplett, verifisert, committet. **Chunk 2 (CA-OPT) krever eget GO.** Ingen annen kode-oppgave åpen. ## Kontinuitet -Tre lag: STATE.md (tracked) + auto-memory (`config-audit-vision` — nå med låste beslutninger) + CLAUDE.md. Plan-detalj → `docs/v5.7-optimization-lens-plan.md`. Oppdater DENNE som siste handling. +Tre lag: STATE.md (tracked) + auto-memory (`config-audit-vision` — låste beslutninger) + CLAUDE.md. Plan-detalj → `docs/v5.7-optimization-lens-plan.md`. Oppdater DENNE som siste handling. diff --git a/knowledge/best-practices.json b/knowledge/best-practices.json new file mode 100644 index 0000000..d11a82d --- /dev/null +++ b/knowledge/best-practices.json @@ -0,0 +1,141 @@ +{ + "version": 1, + "note": "Machine-readable best-practices register. SOURCE OF TRUTH for the optimization lens (v5.7 CA-OPT). Human-readable mirror lives in knowledge/*.md. Every entry is provenance-stamped (source.url + source.verified) and carries a confidence; only CONFIRMED claims are consumed user-facing (Verifiseringsplikt). Curated manually + by /config-audit knowledge-refresh (human-approved). Seeded from docs/v5.5-steering-model-plan.md V-rows + the Anthropic 'Steering Claude Code' blog.", + "entries": [ + { + "id": "BP-MECH-001", + "claim": "Lifecycle automation phrased as an instruction in CLAUDE.md (\"every time\", \"before each\", \"always run X after Y\") should be a hook — a behavior the model chooses to follow is not deterministic.", + "mechanism": "hook", + "appliesTo": "claude-md", + "recommendation": "Move the behavior to a PreToolUse/PostToolUse/Stop hook so it runs deterministically, outside the model's discretion.", + "confidence": "confirmed", + "severity": "low", + "category": "mechanism-fit", + "lensCheck": "claude-md-lifecycle-phrasing", + "source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" } + }, + { + "id": "BP-MECH-002", + "claim": "A file- or path-specific constraint placed in root CLAUDE.md or an unscoped rule should be a path-scoped rule (paths: frontmatter), so it loads only when a matching file is touched.", + "mechanism": "rule", + "appliesTo": "claude-md", + "recommendation": "Move it to .claude/rules/ with a paths: frontmatter; unscoped instructions cost tokens every turn whether relevant or not.", + "confidence": "confirmed", + "severity": "low", + "category": "mechanism-fit", + "lensCheck": "unscoped-path-specific-instruction", + "source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" } + }, + { + "id": "BP-MECH-003", + "claim": "A multi-step procedure (deploy/release checklist) in CLAUDE.md should be a skill — CLAUDE.md is for facts Claude should hold all the time; procedures belong in skills.", + "mechanism": "skill", + "appliesTo": "claude-md", + "recommendation": "Extract the procedure into .claude/skills/; its body then loads only on invoke instead of every turn.", + "confidence": "confirmed", + "severity": "low", + "category": "mechanism-fit", + "lensCheck": "procedure-in-claude-md", + "source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" } + }, + { + "id": "BP-MECH-004", + "claim": "An absolute prohibition phrased as a \"never do X\" instruction is the wrong tool; for something that absolutely must not happen, use permissions or a PreToolUse hook.", + "mechanism": "permission", + "appliesTo": "claude-md", + "recommendation": "Enforce hard prohibitions via permission deny rules or a PreToolUse hook (exit code 2 denies the call), not prose instructions.", + "confidence": "confirmed", + "severity": "low", + "category": "mechanism-fit", + "lensCheck": "never-instruction", + "source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" } + }, + { + "id": "BP-MECH-005", + "claim": "A custom output style without keep-coding-instructions: true removes Claude Code's built-in software-engineering instructions when active.", + "mechanism": "output-style", + "appliesTo": "output-style", + "recommendation": "Set keep-coding-instructions: true, or prefer a built-in style (Explanatory / Learning / Proactive) before writing a custom one.", + "confidence": "confirmed", + "severity": "medium", + "category": "mechanism-fit", + "lensCheck": "CA-OST-001", + "source": { "url": "https://code.claude.com/docs/en/output-styles", "title": "Output styles", "verified": "2026-06-20" } + }, + { + "id": "BP-LOAD-001", + "claim": "Project-root CLAUDE.md and unscoped rules are re-injected from disk after compaction (they survive a /compact).", + "appliesTo": "claude-md", + "confidence": "confirmed", + "category": "loading-model", + "lensCheck": null, + "source": { "url": "https://code.claude.com/docs/en/context-window", "title": "Context window — what survives compaction", "verified": "2026-06-20" } + }, + { + "id": "BP-LOAD-002", + "claim": "Path-scoped rules are lost after compaction until a matching file is read again, and they trigger on Read of a matching file (not on every tool use).", + "appliesTo": "rule", + "confidence": "confirmed", + "category": "loading-model", + "lensCheck": null, + "source": { "url": "https://code.claude.com/docs/en/memory", "title": "Memory — path-specific rules", "verified": "2026-06-20" } + }, + { + "id": "BP-LOAD-003", + "claim": "A nested (non-root) CLAUDE.md is lost after compaction until a file in its directory is read again.", + "appliesTo": "claude-md", + "confidence": "confirmed", + "category": "loading-model", + "lensCheck": null, + "source": { "url": "https://code.claude.com/docs/en/context-window", "title": "Context window — what survives compaction", "verified": "2026-06-20" } + }, + { + "id": "BP-LOAD-004", + "claim": "A skill's name + description load every turn; its body loads only on invoke.", + "appliesTo": "skill", + "confidence": "confirmed", + "category": "loading-model", + "lensCheck": null, + "source": { "url": "https://code.claude.com/docs/en/skills", "title": "Skills", "verified": "2026-06-20" } + }, + { + "id": "BP-LOAD-005", + "claim": "Hook scripts run outside the model context, but any additionalContext they inject is saved to the transcript and is therefore subject to compaction.", + "appliesTo": "hook", + "confidence": "confirmed", + "category": "loading-model", + "lensCheck": null, + "source": { "url": "https://code.claude.com/docs/en/hooks", "title": "Hooks", "verified": "2026-06-20" } + }, + { + "id": "BP-LOAD-006", + "claim": "A subagent runs in an isolated, fresh context window; only its final summary returns to the main session (parent instructions are not auto-injected).", + "appliesTo": "agent", + "confidence": "confirmed", + "category": "loading-model", + "lensCheck": null, + "source": { "url": "https://code.claude.com/docs/en/sub-agents", "title": "Subagents", "verified": "2026-06-20" } + }, + { + "id": "BP-SIZE-001", + "claim": "Keep CLAUDE.md under 200 lines; give it an owner and review changes to it like code. Every line costs tokens whether relevant or not.", + "appliesTo": "claude-md", + "recommendation": "Trim CLAUDE.md to facts; move procedures to skills and path-specific rules to .claude/rules/.", + "confidence": "confirmed", + "severity": "medium", + "category": "size-budget", + "lensCheck": "CA-CML-001", + "source": { "url": "https://claude.com/blog/steering-claude-code-skills-hooks-rules-subagents-and-more", "title": "Steering Claude Code: skills, hooks, rules, subagents and more", "verified": "2026-06-20" } + }, + { + "id": "BP-SIZE-002", + "claim": "The skill-listing description cap is 1,536 characters (maxSkillDescriptionChars, configurable, v2.1.105+); the name + description load every turn.", + "appliesTo": "skill", + "confidence": "confirmed", + "severity": "low", + "category": "size-budget", + "lensCheck": "CA-SKL-002", + "source": { "url": "https://code.claude.com/docs/en/skills", "title": "Skills", "verified": "2026-06-20" } + } + ] +} diff --git a/scanners/lib/best-practices-register.mjs b/scanners/lib/best-practices-register.mjs new file mode 100644 index 0000000..3b620cf --- /dev/null +++ b/scanners/lib/best-practices-register.mjs @@ -0,0 +1,113 @@ +/** + * best-practices-register — loader + schema validator for the machine-readable + * best-practices register (knowledge/best-practices.json). + * + * The register is the SOURCE OF TRUTH for the v5.7 optimization lens (CA-OPT). Each entry + * is provenance-stamped (source.url + source.verified) and carries a confidence; only + * CONFIRMED claims are surfaced user-facing (Verifiseringsplikt). Zero-dependency: native + * JSON, validated by hand here. See docs/v5.7-optimization-lens-plan.md. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { SEVERITY } from './severity.mjs'; + +/** Absolute path to the bundled register. */ +export const REGISTER_PATH = fileURLToPath( + new URL('../../knowledge/best-practices.json', import.meta.url) +); + +/** Confidence levels. Only `confirmed` is consumed user-facing by the lens. */ +export const CONFIDENCE_LEVELS = Object.freeze(['confirmed', 'inferred', 'unverified']); + +const VALID_SEVERITIES = new Set(Object.keys(SEVERITY)); +const ID_RE = /^BP-[A-Z]+-\d{3}$/; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Load + parse a register file (defaults to the bundled one). Throws on missing file or + * invalid JSON — callers that want graceful handling should try/catch. + * @param {string} [path] + * @returns {{version:number, entries:object[]}} + */ +export function loadRegister(path = REGISTER_PATH) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +/** + * Validate a parsed register against the schema. Never throws — returns a result so the + * caller (and tests) can inspect every problem at once. + * @param {unknown} data + * @returns {{valid:boolean, errors:string[]}} + */ +export function validateRegister(data) { + const errors = []; + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return { valid: false, errors: ['register must be an object'] }; + } + if (typeof data.version !== 'number') errors.push('version must be a number'); + if (!Array.isArray(data.entries)) { + errors.push('entries must be an array'); + return { valid: false, errors }; + } + + const seen = new Set(); + data.entries.forEach((e, i) => { + const at = `entry[${i}]${e && typeof e === 'object' && e.id ? ` (${e.id})` : ''}`; + if (!e || typeof e !== 'object' || Array.isArray(e)) { + errors.push(`${at}: must be an object`); + return; + } + // id — required, BP-TOPIC-NNN, unique + if (typeof e.id !== 'string' || !ID_RE.test(e.id)) { + errors.push(`${at}: id must match BP-TOPIC-NNN`); + } else if (seen.has(e.id)) { + errors.push(`${at}: duplicate id`); + } else { + seen.add(e.id); + } + // claim — required, non-empty + if (typeof e.claim !== 'string' || e.claim.trim() === '') { + errors.push(`${at}: claim is required`); + } + // confidence — required, enum + if (!CONFIDENCE_LEVELS.includes(e.confidence)) { + errors.push(`${at}: confidence must be one of ${CONFIDENCE_LEVELS.join('|')}`); + } + // source — required object with url + verified date (provenance) + if (!e.source || typeof e.source !== 'object' || Array.isArray(e.source)) { + errors.push(`${at}: source is required`); + } else { + if (typeof e.source.url !== 'string' || e.source.url.trim() === '') { + errors.push(`${at}: source.url is required`); + } + if (typeof e.source.verified !== 'string' || !DATE_RE.test(e.source.verified)) { + errors.push(`${at}: source.verified must be a YYYY-MM-DD date`); + } + } + // optional fields — typed only when present + if (e.severity !== undefined && !VALID_SEVERITIES.has(e.severity)) { + errors.push(`${at}: severity must be one of ${[...VALID_SEVERITIES].join('|')}`); + } + for (const key of ['mechanism', 'appliesTo', 'recommendation', 'category']) { + if (e[key] !== undefined && typeof e[key] !== 'string') { + errors.push(`${at}: ${key} must be a string`); + } + } + if (e.lensCheck !== undefined && e.lensCheck !== null && typeof e.lensCheck !== 'string') { + errors.push(`${at}: lensCheck must be a string or null`); + } + }); + + return { valid: errors.length === 0, errors }; +} + +/** + * Look up an entry by id. + * @param {{entries:object[]}} register + * @param {string} id + * @returns {object|undefined} + */ +export function getEntry(register, id) { + if (!register || !Array.isArray(register.entries)) return undefined; + return register.entries.find((e) => e.id === id); +} diff --git a/tests/lib/best-practices-register.test.mjs b/tests/lib/best-practices-register.test.mjs new file mode 100644 index 0000000..8330afe --- /dev/null +++ b/tests/lib/best-practices-register.test.mjs @@ -0,0 +1,156 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + loadRegister, + validateRegister, + getEntry, + CONFIDENCE_LEVELS, + REGISTER_PATH, +} from '../../scanners/lib/best-practices-register.mjs'; + +// A minimal well-formed entry; negative tests clone + mutate this. +const validEntry = () => ({ + id: 'BP-TEST-001', + claim: 'A representative best-practice claim.', + confidence: 'confirmed', + source: { url: 'https://example.com/doc', title: 'Doc', verified: '2026-06-20' }, +}); + +const wrap = (entries) => ({ version: 1, entries }); + +describe('CONFIDENCE_LEVELS', () => { + it('has the three documented levels', () => { + assert.deepStrictEqual([...CONFIDENCE_LEVELS].sort(), ['confirmed', 'inferred', 'unverified']); + }); + it('is frozen', () => { + assert.throws(() => { CONFIDENCE_LEVELS.push('x'); }, TypeError); + }); +}); + +describe('loadRegister (bundled register)', () => { + it('loads the bundled register file', () => { + const reg = loadRegister(); + assert.equal(typeof reg, 'object'); + assert.equal(reg.version, 1); + assert.ok(Array.isArray(reg.entries)); + assert.ok(reg.entries.length > 0, 'register should ship with seed entries'); + }); + + it('REGISTER_PATH points at knowledge/best-practices.json', () => { + assert.match(String(REGISTER_PATH), /knowledge\/best-practices\.json$/); + }); +}); + +describe('bundled register integrity (Verifiseringsplikt)', () => { + const reg = loadRegister(); + + it('passes schema validation with zero errors', () => { + const result = validateRegister(reg); + assert.deepStrictEqual(result.errors, []); + assert.equal(result.valid, true); + }); + + it('has unique ids', () => { + const ids = reg.entries.map((e) => e.id); + assert.equal(new Set(ids).size, ids.length); + }); + + it('uses the BP-TOPIC-NNN id convention', () => { + for (const e of reg.entries) { + assert.match(e.id, /^BP-[A-Z]+-\d{3}$/, `bad id: ${e.id}`); + } + }); + + it('seeds only CONFIRMED claims (no unverified assertion enters the consumed register)', () => { + for (const e of reg.entries) { + assert.equal(e.confidence, 'confirmed', `${e.id} must be confirmed in the seed`); + } + }); + + it('every entry carries a source url + verified date', () => { + for (const e of reg.entries) { + assert.ok(e.source && e.source.url, `${e.id} missing source.url`); + assert.match(e.source.verified, /^\d{4}-\d{2}-\d{2}$/, `${e.id} bad verified date`); + } + }); +}); + +describe('validateRegister (negative cases)', () => { + it('accepts a minimal valid register', () => { + assert.equal(validateRegister(wrap([validEntry()])).valid, true); + }); + + it('rejects a non-object', () => { + assert.equal(validateRegister(null).valid, false); + assert.equal(validateRegister('nope').valid, false); + }); + + it('rejects a non-numeric version', () => { + const r = validateRegister({ version: 'one', entries: [validEntry()] }); + assert.equal(r.valid, false); + assert.ok(r.errors.some((m) => /version/.test(m))); + }); + + it('rejects non-array entries', () => { + const r = validateRegister({ version: 1, entries: {} }); + assert.equal(r.valid, false); + assert.ok(r.errors.some((m) => /entries/.test(m))); + }); + + it('rejects duplicate ids', () => { + const r = validateRegister(wrap([validEntry(), validEntry()])); + assert.equal(r.valid, false); + assert.ok(r.errors.some((m) => /duplicate/i.test(m))); + }); + + it('rejects a missing claim', () => { + const e = validEntry(); delete e.claim; + const r = validateRegister(wrap([e])); + assert.equal(r.valid, false); + assert.ok(r.errors.some((m) => /claim/.test(m))); + }); + + it('rejects an out-of-enum confidence', () => { + const e = validEntry(); e.confidence = 'maybe'; + const r = validateRegister(wrap([e])); + assert.equal(r.valid, false); + assert.ok(r.errors.some((m) => /confidence/.test(m))); + }); + + it('rejects a malformed verified date', () => { + const e = validEntry(); e.source.verified = '20-06-2026'; + const r = validateRegister(wrap([e])); + assert.equal(r.valid, false); + assert.ok(r.errors.some((m) => /verified|date/i.test(m))); + }); + + it('rejects a missing source url', () => { + const e = validEntry(); delete e.source.url; + const r = validateRegister(wrap([e])); + assert.equal(r.valid, false); + assert.ok(r.errors.some((m) => /source|url/i.test(m))); + }); + + it('rejects an out-of-enum severity when present', () => { + const e = validEntry(); e.severity = 'urgent'; + const r = validateRegister(wrap([e])); + assert.equal(r.valid, false); + assert.ok(r.errors.some((m) => /severity/.test(m))); + }); + + it('accepts a valid optional severity', () => { + const e = validEntry(); e.severity = 'low'; + assert.equal(validateRegister(wrap([e])).valid, true); + }); +}); + +describe('getEntry', () => { + const reg = loadRegister(); + it('returns the entry for a known id', () => { + const first = reg.entries[0]; + assert.equal(getEntry(reg, first.id), first); + }); + it('returns undefined for an unknown id', () => { + assert.equal(getEntry(reg, 'BP-NOPE-999'), undefined); + }); +});