// reference-integrity.test.mjs // B1 (1.7.1): referanse-integritet for alle ${CLAUDE_PLUGIN_ROOT}-stier i // commands/ og agents/. Fanger doed-referanse-klassen permanent (review 4: // freshen-references pekte paa gitignored .claude/-sti som aldri shippes). // To invarianter per referert sti: // 1. Stien maa finnes paa disk (relativt til plugin-rot). // 2. Stien maa vaere shippbar: aldri under .claude/ (gitignored, finnes // lokalt men ikke i installert plugin — ren existsSync er falsk groenn). // Glob-stier (* i siste segment) sjekkes som: katalog finnes + minst ett treff. // Zero npm deps. Moenster: tests/package-shape.test.mjs (les fil, assert). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const SCAN_DIRS = ['commands', 'agents']; // Stopper paa whitespace, backtick, anfoerselstegn, ), ] — tegnene som // avslutter en sti i markdown-prosa/lenker. Ingen legitime stier her // inneholder dem. const PATH_RE = /\$\{CLAUDE_PLUGIN_ROOT\}\/([^\s`"')\]]+)/g; function collectReferences() { const refs = []; for (const dir of SCAN_DIRS) { for (const name of readdirSync(join(ROOT, dir))) { if (!name.endsWith('.md')) continue; const file = join(dir, name); const body = readFileSync(join(ROOT, file), 'utf8'); for (const m of body.matchAll(PATH_RE)) { refs.push({ file, rel: m[1] }); } } } return refs; } function assertResolvable(rel) { const starName = rel.lastIndexOf('*'); if (starName === -1) { assert.ok(existsSync(join(ROOT, rel)), `finnes ikke: ${rel}`); return; } // Glob: kun *-i-siste-segment brukes i repoet (f.eks. references/*.md). const dir = dirname(rel); assert.ok(!dir.includes('*'), `glob i katalogsegment stoettes ikke: ${rel}`); assert.ok(existsSync(join(ROOT, dir)), `glob-katalog finnes ikke: ${dir}`); const suffix = rel.slice(starName + 1); const hits = readdirSync(join(ROOT, dir)).filter((n) => n.endsWith(suffix)); assert.ok(hits.length > 0, `glob uten treff: ${rel}`); } test('commands/agents refererer minst en plugin-rot-sti (regex-sanity)', () => { const refs = collectReferences(); assert.ok(refs.length >= 10, `fant bare ${refs.length} referanser — regex broken?`); }); test('alle ${CLAUDE_PLUGIN_ROOT}-stier er shippbare (aldri under .claude/)', () => { const offenders = collectReferences().filter(({ rel }) => rel === '.claude' || rel.startsWith('.claude/')); assert.deepEqual( offenders.map((o) => `${o.file} -> ${o.rel}`), [], 'gitignored .claude/-stier shippes aldri med pluginen' ); }); test('alle ${CLAUDE_PLUGIN_ROOT}-stier finnes paa disk', () => { const missing = []; for (const { file, rel } of collectReferences()) { try { assertResolvable(rel); } catch (e) { missing.push(`${file} -> ${rel} (${e.message})`); } } assert.deepEqual(missing, [], 'doede referanser funnet'); }); // 1.8.0 (En kanon): kanon-konsolideringen legger til NYE krysslenker mellom // referansefiler (Steps 3/4/6/10/11). Denne casen fanger doede *relative* // .md-lenker i skills/-treet - komplementaert til ${CLAUDE_PLUGIN_ROOT}- // invariantene over. Kilder: SKILL.md (backtick `references/x.md` / `x.md`) // + sibling-krysslenker (`x.md`) inni references/. Alle forventes aa peke // paa en fil i references/ paa disk. const REF_REL = join('skills', 'okr-offentlig-sektor', 'references'); const REL_MD_RE = /`(?:references\/)?([a-z0-9-]+\.md)`/g; function collectSkillRelativeRefs() { const refs = []; const sources = [join('skills', 'okr-offentlig-sektor', 'SKILL.md')]; for (const name of readdirSync(join(ROOT, REF_REL))) { if (name.endsWith('.md')) sources.push(join(REF_REL, name)); } for (const file of sources) { const body = readFileSync(join(ROOT, file), 'utf8'); for (const m of body.matchAll(REL_MD_RE)) { refs.push({ file, rel: join(REF_REL, m[1]) }); } } return refs; } test('SKILL.md + referansefil-krysslenker resolverer paa disk (relative .md)', () => { const refs = collectSkillRelativeRefs(); assert.ok(refs.length >= 15, `fant bare ${refs.length} relative refs (regex broken?)`); const missing = []; for (const { file, rel } of refs) { try { assertResolvable(rel); } catch (e) { missing.push(`${file} -> ${rel} (${e.message})`); } } assert.deepEqual(missing, [], 'doede relative referanser i skills/-treet'); });