Files
published-language/instrument/run.mjs
T
matthieuandClaude Opus 5 7f03f37ef7 feat(published-language): l'instrument émet la correspondance de rangs déclarée
Percursus lit les strates en rangs (1–5, échelle fermée par ses ADR-003/004) ;
openathle les nomme (domaine, application, infrastructure, interface). La
clause loci de Percursus, déclarée à la main, est devenue inférable depuis que
la coupe existe — et l'humain ne saisit pas l'inférable (P4).

Le réglage de la juridiction peut désormais porter rang: N par strate. Le
constat émet rangs_constates — seulement si TOUTES les strates constatées en
déclarent un : une correspondance partielle laisserait deviner, et deviner
n'est pas constater. Le journal publie la table complète
(correspondance_rangs) pour joindre les constats anciens, qui ne se
réécrivent jamais. Champ optionnel, additif : aucun réglage existant ne
change de comportement.

Auto-épreuve : dix-huit vérifications, dont l'ordre des rangs, l'absence de
rangs sur l'invariant sans locus, et la table du journal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 13:20:11 +02:00

337 lines
15 KiB
JavaScript

#!/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 <réglage>, --repo <dépôt>, --dry-run`);
}
if (args.config === null)
refuse('--config <réglage> 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;
}
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));
}
emit(args, cfg, { journeys, invariants: invariants.size, tally, fresh, meta, sortiePath });
}
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,
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}`}`);
}
main();