diff --git a/instrument/README.md b/instrument/README.md index 8e97d05..594737b 100644 --- a/instrument/README.md +++ b/instrument/README.md @@ -46,6 +46,22 @@ jamais. La grossièreté se dit dans le réglage (en commentaire) : un rang absent de la correspondance est un rang que la juridiction ne sait pas instrumenter, pas un rang qui va de soi. +## Les gardes nommées — des candidats, jamais des constats (optionnel) + +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 porte +`gardes_nommees` (motifs de fichiers) et `adr` (où chercher les citations), +le journal d'exécution rend `gardes_nommees: { total, sans_declaration, +candidats }` — seuls ceux qu'aucun parcours ne cite sont listés, avec leur +strate et ce qui les cite (ADR, épreuve). L'instrument **mesure une existence, +il ne déclare rien** : déclarer un candidat (un invariant dans le parcours qui +le porte) ou décider que non reste à la juridiction. Rien ne devient un +constat, aucun état ne change de sens ; sans les clés, la section n'existe pas. + +Pourquoi si peu : c'est le plus petit instrument qui prouve que le code tient +des règles que nul n'a déclarées. Mesuré chez openathle le jour de son +écriture — huit exceptions de domaine, zéro citée par un parcours. + ## Ce qu'il écrit — chez la juridiction, jamais ailleurs - **`sortie`** : les constats, en **ajout seul**, et **seulement quand l'état diff --git a/instrument/run.mjs b/instrument/run.mjs index 93796d3..9d04ae9 100644 --- a/instrument/run.mjs +++ b/instrument/run.mjs @@ -177,6 +177,42 @@ function scanCitations(repo, files, ids) { 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))] @@ -297,7 +333,8 @@ function main() { 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 }); + const gardes = namedGuards(args.repo, cfg, files); + emit(args, cfg, { journeys, invariants: invariants.size, tally, fresh, meta, sortiePath, gardes }); } function emit(args, cfg, r) { @@ -313,6 +350,19 @@ function emit(args, cfg, r) { : {}), 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) { @@ -331,6 +381,10 @@ function emit(args, cfg, r) { 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(); diff --git a/instrument/selftest.mjs b/instrument/selftest.mjs index bbed6fc..105f48f 100644 --- a/instrument/selftest.mjs +++ b/instrument/selftest.mjs @@ -47,6 +47,21 @@ try { assert((byInv(first, 'INV-001')?.rangs_constates ?? []).join(',') === '4,3', 'INV-001 carries the third-party ranks its tuning declares, in stratum order'); assert(byInv(first, 'INV-003')?.rangs_constates === undefined, 'INV-003 (no locus) carries no ranks — nothing observed maps to nothing'); assert(JSON.stringify(journal().correspondance_rangs) === JSON.stringify({ domaine: 4, application: 3, interface: 1 }), 'journal publishes the full rank correspondence for consumers joining old findings'); + // Named guards — candidates, never findings: a domain exception nobody declared. + const g = journal().gardes_nommees; + assert(g && g.total === 1 && g.sans_declaration === 1, 'the fixture has one named domain guard, and no journey declares it'); + assert(g.candidats[0].nom === 'RuleViolatedException' && g.candidats[0].strate === 'domaine', 'the candidate carries its name and its stratum'); + assert(g.candidats[0].dans_parcours === false && g.candidats[0].dans_adr === true && g.candidats[0].eprouve === false, 'the candidate says where it is cited — ADR yes, journey no, test no'); + assert(!('candidats' in journal()) , 'candidates live under gardes_nommees, not at the journal root'); + // Backward compatibility: a tuning without the key gets no section at all. + // On its OWN copy of the fixture: a second run on `repo` would rewrite the + // journal and falsify the assertions that follow (caught by the self-test + // itself on 2026-09-21 — a guard that shares state with what it guards). + const repo2 = mkdtempSync(join(tmpdir(), 'instrument-selftest-compat-')); + cpSync(join(here, 'selftest/fixture'), repo2, { recursive: true }); + execFileSync(process.execPath, [join(here, 'run.mjs'), '--config', 'reglage-sans-gardes.yaml', '--repo', repo2], { encoding: 'utf8', stdio: 'pipe' }); + const journal2 = parseYaml(readFileSync(join(repo2, 'docs/parcours/_mesures/derniere-execution.yaml'), 'utf8')); + assert(!('gardes_nommees' in journal2), 'a tuning that does not ask for named guards changes nothing'); 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'); diff --git a/instrument/selftest/fixture/docs/adr/ADR-001-essai.md b/instrument/selftest/fixture/docs/adr/ADR-001-essai.md new file mode 100644 index 0000000..3052c9c --- /dev/null +++ b/instrument/selftest/fixture/docs/adr/ADR-001-essai.md @@ -0,0 +1,3 @@ +# ADR-001 — essai + +Le domaine lève `RuleViolatedException` quand la règle est violée. diff --git a/instrument/selftest/fixture/reglage-sans-gardes.yaml b/instrument/selftest/fixture/reglage-sans-gardes.yaml new file mode 100644 index 0000000..dbe8952 --- /dev/null +++ b/instrument/selftest/fixture/reglage-sans-gardes.yaml @@ -0,0 +1,19 @@ +# 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 + rang: 4 + chemins: ["src/main/java/**/Domain/**", "src/test/java/**/Domain/**"] + - nom: application + rang: 3 + chemins: ["src/main/java/**/Application/**", "src/test/java/**/Application/**"] + - nom: interface + rang: 1 + chemins: ["app/src/**"] +epreuves: ["src/test/**", "app/src/**/*.essai.*"] +sortie: docs/parcours/_mesures/coupe.yaml +journal_execution: docs/parcours/_mesures/derniere-execution.yaml + diff --git a/instrument/selftest/fixture/reglage.yaml b/instrument/selftest/fixture/reglage.yaml index 5de656d..cd4baf5 100644 --- a/instrument/selftest/fixture/reglage.yaml +++ b/instrument/selftest/fixture/reglage.yaml @@ -16,3 +16,7 @@ strates: epreuves: ["src/test/**", "app/src/**/*.essai.*"] sortie: docs/parcours/_mesures/coupe.yaml journal_execution: docs/parcours/_mesures/derniere-execution.yaml + +gardes_nommees: + - "src/main/java/**/Domain/**/*Exception.java" +adr: docs/adr diff --git a/instrument/selftest/fixture/src/main/java/x/Domain/RuleViolatedException.java b/instrument/selftest/fixture/src/main/java/x/Domain/RuleViolatedException.java new file mode 100644 index 0000000..25d5176 --- /dev/null +++ b/instrument/selftest/fixture/src/main/java/x/Domain/RuleViolatedException.java @@ -0,0 +1,6 @@ +package x.Domain; + +/** A named domain guard: a rule with a name and a throw — declared nowhere. */ +public class RuleViolatedException extends RuntimeException { + public RuleViolatedException(String why) { super(why); } +}