diff --git a/.changelog/ci-2904-pr-body-citations.md b/.changelog/ci-2904-pr-body-citations.md new file mode 100644 index 000000000..04be5e3fa --- /dev/null +++ b/.changelog/ci-2904-pr-body-citations.md @@ -0,0 +1,5 @@ +--- +section: Changed +--- + +- **Verify PR-body code citations (refs #2904)** — Check cited source lines, offered evidence, lexer-derived test identifiers, and master-red transcripts against the head tree. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9d3dcb8f4..3187af66e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,6 +9,11 @@ Closes #NNN — only when every acceptance criterion is met. Otherwise Refs The reference must ALSO be in the PR title — the title becomes the merge-commit subject. +Citations: every code fact uses ``path:line``; an offered fenced quote is +checked against the cited source line. Test ids in tables are real `it(` titles; +pre-existing-red claims carry the +`origin/master` transcript. + ## Type of change - [ ] Bug fix diff --git a/.gitignore b/.gitignore index d35c13b79..1b75f2d3f 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ migrate-*.js *.md !CLAUDE.md !tests/fixtures/changelog-entries/*.md +!tests/fixtures/ci-pr-bodies/*.md !.changelog/*.md !AGENTS.md !HISTORY.md diff --git a/AGENTS.md b/AGENTS.md index c1c5bb6a1..b3672cbe1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,6 +184,21 @@ Diagnostics have one model-facing surface, `lens_diagnostics`; `source` selects **PR body structure is advisory-linted.** Keep `Summary`, `Tests`, `Blast radius`, `Class sweep`, and `Observability` populated — plus `Test assessment` whenever the PR touches `tests/` (see "Test assessment and removal" under Test requirements); `scripts/check-pr-body.mjs` also checks runtime diff observability when its local range is available, so reviewers still judge the answers. +The PR-body citation checker uses one path-and-line reader for code and +existing-record citations. CI resolves sources from `HEAD`; `--lint-local` +resolves sources and test references from the working tree, including untracked +files under `tests/`, so uncommitted fixer changes can be cited. Fenced +transcripts are excluded from citation and test-reference scans. Source quotes +must match within ±20 lines of the cited line; +transcript fences after citations are not source quotes. Test references come +from declaration titles and paths under `tests/`, excluding the checker's own +fixture inputs; the checker's own test contributes declaration titles only, and +short identifiers use whole-token matching. The +The test-reference corpus comes from lexer-emitted declaration string spans; +table cells are candidates only under headers matching `/test|probe|case|witness|id/i`. +The master-claim rule splits markdown blocks and sentences without treating dots in +code spans as punctuation; a transcript must be in the next non-blank block. + **Draw the blast radius as a call-tree diff (optional, text only; 2026-09-06).** Prose blast radius keeps missing callers. When a change touches a shared seam, the `Blast radius` section may carry a call-tree diff: the changed symbol, its callers above, its callees below, with `+`/`-` on the lines that moved (`resyncLspFile` / ` touchFile` / `+ getAuxiliaryClientsForFile`). A fix round that changes ordering or control flow shows the before/after as a flow diff of the same shape. The reviewer verifies the tree against grep, which is what the reviewer playbook's neighbourhood rule asks for. Never HTML, Mermaid, or diagrams for their own sake — the smallest text view that makes the reviewer's check mechanical. ## Contributing @@ -1730,6 +1745,15 @@ Actions, `scripts/check-pr-body.mjs` fails with `diff unavailable:`; local runs outside CI retain structural-only fallback. Runtime markers exclude test files, `__tests__` directories, and TypeScript declaration files. +The PR-body lint verifies every backticked `path:line` against `HEAD`, checks an +offered adjacent fenced quote against source text, checks declaration-backed +test titles and paths under `tests/`, and requires an `origin/master` transcript +in the next non-blank block for master/environment claims. Ranges and +approximate line hints resolve from +their first line. Both CI and `--lint-local` use the real +`origin/master...HEAD` range; the lane remains advisory until ten consecutive +merged PRs pass. + Message-end attribution uses a bounded two-slot session anchor. A primary `session_start` rotates `lastStableSessionId` into `previousSessionId` because queued stale events from the replaced session can drain after the boundary; diff --git a/docs/pi-lens-fixer.md b/docs/pi-lens-fixer.md index df4b505c9..ede3a34d1 100644 --- a/docs/pi-lens-fixer.md +++ b/docs/pi-lens-fixer.md @@ -109,7 +109,8 @@ A fix on `clients/lsp/`, the read guard, tool registration, or session lifecycle `## Test assessment` whenever the diff touches `tests/`; Observability names a record literal that appears in the runtime diff, and may say exactly "No new failure path; no record added." only when the diff adds no - failure path (no new catch, fallback or degradation branch). Record: on + failure path (no new catch, fallback or degradation branch). Every code fact + is a citation the check verifies. Record: on 2026-09-10 most open PRs failed the PR-body check on one of these two rules. - No Git authority unless granted: leave changes uncommitted; hand off `PR_BODY.md` (template headings, every red and mutation quoted in ≤5 lines) diff --git a/scripts/check-pr-body.d.mts b/scripts/check-pr-body.d.mts index d87551f9b..026d0d57c 100644 --- a/scripts/check-pr-body.d.mts +++ b/scripts/check-pr-body.d.mts @@ -8,7 +8,14 @@ export declare function normalizePrBodyForChecking( ): { body: string; normalized: boolean }; export declare function lintPrBody( body?: string, - options?: { requireTestAssessment?: boolean; diff?: string }, + options?: { + requireTestAssessment?: boolean; + diff?: string; + cwd?: string; + git?: (args: string[], options?: Record) => string; + workingTree?: boolean; + headFiles?: Map; + }, ): { valid: boolean; errors: string[]; diff --git a/scripts/check-pr-body.mjs b/scripts/check-pr-body.mjs index 6431e1480..d540e4a50 100644 --- a/scripts/check-pr-body.mjs +++ b/scripts/check-pr-body.mjs @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { dirname, isAbsolute, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { gitExecFileSync } from "./lib/git-fixture-env.mjs"; @@ -125,6 +125,8 @@ function hasRealContent(lines, section, placeholders) { function blankCommentsAndStrings(source) { let state = "code"; let result = ""; + const strings = []; + let stringStart = -1; for (let index = 0; index < source.length; index += 1) { const char = source[index]; const next = source[index + 1]; @@ -150,7 +152,14 @@ function blankCommentsAndStrings(source) { result += " "; index += 1; } - } else if (char === state) state = "code"; + } else if (char === state) { + strings.push({ + start: stringStart, + end: index + 1, + value: source.slice(stringStart + 1, index), + }); + state = "code"; + } continue; } if (char === "/" && next === "/") { @@ -163,10 +172,11 @@ function blankCommentsAndStrings(source) { state = "block-comment"; } else if (char === "'" || char === '"' || char === "`") { result += " "; + stringStart = index; state = char; } else result += char; } - return result; + return { text: result, strings }; } function isRuntimeObservabilityPath(name) { @@ -194,7 +204,7 @@ function runtimeObservabilityFromDiff(diff = "") { added += `${line.slice(1)}\n`; } if (!runtime) return { runtime: false, records, failurePath: false }; - const blanked = blankCommentsAndStrings(added); + const blanked = blankCommentsAndStrings(added).text; return { runtime: true, records: recordLiteralsFromRuntimeSource(added), @@ -225,7 +235,7 @@ function recordLiteralsFromRuntimeSource(source) { function recordLocationsFromRuntimeSource(source) { const records = []; - const blanked = blankCommentsAndStrings(source); + const blanked = blankCommentsAndStrings(source).text; const calls = [ ["recordDegradationOnce", ["kind"]], ["incrementDegradationCount", ["kind"]], @@ -256,6 +266,345 @@ function recordLocationsFromRuntimeSource(source) { return records; } +const CODE_CITATION = /`([^`\s:]+):((?:~?\d+)(?:-\d+)?)`/g; +const MASTER_CLAIM = + /pre-existing|red on master|also fails on origin\/master|environment-specific/i; +const headTestCorpusCache = new Map(); + +function headFileSource(file, options = {}) { + if (options.headFiles?.has?.(file)) return options.headFiles.get(file); + if (/(?:^|\/)\.\.(?:\/|$)/.test(file) || isAbsolute(file)) return null; + if (options.workingTree) { + try { + return readFileSync(resolve(options.cwd ?? process.cwd(), file), "utf8"); + } catch { + return null; + } + } + try { + return String( + (options.git ?? gitExecFileSync)(["show", `HEAD:${file}`], { + cwd: options.cwd ?? process.cwd(), + encoding: "utf8", + }), + ); + } catch { + return null; + } +} + +function sourceLines(source) { + return String(source ?? "").split(/\r?\n/); +} + +function testCorpus(options = {}) { + const cwd = options.cwd ?? process.cwd(); + const cacheKey = `${cwd}:${options.workingTree ? "working" : "head"}`; + const cached = headTestCorpusCache.get(cacheKey); + if (cached) return cached; + let files = []; + try { + const tracked = String( + (options.git ?? gitExecFileSync)(["ls-files", "--", "tests"], { + cwd, + encoding: "utf8", + }), + ); + files = tracked.split(/\r?\n/).filter(Boolean); + if (options.workingTree) { + const untracked = String( + (options.git ?? gitExecFileSync)( + ["ls-files", "--others", "--exclude-standard", "--", "tests"], + { cwd, encoding: "utf8" }, + ), + ) + .split(/\r?\n/) + .filter(Boolean); + files = [...new Set([...files, ...untracked])]; + } + } catch { + const visit = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) visit(path); + else if (path.endsWith(".ts") || path.endsWith(".tsx")) + files.push(path.slice(cwd.length + 1).replaceAll("\\", "/")); + } + }; + try { + visit(resolve(cwd, "tests")); + } catch { + files = []; + } + } + const paths = new Set(files); + const titles = new Set(); + for (const file of files) { + if ( + file.startsWith("tests/fixtures/ci-pr-bodies/") || + !/\.(?:[cm]?[jt]sx?)$/.test(file) + ) + continue; + // The checker test contributes only its declaration titles. Its fixture + // strings and arbitrary prose never enter this corpus. + let source; + try { + source = readFileSync(resolve(cwd, file), "utf8"); + } catch { + continue; + } + const lexed = blankCommentsAndStrings(source); + for (const string of lexed.strings) { + const prefix = lexed.text.slice(0, string.start).trimEnd(); + const opening = prefix.lastIndexOf("("); + if (opening < 0) continue; + const beforeOpening = prefix.slice(0, opening).trimEnd(); + const direct = /\b(?:it|test|describe)\s*$/.test(beforeOpening); + const each = /\b(?:it|test|describe)\s*\.each\s*$/.test( + beforeOpening.slice(0, beforeOpening.lastIndexOf("(")), + ); + if (!direct && !each) continue; + const title = string.value.replace(/\\(["'`\\])/g, "$1"); + if (title.trim()) titles.add(title.trim()); + } + } + const corpus = { paths, titles }; + headTestCorpusCache.set(cacheKey, corpus); + return corpus; +} + +function markdownBlocks(body) { + const lines = String(body ?? "").split(/\r?\n/); + const blocks = []; + let current = null; + let fence = null; + const flush = () => { + if (current?.lines.length) blocks.push(current); + current = null; + }; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const marker = line.match(/^\s*(```+)/)?.[1]; + if (marker || fence) { + if (marker && !fence && current && current.lines.length) flush(); + if (!current) current = { lines: [], start: index, fence: true }; + current.lines.push(line); + if (marker && !fence) fence = marker; + else if (fence && marker && marker.length >= fence.length) { + fence = null; + flush(); + } + continue; + } + if (!line.trim()) { + flush(); + continue; + } + if (/^\s*\|/.test(line) && current && !/^\s*\|/.test(current.lines[0])) + flush(); + if (!current) current = { lines: [], start: index, fence: false }; + current.lines.push(line); + } + flush(); + return blocks.map((block) => ({ + ...block, + text: block.lines.join("\n"), + table: !block.fence && block.lines.every((line) => /^\s*\|/.test(line)), + })); +} + +function splitMarkdownSentences(text) { + const sentences = []; + let start = 0; + let codeTicks = 0; + for (let index = 0; index < text.length; index += 1) { + if (text[index] === "`") { + let end = index; + while (text[end] === "`") end += 1; + const count = end - index; + if (!codeTicks) codeTicks = count; + else if (count === codeTicks) codeTicks = 0; + index = end - 1; + continue; + } + if (!codeTicks && /[.!?]/.test(text[index])) { + sentences.push({ text: text.slice(start, index + 1), start }); + start = index + 1; + } + } + if (text.slice(start).trim()) + sentences.push({ text: text.slice(start), start }); + return sentences; +} + +function bodyLinesOutsideFences(body) { + let fence; + return String(body ?? "") + .split(/\r?\n/) + .map((line) => { + const marker = line.match(/^\s*(```+)/)?.[1]; + if (marker) { + if (!fence) fence = marker; + else if (marker.length >= fence.length) fence = undefined; + return ""; + } + return fence ? "" : line; + }); +} + +function pathLineReferences(text) { + return [...String(text ?? "").matchAll(CODE_CITATION)].map((match) => ({ + file: match[1], + lineText: match[2], + line: Number(match[2].replace(/^~/, "").split("-", 1)[0]), + index: match.index, + })); +} + +function sourceQuoteAfter(lines, bodyLine) { + let index = bodyLine + 1; + while (index < lines.length && !lines[index].trim()) index += 1; + const opener = lines[index]?.match(/^\s*(```+)(.*)$/); + if (!opener) return null; + const fence = opener[1]; + const end = lines.findIndex( + (row, rowIndex) => + rowIndex > index && new RegExp(`^\\s*${fence}\\s*$`).test(row), + ); + if (end === -1) return null; + const text = lines.slice(index + 1, end).filter((row) => row.trim()); + return { end, info: opener[2].trim(), text }; +} + +function isTranscriptQuote(quote) { + const lines = quote.text.join("\n"); + return ( + /^(?:text|console|shell|sh|bash|output)$/i.test(quote.info) && + /^(?:\s*(?:\$|>)\s+(?:git|npm|npx|vitest|tsc)\b|\s*Test Files?\b.*\b(?:failed|passed)\b|\s*Tests?\s+\d+\s+(?:failed|passed)\b|\s*(?:PASS|FAIL)\s+(?:\||$)|\s*npm ERR!|\s*error TS\d+)/im.test( + lines, + ) + ); +} + +function lintCodeCitations(body, options = {}) { + const errors = []; + const rawLines = String(body ?? "").split(/\r?\n/); + const visibleBody = bodyLinesOutsideFences(body).join("\n"); + for (const { file, lineText, line: lineNumber, index } of pathLineReferences( + visibleBody, + )) { + const bodyLine = visibleBody.slice(0, index).split(/\r?\n/).length - 1; + const key = `${file}:${lineText}`; + const source = headFileSource(file, options); + if (source === null) { + errors.push(`PR body citation ${key} does not exist in the HEAD tree.`); + continue; + } + const sourceRows = sourceLines(source); + if (lineNumber < 1 || lineNumber > sourceRows.length) { + errors.push(`PR body citation ${key} is outside the HEAD tree.`); + continue; + } + const quote = sourceQuoteAfter(rawLines, bodyLine); + if (!quote) continue; + if (isTranscriptQuote(quote)) continue; + const start = Math.max(0, lineNumber - 1 - 20); + const finish = Math.min(sourceRows.length, lineNumber + 20); + const window = sourceRows.slice(start, finish).join("\n"); + if (!quote.text.length || !window.includes(quote.text.join("\n"))) + errors.push( + `PR body quote after citation ${key} does not match HEAD source within ±20 lines.`, + ); + } + return errors; +} + +function lintTestReferences(body, options = {}) { + const references = []; + const visibleBody = bodyLinesOutsideFences(body).join("\n"); + const corpus = testCorpus(options); + for (const match of visibleBody.matchAll(/\bit\(\s*["`]([^"`]+)["`]\s*\)/g)) { + const lineStart = visibleBody.lastIndexOf("\n", match.index) + 1; + if ( + !/^\s*\|/.test(visibleBody.slice(lineStart, match.index)) && + !match[1].includes("…") + ) + references.push(match[1]); + } + const lines = visibleBody.split(/\r?\n/); + let headers = null; + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const row = lines[lineIndex]; + if (/^\s*\|\s*:?-{3,}/.test(row)) { + headers = /^\s*\|.*\|\s*$/.test(lines[lineIndex - 1]) + ? lines[lineIndex - 1].split("|").map((value) => value.trim()) + : null; + continue; + } + if (!headers || !/^\s*\|.*\|\s*$/.test(row)) { + if (!/^\s*\|/.test(row)) headers = null; + continue; + } + const cells = row.split("|").map((value) => value.trim()); + for (let cellIndex = 0; cellIndex < cells.length; cellIndex += 1) { + if (!/(?:test|probe|case|witness|id)/i.test(headers[cellIndex] ?? "")) + continue; + const cell = cells[cellIndex]; + const match = /`([^`]+)`/.exec(cell); + if (!match) continue; + const reference = match[1].trim(); + const title = /^it\(\s*["`]([^"`]+)["`]\s*\)$/.exec(reference)?.[1]; + if (title?.includes("…")) continue; + if (title && !title.includes("…")) references.push(title); + else if ( + corpus.paths.has(reference) || + /^[A-Za-z]\d{2,}$/.test(reference) + ) + references.push(reference); + else if (corpus.titles.has(reference)) references.push(reference); + else if (/^[0-9a-f]{7,40}$/i.test(reference)) continue; + else references.push(reference); + } + } + const exists = (reference) => { + return corpus.paths.has(reference) || corpus.titles.has(reference); + }; + return [...new Set(references)] + .filter((reference) => !exists(reference)) + .map( + (reference) => + `PR body test reference is missing under tests/: ${reference}`, + ); +} + +function lintMasterClaims(body) { + const errors = []; + const blocks = markdownBlocks(body); + for (let blockIndex = 0; blockIndex < blocks.length; blockIndex += 1) { + const block = blocks[blockIndex]; + if (block.fence || block.table) continue; + for (const sentence of splitMarkdownSentences(block.text)) { + if ( + !MASTER_CLAIM.test(sentence.text) || + /reviewer\s+(?:wrote|said)/i.test(sentence.text) + ) + continue; + const next = blocks[blockIndex + 1]; + const hasTranscript = + next?.fence && + /origin\/master/i.test(next.text) && + /^(?:text|console|shell|sh|bash|output)\b/i.test( + next.lines[0]?.replace(/^\s*```+/, "") ?? "", + ); + if (!hasTranscript) + errors.push( + `PR body master/environment claim lacks an origin/master transcript: ${sentence.text.trim()}`, + ); + } + } + return errors; +} + function lintRuntimeObservability( body, lines, @@ -268,15 +617,25 @@ function lintRuntimeObservability( const content = observabilitySectionContent(body, lines, headings); if ([...observation.records].some((record) => content.includes(record))) return []; - const existingRecord = - /covered by existing record `([^`]+)` at `([^`:]+):(\d+)`/.exec(content); + const existingRecordCitation = pathLineReferences(content).find( + (reference) => { + const prefix = content.slice(0, reference.index); + return /covered by existing record `[^`]+` at\s*$/.test(prefix); + }, + ); + const existingRecordPrefix = existingRecordCitation + ? content + .slice(0, existingRecordCitation.index) + .match(/covered by existing record `([^`]+)` at\s*$/) + : null; if ( - existingRecord && - !/(?:^|\/)\.\.(?:\/|$)/.test(existingRecord[2]) && - isRuntimeObservabilityPath(existingRecord[2]) + existingRecordCitation && + existingRecordPrefix && + !/(?:^|\/)\.\.(?:\/|$)/.test(existingRecordCitation.file) && + isRuntimeObservabilityPath(existingRecordCitation.file) ) { - const [, kind, file, lineText] = existingRecord; - const lineNumber = Number(lineText); + const [, kind] = existingRecordPrefix; + const { file, line: lineNumber } = existingRecordCitation; try { const source = readFileSync( isAbsolute(file) ? file : resolve(cwd, file), @@ -550,6 +909,9 @@ export function lintPrBody(body = "", options = {}) { options.cwd, ), ); + errors.push(...lintCodeCitations(body, options)); + errors.push(...lintTestReferences(body, options)); + errors.push(...lintMasterClaims(body)); return { valid: errors.length === 0, errors }; } @@ -744,6 +1106,7 @@ export function lintLocalPrBody( requireTestAssessment: localTouchesTests(cwd, git), diff, cwd, + workingTree: true, }); } diff --git a/tests/fixtures/ci-pr-bodies/issue-2877-round-3.md b/tests/fixtures/ci-pr-bodies/issue-2877-round-3.md new file mode 100644 index 000000000..e7db81c0a --- /dev/null +++ b/tests/fixtures/ci-pr-bodies/issue-2877-round-3.md @@ -0,0 +1,28 @@ +## Summary + +The archived round-3 state-space tables are reconstructed here. + +## Tests + +| Dimension | Same scope | Before declaration | Nested | +| --- | --- | --- | --- | +| Block shadow | `B01` | `B03` | `B05` | +| Destructuring | `B29` | `B31` | `B32` | +| Parameter default | `B21` | `B23` | `B24` | + +| Probe | Runner | +| --- | --- | +| `P01` | ruff | +| `P30` | test-runner | + +## Blast radius + +The sweep is test-only. + +## Class sweep + +The table covers every binding and runner dimension. + +## Observability + +No new failure path; no record added. diff --git a/tests/scripts/check-pr-body.test.ts b/tests/scripts/check-pr-body.test.ts index 16b91ade6..dd883671f 100644 --- a/tests/scripts/check-pr-body.test.ts +++ b/tests/scripts/check-pr-body.test.ts @@ -585,6 +585,7 @@ describe("PR body lint (#1844)", () => { valid: false, errors: [ 'PR body Observability must name a record literal from the runtime diff; "No new failure path; no record added." is not valid when the added lines contain a failure path.', + `PR body citation ${file} does not exist in the HEAD tree.`, ], }); } finally { @@ -607,6 +608,7 @@ describe("PR body lint (#1844)", () => { valid: false, errors: [ 'PR body Observability must name a record literal from the runtime diff; "No new failure path; no record added." is not valid when the added lines contain a failure path.', + "PR body citation clients/does-not-exist.ts:1 does not exist in the HEAD tree.", ], }); }); @@ -1149,6 +1151,336 @@ ${placeholder}`, }); }); +describe("head-tree citations and test references", () => { + const headFiles = new Map([ + [ + "clients/citation.ts", + 'export const value = "head source";\nexport const second = true;\n', + ], + [ + "tests/citation.test.ts", + 'it("contains every label this repo\'s rules require to exist", () => {});\n', + ], + ]); + const options = { headFiles }; + + it("rejects a citation to a missing or out-of-range head file", () => { + const result = lintPrBody( + `${body}\nEvidence: \`clients/missing.ts:1\`\n\nAlso: \`clients/citation.ts:4\``, + options, + ); + expect(result.errors.join(" ")).toContain("clients/missing.ts:1"); + expect(result.errors.join(" ")).toContain( + "PR body citation clients/citation.ts:4 is outside the HEAD tree.", + ); + }); + + it("requires an adjacent quote to match source text within twenty lines", () => { + const result = lintPrBody( + `${body}\nEvidence: \`clients/citation.ts:1\`\n\`\`\`text\nwrong source\n\`\`\``, + options, + ); + expect(result.errors.join(" ")).toContain("does not match HEAD source"); + }); + + it("accepts a plain citation without a quote", () => { + expect( + lintPrBody(`${body}\nEvidence: \`clients/citation.ts:1\``, options), + ).toEqual({ valid: true, errors: [] }); + }); + + it("accepts a citation in a table cell without a quote", () => { + expect( + lintPrBody(`${body}\n| Evidence | \`clients/citation.ts:1\` |`, options), + ).toEqual({ valid: true, errors: [] }); + }); + + it("accepts range citations by their first line", () => { + expect( + lintPrBody(`${body}\nEvidence: \`clients/citation.ts:1-2\``, options), + ).toEqual({ valid: true, errors: [] }); + }); + + it("accepts approximate-line citations by their hinted line", () => { + expect( + lintPrBody(`${body}\nEvidence: \`clients/citation.ts:~1\``, options), + ).toEqual({ valid: true, errors: [] }); + }); + + it("pins the ±20 citation quote window", () => { + const source = Array.from({ length: 40 }, (_, index) => + index === 20 + ? "boundary source line" + : index === 21 + ? "outside source line" + : `line ${index + 1}`, + ).join("\n"); + const localOptions = { + headFiles: new Map([["clients/window.ts", source]]), + }; + const accepted = lintPrBody( + `${body}\nEvidence: \`clients/window.ts:1\`\n\`\`\`ts\nboundary source line\n\`\`\``, + localOptions, + ); + expect(accepted).toEqual({ valid: true, errors: [] }); + const rejected = lintPrBody( + `${body}\nEvidence: \`clients/window.ts:1\`\n\`\`\`text\noutside source line\n\`\`\``, + localOptions, + ); + expect(rejected.errors.join(" ")).toContain("within ±20 lines"); + }); + + it("pins both sides of the ±20 window and resolves range hints from the first line", () => { + const source = Array.from({ length: 60 }, (_, index) => + index === 0 ? "first source line" : `line ${index + 1}`, + ).join("\n"); + const localOptions = { + headFiles: new Map([["clients/window-both-sides.ts", source]]), + }; + const accepted = lintPrBody( + `${body}\nEvidence: \`clients/window-both-sides.ts:21-60\`\n\`\`\`ts\nfirst source line\n\`\`\``, + localOptions, + ); + expect(accepted).toEqual({ valid: true, errors: [] }); + const approximate = lintPrBody( + `${body}\nEvidence: \`clients/window-both-sides.ts:~21\`\n\`\`\`ts\nfirst source line\n\`\`\``, + localOptions, + ); + expect(approximate).toEqual({ valid: true, errors: [] }); + const rejected = lintPrBody( + `${body}\nEvidence: \`clients/window-both-sides.ts:22\`\n\`\`\`text\nfirst source line\n\`\`\``, + localOptions, + ); + expect(rejected.errors.join(" ")).toContain("within ±20 lines"); + }); + + it("checks every repeated citation quote", () => { + const result = lintPrBody( + `${body}\nEvidence: \`clients/citation.ts:1\`\n\`\`\`ts\nexport const value = "head source";\n\`\`\`\nAgain: \`clients/citation.ts:1\`\n\`\`\`ts\ntotally fabricated\n\`\`\``, + options, + ); + expect(result.errors.join(" ")).toContain("does not match HEAD source"); + }); + + it("recognizes only real transcript quote shapes", () => { + const result = lintPrBody( + `${body}\nEvidence: \`clients/citation.ts:1\`\n\`\`\`text\n$ npm test\nTests 1 passed (1)\n\`\`\``, + options, + ); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + it("does not treat incidental pass or fail words as transcripts", () => { + const result = lintPrBody( + `${body}\nEvidence: \`clients/citation.ts:1\`\n\`\`\`text\nthis source failed a review\n\`\`\``, + options, + ); + expect(result.errors.join(" ")).toContain("does not match HEAD source"); + }); + + it("does not treat an origin/master string in source as a transcript", () => { + const result = lintPrBody( + `${body}\nEvidence: \`clients/citation.ts:1\`\n\`\`\`text\nconst branch = "origin/master";\n\`\`\``, + options, + ); + expect(result.errors.join(" ")).toContain("does not match HEAD source"); + }); + + it("checks a transcript-looking quote unless its fence is tagged as output", () => { + const result = lintPrBody( + `${body}\nEvidence: \`clients/citation.ts:1\`\n\`\`\`ts\n$ npm test\nnot source\n\`\`\``, + options, + ); + expect(result.errors.join(" ")).toContain("does not match HEAD source"); + }); + + it("does not read preflight commands as test references", () => { + const result = lintPrBody( + `${body}\n| Gate | Command |\n| --- | --- |\n| typecheck | \`npx tsc --noEmit\` |\n| preflight | \`npm run preflight\` |`, + options, + ); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + it("rejects fabricated it titles and table identifiers", () => { + const result = lintPrBody( + `${body}\nThe check uses it("fabricated test title").\n\n| Case | Evidence |\n| --- | --- |\n| A | \`fabricated table test identifier\` |`, + options, + ); + expect(result.errors.join(" ")).toContain("fabricated test title"); + expect(result.errors.join(" ")).toContain( + "fabricated table test identifier", + ); + }); + + it("requires origin/master transcripts for master-red claims", () => { + const result = lintPrBody( + `${body}\nThis is pre-existing and red on master.`, + options, + ); + expect(result.errors.join(" ")).toContain("origin/master transcript"); + }); + + it("accepts real test references and an origin/master transcript", () => { + const result = lintPrBody( + `${body}\nThe real title is it("contains every label this repo's rules require to exist").\n\n| Case | Evidence |\n| --- | --- |\n| A | \`contains every label this repo's rules require to exist\` |\n\nThis is pre-existing.\n\`\`\`text\n$ git log origin/master\n\`\`\``, + options, + ); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + it("requires the transcript in the next markdown block", () => { + const result = lintPrBody( + `${body}\nThis is pre-existing.\n\nUnrelated paragraph.\n\n\`\`\`text\nrun on origin/master\n\`\`\``, + options, + ); + expect(result.errors.join(" ")).toContain("origin/master transcript"); + }); + + it("accepts a reviewer-attributed pre-existing statement", () => { + const result = lintPrBody( + `${body}\nThe reviewer wrote that the failure is pre-existing on the base branch.`, + options, + ); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + it("keeps dots inside code spans inside the sentence and table block", () => { + const result = lintPrBody( + `${body}\n| Convention | The pre-existing file is \`Fixture.Test.php\`. |`, + options, + ); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + it("ignores citations in fences and accepts the canonical it title in a table", () => { + const result = lintPrBody( + `${body}\n\`\`\`text\n\`clients/missing.ts:1\`\n\`\`\`\n\n| Case | Test |\n| --- | --- |\n| A | \`it("contains every label this repo's rules require to exist")\` |`, + options, + ); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + it("normalizes canonical it titles in table cells", () => { + const result = lintPrBody( + `${body}\n| Case | Test |\n| --- | --- |\n| A | \`it("fabricated table title")\` |`, + options, + ); + expect(result.errors.join(" ")).toContain("fabricated table title"); + }); + + it("checks canonical it titles with trailing table-cell content", () => { + const result = lintPrBody( + `${body}\n| Case | Test |\n| --- | --- |\n| A | \`it("fabricated trailing title")\` (regression) |`, + options, + ); + expect(result.errors.join(" ")).toContain("fabricated trailing title"); + }); + + it("checks canonical it titles in prose", () => { + const result = lintPrBody( + `${body}\nThe test is it("fabricated prose title").`, + options, + ); + expect(result.errors.join(" ")).toContain("fabricated prose title"); + }); + + it("checks bare test titles in table cells", () => { + const result = lintPrBody( + `${body}\n| Case | Test |\n| --- | --- |\n| A | \`fabricated bare title\` |`, + options, + ); + expect(result.errors.join(" ")).toContain("fabricated bare title"); + }); + + it("accepts a test path in a test column", () => { + const result = lintPrBody( + `${body}\n| Kind | Test id |\n| --- | --- |\n| path | \`tests/scripts/check-pr-body.test.ts\` |`, + options, + ); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + it("ignores non-test table cells", () => { + const result = lintPrBody( + `${body}\n| Command | Artifact |\n| --- | --- |\n| tool | \`python3 -m pip\` |`, + options, + ); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + it("ignores table header cells", () => { + const result = lintPrBody( + `${body}\n| \`fabricated header title\` | Test |\n| --- | --- |\n| Case | \`fabricated header value\` |`, + options, + ); + expect(result.errors.join(" ")).toContain("fabricated header value"); + expect(result.errors.join(" ")).not.toContain("fabricated header title"); + }); + + it("rejects a command-shaped test cell without a real title", () => { + const result = lintPrBody( + `${body}\n| Test |\n| --- |\n| \`python3 -m pip\` |`, + options, + ); + expect(result.errors.join(" ")).toContain("python3 -m pip"); + }); + + it("rejects a fabricated bare test title", () => { + const result = lintPrBody( + `${body}\n| Test |\n| --- |\n| \`fabricated bare title\` |`, + options, + ); + expect(result.errors.join(" ")).toContain("fabricated bare title"); + }); + + it("ignores SHA cells in test columns", () => { + const result = lintPrBody( + `${body}\n| Test id |\n| --- |\n| \`deadbeef1234567890\` |`, + options, + ); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + it.each([["each template title"]])( + "harvests each declaration titles", + (_title) => { + const result = lintPrBody( + `${body}\n| Test |\n| --- |\n| \`harvests each declaration titles\` |`, + options, + ); + expect(result).toEqual({ valid: true, errors: [] }); + }, + ); + + it("rejects a fabricated short table identifier", () => { + const result = lintPrBody( + `${body}\n| Case | Test |\n| --- | --- |\n| A | \`B01\` |`, + options, + ); + expect(result.errors.join(" ")).toContain("B01"); + }); + + it.each([ + [ + "#2877 round 3 reconstructed retracted section", + "issue-2877-round-3.md", + "P01", + ], + ])( + "keeps the historical red-first fixture red: %s", + (_name, file, expected) => { + const fixture = readFileSync( + join(repositoryRoot, "tests", "fixtures", "ci-pr-bodies", file), + "utf8", + ); + const result = lintPrBody(fixture); + expect(result.valid).toBe(false); + expect(result.errors.join(" ")).toContain(expected); + }, + ); +}); + describe("local lint parity", () => { let previousCwd: string; let fixtureCwd: string; @@ -1168,6 +1500,19 @@ describe("local lint parity", () => { expect(diff).toContain("diff --git a/"); }); + it("includes untracked test files in local test references", () => { + mkdirSync(join(fixtureCwd, "tests", "scripts"), { recursive: true }); + writeFileSync( + join(fixtureCwd, "tests", "scripts", "new.test.ts"), + 'it("untracked working tree title", () => {});\n', + ); + const result = lintPrBody( + `${body}\n| Test |\n| --- |\n| \`untracked working tree title\` |`, + { cwd: fixtureCwd, workingTree: true }, + ); + expect(result).toEqual({ valid: true, errors: [] }); + }); + it("rejects a runtime-shaped body that names no record", () => { const result = lintLocalPrBody( body.replace(