import { existsSync, readFileSync } from "node:fs"; import { fieldOf, tryParse } from "../readers/json.reader.ts"; import { judge, judgeBranch, unsuppliedReads } from "../validators/gate.validator.ts"; import { GENERATED_DIR } from "../constants/path.constants.ts"; import type { GateFixture } from "../fixtures/gate.fixture.ts"; import type { GateOutcome } from "../validators/gate.validator.ts"; import { discoverFixtures } from "../registries/fixture.registry.ts"; import { discoverRules } from "../registries/rule.registry.ts"; import { isObject } from "../predicates/schema.predicate.ts"; import { resolve } from "node:path"; export type { GateOutcome } from "../validators/gate.validator.ts"; export interface RunnerReport { readonly outcomes: readonly GateOutcome[]; readonly branches: readonly GateOutcome[]; readonly proven: number; readonly exempt: number; readonly untested: number; readonly failed: number; readonly branchesProven: number; readonly branchesOpen: number; readonly unfixturedKinds: readonly string[]; readonly kindVerdicts: Readonly>; readonly movedVerdicts: { readonly kinds: readonly string[]; readonly bound: string }; readonly treeLoadedReads: { readonly fixtures: readonly string[]; readonly bound: string }; readonly kindScope: { readonly declaredKinds: number; readonly rules: number; readonly bound: string }; } const GATE_REPORT = "gate.report.generated.json"; export const verdictKey = function verdictKey(rule: string, kind: string | undefined): string { return kind === undefined ? rule : `${rule}/${kind}`; }; const CLEAR_STATES = new Set(["proven", "exempt"]); export const worstOf = function worstOf(held: string | undefined, arriving: string): string { if (held === undefined) { return arriving; } if (!CLEAR_STATES.has(held)) { return held; } return arriving; }; const priorKindVerdicts = function priorKindVerdicts(repoRoot: string): Record { const path = resolve(repoRoot, GENERATED_DIR, GATE_REPORT); const parsed = existsSync(path) ? tryParse(readFileSync(path, "utf8")) : null; const held = parsed !== null && isObject(parsed.value) ? fieldOf(parsed.value, "kindVerdicts") : null; if (!isObject(held)) { return {}; } return Object.fromEntries( Object.entries(held).flatMap(([kind, state]) => (typeof state === "string" ? [[kind, state]] : [])), ); }; export const publishedKinds = function publishedKinds(repoRoot: string): string[] { return Object.keys(priorKindVerdicts(repoRoot)); }; type Registered = Awaited>["rules"][number]; interface RuleJudgement { readonly outcome: GateOutcome; readonly exposed: string[]; readonly verdicts: [string, GateOutcome["state"]][]; } const UNTESTED: Omit = { detail: "no fixture pair declares what this rule must fire on and must accept", state: "untested", }; const judgeRule = function judgeRule(registered: Registered, held: readonly GateFixture[], repoRoot: string): RuleJudgement { if (held.length === 0) { return { exposed: [], outcome: { ...UNTESTED, rule: registered.id }, verdicts: [] }; } const exposed = held.flatMap((fixture) => { const missing = unsuppliedReads(registered.declaration, fixture); return missing.length === 0 ? [] : [`${verdictKey(registered.id, fixture.kind)}: ${missing.join(", ")}`]; }); const judged = held.map((fixture) => ({ fixture, outcome: judge(registered.id, registered.declaration, repoRoot, fixture), })); const verdicts = judged.map(({ fixture, outcome }): [string, GateOutcome["state"]] => [ verdictKey(registered.id, fixture.kind), outcome.state, ]); const broken = judged.find(({ outcome }) => !CLEAR_STATES.has(outcome.state))?.outcome; const outcome = broken ?? { detail: judged.map((entry) => entry.outcome.detail).join(" · "), rule: registered.id, state: judged[0]?.outcome.state ?? "proven", }; return { exposed, outcome, verdicts }; }; const worstVerdicts = function worstVerdicts(entries: readonly [string, string][]): Record { const verdicts: Record = {}; for (const [key, state] of entries) { verdicts[key] = worstOf(verdicts[key], state); } return verdicts; }; const TREE_READ_NOTE = " · this kind's fixture leaves a declared read to the tree"; const movedOf = function movedOf( verdicts: Readonly>, prior: Readonly>, exposed: readonly string[], ): string[] { const exposedKinds = new Set(exposed.map((entry) => entry.slice(0, entry.indexOf(":")))); return Object.entries(verdicts).flatMap(([kind, state]) => { const was = prior[kind]; if (was === undefined || was === state) { return []; } const note = exposedKinds.has(kind) ? TREE_READ_NOTE : ""; return [`${kind}: ${was} → ${state}${note}`]; }); }; const countIn = function countIn(outcomes: readonly GateOutcome[], state: string): number { return outcomes.filter((outcome) => outcome.state === state).length; }; export const runGates = async function runGates(repoRoot: string): Promise { const registry = await discoverRules(repoRoot); const fixtures = await discoverFixtures(repoRoot); const declared = fixtures.gates; const judgements = registry.rules.map((registered) => judgeRule( registered, declared.filter((fixture) => fixture.rule === registered.id), repoRoot, ), ); const outcomes = judgements.map((judgement) => judgement.outcome); const exposed = judgements.flatMap((judgement) => judgement.exposed); const verdicts = worstVerdicts(judgements.flatMap((judgement) => judgement.verdicts)); const movedVerdicts = movedOf(verdicts, priorKindVerdicts(repoRoot), exposed); const branches = fixtures.branches.map((fixture) => judgeBranch(fixture)); const proven = countIn(outcomes, "proven"); const exempt = countIn(outcomes, "exempt"); const untested = countIn(outcomes, "untested"); const failed = outcomes.length - proven - exempt - untested; const fixtured = new Set( declared.filter((one) => one.kind !== undefined).map((one) => `${one.rule}/${String(one.kind)}`), ); const declaredKinds = registry.rules .flatMap((entry) => entry.declaration.kinds.map((kind) => `${entry.id}/${kind}`)) .toSorted((left, right) => left.localeCompare(right, "en")); const unfixturedKinds = declaredKinds.filter((kind) => !fixtured.has(kind)); const branchesProven = branches.filter((outcome) => outcome.state === "proven").length; return { branches, branchesOpen: branches.length - branchesProven, branchesProven, exempt, failed, kindScope: { bound: "every kind is DECLARED by the check that emits it and read from the registry, so the population " + "has no spelling to miss, no quote style to prefer, no delegation depth to bound and no " + "attribution to guess. THE PRIOR MECHANISM RECOVERED THIS SET BY MATCHING SOURCE TEXT, and a " + "check composing its kind as a template literal matched none of its forms — so the subtraction " + "that reports an unfixtured kind ranged over an empty population for those checks and could " + "never report a gap for one, publishing a zero whose own greenness was the evidence that what it " + "measured was working. A DECLARATION CAN STILL BE WRONG and the direction is the honest one: a " + "kind a check emits and does not declare is unproven and unreported, which is the same silence " + "as before for that one kind, while the population is now stated per check rather than inferred " + "for all of them. What closes that residue is the declaration and the emission sharing one " + "operand, which is a further change on the emitting sites rather than a bound on this one", declaredKinds: declaredKinds.length, rules: registry.rules.length, }, kindVerdicts: verdicts, movedVerdicts: { bound: "each entry names a kind whose verdict DIFFERS from the previous run's. The prior verdict is retained " + "BY this comparison and consumed by it, which is what makes it an operand rather than a diary — remove " + "the comparison and the value stops being written. IT IS THE DELIVERY HALF OF THE TREE-LOADED-READS " + "POPULATION: that list is correct and reaches only a reader who already suspects what it would tell " + "them, because the defect it describes is a proof changing verdict when ANOTHER party edits an " + "unrelated file — so the reader who needs it has just been handed a result they cannot explain and no " + "signal saying this is that case. A moved verdict names the case AT THE MOMENT IT OCCURS, and where the " + "moved kind also leaves a declared read to the tree the entry says so, which is the explanation beside " + "the signal. Movement is also the discriminator the artifact cannot carry: deliberate reliance is " + "stable and a forgotten sibling is exactly what moves when unrelated work lands", kinds: movedVerdicts, }, outcomes, proven, treeLoadedReads: { bound: "each entry names a fixture whose rule declares a read the fixture does not supply, so the certifier " + "loads it FROM THE REAL TREE and that half of the proof ranges over content the fixture never declared. " + "THIS IS PUBLISHED RATHER THAN FAILED, and the reason is measured rather than assumed: the same " + "comparison as a failing check names most of the fixture population, because a rule that DERIVES its " + "schema from a contract surface is meant to be proven against the real contract — so a red here would " + "fire on the correct pattern and the incorrect one alike, which is a check that punishes the right " + "answer. What it cannot separate is a fixture relying on a contract deliberately from one that supplied " + "a sibling and forgot this member, and nothing in either artifact distinguishes them. The measured harm " + "is real: a fixture in this shape changes verdict when unrelated work lands, so its failure is triggered " + "by another party's edit and presents as a flake with no attributable cause. The list is the population " + "a reader checks when a proof moves for no reason it can find", fixtures: exposed, }, unfixturedKinds, untested, }; };