Files
published-language/instrument/run.mjs
T
temoin-agentandClaude Fable 5.1 e27cbbbeff
gardes du contrat / conformite (pull_request) Failing after 18s
Instrument : les gardes nommées, comme candidats — le plus petit lecteur qui prouve la thèse
Une exception de domaine est une règle qui a un nom, un throw, presque
toujours une épreuve, et souvent aucune déclaration. Si le réglage le
demande (gardes_nommees, adr), le journal rend les candidats qu'aucun
parcours ne cite, avec leur strate et ce qui les cite. Rien ne devient un
constat, aucun état ne change de sens, la section n'existe pas sans les
clés : additif, ignorable.

Ce qu'un dessin de trois lecteurs (Java annoté, SQL, réconciliation)
voulait démontrer, abattu par l'objecteur mandaté, ces quarante lignes
le démontrent : huit exceptions de domaine chez openathle, zéro déclarée.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 15:23:10 +02:00

391 lines
17 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;
}
/**
* 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 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));
const out = [];
for (const f of files) {
if (f.isTest || !res.some((re) => re.test(f.path))) continue;
const nom = f.path.split('/').pop().replace(/\.[^.]+$/, '');
const re = new RegExp(`\\b${escapeRe(nom)}\\b`);
out.push({
nom,
chemin: f.path,
strate: f.stratum,
dans_parcours: re.test(parcoursText),
dans_adr: re.test(adrText),
eprouve: tests.some((x) => re.test(x)),
});
}
return out.sort((a, b) => a.nom.localeCompare(b.nom));
}
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: {
total: r.gardes.length,
sans_declaration: r.gardes.filter((g) => !g.dans_parcours).length,
candidats: r.gardes.filter((g) => !g.dans_parcours),
},
}),
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 sans = r.gardes.filter((g) => !g.dans_parcours);
console.log(` gardes nommées : ${r.gardes.length}, dont ${sans.length} qu'aucun parcours ne déclare${sans.length ? ' — ' + sans.map((g) => g.nom).join(', ') : ''}`);
}
}
main();