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:
@@ -0,0 +1,69 @@
|
|||||||
|
# L'instrument — où chaque garde vit réellement
|
||||||
|
|
||||||
|
Chaque garde vit à son étage ; l'instrument regarde où elle est réellement
|
||||||
|
posée. Il court **dans la CI de la juridiction observée** — la juridiction se
|
||||||
|
mesure elle-même ; le registre publie l'instrument et reçoit les constats
|
||||||
|
(`temoin/ADR-018`, `prismagram-corpus/ADR-061` §2.6 et §2.8).
|
||||||
|
|
||||||
|
## Ce qu'il constate
|
||||||
|
|
||||||
|
Pour chaque invariant que la juridiction déclare dans le front-matter de ses
|
||||||
|
parcours (`invariants:`), l'instrument cherche chaque fichier qui le **nomme**
|
||||||
|
et rend un état :
|
||||||
|
|
||||||
|
| État | Sens |
|
||||||
|
|---|---|
|
||||||
|
| `domicilie` | nommé par une épreuve à son étage dû, nulle part au-dessus |
|
||||||
|
| `renforce` | nommé par des épreuves à son étage dû **et** au-dessus — défense en profondeur, signalée positivement, jamais blâmée |
|
||||||
|
| `mal_domicilie` | nommé par des épreuves **uniquement hors** de son étage dû — le seul écart |
|
||||||
|
| `aucun_locus` | nommé par aucune épreuve |
|
||||||
|
|
||||||
|
L'instrument constate la **nomination**, pas l'exécution : c'est la CI de la
|
||||||
|
juridiction qui fait passer ses épreuves ; l'instrument dit seulement où elles
|
||||||
|
habitent. Un même identifiant déclaré avec deux énoncés différents est une
|
||||||
|
homonymie : elle est portée dans le constat, jamais résolue ici.
|
||||||
|
|
||||||
|
## Le réglage — il appartient à la juridiction
|
||||||
|
|
||||||
|
L'instrument ne connaît aucun chemin d'aucun projet : toute convention vit
|
||||||
|
dans un fichier de réglage que la juridiction tient dans son dépôt, et que
|
||||||
|
chaque constat épingle par son empreinte. Voir
|
||||||
|
[`selftest/fixture/reglage.yaml`](selftest/fixture/reglage.yaml) pour la forme
|
||||||
|
complète : `juridiction`, `cadence_heures`, `parcours`, `strates` (la première
|
||||||
|
— ou celle marquée `due: true` — est l'étage dû), `epreuves`, `sortie`,
|
||||||
|
`journal_execution`.
|
||||||
|
|
||||||
|
## Ce qu'il écrit — chez la juridiction, jamais ailleurs
|
||||||
|
|
||||||
|
- **`sortie`** : les constats, en **ajout seul**, et **seulement quand l'état
|
||||||
|
change** — un constat inchangé ne produit aucun événement. Chaque constat
|
||||||
|
porte ses pièces (chemin, ligne, strate), sa révision, l'empreinte du
|
||||||
|
réglage : il est recalculable par quiconque clone le dépôt.
|
||||||
|
- **`journal_execution`** : réécrit à **chaque** exécution — le témoin de
|
||||||
|
fraîcheur. Un journal plus vieux que la cadence déclarée est un écart que le
|
||||||
|
lecteur du dépôt constate ; jamais un silence.
|
||||||
|
|
||||||
|
## Exécution
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node instrument/run.mjs --config docs/parcours/_meta/strates.yaml
|
||||||
|
# --repo DIR : racine du dépôt mesuré (défaut : répertoire courant)
|
||||||
|
# --dry-run : affiche les constats sans rien écrire
|
||||||
|
```
|
||||||
|
|
||||||
|
Sort en erreur **uniquement** sur prérequis cassé (réglage absent ou
|
||||||
|
illisible, aucun parcours, aucun invariant) — jamais sur un écart constaté :
|
||||||
|
l'écart est le produit, pas une panne. `npm run instrument:selftest` joue
|
||||||
|
l'auto-épreuve sur une juridiction d'essai jetable.
|
||||||
|
|
||||||
|
## Limites dites (v0)
|
||||||
|
|
||||||
|
- Seuls les invariants déclarés dans les parcours sont mesurés ; un invariant
|
||||||
|
qui ne vit que dans une ADR n'est pas encore vu.
|
||||||
|
- La citation est textuelle (mot entier) ; une épreuve qui vérifie sans nommer
|
||||||
|
n'est pas constatée — nommer ses invariants dans ses épreuves est le geste
|
||||||
|
que l'instrument récompense.
|
||||||
|
- Une citation est attribuée à l'identifiant que les parcours déclarent ; si
|
||||||
|
une ADR déclare un homonyme, l'attribution peut se tromper. Unifier son
|
||||||
|
espace de noms d'invariants est un geste de la juridiction, en amont de
|
||||||
|
toute mesure.
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Self-test of the instrument, on a throwaway fixture jurisdiction.
|
||||||
|
*
|
||||||
|
* The fixture is synthetic BY NECESSITY: the real jurisdictions this package
|
||||||
|
* serves cannot ship inside it. The instrument's first real run — on the
|
||||||
|
* observed jurisdiction's own CI — is the measure that counts; this file only
|
||||||
|
* proves the mechanics: the four states, the homonymy carried, the
|
||||||
|
* append-on-change discipline, and the run journal.
|
||||||
|
*
|
||||||
|
* Exit codes: 0 sound, 1 broken. A refusal names its cause.
|
||||||
|
*/
|
||||||
|
import { cpSync, mkdtempSync, readFileSync, rmSync, unlinkSync, existsSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { parse as parseYaml } from 'yaml';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
let failures = 0;
|
||||||
|
const ok = (m) => console.log(` ✓ ${m}`);
|
||||||
|
const fail = (m) => { failures += 1; console.error(` ✗ ${m}`); };
|
||||||
|
const assert = (cond, m) => (cond ? ok(m) : fail(m));
|
||||||
|
|
||||||
|
function run(repo, extra = []) {
|
||||||
|
return execFileSync(process.execPath, [join(here, 'run.mjs'), '--config', 'reglage.yaml', '--repo', repo, ...extra], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = mkdtempSync(join(tmpdir(), 'instrument-selftest-'));
|
||||||
|
cpSync(join(here, 'selftest/fixture'), repo, { recursive: true });
|
||||||
|
const coupe = () => parseYaml(readFileSync(join(repo, 'docs/parcours/_mesures/coupe.yaml'), 'utf8'));
|
||||||
|
const journal = () => parseYaml(readFileSync(join(repo, 'docs/parcours/_mesures/derniere-execution.yaml'), 'utf8'));
|
||||||
|
const byInv = (items, id) => items.filter((c) => c.invariant === id).at(-1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('First run — the four states, observed:');
|
||||||
|
run(repo);
|
||||||
|
const first = coupe();
|
||||||
|
assert(first.length === 3, `three findings appended (got ${first.length})`);
|
||||||
|
assert(byInv(first, 'INV-001')?.etat === 'renforce', 'INV-001 renforce — due stratum and above');
|
||||||
|
assert((byInv(first, 'INV-001')?.strates_constatees ?? []).join(',') === 'domaine,application', 'INV-001 strata ordered as the tuning declares them');
|
||||||
|
assert(Array.isArray(byInv(first, 'INV-001')?.homonymie), 'INV-001 carries its homonymy — two statements, one id, exposed not resolved');
|
||||||
|
assert(byInv(first, 'INV-001')?.citations_hors_epreuve === 1, 'INV-001 counts its non-test citation without treating it as proof');
|
||||||
|
assert(byInv(first, 'INV-002')?.etat === 'mal_domicilie', 'INV-002 mal_domicilie — proven only away from home');
|
||||||
|
assert(byInv(first, 'INV-003')?.etat === 'aucun_locus', 'INV-003 aucun_locus — named by no test');
|
||||||
|
assert(journal().constats_nouveaux === 3 && journal().invariants === 3, 'journal counts the run');
|
||||||
|
|
||||||
|
console.log('Second run — an unchanged finding produces no event:');
|
||||||
|
run(repo);
|
||||||
|
assert(coupe().length === 3, 'nothing appended on an unchanged repository');
|
||||||
|
assert(journal().constats_nouveaux === 0, 'journal still rewritten — freshness is witnessed even when nothing changed');
|
||||||
|
|
||||||
|
console.log('Third run — a guard disappears, the finding changes:');
|
||||||
|
unlinkSync(join(repo, 'src/test/java/x/Domain/RuleTest.java'));
|
||||||
|
run(repo);
|
||||||
|
const third = coupe();
|
||||||
|
assert(third.length === 4, 'exactly one new finding appended');
|
||||||
|
assert(byInv(third, 'INV-001')?.etat === 'mal_domicilie', 'INV-001 fell to mal_domicilie when its due-stratum test vanished');
|
||||||
|
assert(byInv(third, 'INV-001')?.id === 'essai/MES-INV-001-2', 'the new finding takes the next sequence number, the old one stands');
|
||||||
|
|
||||||
|
console.log('Dry run — states without writing:');
|
||||||
|
const before = readFileSync(join(repo, 'docs/parcours/_mesures/coupe.yaml'), 'utf8');
|
||||||
|
run(repo, ['--dry-run']);
|
||||||
|
assert(readFileSync(join(repo, 'docs/parcours/_mesures/coupe.yaml'), 'utf8') === before, '--dry-run writes nothing');
|
||||||
|
|
||||||
|
console.log('Loud failure — a broken prerequisite refuses, it never half-runs:');
|
||||||
|
let refused = false;
|
||||||
|
try {
|
||||||
|
execFileSync(process.execPath, [join(here, 'run.mjs'), '--config', 'absent.yaml', '--repo', repo], { encoding: 'utf8', stdio: 'pipe' });
|
||||||
|
} catch (e) {
|
||||||
|
refused = String(e.stderr).includes('REFUS');
|
||||||
|
}
|
||||||
|
assert(refused, 'a missing tuning file is refused with its remedy, not defaulted');
|
||||||
|
} finally {
|
||||||
|
rmSync(repo, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures > 0) {
|
||||||
|
console.error(`Instrument self-test: ${failures} failure(s).`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('Instrument self-test: sound.');
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// INV-002 proven at the interface stratum.
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
id: PM-001
|
||||||
|
titre: Processus d'essai
|
||||||
|
invariants:
|
||||||
|
- id: INV-001
|
||||||
|
enonce: Un essai vaut toujours, constaté ou non.
|
||||||
|
---
|
||||||
|
# PM-001 — le même identifiant, un autre énoncé : homonymie à exposer
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
id: PU-001
|
||||||
|
titre: Parcours d'essai
|
||||||
|
invariants:
|
||||||
|
- id: INV-001
|
||||||
|
enonce: Un essai ne vaut que constaté.
|
||||||
|
- id: INV-002
|
||||||
|
enonce: Nul ne franchit sans être nommé.
|
||||||
|
- id: INV-003
|
||||||
|
enonce: Ce que personne n'éprouve n'existe pas.
|
||||||
|
---
|
||||||
|
# PU-001 — corps sans importance pour l'instrument
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Réglage de la juridiction d'essai — la forme que toute juridiction fournit.
|
||||||
|
juridiction: essai
|
||||||
|
cadence_heures: 24
|
||||||
|
parcours: docs/parcours
|
||||||
|
strates:
|
||||||
|
- nom: domaine
|
||||||
|
due: true
|
||||||
|
chemins: ["src/main/java/**/Domain/**", "src/test/java/**/Domain/**"]
|
||||||
|
- nom: application
|
||||||
|
chemins: ["src/main/java/**/Application/**", "src/test/java/**/Application/**"]
|
||||||
|
- nom: interface
|
||||||
|
chemins: ["app/src/**"]
|
||||||
|
epreuves: ["src/test/**", "app/src/**/*.essai.*"]
|
||||||
|
sortie: docs/parcours/_mesures/coupe.yaml
|
||||||
|
journal_execution: docs/parcours/_mesures/derniere-execution.yaml
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Guard for INV-001 — names it, proves nothing.
|
||||||
|
class Rule {}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Proves INV-001 and INV-002 above the due stratum.
|
||||||
|
class FlowTest {}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Proves INV-001 at its due stratum.
|
||||||
|
class RuleTest {}
|
||||||
+6
-1
@@ -6,10 +6,15 @@
|
|||||||
"description": "Contrat d'échange inter-juridictions : grammaire source (grammar/) et enveloppe d'assertion (envelope/), sous un seul numéro de version (ADR-061/A1.2 §2.6, décision 6). Privé jusqu'à l'extraction publique décrite dans OPENING.md.",
|
"description": "Contrat d'échange inter-juridictions : grammaire source (grammar/) et enveloppe d'assertion (envelope/), sous un seul numéro de version (ADR-061/A1.2 §2.6, décision 6). Privé jusqu'à l'extraction publique décrite dans OPENING.md.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"conformance": "node envelope/conformance/run.mjs"
|
"conformance": "node envelope/conformance/run.mjs",
|
||||||
|
"instrument": "node instrument/run.mjs",
|
||||||
|
"instrument:selftest": "node instrument/selftest.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"ajv": "^8.17.0",
|
"ajv": "^8.17.0",
|
||||||
"ajv-formats": "^3.0.0"
|
"ajv-formats": "^3.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "2.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user