Skip to content

Commit 82ccb70

Browse files
authored
refactor(rees): promote reconstructOldContent into a shared analyzer helper (#4752)
reconstructOldContent (unified-diff reverse-patch reconstruction) was private to doc-comment-drift.ts and imported cross-file from there by exhaustiveness-drift.ts -- a one-off trick, not shared infrastructure. Move it into its own co-located module (matching the existing diff-lines.ts / binary-extensions.ts / github-headers.ts precedent for shared analyzer helpers) so any analyzer can recover a changed file's pre-PR text without re-deriving this. Pure code motion: every executable line is byte-identical (verified via diff + matching MD5 after stripping whole-line comments); only the function's own doc comment and one inline comment were reworded to drop doc-comment-drift-specific framing now that the helper is shared. Both existing callers are migrated with a one-line import-path change each and no other modifications. Adds a dedicated test file for the promoted helper: the four existing reconstructOldContent unit tests move over verbatim, plus five new tests closing every previously-untested branch (a non-hunk preamble line, out-of-order/overlapping hunks, a "no newline at end of file" marker, the trailing-flush no-op case, and the wholly-new-file case) -- 100% line/branch/function coverage confirmed via node's built-in coverage instrumentation.
1 parent 222cc0c commit 82ccb70

5 files changed

Lines changed: 156 additions & 68 deletions

File tree

review-enrichment/src/analyzers/doc-comment-drift.ts

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// non-finding. Deliberately conservative: only NAMED `function` declarations whose parameters are confidently
77
// enumerable (any destructuring / non-identifier param → skip the function). Reports symbol + stale params + line.
88
import type { EnrichRequest, DocCommentDriftFinding } from "../types.js";
9+
import { reconstructOldContent } from "./reconstruct-old-content.js";
910

1011
const MAX_FILES = 20;
1112
const MAX_FINDINGS = 50;
@@ -51,47 +52,6 @@ async function readBoundedText(resp: Response, signal?: AbortSignal): Promise<st
5152
}
5253
}
5354

54-
/** Reconstruct the pre-PR content of a file by reverse-applying its unified `patch` to the post-PR `newContent`:
55-
* context and removed (`-`) lines rebuild the old text; added (`+`) lines are dropped. Returns null if a hunk's
56-
* position runs past the content (so the caller falls back to "no old parameters" and reports nothing). Pure. */
57-
export function reconstructOldContent(newContent: string, patch: string): string | null {
58-
const newLines = newContent.split("\n");
59-
const patchLines = patch.split("\n");
60-
const out: string[] = [];
61-
let cursor = 0; // next unconsumed index into newLines
62-
let i = 0;
63-
while (i < patchLines.length) {
64-
const header = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(patchLines[i]!);
65-
if (!header) {
66-
i += 1;
67-
continue;
68-
}
69-
const hunkStart = Number(header[1]) - 1; // 0-based new-file line the hunk begins at
70-
if (hunkStart < cursor || hunkStart > newLines.length) return null;
71-
while (cursor < hunkStart) out.push(newLines[cursor++]!); // unchanged lines before the hunk
72-
i += 1;
73-
while (i < patchLines.length && !patchLines[i]!.startsWith("@@")) {
74-
const l = patchLines[i]!;
75-
if (!l.startsWith("\\")) {
76-
const sign = l[0];
77-
const body = l.slice(1);
78-
if (sign === "-") {
79-
out.push(body); // removed: present in old only
80-
} else {
81-
// added or context lines must match the fetched head content at the cursor; a mismatch means the patch
82-
// doesn't align with `newContent` (malformed/truncated input) → bail so we never trust a bad old signature.
83-
if (newLines[cursor] !== body) return null;
84-
if (sign !== "+") out.push(body); // context is present in old too; an added line is not
85-
cursor += 1;
86-
}
87-
}
88-
i += 1;
89-
}
90-
}
91-
while (cursor < newLines.length) out.push(newLines[cursor++]!);
92-
return out.join("\n");
93-
}
94-
9555
/** Map every named `function NAME` declaration in `content` to its enumerable parameter-name set. A function whose
9656
* parameters aren't confidently enumerable is omitted; a name DECLARED MORE THAN ONCE (overload/duplicate) is
9757
* excluded entirely, so a lookup can never return a sibling declaration's parameters. Used to compare OLD vs NEW. */

review-enrichment/src/analyzers/exhaustiveness-drift.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// file-fetch caps; fail-safe on missing token/headSha, bad slug, or fetch errors.
66
import type { EnrichRequest, ExhaustivenessFinding } from "../types.js";
77
import { githubHeaders } from "../github-headers.js";
8-
import { reconstructOldContent } from "./doc-comment-drift.js";
8+
import { reconstructOldContent } from "./reconstruct-old-content.js";
99
import { isDiffFileHeaderLine } from "./diff-lines.js";
1010
import { isTestPath } from "./test-ratio.js";
1111
import { DEFAULT_MAX_FINDINGS } from "./limits.js";
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Shared unified-diff reverse-patch reconstruction (#4739, part of epic #4737). Originally private to
2+
// doc-comment-drift.ts (#1519) and imported cross-file from there by exhaustiveness-drift.ts (#2028) — a
3+
// one-off trick living in the wrong place rather than shared infrastructure. Promoted here, unchanged in
4+
// behavior, so any analyzer can recover a changed file's pre-PR text without re-deriving this.
5+
//
6+
// Cost note: this function does no I/O. The caller must already have fetched the file's post-change
7+
// (`headSha`) content — the same authed GitHub contents-API fetch every current caller already performs
8+
// for its own purposes — and pass it in as `newContent`. Promoting the reverse-patch algorithm out of
9+
// doc-comment-drift.ts does not add a new network call.
10+
//
11+
// Binary files: this function only ever sees two text blobs (`newContent`, `patch`) and has no file path
12+
// or extension to inspect, so it cannot itself detect a binary file. That filtering happens one layer up:
13+
// every current caller only invokes this after confirming the file's patch is present and its path
14+
// matches a known source extension (GitHub omits `.patch` entirely for binary/oversized files, so a
15+
// binary path never reaches here in practice). A future caller must keep doing that same source/extension
16+
// filtering before calling this — it is not this function's job to guess from content alone.
17+
18+
/** Reconstruct the pre-PR content of a file by reverse-applying its unified `patch` to the post-PR
19+
* `newContent`: context and removed (`-`) lines rebuild the old text; added (`+`) lines are dropped.
20+
*
21+
* Returns `null` when the patch cannot be reverse-applied against the given `newContent` — a hunk starts
22+
* before the cursor or past the end of the content, or an added/context line doesn't match `newContent`
23+
* at the expected position (a malformed/truncated patch, or a `newContent` that doesn't correspond to
24+
* the same ref the patch was computed against).
25+
*
26+
* Returns an empty string when the patch reverse-applies cleanly but yields zero pre-PR lines — the case
27+
* for a file that did not exist before this PR (a "wholly added" patch has no old-side content to
28+
* rebuild). An empty string and `null` are both falsy; every caller should treat either as "no usable
29+
* before-content for this file" via a plain truthiness check (`if (!beforeContent) …`), not a strict
30+
* `=== null` comparison — the two are operationally the same "nothing to compare against" outcome, and
31+
* patch data alone cannot (and need not) distinguish a brand-new file from a pre-existing 0-byte one.
32+
*
33+
* Pure — no I/O, no dependency on `path` or any other file metadata. */
34+
export function reconstructOldContent(newContent: string, patch: string): string | null {
35+
const newLines = newContent.split("\n");
36+
const patchLines = patch.split("\n");
37+
const out: string[] = [];
38+
let cursor = 0; // next unconsumed index into newLines
39+
let i = 0;
40+
while (i < patchLines.length) {
41+
const header = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(patchLines[i]!);
42+
if (!header) {
43+
i += 1;
44+
continue;
45+
}
46+
const hunkStart = Number(header[1]) - 1; // 0-based new-file line the hunk begins at
47+
if (hunkStart < cursor || hunkStart > newLines.length) return null;
48+
while (cursor < hunkStart) out.push(newLines[cursor++]!); // unchanged lines before the hunk
49+
i += 1;
50+
while (i < patchLines.length && !patchLines[i]!.startsWith("@@")) {
51+
const l = patchLines[i]!;
52+
if (!l.startsWith("\\")) {
53+
const sign = l[0];
54+
const body = l.slice(1);
55+
if (sign === "-") {
56+
out.push(body); // removed: present in old only
57+
} else {
58+
// added or context lines must match the fetched head content at the cursor; a mismatch means the patch
59+
// doesn't align with `newContent` (malformed/truncated input, or a different ref) → bail so we never
60+
// trust a reconstructed result that isn't provably faithful to the real pre-PR file.
61+
if (newLines[cursor] !== body) return null;
62+
if (sign !== "+") out.push(body); // context is present in old too; an added line is not
63+
cursor += 1;
64+
}
65+
}
66+
i += 1;
67+
}
68+
}
69+
while (cursor < newLines.length) out.push(newLines[cursor++]!);
70+
return out.join("\n");
71+
}

review-enrichment/test/doc-comment-drift.test.ts

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import { test } from "node:test";
44
import assert from "node:assert/strict";
55
import {
6-
reconstructOldContent,
76
extractFunctionParams,
87
parseDocParams,
98
parseFunctionParams,
@@ -26,12 +25,6 @@ const oldParams = (entries) => new Map(entries.map(([name, ids]) => [name, new S
2625
const DRIFTED = `/**\n * @param oldName the old one\n */\nexport function doThing(newName) {\n return newName;\n}\n`;
2726
const DRIFT_PATCH = `@@ -1,6 +1,6 @@\n /**\n * @param oldName the old one\n */\n-export function doThing(oldName) {\n+export function doThing(newName) {\n return newName;\n }`;
2827

29-
test("reconstructOldContent: reverse-applies a patch to rebuild the pre-PR file", () => {
30-
const old = reconstructOldContent(DRIFTED, DRIFT_PATCH);
31-
assert.match(old, /function doThing\(oldName\)/); // the old parameter name is restored
32-
assert.doesNotMatch(old, /newName\) \{/); // the added signature line is dropped
33-
});
34-
3528
test("extractFunctionParams: maps each enumerable named function to its parameter set", () => {
3629
const map = extractFunctionParams(`export function f(a, b) {}\nfunction g({ x }) {}\nfunction h(c) {}\n`);
3730
assert.deepEqual([...map.get("f")], ["a", "b"]);
@@ -65,25 +58,6 @@ test("extractFunctionParams: skips a TS `this` pseudo-parameter, keeping the rea
6558
assert.deepEqual([...map.get("qux")], ["a"]);
6659
});
6760

68-
test("reconstructOldContent: bails (null) when the patch context does not match the head content", () => {
69-
// The context line ` other` doesn't exist in newContent → misaligned patch → fail closed.
70-
assert.equal(reconstructOldContent(`a\nb\n`, `@@ -1,2 +1,2 @@\n-x\n+a\n other`), null);
71-
});
72-
73-
test("reconstructOldContent: rebuilds across MULTIPLE hunks, filling the unchanged gap between them", () => {
74-
// new file: a / X / c / d. Hunk 1 changed Y→X (line 2); hunk 2 changed D→d (line 4); `c` is the untouched gap.
75-
const old = reconstructOldContent(
76-
`a\nX\nc\nd\n`,
77-
`@@ -1,2 +1,2 @@\n a\n-Y\n+X\n@@ -4,1 +4,1 @@\n-D\n+d`,
78-
);
79-
assert.equal(old, `a\nY\nc\nD\n`);
80-
});
81-
82-
test("reconstructOldContent: bails (null) when a hunk starts beyond the head content's length", () => {
83-
// A hunk anchored at line 99 of a 2-line file can't align → fail closed rather than fabricate old content.
84-
assert.equal(reconstructOldContent(`a\nb\n`, `@@ -99,1 +99,1 @@\n a`), null);
85-
});
86-
8761
test("findDocCommentDrift: a duplicate-named function is skipped (no cross-declaration false positive)", () => {
8862
// Two `dup` declarations; a stale @param on the first must not borrow the other's old params.
8963
const content = `/**\n * @param gone\n */\nexport function dup(a) {}\nfunction dup(b) {}\n`;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Units for the shared reverse-patch reconstruction helper (#4739, part of epic #4737). Own file (not
2+
// doc-comment-drift.test.ts / exhaustiveness-drift.test.ts) now that the function lives in its own
3+
// module and is consumed by both of those analyzers. Runs against the compiled dist/.
4+
import { test } from "node:test";
5+
import assert from "node:assert/strict";
6+
import { reconstructOldContent } from "../dist/analyzers/reconstruct-old-content.js";
7+
8+
// The four tests below are relocated verbatim from doc-comment-drift.test.ts (this function's prior home)
9+
// as part of #4739's extraction — same fixtures, same assertions, zero behavior change.
10+
11+
test("reconstructOldContent: reverse-applies a patch to rebuild the pre-PR file", () => {
12+
const DRIFTED = `/**\n * @param oldName the old one\n */\nexport function doThing(newName) {\n return newName;\n}\n`;
13+
const DRIFT_PATCH = `@@ -1,6 +1,6 @@\n /**\n * @param oldName the old one\n */\n-export function doThing(oldName) {\n+export function doThing(newName) {\n return newName;\n }`;
14+
const old = reconstructOldContent(DRIFTED, DRIFT_PATCH);
15+
assert.match(old, /function doThing\(oldName\)/); // the old parameter name is restored
16+
assert.doesNotMatch(old, /newName\) \{/); // the added signature line is dropped
17+
});
18+
19+
test("reconstructOldContent: bails (null) when the patch context does not match the head content", () => {
20+
// The context line ` other` doesn't exist in newContent → misaligned patch → fail closed.
21+
assert.equal(reconstructOldContent(`a\nb\n`, `@@ -1,2 +1,2 @@\n-x\n+a\n other`), null);
22+
});
23+
24+
test("reconstructOldContent: rebuilds across MULTIPLE hunks, filling the unchanged gap between them", () => {
25+
// new file: a / X / c / d. Hunk 1 changed Y→X (line 2); hunk 2 changed D→d (line 4); `c` is the untouched gap.
26+
const old = reconstructOldContent(
27+
`a\nX\nc\nd\n`,
28+
`@@ -1,2 +1,2 @@\n a\n-Y\n+X\n@@ -4,1 +4,1 @@\n-D\n+d`,
29+
);
30+
assert.equal(old, `a\nY\nc\nD\n`);
31+
});
32+
33+
test("reconstructOldContent: bails (null) when a hunk starts beyond the head content's length", () => {
34+
// A hunk anchored at line 99 of a 2-line file can't align → fail closed rather than fabricate old content.
35+
assert.equal(reconstructOldContent(`a\nb\n`, `@@ -99,1 +99,1 @@\n a`), null);
36+
});
37+
38+
// The tests below are new, added for #4739's full-branch-coverage requirement on the promoted shared
39+
// helper — each pins a branch the four relocated tests above don't already exercise.
40+
41+
test("reconstructOldContent: a non-hunk preamble line before the first @@ header is skipped, not fatal", () => {
42+
// A raw `diff --git a/x b/x` style line (never present in GitHub's per-file `.patch`, but the loop
43+
// defensively tolerates it) must be skipped over, not mistaken for hunk content or a parse failure.
44+
const old = reconstructOldContent("b", "diff --git a/x b/x\n@@ -1,1 +1,1 @@\n-a\n+b");
45+
assert.equal(old, "a");
46+
});
47+
48+
test("reconstructOldContent: bails (null) when a later hunk starts before the previous hunk's cursor (out of order/overlap)", () => {
49+
// Hunk 1 consumes new-file lines 1-2 (cursor ends at 2); hunk 2 claims to start at new-file line 2
50+
// (0-based index 1), which is BEHIND the cursor — an out-of-order or overlapping hunk pair that must
51+
// fail closed rather than reconstruct a nonsensical result.
52+
assert.equal(
53+
reconstructOldContent("a\nb\nc\nd", "@@ -1,2 +1,2 @@\n a\n b\n@@ -2,1 +2,1 @@\n c"),
54+
null,
55+
);
56+
});
57+
58+
test("reconstructOldContent: a `\\ No newline at end of file` marker line is skipped, not treated as content", () => {
59+
// The marker starts with `\` (never `+`/`-`/` `); it must be ignored entirely rather than parsed as a
60+
// sign+body pair (which would read a bogus sign and desync the cursor, or falsely fail closed).
61+
const old = reconstructOldContent(
62+
"a\nb",
63+
"@@ -1,2 +1,2 @@\n a\n-x\n+b\n\\ No newline at end of file",
64+
);
65+
assert.equal(old, "a\nx");
66+
});
67+
68+
test("reconstructOldContent: the trailing unchanged-lines flush is a no-op when the last hunk already reaches EOF", () => {
69+
// The final `while (cursor < newLines.length)` flush must correctly do NOTHING when the last hunk's
70+
// context/added lines already consumed every remaining new-file line.
71+
const old = reconstructOldContent("a\nb", "@@ -1,2 +1,2 @@\n-x\n+a\n b");
72+
assert.equal(old, "x\nb");
73+
});
74+
75+
test("reconstructOldContent: a wholly new file reconstructs to an empty string, not null — both are falsy", () => {
76+
// A patch that is 100% additions (old range `-0,0`) has no old-side content to rebuild: the correct
77+
// reconstruction of "the file did not exist before this PR" is an empty string, not null. Every caller
78+
// must treat this the same as null via a plain truthiness check (see the module's own doc comment) —
79+
// patch data alone cannot (and need not) distinguish a brand-new file from a pre-existing 0-byte one.
80+
const old = reconstructOldContent("a\nb\nc", "@@ -0,0 +1,3 @@\n+a\n+b\n+c");
81+
assert.equal(old, "");
82+
assert.ok(!old); // falsy, exactly like null — this is the contract every caller relies on
83+
});

0 commit comments

Comments
 (0)