gardes du contrat / conformite (pull_request) Successful in 17s
Les trois corrections retenues de la revue extérieure d'openathle#275 — que j'avais amendées sur la branche d'une PR déjà fusionnée, donc jamais livrées : les champs disent ce qu'ils mesurent (nommee_par_parcours, _par_adr, _par_epreuve — un parcours peut porter la règle en prose sans nommer la classe) ; une absence n'est pas une règle (gardes_exclues, par motif — exclue = non observée) ; décider que non a un réceptacle (gardes_ecartees, registre de la juridiction, lu jamais écrit). Le journal porte ses motifs : la limite est dite là où la mesure s'écrit. La triade est mesurée : sans_epreuve. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
422 lines
20 KiB
JavaScript
422 lines
20 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 exclus = (Array.isArray(cfg.gardes_exclues) ? cfg.gardes_exclues : []).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));
|
|
// Le réceptacle du refus : ce que la juridiction a DÉCIDÉ d'écarter ou de
|
|
// différer ne reparaît pas chaque nuit. Sans lui, la liste n'est jamais un
|
|
// delta et le bruit devient un stresseur (ADR-061 §2.3). Le registre est un
|
|
// fichier de la juridiction ; l'instrument le lit, il ne l'écrit jamais.
|
|
const decisions = new Map();
|
|
if (typeof cfg.gardes_ecartees === 'string' && existsFileAt(repo, cfg.gardes_ecartees)) {
|
|
const reg = parseYaml(read(cfg.gardes_ecartees));
|
|
for (const d of Array.isArray(reg) ? reg : []) if (d && typeof d.nom === 'string') decisions.set(d.nom, d);
|
|
}
|
|
const out = [];
|
|
for (const f of files) {
|
|
if (f.isTest || !res.some((re) => re.test(f.path)) || exclus.some((re) => re.test(f.path))) continue;
|
|
const nom = f.path.split('/').pop().replace(/\.[^.]+$/, '');
|
|
const re = new RegExp(`\\b${escapeRe(nom)}\\b`);
|
|
const d = decisions.get(nom);
|
|
const differee = d?.decision === 'differe' && typeof d.jusqu_au === 'string' && d.jusqu_au >= new Date().toISOString().slice(0, 10);
|
|
out.push({
|
|
nom,
|
|
chemin: f.path,
|
|
strate: f.stratum,
|
|
// NOMMÉE, jamais « déclarée » : un parcours peut porter la règle en
|
|
// langue naturelle sans nommer la classe. L'instrument observe la
|
|
// nomination — c'est sa limite, dite dans le nom des champs.
|
|
nommee_par_parcours: re.test(parcoursText),
|
|
nommee_par_adr: re.test(adrText),
|
|
nommee_par_epreuve: tests.some((x) => re.test(x)),
|
|
...(d?.decision === 'ecarte' ? { decision: 'ecarte', motif: d.motif ?? null } : {}),
|
|
...(differee ? { decision: 'differe', jusqu_au: d.jusqu_au } : {}),
|
|
});
|
|
}
|
|
return out.sort((a, b) => a.nom.localeCompare(b.nom));
|
|
}
|
|
|
|
function existsFileAt(repo, p) {
|
|
const st = statSync(join(repo, p), { throwIfNoEntry: false });
|
|
return st !== undefined && st.isFile();
|
|
}
|
|
|
|
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: {
|
|
// Ce que la mesure regarde, dit tel quel : les motifs. Ce qui n'y
|
|
// correspond pas (require, objets-valeurs, contraintes SQL) est
|
|
// invisible — la limite est dans le journal, pas seulement au README.
|
|
motifs: cfg.gardes_nommees,
|
|
...(Array.isArray(cfg.gardes_exclues) && cfg.gardes_exclues.length ? { exclus: cfg.gardes_exclues } : {}),
|
|
total: r.gardes.length,
|
|
non_nommees_par_un_parcours: r.gardes.filter((g) => !g.nommee_par_parcours).length,
|
|
sans_epreuve: r.gardes.filter((g) => !g.nommee_par_epreuve).length,
|
|
ecartees: r.gardes.filter((g) => g.decision === 'ecarte').length,
|
|
differees: r.gardes.filter((g) => g.decision === 'differe').length,
|
|
// Les candidats du jour : non nommés par un parcours, ni écartés, ni différés.
|
|
candidats: r.gardes.filter((g) => !g.nommee_par_parcours && g.decision === undefined),
|
|
},
|
|
}),
|
|
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 cand = r.gardes.filter((g) => !g.nommee_par_parcours && g.decision === undefined);
|
|
console.log(` gardes nommées : ${r.gardes.length} · candidates (non nommées par un parcours, ni écartées) : ${cand.length}${cand.length ? ' — ' + cand.map((g) => g.nom).join(', ') : ''}`);
|
|
}
|
|
}
|
|
|
|
main();
|