#!/usr/bin/env node /** * Instrument — where each guard actually lives (temoin/ADR-018, * prismagram-corpus/ADR-061 §2.6 and §2.8). * * Runs INSIDE the observed jurisdiction's CI: the jurisdiction measures * itself; the registry publishes the instrument and receives the findings. * The instrument reads the invariants the jurisdiction declares in its * journey documents, finds every file that names them, and states, per * invariant, at which stratum its verification is observed: * * domicilie named by a test at its due stratum, nowhere above * renforce named by tests at its due stratum AND above — defence in * depth, reported positively, never blamed * mal_domicilie named by tests only OUTSIDE its due stratum — the one gap * aucun_locus named by no test at all * * The instrument states where guards live; it does not run them, and it does * not fail on gaps: the gap IS the product. It exits non-zero only when a * prerequisite is broken — loud failure, never a silent success. * * Findings are appended (never rewritten) to the jurisdiction's own * repository, and only when they change: an unchanged finding produces no * event. The run journal is rewritten at every run, so that staleness can be * judged against the declared cadence by whoever reads the repository. * * Usage: * node run.mjs --config docs/parcours/_meta/strates.yaml [--repo DIR] [--dry-run] */ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; import { dirname, isAbsolute, join, relative, sep } from 'node:path'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; const JOURNEY_FILE = /^P[MU]-\d{3}.*\.md$/; const IGNORED_DIRS = new Set(['node_modules', 'target', 'dist', 'build', 'coverage', 'out']); const MAX_FILE_BYTES = 2_000_000; const MAX_PIECES_PER_STRATUM = 5; function refuse(message) { console.error(`Instrument — REFUS : ${message}`); process.exit(1); } function parseArgs(argv) { const args = { repo: process.cwd(), dryRun: false, config: null }; for (let i = 0; i < argv.length; i += 1) { if (argv[i] === '--config') args.config = argv[i + 1] ?? null, i += 1; else if (argv[i] === '--repo') args.repo = argv[i + 1] ?? args.repo, i += 1; else if (argv[i] === '--dry-run') args.dryRun = true; else refuse(`argument inconnu « ${argv[i]} » — attendus : --config , --repo , --dry-run`); } if (args.config === null) refuse('--config est requis. Le réglage appartient à la juridiction : voir instrument/README.md pour sa forme.'); return args; } /** The tuning file belongs to the jurisdiction; the instrument only knows its shape. */ function loadConfig(repo, path) { const full = isAbsolute(path) ? path : join(repo, path); if (!existsSync(full)) refuse(`le réglage « ${path} » n'existe pas dans ${repo}. Rien n'est créé à la volée : la juridiction pose son réglage (voir instrument/README.md), puis relance.`); let cfg; try { cfg = parseYaml(readFileSync(full, 'utf8')); } catch (e) { refuse(`le réglage « ${path} » n'est pas un YAML lisible : ${e instanceof Error ? e.message.split('\n')[0] : e}`); } for (const key of ['juridiction', 'parcours', 'strates', 'epreuves', 'sortie', 'journal_execution']) if (cfg?.[key] === undefined) refuse(`le réglage « ${path} » ne déclare pas « ${key} »`); if (!Array.isArray(cfg.strates) || cfg.strates.length === 0) refuse(`le réglage « ${path} » doit déclarer au moins une strate`); for (const s of cfg.strates) { if (typeof s?.nom !== 'string' || !Array.isArray(s?.chemins)) refuse(`chaque strate du réglage porte « nom » et « chemins » — l'une des deux manque`); if (s.rang !== undefined && typeof s.rang !== 'number') refuse(`la strate « ${s.nom} » déclare un rang qui n'est pas un nombre — le rang est une correspondance vers une échelle tierce, ou rien`); } const due = cfg.strates.find((s) => s.due === true) ?? cfg.strates[0]; return { ...cfg, due: due.nom, raw: readFileSync(full, 'utf8'), path }; } /** Glob → RegExp. Supports **, * and ? — nothing else, on purpose. */ function globToRegExp(glob) { let out = '^'; for (let i = 0; i < glob.length; i += 1) { const c = glob[i]; if (c === '*' && glob[i + 1] === '*') { out += glob[i + 2] === '/' ? '(?:.*/)?' : '.*'; i += glob[i + 2] === '/' ? 2 : 1; } else if (c === '*') out += '[^/]*'; else if (c === '?') out += '[^/]'; else out += /[A-Za-z0-9_\-/]/.test(c) ? c : `\\${c}`; } return new RegExp(`${out}$`); } function walk(root, dir, acc) { for (const name of readdirSync(dir)) { if (name.startsWith('.')) continue; const full = join(dir, name); const st = statSync(full, { throwIfNoEntry: false }); if (st === undefined) continue; if (st.isDirectory() && !IGNORED_DIRS.has(name)) walk(root, full, acc); else if (st.isFile() && st.size <= MAX_FILE_BYTES) acc.push(relative(root, full).split(sep).join('/')); } return acc; } function frontMatter(text) { const m = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/.exec(text); if (m === null) return null; try { return parseYaml(m[1]); } catch { return null; } } /** * Invariants as the jurisdiction declares them, in its journeys' front matter. * The same id declared with two different statements is an homonymy: it is * carried into the finding, never resolved here — the instrument exposes, it * does not reconcile. */ function collectInvariants(repo, dir) { let files; try { files = readdirSync(join(repo, dir)).filter((f) => JOURNEY_FILE.test(f)).sort(); } catch { refuse(`le répertoire des parcours « ${dir} » est introuvable — rien à mesurer n'est pas un succès`); } const byId = new Map(); for (const f of files) { const fm = frontMatter(readFileSync(join(repo, dir, f), 'utf8')); const journey = typeof fm?.id === 'string' ? fm.id : (f.match(/^(P[MU]-\d{3})/)?.[1] ?? f); const declared = Array.isArray(fm?.invariants) ? fm.invariants : []; for (const inv of declared) { if (typeof inv?.id !== 'string') continue; const cur = byId.get(inv.id) ?? { id: inv.id, statements: new Map(), declaredBy: [] }; cur.declaredBy.push(journey); const s = typeof inv.enonce === 'string' ? inv.enonce.trim() : ''; if (s !== '' && !cur.statements.has(s)) cur.statements.set(s, journey); byId.set(inv.id, cur); } } if (byId.size === 0) refuse(`aucun invariant déclaré dans « ${dir} » — rien à mesurer n'est pas un succès`); return { invariants: byId, journeys: files.length }; } const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); /** Every file covered by a stratum, with its stratum and its test-ness. */ function classifyFiles(repo, cfg) { const strata = cfg.strates.map((s) => ({ nom: s.nom, res: s.chemins.map(globToRegExp) })); const testRes = cfg.epreuves.map(globToRegExp); const out = []; for (const path of walk(repo, repo, [])) { const stratum = strata.find((s) => s.res.some((re) => re.test(path))); if (stratum === undefined) continue; out.push({ path, stratum: stratum.nom, isTest: testRes.some((re) => re.test(path)) }); } return out; } /** Where each invariant is NAMED — the instrument observes naming, not execution. */ function scanCitations(repo, files, ids) { const pattern = new RegExp(`\\b(${ids.map(escapeRe).join('|')})\\b`); const hits = new Map(ids.map((id) => [id, []])); for (const f of files) { const text = readFileSync(join(repo, f.path), 'utf8'); if (!pattern.test(text)) continue; const each = new RegExp(pattern.source, 'g'); text.split('\n').forEach((line, i) => { for (const m of line.matchAll(each)) hits.get(m[1]).push({ path: f.path, line: i + 1, stratum: f.stratum, isTest: f.isTest }); }); } return hits; } /** * Les GARDES NOMMÉES — le plus petit instrument qui prouve que le code tient * des règles que nul n'a déclarées. Une exception de domaine a un nom, un * `throw`, presque toujours une épreuve — et souvent aucune déclaration. * L'instrument la rend comme CANDIDAT, jamais comme constat : il mesure une * existence, il ne déclare rien, il ne renomme aucun état. Déclarer ou non * reste à la juridiction (P4 : l'humain écrit ce que la règle signifie). * Absente du réglage (`gardes_nommees`), la section n'existe pas — rien ne * change pour qui ne l'a pas demandée. */ function namedGuards(repo, cfg, files) { if (!Array.isArray(cfg.gardes_nommees) || cfg.gardes_nommees.length === 0) return null; const res = cfg.gardes_nommees.map(globToRegExp); const exclus = (Array.isArray(cfg.gardes_exclues) ? cfg.gardes_exclues : []).map(globToRegExp); const read = (p) => readFileSync(join(repo, p), 'utf8'); const mdUnder = (dir) => typeof dir === 'string' && existsSync(join(repo, dir)) ? walk(repo, join(repo, dir), []).filter((p) => p.endsWith('.md')) : []; const parcoursText = mdUnder(cfg.parcours).map(read).join('\n'); const adrText = mdUnder(cfg.adr).map(read).join('\n'); const tests = files.filter((f) => f.isTest).map((f) => read(f.path)); // Le réceptacle du refus : ce que la juridiction a DÉCIDÉ d'écarter ou de // différer ne reparaît pas chaque nuit. Sans lui, la liste n'est jamais un // delta et le bruit devient un stresseur (ADR-061 §2.3). Le registre est un // fichier de la juridiction ; l'instrument le lit, il ne l'écrit jamais. const decisions = new Map(); if (typeof cfg.gardes_ecartees === 'string' && existsFileAt(repo, cfg.gardes_ecartees)) { const reg = parseYaml(read(cfg.gardes_ecartees)); for (const d of Array.isArray(reg) ? reg : []) if (d && typeof d.nom === 'string') decisions.set(d.nom, d); } const out = []; for (const f of files) { if (f.isTest || !res.some((re) => re.test(f.path)) || exclus.some((re) => re.test(f.path))) continue; const nom = f.path.split('/').pop().replace(/\.[^.]+$/, ''); const re = new RegExp(`\\b${escapeRe(nom)}\\b`); const d = decisions.get(nom); const differee = d?.decision === 'differe' && typeof d.jusqu_au === 'string' && d.jusqu_au >= new Date().toISOString().slice(0, 10); out.push({ nom, chemin: f.path, strate: f.stratum, // NOMMÉE, jamais « déclarée » : un parcours peut porter la règle en // langue naturelle sans nommer la classe. L'instrument observe la // nomination — c'est sa limite, dite dans le nom des champs. nommee_par_parcours: re.test(parcoursText), nommee_par_adr: re.test(adrText), nommee_par_epreuve: tests.some((x) => re.test(x)), ...(d?.decision === 'ecarte' ? { decision: 'ecarte', motif: d.motif ?? null } : {}), ...(differee ? { decision: 'differe', jusqu_au: d.jusqu_au } : {}), }); } return out.sort((a, b) => a.nom.localeCompare(b.nom)); } function existsFileAt(repo, p) { const st = statSync(join(repo, p), { throwIfNoEntry: false }); return st !== undefined && st.isFile(); } function judge(hits, cfg) { const order = cfg.strates.map((s) => s.nom); const strata = [...new Set(hits.filter((h) => h.isTest).map((h) => h.stratum))] .sort((a, b) => order.indexOf(a) - order.indexOf(b)); if (strata.length === 0) return { etat: 'aucun_locus', strates: [] }; if (!strata.includes(cfg.due)) return { etat: 'mal_domicilie', strates: strata }; return { etat: strata.length === 1 ? 'domicilie' : 'renforce', strates: strata }; } /** One sentence a reader understands without the doctrine — layer 1 wording. */ function sentence(id, verdict, due) { const above = verdict.strates.filter((s) => s !== due); if (verdict.etat === 'aucun_locus') return `${id} : aucune épreuve ne le nomme — aucun endroit constaté.`; if (verdict.etat === 'mal_domicilie') return `${id} : vérifié seulement hors de son étage (${verdict.strates.join(', ')}, jamais ${due}) — la garde n'est pas à sa place.`; if (verdict.etat === 'renforce') return `${id} : vérifié à son étage (${due}) et au-dessus (${above.join(', ')}) — défense en profondeur.`; return `${id} : vérifié à son étage (${due}).`; } /** Last recorded finding per invariant, and how many findings each has. */ function readPrevious(sortiePath) { const last = new Map(); const counts = new Map(); if (!existsSync(sortiePath)) return { last, counts }; const items = parseYaml(readFileSync(sortiePath, 'utf8')); for (const it of Array.isArray(items) ? items : []) { if (typeof it?.invariant !== 'string') continue; counts.set(it.invariant, (counts.get(it.invariant) ?? 0) + 1); last.set(it.invariant, `${it.etat}|${(it.strates_constatees ?? []).join(',')}`); } return { last, counts }; } function pieces(hits) { const kept = []; const omitted = { n: 0 }; const perStratum = new Map(); for (const h of hits.filter((x) => x.isTest)) { const n = (perStratum.get(h.stratum) ?? 0) + 1; perStratum.set(h.stratum, n); if (n <= MAX_PIECES_PER_STRATUM) kept.push({ chemin: h.path, ligne: h.line, strate: h.stratum }); else omitted.n += 1; } return { kept, omitted: omitted.n }; } function constatItem(cfg, inv, verdict, hits, seq, meta) { const { kept, omitted } = pieces(hits); const item = { id: `${cfg.juridiction}/MES-${inv.id}-${seq}`, classe: 'measure', invariant: inv.id, declare_par: inv.declaredBy, enonce: inv.statements.size > 0 ? [...inv.statements.keys()][0] : null, etat: verdict.etat, constat: sentence(inv.id, verdict, cfg.due), strates_constatees: verdict.strates, ...rangsConstates(cfg, verdict.strates), pieces: kept, ...(omitted > 0 ? { pieces_omises: omitted } : {}), citations_hors_epreuve: hits.filter((h) => !h.isTest).length, ...(inv.statements.size > 1 ? { homonymie: [...inv.statements.entries()].map(([enonce, par]) => ({ enonce, declare_par: par })) } : {}), provenance: 'measured', confiance: 1.0, mesure_le: meta.now, revision: meta.revision, reglage_empreinte: meta.reglageHash, }; return item; } /** * La correspondance vers une échelle tierce (p. ex. les rangs de Percursus), * si la juridiction la déclare dans son réglage. Émise SEULEMENT quand toutes * les strates constatées portent un rang : une correspondance partielle * laisserait le consommateur deviner — deviner n'est pas constater. */ function rangsConstates(cfg, strates) { const rang = new Map(cfg.strates.filter((s) => typeof s.rang === 'number').map((s) => [s.nom, s.rang])); if (strates.length === 0 || !strates.every((n) => rang.has(n))) return {}; return { rangs_constates: strates.map((n) => rang.get(n)) }; } function gitRevision(repo) { try { return execFileSync('git', ['-C', repo, 'rev-parse', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; } } const FILE_HEADER = `# Constats de l'instrument — en ajout seul, un constat ne se réécrit jamais. # Un état qui change s'écrit comme un constat nouveau (temoin/ADR-018). `; function main() { const args = parseArgs(process.argv.slice(2)); const cfg = loadConfig(args.repo, args.config); const { invariants, journeys } = collectInvariants(args.repo, cfg.parcours); const files = classifyFiles(args.repo, cfg); const hits = scanCitations(args.repo, files, [...invariants.keys()]); const meta = { now: new Date().toISOString(), revision: gitRevision(args.repo), reglageHash: `sha256:${createHash('sha256').update(cfg.raw).digest('hex')}`, }; const sortiePath = join(args.repo, cfg.sortie); const { last, counts } = readPrevious(sortiePath); const tally = { domicilie: 0, renforce: 0, mal_domicilie: 0, aucun_locus: 0 }; const fresh = []; for (const inv of [...invariants.values()].sort((a, b) => a.id.localeCompare(b.id))) { const verdict = judge(hits.get(inv.id), cfg); tally[verdict.etat] += 1; const key = `${verdict.etat}|${verdict.strates.join(',')}`; if (last.get(inv.id) === key) continue; fresh.push(constatItem(cfg, inv, verdict, hits.get(inv.id), (counts.get(inv.id) ?? 0) + 1, meta)); } const gardes = namedGuards(args.repo, cfg, files); emit(args, cfg, { journeys, invariants: invariants.size, tally, fresh, meta, sortiePath, gardes }); } function emit(args, cfg, r) { const journal = { juridiction: cfg.juridiction, executee_le: r.meta.now, revision: r.meta.revision, ...(typeof cfg.cadence_heures === 'number' ? { cadence_heures: cfg.cadence_heures } : {}), parcours_lus: r.journeys, invariants: r.invariants, ...(cfg.strates.some((s) => typeof s.rang === 'number') ? { correspondance_rangs: Object.fromEntries(cfg.strates.filter((s) => typeof s.rang === 'number').map((s) => [s.nom, s.rang])) } : {}), etats: r.tally, constats_nouveaux: r.fresh.length, // Les candidats vont au journal, réécrit à chaque course : ce n'est pas un // constat (rien n'est mesuré sur un invariant), c'est un inventaire du jour. // Seuls ceux qu'aucun parcours ne cite sont listés — lisibilité (P9) ; le // total dit combien de gardes nommées le code porte. ...(r.gardes === null ? {} : { gardes_nommees: { // Ce que la mesure regarde, dit tel quel : les motifs. Ce qui n'y // correspond pas (require, objets-valeurs, contraintes SQL) est // invisible — la limite est dans le journal, pas seulement au README. motifs: cfg.gardes_nommees, ...(Array.isArray(cfg.gardes_exclues) && cfg.gardes_exclues.length ? { exclus: cfg.gardes_exclues } : {}), total: r.gardes.length, non_nommees_par_un_parcours: r.gardes.filter((g) => !g.nommee_par_parcours).length, sans_epreuve: r.gardes.filter((g) => !g.nommee_par_epreuve).length, ecartees: r.gardes.filter((g) => g.decision === 'ecarte').length, differees: r.gardes.filter((g) => g.decision === 'differe').length, // Les candidats du jour : non nommés par un parcours, ni écartés, ni différés. candidats: r.gardes.filter((g) => !g.nommee_par_parcours && g.decision === undefined), }, }), reglage_empreinte: r.meta.reglageHash, }; if (args.dryRun) { if (r.fresh.length > 0) process.stdout.write(stringifyYaml(r.fresh)); console.log('--- (--dry-run : rien n\'est écrit)'); } else { if (r.fresh.length > 0) { mkdirSync(dirname(r.sortiePath), { recursive: true }); const prefix = existsSync(r.sortiePath) ? '' : FILE_HEADER; appendFileSync(r.sortiePath, prefix + stringifyYaml(r.fresh)); } mkdirSync(dirname(join(args.repo, cfg.journal_execution)), { recursive: true }); writeFileSync(join(args.repo, cfg.journal_execution), stringifyYaml(journal)); } console.log(`Instrument — ${cfg.juridiction}${r.meta.revision === null ? '' : ` @ ${r.meta.revision.slice(0, 10)}`}`); console.log(` ${r.invariants} invariant(s) déclaré(s) par ${r.journeys} parcours`); console.log(` à son étage : ${r.tally.domicilie} · renforcé : ${r.tally.renforce} · hors de son étage : ${r.tally.mal_domicilie} · jamais éprouvé : ${r.tally.aucun_locus}`); console.log(` constat(s) nouveau(x) : ${r.fresh.length}${args.dryRun ? '' : ` → ${cfg.sortie}`}`); if (r.gardes !== null) { const cand = r.gardes.filter((g) => !g.nommee_par_parcours && g.decision === undefined); console.log(` gardes nommées : ${r.gardes.length} · candidates (non nommées par un parcours, ni écartées) : ${cand.length}${cand.length ? ' — ' + cand.map((g) => g.nom).join(', ') : ''}`); } } main();