From 76d8c2de03513bd1ef230e09869269df1defac60 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sun, 25 Oct 2026 20:41:08 +0000 Subject: [PATCH 1/5] feat(ci): write the PR report in Simplified Technical English; release 1.10.1 The report used Coherence's own words (spec gap, entrance, trust, chokepoint, guard, invariant, crossing, baseline) with no definition. It now says in one sentence what Coherence is, names each kind of finding by what it means to the reader (spec file error, broken rule, unchecked input, bug that came back), uses short active sentences, gives the fix as a list, shows spec syntax only as code to type, and leaves out sections with nothing in them. The plain closing steps sit beside CLOSE_WAYS in gaps.ts. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 4189fcb8-6fd5-428d-8c5f-4314d28e4dac --- .coherence/runs/pr-report-ste.a4e932f1.jsonl | 3 + mods/coherence/.claude-plugin/plugin.json | 2 +- package-lock.json | 4 +- package.json | 2 +- plugins/declared/plugin.json | 2 +- plugins/python/plugin.json | 2 +- plugins/rust/plugin.json | 2 +- plugins/typescript/plugin.json | 2 +- src/lifecycle/Lifecycle.spec.md | 3 +- src/lifecycle/ci-report.ts | 107 +++++++++++-------- src/lifecycle/ci.test.ts | 59 +++++----- src/readings/scope/gaps.ts | 7 ++ 12 files changed, 110 insertions(+), 85 deletions(-) create mode 100644 .coherence/runs/pr-report-ste.a4e932f1.jsonl diff --git a/.coherence/runs/pr-report-ste.a4e932f1.jsonl b/.coherence/runs/pr-report-ste.a4e932f1.jsonl new file mode 100644 index 00000000..074b56cf --- /dev/null +++ b/.coherence/runs/pr-report-ste.a4e932f1.jsonl @@ -0,0 +1,3 @@ +{"at":"2026-10-25T20:39:57.983Z","session":"pr-report-ste","agent":"claude","binding":"none: this session owns no active work order","commit":"01235ab","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":35568,"load":{"average":0.55,"cores":8},"batch":{"ms":35060},"invariants":[{"component":"src/lifecycle","name":"the CI report says what each finding means and what to do","form":"totality oracle","verdict":"fail","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":35060,"reason":"2 of 17 tests under \"ci report says what each finding means and what to do\" failed: ci report says what each finding means and what to do nothing; ci report says what each finding means and what to do","testMs":394,"mode":"batched","state":"structural defect","enforces":["ci report says what each finding means and what to do"]}]} +{"kind":"refutation","at":"2026-10-25T20:39:58.478Z","session":"pr-report-ste","agent":"claude","component":"src/lifecycle","name":"the CI report says what each finding means and what to do","form":"totality oracle","broke":"dropped the sentence that says what Coherence is from ci-report.ts","verdict":"fail","reason":"2 of 17 tests under \"ci report says what each finding means and what to do\" failed: ci report says what each finding means and what to do nothing; ci report says what each finding means and what to do","commit":"01235ab","dirty":true} +{"at":"2026-10-25T20:40:34.774Z","session":"pr-report-ste","agent":"claude","binding":"none: this session owns no active work order","commit":"01235ab","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":35621,"load":{"average":0.81,"cores":8},"batch":{"ms":35070},"invariants":[{"component":"src/lifecycle","name":"the CI report says what each finding means and what to do","form":"totality oracle","verdict":"pass","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":35070,"reason":"17 tests under \"ci report says what each finding means and what to do\" passed in one invocation of node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-reporter=./src/enforcement/node-test-reporter.ts --test-reporter-destination= --test-name-pattern=<1 names> src/**/*.test.ts src/*.e2e.ts","testMs":441,"mode":"batched","state":"invariant","enforces":["ci report says what each finding means and what to do"]}]} diff --git a/mods/coherence/.claude-plugin/plugin.json b/mods/coherence/.claude-plugin/plugin.json index c1b76ba8..571660da 100644 --- a/mods/coherence/.claude-plugin/plugin.json +++ b/mods/coherence/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "coherence", - "version": "1.10.0", + "version": "1.10.1", "description": "Coherence's hooks through one warm process per session, with its verdicts where the user reads them", "author": { "name": "PostHog" }, "types": "./types/index.d.ts" diff --git a/package-lock.json b/package-lock.json index ffaca231..7ddf1bd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@posthog/coherence", - "version": "1.10.0", + "version": "1.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@posthog/coherence", - "version": "1.10.0", + "version": "1.10.1", "license": "MIT", "os": [ "darwin", diff --git a/package.json b/package.json index 6aa5b739..3e28f291 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@posthog/coherence", - "version": "1.10.0", + "version": "1.10.1", "description": "A prosthetic for proprioception: specs, invariants, and the readings that keep an agent balanced.", "type": "module", "license": "MIT", diff --git a/plugins/declared/plugin.json b/plugins/declared/plugin.json index d651cb8a..23be55ae 100644 --- a/plugins/declared/plugin.json +++ b/plugins/declared/plugin.json @@ -1,6 +1,6 @@ { "name": "declared", - "version": "1.10.0", + "version": "1.10.1", "pluginApi": 2, "provides": { "declaredLanguages": true diff --git a/plugins/python/plugin.json b/plugins/python/plugin.json index c8ee2d67..1ab82d39 100644 --- a/plugins/python/plugin.json +++ b/plugins/python/plugin.json @@ -1,6 +1,6 @@ { "name": "python", - "version": "1.10.0", + "version": "1.10.1", "pluginApi": 2, "provides": { "language": "python" diff --git a/plugins/rust/plugin.json b/plugins/rust/plugin.json index bb2ffeb4..f4be9eff 100644 --- a/plugins/rust/plugin.json +++ b/plugins/rust/plugin.json @@ -1,6 +1,6 @@ { "name": "rust", - "version": "1.10.0", + "version": "1.10.1", "pluginApi": 2, "provides": { "language": "rust" diff --git a/plugins/typescript/plugin.json b/plugins/typescript/plugin.json index d6b107e1..63070904 100644 --- a/plugins/typescript/plugin.json +++ b/plugins/typescript/plugin.json @@ -1,6 +1,6 @@ { "name": "typescript", - "version": "1.10.0", + "version": "1.10.1", "pluginApi": 2, "provides": { "language": "typescript" diff --git a/src/lifecycle/Lifecycle.spec.md b/src/lifecycle/Lifecycle.spec.md index 36ee5b99..c7886fd1 100644 --- a/src/lifecycle/Lifecycle.spec.md +++ b/src/lifecycle/Lifecycle.spec.md @@ -1283,13 +1283,14 @@ The gyroscope delivered through harness events: orient at start, regulate at sto because: in the comment mode, findings are reported and never fail the job, while a run that never happened is never mistaken for a clean one; a report read from the latest verdicts instead of the session's run would show a pass from another branch refuted: buildReport read every run in each project instead of the CI session's, so another session's later pass hid the failing verdict -> "ci report carries each project's problems, chokepoint verdicts, spec gaps and guard failures as Markdown, and fails only when Coherence did not finish" went red in ci.test.ts on its own assertion; restored byte for byte, green batched and alone (2026-10-09) kinds: none -- the CI report says what each finding means and what to do: Under its counts the report says in plain words whether there is anything to fix, never claiming that when Coherence did not finish or a project's spec gaps were not read; it says that findings do not fail the check and can come from files the pull request did not change; each section with a finding says what that kind of finding means and how to fix it, a spec gap section says how many more the adoption baseline holds and why they are not listed, and a guard failure section says it carries no changed-file mark. Given --base, or else in a GitHub pull request run the pull request's fixed base commit from the triggering event, falling back to the base branch GITHUB_BASE_REF names, it says how many findings are in files changed since that commit, or why it could not tell — saying so without claiming none are when only some projects' changes were read — and marks each one, including a registry problem in a changed top-level config and a failing chokepoint attributed by its recorded files as well as its bypasses. +- the CI report says what each finding means and what to do: Under its counts the report says in plain words whether there is anything to fix, never claiming that when Coherence did not finish or a project's spec gaps were not read; it says that findings do not fail the check and can come from files the pull request did not change; it says in one sentence what Coherence is; it names each kind of finding by what it means to the reader (a spec file error, a broken rule, an unchecked input, a bug that came back) in short active sentences with the fix as a list, shows spec syntax only as code to type, and leaves out a section with nothing in it; each section with a finding says what that kind of finding means and how to fix it, an unchecked input section says how many more the adoption baseline holds and why they are not listed, and a guard failure section says it carries no changed-file mark. Given --base, or else in a GitHub pull request run the pull request's fixed base commit from the triggering event, falling back to the base branch GITHUB_BASE_REF names, it says how many findings are in files changed since that commit, or why it could not tell — saying so without claiming none are when only some projects' changes were read — and marks each one, including a registry problem in a changed top-level config and a failing chokepoint attributed by its recorded files as well as its bypasses. over: a report with nothing to fix, one Coherence could not finish or read every gap for, a spec problem, a failing chokepoint with a bypass and one attributed only by its own recorded file, a guard failure, an open spec gap beside one the adoption baseline holds, a spec problem and a gap in files the pull request changed, a registry problem in a changed config, findings in none it changed, a project read beside one that was not, a base git cannot diff against at a project or at the registry top, a file name git would quote, and --base, an event's fixed base commit, GITHUB_BASE_REF and neither read from a real repository via: ci report says what each finding means and what to do because: an adopter read a report of spec problems, spec gaps, traced controls and an adoption baseline and could not tell what any of it meant, whether the pull request caused it, or what to change; a report a reader cannot act on is noise on every pull request refuted: put back the old spec gap words in ci-report.ts, "no traced control" for what the gap means -> "ci report says what each finding means and what to do" went red in ci.test.ts on the spec gap case; restored byte for byte, green (2026-10-25) refuted: made the changed-file mark in ci-report.ts never mark a finding -> "ci report says what each finding means and what to do" went red in ci.test.ts, 4 of 10 cases; restored byte for byte, green (2026-10-25) refuted: dropped the GITHUB_BASE_REF default in ci-report.ts -> "ci report says what each finding means and what to do" went red in ci.test.ts on the base branch case; restored byte for byte, green (2026-10-25) + refuted: dropped the sentence that says what Coherence is from ci-report.ts -> "ci report says what each finding means and what to do" went red in ci.test.ts, 2 of 17 cases; restored byte for byte, green (2026-10-25) kinds: none - a closure mirror is named at the edit that breaks it: At PostToolUse, a write to a file in the value-import closure a declared mirror names is read again, and each file the closure now reaches that the list does not name is added to the edit's context as advice, never a block, naming the file that imports it and the list's file and line; a type-only import adds nothing, and the file the closure grew by is watched from then on. over: a fixture of the b37627e shape: a list of the page's modules, the closure of page.ts, a type-only import added to a closure file, then a value import of a file the list omits diff --git a/src/lifecycle/ci-report.ts b/src/lifecycle/ci-report.ts index 9b31144b..e3a82c71 100644 --- a/src/lifecycle/ci-report.ts +++ b/src/lifecycle/ci-report.ts @@ -17,7 +17,7 @@ import { registryOf, registryProblems } from "../adapters/project-config.ts"; import { loadRuns, type RunEntry, type RunRecord } from "../enforcement/record.ts"; import type { Io } from "../journal/cli.ts"; import { readComponentInterfaces } from "../readings/scope/component-interfaces.ts"; -import { CLOSE_WAYS, currentGaps, gapsOf, readAndRecord, readGapBaseline, structureState, type Gap } from "../readings/scope/gaps.ts"; +import { CLOSE_STEPS, currentGaps, gapsOf, readAndRecord, readGapBaseline, structureState, type Gap } from "../readings/scope/gaps.ts"; import { flowOf } from "../readings/scope/structure-flow.ts"; import { changedSince } from "../observation/observed.ts"; import { loadSpecModel, type SpecModel } from "../spec/model.ts"; @@ -99,10 +99,15 @@ function bullet(e: RunEntry): string { return e.component === "." ? e.name : `${e.component}/${e.name}`; } -const MARK: Record = { pass: "✓", fail: "✕", "not run": "○" }; +const MARK = { fail: "✕", "not run": "○" } as const; const CHANGED_HERE = " · **changed in this pull request**"; +/** A section's bold title, with its count when it lists findings. */ +function title(name: string, count?: number): string { + return count === undefined ? `**${name}:**` : `**${name} (${count})**`; +} + /** One project's section, and why its part did not finish. */ function projectSection(p: ProjectReport, session: string, heading: boolean): { lines: string[]; unfinished: string[]; touched: number } { const lines: string[] = []; @@ -115,64 +120,69 @@ function projectSection(p: ProjectReport, session: string, heading: boolean): { return CHANGED_HERE; }; const where = p.name === "." ? "" : ` in ${p.name}`; - const c = p.model.counts; if (heading) lines.push(`### ${cell(p.name)}`, ""); - lines.push(`Spec size: ${plural(c.components, "component")}, ${plural(c.bullets, "bullet")} (${plural(c.invariants, "invariant")}, ${plural(c.requirements, "requirement")})`, ""); if (p.model.problems.length > 0) { - lines.push(`**Spec problems** (spec --check${where}): lines Coherence cannot read as written. Fix each one, then run \`coherence spec --check\` until it finds 0 problems.`, ""); + const runIn = p.name === "." ? "" : ` in \`${p.name}\``; + lines.push(title("Spec file errors", p.model.problems.length), "", `Coherence cannot read these lines in the spec files. Fix each line. Then run \`npx coherence spec --check\`${runIn} again.`, ""); for (const problem of p.model.problems) lines.push(`- \`${problem.file}:${problem.line}\` ${cell(problem.message)}${mark(problem.file)}`); lines.push(""); } const entries = chokepointEntries(p.runs); if (p.runs.length === 0) { - unfinished.push(`no chokepoint run of session ${session}${where}`); - lines.push(`**Chokepoints:** no run of session ${cell(session)} was recorded${where}, so no chokepoint was checked.`, ""); - } else if (entries.length === 0) { - lines.push("**Chokepoints:** none declared.", ""); + unfinished.push(`no check run was recorded for session ${session}${where}`); + lines.push(`${title("Rules")} Coherence has no record of a check run for session ${cell(session)}${where}, so it did not check the rules.`, ""); } else { - const fails = entries.filter((e) => e.verdict === "fail").length; + const fails = entries.filter((e) => e.verdict === "fail"); const notRun = entries.filter((e) => e.verdict === "not run"); - lines.push(`**Chokepoints:** ${entries.length - fails - notRun.length} pass, ${fails} fail, ${notRun.length} not run`, ""); - if (fails > 0) + if (fails.length > 0) { lines.push( - "A chokepoint fails when code reaches what it protects without going through it — for example, code that reads a table directly instead of going through the one function meant to guard it. Send each bypass below through the chokepoint, or fix the spec if the rule is wrong.", + title("Broken rules", fails.length), + "", + "Each of these rules says that some code or data must only be used through one function. The code below uses it without that function. Change the code to use the function. If the rule is wrong, change the rule in the spec.", "", ); - for (const e of [...entries].sort((a, b) => order(a) - order(b))) { - lines.push(`- ${MARK[e.verdict]} \`${bullet(e)}\` ${e.grade ?? e.verdict}: ${cell(e.reason)}${e.verdict === "fail" ? mark(...e.files, ...e.bypasses.map((b) => b.file)) : ""}`); - for (const b of e.bypasses.slice(0, BYPASSES)) lines.push(` - bypass \`${b.file}:${b.line}\` in ${cell(b.symbol)}`); - if (e.bypasses.length > BYPASSES) lines.push(` - and ${e.bypasses.length - BYPASSES} more; run --form chokepoint names them all`); + for (const e of fails) { + lines.push(`- ${MARK.fail} \`${bullet(e)}\`: ${cell(e.reason)}${mark(...e.files, ...e.bypasses.map((b) => b.file))}`); + for (const b of e.bypasses.slice(0, BYPASSES)) lines.push(` - used without the function at \`${b.file}:${b.line}\`, in ${cell(b.symbol)}`); + if (e.bypasses.length > BYPASSES) lines.push(` - and ${e.bypasses.length - BYPASSES} more. To see all of them, run \`npx coherence run --form chokepoint\`.`); + } + lines.push(""); } - lines.push(""); - for (const e of notRun) unfinished.push(`chokepoint ${bullet(e)}${where} was not run: ${e.reason}`); + if (notRun.length > 0) { + lines.push(title("Rules Coherence could not check", notRun.length), ""); + for (const e of notRun) lines.push(`- ${MARK["not run"]} \`${bullet(e)}\`: ${cell(e.reason)}`); + lines.push(""); + } + for (const e of notRun) unfinished.push(`rule ${bullet(e)}${where} was not checked: ${e.reason}`); } if ("unread" in p.gaps) { - lines.push(`**Spec gaps:** not read: ${cell(p.gaps.unread)}.`, ""); + lines.push(`${title("Unchecked inputs")} Coherence could not trace the code: ${cell(p.gaps.unread)}.`, ""); } else { const open = p.gaps.gaps.filter((g) => !p.baselined.has(`${g.component}\u0000${g.name}`)); const held = p.gaps.gaps.length - open.length; - const takes = open.length === 1 ? "takes" : "take"; - const was = held === 1 ? "was" : "were"; - lines.push(`**Spec gaps:** ${plural(open.length, "entrance")} ${takes} input from outside this code (trust outside or unknown), and the spec names no check on that input.${held === 0 ? "" : ` ${held} more ${was} already open when Coherence was adopted, so ${held === 1 ? "it is" : "they are"} not listed.`}`, ""); - for (const g of open) lines.push(`- \`${g.specPath}\` entrance **${cell(g.name)}** (trust ${cell(g.trust.join(", ") || "unknown")}), route: ${cell(g.route.stops.join(" -> "))}${mark(g.file, g.specPath)}`); - if (open.length > 0) lines.push("", `To close a gap, write in its spec what checks that input. In spec terms: ${cell(CLOSE_WAYS)}. \`coherence scaffold control ""\` proposes the line.`); - lines.push(""); + if (open.length > 0) { + const older = held === 0 ? "" : held === 1 ? " 1 more place was already like this when Coherence was added. This list does not show it." : ` ${held} more places were already like this when Coherence was added. This list does not show them.`; + lines.push(title("Unchecked inputs", open.length), "", `Input from outside this code comes in at these places. The spec does not say what checks that input.${older}`, ""); + for (const g of open) { + const from = g.trust.length === 0 ? "The spec does not say where the input comes from." : `The input comes from: ${cell(g.trust.join(", "))}.`; + lines.push(`- **${cell(g.name)}** in \`${g.specPath}\`. ${from}${mark(g.file, g.specPath)}`); + } + lines.push("", "To close one of these, find the place by its name in the `## entrances` section of its spec file. Add one line under it:", "", ...CLOSE_STEPS.map((step) => `- ${step}`), "", 'To get the line to add, run `npx coherence scaffold control ""`.', ""); + } } const failures = p.model.defects?.guardFailures ?? []; if (failures.length > 0) { lines.push( - `**Guard failures:** ${failures.length}`, + title("Bugs that came back", failures.length), "", - "A kind of bug came back, even though a guard was meant to stop it. Check why each guard below let it through.", - "", - "These have no file to check against this pull request's changes, so none of them ever carry the changed-in-this-pull-request mark.", + "A guard is a rule that was added to stop one kind of bug. These bugs came back after their guard was added. Find out why the guard did not stop each one. Coherence cannot tell if this pull request caused them.", "", ); - for (const g of failures) lines.push(`- ${g.id} in class ${cell(g.class)}, which ${cell(g.guard)} has guarded since ${g.resolution} closed ${g.guardedBy}`); + for (const g of failures) lines.push(`- \`${g.id}\`: kind \`${cell(g.class)}\`. Guard: \`${cell(g.guard)}\`, added when \`${g.resolution}\` closed \`${g.guardedBy}\`.`); lines.push(""); } return { lines, unfinished, touched }; @@ -181,32 +191,27 @@ function projectSection(p: ProjectReport, session: string, heading: boolean): { /** How many bypass sites one verdict lists before it counts the rest. */ const BYPASSES = 5; -/** Failures first, then what was not run, then passes. */ -function order(e: RunEntry): number { - return e.verdict === "fail" ? 0 : e.verdict === "not run" ? 1 : 2; -} - -/** Whether there is anything to fix, and which findings this pull request's own files hold, when the report knows them. */ /** Where the report read changed files: each project, and the registry top when it has problems of its own. */ interface ChangedWhere { where: string; changed: ChangedRead | undefined; } +/** Whether there is anything to fix, and which findings this pull request's own files hold, when the report knows them. */ function findingsLead(projects: readonly ProjectReport[], reads: readonly ChangedWhere[], findings: number, touched: number, unfinished: readonly string[]): string[] { if (findings === 0) { - if (unfinished.length > 0 || projects.some((p) => "unread" in p.gaps)) return ["Coherence could not check everything, so this might not list every finding.", ""]; + if (unfinished.length > 0 || projects.some((p) => "unread" in p.gaps)) return ["Coherence could not check everything. This list can be incomplete.", ""]; return ["Nothing to fix.", ""]; } const changed = reads.filter((r) => r.changed !== undefined); - if (changed.length === 0) return ["These findings do not fail this check. Some can come from files this pull request did not change.", ""]; - const lines = ["These findings do not fail this check.", ""]; + if (changed.length === 0) return ["These findings do not make this check fail. Some findings can come from files that this pull request does not change.", ""]; + const lines = ["These findings do not make this check fail.", ""]; const allRead = changed.every((r) => "files" in r.changed!); - for (const r of changed) if ("unread" in r.changed!) lines.push(`Coherence could not tell which files this pull request changed${r.where}: ${cell(r.changed.unread)}.`, ""); + for (const r of changed) if ("unread" in r.changed!) lines.push(`Coherence could not find the files that this pull request changes${r.where}: ${cell(r.changed.unread)}.`, ""); if (changed.some((r) => "files" in r.changed!)) { - if (touched === 0) lines.push(allRead ? "None of these findings are in files this pull request changed." : "None of the findings Coherence could check are in files this pull request changed.", ""); - else if (touched === 1) lines.push("1 of these findings is in a file this pull request changed. It is marked below.", ""); - else lines.push(`${touched} of these findings are in files this pull request changed. They are marked below.`, ""); + if (touched === 0) lines.push(allRead ? "None of these findings are in files that this pull request changes. You do not need to fix them in this pull request." : "None of the findings that Coherence could check are in files that this pull request changes.", ""); + else if (touched === 1) lines.push("1 finding is in a file that this pull request changes. It has the mark **changed in this pull request**.", ""); + else lines.push(`${touched} findings are in files that this pull request changes. They have the mark **changed in this pull request**.`, ""); } return lines; } @@ -216,6 +221,9 @@ function registryMark(problem: { file: string }, changed: ChangedRead | undefine return changedFiles(changed).has(problem.file) ? CHANGED_HERE : ""; } +/** What Coherence is, in one place, for a reader who has not met it. */ +const ABOUT = "Coherence compares the code with its spec files (`*.spec.md`). A spec file lists the rules for one part of the code."; + /** The report over the projects, with the registry's own problems when the report ran at its top. */ export function renderReport( projects: readonly ProjectReport[], @@ -238,17 +246,22 @@ export function renderReport( const gaps = projects.reduce((n, p) => n + ("gaps" in p.gaps ? p.gaps.gaps.filter((g) => !p.baselined.has(`${g.component}\u0000${g.name}`)).length : 0), 0); const guards = projects.reduce((n, p) => n + (p.model.defects?.guardFailures.length ?? 0), 0); const head = [REPORT_MARKER, "## Coherence", ""]; - head.push(`**${plural(problems, "spec problem")} · ${plural(fails, "failing chokepoint")} · ${plural(gaps, "spec gap")} · ${plural(guards, "guard failure")}**${projects.length > 1 ? ` across ${plural(projects.length, "project")}` : ""}`, ""); + head.push( + `**${plural(problems, "spec file error")} · ${plural(fails, "broken rule")} · ${plural(gaps, "unchecked input")} · ${plural(guards, "bug that came back", "bugs that came back")}**${projects.length > 1 ? ` in ${plural(projects.length, "project")}` : ""}`, + "", + ABOUT, + "", + ); const reads: ChangedWhere[] = projects.map((p) => ({ where: p.name === "." ? "" : ` in ${cell(p.name)}`, changed: p.changed })); if (registry !== undefined && registry.problems.length > 0) reads.push({ where: " at the registry top", changed: registry.changed }); head.push(...findingsLead(projects, reads, problems + fails + gaps + guards, touched, unfinished)); if (unfinished.length > 0) { - head.push("> [!WARNING]", "> Coherence did not finish, so this report is incomplete and the check fails:"); + head.push("> [!WARNING]", "> Coherence did not finish. This report is not complete, and the check fails:"); for (const line of unfinished) head.push(`> - ${cell(line)}`); head.push(""); } if (registry !== undefined && registry.problems.length > 0) { - head.push("**Registry problems**", ""); + head.push(title("Project list errors", registry.problems.length), "", "Coherence cannot read the list of projects in `coherence.config.json`. Fix each line below.", ""); registry.problems.forEach((problem, i) => head.push(`- \`${problem.file}\` ${cell(problem.message)}${registryMarks[i]}`)); head.push(""); } diff --git a/src/lifecycle/ci.test.ts b/src/lifecycle/ci.test.ts index 76c60e72..5b48a0aa 100644 --- a/src/lifecycle/ci.test.ts +++ b/src/lifecycle/ci.test.ts @@ -280,23 +280,23 @@ test("ci report carries each project's problems, chokepoint verdicts, spec gaps const report = await buildReport(top, "ci-7", gaps); const md = report.markdown; assert.ok(md.startsWith(`${REPORT_MARKER}\n## Coherence\n`), "the marker the workflow finds its comment by comes first"); - assert.match(md, /\*\*1 spec problem · 1 failing chokepoint · 1 spec gap · 0 guard failures\*\* across 2 projects/); - assert.match(md, /### products\/a[\s\S]*✓ `a door` reference-choked|### products\/a[\s\S]*✓ `a door` pass/); - assert.match(md, /### products\/b[\s\S]*\*\*Spec problems\*\* \(spec --check in products\/b\): [^\n]+\n\n- `Extra\.spec\.md:1` a folder holds one spec/); - assert.match(md, /✕ `b door` broken: 1 reference to ROWS outside door: src\/leak\.ts:3 in leak\n {2}- bypass `src\/leak\.ts:3` in leak/); - assert.match(md, /\*\*Spec gaps:\*\* 1 entrance takes input from outside this code [^\n]+\n\n- `A\.spec\.md` entrance \*\*webhook\*\* \(trust outside\), route: \. -\\> store/); - assert.match(md, /\*\*Spec gaps:\*\* not read: the language server never answered\./, "a gap reading that failed is said, never shown as none"); + assert.match(md, /\*\*1 spec file error · 1 broken rule · 1 unchecked input · 0 bugs that came back\*\* in 2 projects/); + assert.doesNotMatch(md, /`a door`/, "a rule that holds is not listed"); + assert.match(md, /### products\/b[\s\S]*\*\*Spec file errors \(1\)\*\*\n\nCoherence cannot read these lines in the spec files\. Fix each line\. Then run `npx coherence spec --check` in `products\/b` again\.\n\n- `Extra\.spec\.md:1` a folder holds one spec/); + assert.match(md, /- ✕ `b door`: 1 reference to ROWS outside door: src\/leak\.ts:3 in leak\n {2}- used without the function at `src\/leak\.ts:3`, in leak\n/); + assert.match(md, /\*\*Unchecked inputs \(1\)\*\*\n\n[^\n]+\n\n- \*\*webhook\*\* in `A\.spec\.md`\. The input comes from: outside\.\n/); + assert.match(md, /\*\*Unchecked inputs:\*\* Coherence could not trace the code: the language server never answered\./, "a gap reading that failed is said, never shown as none"); assert.deepEqual(report.unfinished, [], "findings alone leave the report finished"); assert.doesNotMatch(md, /someone-else|\[!WARNING\]/, "only the CI session's run is read"); const missing = capture(top); assert.equal(await ciReportCommand(["--session", "ci-8"], missing.io, gaps), 1, "no run of the session: Coherence did not finish"); - assert.match(missing.out.join("\n"), /> \[!WARNING\]\n> Coherence did not finish[\s\S]*> - no chokepoint run of session ci-8 in products\/a/); + assert.match(missing.out.join("\n"), /> \[!WARNING\]\n> Coherence did not finish\. This report is not complete, and the check fails:\n> - no check run was recorded for session ci-8 in products\/a\n/); appendRun(a, record("ci-9", [{ name: "a door", verdict: "not run", reason: "the instrument never answered" }])); appendRun(b, record("ci-9", [{ name: "b door" }])); const notRun = await buildReport(top, "ci-9", gaps); - assert.deepEqual(notRun.unfinished, ["chokepoint a door in products/a was not run: the instrument never answered"]); + assert.deepEqual(notRun.unfinished, ["rule a door in products/a was not checked: the instrument never answered"]); assert.equal(await ciReportCommand([], capture(top).io, gaps), 64, "the session is required"); @@ -304,8 +304,8 @@ test("ci report carries each project's problems, chokepoint verdicts, spec gaps const model = loadSpecModel(a); model.defects = { recorded: 2, closed: 1, guarded: 1, decided: 0, unguarded: [], undeclared: [], guardFailures: [{ id: "df-2", class: "bypass", guard: "products/a/a door", guardedBy: "df-1", resolution: "rs-1" }] }; const guarded = renderReport([{ name: ".", model, runs: [record("ci-7", [{ name: "a door" }])], gaps: { gaps: [] }, baselined: new Set() }], "ci-7", undefined); - assert.match(guarded.markdown, /0 spec gaps · 1 guard failure\*\*/); - assert.match(guarded.markdown, /\*\*Guard failures:\*\* 1\n\n[^\n]+\n\n[^\n]+\n\n- df-2 in class bypass, which products\/a\/a door has guarded since rs-1 closed df-1/); + assert.match(guarded.markdown, /0 unchecked inputs · 1 bug that came back\*\*/); + assert.match(guarded.markdown, /\*\*Bugs that came back \(1\)\*\*\n\nA guard is a rule that was added to stop one kind of bug\. These bugs came back after their guard was added\. Find out why the guard did not stop each one\. Coherence cannot tell if this pull request caused them\.\n\n- `df-2`: kind `bypass`\. Guard: `products\/a\/a door`, added when `rs-1` closed `df-1`\.\n/); assert.doesNotMatch(guarded.markdown, /^### /m, "one project at the root needs no heading"); }); @@ -314,14 +314,14 @@ const WEBHOOK: Gap = { component: ".", name: "webhook", specPath: "A.spec.md", h describe("ci report says what each finding means and what to do", () => { for (const { finding, spec, runs, gaps, baselined, changed, says } of [ - { finding: "nothing", spec: SPEC("a"), runs: [{ name: "a door" }], gaps: [], baselined: [], says: [/\*\*\n\nNothing to fix\.\n/] }, + { finding: "nothing", spec: SPEC("a"), runs: [{ name: "a door" }], gaps: [], baselined: [], says: [/\*\*\n\nCoherence compares the code with its spec files \(`\*\.spec\.md`\)\. A spec file lists the rules for one part of the code\.\n\nNothing to fix\.\n/] }, { finding: "a spec problem", spec: UNNAMED, runs: [], gaps: [], baselined: [], - says: [/\n\nThese findings do not fail this check\. Some can come from files this pull request did not change\.\n/, /\*\*Spec problems\*\* \(spec --check\): lines Coherence cannot read as written\. Fix each one, then run `coherence spec --check` until it finds 0 problems\.\n\n- `A\.spec\.md:6` an invariant bullet starts with a short name and a colon, as in "- \\: \\"\n/], + says: [/\n\nThese findings do not make this check fail\. Some findings can come from files that this pull request does not change\.\n/, /\*\*Spec file errors \(1\)\*\*\n\nCoherence cannot read these lines in the spec files\. Fix each line\. Then run `npx coherence spec --check` again\.\n\n- `A\.spec\.md:6` an invariant bullet starts with a short name and a colon, as in "- \\: \\"\n/], }, { finding: "a failing chokepoint", @@ -330,7 +330,7 @@ describe("ci report says what each finding means and what to do", () => { gaps: [], baselined: [], says: [ - /\*\*Chokepoints:\*\* 0 pass, 1 fail, 0 not run\n\nA chokepoint fails when code reaches what it protects without going through it — for example, code that reads a table directly instead of going through the one function meant to guard it\. Send each bypass below through the chokepoint, or fix the spec if the rule is wrong\.\n/, + /\*\*Broken rules \(1\)\*\*\n\nEach of these rules says that some code or data must only be used through one function\. The code below uses it without that function\. Change the code to use the function\. If the rule is wrong, change the rule in the spec\.\n\n- ✕ `a door`: /, ], }, { @@ -340,7 +340,7 @@ describe("ci report says what each finding means and what to do", () => { gaps: [], baselined: [], changed: ["src/door.ts"], - says: [/✕ `a door` broken: [^\n]+ · \*\*changed in this pull request\*\*\n/], + says: [/- ✕ `a door`: [^\n]+ · \*\*changed in this pull request\*\*\n/], }, { finding: "a spec gap and one the baseline holds", @@ -349,8 +349,8 @@ describe("ci report says what each finding means and what to do", () => { gaps: [WEBHOOK, { ...WEBHOOK, name: "old" }], baselined: ["old"], says: [ - /\*\*Spec gaps:\*\* 1 entrance takes input from outside this code \(trust outside or unknown\), and the spec names no check on that input\. 1 more was already open when Coherence was adopted, so it is not listed\.\n\n- `A\.spec\.md` entrance \*\*webhook\*\* \(trust outside\), route: \. -\\> store\n/, - /\nTo close a gap, write in its spec what checks that input\. In spec terms: declare guard: \\ where a verified chokepoint wraps it, name it on the entrances: line of an invariant whose crossing enters from its trust, or record control: none — \\\. `coherence scaffold control ""` proposes the line\.\n/, + /\*\*Unchecked inputs \(1\)\*\*\n\nInput from outside this code comes in at these places\. The spec does not say what checks that input\. 1 more place was already like this when Coherence was added\. This list does not show it\.\n\n- \*\*webhook\*\* in `A\.spec\.md`\. The input comes from: outside\.\n/, + /\nTo close one of these, find the place by its name in the `## entrances` section of its spec file\. Add one line under it:\n\n- If the input needs no check, add `control: none - `\.\n- If all of the input goes through one function that a rule in the spec protects, add `guard: `\.\n- If a rule in the spec already checks this input, add the place's name to the `entrances:` line of that rule\.\n\nTo get the line to add, run `npx coherence scaffold control \"\"`\.\n/, ], }, { @@ -360,18 +360,18 @@ describe("ci report says what each finding means and what to do", () => { gaps: [], baselined: [], changed: ["A.spec.md"], - says: [/\n\n1 of these findings is in a file this pull request changed\. It is marked below\.\n/, /- `A\.spec\.md:6` [^\n]+ · \*\*changed in this pull request\*\*\n/], + says: [/\n\n1 finding is in a file that this pull request changes\. It has the mark \*\*changed in this pull request\*\*\.\n/, /- `A\.spec\.md:6` [^\n]+ · \*\*changed in this pull request\*\*\n/], }, - { finding: "a spec gap whose handler the pull request changed", spec: SPEC("a"), runs: [{ name: "a door" }], gaps: [WEBHOOK], baselined: [], changed: ["src/hook.ts"], says: [/route: \. -\\> store · \*\*changed in this pull request\*\*\n/] }, - { finding: "findings in no file the pull request changed", spec: UNNAMED, runs: [], gaps: [WEBHOOK], baselined: [], changed: ["src/other.ts"], says: [/\n\nNone of these findings are in files this pull request changed\.\n/] }, - { finding: "changed files git could not list", spec: UNNAMED, runs: [], gaps: [], baselined: [], changed: "git diff main failed: bad revision", says: [/\n\nCoherence could not tell which files this pull request changed: git diff main failed: bad revision\.\n/] }, + { finding: "a spec gap whose handler the pull request changed", spec: SPEC("a"), runs: [{ name: "a door" }], gaps: [WEBHOOK], baselined: [], changed: ["src/hook.ts"], says: [/The input comes from: outside\. · \*\*changed in this pull request\*\*\n/] }, + { finding: "findings in no file the pull request changed", spec: UNNAMED, runs: [], gaps: [WEBHOOK], baselined: [], changed: ["src/other.ts"], says: [/\n\nNone of these findings are in files that this pull request changes\. You do not need to fix them in this pull request\.\n/] }, + { finding: "changed files git could not list", spec: UNNAMED, runs: [], gaps: [], baselined: [], changed: "git diff main failed: bad revision", says: [/\n\nCoherence could not find the files that this pull request changes: git diff main failed: bad revision\.\n/] }, { finding: "no chokepoint run, so there is nothing to fix only in appearance", spec: SPEC("a"), runs: [{ name: "a door", verdict: "not run", reason: "the instrument never answered" }], gaps: [], baselined: [], - says: [/\*\*\n\nCoherence could not check everything, so this might not list every finding\.\n/], + says: [/\n\nCoherence could not check everything\. This list can be incomplete\.\n/, /\*\*Rules Coherence could not check \(1\)\*\*\n\n- ○ `a door`: the instrument never answered\n/], }, ] satisfies { finding: string; spec: string; runs: Partial[]; gaps: Gap[]; baselined: string[]; changed?: string[] | string; says: RegExp[] }[]) { test(finding, () => { @@ -386,7 +386,7 @@ describe("ci report says what each finding means and what to do", () => { const root = repository({ "A.spec.md": SPEC("a") }); const model = loadSpecModel(root); const report = renderReport([{ name: ".", model, runs: [record("ci-7", [{ name: "a door" }])], gaps: { unread: "the language server never answered" }, baselined: new Set() }], "ci-7", undefined); - assert.match(report.markdown, /\*\*\n\nCoherence could not check everything, so this might not list every finding\.\n/); + assert.match(report.markdown, /\n\nCoherence could not check everything\. This list can be incomplete\.\n/); }); test("a project whose changed files were read beside one that was not keeps the lead from claiming none of the findings changed", () => { @@ -395,8 +395,8 @@ describe("ci report says what each finding means and what to do", () => { const read: ProjectReport = { name: "read", model, runs: [record("ci-7", [{ name: "a door" }])], gaps: { gaps: [WEBHOOK] }, baselined: new Set(), changed: { files: new Set(["src/other.ts"]) } }; const unread: ProjectReport = { name: "unread", model, runs: [record("ci-7", [{ name: "a door" }])], gaps: { gaps: [] }, baselined: new Set(), changed: { unread: "git diff main failed: bad revision" } }; const report = renderReport([read, unread], "ci-7", undefined); - assert.match(report.markdown, /None of the findings Coherence could check are in files this pull request changed\./); - assert.doesNotMatch(report.markdown, /None of these findings are in files this pull request changed\./); + assert.match(report.markdown, /None of the findings that Coherence could check are in files that this pull request changes\./); + assert.doesNotMatch(report.markdown, /None of these findings/); }); test("registry problems are marked when the pull request changed the file they are about", () => { @@ -407,6 +407,7 @@ describe("ci report says what each finding means and what to do", () => { "ci-7", { problems: [{ file: "coherence.config.json", message: "projects is a list of folders relative to the repository top" }], changed: { files: new Set(["coherence.config.json"]) } }, ); + assert.match(changedReport.markdown, /\*\*Project list errors \(1\)\*\*\n\nCoherence cannot read the list of projects in `coherence\.config\.json`\. Fix each line below\.\n\n/); assert.match(changedReport.markdown, /- `coherence\.config\.json` projects is a list of folders relative to the repository top · \*\*changed in this pull request\*\*\n/); const unchangedReport = renderReport( [{ name: ".", model, runs: [record("ci-7", [{ name: "a door" }])], gaps: { gaps: [] }, baselined: new Set() }], @@ -420,8 +421,8 @@ describe("ci report says what each finding means and what to do", () => { const root = repository({ "A.spec.md": SPEC("a") }); const project = { name: "products/a", model: loadSpecModel(root), runs: [record("ci-7", [{ name: "a door" }])], gaps: { gaps: [] }, baselined: new Set(), changed: { files: new Set() } }; const report = renderReport([project], "ci-7", { problems: [{ file: "coherence.config.json", message: "lists a folder that is not there" }], changed: { unread: "git diff main failed" } }); - assert.match(report.markdown, /\n\nCoherence could not tell which files this pull request changed at the registry top: git diff main failed\.\n/); - assert.match(report.markdown, /\n\nNone of the findings Coherence could check are in files this pull request changed\.\n/); + assert.match(report.markdown, /\n\nCoherence could not find the files that this pull request changes at the registry top: git diff main failed\.\n/); + assert.match(report.markdown, /\n\nNone of the findings that Coherence could check are in files that this pull request changes\.\n/); }); test("a file name git would quote still matches its finding", async () => { @@ -454,7 +455,7 @@ describe("ci report says what each finding means and what to do", () => { appendRun(root, record("ci-7", [])); const said = capture(root); assert.equal(await ciReportCommand(["--session", "ci-7", "--base", base], said.io, async () => ({ gaps: [] })), 0); - const marked = /1 of these findings is in a file this pull request changed[\s\S]*- `A\.spec\.md:6` [^\n]+ · \*\*changed in this pull request\*\*/; + const marked = /1 finding is in a file that this pull request changes[\s\S]*- `A\.spec\.md:6` [^\n]+ · \*\*changed in this pull request\*\*/; assert.match(said.out.join("\n"), marked); git("update-ref", "refs/remotes/origin/main", base); @@ -476,7 +477,7 @@ describe("ci report says what each finding means and what to do", () => { const elsewhere = capture(root); assert.equal(await ciReportCommand(["--session", "ci-7"], elsewhere.io, async () => ({ gaps: [] }), {}), 0); - assert.match(elsewhere.out.join("\n"), /These findings do not fail this check\. Some can come from files this pull request did not change\./, "with neither, no files are compared"); + assert.match(elsewhere.out.join("\n"), /These findings do not make this check fail\. Some findings can come from files that this pull request does not change\./, "with neither, no files are compared"); }); }); @@ -499,7 +500,7 @@ test("run at a registry's top runs each listed project from its own folder, its const report = await buildReport(top, "ci-1", async () => ({ gaps: [] })); assert.deepEqual(report.unfinished, [], "the report finds each project's run of the session"); - assert.match(report.markdown, /1 failing chokepoint/); + assert.match(report.markdown, /1 broken rule/); } finally { await stopWarmServers(top); } diff --git a/src/readings/scope/gaps.ts b/src/readings/scope/gaps.ts index aac90ddf..78cda92d 100644 --- a/src/readings/scope/gaps.ts +++ b/src/readings/scope/gaps.ts @@ -73,6 +73,13 @@ const RECORD_VERSION = 1; /** The three ways a gap closes, in the words orient, regulate and the scaffold share. */ export const CLOSE_WAYS = "declare guard: where a verified chokepoint wraps it, name it on the entrances: line of an invariant whose crossing enters from its trust, or record control: none — "; +/** The same three ways, as plain steps for a reader who does not know the spec's terms; keep the two in step. */ +export const CLOSE_STEPS: readonly string[] = [ + "If the input needs no check, add `control: none - `.", + "If all of the input goes through one function that a rule in the spec protects, add `guard: `.", + "If a rule in the spec already checks this input, add the place's name to the `entrances:` line of that rule.", +]; + /** An entrance with outside or unknown trust whose route has no traced control, and which declares no control: none. */ export interface Gap { /** The folder of the component whose spec declares it. */ From 4534f2640347a8889390f7d6f0fab9705a73dda4 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sun, 25 Oct 2026 20:48:21 +0000 Subject: [PATCH 2/5] feat(ci): fold the report's explanations, colour its verdict, and add icons A familiar reader can now scan the report. Each section lists its findings first, and what the finding means and how to fix it sits in a details block below the list, as does "What is Coherence?". The verdict is a GitHub alert, so it has a colour: TIP when there is nothing to fix, NOTE for findings outside the pull request's files, IMPORTANT when some are in them, and WARNING when Coherence could not check everything. Icons mark each kind of finding and the changed-file mark. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 4189fcb8-6fd5-428d-8c5f-4314d28e4dac --- .coherence/runs/pr-report-ste.4d69b110.jsonl | 3 + src/lifecycle/Lifecycle.spec.md | 3 +- src/lifecycle/ci-report.ts | 99 ++++++++++++-------- src/lifecycle/ci.test.ts | 48 +++++----- 4 files changed, 91 insertions(+), 62 deletions(-) create mode 100644 .coherence/runs/pr-report-ste.4d69b110.jsonl diff --git a/.coherence/runs/pr-report-ste.4d69b110.jsonl b/.coherence/runs/pr-report-ste.4d69b110.jsonl new file mode 100644 index 00000000..23357be2 --- /dev/null +++ b/.coherence/runs/pr-report-ste.4d69b110.jsonl @@ -0,0 +1,3 @@ +{"at":"2026-10-25T20:47:23.231Z","session":"pr-report-ste","agent":"claude","binding":"none: this session owns no active work order","commit":"76d8c2d","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":34603,"load":{"average":0.14,"cores":8},"batch":{"ms":34112},"invariants":[{"component":"src/lifecycle","name":"the CI report says what each finding means and what to do","form":"totality oracle","verdict":"fail","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":34112,"reason":"6 of 17 tests under \"ci report says what each finding means and what to do\" failed: ci report says what each finding means and what to do nothing; ci report says what each finding means and what to do a spec problem; ci report says what each finding means and what to do a failing chokepoint","testMs":414,"mode":"batched","state":"structural defect","enforces":["ci report says what each finding means and what to do"]}]} +{"kind":"refutation","at":"2026-10-25T20:47:23.722Z","session":"pr-report-ste","agent":"claude","component":"src/lifecycle","name":"the CI report says what each finding means and what to do","form":"totality oracle","broke":"made folded() in ci-report.ts print its text with no details block","verdict":"fail","reason":"6 of 17 tests under \"ci report says what each finding means and what to do\" failed: ci report says what each finding means and what to do nothing; ci report says what each finding means and what to do a spec problem; ci report says what each finding means and what to do a failing chokepoint","commit":"76d8c2d","dirty":true} +{"at":"2026-10-25T20:47:59.334Z","session":"pr-report-ste","agent":"claude","binding":"none: this session owns no active work order","commit":"76d8c2d","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":34973,"load":{"average":0.62,"cores":8},"batch":{"ms":34447},"invariants":[{"component":"src/lifecycle","name":"the CI report says what each finding means and what to do","form":"totality oracle","verdict":"pass","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":34447,"reason":"17 tests under \"ci report says what each finding means and what to do\" passed in one invocation of node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-reporter=./src/enforcement/node-test-reporter.ts --test-reporter-destination= --test-name-pattern=<1 names> src/**/*.test.ts src/*.e2e.ts","testMs":383,"mode":"batched","state":"invariant","enforces":["ci report says what each finding means and what to do"]}]} diff --git a/src/lifecycle/Lifecycle.spec.md b/src/lifecycle/Lifecycle.spec.md index c7886fd1..7334c2e6 100644 --- a/src/lifecycle/Lifecycle.spec.md +++ b/src/lifecycle/Lifecycle.spec.md @@ -1283,7 +1283,7 @@ The gyroscope delivered through harness events: orient at start, regulate at sto because: in the comment mode, findings are reported and never fail the job, while a run that never happened is never mistaken for a clean one; a report read from the latest verdicts instead of the session's run would show a pass from another branch refuted: buildReport read every run in each project instead of the CI session's, so another session's later pass hid the failing verdict -> "ci report carries each project's problems, chokepoint verdicts, spec gaps and guard failures as Markdown, and fails only when Coherence did not finish" went red in ci.test.ts on its own assertion; restored byte for byte, green batched and alone (2026-10-09) kinds: none -- the CI report says what each finding means and what to do: Under its counts the report says in plain words whether there is anything to fix, never claiming that when Coherence did not finish or a project's spec gaps were not read; it says that findings do not fail the check and can come from files the pull request did not change; it says in one sentence what Coherence is; it names each kind of finding by what it means to the reader (a spec file error, a broken rule, an unchecked input, a bug that came back) in short active sentences with the fix as a list, shows spec syntax only as code to type, and leaves out a section with nothing in it; each section with a finding says what that kind of finding means and how to fix it, an unchecked input section says how many more the adoption baseline holds and why they are not listed, and a guard failure section says it carries no changed-file mark. Given --base, or else in a GitHub pull request run the pull request's fixed base commit from the triggering event, falling back to the base branch GITHUB_BASE_REF names, it says how many findings are in files changed since that commit, or why it could not tell — saying so without claiming none are when only some projects' changes were read — and marks each one, including a registry problem in a changed top-level config and a failing chokepoint attributed by its recorded files as well as its bypasses. +- the CI report says what each finding means and what to do: Under its counts the report says in plain words whether there is anything to fix, never claiming that when Coherence did not finish or a project's spec gaps were not read; it says that findings do not fail the check and can come from files the pull request did not change; it says in one sentence what Coherence is; it names each kind of finding by what it means to the reader (a spec file error, a broken rule, an unchecked input, a bug that came back) in short active sentences with the fix as a list, shows spec syntax only as code to type, and leaves out a section with nothing in it; it gives the verdict as a coloured GitHub alert (TIP when there is nothing to fix, NOTE for findings outside the pull request's files, IMPORTANT when some are in them, WARNING when Coherence could not check everything), puts an icon before each kind of finding and on the changed-file mark, and lists each section's findings first, with what Coherence is and what each finding means folded in a details block that a familiar reader can skip; each section with a finding says what that kind of finding means and how to fix it, an unchecked input section says how many more the adoption baseline holds and why they are not listed, and a guard failure section says it carries no changed-file mark. Given --base, or else in a GitHub pull request run the pull request's fixed base commit from the triggering event, falling back to the base branch GITHUB_BASE_REF names, it says how many findings are in files changed since that commit, or why it could not tell — saying so without claiming none are when only some projects' changes were read — and marks each one, including a registry problem in a changed top-level config and a failing chokepoint attributed by its recorded files as well as its bypasses. over: a report with nothing to fix, one Coherence could not finish or read every gap for, a spec problem, a failing chokepoint with a bypass and one attributed only by its own recorded file, a guard failure, an open spec gap beside one the adoption baseline holds, a spec problem and a gap in files the pull request changed, a registry problem in a changed config, findings in none it changed, a project read beside one that was not, a base git cannot diff against at a project or at the registry top, a file name git would quote, and --base, an event's fixed base commit, GITHUB_BASE_REF and neither read from a real repository via: ci report says what each finding means and what to do because: an adopter read a report of spec problems, spec gaps, traced controls and an adoption baseline and could not tell what any of it meant, whether the pull request caused it, or what to change; a report a reader cannot act on is noise on every pull request @@ -1291,6 +1291,7 @@ The gyroscope delivered through harness events: orient at start, regulate at sto refuted: made the changed-file mark in ci-report.ts never mark a finding -> "ci report says what each finding means and what to do" went red in ci.test.ts, 4 of 10 cases; restored byte for byte, green (2026-10-25) refuted: dropped the GITHUB_BASE_REF default in ci-report.ts -> "ci report says what each finding means and what to do" went red in ci.test.ts on the base branch case; restored byte for byte, green (2026-10-25) refuted: dropped the sentence that says what Coherence is from ci-report.ts -> "ci report says what each finding means and what to do" went red in ci.test.ts, 2 of 17 cases; restored byte for byte, green (2026-10-25) + refuted: made folded() in ci-report.ts print its text with no details block -> "ci report says what each finding means and what to do" went red in ci.test.ts, 6 of 17 cases; restored byte for byte, green (2026-10-25) kinds: none - a closure mirror is named at the edit that breaks it: At PostToolUse, a write to a file in the value-import closure a declared mirror names is read again, and each file the closure now reaches that the list does not name is added to the edit's context as advice, never a block, naming the file that imports it and the list's file and line; a type-only import adds nothing, and the file the closure grew by is watched from then on. over: a fixture of the b37627e shape: a list of the page's modules, the closure of page.ts, a type-only import added to a closure file, then a value import of a file the list omits diff --git a/src/lifecycle/ci-report.ts b/src/lifecycle/ci-report.ts index e3a82c71..2eac6aaf 100644 --- a/src/lifecycle/ci-report.ts +++ b/src/lifecycle/ci-report.ts @@ -101,13 +101,20 @@ function bullet(e: RunEntry): string { const MARK = { fail: "✕", "not run": "○" } as const; -const CHANGED_HERE = " · **changed in this pull request**"; +const CHANGED_HERE = " · 📍 **changed in this pull request**"; -/** A section's bold title, with its count when it lists findings. */ -function title(name: string, count?: number): string { - return count === undefined ? `**${name}:**` : `**${name} (${count})**`; +/** A section's bold title with its icon, and its count when it lists findings. */ +function title(icon: string, name: string, count?: number): string { + return count === undefined ? `**${icon} ${name}:**` : `**${icon} ${name} (${count})**`; } +/** Text a familiar reader can skip, folded under a summary line; GitHub needs the blank lines to read Markdown inside it. */ +function folded(summary: string, ...paragraphs: string[]): string[] { + return ["
", `${summary}`, "", ...paragraphs.flatMap((paragraph) => [paragraph, ""]), "
", ""]; +} + +const HOW_TO_FIX = "What this means and how to fix it"; + /** One project's section, and why its part did not finish. */ function projectSection(p: ProjectReport, session: string, heading: boolean): { lines: string[]; unfinished: string[]; touched: number } { const lines: string[] = []; @@ -124,34 +131,35 @@ function projectSection(p: ProjectReport, session: string, heading: boolean): { if (p.model.problems.length > 0) { const runIn = p.name === "." ? "" : ` in \`${p.name}\``; - lines.push(title("Spec file errors", p.model.problems.length), "", `Coherence cannot read these lines in the spec files. Fix each line. Then run \`npx coherence spec --check\`${runIn} again.`, ""); + lines.push(title("📄", "Spec file errors", p.model.problems.length), ""); for (const problem of p.model.problems) lines.push(`- \`${problem.file}:${problem.line}\` ${cell(problem.message)}${mark(problem.file)}`); - lines.push(""); + lines.push("", ...folded(HOW_TO_FIX, `Coherence cannot read these lines in the spec files. Fix each line. Then run \`npx coherence spec --check\`${runIn} again.`)); } const entries = chokepointEntries(p.runs); if (p.runs.length === 0) { unfinished.push(`no check run was recorded for session ${session}${where}`); - lines.push(`${title("Rules")} Coherence has no record of a check run for session ${cell(session)}${where}, so it did not check the rules.`, ""); + lines.push(`${title("⛔", "Rules")} Coherence has no record of a check run for session ${cell(session)}${where}, so it did not check the rules.`, ""); } else { const fails = entries.filter((e) => e.verdict === "fail"); const notRun = entries.filter((e) => e.verdict === "not run"); if (fails.length > 0) { - lines.push( - title("Broken rules", fails.length), - "", - "Each of these rules says that some code or data must only be used through one function. The code below uses it without that function. Change the code to use the function. If the rule is wrong, change the rule in the spec.", - "", - ); + lines.push(title("⛔", "Broken rules", fails.length), ""); for (const e of fails) { lines.push(`- ${MARK.fail} \`${bullet(e)}\`: ${cell(e.reason)}${mark(...e.files, ...e.bypasses.map((b) => b.file))}`); for (const b of e.bypasses.slice(0, BYPASSES)) lines.push(` - used without the function at \`${b.file}:${b.line}\`, in ${cell(b.symbol)}`); if (e.bypasses.length > BYPASSES) lines.push(` - and ${e.bypasses.length - BYPASSES} more. To see all of them, run \`npx coherence run --form chokepoint\`.`); } - lines.push(""); + lines.push( + "", + ...folded( + HOW_TO_FIX, + "Each of these rules says that some code or data must only be used through one function. The code above uses it without that function. Change the code to use the function. If the rule is wrong, change the rule in the spec.", + ), + ); } if (notRun.length > 0) { - lines.push(title("Rules Coherence could not check", notRun.length), ""); + lines.push(title("⏸️", "Rules Coherence could not check", notRun.length), ""); for (const e of notRun) lines.push(`- ${MARK["not run"]} \`${bullet(e)}\`: ${cell(e.reason)}`); lines.push(""); } @@ -159,31 +167,41 @@ function projectSection(p: ProjectReport, session: string, heading: boolean): { } if ("unread" in p.gaps) { - lines.push(`${title("Unchecked inputs")} Coherence could not trace the code: ${cell(p.gaps.unread)}.`, ""); + lines.push(`${title("🚪", "Unchecked inputs")} Coherence could not trace the code: ${cell(p.gaps.unread)}.`, ""); } else { const open = p.gaps.gaps.filter((g) => !p.baselined.has(`${g.component}\u0000${g.name}`)); const held = p.gaps.gaps.length - open.length; if (open.length > 0) { const older = held === 0 ? "" : held === 1 ? " 1 more place was already like this when Coherence was added. This list does not show it." : ` ${held} more places were already like this when Coherence was added. This list does not show them.`; - lines.push(title("Unchecked inputs", open.length), "", `Input from outside this code comes in at these places. The spec does not say what checks that input.${older}`, ""); + lines.push(title("🚪", "Unchecked inputs", open.length), ""); for (const g of open) { const from = g.trust.length === 0 ? "The spec does not say where the input comes from." : `The input comes from: ${cell(g.trust.join(", "))}.`; lines.push(`- **${cell(g.name)}** in \`${g.specPath}\`. ${from}${mark(g.file, g.specPath)}`); } - lines.push("", "To close one of these, find the place by its name in the `## entrances` section of its spec file. Add one line under it:", "", ...CLOSE_STEPS.map((step) => `- ${step}`), "", 'To get the line to add, run `npx coherence scaffold control ""`.', ""); + lines.push( + "", + ...folded( + HOW_TO_FIX, + `Input from outside this code comes in at these places. The spec does not say what checks that input.${older}`, + "To close one of these, find the place by its name in the `## entrances` section of its spec file. Add one line under it:", + CLOSE_STEPS.map((step) => `- ${step}`).join("\n"), + 'To get the line to add, run `npx coherence scaffold control ""`.', + ), + ); } } const failures = p.model.defects?.guardFailures ?? []; if (failures.length > 0) { + lines.push(title("🐛", "Bugs that came back", failures.length), ""); + for (const g of failures) lines.push(`- \`${g.id}\`: kind \`${cell(g.class)}\`. Guard: \`${cell(g.guard)}\`, added when \`${g.resolution}\` closed \`${g.guardedBy}\`.`); lines.push( - title("Bugs that came back", failures.length), - "", - "A guard is a rule that was added to stop one kind of bug. These bugs came back after their guard was added. Find out why the guard did not stop each one. Coherence cannot tell if this pull request caused them.", "", + ...folded( + HOW_TO_FIX, + "A guard is a rule that was added to stop one kind of bug. These bugs came back after their guard was added. Find out why the guard did not stop each one. Coherence cannot tell if this pull request caused them.", + ), ); - for (const g of failures) lines.push(`- \`${g.id}\`: kind \`${cell(g.class)}\`. Guard: \`${cell(g.guard)}\`, added when \`${g.resolution}\` closed \`${g.guardedBy}\`.`); - lines.push(""); } return { lines, unfinished, touched }; } @@ -197,23 +215,31 @@ interface ChangedWhere { changed: ChangedRead | undefined; } +/** The colour GitHub gives a quoted alert block: green, blue, purple, or yellow. */ +type AlertKind = "TIP" | "NOTE" | "IMPORTANT" | "WARNING"; + +/** Sentences as one GitHub alert block, a quoted blank line between them. */ +function alert(kind: AlertKind, sentences: readonly string[]): string[] { + return [`> [!${kind}]`, ...sentences.flatMap((sentence, i) => (i === 0 ? [`> ${sentence}`] : [">", `> ${sentence}`])), ""]; +} + /** Whether there is anything to fix, and which findings this pull request's own files hold, when the report knows them. */ function findingsLead(projects: readonly ProjectReport[], reads: readonly ChangedWhere[], findings: number, touched: number, unfinished: readonly string[]): string[] { if (findings === 0) { - if (unfinished.length > 0 || projects.some((p) => "unread" in p.gaps)) return ["Coherence could not check everything. This list can be incomplete.", ""]; - return ["Nothing to fix.", ""]; + if (unfinished.length > 0 || projects.some((p) => "unread" in p.gaps)) return alert("WARNING", ["Coherence could not check everything. This list can be incomplete."]); + return alert("TIP", ["Nothing to fix."]); } const changed = reads.filter((r) => r.changed !== undefined); - if (changed.length === 0) return ["These findings do not make this check fail. Some findings can come from files that this pull request does not change.", ""]; - const lines = ["These findings do not make this check fail.", ""]; + if (changed.length === 0) return alert("NOTE", ["These findings do not make this check fail. Some findings can come from files that this pull request does not change."]); + const sentences = ["These findings do not make this check fail."]; const allRead = changed.every((r) => "files" in r.changed!); - for (const r of changed) if ("unread" in r.changed!) lines.push(`Coherence could not find the files that this pull request changes${r.where}: ${cell(r.changed.unread)}.`, ""); + for (const r of changed) if ("unread" in r.changed!) sentences.push(`Coherence could not find the files that this pull request changes${r.where}: ${cell(r.changed.unread)}.`); if (changed.some((r) => "files" in r.changed!)) { - if (touched === 0) lines.push(allRead ? "None of these findings are in files that this pull request changes. You do not need to fix them in this pull request." : "None of the findings that Coherence could check are in files that this pull request changes.", ""); - else if (touched === 1) lines.push("1 finding is in a file that this pull request changes. It has the mark **changed in this pull request**.", ""); - else lines.push(`${touched} findings are in files that this pull request changes. They have the mark **changed in this pull request**.`, ""); + if (touched === 0) sentences.push(allRead ? "None of these findings are in files that this pull request changes. You do not need to fix them in this pull request." : "None of the findings that Coherence could check are in files that this pull request changes."); + else if (touched === 1) sentences.push("1 finding is in a file that this pull request changes. It has the mark 📍 **changed in this pull request**."); + else sentences.push(`${touched} findings are in files that this pull request changes. They have the mark 📍 **changed in this pull request**.`); } - return lines; + return alert(touched > 0 ? "IMPORTANT" : "NOTE", sentences); } /** Whether a registry problem's file is one this pull request changed. */ @@ -247,10 +273,9 @@ export function renderReport( const guards = projects.reduce((n, p) => n + (p.model.defects?.guardFailures.length ?? 0), 0); const head = [REPORT_MARKER, "## Coherence", ""]; head.push( - `**${plural(problems, "spec file error")} · ${plural(fails, "broken rule")} · ${plural(gaps, "unchecked input")} · ${plural(guards, "bug that came back", "bugs that came back")}**${projects.length > 1 ? ` in ${plural(projects.length, "project")}` : ""}`, - "", - ABOUT, + `**📄 ${plural(problems, "spec file error")} · ⛔ ${plural(fails, "broken rule")} · 🚪 ${plural(gaps, "unchecked input")} · 🐛 ${plural(guards, "bug that came back", "bugs that came back")}**${projects.length > 1 ? ` in ${plural(projects.length, "project")}` : ""}`, "", + ...folded("What is Coherence?", ABOUT), ); const reads: ChangedWhere[] = projects.map((p) => ({ where: p.name === "." ? "" : ` in ${cell(p.name)}`, changed: p.changed })); if (registry !== undefined && registry.problems.length > 0) reads.push({ where: " at the registry top", changed: registry.changed }); @@ -261,9 +286,9 @@ export function renderReport( head.push(""); } if (registry !== undefined && registry.problems.length > 0) { - head.push(title("Project list errors", registry.problems.length), "", "Coherence cannot read the list of projects in `coherence.config.json`. Fix each line below.", ""); + head.push(title("⚙️", "Project list errors", registry.problems.length), ""); registry.problems.forEach((problem, i) => head.push(`- \`${problem.file}\` ${cell(problem.message)}${registryMarks[i]}`)); - head.push(""); + head.push("", ...folded(HOW_TO_FIX, "Coherence cannot read the list of projects in `coherence.config.json`. Fix each line above.")); } const markdown = [...head, ...sections].join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; return { markdown, unfinished }; diff --git a/src/lifecycle/ci.test.ts b/src/lifecycle/ci.test.ts index 5b48a0aa..36d971c4 100644 --- a/src/lifecycle/ci.test.ts +++ b/src/lifecycle/ci.test.ts @@ -280,12 +280,12 @@ test("ci report carries each project's problems, chokepoint verdicts, spec gaps const report = await buildReport(top, "ci-7", gaps); const md = report.markdown; assert.ok(md.startsWith(`${REPORT_MARKER}\n## Coherence\n`), "the marker the workflow finds its comment by comes first"); - assert.match(md, /\*\*1 spec file error · 1 broken rule · 1 unchecked input · 0 bugs that came back\*\* in 2 projects/); + assert.match(md, /\*\*📄 1 spec file error · ⛔ 1 broken rule · 🚪 1 unchecked input · 🐛 0 bugs that came back\*\* in 2 projects/); assert.doesNotMatch(md, /`a door`/, "a rule that holds is not listed"); - assert.match(md, /### products\/b[\s\S]*\*\*Spec file errors \(1\)\*\*\n\nCoherence cannot read these lines in the spec files\. Fix each line\. Then run `npx coherence spec --check` in `products\/b` again\.\n\n- `Extra\.spec\.md:1` a folder holds one spec/); + assert.match(md, /### products\/b[\s\S]*\*\*📄 Spec file errors \(1\)\*\*\n\n- `Extra\.spec\.md:1` a folder holds one spec[^\n]*\n\n
\nWhat this means and how to fix it<\/summary>\n\nCoherence cannot read these lines in the spec files\. Fix each line\. Then run `npx coherence spec --check` in `products\/b` again\./); assert.match(md, /- ✕ `b door`: 1 reference to ROWS outside door: src\/leak\.ts:3 in leak\n {2}- used without the function at `src\/leak\.ts:3`, in leak\n/); - assert.match(md, /\*\*Unchecked inputs \(1\)\*\*\n\n[^\n]+\n\n- \*\*webhook\*\* in `A\.spec\.md`\. The input comes from: outside\.\n/); - assert.match(md, /\*\*Unchecked inputs:\*\* Coherence could not trace the code: the language server never answered\./, "a gap reading that failed is said, never shown as none"); + assert.match(md, /\*\*🚪 Unchecked inputs \(1\)\*\*\n\n- \*\*webhook\*\* in `A\.spec\.md`\. The input comes from: outside\.\n/); + assert.match(md, /\*\*🚪 Unchecked inputs:\*\* Coherence could not trace the code: the language server never answered\./, "a gap reading that failed is said, never shown as none"); assert.deepEqual(report.unfinished, [], "findings alone leave the report finished"); assert.doesNotMatch(md, /someone-else|\[!WARNING\]/, "only the CI session's run is read"); @@ -304,8 +304,8 @@ test("ci report carries each project's problems, chokepoint verdicts, spec gaps const model = loadSpecModel(a); model.defects = { recorded: 2, closed: 1, guarded: 1, decided: 0, unguarded: [], undeclared: [], guardFailures: [{ id: "df-2", class: "bypass", guard: "products/a/a door", guardedBy: "df-1", resolution: "rs-1" }] }; const guarded = renderReport([{ name: ".", model, runs: [record("ci-7", [{ name: "a door" }])], gaps: { gaps: [] }, baselined: new Set() }], "ci-7", undefined); - assert.match(guarded.markdown, /0 unchecked inputs · 1 bug that came back\*\*/); - assert.match(guarded.markdown, /\*\*Bugs that came back \(1\)\*\*\n\nA guard is a rule that was added to stop one kind of bug\. These bugs came back after their guard was added\. Find out why the guard did not stop each one\. Coherence cannot tell if this pull request caused them\.\n\n- `df-2`: kind `bypass`\. Guard: `products\/a\/a door`, added when `rs-1` closed `df-1`\.\n/); + assert.match(guarded.markdown, /🚪 0 unchecked inputs · 🐛 1 bug that came back\*\*/); + assert.match(guarded.markdown, /\*\*🐛 Bugs that came back \(1\)\*\*\n\n- `df-2`: kind `bypass`\. Guard: `products\/a\/a door`, added when `rs-1` closed `df-1`\.\n\n
\nWhat this means and how to fix it<\/summary>\n\nA guard is a rule that was added to stop one kind of bug\. These bugs came back after their guard was added\. Find out why the guard did not stop each one\. Coherence cannot tell if this pull request caused them\.\n\n<\/details>\n/); assert.doesNotMatch(guarded.markdown, /^### /m, "one project at the root needs no heading"); }); @@ -314,14 +314,14 @@ const WEBHOOK: Gap = { component: ".", name: "webhook", specPath: "A.spec.md", h describe("ci report says what each finding means and what to do", () => { for (const { finding, spec, runs, gaps, baselined, changed, says } of [ - { finding: "nothing", spec: SPEC("a"), runs: [{ name: "a door" }], gaps: [], baselined: [], says: [/\*\*\n\nCoherence compares the code with its spec files \(`\*\.spec\.md`\)\. A spec file lists the rules for one part of the code\.\n\nNothing to fix\.\n/] }, + { finding: "nothing", spec: SPEC("a"), runs: [{ name: "a door" }], gaps: [], baselined: [], says: [/\*\*\n\n
\nWhat is Coherence\?<\/summary>\n\nCoherence compares the code with its spec files \(`\*\.spec\.md`\)\. A spec file lists the rules for one part of the code\.\n\n<\/details>\n\n> \[!TIP\]\n> Nothing to fix\.\n/] }, { finding: "a spec problem", spec: UNNAMED, runs: [], gaps: [], baselined: [], - says: [/\n\nThese findings do not make this check fail\. Some findings can come from files that this pull request does not change\.\n/, /\*\*Spec file errors \(1\)\*\*\n\nCoherence cannot read these lines in the spec files\. Fix each line\. Then run `npx coherence spec --check` again\.\n\n- `A\.spec\.md:6` an invariant bullet starts with a short name and a colon, as in "- \\: \\"\n/], + says: [/> \[!NOTE\]\n> These findings do not make this check fail\. Some findings can come from files that this pull request does not change\.\n/, /\*\*📄 Spec file errors \(1\)\*\*\n\n- `A\.spec\.md:6` an invariant bullet starts with a short name and a colon, as in "- \\: \\"\n\n
\nWhat this means and how to fix it<\/summary>\n\nCoherence cannot read these lines in the spec files\. Fix each line\. Then run `npx coherence spec --check` again\.\n\n<\/details>\n/], }, { finding: "a failing chokepoint", @@ -330,7 +330,7 @@ describe("ci report says what each finding means and what to do", () => { gaps: [], baselined: [], says: [ - /\*\*Broken rules \(1\)\*\*\n\nEach of these rules says that some code or data must only be used through one function\. The code below uses it without that function\. Change the code to use the function\. If the rule is wrong, change the rule in the spec\.\n\n- ✕ `a door`: /, + /\*\*⛔ Broken rules \(1\)\*\*\n\n- ✕ `a door`: [^\n]*\n\n
\nWhat this means and how to fix it<\/summary>\n\nEach of these rules says that some code or data must only be used through one function\. The code above uses it without that function\. Change the code to use the function\. If the rule is wrong, change the rule in the spec\.\n/, ], }, { @@ -340,7 +340,7 @@ describe("ci report says what each finding means and what to do", () => { gaps: [], baselined: [], changed: ["src/door.ts"], - says: [/- ✕ `a door`: [^\n]+ · \*\*changed in this pull request\*\*\n/], + says: [/- ✕ `a door`: [^\n]+ · 📍 \*\*changed in this pull request\*\*\n/], }, { finding: "a spec gap and one the baseline holds", @@ -349,8 +349,8 @@ describe("ci report says what each finding means and what to do", () => { gaps: [WEBHOOK, { ...WEBHOOK, name: "old" }], baselined: ["old"], says: [ - /\*\*Unchecked inputs \(1\)\*\*\n\nInput from outside this code comes in at these places\. The spec does not say what checks that input\. 1 more place was already like this when Coherence was added\. This list does not show it\.\n\n- \*\*webhook\*\* in `A\.spec\.md`\. The input comes from: outside\.\n/, - /\nTo close one of these, find the place by its name in the `## entrances` section of its spec file\. Add one line under it:\n\n- If the input needs no check, add `control: none - `\.\n- If all of the input goes through one function that a rule in the spec protects, add `guard: `\.\n- If a rule in the spec already checks this input, add the place's name to the `entrances:` line of that rule\.\n\nTo get the line to add, run `npx coherence scaffold control \"\"`\.\n/, + /\*\*🚪 Unchecked inputs \(1\)\*\*\n\n- \*\*webhook\*\* in `A\.spec\.md`\. The input comes from: outside\.\n\n
\nWhat this means and how to fix it<\/summary>\n\nInput from outside this code comes in at these places\. The spec does not say what checks that input\. 1 more place was already like this when Coherence was added\. This list does not show it\.\n/, + /\nTo close one of these, find the place by its name in the `## entrances` section of its spec file\. Add one line under it:\n\n- If the input needs no check, add `control: none - `\.\n- If all of the input goes through one function that a rule in the spec protects, add `guard: `\.\n- If a rule in the spec already checks this input, add the place's name to the `entrances:` line of that rule\.\n\nTo get the line to add, run `npx coherence scaffold control \"\"`\.\n\n<\/details>\n/, ], }, { @@ -360,18 +360,18 @@ describe("ci report says what each finding means and what to do", () => { gaps: [], baselined: [], changed: ["A.spec.md"], - says: [/\n\n1 finding is in a file that this pull request changes\. It has the mark \*\*changed in this pull request\*\*\.\n/, /- `A\.spec\.md:6` [^\n]+ · \*\*changed in this pull request\*\*\n/], + says: [/> \[!IMPORTANT\]\n> These findings do not make this check fail\.\n>\n> 1 finding is in a file that this pull request changes\. It has the mark 📍 \*\*changed in this pull request\*\*\.\n/, /- `A\.spec\.md:6` [^\n]+ · 📍 \*\*changed in this pull request\*\*\n/], }, - { finding: "a spec gap whose handler the pull request changed", spec: SPEC("a"), runs: [{ name: "a door" }], gaps: [WEBHOOK], baselined: [], changed: ["src/hook.ts"], says: [/The input comes from: outside\. · \*\*changed in this pull request\*\*\n/] }, - { finding: "findings in no file the pull request changed", spec: UNNAMED, runs: [], gaps: [WEBHOOK], baselined: [], changed: ["src/other.ts"], says: [/\n\nNone of these findings are in files that this pull request changes\. You do not need to fix them in this pull request\.\n/] }, - { finding: "changed files git could not list", spec: UNNAMED, runs: [], gaps: [], baselined: [], changed: "git diff main failed: bad revision", says: [/\n\nCoherence could not find the files that this pull request changes: git diff main failed: bad revision\.\n/] }, + { finding: "a spec gap whose handler the pull request changed", spec: SPEC("a"), runs: [{ name: "a door" }], gaps: [WEBHOOK], baselined: [], changed: ["src/hook.ts"], says: [/The input comes from: outside\. · 📍 \*\*changed in this pull request\*\*\n/] }, + { finding: "findings in no file the pull request changed", spec: UNNAMED, runs: [], gaps: [WEBHOOK], baselined: [], changed: ["src/other.ts"], says: [/\n> None of these findings are in files that this pull request changes\. You do not need to fix them in this pull request\.\n/] }, + { finding: "changed files git could not list", spec: UNNAMED, runs: [], gaps: [], baselined: [], changed: "git diff main failed: bad revision", says: [/\n> Coherence could not find the files that this pull request changes: git diff main failed: bad revision\.\n/] }, { finding: "no chokepoint run, so there is nothing to fix only in appearance", spec: SPEC("a"), runs: [{ name: "a door", verdict: "not run", reason: "the instrument never answered" }], gaps: [], baselined: [], - says: [/\n\nCoherence could not check everything\. This list can be incomplete\.\n/, /\*\*Rules Coherence could not check \(1\)\*\*\n\n- ○ `a door`: the instrument never answered\n/], + says: [/> \[!WARNING\]\n> Coherence could not check everything\. This list can be incomplete\.\n/, /\*\*⏸️ Rules Coherence could not check \(1\)\*\*\n\n- ○ `a door`: the instrument never answered\n/], }, ] satisfies { finding: string; spec: string; runs: Partial[]; gaps: Gap[]; baselined: string[]; changed?: string[] | string; says: RegExp[] }[]) { test(finding, () => { @@ -386,7 +386,7 @@ describe("ci report says what each finding means and what to do", () => { const root = repository({ "A.spec.md": SPEC("a") }); const model = loadSpecModel(root); const report = renderReport([{ name: ".", model, runs: [record("ci-7", [{ name: "a door" }])], gaps: { unread: "the language server never answered" }, baselined: new Set() }], "ci-7", undefined); - assert.match(report.markdown, /\n\nCoherence could not check everything\. This list can be incomplete\.\n/); + assert.match(report.markdown, /> \[!WARNING\]\n> Coherence could not check everything\. This list can be incomplete\.\n/); }); test("a project whose changed files were read beside one that was not keeps the lead from claiming none of the findings changed", () => { @@ -407,8 +407,8 @@ describe("ci report says what each finding means and what to do", () => { "ci-7", { problems: [{ file: "coherence.config.json", message: "projects is a list of folders relative to the repository top" }], changed: { files: new Set(["coherence.config.json"]) } }, ); - assert.match(changedReport.markdown, /\*\*Project list errors \(1\)\*\*\n\nCoherence cannot read the list of projects in `coherence\.config\.json`\. Fix each line below\.\n\n/); - assert.match(changedReport.markdown, /- `coherence\.config\.json` projects is a list of folders relative to the repository top · \*\*changed in this pull request\*\*\n/); + assert.match(changedReport.markdown, /\*\*⚙️ Project list errors \(1\)\*\*\n\n- `coherence\.config\.json` [^\n]+\n\n
\nWhat this means and how to fix it<\/summary>\n\nCoherence cannot read the list of projects in `coherence\.config\.json`\. Fix each line above\.\n/); + assert.match(changedReport.markdown, /- `coherence\.config\.json` projects is a list of folders relative to the repository top · 📍 \*\*changed in this pull request\*\*\n/); const unchangedReport = renderReport( [{ name: ".", model, runs: [record("ci-7", [{ name: "a door" }])], gaps: { gaps: [] }, baselined: new Set() }], "ci-7", @@ -421,8 +421,8 @@ describe("ci report says what each finding means and what to do", () => { const root = repository({ "A.spec.md": SPEC("a") }); const project = { name: "products/a", model: loadSpecModel(root), runs: [record("ci-7", [{ name: "a door" }])], gaps: { gaps: [] }, baselined: new Set(), changed: { files: new Set() } }; const report = renderReport([project], "ci-7", { problems: [{ file: "coherence.config.json", message: "lists a folder that is not there" }], changed: { unread: "git diff main failed" } }); - assert.match(report.markdown, /\n\nCoherence could not find the files that this pull request changes at the registry top: git diff main failed\.\n/); - assert.match(report.markdown, /\n\nNone of the findings that Coherence could check are in files that this pull request changes\.\n/); + assert.match(report.markdown, /\n> Coherence could not find the files that this pull request changes at the registry top: git diff main failed\.\n/); + assert.match(report.markdown, /\n> None of the findings that Coherence could check are in files that this pull request changes\.\n/); }); test("a file name git would quote still matches its finding", async () => { @@ -439,7 +439,7 @@ describe("ci report says what each finding means and what to do", () => { appendRun(root, record("ci-7", [{ name: "a door" }])); const said = capture(root); assert.equal(await ciReportCommand(["--session", "ci-7", "--base", base], said.io, async () => ({ gaps: [] }), {}), 0); - assert.match(said.out.join("\n"), /- `Café\.spec\.md:1` [^\n]+ · \*\*changed in this pull request\*\*/); + assert.match(said.out.join("\n"), /- `Café\.spec\.md:1` [^\n]+ · 📍 \*\*changed in this pull request\*\*/); }); test("ci report --base, or a pull request's base branch on GitHub Actions, reads the files changed since that commit", async () => { @@ -455,7 +455,7 @@ describe("ci report says what each finding means and what to do", () => { appendRun(root, record("ci-7", [])); const said = capture(root); assert.equal(await ciReportCommand(["--session", "ci-7", "--base", base], said.io, async () => ({ gaps: [] })), 0); - const marked = /1 finding is in a file that this pull request changes[\s\S]*- `A\.spec\.md:6` [^\n]+ · \*\*changed in this pull request\*\*/; + const marked = /1 finding is in a file that this pull request changes[\s\S]*- `A\.spec\.md:6` [^\n]+ · 📍 \*\*changed in this pull request\*\*/; assert.match(said.out.join("\n"), marked); git("update-ref", "refs/remotes/origin/main", base); From 806d8e84916a02067bdcdf9d98c10d6cda3e41d6 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sun, 25 Oct 2026 21:18:41 +0000 Subject: [PATCH 3/5] fix(ci-report): apply review-triage fixes to the PR report Addresses the posthog bot thread and 12 QA Swarm findings on PR #155, per the shepherd's agreed direction: - every report command now reads `npx --no -- coherence ...`, so a reader without a local install does not fetch the wrong package - CLOSE_WAYS and CLOSE_STEPS build from one CLOSE_PARTS list (guard, entrances, control none, in that order), so the two forms cannot drift apart; the entrances step now says "from the same source" - an unchecked input's route is shown again as "It reaches: ``." when it goes further than the entrance's own component - the report now says how many parts of the code and rules it read, and how many rules pass, inside the "What is Coherence?" fold - extracted `howToFix(...)` and `whatThisMeans(...)` helpers for the repeated fold pattern - "at the registry top" reads "for the list of projects in `coherence.config.json`" - added a test for a recorded run that declares no rules (nothing about rules shown) - split `title()` into `heading()` (counted sections) and `label()` (colon-terminated sections) - the no-run message now reads "Coherence has no record of running its check (session ``), so it did not check the rules." - the bugs fold is titled "What this means" since Coherence cannot promise a fix there - "close" stays for unchecked inputs; the broken-rules fold now says "Fix the code..." / "fix the rule in the spec" Updated the matching assertions in ci.test.ts and the spec bullet in Lifecycle.spec.md that described the old behaviour. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 4189fcb8-6fd5-428d-8c5f-4314d28e4dac --- src/lifecycle/Lifecycle.spec.md | 2 +- src/lifecycle/ci-report.ts | 82 ++++++++++++++++++--------------- src/lifecycle/ci.test.ts | 43 +++++++++++++---- src/readings/scope/gaps.ts | 26 ++++++++--- 4 files changed, 99 insertions(+), 54 deletions(-) diff --git a/src/lifecycle/Lifecycle.spec.md b/src/lifecycle/Lifecycle.spec.md index 7334c2e6..8914c916 100644 --- a/src/lifecycle/Lifecycle.spec.md +++ b/src/lifecycle/Lifecycle.spec.md @@ -1283,7 +1283,7 @@ The gyroscope delivered through harness events: orient at start, regulate at sto because: in the comment mode, findings are reported and never fail the job, while a run that never happened is never mistaken for a clean one; a report read from the latest verdicts instead of the session's run would show a pass from another branch refuted: buildReport read every run in each project instead of the CI session's, so another session's later pass hid the failing verdict -> "ci report carries each project's problems, chokepoint verdicts, spec gaps and guard failures as Markdown, and fails only when Coherence did not finish" went red in ci.test.ts on its own assertion; restored byte for byte, green batched and alone (2026-10-09) kinds: none -- the CI report says what each finding means and what to do: Under its counts the report says in plain words whether there is anything to fix, never claiming that when Coherence did not finish or a project's spec gaps were not read; it says that findings do not fail the check and can come from files the pull request did not change; it says in one sentence what Coherence is; it names each kind of finding by what it means to the reader (a spec file error, a broken rule, an unchecked input, a bug that came back) in short active sentences with the fix as a list, shows spec syntax only as code to type, and leaves out a section with nothing in it; it gives the verdict as a coloured GitHub alert (TIP when there is nothing to fix, NOTE for findings outside the pull request's files, IMPORTANT when some are in them, WARNING when Coherence could not check everything), puts an icon before each kind of finding and on the changed-file mark, and lists each section's findings first, with what Coherence is and what each finding means folded in a details block that a familiar reader can skip; each section with a finding says what that kind of finding means and how to fix it, an unchecked input section says how many more the adoption baseline holds and why they are not listed, and a guard failure section says it carries no changed-file mark. Given --base, or else in a GitHub pull request run the pull request's fixed base commit from the triggering event, falling back to the base branch GITHUB_BASE_REF names, it says how many findings are in files changed since that commit, or why it could not tell — saying so without claiming none are when only some projects' changes were read — and marks each one, including a registry problem in a changed top-level config and a failing chokepoint attributed by its recorded files as well as its bypasses. +- the CI report says what each finding means and what to do: Under its counts the report says in plain words whether there is anything to fix, never claiming that when Coherence did not finish or a project's spec gaps were not read; it says that findings do not fail the check and can come from files the pull request did not change; it says in one sentence what Coherence is; it names each kind of finding by what it means to the reader (a spec file error, a broken rule, an unchecked input, a bug that came back) in short active sentences with the fix as a list, shows spec syntax only as code to type, and leaves out a section with nothing in it; it gives the verdict as a coloured GitHub alert (TIP when there is nothing to fix, NOTE for findings outside the pull request's files, IMPORTANT when some are in them, WARNING when Coherence could not check everything), puts an icon before each kind of finding and on the changed-file mark, and lists each section's findings first, with what Coherence is and what each finding means folded in a details block that a familiar reader can skip; each section with a finding says what that kind of finding means, and how to fix it except for a bug that came back, which says only what it means since Coherence cannot tell the fix; an unchecked input section says how many more the adoption baseline holds and why they are not listed, and when an input's route goes further than its own component names where it reaches; and a guard failure section says it carries no changed-file mark. Given --base, or else in a GitHub pull request run the pull request's fixed base commit from the triggering event, falling back to the base branch GITHUB_BASE_REF names, it says how many findings are in files changed since that commit, or why it could not tell — saying so without claiming none are when only some projects' changes were read — and marks each one, including a registry problem in a changed top-level config and a failing chokepoint attributed by its recorded files as well as its bypasses. over: a report with nothing to fix, one Coherence could not finish or read every gap for, a spec problem, a failing chokepoint with a bypass and one attributed only by its own recorded file, a guard failure, an open spec gap beside one the adoption baseline holds, a spec problem and a gap in files the pull request changed, a registry problem in a changed config, findings in none it changed, a project read beside one that was not, a base git cannot diff against at a project or at the registry top, a file name git would quote, and --base, an event's fixed base commit, GITHUB_BASE_REF and neither read from a real repository via: ci report says what each finding means and what to do because: an adopter read a report of spec problems, spec gaps, traced controls and an adoption baseline and could not tell what any of it meant, whether the pull request caused it, or what to change; a report a reader cannot act on is noise on every pull request diff --git a/src/lifecycle/ci-report.ts b/src/lifecycle/ci-report.ts index 2eac6aaf..91e2c51b 100644 --- a/src/lifecycle/ci-report.ts +++ b/src/lifecycle/ci-report.ts @@ -103,9 +103,14 @@ const MARK = { fail: "✕", "not run": "○" } as const; const CHANGED_HERE = " · 📍 **changed in this pull request**"; -/** A section's bold title with its icon, and its count when it lists findings. */ -function title(icon: string, name: string, count?: number): string { - return count === undefined ? `**${icon} ${name}:**` : `**${icon} ${name} (${count})**`; +/** A section's bold heading with its icon and finding count, e.g. "**📄 Spec file errors (1)**". */ +function heading(icon: string, name: string, count: number): string { + return `**${icon} ${name} (${count})**`; +} + +/** A section's bold label with its icon, ending in a colon, for a line that explains itself rather than counting findings. */ +function label(icon: string, name: string): string { + return `**${icon} ${name}:**`; } /** Text a familiar reader can skip, folded under a summary line; GitHub needs the blank lines to read Markdown inside it. */ @@ -115,8 +120,24 @@ function folded(summary: string, ...paragraphs: string[]): string[] { const HOW_TO_FIX = "What this means and how to fix it"; +/** A blank line then a folded "what this means and how to fix it" explanation; the shape every findings section repeats. */ +function howToFix(...paragraphs: string[]): string[] { + return ["", ...folded(HOW_TO_FIX, ...paragraphs)]; +} + +/** A blank line then a folded explanation that only says what a finding means, with no fix to promise. */ +function whatThisMeans(...paragraphs: string[]): string[] { + return ["", ...folded("What this means", ...paragraphs)]; +} + +/** The route's stops beyond the entrance's own component, as a short plain clause, e.g. " It reaches: `store`."; empty when the route goes no further. */ +function routeReach(g: Gap): string { + const rest = g.route.stops.filter((stop) => stop !== g.component); + return rest.length === 0 ? "" : ` It reaches: \`${rest.join(" -> ")}\`.`; +} + /** One project's section, and why its part did not finish. */ -function projectSection(p: ProjectReport, session: string, heading: boolean): { lines: string[]; unfinished: string[]; touched: number } { +function projectSection(p: ProjectReport, session: string, showHeading: boolean): { lines: string[]; unfinished: string[]; touched: number } { const lines: string[] = []; const unfinished: string[] = []; let touched = 0; @@ -127,39 +148,33 @@ function projectSection(p: ProjectReport, session: string, heading: boolean): { return CHANGED_HERE; }; const where = p.name === "." ? "" : ` in ${p.name}`; - if (heading) lines.push(`### ${cell(p.name)}`, ""); + if (showHeading) lines.push(`### ${cell(p.name)}`, ""); if (p.model.problems.length > 0) { const runIn = p.name === "." ? "" : ` in \`${p.name}\``; - lines.push(title("📄", "Spec file errors", p.model.problems.length), ""); + lines.push(heading("📄", "Spec file errors", p.model.problems.length), ""); for (const problem of p.model.problems) lines.push(`- \`${problem.file}:${problem.line}\` ${cell(problem.message)}${mark(problem.file)}`); - lines.push("", ...folded(HOW_TO_FIX, `Coherence cannot read these lines in the spec files. Fix each line. Then run \`npx coherence spec --check\`${runIn} again.`)); + lines.push(...howToFix(`Coherence cannot read these lines in the spec files. Fix each line. Then run \`npx --no -- coherence spec --check\`${runIn} again.`)); } const entries = chokepointEntries(p.runs); if (p.runs.length === 0) { unfinished.push(`no check run was recorded for session ${session}${where}`); - lines.push(`${title("⛔", "Rules")} Coherence has no record of a check run for session ${cell(session)}${where}, so it did not check the rules.`, ""); + lines.push(`${label("⛔", "Rules")} Coherence has no record of running its check (session \`${cell(session)}\`)${where}, so it did not check the rules.`, ""); } else { const fails = entries.filter((e) => e.verdict === "fail"); const notRun = entries.filter((e) => e.verdict === "not run"); if (fails.length > 0) { - lines.push(title("⛔", "Broken rules", fails.length), ""); + lines.push(heading("⛔", "Broken rules", fails.length), ""); for (const e of fails) { lines.push(`- ${MARK.fail} \`${bullet(e)}\`: ${cell(e.reason)}${mark(...e.files, ...e.bypasses.map((b) => b.file))}`); for (const b of e.bypasses.slice(0, BYPASSES)) lines.push(` - used without the function at \`${b.file}:${b.line}\`, in ${cell(b.symbol)}`); - if (e.bypasses.length > BYPASSES) lines.push(` - and ${e.bypasses.length - BYPASSES} more. To see all of them, run \`npx coherence run --form chokepoint\`.`); + if (e.bypasses.length > BYPASSES) lines.push(` - and ${e.bypasses.length - BYPASSES} more. To see all of them, run \`npx --no -- coherence run --form chokepoint\`.`); } - lines.push( - "", - ...folded( - HOW_TO_FIX, - "Each of these rules says that some code or data must only be used through one function. The code above uses it without that function. Change the code to use the function. If the rule is wrong, change the rule in the spec.", - ), - ); + lines.push(...howToFix("Each of these rules says that some code or data must only be used through one function. The code above uses it without that function. Fix the code so it uses the function. If the rule is wrong, fix the rule in the spec.")); } if (notRun.length > 0) { - lines.push(title("⏸️", "Rules Coherence could not check", notRun.length), ""); + lines.push(heading("⏸️", "Rules Coherence could not check", notRun.length), ""); for (const e of notRun) lines.push(`- ${MARK["not run"]} \`${bullet(e)}\`: ${cell(e.reason)}`); lines.push(""); } @@ -167,25 +182,23 @@ function projectSection(p: ProjectReport, session: string, heading: boolean): { } if ("unread" in p.gaps) { - lines.push(`${title("🚪", "Unchecked inputs")} Coherence could not trace the code: ${cell(p.gaps.unread)}.`, ""); + lines.push(`${label("🚪", "Unchecked inputs")} Coherence could not trace the code: ${cell(p.gaps.unread)}.`, ""); } else { const open = p.gaps.gaps.filter((g) => !p.baselined.has(`${g.component}\u0000${g.name}`)); const held = p.gaps.gaps.length - open.length; if (open.length > 0) { const older = held === 0 ? "" : held === 1 ? " 1 more place was already like this when Coherence was added. This list does not show it." : ` ${held} more places were already like this when Coherence was added. This list does not show them.`; - lines.push(title("🚪", "Unchecked inputs", open.length), ""); + lines.push(heading("🚪", "Unchecked inputs", open.length), ""); for (const g of open) { const from = g.trust.length === 0 ? "The spec does not say where the input comes from." : `The input comes from: ${cell(g.trust.join(", "))}.`; - lines.push(`- **${cell(g.name)}** in \`${g.specPath}\`. ${from}${mark(g.file, g.specPath)}`); + lines.push(`- **${cell(g.name)}** in \`${g.specPath}\`. ${from}${routeReach(g)}${mark(g.file, g.specPath)}`); } lines.push( - "", - ...folded( - HOW_TO_FIX, + ...howToFix( `Input from outside this code comes in at these places. The spec does not say what checks that input.${older}`, "To close one of these, find the place by its name in the `## entrances` section of its spec file. Add one line under it:", CLOSE_STEPS.map((step) => `- ${step}`).join("\n"), - 'To get the line to add, run `npx coherence scaffold control ""`.', + 'To get the line to add, run `npx --no -- coherence scaffold control ""`.', ), ); } @@ -193,15 +206,9 @@ function projectSection(p: ProjectReport, session: string, heading: boolean): { const failures = p.model.defects?.guardFailures ?? []; if (failures.length > 0) { - lines.push(title("🐛", "Bugs that came back", failures.length), ""); + lines.push(heading("🐛", "Bugs that came back", failures.length), ""); for (const g of failures) lines.push(`- \`${g.id}\`: kind \`${cell(g.class)}\`. Guard: \`${cell(g.guard)}\`, added when \`${g.resolution}\` closed \`${g.guardedBy}\`.`); - lines.push( - "", - ...folded( - HOW_TO_FIX, - "A guard is a rule that was added to stop one kind of bug. These bugs came back after their guard was added. Find out why the guard did not stop each one. Coherence cannot tell if this pull request caused them.", - ), - ); + lines.push(...whatThisMeans("A guard is a rule that was added to stop one kind of bug. These bugs came back after their guard was added. Find out why the guard did not stop each one. Coherence cannot tell if this pull request caused them.")); } return { lines, unfinished, touched }; } @@ -271,14 +278,17 @@ export function renderReport( const fails = projects.reduce((n, p) => n + chokepointEntries(p.runs).filter((e) => e.verdict === "fail").length, 0); const gaps = projects.reduce((n, p) => n + ("gaps" in p.gaps ? p.gaps.gaps.filter((g) => !p.baselined.has(`${g.component}\u0000${g.name}`)).length : 0), 0); const guards = projects.reduce((n, p) => n + (p.model.defects?.guardFailures.length ?? 0), 0); + const components = projects.reduce((n, p) => n + p.model.counts.components, 0); + const rules = projects.reduce((n, p) => n + p.model.counts.bullets, 0); + const passing = projects.reduce((n, p) => n + chokepointEntries(p.runs).filter((e) => e.verdict === "pass").length, 0); const head = [REPORT_MARKER, "## Coherence", ""]; head.push( `**📄 ${plural(problems, "spec file error")} · ⛔ ${plural(fails, "broken rule")} · 🚪 ${plural(gaps, "unchecked input")} · 🐛 ${plural(guards, "bug that came back", "bugs that came back")}**${projects.length > 1 ? ` in ${plural(projects.length, "project")}` : ""}`, "", - ...folded("What is Coherence?", ABOUT), + ...folded("What is Coherence?", ABOUT, `This check read ${plural(components, "part of the code", "parts of the code")} and ${plural(rules, "rule")}. ${plural(passing, "rule")} ${passing === 1 ? "passes" : "pass"}.`), ); const reads: ChangedWhere[] = projects.map((p) => ({ where: p.name === "." ? "" : ` in ${cell(p.name)}`, changed: p.changed })); - if (registry !== undefined && registry.problems.length > 0) reads.push({ where: " at the registry top", changed: registry.changed }); + if (registry !== undefined && registry.problems.length > 0) reads.push({ where: " for the list of projects in `coherence.config.json`", changed: registry.changed }); head.push(...findingsLead(projects, reads, problems + fails + gaps + guards, touched, unfinished)); if (unfinished.length > 0) { head.push("> [!WARNING]", "> Coherence did not finish. This report is not complete, and the check fails:"); @@ -286,7 +296,7 @@ export function renderReport( head.push(""); } if (registry !== undefined && registry.problems.length > 0) { - head.push(title("⚙️", "Project list errors", registry.problems.length), ""); + head.push(heading("⚙️", "Project list errors", registry.problems.length), ""); registry.problems.forEach((problem, i) => head.push(`- \`${problem.file}\` ${cell(problem.message)}${registryMarks[i]}`)); head.push("", ...folded(HOW_TO_FIX, "Coherence cannot read the list of projects in `coherence.config.json`. Fix each line above.")); } diff --git a/src/lifecycle/ci.test.ts b/src/lifecycle/ci.test.ts index 36d971c4..c78ba318 100644 --- a/src/lifecycle/ci.test.ts +++ b/src/lifecycle/ci.test.ts @@ -282,9 +282,9 @@ test("ci report carries each project's problems, chokepoint verdicts, spec gaps assert.ok(md.startsWith(`${REPORT_MARKER}\n## Coherence\n`), "the marker the workflow finds its comment by comes first"); assert.match(md, /\*\*📄 1 spec file error · ⛔ 1 broken rule · 🚪 1 unchecked input · 🐛 0 bugs that came back\*\* in 2 projects/); assert.doesNotMatch(md, /`a door`/, "a rule that holds is not listed"); - assert.match(md, /### products\/b[\s\S]*\*\*📄 Spec file errors \(1\)\*\*\n\n- `Extra\.spec\.md:1` a folder holds one spec[^\n]*\n\n
\nWhat this means and how to fix it<\/summary>\n\nCoherence cannot read these lines in the spec files\. Fix each line\. Then run `npx coherence spec --check` in `products\/b` again\./); + assert.match(md, /### products\/b[\s\S]*\*\*📄 Spec file errors \(1\)\*\*\n\n- `Extra\.spec\.md:1` a folder holds one spec[^\n]*\n\n
\nWhat this means and how to fix it<\/summary>\n\nCoherence cannot read these lines in the spec files\. Fix each line\. Then run `npx --no -- coherence spec --check` in `products\/b` again\./); assert.match(md, /- ✕ `b door`: 1 reference to ROWS outside door: src\/leak\.ts:3 in leak\n {2}- used without the function at `src\/leak\.ts:3`, in leak\n/); - assert.match(md, /\*\*🚪 Unchecked inputs \(1\)\*\*\n\n- \*\*webhook\*\* in `A\.spec\.md`\. The input comes from: outside\.\n/); + assert.match(md, /\*\*🚪 Unchecked inputs \(1\)\*\*\n\n- \*\*webhook\*\* in `A\.spec\.md`\. The input comes from: outside\. It reaches: `store`\.\n/); assert.match(md, /\*\*🚪 Unchecked inputs:\*\* Coherence could not trace the code: the language server never answered\./, "a gap reading that failed is said, never shown as none"); assert.deepEqual(report.unfinished, [], "findings alone leave the report finished"); assert.doesNotMatch(md, /someone-else|\[!WARNING\]/, "only the CI session's run is read"); @@ -305,7 +305,7 @@ test("ci report carries each project's problems, chokepoint verdicts, spec gaps model.defects = { recorded: 2, closed: 1, guarded: 1, decided: 0, unguarded: [], undeclared: [], guardFailures: [{ id: "df-2", class: "bypass", guard: "products/a/a door", guardedBy: "df-1", resolution: "rs-1" }] }; const guarded = renderReport([{ name: ".", model, runs: [record("ci-7", [{ name: "a door" }])], gaps: { gaps: [] }, baselined: new Set() }], "ci-7", undefined); assert.match(guarded.markdown, /🚪 0 unchecked inputs · 🐛 1 bug that came back\*\*/); - assert.match(guarded.markdown, /\*\*🐛 Bugs that came back \(1\)\*\*\n\n- `df-2`: kind `bypass`\. Guard: `products\/a\/a door`, added when `rs-1` closed `df-1`\.\n\n
\nWhat this means and how to fix it<\/summary>\n\nA guard is a rule that was added to stop one kind of bug\. These bugs came back after their guard was added\. Find out why the guard did not stop each one\. Coherence cannot tell if this pull request caused them\.\n\n<\/details>\n/); + assert.match(guarded.markdown, /\*\*🐛 Bugs that came back \(1\)\*\*\n\n- `df-2`: kind `bypass`\. Guard: `products\/a\/a door`, added when `rs-1` closed `df-1`\.\n\n
\nWhat this means<\/summary>\n\nA guard is a rule that was added to stop one kind of bug\. These bugs came back after their guard was added\. Find out why the guard did not stop each one\. Coherence cannot tell if this pull request caused them\.\n\n<\/details>\n/); assert.doesNotMatch(guarded.markdown, /^### /m, "one project at the root needs no heading"); }); @@ -314,14 +314,14 @@ const WEBHOOK: Gap = { component: ".", name: "webhook", specPath: "A.spec.md", h describe("ci report says what each finding means and what to do", () => { for (const { finding, spec, runs, gaps, baselined, changed, says } of [ - { finding: "nothing", spec: SPEC("a"), runs: [{ name: "a door" }], gaps: [], baselined: [], says: [/\*\*\n\n
\nWhat is Coherence\?<\/summary>\n\nCoherence compares the code with its spec files \(`\*\.spec\.md`\)\. A spec file lists the rules for one part of the code\.\n\n<\/details>\n\n> \[!TIP\]\n> Nothing to fix\.\n/] }, + { finding: "nothing", spec: SPEC("a"), runs: [{ name: "a door" }], gaps: [], baselined: [], says: [/\*\*\n\n
\nWhat is Coherence\?<\/summary>\n\nCoherence compares the code with its spec files \(`\*\.spec\.md`\)\. A spec file lists the rules for one part of the code\.\n\nThis check read 1 part of the code and 1 rule\. 1 rule passes\.\n\n<\/details>\n\n> \[!TIP\]\n> Nothing to fix\.\n/] }, { finding: "a spec problem", spec: UNNAMED, runs: [], gaps: [], baselined: [], - says: [/> \[!NOTE\]\n> These findings do not make this check fail\. Some findings can come from files that this pull request does not change\.\n/, /\*\*📄 Spec file errors \(1\)\*\*\n\n- `A\.spec\.md:6` an invariant bullet starts with a short name and a colon, as in "- \\: \\"\n\n
\nWhat this means and how to fix it<\/summary>\n\nCoherence cannot read these lines in the spec files\. Fix each line\. Then run `npx coherence spec --check` again\.\n\n<\/details>\n/], + says: [/> \[!NOTE\]\n> These findings do not make this check fail\. Some findings can come from files that this pull request does not change\.\n/, /\*\*📄 Spec file errors \(1\)\*\*\n\n- `A\.spec\.md:6` an invariant bullet starts with a short name and a colon, as in "- \\: \\"\n\n
\nWhat this means and how to fix it<\/summary>\n\nCoherence cannot read these lines in the spec files\. Fix each line\. Then run `npx --no -- coherence spec --check` again\.\n\n<\/details>\n/], }, { finding: "a failing chokepoint", @@ -330,7 +330,7 @@ describe("ci report says what each finding means and what to do", () => { gaps: [], baselined: [], says: [ - /\*\*⛔ Broken rules \(1\)\*\*\n\n- ✕ `a door`: [^\n]*\n\n
\nWhat this means and how to fix it<\/summary>\n\nEach of these rules says that some code or data must only be used through one function\. The code above uses it without that function\. Change the code to use the function\. If the rule is wrong, change the rule in the spec\.\n/, + /\*\*⛔ Broken rules \(1\)\*\*\n\n- ✕ `a door`: [^\n]*\n\n
\nWhat this means and how to fix it<\/summary>\n\nEach of these rules says that some code or data must only be used through one function\. The code above uses it without that function\. Fix the code so it uses the function\. If the rule is wrong, fix the rule in the spec\.\n/, ], }, { @@ -349,8 +349,8 @@ describe("ci report says what each finding means and what to do", () => { gaps: [WEBHOOK, { ...WEBHOOK, name: "old" }], baselined: ["old"], says: [ - /\*\*🚪 Unchecked inputs \(1\)\*\*\n\n- \*\*webhook\*\* in `A\.spec\.md`\. The input comes from: outside\.\n\n
\nWhat this means and how to fix it<\/summary>\n\nInput from outside this code comes in at these places\. The spec does not say what checks that input\. 1 more place was already like this when Coherence was added\. This list does not show it\.\n/, - /\nTo close one of these, find the place by its name in the `## entrances` section of its spec file\. Add one line under it:\n\n- If the input needs no check, add `control: none - `\.\n- If all of the input goes through one function that a rule in the spec protects, add `guard: `\.\n- If a rule in the spec already checks this input, add the place's name to the `entrances:` line of that rule\.\n\nTo get the line to add, run `npx coherence scaffold control \"\"`\.\n\n<\/details>\n/, + /\*\*🚪 Unchecked inputs \(1\)\*\*\n\n- \*\*webhook\*\* in `A\.spec\.md`\. The input comes from: outside\. It reaches: `store`\.\n\n
\nWhat this means and how to fix it<\/summary>\n\nInput from outside this code comes in at these places\. The spec does not say what checks that input\. 1 more place was already like this when Coherence was added\. This list does not show it\.\n/, + /\nTo close one of these, find the place by its name in the `## entrances` section of its spec file\. Add one line under it:\n\n- If all of the input goes through one function that a rule in the spec protects, add `guard: `\.\n- If a rule in the spec already checks input from the same source, add the place's name to the `entrances:` line of that rule\.\n- If the input needs no check, add `control: none - `\.\n\nTo get the line to add, run `npx --no -- coherence scaffold control \"\"`\.\n\n<\/details>\n/, ], }, { @@ -362,7 +362,7 @@ describe("ci report says what each finding means and what to do", () => { changed: ["A.spec.md"], says: [/> \[!IMPORTANT\]\n> These findings do not make this check fail\.\n>\n> 1 finding is in a file that this pull request changes\. It has the mark 📍 \*\*changed in this pull request\*\*\.\n/, /- `A\.spec\.md:6` [^\n]+ · 📍 \*\*changed in this pull request\*\*\n/], }, - { finding: "a spec gap whose handler the pull request changed", spec: SPEC("a"), runs: [{ name: "a door" }], gaps: [WEBHOOK], baselined: [], changed: ["src/hook.ts"], says: [/The input comes from: outside\. · 📍 \*\*changed in this pull request\*\*\n/] }, + { finding: "a spec gap whose handler the pull request changed", spec: SPEC("a"), runs: [{ name: "a door" }], gaps: [WEBHOOK], baselined: [], changed: ["src/hook.ts"], says: [/The input comes from: outside\. It reaches: `store`\. · 📍 \*\*changed in this pull request\*\*\n/] }, { finding: "findings in no file the pull request changed", spec: UNNAMED, runs: [], gaps: [WEBHOOK], baselined: [], changed: ["src/other.ts"], says: [/\n> None of these findings are in files that this pull request changes\. You do not need to fix them in this pull request\.\n/] }, { finding: "changed files git could not list", spec: UNNAMED, runs: [], gaps: [], baselined: [], changed: "git diff main failed: bad revision", says: [/\n> Coherence could not find the files that this pull request changes: git diff main failed: bad revision\.\n/] }, { @@ -382,6 +382,29 @@ describe("ci report says what each finding means and what to do", () => { }); } + test("a project with a recorded run that declares no rules says nothing about rules", () => { + const root = repository({ "A.spec.md": "# a\n\nThe a product.\n" }); + const model = loadSpecModel(root); + const report = renderReport([{ name: ".", model, runs: [record("ci-7", [])], gaps: { gaps: [] }, baselined: new Set() }], "ci-7", undefined); + assert.doesNotMatch(report.markdown, /Broken rules|Rules Coherence could not check|Coherence has no record of running its check/, "a run was recorded, and it declared no rules, so there is nothing to say about them"); + }); + + test("no record of a run for the session names the session and says Coherence did not check the rules", () => { + const root = repository({ "A.spec.md": SPEC("a") }); + const model = loadSpecModel(root); + const report = renderReport([{ name: ".", model, runs: [], gaps: { gaps: [] }, baselined: new Set() }], "ci-7", undefined); + assert.match(report.markdown, /\*\*⛔ Rules:\*\* Coherence has no record of running its check \(session `ci-7`\), so it did not check the rules\.\n/); + }); + + test("a spec gap whose route goes no further than its own component says nothing extra after its source", () => { + const root = repository({ "A.spec.md": SPEC("a") }); + const model = loadSpecModel(root); + const gap: Gap = { ...WEBHOOK, route: { ...WEBHOOK.route, stops: ["."] } }; + const report = renderReport([{ name: ".", model, runs: [record("ci-7", [{ name: "a door" }])], gaps: { gaps: [gap] }, baselined: new Set() }], "ci-7", undefined); + assert.match(report.markdown, /The input comes from: outside\.\n/, "no route clause when the route stops at the entrance's own component"); + assert.doesNotMatch(report.markdown, /It reaches:/); + }); + test("spec gaps Coherence could not read keep the lead from claiming there is nothing to fix", () => { const root = repository({ "A.spec.md": SPEC("a") }); const model = loadSpecModel(root); @@ -421,7 +444,7 @@ describe("ci report says what each finding means and what to do", () => { const root = repository({ "A.spec.md": SPEC("a") }); const project = { name: "products/a", model: loadSpecModel(root), runs: [record("ci-7", [{ name: "a door" }])], gaps: { gaps: [] }, baselined: new Set(), changed: { files: new Set() } }; const report = renderReport([project], "ci-7", { problems: [{ file: "coherence.config.json", message: "lists a folder that is not there" }], changed: { unread: "git diff main failed" } }); - assert.match(report.markdown, /\n> Coherence could not find the files that this pull request changes at the registry top: git diff main failed\.\n/); + assert.match(report.markdown, /\n> Coherence could not find the files that this pull request changes for the list of projects in `coherence\.config\.json`: git diff main failed\.\n/); assert.match(report.markdown, /\n> None of the findings that Coherence could check are in files that this pull request changes\.\n/); }); diff --git a/src/readings/scope/gaps.ts b/src/readings/scope/gaps.ts index 78cda92d..87db22ed 100644 --- a/src/readings/scope/gaps.ts +++ b/src/readings/scope/gaps.ts @@ -70,15 +70,27 @@ export const STRUCTURE_DIR = join(".coherence", "structure"); const RECORD_VERSION = 1; +/** The three ways a gap closes, each in the words orient, regulate and the scaffold share and as a plain step for a reader who does not know the spec's terms: guard, entrances, control none. One list, so the two forms cannot drift apart. */ +const CLOSE_PARTS: readonly { spec: string; plain: string }[] = [ + { + spec: "declare guard: where a verified chokepoint wraps it", + plain: "If all of the input goes through one function that a rule in the spec protects, add `guard: `.", + }, + { + spec: "name it on the entrances: line of an invariant whose crossing enters from its trust", + plain: "If a rule in the spec already checks input from the same source, add the place's name to the `entrances:` line of that rule.", + }, + { + spec: "record control: none — ", + plain: "If the input needs no check, add `control: none - `.", + }, +]; + /** The three ways a gap closes, in the words orient, regulate and the scaffold share. */ -export const CLOSE_WAYS = "declare guard: where a verified chokepoint wraps it, name it on the entrances: line of an invariant whose crossing enters from its trust, or record control: none — "; +export const CLOSE_WAYS = `${CLOSE_PARTS[0]!.spec}, ${CLOSE_PARTS[1]!.spec}, or ${CLOSE_PARTS[2]!.spec}`; -/** The same three ways, as plain steps for a reader who does not know the spec's terms; keep the two in step. */ -export const CLOSE_STEPS: readonly string[] = [ - "If the input needs no check, add `control: none - `.", - "If all of the input goes through one function that a rule in the spec protects, add `guard: `.", - "If a rule in the spec already checks this input, add the place's name to the `entrances:` line of that rule.", -]; +/** The same three ways, as plain steps for a reader who does not know the spec's terms; built from the same list as CLOSE_WAYS, so the two stay in step. */ +export const CLOSE_STEPS: readonly string[] = CLOSE_PARTS.map((p) => p.plain); /** An entrance with outside or unknown trust whose route has no traced control, and which declares no control: none. */ export interface Gap { From 180527b36fc1672eba7d1be57fe1afd5b131d20b Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sun, 25 Oct 2026 21:23:37 +0000 Subject: [PATCH 4/5] fix(ci): put every path and name in a safe code span, and count rules only when the check ran them A new code() helper writes a Markdown code span with a fence longer than any backtick run in the text, so a folder or file name cannot end the span early and add links to the comment. It replaces every dynamic code span, which also stops escape backslashes from showing inside a span (`ci\_7`). The "What is Coherence?" fold no longer says "0 rules pass" for a project whose rules the check did not run. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 4189fcb8-6fd5-428d-8c5f-4314d28e4dac --- .coherence/runs/pr-report-ste.ec958af1.jsonl | 3 ++ src/lifecycle/Lifecycle.spec.md | 3 +- src/lifecycle/ci-report.ts | 34 +++++++++++++------- src/lifecycle/ci.test.ts | 14 ++++++-- 4 files changed, 40 insertions(+), 14 deletions(-) create mode 100644 .coherence/runs/pr-report-ste.ec958af1.jsonl diff --git a/.coherence/runs/pr-report-ste.ec958af1.jsonl b/.coherence/runs/pr-report-ste.ec958af1.jsonl new file mode 100644 index 00000000..29f4e0a4 --- /dev/null +++ b/.coherence/runs/pr-report-ste.ec958af1.jsonl @@ -0,0 +1,3 @@ +{"at":"2026-10-25T21:22:46.099Z","session":"pr-report-ste","agent":"claude","binding":"none: this session owns no active work order","commit":"806d8e8","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":35812,"load":{"average":0.99,"cores":8},"batch":{"ms":35308},"invariants":[{"component":"src/lifecycle","name":"the CI report says what each finding means and what to do","form":"totality oracle","verdict":"fail","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":35308,"reason":"2 of 21 tests under \"ci report says what each finding means and what to do\" failed: ci report says what each finding means and what to do a path, name or id with backticks or Markdown stays one code span; ci report says what each finding means and what to do","testMs":431,"mode":"batched","state":"structural defect","enforces":["ci report says what each finding means and what to do"]}]} +{"kind":"refutation","at":"2026-10-25T21:22:46.567Z","session":"pr-report-ste","agent":"claude","component":"src/lifecycle","name":"the CI report says what each finding means and what to do","form":"totality oracle","broke":"made code() in ci-report.ts wrap its text in single backticks","verdict":"fail","reason":"2 of 21 tests under \"ci report says what each finding means and what to do\" failed: ci report says what each finding means and what to do a path, name or id with backticks or Markdown stays one code span; ci report says what each finding means and what to do","commit":"806d8e8","dirty":true} +{"at":"2026-10-25T21:23:22.094Z","session":"pr-report-ste","agent":"claude","binding":"none: this session owns no active work order","commit":"806d8e8","dirty":true,"instrument":{"language":"typescript","server":"none"},"latency":34911,"load":{"average":1.51,"cores":8},"batch":{"ms":34402},"invariants":[{"component":"src/lifecycle","name":"the CI report says what each finding means and what to do","form":"totality oracle","verdict":"pass","refutation":"witnessed","bypasses":[],"testReferences":0,"files":[],"latency":34402,"reason":"21 tests under \"ci report says what each finding means and what to do\" passed in one invocation of node --disable-warning=ExperimentalWarning --import=./src/enforcement/leak-guard.ts --test --test-concurrency=1 --test-reporter=./src/enforcement/node-test-reporter.ts --test-reporter-destination= --test-name-pattern=<1 names> src/**/*.test.ts src/*.e2e.ts","testMs":433,"mode":"batched","state":"invariant","enforces":["ci report says what each finding means and what to do"]}]} diff --git a/src/lifecycle/Lifecycle.spec.md b/src/lifecycle/Lifecycle.spec.md index 8914c916..7a4bb878 100644 --- a/src/lifecycle/Lifecycle.spec.md +++ b/src/lifecycle/Lifecycle.spec.md @@ -1284,7 +1284,7 @@ The gyroscope delivered through harness events: orient at start, regulate at sto refuted: buildReport read every run in each project instead of the CI session's, so another session's later pass hid the failing verdict -> "ci report carries each project's problems, chokepoint verdicts, spec gaps and guard failures as Markdown, and fails only when Coherence did not finish" went red in ci.test.ts on its own assertion; restored byte for byte, green batched and alone (2026-10-09) kinds: none - the CI report says what each finding means and what to do: Under its counts the report says in plain words whether there is anything to fix, never claiming that when Coherence did not finish or a project's spec gaps were not read; it says that findings do not fail the check and can come from files the pull request did not change; it says in one sentence what Coherence is; it names each kind of finding by what it means to the reader (a spec file error, a broken rule, an unchecked input, a bug that came back) in short active sentences with the fix as a list, shows spec syntax only as code to type, and leaves out a section with nothing in it; it gives the verdict as a coloured GitHub alert (TIP when there is nothing to fix, NOTE for findings outside the pull request's files, IMPORTANT when some are in them, WARNING when Coherence could not check everything), puts an icon before each kind of finding and on the changed-file mark, and lists each section's findings first, with what Coherence is and what each finding means folded in a details block that a familiar reader can skip; each section with a finding says what that kind of finding means, and how to fix it except for a bug that came back, which says only what it means since Coherence cannot tell the fix; an unchecked input section says how many more the adoption baseline holds and why they are not listed, and when an input's route goes further than its own component names where it reaches; and a guard failure section says it carries no changed-file mark. Given --base, or else in a GitHub pull request run the pull request's fixed base commit from the triggering event, falling back to the base branch GITHUB_BASE_REF names, it says how many findings are in files changed since that commit, or why it could not tell — saying so without claiming none are when only some projects' changes were read — and marks each one, including a registry problem in a changed top-level config and a failing chokepoint attributed by its recorded files as well as its bypasses. - over: a report with nothing to fix, one Coherence could not finish or read every gap for, a spec problem, a failing chokepoint with a bypass and one attributed only by its own recorded file, a guard failure, an open spec gap beside one the adoption baseline holds, a spec problem and a gap in files the pull request changed, a registry problem in a changed config, findings in none it changed, a project read beside one that was not, a base git cannot diff against at a project or at the registry top, a file name git would quote, and --base, an event's fixed base commit, GITHUB_BASE_REF and neither read from a real repository + over: a report with nothing to fix, one Coherence could not finish or read every gap for, a spec problem, a failing chokepoint with a bypass and one attributed only by its own recorded file, a guard failure, an open spec gap beside one the adoption baseline holds, a spec problem and a gap in files the pull request changed, a registry problem in a changed config, findings in none it changed, a project read beside one that was not, a base git cannot diff against at a project or at the registry top, a file name git would quote, a path or name holding backticks or Markdown, and --base, an event's fixed base commit, GITHUB_BASE_REF and neither read from a real repository via: ci report says what each finding means and what to do because: an adopter read a report of spec problems, spec gaps, traced controls and an adoption baseline and could not tell what any of it meant, whether the pull request caused it, or what to change; a report a reader cannot act on is noise on every pull request refuted: put back the old spec gap words in ci-report.ts, "no traced control" for what the gap means -> "ci report says what each finding means and what to do" went red in ci.test.ts on the spec gap case; restored byte for byte, green (2026-10-25) @@ -1292,6 +1292,7 @@ The gyroscope delivered through harness events: orient at start, regulate at sto refuted: dropped the GITHUB_BASE_REF default in ci-report.ts -> "ci report says what each finding means and what to do" went red in ci.test.ts on the base branch case; restored byte for byte, green (2026-10-25) refuted: dropped the sentence that says what Coherence is from ci-report.ts -> "ci report says what each finding means and what to do" went red in ci.test.ts, 2 of 17 cases; restored byte for byte, green (2026-10-25) refuted: made folded() in ci-report.ts print its text with no details block -> "ci report says what each finding means and what to do" went red in ci.test.ts, 6 of 17 cases; restored byte for byte, green (2026-10-25) + refuted: made code() in ci-report.ts wrap its text in single backticks -> "ci report says what each finding means and what to do" went red in ci.test.ts, 2 of 21 cases; restored byte for byte, green (2026-10-25) kinds: none - a closure mirror is named at the edit that breaks it: At PostToolUse, a write to a file in the value-import closure a declared mirror names is read again, and each file the closure now reaches that the list does not name is added to the edit's context as advice, never a block, naming the file that imports it and the list's file and line; a type-only import adds nothing, and the file the closure grew by is watched from then on. over: a fixture of the b37627e shape: a list of the page's modules, the closure of page.ts, a type-only import added to a closure file, then a value import of a file the list omits diff --git a/src/lifecycle/ci-report.ts b/src/lifecycle/ci-report.ts index 91e2c51b..32f91321 100644 --- a/src/lifecycle/ci-report.ts +++ b/src/lifecycle/ci-report.ts @@ -131,9 +131,18 @@ function whatThisMeans(...paragraphs: string[]): string[] { } /** The route's stops beyond the entrance's own component, as a short plain clause, e.g. " It reaches: `store`."; empty when the route goes no further. */ +/** Text as one Markdown code span whatever it holds: a fence longer than its longest run of backticks, padded where it starts or ends with one. */ +function code(text: string): string { + const flat = text.replace(/\r?\n/g, " "); + const longest = Math.max(0, ...(flat.match(/`+/g) ?? []).map((run) => run.length)); + const fence = "`".repeat(longest + 1); + const pad = flat.startsWith("`") || flat.endsWith("`") ? " " : ""; + return `${fence}${pad}${flat}${pad}${fence}`; +} + function routeReach(g: Gap): string { const rest = g.route.stops.filter((stop) => stop !== g.component); - return rest.length === 0 ? "" : ` It reaches: \`${rest.join(" -> ")}\`.`; + return rest.length === 0 ? "" : ` It reaches: ${code(rest.join(" -> "))}.`; } /** One project's section, and why its part did not finish. */ @@ -151,31 +160,31 @@ function projectSection(p: ProjectReport, session: string, showHeading: boolean) if (showHeading) lines.push(`### ${cell(p.name)}`, ""); if (p.model.problems.length > 0) { - const runIn = p.name === "." ? "" : ` in \`${p.name}\``; + const runIn = p.name === "." ? "" : ` in ${code(p.name)}`; lines.push(heading("📄", "Spec file errors", p.model.problems.length), ""); - for (const problem of p.model.problems) lines.push(`- \`${problem.file}:${problem.line}\` ${cell(problem.message)}${mark(problem.file)}`); + for (const problem of p.model.problems) lines.push(`- ${code(`${problem.file}:${problem.line}`)} ${cell(problem.message)}${mark(problem.file)}`); lines.push(...howToFix(`Coherence cannot read these lines in the spec files. Fix each line. Then run \`npx --no -- coherence spec --check\`${runIn} again.`)); } const entries = chokepointEntries(p.runs); if (p.runs.length === 0) { unfinished.push(`no check run was recorded for session ${session}${where}`); - lines.push(`${label("⛔", "Rules")} Coherence has no record of running its check (session \`${cell(session)}\`)${where}, so it did not check the rules.`, ""); + lines.push(`${label("⛔", "Rules")} Coherence has no record of running its check (session ${code(session)})${where}, so it did not check the rules.`, ""); } else { const fails = entries.filter((e) => e.verdict === "fail"); const notRun = entries.filter((e) => e.verdict === "not run"); if (fails.length > 0) { lines.push(heading("⛔", "Broken rules", fails.length), ""); for (const e of fails) { - lines.push(`- ${MARK.fail} \`${bullet(e)}\`: ${cell(e.reason)}${mark(...e.files, ...e.bypasses.map((b) => b.file))}`); - for (const b of e.bypasses.slice(0, BYPASSES)) lines.push(` - used without the function at \`${b.file}:${b.line}\`, in ${cell(b.symbol)}`); + lines.push(`- ${MARK.fail} ${code(bullet(e))}: ${cell(e.reason)}${mark(...e.files, ...e.bypasses.map((b) => b.file))}`); + for (const b of e.bypasses.slice(0, BYPASSES)) lines.push(` - used without the function at ${code(`${b.file}:${b.line}`)}, in ${cell(b.symbol)}`); if (e.bypasses.length > BYPASSES) lines.push(` - and ${e.bypasses.length - BYPASSES} more. To see all of them, run \`npx --no -- coherence run --form chokepoint\`.`); } lines.push(...howToFix("Each of these rules says that some code or data must only be used through one function. The code above uses it without that function. Fix the code so it uses the function. If the rule is wrong, fix the rule in the spec.")); } if (notRun.length > 0) { lines.push(heading("⏸️", "Rules Coherence could not check", notRun.length), ""); - for (const e of notRun) lines.push(`- ${MARK["not run"]} \`${bullet(e)}\`: ${cell(e.reason)}`); + for (const e of notRun) lines.push(`- ${MARK["not run"]} ${code(bullet(e))}: ${cell(e.reason)}`); lines.push(""); } for (const e of notRun) unfinished.push(`rule ${bullet(e)}${where} was not checked: ${e.reason}`); @@ -191,7 +200,7 @@ function projectSection(p: ProjectReport, session: string, showHeading: boolean) lines.push(heading("🚪", "Unchecked inputs", open.length), ""); for (const g of open) { const from = g.trust.length === 0 ? "The spec does not say where the input comes from." : `The input comes from: ${cell(g.trust.join(", "))}.`; - lines.push(`- **${cell(g.name)}** in \`${g.specPath}\`. ${from}${routeReach(g)}${mark(g.file, g.specPath)}`); + lines.push(`- **${cell(g.name)}** in ${code(g.specPath)}. ${from}${routeReach(g)}${mark(g.file, g.specPath)}`); } lines.push( ...howToFix( @@ -207,7 +216,7 @@ function projectSection(p: ProjectReport, session: string, showHeading: boolean) const failures = p.model.defects?.guardFailures ?? []; if (failures.length > 0) { lines.push(heading("🐛", "Bugs that came back", failures.length), ""); - for (const g of failures) lines.push(`- \`${g.id}\`: kind \`${cell(g.class)}\`. Guard: \`${cell(g.guard)}\`, added when \`${g.resolution}\` closed \`${g.guardedBy}\`.`); + for (const g of failures) lines.push(`- ${code(g.id)}: kind ${code(g.class)}. Guard: ${code(g.guard)}, added when ${code(g.resolution)} closed ${code(g.guardedBy)}.`); lines.push(...whatThisMeans("A guard is a rule that was added to stop one kind of bug. These bugs came back after their guard was added. Find out why the guard did not stop each one. Coherence cannot tell if this pull request caused them.")); } return { lines, unfinished, touched }; @@ -280,12 +289,15 @@ export function renderReport( const guards = projects.reduce((n, p) => n + (p.model.defects?.guardFailures.length ?? 0), 0); const components = projects.reduce((n, p) => n + p.model.counts.components, 0); const rules = projects.reduce((n, p) => n + p.model.counts.bullets, 0); + const checked = projects.reduce((n, p) => n + chokepointEntries(p.runs).length, 0); const passing = projects.reduce((n, p) => n + chokepointEntries(p.runs).filter((e) => e.verdict === "pass").length, 0); + const read = `This check read ${plural(components, "part of the code", "parts of the code")} and ${plural(rules, "rule")}.`; + const ran = checked === 0 ? "" : ` It checked ${plural(checked, "rule")}. ${plural(passing, "rule")} ${passing === 1 ? "passes" : "pass"}.`; const head = [REPORT_MARKER, "## Coherence", ""]; head.push( `**📄 ${plural(problems, "spec file error")} · ⛔ ${plural(fails, "broken rule")} · 🚪 ${plural(gaps, "unchecked input")} · 🐛 ${plural(guards, "bug that came back", "bugs that came back")}**${projects.length > 1 ? ` in ${plural(projects.length, "project")}` : ""}`, "", - ...folded("What is Coherence?", ABOUT, `This check read ${plural(components, "part of the code", "parts of the code")} and ${plural(rules, "rule")}. ${plural(passing, "rule")} ${passing === 1 ? "passes" : "pass"}.`), + ...folded("What is Coherence?", ABOUT, read + ran), ); const reads: ChangedWhere[] = projects.map((p) => ({ where: p.name === "." ? "" : ` in ${cell(p.name)}`, changed: p.changed })); if (registry !== undefined && registry.problems.length > 0) reads.push({ where: " for the list of projects in `coherence.config.json`", changed: registry.changed }); @@ -297,7 +309,7 @@ export function renderReport( } if (registry !== undefined && registry.problems.length > 0) { head.push(heading("⚙️", "Project list errors", registry.problems.length), ""); - registry.problems.forEach((problem, i) => head.push(`- \`${problem.file}\` ${cell(problem.message)}${registryMarks[i]}`)); + registry.problems.forEach((problem, i) => head.push(`- ${code(problem.file)} ${cell(problem.message)}${registryMarks[i]}`)); head.push("", ...folded(HOW_TO_FIX, "Coherence cannot read the list of projects in `coherence.config.json`. Fix each line above.")); } const markdown = [...head, ...sections].join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; diff --git a/src/lifecycle/ci.test.ts b/src/lifecycle/ci.test.ts index c78ba318..7fd41dbb 100644 --- a/src/lifecycle/ci.test.ts +++ b/src/lifecycle/ci.test.ts @@ -314,14 +314,14 @@ const WEBHOOK: Gap = { component: ".", name: "webhook", specPath: "A.spec.md", h describe("ci report says what each finding means and what to do", () => { for (const { finding, spec, runs, gaps, baselined, changed, says } of [ - { finding: "nothing", spec: SPEC("a"), runs: [{ name: "a door" }], gaps: [], baselined: [], says: [/\*\*\n\n
\nWhat is Coherence\?<\/summary>\n\nCoherence compares the code with its spec files \(`\*\.spec\.md`\)\. A spec file lists the rules for one part of the code\.\n\nThis check read 1 part of the code and 1 rule\. 1 rule passes\.\n\n<\/details>\n\n> \[!TIP\]\n> Nothing to fix\.\n/] }, + { finding: "nothing", spec: SPEC("a"), runs: [{ name: "a door" }], gaps: [], baselined: [], says: [/\*\*\n\n
\nWhat is Coherence\?<\/summary>\n\nCoherence compares the code with its spec files \(`\*\.spec\.md`\)\. A spec file lists the rules for one part of the code\.\n\nThis check read 1 part of the code and 1 rule\. It checked 1 rule\. 1 rule passes\.\n\n<\/details>\n\n> \[!TIP\]\n> Nothing to fix\.\n/] }, { finding: "a spec problem", spec: UNNAMED, runs: [], gaps: [], baselined: [], - says: [/> \[!NOTE\]\n> These findings do not make this check fail\. Some findings can come from files that this pull request does not change\.\n/, /\*\*📄 Spec file errors \(1\)\*\*\n\n- `A\.spec\.md:6` an invariant bullet starts with a short name and a colon, as in "- \\: \\"\n\n
\nWhat this means and how to fix it<\/summary>\n\nCoherence cannot read these lines in the spec files\. Fix each line\. Then run `npx --no -- coherence spec --check` again\.\n\n<\/details>\n/], + says: [/This check read 1 part of the code and 1 rule\.\n\n<\/details>/, /> \[!NOTE\]\n> These findings do not make this check fail\. Some findings can come from files that this pull request does not change\.\n/, /\*\*📄 Spec file errors \(1\)\*\*\n\n- `A\.spec\.md:6` an invariant bullet starts with a short name and a colon, as in "- \\: \\"\n\n
\nWhat this means and how to fix it<\/summary>\n\nCoherence cannot read these lines in the spec files\. Fix each line\. Then run `npx --no -- coherence spec --check` again\.\n\n<\/details>\n/], }, { finding: "a failing chokepoint", @@ -448,6 +448,16 @@ describe("ci report says what each finding means and what to do", () => { assert.match(report.markdown, /\n> None of the findings that Coherence could check are in files that this pull request changes\.\n/); }); + test("a path, name or id with backticks or Markdown stays one code span", () => { + const root = repository({ "A.spec.md": SPEC("a") }); + const model = loadSpecModel(root); + const gap: Gap = { ...WEBHOOK, specPath: "s`p/A.spec.md", route: { ...WEBHOOK.route, stops: [".", "a`b](https://x.test)"] } }; + const report = renderReport([{ name: ".", model, runs: [record("ci-7", [{ name: "a door" }])], gaps: { gaps: [gap] }, baselined: new Set() }], "ci-7", undefined); + assert.match(report.markdown, /in ``s`p\/A\.spec\.md``\. The input comes from: outside\. It reaches: ``a`b\]\(https:\/\/x\.test\)``\.\n/); + const unrun = renderReport([{ name: ".", model, runs: [], gaps: { gaps: [] }, baselined: new Set() }], "ci_7", undefined); + assert.match(unrun.markdown, /\(session `ci_7`\)/, "no escape backslash shows inside a code span"); + }); + test("a file name git would quote still matches its finding", async () => { const root = repository({ "A.spec.md": SPEC("a") }); const git = (...args: string[]): string => execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...args], { cwd: root, encoding: "utf8" }).trim(); From dff951cbc1f595d40f684827552ded953edf794e Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Sun, 25 Oct 2026 21:30:12 +0000 Subject: [PATCH 5/5] fix(ci): show the stops after a route's start, and test counts across projects A route starts where its entrance is declared, so "It reaches:" now lists route.stops after the first. Before, it removed the gap's own component, which differs from the first stop for a deeply nested folder. The routeReach comment now sits above routeReach. A new test checks the fold's counts across two projects. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 4189fcb8-6fd5-428d-8c5f-4314d28e4dac --- src/lifecycle/ci-report.ts | 4 ++-- src/lifecycle/ci.test.ts | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/lifecycle/ci-report.ts b/src/lifecycle/ci-report.ts index 32f91321..b0fc8ed7 100644 --- a/src/lifecycle/ci-report.ts +++ b/src/lifecycle/ci-report.ts @@ -130,7 +130,6 @@ function whatThisMeans(...paragraphs: string[]): string[] { return ["", ...folded("What this means", ...paragraphs)]; } -/** The route's stops beyond the entrance's own component, as a short plain clause, e.g. " It reaches: `store`."; empty when the route goes no further. */ /** Text as one Markdown code span whatever it holds: a fence longer than its longest run of backticks, padded where it starts or ends with one. */ function code(text: string): string { const flat = text.replace(/\r?\n/g, " "); @@ -140,8 +139,9 @@ function code(text: string): string { return `${fence}${pad}${flat}${pad}${fence}`; } +/** The route's stops after its first, where the entrance is declared, as a short plain clause, e.g. " It reaches: `store`."; empty when the route goes no further. */ function routeReach(g: Gap): string { - const rest = g.route.stops.filter((stop) => stop !== g.component); + const rest = g.route.stops.slice(1); return rest.length === 0 ? "" : ` It reaches: ${code(rest.join(" -> "))}.`; } diff --git a/src/lifecycle/ci.test.ts b/src/lifecycle/ci.test.ts index 7fd41dbb..ccf24e72 100644 --- a/src/lifecycle/ci.test.ts +++ b/src/lifecycle/ci.test.ts @@ -458,6 +458,22 @@ describe("ci report says what each finding means and what to do", () => { assert.match(unrun.markdown, /\(session `ci_7`\)/, "no escape backslash shows inside a code span"); }); + test("an unchecked input reaches the stops after its route's first, whatever folder declared it", () => { + const root = repository({ "A.spec.md": SPEC("a") }); + const model = loadSpecModel(root); + const nested: Gap = { ...WEBHOOK, component: "a/b/c", route: { ...WEBHOOK.route, stops: ["a", "store"] } }; + const report = renderReport([{ name: ".", model, runs: [record("ci-7", [{ name: "a door" }])], gaps: { gaps: [nested] }, baselined: new Set() }], "ci-7", undefined); + assert.match(report.markdown, /The input comes from: outside\. It reaches: `store`\.\n/); + }); + + test("the rules counted in the fold add up across projects", () => { + const root = repository({ "A.spec.md": SPEC("a") }); + const model = loadSpecModel(root); + const project = (name: string, verdict: "pass" | "fail"): ProjectReport => ({ name, model, runs: [record("ci-7", [{ name: "a door", verdict }])], gaps: { gaps: [] }, baselined: new Set() }); + const report = renderReport([project("one", "pass"), project("two", "fail")], "ci-7", undefined); + assert.match(report.markdown, /This check read 2 parts of the code and 2 rules\. It checked 2 rules\. 1 rule passes\.\n/); + }); + test("a file name git would quote still matches its finding", async () => { const root = repository({ "A.spec.md": SPEC("a") }); const git = (...args: string[]): string => execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...args], { cwd: root, encoding: "utf8" }).trim();