okr/tests/reference-integrity.test.mjs

119 lines
4.5 KiB
JavaScript

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