diff --git a/.github/scripts/pr-carry-attribution.cjs b/.github/scripts/pr-carry-attribution.cjs index 28864491027..c7849987dc9 100644 --- a/.github/scripts/pr-carry-attribution.cjs +++ b/.github/scripts/pr-carry-attribution.cjs @@ -54,8 +54,121 @@ function carryWindow(text, from) { const TRAILER_RE = /^[ \t]*co-authored-by:[ \t]*(.+)$/gim; -const FENCED_CODE_RE = /^[ \t]*(\u0060{3,}|~{3,})[\s\S]*?^[ \t]*\1[ \t]*$/gm; const INLINE_CODE_RE = /\u0060[^\u0060\n]*\u0060/g; + +/** + * Remove fenced blocks in linear time. + * + * A regex that searches lazily for a closing fence has to retry from every + * opening-looking line when no close exists. Pull request and commit text is + * untrusted workflow input, so that quadratic failure mode is significant + * here. Index the pure fence lines -- the only lines that can close a block -- + * then walk the lines once: an opener pairs with the longest closing run no + * longer than its own, and an opener with no such line remains ordinary text, + * so a later opener can still pair with its own close. + * + * Line boundaries follow the same rule the regex's ^ and $ did: CR, LF, and + * the Unicode separators all end a line, with CRLF as one terminator. The + * closing-length rule is what the backreference produced by backtracking: it + * captured the full greedy run first and shortened it one delimiter at a + * time, so the longest close length still ahead wins and the earliest line + * carrying it ends the block. + */ +function stripFencedCode(text) { + const lineStarts = [0]; + const lineEnds = [text.length]; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === "\n" || c === "\r" || c === "\u2028" || c === "\u2029") { + lineEnds[lineStarts.length - 1] = i; + if (c === "\r" && text[i + 1] === "\n") i++; + lineStarts.push(i + 1); + lineEnds.push(text.length); + } + } + + const openRun = new Array(lineStarts.length).fill(null); + const closeLists = { "`": new Map(), "~": new Map() }; + for (let i = 0; i < lineStarts.length; i++) { + const line = text.slice(lineStarts[i], lineEnds[i]); + const opening = /^[ \t]*(\u0060{3,}|~{3,})/.exec(line); + if (opening) openRun[i] = { fence: opening[1][0], len: opening[1].length }; + const closing = /^[ \t]*(\u0060{3,}|~{3,})[ \t]*$/.exec(line); + if (closing) { + const lists = closeLists[closing[1][0]]; + const len = closing[1].length; + const list = lists.get(len); + if (list) list.push(i); + else lists.set(len, [i]); + } + } + + // For each delimiter, the distinct close lengths and a disjoint set that + // permanently skips a length once every line carrying it is behind the + // scan. Scanning only moves forward, so each removal is final and total + // work stays near-linear. + const fenceIndex = {}; + for (const fence of ["\u0060", "~"]) { + const lengths = [...closeLists[fence].keys()].sort((a, b) => a - b); + fenceIndex[fence] = { + lengths, + lists: lengths.map((len) => closeLists[fence].get(len)), + cursors: new Array(lengths.length).fill(0), + parent: lengths.map((_, index) => index), + }; + } + + // Largest member of index's set still reachable -- the disjoint-set + // "previous element" trick; a linked root below index is the answer. + function aliveAt(scan, index) { + let root = index; + while (root >= 0 && scan.parent[root] !== root) root = scan.parent[root]; + while (index >= 0 && scan.parent[index] !== index) { + const next = scan.parent[index]; + scan.parent[index] = root; + index = next; + } + return root; + } + + // First pure-fence line after `after` carrying the longest close length + // that is at most openerLen; -1 when no close length qualifies. + function closeFor(fence, openerLen, after) { + const scan = fenceIndex[fence]; + let lo = 0; + let hi = scan.lengths.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (scan.lengths[mid] <= openerLen) lo = mid + 1; + else hi = mid; + } + const pos = lo - 1; + while (pos >= 0) { + const index = aliveAt(scan, pos); + if (index < 0) return -1; + const list = scan.lists[index]; + let cursor = scan.cursors[index]; + while (cursor < list.length && list[cursor] <= after) cursor++; + scan.cursors[index] = cursor; + if (cursor < list.length) return list[cursor]; + scan.parent[index] = aliveAt(scan, index - 1); + } + return -1; + } + + let output = ""; + let copiedThrough = 0; + for (let i = 0; i < lineStarts.length; i++) { + const run = openRun[i]; + if (run === null) continue; + const close = closeFor(run.fence, run.len, i); + if (close === -1) continue; + output += text.slice(copiedThrough, lineStarts[i]); + copiedThrough = lineEnds[close]; + i = close; + } + return output + text.slice(copiedThrough); +} /** * HTML comments, which GitHub never renders. * @@ -81,8 +194,7 @@ const HTML_COMMENT_RE = /|$)/g; */ function strippedText(text) { if (typeof text !== "string") return ""; - return text - .replace(FENCED_CODE_RE, "") + return stripFencedCode(text) .replace(HTML_COMMENT_RE, "") .replace(INLINE_CODE_RE, ""); } diff --git a/.github/scripts/pr-carry-attribution.test.cjs b/.github/scripts/pr-carry-attribution.test.cjs index 08010a18d20..9ba28dc7e18 100644 --- a/.github/scripts/pr-carry-attribution.test.cjs +++ b/.github/scripts/pr-carry-attribution.test.cjs @@ -2,7 +2,10 @@ const { describe, it } = require("node:test"); const assert = require("node:assert/strict"); -const { assessCarryAttribution } = require("./pr-carry-attribution.cjs"); +const { + assessCarryAttribution, + referencedCarryNumbers, +} = require("./pr-carry-attribution.cjs"); const RRMLIMA = { login: "rrmlima", @@ -120,6 +123,107 @@ describe("assessCarryAttribution", () => { ); }); + it("scans many unclosed fence-like lines without repeatedly searching the tail", () => { + const body = "```x\n".repeat(20_000) + "Reimplements #2797."; + const started = performance.now(); + + assert.deepEqual([...referencedCarryNumbers(body)], [2797]); + assert.ok(performance.now() - started < 2_000, "fence scan should remain linear"); + }); + + it("strips a complete tilde fence that follows an unmatched backtick opener", () => { + // The unclosed opener stays ordinary text, but it must not swallow the + // independent fenced block after it. + assert.deepEqual( + assessCarryAttribution( + base({ + body: [ + "\u0060\u0060\u0060unclosed", + "~~~", + "Reimplements #2797", + "~~~", + ].join("\n"), + }), + ), + [], + ); + }); + + it("strips a longer fence that follows an unmatched shorter opener", () => { + assert.deepEqual( + assessCarryAttribution( + base({ + body: [ + "\u0060\u0060\u0060unclosed", + "\u0060\u0060\u0060\u0060", + "Reimplements #2797", + "\u0060\u0060\u0060\u0060", + ].join("\n"), + }), + ), + [], + ); + }); + + it("strips a fence whose closing run is shorter than its opening run", () => { + // The backreferenced regex gave back opener delimiters until a close + // matched: a pure ``` line still closes a ```` opener. An exact-length + // lookup would leave "Reimplements #2797" readable as a declaration. + assert.deepEqual( + assessCarryAttribution( + base({ + body: [ + "\u0060\u0060\u0060\u0060", + "Reimplements #2797", + "\u0060\u0060\u0060", + ].join("\n"), + }), + ), + [], + ); + }); + + it("prefers the longest closing run, the way the backreference backtracked", () => { + // Greedy capture tries the full opener run first: a pure ```` line + // farther down outranks a nearer ``` line, so the whole span is removed. + assert.deepEqual( + assessCarryAttribution( + base({ + body: [ + "\u0060\u0060\u0060\u0060", + "\u0060\u0060\u0060", + "Reimplements #2797", + "\u0060\u0060\u0060\u0060", + ].join("\n"), + }), + ), + [], + ); + }); + + it("strips a fenced block written with CRLF line endings", () => { + assert.deepEqual( + assessCarryAttribution( + base({ + body: "\u0060\u0060\u0060\r\nReimplements #2797\r\n\u0060\u0060\u0060\r\n", + }), + ), + [], + ); + }); + + it("still reads carry language around an unmatched opener", () => { + // Falling back to ordinary text is not a license to hide a real claim: + // the unmatched opener line itself remains in the scanned text. + const failures = assessCarryAttribution( + base({ + body: ["\u0060\u0060\u0060unclosed", "Reimplements #2797."].join("\n"), + }), + ); + assert.equal(failures.length, 1); + assert.deepEqual(failures[0].paths, ["#2797"]); + }); + it("ignores carry language after an unclosed HTML comment", () => { // GitHub renders nothing after an unterminated `