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>
337 lines
15 KiB
JavaScript
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();
|