diff --git a/.github/workflows/changeset-presence.yml b/.github/workflows/changeset-presence.yml index 67e6af2b90..462de968e1 100644 --- a/.github/workflows/changeset-presence.yml +++ b/.github/workflows/changeset-presence.yml @@ -246,9 +246,16 @@ jobs: ); } + // BOTH halves (objectui#9509). `findings` is the went-false half — + // somebody else's pending changeset — and `bornFalse` is this pull + // request's own prose. A born-false-only run must still create the + // comment, or the one half nothing later will ever turn red is + // delivered to the job log the ruling measured at zero answers out + // of four. let findings = 0; try { - findings = JSON.parse(fs.readFileSync('claims.json', 'utf8')).findings.length; + const measured = JSON.parse(fs.readFileSync('claims.json', 'utf8')); + findings = measured.findings.length + (measured.bornFalse ?? []).length; } catch (error) { core.warning(`Could not read the finding count: ${error.message}`); } diff --git a/scripts/__tests__/check-changeset-claims.test.ts b/scripts/__tests__/check-changeset-claims.test.ts index db4c119562..a413fe965f 100644 --- a/scripts/__tests__/check-changeset-claims.test.ts +++ b/scripts/__tests__/check-changeset-claims.test.ts @@ -5,7 +5,19 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { audit, namedFiles, paragraphNaming, resolveNamed, treeIndex } from '../check-changeset-claims.mjs'; +import { + BORN_FALSE_VERDICTS, + audit, + evaluateBornFalseControls, + judgeAddress, + lineAddresses, + mapLine, + namedFiles, + paragraphNaming, + resolveNamed, + sentenceAround, + treeIndex, +} from '../check-changeset-claims.mjs'; /** * objectui#9003 — a pending changeset's prose is judged by nothing. @@ -30,8 +42,10 @@ import { audit, namedFiles, paragraphNaming, resolveNamed, treeIndex } from '../ * file left alone — so a green can never come from the gate looking at * nothing. * 3. **The exclusions are deliberate, not accidents.** A changeset this change - * ADDS is never reported (which is also why the gate is blind to a BORN - * FALSE claim — objectui#8759 — and that limit is pinned as a limit). A + * ADDS is never reported BY THE WENT-FALSE HALF. ⚠️ That is no longer the + * same sentence as "the gate is blind to BORN FALSE": objectui#9509 added a + * second reading with its own corpus and its own coordinate, and section 6 + * below pins it. The exclusion itself is unchanged and still load-bearing. A * changeset declaring no bump is never reported: its body never publishes. * An ambiguously-named file is never reported: a span resolving to many * files names none of them. @@ -158,11 +172,20 @@ interface Run { * anyway, so this is what a reader actually gets. */ function runGate(root: string, args: string[] = [], env: Record = {}): Run { + // ⚠️ EVERY case decides its own corpus. `GITHUB_EVENT_PATH` is exported to every + // process on a runner, and the gate reads it when `--pr-body` is absent — so an + // inherited environment fed the gate under test the REAL pull request body of + // whatever build was running, in a temp repository that has nothing to do with + // it (objectui#9509, patch round 1: measured, off by exactly one body). The + // cases that did not redden survived it by luck, not by hermeticity, so it is + // stripped here for all of them rather than at the two that noticed. A case + // that WANTS the variable sets it back through `env`. + const { GITHUB_EVENT_PATH: _inherited, ...hermetic } = process.env; const run = spawnSync('node', [path.join(repoRoot, GATE), '--root', root, ...args], { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, ...env }, + env: { ...hermetic, ...env }, }); return { status: run.status ?? -1, output: `${run.stdout ?? ''}${run.stderr ?? ''}` }; } @@ -339,15 +362,24 @@ describe('a changeset THIS change adds', () => { fixture.commit('fix(alpha): rewrite untouched'); const run = runGate(fixture.root, lastCommitRange(fixture)); - it('is never reported — and that is the BORN-FALSE blind spot, pinned as a limit', () => { - // objectui#8759 was born false: authored against the merge base while - // describing the head. Excluding a change's own changesets is what makes the - // gate readable at all (they name their own files by construction), and it - // is exactly why this gate cannot see that sub-shape. It says so in its own - // output rather than letting a reader assume coverage. + it('is never reported by the WENT-FALSE half — the exclusion that keeps it readable', () => { + // Excluding a change's own changesets is what makes that half readable at + // all: they name their own files by construction, so it would fire on + // nearly every change here. objectui#9509 ⛔ did not relax this — it took + // those same bodies on a different coordinate (section 6). expect(run.output).toContain('No pending changeset names a file this change touches'); expect(run.status).toBe(0); }); + + it('⛔ reports a corpus it read but could not judge as neither clean nor a finding', () => { + // This fixture's own changeset IS in the born-false corpus and spells no + // line address. "Read, but nothing to judge" and "every address checked + // out" are different answers, and one tick for both teaches the reader to + // skim the tick. + expect(run.output).toContain('Corpus: 1 body(ies)'); + expect(run.output).toContain('Read, but nothing to judge'); + expect(run.output).not.toContain('either names the tree it was read'); + }); }); describe('a changeset that declares no bump', () => { @@ -396,6 +428,20 @@ describe('a file the change DELETES', () => { expect(run.output).toContain('THIS CHANGE LEAVES NO SUCH FILE'); expect(run.status).toBe(0); }); + + it('⛔ no longer prints that a born-false claim is outside the gate entirely', () => { + // ⚠️ THE CARRIER INVERTED, so a bare count of the words "BORN false" across + // this landing proves nothing — the phrase survives in both trees. What + // changed is what the sentence SAYS. The footer used to name born-false as a + // whole class the gate cannot see ("a changeset this change adds is excluded + // by construction"); it now names the ONE born-false shape still out of + // reach — an ordinal claim spelling no line address — and the section above + // it reads the rest. This is asserted on a run that HAS went-false findings, + // because that footer prints nowhere else. + expect(run.output).not.toContain('A changeset this change adds is excluded by construction'); + expect(run.output).toContain('A born-false claim carrying no LINE ADDRESS'); + expect(run.output).toContain('Born false — claims this change publishes about a tree it replaced'); + }); }); describe('.changeset/README.md', () => { @@ -623,3 +669,382 @@ describe('--json, the hand-off the pull request comment is rendered from', () => expect(broken.output).toContain('Could not write'); }); }); + +// ── 6. the BORN-FALSE reading (objectui#9509) ──────────────────────────────── + +/** + * objectui#9509: a claim written against the MERGE BASE while describing the + * HEAD, falsified by the diff's OWN insertion. It fails worse than went-false — + * it is false at the moment of publication, and ⛔ no later event will ever turn + * it red. + * + * The geometry every case below uses is objectui#9496's, because that pull + * request published the arithmetic independently of this file: its body and the + * docblock it landed both state `:246`@`b8a006883d` = `:274`@head. ⇒ 274 is + * attested OUTSIDE this test and cannot be quietly re-derived to match a mapper + * that drifts. + */ +describe('mapLine — where this change moves a base line', () => { + // `@@ -220,0 +221,28 @@` then `@@ -223 +251 @@`, measured on 8700d6d93. + const hunks = [ + { oldStart: 220, oldCount: 0, newCount: 28 }, + { oldStart: 223, oldCount: 1, newCount: 1 }, + ]; + + it('reproduces the figure objectui#9496 published: :246 at the base is :274 at the head', () => { + expect(mapLine(hunks, 246)).toBe(274); + }); + + it('reports a rewritten line as gone rather than as some other number', () => { + // Base `:223` IS the line the repair rewrites. Returning 251 for it would be + // the worst possible answer: a number that resolves, to the wrong thing. + expect(mapLine(hunks, 223)).toBeNull(); + }); + + it('leaves the insertion point itself alone — the off-by-one that would fake a finding', () => { + // git spells a pure insertion as "after old line 220", so 220 is untouched + // and 221 is not. An implementation that moved 220 would report stable + // citations as displaced, which is the false positive this reading can + // least afford on a report-only channel. + expect(mapLine(hunks, 220)).toBe(220); + expect(mapLine(hunks, 221)).toBe(249); + }); + + it('leaves a line above every hunk where it is', () => { + expect(mapLine(hunks, 100)).toBe(100); + }); +}); + +describe('lineAddresses — what counts as an address, and what binds it', () => { + const resolve = (span: string): string | null => + span.includes('imported-defaults.ts') ? 'packages/types/src/zod/imported-defaults.ts' : null; + + it('binds a bare `:246` to the file named earlier in ITS paragraph', () => { + // Both carded instances were written this way. Reading `:246` without that + // binding would turn every port number and every `key: 246` into a citation. + const rows = lineAddresses('The shape recurs in `imported-defaults.ts`: `:223` and `:246`.', resolve); + expect(rows.map((r) => r.line)).toEqual([223, 246]); + expect(rows.every((r) => r.file.endsWith('imported-defaults.ts'))).toBe(true); + }); + + it('⛔ never carries that binding across a blank line into somebody else\'s subject', () => { + const rows = lineAddresses('See `imported-defaults.ts` for the walker.\n\nThe frame is at `:246`.', resolve); + expect(rows).toEqual([]); + }); + + it('reads the sha binding per SENTENCE, ⛔ not per paragraph', () => { + // THE load-bearing choice. objectui#9496's §2 paragraph names a sha, and a + // paragraph-wide window would have exempted the very claim the card is + // about. A sha three sentences away binds nothing. + const bound = lineAddresses('At `b8a006883d` the shape recurs in `imported-defaults.ts`: `:246`.', resolve); + expect(bound[0].bound).toBe(true); + + const adrift = lineAddresses( + 'The repair landed at `b8a006883d`. The shape recurs in `imported-defaults.ts`: `:246`.', + resolve, + ); + expect(adrift[0].bound).toBe(false); + }); + + it('accepts `at this head` as a binding — it names a tree as definitely as a sha', () => { + const rows = lineAddresses('At this head `imported-defaults.ts:274` is the tuple arm.', resolve); + expect(rows[0].bound).toBe(true); + }); + + it('⛔ does not read a bare decimal as a sha', () => { + // `12345678` is a number. Accepting it would let any figure in a sentence + // silence every address beside it. + const rows = lineAddresses('Run 12345678 read `imported-defaults.ts:246` as the tuple arm.', resolve); + expect(rows[0].bound).toBe(false); + }); + + it('keeps a column and a range, because the FIRST number is the one that moves', () => { + const rows = lineAddresses( + 'The frame is `imported-defaults.ts:281:75` and the arm is `imported-defaults.ts:221-228`.', + resolve, + ); + expect(rows.map((r) => r.line)).toEqual([281, 221]); + }); + + it('drops a spelling that resolves to no tracked file — it names nothing definite', () => { + expect(lineAddresses('The frame is at `…9088:189:7`.', resolve)).toEqual([]); + }); +}); + +describe('sentenceAround', () => { + it('stops at the sentence boundary rather than running to the paragraph end', () => { + const paragraph = 'First at `abc1234`. Second cites `:246`. Third.'; + expect(sentenceAround(paragraph, paragraph.indexOf('`:246`'))).not.toContain('abc1234'); + }); +}); + +describe('judgeAddress — the arithmetic, never the meaning', () => { + const hunks = [{ oldStart: 220, oldCount: 0, newCount: 28 }]; + const address = { file: 'a.ts', line: 246, bound: false }; + + it('reports a moved line in a file this change MODIFIES', () => { + const verdict = judgeAddress(address, () => 'modified', () => hunks); + expect(verdict).toEqual({ verdict: 'moved', movedTo: 274 }); + expect(BORN_FALSE_VERDICTS.has(verdict.verdict)).toBe(true); + }); + + it('reports any address into a file this change ADDS as unanchored', () => { + // Instance 1's shape: the frame moved TWICE, between revisions of one + // branch. There is no tree outside the pull request in which that number can + // be read, so binding it is the only thing that can make it durable. + const verdict = judgeAddress(address, () => 'added', () => hunks); + expect(verdict.verdict).toBe('unanchored'); + }); + + it('⛔ says nothing about a file this change does not touch', () => { + // That address may well be false, but nothing about THIS diff made it so. + // It is the citation census's population (`check-new-cross-file-line-citations.mjs`) + // and ⛔ not this one's — one reader per population. + expect(judgeAddress(address, () => null, () => hunks).verdict).toBe('untouched'); + }); + + it('⛔ says nothing about an address bound to the tree it was read from', () => { + // The durable form. A gate that reported it would be teaching authors to + // unbind, which is the opposite of what objectui#9509 asks for. + const verdict = judgeAddress({ ...address, bound: true }, () => 'modified', () => hunks); + expect(verdict.verdict).toBe('anchored'); + }); + + it('distinguishes a stable line from a moved one — without this it is an absolute count', () => { + expect(judgeAddress({ ...address, line: 100 }, () => 'modified', () => hunks).verdict).toBe('stable'); + }); +}); + +describe('the born-false controls', () => { + it('all pass against objectui#9496\'s own geometry', () => { + const failures = evaluateBornFalseControls().filter((control) => !control.ok); + expect(failures.map((f) => `${f.id}: ${f.detail}`)).toEqual([]); + }); + + it('CAN fail — a judge that lies is caught rather than passed', () => { + // ⭐ The firing control on the controls. A suite that cannot be made to fail + // is decoration, and a differential reader that reports zero because its + // differ broke is indistinguishable from prose with nothing wrong in it. + const lying = (): { verdict: string; movedTo: null } => ({ verdict: 'stable', movedTo: null }); + const failed = evaluateBornFalseControls(lying).filter((control) => !control.ok); + expect(failed.map((f) => f.id)).toEqual([ + 'unbound-address-into-a-line-this-diff-moves', + 'an-address-into-a-file-this-change-adds-is-unanchored', + ]); + }); + + it('keeps BOTH directions — a control suite that only ever fires proves nothing', () => { + const controls = evaluateBornFalseControls(); + expect(controls.filter((c) => c.detail === '(silent)').length).toBeGreaterThan(0); + expect(controls.filter((c) => c.detail !== '(silent)').length).toBeGreaterThan(0); + }); +}); + +describe('end to end — a pull request body read against its own diff', () => { + const fixture = fixtureRepo('born-false'); + // 20 lines, so a citation into the middle of it has somewhere to be moved to. + fixture.write( + 'packages/alpha/src/walker.ts', + Array.from({ length: 20 }, (_, i) => `export const step${i + 1} = ${i + 1};\n`).join(''), + ); + fixture.commit('feat(alpha): the walker'); + // The change under test INSERTS above the cited line, exactly as objectui#9496 + // did, and adds a brand-new file. + fixture.write( + 'packages/alpha/src/walker.ts', + '// inserted\n// inserted\n// inserted\n' + + Array.from({ length: 20 }, (_, i) => `export const step${i + 1} = ${i + 1};\n`).join(''), + ); + fixture.write('packages/alpha/src/brand-new.ts', 'export const fresh = 1;\n'); + const head = fixture.commit('feat(alpha): insert above the cited line'); + const base = fixture.git('rev-parse', 'HEAD~1'); + + const bodyFile = path.join(fixture.root, 'body.md'); + fs.writeFileSync( + bodyFile, + 'The subject is `walker.ts:10`.\n\n' + + `At \`${base}\` the subject is \`walker.ts:10\`.\n\n` + + 'The new pin is `brand-new.ts:1`.\n\n' + + 'Untouched: `reconciliation.test.ts:1`.\n', + ); + const run = runGate(fixture.root, ['--base', base, '--head', head, '--pr-body', bodyFile]); + + it('reports the unbound address the diff moved, and says where it went', () => { + expect(run.output).toContain('this change moves packages/alpha/src/walker.ts:10 to :13'); + }); + + it('reports an address into a file this change ADDS as having no tree outside the pull request', () => { + expect(run.output).toContain('exists in no tree outside this pull request'); + }); + + it('⛔ stays silent on the same address bound to a sha — the discrimination', () => { + // ⛔ A checker that flags everything is not a checker. Four addresses were + // read; two were reported. Both halves of that split are the measurement. + expect(run.output).toContain('Line addresses read in them: 4'); + expect(run.output).toContain('2 address(es) in the prose this change publishes'); + }); + + it('⛔ stays silent on an address into a file this change does not touch', () => { + expect(run.output).not.toContain('reconciliation.test.ts:1'); + }); + + it('asks for the number to be BOUND, ⛔ never for it to be corrected', () => { + // The card states this before anything else: correcting `:246` to `:274` + // produces a claim that is true today and born false again on the next + // insertion. ⇒ the instruction has to be the durable form, or the gate + // manufactures the next instance. + expect(run.output).toContain('BIND THE NUMBER TO THE TREE IT WAS READ FROM'); + expect(run.output).toContain('not an instruction to'); + }); + + it('is REPORT-ONLY — findings do not fail the run', () => { + expect(run.status).toBe(0); + }); +}); + +describe('the floor under the born-false census', () => { + const fixture = fixtureRepo('born-false-floor'); + fixture.write('packages/alpha/src/untouched.ts', 'export const untouched = 2;\n'); + fixture.commit('chore(alpha): touch a file, publish no prose'); + const run = runGate(fixture.root, lastCommitRange(fixture)); + + it('⛔ never reports an empty corpus as clean', () => { + // A merge_group build has no pull request body, and a change may add no + // changeset. Reading NOTHING and printing a tick would be reporting "this + // change publishes no false claim" on a run that read no prose at all + // (objectstack#4928). + expect(run.output).toContain('Corpus: 0 body(ies)'); + expect(run.output).toContain('NOT a clean verdict'); + expect(run.output).toContain('measured NOTHING'); + }); + + it('⛔ does not print the all-clear line it prints when it DID read prose', () => { + expect(run.output).not.toContain('either names the tree it was read'); + }); +}); + +describe('the corpus is the prose this change publishes about itself', () => { + it('reads the pull request body from the event payload the job already receives', () => { + // ⛔ No new workflow, no new required context, no new permission and no API + // call: `GITHUB_EVENT_PATH` is a file on the runner, and + // `check-governed-queue-guard.mjs` already reads it the same way. + const gate = fs.readFileSync(path.join(repoRoot, GATE), 'utf8'); + expect(gate).toContain('GITHUB_EVENT_PATH'); + expect(gate).toContain('pull_request?.body'); + }); + + it('⛔ does not take the tree at large — that population has a reader already', () => { + // A citation written into an ordinary source file is + // `check-new-cross-file-line-citations.mjs`'s differential population. Two + // readers over one population is how two answers start disagreeing. + expect(fs.existsSync(path.join(repoRoot, 'scripts/check-new-cross-file-line-citations.mjs'))).toBe(true); + const gate = fs.readFileSync(path.join(repoRoot, GATE), 'utf8'); + expect(gate).toContain('check-new-cross-file-line-citations.mjs'); + }); + + it('delivers a born-false-only finding instead of leaving it in the job log', () => { + // objectui#9140 measured the job-log channel at zero answers out of four. + // The comment step decides whether to post from the finding COUNT, so a run + // whose only finding is born-false has to be counted there too. + expect(workflowYaml).toContain('measured.findings.length + (measured.bornFalse ?? []).length'); + }); +}); + +describe('the boundary control the first ablation of this change exposed', () => { + it('catches the off-by-one that the other four controls all pass', () => { + // ⚠️ MEASURED, not anticipated. The first ablation of this change mutated + // `line <= hunk.oldStart` to `line <`, and the pin in section 6 went red + // while ALL FOUR of the gate's own controls stayed green — so the gate + // would have reported "instrument fine" while silently marking every stable + // citation at an insertion point as moved. A control suite that cannot see + // the mutation its own unit tests can see is not a self-check. + const ids = evaluateBornFalseControls().map((control) => control.id); + expect(ids).toContain('the-insertion-point-itself-does-not-move'); + expect(evaluateBornFalseControls().every((control) => control.ok)).toBe(true); + }); +}); + +// ── 7. the corpus may not be AMBIENT (objectui#9509, patch round 1) ────────── + +/** + * ⭐ Found by this file's own empty-corpus floor, in CI, ⛔ not by review. + * + * `GITHUB_EVENT_PATH` is exported to every process on a runner. The gate reads + * it when `--pr-body` is absent, so a run against a throwaway fixture repository + * picked up the REAL pull request body of the build that happened to be running + * and counted it as "the prose this change publishes about itself" — off by + * exactly one body. The floor whose whole job is "this run measured NOTHING" + * slid up to the next floor instead. ⇒ the reading built to be unfakeable was + * being fed by the environment. + * + * The repair is a predicate about the TREE rather than about how the process was + * launched: the payload names `pull_request.head.sha`, and a tree that cannot + * resolve that commit is not the tree the event is about. + */ +describe('an event payload from another tree', () => { + const fixture = fixtureRepo('ambient-corpus'); + fixture.write('packages/alpha/src/untouched.ts', 'export const untouched = 3;\n'); + fixture.commit('chore(alpha): touch a file, publish no prose'); + + // A payload shaped exactly like a real one, naming a head this tree cannot + // have. `.json`, ⛔ never `.md`: a markdown literal here would become a + // candidate for the ledger in `scripts/markdown-test-inputs.mjs`. + const foreign = path.join(fixture.root, 'foreign-event.json'); + fs.writeFileSync( + foreign, + JSON.stringify({ + pull_request: { number: 4242, head: { sha: '0'.repeat(40) }, body: 'The frame is at `untouched.ts:1`.' }, + }), + ); + const run = runGate(fixture.root, lastCommitRange(fixture), { GITHUB_EVENT_PATH: foreign }); + + it('is ⛔ ignored, and the run says so rather than counting it', () => { + expect(run.output).toContain('THIS TREE DOES NOT CARRY'); + expect(run.output).not.toContain('The frame is at'); + }); + + it('leaves the empty-corpus floor standing — BOTH halves of it', () => { + // ⚠️ The control the patch round set, reasoned before it was read: a run + // that prints `Corpus: 0` while having silently skipped the section is the + // same lie one level down. Both, ⛔ never either. + expect(run.output).toContain('Corpus: 0 body(ies)'); + expect(run.output).toContain('NOT a clean verdict'); + expect(run.output).toContain('measured NOTHING'); + }); + + it('⛔ does not move the five born-false controls, which are hermetic by construction', () => { + // If ANY of them moved when the environment changed, the hermeticity claim in + // the gate's own docblock would be false — and that, not the test, would be + // the finding. + expect(run.output.match(/^\s+PASS\s/gm)?.length).toBe(5); + expect(run.output).not.toMatch(/^\s+FAIL\s/m); + }); +}); + +describe('an event payload for THIS tree', () => { + const fixture = fixtureRepo('carried-corpus'); + fixture.write('packages/alpha/src/walker.ts', Array.from({ length: 12 }, (_, i) => `export const s${i} = ${i};\n`).join('')); + fixture.commit('feat(alpha): the walker'); + fixture.write( + 'packages/alpha/src/walker.ts', + '// inserted\n// inserted\n' + Array.from({ length: 12 }, (_, i) => `export const s${i} = ${i};\n`).join(''), + ); + const head = fixture.commit('feat(alpha): insert above the cited line'); + const base = fixture.git('rev-parse', 'HEAD~1'); + + const payload = path.join(fixture.root, 'event.json'); + fs.writeFileSync( + payload, + JSON.stringify({ pull_request: { number: 1, head: { sha: head }, body: 'The subject is `walker.ts:6`.' } }), + ); + const run = runGate(fixture.root, ['--base', base, '--head', head], { GITHUB_EVENT_PATH: payload }); + + it('IS read — the mechanism the whole no-new-workflow design rests on still works', () => { + // ⛔ The repair must not throw the mechanism away to silence the tests. The + // pull request body is where two of the three carded instances lived, and + // the event payload is the only way to reach it without a new workflow. + expect(run.output).toContain('carried by this tree'); + expect(run.output).toContain('Corpus: 1 body(ies)'); + expect(run.output).toContain('this change moves packages/alpha/src/walker.ts:6 to :8'); + }); +}); diff --git a/scripts/__tests__/render-changeset-claims-comment.test.ts b/scripts/__tests__/render-changeset-claims-comment.test.ts index a8e6a88204..6560cb4713 100644 --- a/scripts/__tests__/render-changeset-claims-comment.test.ts +++ b/scripts/__tests__/render-changeset-claims-comment.test.ts @@ -206,7 +206,12 @@ describe('an empty finding set', () => { // The inversion this pins is objectui#3152's in this gate's costume: // rendering "not measured" or "nothing found" as a finding is how a channel // gets muted — and this channel was just un-muted by ruling. - expect(body).toContain('Nothing pending names a file this change touches'); + // ⚠️ The resolved body now speaks for BOTH halves (objectui#9509): a stale + // request at the top of a thread is as misleading about the born-false half + // as about the went-false one. + expect(body).toContain('Nothing to re-read'); + expect(body).toContain('No pending changeset names a file this change touches'); + expect(body).toContain("no address in this pull request's own prose"); expect(body).not.toContain('pending changeset(s) describe a file'); expect(body).not.toContain('⚠️'); }); @@ -245,3 +250,101 @@ describe('the hand-off from the gate', () => { expect(() => renderFromFile(path.join(os.tmpdir(), 'absent-claims-hand-off.json'), {})).toThrow(); }); }); + +// ── the born-false half (objectui#9509) ────────────────────────────────────── + +describe('the born-false section', () => { + const moved = { + origin: 'the pull request body', + span: ':246', + file: 'packages/types/src/zod/imported-defaults.ts', + line: 246, + verdict: 'moved', + movedTo: 274, + sentence: 'The shape recurs exactly twice: `:223` and `:246`.', + }; + const unanchored = { + // ⛔ A name no committed declaration can wear. `pnpm changeset` generates + // `adjective-animal-verb` and this repository commits an issue-number-and-slug + // name, so a `fixture-` prefix belongs to neither namespace — and the file does + // not exist, so `scripts/markdown-test-inputs.mjs` never offers it as a ledger + // candidate. Naming a LIVE pending declaration here made `changeset:version` + // delete the thing a ledger entry pointed at, and reddened the release lane days + // later (objectui#9583). + origin: '.changeset/fixture-born-false-origin.md', + span: 'pin.test.ts:281', + file: 'packages/types/src/__tests__/pin.test.ts', + line: 281, + verdict: 'unanchored', + movedTo: null, + sentence: 'The deep-clean control is at `pin.test.ts:281`.', + }; + + it('is rendered even when NO pending changeset names anything', () => { + // The two halves are independent. A born-false-only run must still produce + // a body, or the half nothing later will ever turn red is delivered to the + // job log objectui#9140 measured at zero answers out of four. + const body = renderClaimsComment({ findings: [], bornFalse: [moved] }); + expect(body).toContain("address(es) in this pull request's own prose name a tree it replaced"); + expect(body).not.toContain('Nothing to re-read'); + }); + + it('says where the number went, rather than only that it is wrong', () => { + const body = renderClaimsComment({ findings: [], bornFalse: [moved] }); + expect(body).toContain('to **`:274`**'); + }); + + it('names an added file as having no tree outside the pull request', () => { + const body = renderClaimsComment({ findings: [], bornFalse: [unanchored] }); + expect(body).toContain('added by this change'); + expect(body).toContain('exists in no tree outside this pull request'); + }); + + it('⛔ asks for the number to be BOUND, never for it to be corrected', () => { + // Correcting `:246` to `:274` is true today and born false again on the + // next insertion. A comment that asked for the correction would manufacture + // the next instance of the class it reports. + const body = renderClaimsComment({ findings: [], bornFalse: [moved] }); + expect(body).toContain('The repair is not to correct the number'); + expect(body).toContain('Bind the number to the tree it was read from'); + }); + + it('⛔ prints no went-false heading when that half found nothing', () => { + // A heading over an empty list reads as a measurement that came back clean. + const body = renderClaimsComment({ findings: [], bornFalse: [moved] }); + expect(body).not.toContain('pending changeset(s) describe a file'); + }); + + it('repairs tag-shaped spans in the quoted sentence too', () => { + // GitHub deletes tag-shaped fragments from a stored body, and this half + // quotes prose that was never written for this channel either. + const body = renderClaimsComment({ + findings: [], + bornFalse: [{ ...moved, sentence: 'The frame is in `FieldWidgetProps` at `:246`.' }], + }); + expect(body).toContain('ANGLE-BRACKETS(T)'); + expect(body).not.toContain(''); + }); + + it('renders both halves together, born-false first', () => { + const body = renderClaimsComment({ + findings: [{ changeset: '.changeset/a.md', span: 'x.ts', file: 'packages/alpha/src/x.ts', severity: 'edited', paragraph: 'p' }], + bornFalse: [moved], + }); + expect(body.indexOf("this pull request's own prose")).toBeLessThan( + body.indexOf('pending changeset(s) describe a file'), + ); + }); + + it('opens with the marker whichever halves fired', () => { + // The workflow finds its own earlier comment by the FIRST LINE of the body + // it is about to post. A body that opened with a section heading instead + // would stack one comment per push. + for (const result of [ + { findings: [], bornFalse: [moved] }, + { findings: [], bornFalse: [] }, + ]) { + expect(renderClaimsComment(result).split('\n', 1)[0]).toBe(MARKER); + } + }); +}); diff --git a/scripts/check-changeset-claims.mjs b/scripts/check-changeset-claims.mjs index 6cd26b02dd..fe60a4c583 100644 --- a/scripts/check-changeset-claims.mjs +++ b/scripts/check-changeset-claims.mjs @@ -30,10 +30,73 @@ * WENT FALSE - true when written, falsified by a LATER merge * (objectui#7721, objectui#8617). * - * ⛔ This gate covers WENT FALSE ONLY, and says so in its own output. It cannot - * see a born-false claim: a changeset this change ADDS is excluded by - * construction, and objectui#8759's defect was a cardinal in prose, which is the - * kind of reading the next section refuses to attempt. + * This gate opened covering WENT FALSE ONLY, and said so in its own output. It + * now carries a SECOND reading for BORN FALSE (objectui#9509), over a different + * corpus and on a different coordinate — see "The born-false reading" below. + * ⛔ The two readings are never merged: the went-false reading still excludes a + * changeset this change adds, because a body naming its own pull request's files + * is the normal case and reporting it would fire on nearly every change here. + * + * ## The born-false reading (objectui#9509) — corpus, coordinate, carve-out + * + * objectui#9509 measured three instances in one day across two pull requests. + * ⛔ None of them was in a pending changeset, and that is the whole reason this + * reading needed a corpus of its own: + * + * 1. objectui#9496, in its PULL REQUEST BODY: a test frame cited as `:281:75` + * that the same branch's later edit moved to `:283:75` — and then to + * `:299:75`. + * 2. objectui#9496, in its PULL REQUEST BODY: "recurs exactly twice: `:223` + * ... and `:246`", falsified by the 28-line comment block THE SAME DIFF + * inserts at `imported-defaults.ts:221`. At the head `:223` is rewritten and + * `:246` is at `:274` — and both numbers then land inside the block that + * displaced them, so a reader following the citation reaches prose ABOUT + * the claim instead of the code it is about. + * 3. objectui#9495, in a SOURCE DOCBLOCK: "a grep for `shortcut` finds that + * member first", falsified by the same diff's own 28-line insertion above + * it. ⛔ Out of reach here and deliberately — see the limits below. + * + * CORPUS: the prose this change PUBLISHES ABOUT ITSELF. That is the pull request + * body (read from `GITHUB_EVENT_PATH`, or `--pr-body ` locally) plus the + * `.changeset/*.md` bodies this change adds or modifies. ⛔ Not the tree at + * large: a citation written into an ordinary source file is the differential + * citation gate's population (`check-new-cross-file-line-citations.mjs`), and + * this repository treats a second reader over one population as a defect. + * + * COORDINATE: a backticked LINE ADDRESS — `some-file.ts:246`, or a bare `:246` + * continuing a file named earlier in the same paragraph — resolving to exactly + * one tracked file THIS CHANGE TOUCHES. Same resolution rule as the went-false + * reading, and for the same reason: an ambiguous spelling names nothing. + * + * The question asked of it is arithmetic, and is still not "is this sentence + * true?": + * + * MOVED the file is MODIFIED here, and this change's own hunks map the + * cited base line to a different head line, or delete it outright. + * The number was read from a tree this diff replaced. + * UNANCHORED the file is ADDED here, so the address can only ever have been + * read from a tree that exists nowhere but inside this pull request, + * and moves again on the next push. Instance 1 is this shape: the + * frame moved twice, between revisions of one branch. + * + * CARVE-OUT: an address whose own SENTENCE names the tree it was read from is + * ⛔ never reported. Both pull requests converged on that form independently and + * objectui#9509 carded it as the durable one: `` `:246` at `b8a006883d`, `:274` + * at this head ``. A number bound to a sha cannot re-stale, so a gate that + * reported it would be teaching authors to unbind. ⚠️ The binding is read per + * SENTENCE, not per paragraph: a sha floating three sentences away binds + * nothing, and a paragraph-wide window would have exempted instance 2. + * + * ⛔ What the born-false reading does NOT cover, so nobody reads it as more: + * - A claim with no line address at all. Instance 3 coordinates itself by + * ORDINAL ("finds that member first"), and deciding that requires reading + * what the sentence means — the one question triage fenced off, below. + * - An address into a file this change does not touch. That address may be + * false, but nothing about THIS diff made it so; it is the citation census's + * population and not this one's. + * - A pull request body EDITED without a push. `pull_request` fires on + * `opened`/`synchronize`/`reopened`, so a body rewritten on its own is read + * at the next push and not before. * * ## What it judges, and the one thing it refuses to judge * @@ -363,9 +426,11 @@ export function readBlobs(root, ref, paths) { * * 1. Changesets this change ADDS OR MODIFIES. A changeset naming files from its * own pull request is the normal case, not a finding - it would fire on - * every change that carries one, which is nearly all of them here. This is - * also the exclusion that makes the gate blind to BORN-FALSE claims, and - * that limit is reported rather than papered over. + * every change that carries one, which is nearly all of them here. ⛔ This + * exclusion is NOT relaxed by objectui#9509: the born-false reading takes + * those same bodies on a different coordinate (an unbound LINE ADDRESS into + * a file this change moves), which is rare where "names a file from its own + * pull request" is near-universal. * 2. Changesets that declare NO bump (empty frontmatter). Their body never * reaches a CHANGELOG, so there is no verbatim publication to protect. 427 * of the 1,384 pending here are these. @@ -479,6 +544,333 @@ export function audit(root, ref = null) { return totals; } +// -- the BORN-FALSE reading (objectui#9509) ----------------------------------- + +/** + * A backticked span that carries a LINE ADDRESS. + * + * Two spellings, and the second is why this is not a one-line regex over the + * whole body. `imported-defaults.ts:246` names its own file. `:246` does not — + * it CONTINUES a file named earlier in the same paragraph, which is how both + * measured instances were actually written, and reading it without that binding + * would turn every port number and every `key: 246` into a citation. + * + * A trailing column (`:281:75`) and a range (`:221-:248`, `ts:221-228`) are both + * kept: the first number is the one that moves, and the one a reader follows. + */ +const LINE_ADDRESS = /^(?[^\s`]*?):(?\d+)(?::\d+)?(?:-:?\d+)?$/; + +/** + * The sha a sentence binds its numbers to, if any. + * + * Hex, 7 to 40, with at least one of `a-f` in it — a bare `12345678` is a + * number, not a tree. `at this head` / `@head` counts too: it names the tree as + * definitely as a sha does, and it is the spelling the landed repairs used for + * the half of a pair that was read at the head. + */ +const SHA_BINDING = /\b(?=[0-9a-f]{7,40}\b)[0-9a-f]*[a-f][0-9a-f]*\b|@\s*head\b|\bat\s+(?:this\s+)?head\b/i; + +/** + * The sentence of `paragraph` containing the character at `index`. + * + * ⚠️ Deliberately NOT the paragraph. The went-false reading quotes a paragraph + * because a paragraph is the unit that ROTS (objectui#8617); a BINDING is the + * opposite kind of thing — it is an author saying which tree THIS number came + * from — and a sha three sentences away binds nothing. Measured against the + * carded instance: objectui#9496's §2 paragraph names a sha, and a + * paragraph-wide window would have exempted the very claim the card is about. + */ +export function sentenceAround(paragraph, index) { + const before = paragraph.slice(0, index); + const start = Math.max(before.lastIndexOf('. '), before.lastIndexOf('。'), before.lastIndexOf('\n')); + const rest = paragraph.slice(index); + const endOffset = rest.search(/\.\s|。|\n/); + const end = endOffset === -1 ? paragraph.length : index + endOffset + 1; + return paragraph.slice(start + 1, end); +} + +/** + * Every line address in one body, each already bound to the file it continues. + * + * Paragraph by paragraph, because the continuation binding is paragraph-scoped: + * a bare `:246` reaches back to the last span in ITS paragraph that named a file, + * and ⛔ never across a blank line into somebody else's subject. + * + * @param {string} source + * @param {(span: string) => string | null} resolve a spelling to one tracked file + * @returns {{ span: string, written: string, line: number, file: string, bound: boolean, + * paragraph: string, sentence: string }[]} + */ +export function lineAddresses(source, resolve) { + const out = []; + for (const paragraph of source.split(/\r?\n\s*\r?\n/)) { + let carried = null; + for (const match of paragraph.matchAll(/`([^`\n]{2,200})`/g)) { + const span = match[1].trim(); + // A plain file name with no number still sets the subject for the bare + // addresses after it: "`imported-defaults.ts` ... at `:223`". + if (NAMED_FILE.test(span) && PATH_SHAPED.test(span) && !span.includes(':')) { + const resolved = resolve(span); + if (resolved) carried = resolved; + continue; + } + const parsed = LINE_ADDRESS.exec(span); + if (!parsed) continue; + const written = parsed.groups.written; + let file = null; + if (written === '') { + file = carried; + } else if (PATH_SHAPED.test(written) && NAMED_FILE.test(written)) { + file = resolve(written); + if (file) carried = file; + } + if (!file) continue; + const sentence = sentenceAround(paragraph, match.index); + out.push({ + span, + written, + line: Number(parsed.groups.line), + file, + bound: SHA_BINDING.test(sentence), + paragraph: paragraph.replace(/\s+/g, ' ').trim(), + sentence: sentence.replace(/\s+/g, ' ').trim(), + }); + } + } + return out; +} + +/** + * `@@ -a,b +c,d @@` hunk headers for one file, base to head. + * + * `-U0` deliberately: context lines would merge neighbouring hunks and make the + * arithmetic below approximate, and this reading exists precisely to be exact + * about which line a number lands on. + */ +export function hunksFor(root, { base, head = null }, file) { + const args = ['diff', '-U0', '--no-color', base]; + if (head) args.push(head); + args.push('--', file); + const out = git(root, args, { allowFailure: true }) ?? ''; + const hunks = []; + for (const match of out.matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm)) { + hunks.push({ + oldStart: Number(match[1]), + oldCount: match[2] === undefined ? 1 : Number(match[2]), + newCount: match[4] === undefined ? 1 : Number(match[4]), + }); + } + return hunks; +} + +/** + * Where a BASE line ends up at the head, given this change's own hunks. + * + * `null` means the change rewrote or deleted that line outright — the sharpest + * shape of the defect, because the number now points at whatever the diff put + * there instead. + * + * A pure insertion (`oldCount === 0`) is spelled by git as "after old line + * `oldStart`", so it moves lines STRICTLY BELOW it and leaves `oldStart` itself + * alone. Getting that boundary wrong is an off-by-one that would report a + * stable citation as moved, which is the false positive this reading can least + * afford on a report-only channel. + */ +export function mapLine(hunks, line) { + let delta = 0; + for (const hunk of hunks) { + if (hunk.oldCount === 0) { + if (line <= hunk.oldStart) break; + delta += hunk.newCount; + continue; + } + if (line < hunk.oldStart) break; + if (line < hunk.oldStart + hunk.oldCount) return null; + delta += hunk.newCount - hunk.oldCount; + } + return line + delta; +} + +/** + * The born-false verdict for one address, given what this change did to its file. + * + * Pure, and injectable, so the controls below and the real run are judged by one + * function rather than by two that can disagree. + * + * @param {{ file: string, line: number, bound: boolean }} address + * @param {(file: string) => 'added' | 'modified' | null} statusOf + * @param {(file: string) => {oldStart: number, oldCount: number, newCount: number}[]} hunksOf + */ +export function judgeAddress(address, statusOf, hunksOf) { + if (address.bound) return { verdict: 'anchored', movedTo: null }; + const status = statusOf(address.file); + if (status === null) return { verdict: 'untouched', movedTo: null }; + if (status === 'added') return { verdict: 'unanchored', movedTo: null }; + const movedTo = mapLine(hunksOf(address.file), address.line); + if (movedTo === address.line) return { verdict: 'stable', movedTo }; + return { verdict: 'moved', movedTo }; +} + +/** Verdicts that are findings. The other two are the discrimination, not noise. */ +export const BORN_FALSE_VERDICTS = new Set(['moved', 'unanchored']); + +/** + * The firing controls, taken from the artefact this card was measured on. + * + * ⚠️ NOT a round number invented here. objectui#9496 inserts 28 lines at + * `imported-defaults.ts:221` and rewrites base `:223`; the landed pull request + * body and the landed docblock BOTH publish the arithmetic independently — + * `:246`@`b8a006883d` = `:274`@head. So 274 is attested outside this file and + * ⛔ cannot be moved by an implementation that is wrong: a mapper that drifts by + * one fails here rather than reporting a clean branch. + * + * The first two cases are the PAIR, one step apart: the same sentence, the same + * geometry, differing only in whether the number names the tree it was read + * from. The bound one must stay silent and the unbound one must fire — a + * control that only ever fires proves nothing about discrimination. + */ +export const BORN_FALSE_CONTROLS = [ + { + id: 'unbound-address-into-a-line-this-diff-moves', + why: "objectui#9496 §2: `:246` at the base is `:274` at the head, moved by the diff's own 28-line insertion", + body: 'The shape recurs exactly twice in `imported-defaults.ts`: `:223` and `:246`.', + want: (rows) => rows.length === 2 && rows.every((r) => BORN_FALSE_VERDICTS.has(r.verdict)) + && rows[0].movedTo === null && rows[1].movedTo === 274, + }, + { + id: 'the-same-address-bound-to-a-sha-is-silent', + why: 'the durable form both pull requests converged on is ⛔ never reported, or the gate teaches authors to unbind', + body: 'At `b8a006883d` the shape recurs twice in `imported-defaults.ts`: `:223` and `:246`.', + want: (rows) => rows.length === 0, + }, + { + id: 'an-address-this-diff-does-not-move-is-silent', + why: 'a line above every hunk is untouched — without this the reading is an absolute count of citations, not a differential', + body: 'See `imported-defaults.ts:100` for the walker entry point.', + want: (rows) => rows.length === 0, + }, + { + // ⭐ THE BOUNDARY. git spells a pure insertion as "after old line 220", so + // 220 is the last line the insertion does not move and 221 is the first it + // does. Measured: mutating `line <= hunk.oldStart` to `line <` leaves the + // three controls above it all PASSING while every stable citation at an + // insertion point is silently reported as moved — a gate that manufactures + // findings, which on a report-only channel is how a channel gets muted. + id: 'the-insertion-point-itself-does-not-move', + why: 'a pure insertion moves the lines BELOW it; reporting its own anchor line would fake a finding on every stable citation', + body: 'The walker entry is at `imported-defaults.ts:220`, just above the block.', + want: (rows) => rows.length === 0, + }, + { + id: 'an-address-into-a-file-this-change-adds-is-unanchored', + why: 'instance 1: a frame in a file the branch itself creates was read from a tree that exists nowhere else, and moved twice', + body: 'The deep-clean control is at `imported-defaults-rest-less-tuple-9088.test.ts:281`.', + want: (rows) => rows.length === 1 && rows[0].verdict === 'unanchored', + }, +]; + +/** + * Runs every control through the real reader, against objectui#9496's geometry. + * + * HERMETIC — the hunks are the ones that pull request actually produced, written + * out here rather than read from the tree. A control that resolved against the + * live history would answer a different question after the next squash, and a + * gate whose controls rot reports "instrument broken" wherever it is not run + * from this checkout. + * + * `judgeFor` is injectable for one reason: a test has to be able to show that a + * judge which LIES fails these controls rather than passing them. A control + * suite that cannot be made to fail is decoration. + */ +export function evaluateBornFalseControls(judgeFor = judgeAddress) { + const MODIFIED = 'packages/types/src/zod/imported-defaults.ts'; + const ADDED = 'packages/types/src/__tests__/imported-defaults-rest-less-tuple-9088.test.ts'; + // `@@ -220,0 +221,28 @@` and `@@ -223 +251 @@`, as measured on 8700d6d93. + const hunks = [ + { oldStart: 220, oldCount: 0, newCount: 28 }, + { oldStart: 223, oldCount: 1, newCount: 1 }, + ]; + const resolve = (span) => + MODIFIED.endsWith('/' + span) ? MODIFIED : ADDED.endsWith('/' + span) ? ADDED : null; + const statusOf = (file) => (file === MODIFIED ? 'modified' : file === ADDED ? 'added' : null); + + return BORN_FALSE_CONTROLS.map((control) => { + let rows = []; + let ok = false; + let detail = ''; + try { + rows = lineAddresses(control.body, resolve) + .map((address) => ({ ...address, ...judgeFor(address, statusOf, () => hunks) })) + .filter((row) => BORN_FALSE_VERDICTS.has(row.verdict)); + ok = control.want(rows) === true; + detail = rows.map((r) => `${r.span} -> ${r.verdict}${r.movedTo ? ` (:${r.movedTo})` : ''}`).join(', ') || '(silent)'; + } catch (error) { + detail = `threw: ${error instanceof Error ? error.message : String(error)}`; + } + return { id: control.id, why: control.why, ok, detail }; + }); +} + +/** + * The born-false findings for this change, over the prose it publishes about + * itself. + * + * @param {string} root + * @param {{ base: string, head?: string | null, prBody?: string | null }} options + * @returns {{ findings: object[], corpus: {origin: string, addresses: number}[], read: number }} + */ +export function bornFalse(root, { base, head = null, prBody = null }) { + const changed = changedFiles(root, { base, head }); + const added = new Set(changedFiles(root, { base, head, filter: 'A' })); + const subject = changed.filter((file) => !file.startsWith(CHANGESET_DIR)); + const ownChangesets = changed.filter((file) => file.startsWith(CHANGESET_DIR)); + + const baseIndex = treeIndex(root, base); + const headIndex = treeIndex(root, head); + // Resolved against the HEAD tree as well as the base: a file this change ADDS + // is not in the base listing at all, and the unanchored half of this reading + // is exactly about those. + const resolve = (span) => resolveNamed(headIndex, span) ?? resolveNamed(baseIndex, span); + const touched = new Set(subject); + const statusOf = (file) => (touched.has(file) ? (added.has(file) ? 'added' : 'modified') : null); + + const hunkCache = new Map(); + const hunksOf = (file) => { + if (!hunkCache.has(file)) hunkCache.set(file, hunksFor(root, { base, head }, file)); + return hunkCache.get(file); + }; + + const corpus = []; + if (typeof prBody === 'string' && prBody.trim() !== '') { + corpus.push({ origin: 'the pull request body', source: prBody }); + } + for (const [path, source] of readBlobs(root, head, ownChangesets)) { + corpus.push({ origin: path, source }); + } + + const findings = []; + const read = []; + for (const entry of corpus) { + const addresses = lineAddresses(entry.source, resolve); + read.push({ origin: entry.origin, addresses: addresses.length }); + for (const address of addresses) { + const judged = judgeAddress(address, statusOf, hunksOf); + if (!BORN_FALSE_VERDICTS.has(judged.verdict)) continue; + findings.push({ + origin: entry.origin, + span: address.span, + file: address.file, + line: address.line, + verdict: judged.verdict, + movedTo: judged.movedTo, + sentence: address.sentence, + }); + } + } + findings.sort((a, b) => a.origin.localeCompare(b.origin) || a.line - b.line); + return { findings, corpus: read, subject: subject.length }; +} + // -- CLI ---------------------------------------------------------------------- if (isEntrypoint(import.meta.url)) { @@ -554,6 +946,74 @@ if (isEntrypoint(import.meta.url)) { process.exit(1); } + // ── the born-false reading (objectui#9509) ──────────────────────────────── + // + // The corpus is the prose THIS change publishes about itself, and the pull + // request body is where two of the three carded instances lived. It is read + // from the event payload the job already receives — ⛔ no new workflow, no new + // permission and no API call: `GITHUB_EVENT_PATH` is a file on the runner, and + // `check-governed-queue-guard.mjs` already reads it the same way. + // + // ⚠️ A `merge_group` build has no `pull_request` payload. That is an ABSENCE + // of corpus, never a clean verdict, and the report below says which. + const prBodyFile = argOf('--pr-body'); + let prBody = null; + let prBodyHow = 'not available on this event'; + if (prBodyFile) { + try { + prBody = readFileSync(resolve(root, prBodyFile), 'utf8'); + prBodyHow = `--pr-body ${prBodyFile}`; + } catch (error) { + console.error(`⚠️ Could not read ${prBodyFile}: ${error.message}`); + prBodyHow = `--pr-body ${prBodyFile} (UNREADABLE — this run read no pull request body)`; + } + } else if (process.env.GITHUB_EVENT_PATH) { + // ⚠️ GATED, and the gate is the whole point (objectui#9509, patch round 1). + // + // `GITHUB_EVENT_PATH` is exported to EVERY process on a runner, not just the + // workflow step this reading was written for. An ungated read made this gate's + // corpus AMBIENT: run against a throwaway fixture repository in CI, it picked + // up the real pull request body of whatever build happened to be running and + // counted it as "the prose this change publishes about itself" — which in that + // tree it provably was not. Measured, by this gate's own empty-corpus floor + // going soft in CI while passing locally. + // + // The predicate is about the TREE, not about how the process was launched: the + // payload names `pull_request.head.sha`, and if the tree under this run cannot + // resolve that commit then the payload describes some other repository at some + // other head. Fails CLOSED and says so — ⛔ never silently. + try { + const payload = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')); + const body = payload?.pull_request?.body; + const sha = payload?.pull_request?.head?.sha; + const carried = typeof sha === 'string' && git(root, ['cat-file', '-e', `${sha}^{commit}`], { allowFailure: true }) !== null; + if (typeof body === 'string' && carried) { + prBody = body; + prBodyHow = `GITHUB_EVENT_PATH → pull_request.body (head ${String(sha).slice(0, 9)}, carried by this tree)`; + } else if (typeof body === 'string') { + prBodyHow = + `GITHUB_EVENT_PATH names a pull request at ${sha ? String(sha).slice(0, 9) : 'an unstated head'} ` + + 'which THIS TREE DOES NOT CARRY — ⛔ ignored, because a body from another tree is not the prose ' + + 'this change publishes about itself'; + } + } catch (error) { + prBodyHow = `GITHUB_EVENT_PATH unreadable (${error.message}) — this run read no pull request body`; + } + } + + const bornControls = evaluateBornFalseControls(); + let born; + try { + born = bornFalse(root, { base: base.ref, head, prBody }); + } catch (error) { + console.error( + `❌ ${error.message}\n\n` + + ' The born-false reading lost an input, so it measured nothing. Reported as a failure\n' + + ' rather than as an empty finding set (objectui#4690).', + ); + process.exit(1); + } + // The DELIVERY hand-off (objectui#9140). One measurement, two consumers: the // log below, which a human reads by opening the job, and this file, which // `render-changeset-claims-comment.mjs` turns into the pull request comment. @@ -581,6 +1041,10 @@ if (isEntrypoint(import.meta.url)) { pending: result.pending, considered: result.considered, findings: result.findings, + bornFalse: born.findings, + bornFalseCorpus: born.corpus, + bornFalseControls: bornControls, + prBodyHow, }, null, 2, @@ -597,8 +1061,85 @@ if (isEntrypoint(import.meta.url)) { `pending declaration(s) that publish a body (${result.pending} pending in total).`, ); + // ── the born-false report (objectui#9509) ───────────────────────────────── + // + // Printed FIRST and unconditionally. It is about the prose this change is + // publishing right now, which is the one thing the seat reading this can still + // change for free — and unlike the went-false half, nothing later will ever + // turn it red. + console.log('\n── Born false — claims this change publishes about a tree it replaced ──\n'); + for (const control of bornControls) { + console.log(` ${control.ok ? 'PASS' : 'FAIL'} ${control.id}: ${control.detail}`); + } + const corpusRead = born.corpus.reduce((sum, entry) => sum + entry.addresses, 0); + console.log( + `\n Corpus: ${born.corpus.length} body(ies) this change publishes about itself ` + + `(${prBodyHow}).\n Line addresses read in them: ${corpusRead}.`, + ); + if (born.corpus.length === 0) { + // ⛔ The FLOOR. An empty corpus is an absence of subject, and printing a tick + // for it would report "this change publishes no false claim" on a run that + // read no prose at all — the shape objectstack#4928 named. + console.log( + '\n ⛔ NOT a clean verdict: this run read NO published prose, so it measured NOTHING\n' + + ' of this class. On a pull request the body arrives via GITHUB_EVENT_PATH; on a\n' + + ' merge_group build there is no pull request and no body to read.', + ); + } else if (corpusRead === 0) { + // A SECOND floor, one level in. The corpus was read but spells no address at + // all, so there was nothing of this class to judge — which is not the same + // answer as "every address checked out", and printing the same tick for both + // is how a reader learns to read the tick as noise. + console.log( + '\n ⚠️ Read, but nothing to judge: that prose spells no line address this gate can\n' + + ' resolve to one tracked file. ⛔ Not the same answer as a clean one.', + ); + } else if (born.findings.length === 0) { + console.log( + `\n ✅ Every one of those ${corpusRead} address(es) either names the tree it was read\n` + + ' from, or points at a line this change does not move.', + ); + } else { + console.log( + `\n⚠️ ${born.findings.length} address(es) in the prose this change publishes were read from a\n` + + " tree this change itself replaces:\n", + ); + for (const hit of born.findings) { + const where = + hit.verdict === 'unanchored' + ? `${hit.file} is ADDED by this change — that line exists in no tree outside this pull request` + : hit.movedTo === null + ? `this change REWRITES ${hit.file}:${hit.line}` + : `this change moves ${hit.file}:${hit.line} to :${hit.movedTo}`; + console.log(` ${hit.origin} says \`${hit.span}\` -> ${where}`); + console.log(` sentence: ${hit.sentence}`); + } + console.log(` + ⛔ This is NOT a claim that any of those sentences is false, and ⛔ not an instruction to + change the number. Correcting \`:246\` to \`:274\` produces a claim that is true today and + born false again on the next insertion — objectui#9509 states that before anything else. + + BIND THE NUMBER TO THE TREE IT WAS READ FROM. \`\`\`:246\` at \`b8a006883d\`, \`:274\` at this + head\`\` cannot re-stale, because each number names its own tree. Both pull requests that + produced this card converged on that form independently. Or state a RULE instead of a + coordinate — "every file in \`git diff --name-only\` against the merge base" — which is + what objectui#9495 did with a file count that had already staled once between rounds. + + Report-only, exactly like the half above it: a moved address is usually a moved address + and not a lie. The finding is a request to re-read, addressed to the one seat that can + answer it without re-deriving anything.`); + } + if (result.findings.length === 0) { - console.log('✅ No pending changeset names a file this change touches.'); + console.log('\n✅ No pending changeset names a file this change touches.'); + if (bornControls.some((control) => !control.ok)) { + console.error( + `❌ ${bornControls.filter((c) => !c.ok).length} born-false control(s) FAILED — this run is ` + + 'not a reading. ⛔ Not a finding failure: the instrument failed, and a differential ' + + 'reader that reports zero because its differ broke is indistinguishable from clean prose.', + ); + process.exit(1); + } process.exit(0); } @@ -644,8 +1185,10 @@ if (isEntrypoint(import.meta.url)) { intended shape here: one gate asks for the read, the other records the write. ⛔ What this gate does NOT cover, stated so nobody reads it as more: - - A claim that was BORN false — written against the merge base while describing the - head (objectui#8759). A changeset this change adds is excluded by construction. + - A born-false claim carrying no LINE ADDRESS. The section above this one now reads + that class (objectui#9509) over the prose this change publishes about itself, but + only where it spells a coordinate: objectui#9495's "a grep finds that member first" + is an ORDINAL claim, and deciding it means reading what the sentence means. - A claim about anything it does not spell as a file name in backticks: a symbol, a package, an installed dependency's version, a count. Symbol matching was measured and dropped — it asked for 191 paragraphs per commit, which is the release-time @@ -657,5 +1200,13 @@ if (isEntrypoint(import.meta.url)) { usually still true. The finding is a request to read, addressed to the one seat that can answer it cheaply — the one whose diff might have falsified it.`); + if (bornControls.some((control) => !control.ok)) { + console.error( + `❌ ${bornControls.filter((c) => !c.ok).length} born-false control(s) FAILED — this run is ` + + 'not a reading. ⛔ Not a finding failure: the instrument failed, and a differential ' + + 'reader that reports zero because its differ broke is indistinguishable from clean prose.', + ); + process.exit(1); + } process.exit(0); } diff --git a/scripts/render-changeset-claims-comment.mjs b/scripts/render-changeset-claims-comment.mjs index 9e46b3efeb..6cd89e0f7a 100644 --- a/scripts/render-changeset-claims-comment.mjs +++ b/scripts/render-changeset-claims-comment.mjs @@ -119,8 +119,48 @@ function measurement(result) { * @param {string} [options.runUrl] link back to the workflow run that measured it * @returns {string} the comment body, opening with {@link MARKER} */ +/** + * The BORN-FALSE half (objectui#9509), rendered above the went-false half. + * + * Above it deliberately: this half is about the prose the reader is publishing + * RIGHT NOW, which is the one thing they can still change for free, and + * objectui#9509's triage ruling is that nothing later will ever turn it red. + */ +function bornFalseSection(rows) { + const lines = [ + `## ⚠️ ${rows.length} address(es) in this pull request's own prose name a tree it replaced`, + '', + 'Each was read from a tree **this change itself moves**, so a reader who follows it lands ' + + 'somewhere else. ⛔ Nothing here blocks and nothing here says the sentence is false — the ' + + 'question asked is arithmetic: does this diff move the line that number points at?', + '', + ]; + for (const row of rows) { + const what = + row.verdict === 'unanchored' + ? `\`${row.file}\` is **added by this change**, so \`:${row.line}\` exists in no tree outside this pull request` + : row.movedTo === null || row.movedTo === undefined + ? `this change **rewrites** \`${row.file}:${row.line}\`` + : `this change moves \`${row.file}:${row.line}\` to **\`:${row.movedTo}\`**`; + lines.push(`- in ${row.origin === 'the pull request body' ? 'this body' : `\`${row.origin}\``}, \`${row.span}\` — ${what}`); + if (row.sentence) lines.push('', ` > ${spellOutTags(row.sentence)}`); + lines.push(''); + } + lines.push( + '⛔ **The repair is not to correct the number.** Changing `:246` to `:274` is true today and ' + + 'born false again on the next insertion — objectui#9509 states that before anything else. ' + + '**Bind the number to the tree it was read from** (`` `:246` at `b8a006883d`, `:274` at this ' + + 'head ``), which cannot re-stale because each number names its own tree; or state a **rule** ' + + 'instead of a coordinate, the way objectui#9495 replaced a file count with "every file in ' + + '`git diff --name-only` against the merge base".', + '', + ); + return lines; +} + export function renderClaimsComment(result = {}, { runUrl = '' } = {}) { const findings = Array.isArray(result.findings) ? result.findings : []; + const born = Array.isArray(result.bornFalse) ? result.bornFalse : []; const grouped = byChangeset(findings); const trailer = runUrl ? `\n\n${measurement(result)} · [run](${runUrl})\n` : `\n\n${measurement(result)}\n`; @@ -129,21 +169,24 @@ export function renderClaimsComment(result = {}, { runUrl = '' } = {}) { // It has to exist all the same: a pull request that fixed the thing, or whose // diff moved off the named file, would otherwise keep a stale request to // re-read at the top of its thread forever. - if (grouped.size === 0) { + if (grouped.size === 0 && born.length === 0) { return ( `${MARKER}\n\n` + - '## ✅ Nothing pending names a file this change touches\n\n' + - 'An earlier revision of this pull request did. That request to re-read does **not** apply to ' + - 'the current diff.\n\n' + + '## ✅ Nothing to re-read\n\n' + + 'No pending changeset names a file this change touches, and no address in this pull ' + + "request's own prose points at a line this change moves. An earlier revision did. That " + + 'request to re-read does **not** apply to the current diff.\n\n' + 'This comment is updated in place on every re-run rather than posted again, so the thread ' + 'does not grow one per push.' + trailer ); } - const lines = [ - MARKER, - '', + const lines = [MARKER, '']; + if (born.length > 0) lines.push(...bornFalseSection(born)); + // ⛔ Never printed as an empty section: a heading over nothing reads as a + // measurement that came back clean, and this half may simply not have run. + if (grouped.size > 0) lines.push( `## ⚠️ ${grouped.size} pending changeset(s) describe a file this change touches`, '', 'Their bodies publish **verbatim** into the CHANGELOG at the next release, so this is a ' + @@ -155,7 +198,7 @@ export function renderClaimsComment(result = {}, { runUrl = '' } = {}) { 'pending body names a file you touched. "Is this sentence still true?" is the one question ' + 'it will not answer, and the one you are being asked to answer.', '', - ]; + ); let repaired = false; for (const [changeset, hits] of grouped) { @@ -173,19 +216,24 @@ export function renderClaimsComment(result = {}, { runUrl = '' } = {}) { } } + if (grouped.size > 0) { + lines.push( + 'Read the **paragraph**, not the line: both false halves of the objectui#8617 claim sat in ' + + 'one paragraph, and correcting either alone would have left it asserting the same wrong ' + + 'thing.', + '', + 'If a claim did go false, **correct the body**. That is precedented and prose-only, ' + + 'frontmatter untouched; `check-changeset-overwrite.mjs` will report the correction as its ' + + 'own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended ' + + 'shape — one gate asks for the read, the other records the write.', + '', + ); + } lines.push( - 'Read the **paragraph**, not the line: both false halves of the objectui#8617 claim sat in ' + - 'one paragraph, and correcting either alone would have left it asserting the same wrong ' + - 'thing.', - '', - 'If a claim did go false, **correct the body**. That is precedented and prose-only, ' + - 'frontmatter untouched; `check-changeset-overwrite.mjs` will report the correction as its ' + - 'own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended ' + - 'shape — one gate asks for the read, the other records the write.', - '', - 'Not covered, stated so nobody reads this as more: a claim that was born false (a changeset ' + - 'this change adds is excluded by construction), a claim spelled as a symbol or a package ' + - 'rather than a backticked file name, and a file named ambiguously.', + 'Not covered, stated so nobody reads this as more: a born-false claim that spells no line ' + + 'address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member ' + + 'first" — and deciding that means reading what the sentence means), a claim spelled as a ' + + 'symbol or a package rather than a backticked file name, and a file named ambiguously.', ); // ⛔ Only when a span was ACTUALLY rewritten. A standing note about a repair