diff --git a/instrument/README.md b/instrument/README.md index 8e97d05..08c19c8 100644 --- a/instrument/README.md +++ b/instrument/README.md @@ -46,6 +46,35 @@ 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 et un `throw` — et souvent +ni épreuve ni déclaration. Si le réglage porte `gardes_nommees` (motifs de +fichiers) et `adr` (où chercher les citations), le journal rend +`gardes_nommees: { motifs, total, non_nommees_par_un_parcours, sans_epreuve, +ecartees, differees, candidats }`. Trois précautions, chacune répondant à une +faute possible : + +- **« Nommée », jamais « déclarée ».** Un parcours peut porter la règle en + langue naturelle sans nommer la classe ; l'instrument n'observe que la + nomination, et les champs le disent (`nommee_par_parcours`, `_par_adr`, + `_par_epreuve`). Absent ≠ non déclaré. +- **Une exception n'est pas un invariant.** Une absence (`NotFound`) n'est une + règle pour personne : la juridiction l'exclut par motif (`gardes_exclues`). +- **Décider que non a un réceptacle.** Un registre de la juridiction + (`gardes_ecartees` : `nom`, `decision: ecarte | differe`, `motif`, `jusqu_au`), + que l'instrument **lit et n'écrit jamais** : l'écarté sort des candidats et + reste dans le compte ; le différé revient à sa date. + +L'instrument **mesure une existence, il ne déclare rien** ; rien ne devient un +constat, aucun état ne change de sens ; sans les clés, la section n'existe pas. +Sa limite est dans le journal (`motifs`) : ce qui ne porte pas le motif — +`require`, objets-valeurs, contraintes de schéma — lui est invisible. + +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..db0a4f7 100644 --- a/instrument/run.mjs +++ b/instrument/run.mjs @@ -177,6 +177,64 @@ 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 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))] @@ -297,7 +355,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 +372,28 @@ 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: { + // 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) { @@ -331,6 +412,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 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(); diff --git a/instrument/selftest.mjs b/instrument/selftest.mjs index bbed6fc..fc00798 100644 --- a/instrument/selftest.mjs +++ b/instrument/selftest.mjs @@ -10,7 +10,7 @@ * * Exit codes: 0 sound, 1 broken. A refusal names its cause. */ -import { cpSync, mkdtempSync, readFileSync, rmSync, unlinkSync, existsSync } from 'node:fs'; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, existsSync, writeFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; @@ -47,6 +47,36 @@ 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 NAMES + // in a journey. Fields say « named », not « declared » : a journey may hold + // the rule in prose without naming the class — that is the instrument's limit. + const g = journal().gardes_nommees; + // Excluded by pattern = not observed at all: the NotFound one is neither counted nor listed. + assert(g && g.total === 1 && g.non_nommees_par_un_parcours === 1, 'the fixture has one observed named guard (the NotFound one is excluded), and no journey names it'); + assert(JSON.stringify(g.motifs) === JSON.stringify(['src/main/java/**/Domain/**/*Exception.java']), 'the journal says which patterns it looked at — the lamppost is written down'); + assert(g.candidats.length === 1 && g.candidats[0].nom === 'RuleViolatedException', 'the NotFound one is excluded by pattern; the other is a candidate'); + const c = g.candidats[0]; + assert(c.strate === 'domaine' && c.nommee_par_parcours === false && c.nommee_par_adr === true && c.nommee_par_epreuve === false, 'the candidate says who names it — ADR yes, journey no, test no'); + assert(g.sans_epreuve === 1, 'the triad is measured, not asserted: the observed guard lacks a test'); + assert(g.ecartees === 0 && g.differees === 0, 'no decision register yet: nothing set aside'); + // 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'); + // The refusal register: what the jurisdiction set aside does not come back + // every night. Read by the instrument, never written by it. + const repo3 = mkdtempSync(join(tmpdir(), 'instrument-selftest-registre-')); + cpSync(join(here, 'selftest/fixture'), repo3, { recursive: true }); + mkdirSync(join(repo3, 'docs/parcours/_meta'), { recursive: true }); + writeFileSync(join(repo3, 'docs/parcours/_meta/gardes-ecartees.yaml'), "- nom: RuleViolatedException\n decision: ecarte\n motif: une plomberie, pas une règle\n"); + execFileSync(process.execPath, [join(here, 'run.mjs'), '--config', 'reglage.yaml', '--repo', repo3], { encoding: 'utf8', stdio: 'pipe' }); + const j3 = parseYaml(readFileSync(join(repo3, 'docs/parcours/_mesures/derniere-execution.yaml'), 'utf8')).gardes_nommees; + assert(j3.total === 1 && j3.ecartees === 1 && j3.candidats.length === 0, 'a guard set aside leaves the candidates and stays in the count'); 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..c2a04ae 100644 --- a/instrument/selftest/fixture/reglage.yaml +++ b/instrument/selftest/fixture/reglage.yaml @@ -16,3 +16,10 @@ 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" +gardes_exclues: + - "**/*NotFoundException.java" +gardes_ecartees: docs/parcours/_meta/gardes-ecartees.yaml +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); } +} diff --git a/instrument/selftest/fixture/src/main/java/x/Domain/ThingNotFoundException.java b/instrument/selftest/fixture/src/main/java/x/Domain/ThingNotFoundException.java new file mode 100644 index 0000000..2f0f947 --- /dev/null +++ b/instrument/selftest/fixture/src/main/java/x/Domain/ThingNotFoundException.java @@ -0,0 +1,4 @@ +package x.Domain; + +/** An absence is nobody's business rule: excluded by pattern. */ +public class ThingNotFoundException extends RuntimeException {}