feat(published-language): l'instrument de coupe — où chaque garde vit réellement

La juridiction se mesure elle-même (temoin/ADR-018, mandat temoin/DEC-012 ;
ADR-061 §2.6/§2.8 : le plan de mesure remplit la coupe). L'instrument court
dans la CI de la juridiction observée, lit les invariants déclarés par ses
parcours, et constate pour chacun l'étage où sa vérification est nommée :
domicilié, renforcé, mal domicilié, aucun locus. Il constate la nomination,
pas l'exécution ; il n'échoue jamais sur un écart — l'écart est le produit.

Constats en ajout seul et sur changement d'état seulement ; journal de
fraîcheur réécrit à chaque exécution ; réglage tenu par la juridiction et
épinglé dans chaque constat ; homonymie exposée, jamais résolue. Auto-épreuve
sur juridiction d'essai (quinze vérifications). Essai à blanc sur openathle
réel : 21 invariants, 3 mal domiciliés, 18 sans locus — la coupe mord.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 18:06:40 +02:00
co-authored by Claude Opus 5
parent 79b3d3ca54
commit 92d402444e
11 changed files with 520 additions and 1 deletions
+317
View File
@@ -0,0 +1,317 @@
#!/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`);
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,
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;
}
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,
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();