pr-shepherd 0.37.1 → 0.37.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/commands/commit-suggestion-instruction.mjs +3 -0
- package/bin/commands/commit-suggestion.mjs +13 -0
- package/bin/commands/iterate/render.mjs +1 -1
- package/bin/suggestions/lines.d.mts +12 -0
- package/bin/suggestions/lines.mjs +56 -0
- package/bin/suggestions/patch.mjs +2 -45
- package/bin/suggestions/range-adjacent.d.mts +7 -0
- package/bin/suggestions/range-adjacent.mjs +100 -0
- package/bin/suggestions/range-anchor.d.mts +1 -0
- package/bin/suggestions/range-anchor.mjs +40 -0
- package/bin/suggestions/range-exact-overlap.d.mts +2 -0
- package/bin/suggestions/range-exact-overlap.mjs +28 -0
- package/bin/suggestions/range-similarity.d.mts +2 -0
- package/bin/suggestions/range-similarity.mjs +135 -0
- package/bin/suggestions/range-substantive.d.mts +4 -0
- package/bin/suggestions/range-substantive.mjs +12 -0
- package/bin/suggestions/range-work.d.mts +2 -0
- package/bin/suggestions/range-work.mjs +12 -0
- package/bin/suggestions/range.d.mts +14 -0
- package/bin/suggestions/range.mjs +56 -0
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
|
@@ -19,9 +19,12 @@ export function buildCommitSuggestionInstruction(prNumber, sectionName, includeD
|
|
|
19
19
|
const driftHint = includeDriftHint
|
|
20
20
|
? "If the patch does not apply because the suggestion drifted, use the manual-fix step below. Do not retry the command."
|
|
21
21
|
: "If the patch does not apply, use the manual-edit step below. Do not retry the command.";
|
|
22
|
+
const manualStep = includeDriftHint ? "manual-fix step" : "manual-edit step";
|
|
22
23
|
return [
|
|
23
24
|
`For each thread marked \`[suggestion]\` under \`${sectionName}\`, run \`${command}\` to retrieve its patch and suggested commit.`,
|
|
24
25
|
"The CLI only builds the patch. Apply it, stage the listed file, and follow the returned commit instructions.",
|
|
26
|
+
`If the command refuses because the suggestion is unsafe (an unsafe anchored range or nested/unbalanced suggestion fences), skip patch application and use the ${manualStep} below. Do not retry the command.`,
|
|
27
|
+
"For any other refusal, follow the CLI error's stated recovery action; do not manually edit the suggestion.",
|
|
25
28
|
driftHint,
|
|
26
29
|
"Keep human-authored thread IDs in `apply review:` so Shepherd replies instead of resolving them.",
|
|
27
30
|
];
|
|
@@ -6,6 +6,7 @@ import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/cli
|
|
|
6
6
|
import { fetchSuggestionThread } from "../github/suggestion-thread.mjs";
|
|
7
7
|
import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
|
|
8
8
|
import { buildUnifiedDiff } from "../suggestions/patch.mjs";
|
|
9
|
+
import { getUnsafeSuggestionRangeReason } from "../suggestions/range.mjs";
|
|
9
10
|
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
10
11
|
import { buildPrShepherdCommand } from "../cli/runner.mjs";
|
|
11
12
|
import { getEffectiveCwd, getExecutionCwd } from "../execution-context.mjs";
|
|
@@ -80,6 +81,18 @@ export async function runCommitSuggestion(opts) {
|
|
|
80
81
|
throw new ShepherdError(`Thread ${opts.threadId} path escapes the working tree.`, EXIT.UNAVAILABLE);
|
|
81
82
|
}
|
|
82
83
|
const originalContent = await readFile(resolvedPath, "utf8");
|
|
84
|
+
const unsafeRangeReason = getUnsafeSuggestionRangeReason({
|
|
85
|
+
originalContent,
|
|
86
|
+
startLine,
|
|
87
|
+
endLine,
|
|
88
|
+
replacementLines: parsed.lines,
|
|
89
|
+
});
|
|
90
|
+
if (unsafeRangeReason) {
|
|
91
|
+
const range = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
|
|
92
|
+
throw new ShepherdError(`Thread ${opts.threadId}'s suggestion does not safely fit GitHub's anchored range ` +
|
|
93
|
+
`${filePath}:${range}: ${unsafeRangeReason} Refusing to build a patch; inspect the surrounding ` +
|
|
94
|
+
`source and reviewer intent, then apply the change manually.`, EXIT.UNAVAILABLE);
|
|
95
|
+
}
|
|
83
96
|
const patch = buildUnifiedDiff({
|
|
84
97
|
path: filePath,
|
|
85
98
|
originalContent,
|
|
@@ -68,7 +68,7 @@ isBehind = false) {
|
|
|
68
68
|
const filesRef = threads.length > 0 ? "each file referenced above" : "the relevant files";
|
|
69
69
|
instructions.push(`Apply every warranted review fix in ${filesRef}.`);
|
|
70
70
|
if (hasSuggestions) {
|
|
71
|
-
instructions.push("
|
|
71
|
+
instructions.push("After source drift prevents a generated suggestion patch from applying, replace the heading's exact `path:startLine-endLine` range with the `Replaces lines …` block verbatim. An empty replacement deletes the range. One blank line replaces it with one blank line.", "When `build-suggestion-patch` refuses because the suggestion is unsafe (an unsafe anchored range or nested/unbalanced suggestion fences), do not apply the replacement block verbatim. Inspect the surrounding source and reviewer intent, then make the intended edit manually.");
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
if (resolutionOnlyThreads.length > 0) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const normalizeLine: (line: string) => string;
|
|
2
|
+
export declare function splitFileLines(originalContent: string): string[];
|
|
3
|
+
/**
|
|
4
|
+
* Strip leading/trailing replacement lines that exactly duplicate file lines
|
|
5
|
+
* immediately outside the anchored range.
|
|
6
|
+
*/
|
|
7
|
+
export declare function analyzeReplacementContext(fileLines: readonly string[], startLine: number, endLine: number, replacementLines: readonly string[]): {
|
|
8
|
+
leadingLength: number;
|
|
9
|
+
replacementLines: string[];
|
|
10
|
+
trailingLength: number;
|
|
11
|
+
};
|
|
12
|
+
export declare function trimReplacementToContext(fileLines: readonly string[], startLine: number, endLine: number, replacementLines: readonly string[]): readonly string[];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const normalizeLine = (line) => line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
2
|
+
function linesEqual(left, right) {
|
|
3
|
+
return (left.length === right.length &&
|
|
4
|
+
left.every((line, index) => normalizeLine(line) === normalizeLine(right[index])));
|
|
5
|
+
}
|
|
6
|
+
export function splitFileLines(originalContent) {
|
|
7
|
+
const body = originalContent.endsWith("\n") ? originalContent.slice(0, -1) : originalContent;
|
|
8
|
+
return originalContent === "" ? [] : body.split("\n");
|
|
9
|
+
}
|
|
10
|
+
function retainsBoundaryAnchor(replacementLines, removedLines) {
|
|
11
|
+
return (removedLines.length > 0 &&
|
|
12
|
+
(linesEqual(replacementLines.slice(0, removedLines.length), removedLines) ||
|
|
13
|
+
linesEqual(replacementLines.slice(-removedLines.length), removedLines)));
|
|
14
|
+
}
|
|
15
|
+
function largestPermittedContextTrim({ maxLength, matchesContext, remainingAfterTrim, mustRetainAnchor, removedLines, }) {
|
|
16
|
+
for (let length = maxLength; length >= 1; length--) {
|
|
17
|
+
if (!matchesContext(length))
|
|
18
|
+
continue;
|
|
19
|
+
if (mustRetainAnchor && !retainsBoundaryAnchor(remainingAfterTrim(length), removedLines)) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
return length;
|
|
23
|
+
}
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Strip leading/trailing replacement lines that exactly duplicate file lines
|
|
28
|
+
* immediately outside the anchored range.
|
|
29
|
+
*/
|
|
30
|
+
export function analyzeReplacementContext(fileLines, startLine, endLine, replacementLines) {
|
|
31
|
+
const removedLines = fileLines.slice(startLine - 1, endLine);
|
|
32
|
+
const leadingMayBeAnchor = removedLines.length > 0 &&
|
|
33
|
+
linesEqual(replacementLines.slice(0, removedLines.length), removedLines);
|
|
34
|
+
const leadingLength = largestPermittedContextTrim({
|
|
35
|
+
maxLength: Math.min(startLine - 1, replacementLines.length),
|
|
36
|
+
matchesContext: (length) => linesEqual(replacementLines.slice(0, length), fileLines.slice(startLine - 1 - length, startLine - 1)),
|
|
37
|
+
remainingAfterTrim: (length) => replacementLines.slice(length),
|
|
38
|
+
mustRetainAnchor: leadingMayBeAnchor,
|
|
39
|
+
removedLines,
|
|
40
|
+
});
|
|
41
|
+
const remainder = replacementLines.slice(leadingLength);
|
|
42
|
+
const trailingMayBeAnchor = removedLines.length > 0 && linesEqual(remainder.slice(-removedLines.length), removedLines);
|
|
43
|
+
const trailingLength = largestPermittedContextTrim({
|
|
44
|
+
maxLength: Math.min(fileLines.length - endLine, remainder.length),
|
|
45
|
+
matchesContext: (length) => linesEqual(remainder.slice(-length), fileLines.slice(endLine, endLine + length)),
|
|
46
|
+
remainingAfterTrim: (length) => remainder.slice(0, -length),
|
|
47
|
+
mustRetainAnchor: trailingMayBeAnchor,
|
|
48
|
+
removedLines,
|
|
49
|
+
});
|
|
50
|
+
const trimmedLines = trailingLength === 0 ? remainder : remainder.slice(0, -trailingLength);
|
|
51
|
+
return { leadingLength, replacementLines: trimmedLines, trailingLength };
|
|
52
|
+
}
|
|
53
|
+
export function trimReplacementToContext(fileLines, startLine, endLine, replacementLines) {
|
|
54
|
+
return analyzeReplacementContext(fileLines, startLine, endLine, replacementLines)
|
|
55
|
+
.replacementLines;
|
|
56
|
+
}
|
|
@@ -4,53 +4,10 @@
|
|
|
4
4
|
* The diff uses `--- a/<path>` / `+++ b/<path>` headers so `git apply` and
|
|
5
5
|
* `git apply --check` accept it without a `diff --git` preamble.
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
|
-
* Strip leading/trailing replacement lines that are identical to the adjacent
|
|
9
|
-
* file lines just outside the removed range.
|
|
10
|
-
*
|
|
11
|
-
* GitHub stores a suggestion as a verbatim replacement for the *highlighted*
|
|
12
|
-
* line range, but reviewers often paste surrounding context into the box. Those
|
|
13
|
-
* context-duplicating lines must not appear as additions in the diff — they
|
|
14
|
-
* belong to the surrounding unchanged file content. Stripping them here produces
|
|
15
|
-
* a minimal diff that applies cleanly without duplicating lines.
|
|
16
|
-
*
|
|
17
|
-
* Comparison normalises trailing `\r` from file lines so CRLF files (which
|
|
18
|
-
* carry `\r` on each entry after `split("\n")`) still match suggestion lines
|
|
19
|
-
* delivered as LF-only by the GitHub API.
|
|
20
|
-
*/
|
|
21
|
-
function trimReplacementToContext(fileLines, startLine, endLine, replacementLines) {
|
|
22
|
-
const norm = (s) => (s.endsWith("\r") ? s.slice(0, -1) : s);
|
|
23
|
-
// Leading trim: largest L where replacement[0..L) == fileLines[startLine-1-L..startLine-1)
|
|
24
|
-
const maxL = Math.min(startLine - 1, replacementLines.length);
|
|
25
|
-
let L = 0;
|
|
26
|
-
leading: for (let l = maxL; l >= 1; l--) {
|
|
27
|
-
for (let i = 0; i < l; i++) {
|
|
28
|
-
if (norm(replacementLines[i]) !== norm(fileLines[startLine - 1 - l + i]))
|
|
29
|
-
continue leading;
|
|
30
|
-
}
|
|
31
|
-
L = l;
|
|
32
|
-
break;
|
|
33
|
-
}
|
|
34
|
-
const remainder = replacementLines.slice(L);
|
|
35
|
-
// Trailing trim: largest T where remainder[len-T..len) == fileLines[endLine..endLine+T)
|
|
36
|
-
const maxT = Math.min(fileLines.length - endLine, remainder.length);
|
|
37
|
-
let T = 0;
|
|
38
|
-
trailing: for (let t = maxT; t >= 1; t--) {
|
|
39
|
-
for (let j = 0; j < t; j++) {
|
|
40
|
-
if (norm(remainder[remainder.length - t + j]) !== norm(fileLines[endLine + j]))
|
|
41
|
-
continue trailing;
|
|
42
|
-
}
|
|
43
|
-
T = t;
|
|
44
|
-
break;
|
|
45
|
-
}
|
|
46
|
-
if (L === 0 && T === 0)
|
|
47
|
-
return replacementLines;
|
|
48
|
-
return T === 0 ? remainder : remainder.slice(0, remainder.length - T);
|
|
49
|
-
}
|
|
7
|
+
import { splitFileLines, trimReplacementToContext } from "./lines.mjs";
|
|
50
8
|
export function buildUnifiedDiff({ path, originalContent, startLine, endLine, replacementLines, context = 3, }) {
|
|
51
9
|
const endsWithNewline = originalContent.endsWith("\n");
|
|
52
|
-
const
|
|
53
|
-
const fileLines = body === "" ? [] : body.split("\n");
|
|
10
|
+
const fileLines = splitFileLines(originalContent);
|
|
54
11
|
const removedLines = fileLines.slice(startLine - 1, endLine);
|
|
55
12
|
const beforeStart = Math.max(0, startLine - 1 - context);
|
|
56
13
|
const beforeLines = fileLines.slice(beforeStart, startLine - 1);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function getAdjacentSuggestionRangeReason({ fileLines, removedLines, startLine, endLine, replacementLines, }: {
|
|
2
|
+
fileLines: readonly string[];
|
|
3
|
+
removedLines: readonly string[];
|
|
4
|
+
startLine: number;
|
|
5
|
+
endLine: number;
|
|
6
|
+
replacementLines: readonly string[];
|
|
7
|
+
}): string | null;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { findLineSequenceOffsets } from "./range-anchor.mjs";
|
|
2
|
+
import { sharesExactProperPrefix, sharesExactProperSuffix } from "./range-exact-overlap.mjs";
|
|
3
|
+
import { likelyRewritesChangedLineSubrange, likelyRewritesAdjacentSpan, } from "./range-similarity.mjs";
|
|
4
|
+
import { createScanWorkBudget } from "./range-work.mjs";
|
|
5
|
+
function adjacentSpansBefore(fileLines, startLine, replacementLineCount) {
|
|
6
|
+
const endIndex = startLine - 1;
|
|
7
|
+
const limit = Math.min(endIndex, Math.max(2, replacementLineCount * 2));
|
|
8
|
+
return Array.from({ length: limit }, (_, index) => fileLines.slice(endIndex - index - 1, endIndex));
|
|
9
|
+
}
|
|
10
|
+
function adjacentSpansAfter(fileLines, endLine, replacementLineCount) {
|
|
11
|
+
const limit = Math.min(fileLines.length - endLine, Math.max(2, replacementLineCount * 2));
|
|
12
|
+
return Array.from({ length: limit }, (_, index) => fileLines.slice(endLine, endLine + index + 1));
|
|
13
|
+
}
|
|
14
|
+
function likelyRewritesAdjacentSource(replacementLines, adjacentSpans, allowSingleLinePair) {
|
|
15
|
+
if (adjacentSpans
|
|
16
|
+
.slice(0, 8)
|
|
17
|
+
.some((span) => likelyRewritesAdjacentSpan(replacementLines, span, allowSingleLinePair))) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
return likelyRewritesChangedLineSubrange(replacementLines, adjacentSpans);
|
|
21
|
+
}
|
|
22
|
+
function extensionRewritesAfter(fileLines, endLine, extension) {
|
|
23
|
+
if (extension.length === 0)
|
|
24
|
+
return false;
|
|
25
|
+
const adjacentLines = fileLines.slice(endLine, endLine + extension.length);
|
|
26
|
+
return (sharesExactProperPrefix(extension, adjacentLines) ||
|
|
27
|
+
likelyRewritesAdjacentSource(extension, adjacentSpansAfter(fileLines, endLine, extension.length), true));
|
|
28
|
+
}
|
|
29
|
+
function extensionRewritesBefore(fileLines, startLine, extension) {
|
|
30
|
+
if (extension.length === 0)
|
|
31
|
+
return false;
|
|
32
|
+
const adjacentStart = startLine - 1 - extension.length;
|
|
33
|
+
const adjacentLines = fileLines.slice(Math.max(0, adjacentStart), startLine - 1);
|
|
34
|
+
return (sharesExactProperSuffix(extension, adjacentLines) ||
|
|
35
|
+
likelyRewritesAdjacentSource(extension, adjacentSpansBefore(fileLines, startLine, extension.length), true));
|
|
36
|
+
}
|
|
37
|
+
function scanExtension(fileLines, startLine, endLine, extension, direction, chargeScanWork) {
|
|
38
|
+
const available = direction === "before" ? startLine - 1 : fileLines.length - endLine;
|
|
39
|
+
if (chargeScanWork(extension.length, available))
|
|
40
|
+
return "work";
|
|
41
|
+
const rewrites = direction === "before"
|
|
42
|
+
? extensionRewritesBefore(fileLines, startLine, extension)
|
|
43
|
+
: extensionRewritesAfter(fileLines, endLine, extension);
|
|
44
|
+
return rewrites ? direction : null;
|
|
45
|
+
}
|
|
46
|
+
function retainedAnchorRewriteDirection(fileLines, removedLines, startLine, endLine, replacementLines, chargeScanWork) {
|
|
47
|
+
const offsets = findLineSequenceOffsets(replacementLines, removedLines);
|
|
48
|
+
// Bound repeated extension scans while rejecting ambiguous bodies safely.
|
|
49
|
+
if (offsets.length * replacementLines.length > 4_096)
|
|
50
|
+
return "ambiguous";
|
|
51
|
+
if (offsets.length === 0)
|
|
52
|
+
return null;
|
|
53
|
+
for (const offset of offsets) {
|
|
54
|
+
const before = replacementLines.slice(0, offset);
|
|
55
|
+
const after = replacementLines.slice(offset + removedLines.length);
|
|
56
|
+
const scans = [
|
|
57
|
+
[after, "after"],
|
|
58
|
+
[after, "before"],
|
|
59
|
+
[before, "before"],
|
|
60
|
+
[before, "after"],
|
|
61
|
+
];
|
|
62
|
+
for (const [extension, direction] of scans) {
|
|
63
|
+
const result = scanExtension(fileLines, startLine, endLine, extension, direction, chargeScanWork);
|
|
64
|
+
if (result !== null)
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return "safe";
|
|
69
|
+
}
|
|
70
|
+
export function getAdjacentSuggestionRangeReason({ fileLines, removedLines, startLine, endLine, replacementLines, }) {
|
|
71
|
+
const chargeScanWork = createScanWorkBudget();
|
|
72
|
+
const retainedAnchorDirection = retainedAnchorRewriteDirection(fileLines, removedLines, startLine, endLine, replacementLines, chargeScanWork);
|
|
73
|
+
if (retainedAnchorDirection === "after") {
|
|
74
|
+
return "The replacement retains the complete anchored range and appears to rewrite source immediately after it.";
|
|
75
|
+
}
|
|
76
|
+
if (retainedAnchorDirection === "before") {
|
|
77
|
+
return "The replacement retains the complete anchored range and appears to rewrite source immediately before it.";
|
|
78
|
+
}
|
|
79
|
+
if (retainedAnchorDirection === "ambiguous") {
|
|
80
|
+
return "The replacement repeats the complete anchored range too many times to validate its surrounding source safely.";
|
|
81
|
+
}
|
|
82
|
+
if (retainedAnchorDirection === "work") {
|
|
83
|
+
return "The replacement and adjacent source require too much similarity work to validate the anchored range safely.";
|
|
84
|
+
}
|
|
85
|
+
if (retainedAnchorDirection === "safe")
|
|
86
|
+
return null;
|
|
87
|
+
if (chargeScanWork(replacementLines.length, startLine - 1)) {
|
|
88
|
+
return "The replacement and adjacent source require too much similarity work to validate the anchored range safely.";
|
|
89
|
+
}
|
|
90
|
+
if (likelyRewritesAdjacentSource(replacementLines, adjacentSpansBefore(fileLines, startLine, replacementLines.length), false)) {
|
|
91
|
+
return "The replacement partially rewrites a source block before the anchored range.";
|
|
92
|
+
}
|
|
93
|
+
if (chargeScanWork(replacementLines.length, fileLines.length - endLine)) {
|
|
94
|
+
return "The replacement and adjacent source require too much similarity work to validate the anchored range safely.";
|
|
95
|
+
}
|
|
96
|
+
if (likelyRewritesAdjacentSource(replacementLines, adjacentSpansAfter(fileLines, endLine, replacementLines.length), false)) {
|
|
97
|
+
return "The replacement partially rewrites a source block after the anchored range.";
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function findLineSequenceOffsets(lines: readonly string[], sequence: readonly string[]): readonly number[];
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { normalizeLine } from "./lines.mjs";
|
|
2
|
+
function buildPrefixTable(pattern) {
|
|
3
|
+
const table = Array.from({ length: pattern.length }, () => 0);
|
|
4
|
+
let prefixLength = 0;
|
|
5
|
+
for (let index = 1; index < pattern.length;) {
|
|
6
|
+
if (pattern[index] === pattern[prefixLength]) {
|
|
7
|
+
prefixLength++;
|
|
8
|
+
table[index] = prefixLength;
|
|
9
|
+
index++;
|
|
10
|
+
}
|
|
11
|
+
else if (prefixLength > 0) {
|
|
12
|
+
prefixLength = table[prefixLength - 1];
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
index++;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return table;
|
|
19
|
+
}
|
|
20
|
+
export function findLineSequenceOffsets(lines, sequence) {
|
|
21
|
+
if (sequence.length === 0)
|
|
22
|
+
return [];
|
|
23
|
+
const pattern = sequence.map(normalizeLine);
|
|
24
|
+
const prefixTable = buildPrefixTable(pattern);
|
|
25
|
+
const offsets = [];
|
|
26
|
+
let matchedLength = 0;
|
|
27
|
+
for (let index = 0; index < lines.length; index++) {
|
|
28
|
+
const line = normalizeLine(lines[index]);
|
|
29
|
+
while (matchedLength > 0 && line !== pattern[matchedLength]) {
|
|
30
|
+
matchedLength = prefixTable[matchedLength - 1];
|
|
31
|
+
}
|
|
32
|
+
if (line === pattern[matchedLength])
|
|
33
|
+
matchedLength++;
|
|
34
|
+
if (matchedLength !== pattern.length)
|
|
35
|
+
continue;
|
|
36
|
+
offsets.push(index - pattern.length + 1);
|
|
37
|
+
matchedLength = prefixTable[matchedLength - 1];
|
|
38
|
+
}
|
|
39
|
+
return offsets;
|
|
40
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { normalizeLine } from "./lines.mjs";
|
|
2
|
+
function sharedExactPrefixLength(replacementLines, adjacentLines) {
|
|
3
|
+
const limit = Math.min(replacementLines.length, adjacentLines.length);
|
|
4
|
+
let length = 0;
|
|
5
|
+
while (length < limit &&
|
|
6
|
+
normalizeLine(replacementLines[length]) === normalizeLine(adjacentLines[length])) {
|
|
7
|
+
length++;
|
|
8
|
+
}
|
|
9
|
+
return length;
|
|
10
|
+
}
|
|
11
|
+
function sharedExactSuffixLength(replacementLines, adjacentLines) {
|
|
12
|
+
const limit = Math.min(replacementLines.length, adjacentLines.length);
|
|
13
|
+
let length = 0;
|
|
14
|
+
while (length < limit &&
|
|
15
|
+
normalizeLine(replacementLines[replacementLines.length - 1 - length]) ===
|
|
16
|
+
normalizeLine(adjacentLines[adjacentLines.length - 1 - length])) {
|
|
17
|
+
length++;
|
|
18
|
+
}
|
|
19
|
+
return length;
|
|
20
|
+
}
|
|
21
|
+
export function sharesExactProperPrefix(replacementLines, adjacentLines) {
|
|
22
|
+
const sharedLength = sharedExactPrefixLength(replacementLines, adjacentLines);
|
|
23
|
+
return sharedLength > 0 && sharedLength < replacementLines.length;
|
|
24
|
+
}
|
|
25
|
+
export function sharesExactProperSuffix(replacementLines, adjacentLines) {
|
|
26
|
+
const sharedLength = sharedExactSuffixLength(replacementLines, adjacentLines);
|
|
27
|
+
return sharedLength > 0 && sharedLength < replacementLines.length;
|
|
28
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare function likelyRewritesAdjacentSpan(replacementLines: readonly string[], adjacentLines: readonly string[], allowSingleLinePair: boolean): boolean;
|
|
2
|
+
export declare function likelyRewritesChangedLineSubrange(replacementLines: readonly string[], adjacentSpans: readonly (readonly string[])[]): boolean;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { normalizeLine } from "./lines.mjs";
|
|
2
|
+
import { hasLetterOrNumber, isSubstantiveExactAlignedBlock, isSubstantiveLine, isSubstantiveSharedRun, } from "./range-substantive.mjs";
|
|
3
|
+
function closelyRewritesText(replacement, adjacent) {
|
|
4
|
+
const shorterLength = Math.min(replacement.length, adjacent.length);
|
|
5
|
+
let prefixLength = 0;
|
|
6
|
+
while (prefixLength < shorterLength && replacement[prefixLength] === adjacent[prefixLength]) {
|
|
7
|
+
prefixLength++;
|
|
8
|
+
}
|
|
9
|
+
let suffixLength = 0;
|
|
10
|
+
while (prefixLength + suffixLength < shorterLength &&
|
|
11
|
+
replacement[replacement.length - 1 - suffixLength] ===
|
|
12
|
+
adjacent[adjacent.length - 1 - suffixLength]) {
|
|
13
|
+
suffixLength++;
|
|
14
|
+
}
|
|
15
|
+
const sharedLength = prefixLength + suffixLength;
|
|
16
|
+
return sharedLength >= 12 && sharedLength >= Math.ceil(shorterLength * 0.6);
|
|
17
|
+
}
|
|
18
|
+
function normalizeBlockText(lines) {
|
|
19
|
+
return lines.map(normalizeLine).join(" ").trim().replace(/\s+/g, " ");
|
|
20
|
+
}
|
|
21
|
+
const normalizeSharedLine = (line) => normalizeLine(line).trim().replace(/\s+/g, " ");
|
|
22
|
+
function sharedInternalRunAt(replacementLines, adjacentLines, replacementStart, adjacentStart) {
|
|
23
|
+
const sharedLines = [];
|
|
24
|
+
while (replacementStart + sharedLines.length < replacementLines.length - 1 &&
|
|
25
|
+
adjacentStart + sharedLines.length < adjacentLines.length - 1) {
|
|
26
|
+
const replacement = normalizeSharedLine(replacementLines[replacementStart + sharedLines.length]);
|
|
27
|
+
const adjacent = normalizeSharedLine(adjacentLines[adjacentStart + sharedLines.length]);
|
|
28
|
+
if (replacement !== adjacent)
|
|
29
|
+
break;
|
|
30
|
+
sharedLines.push(replacement);
|
|
31
|
+
}
|
|
32
|
+
return sharedLines;
|
|
33
|
+
}
|
|
34
|
+
function hasSubstantiveInternalOverlap(replacementLines, adjacentLines) {
|
|
35
|
+
if (replacementLines.length < 4 || adjacentLines.length < 4)
|
|
36
|
+
return false;
|
|
37
|
+
for (let replacementStart = 1; replacementStart < replacementLines.length - 1; replacementStart++) {
|
|
38
|
+
for (let adjacentStart = 1; adjacentStart < adjacentLines.length - 1; adjacentStart++) {
|
|
39
|
+
if (isSubstantiveSharedRun(sharedInternalRunAt(replacementLines, adjacentLines, replacementStart, adjacentStart))) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
export function likelyRewritesAdjacentSpan(replacementLines, adjacentLines, allowSingleLinePair) {
|
|
47
|
+
if (replacementLines.length === 0 || adjacentLines.length === 0)
|
|
48
|
+
return false;
|
|
49
|
+
if (!allowSingleLinePair && replacementLines.length === 1 && adjacentLines.length === 1) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const replacement = normalizeBlockText(replacementLines);
|
|
53
|
+
const adjacent = normalizeBlockText(adjacentLines);
|
|
54
|
+
return (replacement === adjacent ||
|
|
55
|
+
closelyRewritesText(replacement, adjacent) ||
|
|
56
|
+
hasSubstantiveInternalOverlap(replacementLines, adjacentLines));
|
|
57
|
+
}
|
|
58
|
+
function alignedLineRelation(replacementLine, adjacentLine) {
|
|
59
|
+
const replacement = normalizeSharedLine(replacementLine);
|
|
60
|
+
const adjacent = normalizeSharedLine(adjacentLine);
|
|
61
|
+
if (replacement === adjacent) {
|
|
62
|
+
const isNeutral = replacement === "" || !hasLetterOrNumber(replacement);
|
|
63
|
+
return isNeutral || isSubstantiveLine(replacementLine) ? "exact" : null;
|
|
64
|
+
}
|
|
65
|
+
if (!isSubstantiveLine(replacementLine) || !isSubstantiveLine(adjacentLine))
|
|
66
|
+
return null;
|
|
67
|
+
return closelyRewritesText(replacement, adjacent) ? "changed" : null;
|
|
68
|
+
}
|
|
69
|
+
function isMixedAlignedRewrite(candidate, adjacentSpan) {
|
|
70
|
+
for (let index = 0; index < candidate.length - 1; index++) {
|
|
71
|
+
const first = alignedLineRelation(candidate[index], adjacentSpan[index]);
|
|
72
|
+
const second = alignedLineRelation(candidate[index + 1], adjacentSpan[index + 1]);
|
|
73
|
+
if ((first === "exact" && second === "changed") ||
|
|
74
|
+
(first === "changed" && second === "exact")) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
function isChangedWithUnrelatedSubstantiveNeighbors(candidate, adjacentSpan) {
|
|
81
|
+
const relations = new Set(candidate.map((line, index) => alignedLineRelation(line, adjacentSpan[index])));
|
|
82
|
+
if (!relations.has("changed") || relations.has("exact"))
|
|
83
|
+
return false;
|
|
84
|
+
if (![...candidate, ...adjacentSpan].every((line) => isSubstantiveLine(line) || !hasLetterOrNumber(line))) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return (candidate.filter((line, index) => isSubstantiveLine(line) && isSubstantiveLine(adjacentSpan[index])).length >= 2);
|
|
88
|
+
}
|
|
89
|
+
function likelyRewritesChangedWindow(replacementLines, adjacentSpan) {
|
|
90
|
+
const length = adjacentSpan.length;
|
|
91
|
+
for (let start = 0; start <= replacementLines.length - length; start++) {
|
|
92
|
+
const candidate = replacementLines.slice(start, start + length);
|
|
93
|
+
if (isMixedAlignedRewrite(candidate, adjacentSpan))
|
|
94
|
+
return true;
|
|
95
|
+
if (isChangedWithUnrelatedSubstantiveNeighbors(candidate, adjacentSpan))
|
|
96
|
+
return true;
|
|
97
|
+
const isProperSubrange = start > 0 || start + length < replacementLines.length;
|
|
98
|
+
if (isProperSubrange && isSubstantiveExactAlignedBlock(candidate, adjacentSpan)) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
if (!candidate.every(isSubstantiveLine) || !adjacentSpan.every(isSubstantiveLine))
|
|
102
|
+
continue;
|
|
103
|
+
const hasExactLine = candidate.some((line, index) => normalizeSharedLine(line) === normalizeSharedLine(adjacentSpan[index]));
|
|
104
|
+
if (length > 8 || hasExactLine)
|
|
105
|
+
continue;
|
|
106
|
+
if (likelyRewritesAdjacentSpan(candidate, adjacentSpan, true))
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
export function likelyRewritesChangedLineSubrange(replacementLines, adjacentSpans) {
|
|
112
|
+
if (replacementLines.length < 2)
|
|
113
|
+
return false;
|
|
114
|
+
const adjacentLine = adjacentSpans.find((span) => span.length === 1)?.[0];
|
|
115
|
+
if (adjacentLine !== undefined && isSubstantiveLine(adjacentLine)) {
|
|
116
|
+
const adjacent = normalizeSharedLine(adjacentLine);
|
|
117
|
+
if (replacementLines.some((line) => {
|
|
118
|
+
if (!isSubstantiveLine(line))
|
|
119
|
+
return false;
|
|
120
|
+
const replacement = normalizeSharedLine(line);
|
|
121
|
+
return replacement !== adjacent && closelyRewritesText(replacement, adjacent);
|
|
122
|
+
})) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const maxLength = Math.min(replacementLines.length, adjacentSpans.length);
|
|
127
|
+
for (let length = 2; length <= maxLength; length++) {
|
|
128
|
+
const adjacentSpan = adjacentSpans.find((span) => span.length === length);
|
|
129
|
+
if (adjacentSpan === undefined)
|
|
130
|
+
continue;
|
|
131
|
+
if (likelyRewritesChangedWindow(replacementLines, adjacentSpan))
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const hasLetterOrNumber: (line: string) => boolean;
|
|
2
|
+
export declare const isSubstantiveLine: (line: string) => boolean;
|
|
3
|
+
export declare function isSubstantiveSharedRun(sharedLines: readonly string[]): boolean;
|
|
4
|
+
export declare function isSubstantiveExactAlignedBlock(candidate: readonly string[], adjacentLines: readonly string[]): boolean;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { normalizeLine } from "./lines.mjs";
|
|
2
|
+
export const hasLetterOrNumber = (line) => /[\p{L}\p{N}]/u.test(line);
|
|
3
|
+
export const isSubstantiveLine = (line) => hasLetterOrNumber(line) && line.replace(/\s/g, "").length >= 8;
|
|
4
|
+
export function isSubstantiveSharedRun(sharedLines) {
|
|
5
|
+
const substantiveLines = sharedLines.filter(isSubstantiveLine);
|
|
6
|
+
return substantiveLines.length >= 2 && substantiveLines.join("").replace(/\s/g, "").length >= 24;
|
|
7
|
+
}
|
|
8
|
+
export function isSubstantiveExactAlignedBlock(candidate, adjacentLines) {
|
|
9
|
+
return (isSubstantiveSharedRun(candidate) &&
|
|
10
|
+
candidate.every((line, index) => normalizeLine(line).trim().replace(/\s+/g, " ") ===
|
|
11
|
+
normalizeLine(adjacentLines[index]).trim().replace(/\s+/g, " ")));
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const MAX_ADJACENT_SCAN_WORK = 1_000_000;
|
|
2
|
+
function estimatedScanWork(replacementLineCount, availableLineCount) {
|
|
3
|
+
const changedWindowLimit = Math.min(availableLineCount, replacementLineCount);
|
|
4
|
+
return replacementLineCount * changedWindowLimit ** 2;
|
|
5
|
+
}
|
|
6
|
+
export function createScanWorkBudget() {
|
|
7
|
+
let work = 0;
|
|
8
|
+
return (replacementLineCount, availableLineCount) => {
|
|
9
|
+
work += estimatedScanWork(replacementLineCount, availableLineCount);
|
|
10
|
+
return work > MAX_ADJACENT_SCAN_WORK;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Return why a suggestion body cannot be reconciled safely with GitHub's
|
|
3
|
+
* anchored line range, or null when the exact range is safe to use.
|
|
4
|
+
*
|
|
5
|
+
* The range is authoritative: this validator never shifts or expands it. It
|
|
6
|
+
* only detects bodies that appear to reach beyond the anchor, leaving manual
|
|
7
|
+
* interpretation to the caller instead of emitting a structurally unsafe diff.
|
|
8
|
+
*/
|
|
9
|
+
export declare function getUnsafeSuggestionRangeReason({ originalContent, startLine, endLine, replacementLines, }: {
|
|
10
|
+
originalContent: string;
|
|
11
|
+
startLine: number;
|
|
12
|
+
endLine: number;
|
|
13
|
+
replacementLines: readonly string[];
|
|
14
|
+
}): string | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { analyzeReplacementContext, normalizeLine, splitFileLines } from "./lines.mjs";
|
|
2
|
+
import { getAdjacentSuggestionRangeReason } from "./range-adjacent.mjs";
|
|
3
|
+
import { findLineSequenceOffsets } from "./range-anchor.mjs";
|
|
4
|
+
import { hasLetterOrNumber } from "./range-substantive.mjs";
|
|
5
|
+
function discardedContextContainsAnchorLine({ leadingLength, removedLines, replacementLines, trailingLength, }) {
|
|
6
|
+
if (removedLines.length < 2)
|
|
7
|
+
return false;
|
|
8
|
+
const discardedLines = [
|
|
9
|
+
...replacementLines.slice(0, leadingLength),
|
|
10
|
+
...replacementLines.slice(replacementLines.length - trailingLength),
|
|
11
|
+
];
|
|
12
|
+
const normalizedAnchorLines = new Set(removedLines.map(normalizeLine).filter(hasLetterOrNumber));
|
|
13
|
+
return discardedLines.some((line) => normalizedAnchorLines.has(normalizeLine(line)));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Return why a suggestion body cannot be reconciled safely with GitHub's
|
|
17
|
+
* anchored line range, or null when the exact range is safe to use.
|
|
18
|
+
*
|
|
19
|
+
* The range is authoritative: this validator never shifts or expands it. It
|
|
20
|
+
* only detects bodies that appear to reach beyond the anchor, leaving manual
|
|
21
|
+
* interpretation to the caller instead of emitting a structurally unsafe diff.
|
|
22
|
+
*/
|
|
23
|
+
export function getUnsafeSuggestionRangeReason({ originalContent, startLine, endLine, replacementLines, }) {
|
|
24
|
+
const fileLines = splitFileLines(originalContent);
|
|
25
|
+
if (!Number.isInteger(startLine) ||
|
|
26
|
+
!Number.isInteger(endLine) ||
|
|
27
|
+
startLine < 1 ||
|
|
28
|
+
endLine < startLine ||
|
|
29
|
+
endLine > fileLines.length) {
|
|
30
|
+
return `GitHub reported an invalid or out-of-bounds range (${startLine}-${endLine}) for a ${fileLines.length}-line file.`;
|
|
31
|
+
}
|
|
32
|
+
const removedLines = fileLines.slice(startLine - 1, endLine);
|
|
33
|
+
const contextTrim = analyzeReplacementContext(fileLines, startLine, endLine, replacementLines);
|
|
34
|
+
if (contextTrim.leadingLength > 0 || contextTrim.trailingLength > 0) {
|
|
35
|
+
const originalOccurrences = findLineSequenceOffsets(replacementLines, removedLines).length;
|
|
36
|
+
const trimmedOccurrences = findLineSequenceOffsets(contextTrim.replacementLines, removedLines).length;
|
|
37
|
+
if (originalOccurrences > trimmedOccurrences) {
|
|
38
|
+
return "Exact-context trimming would discard a complete copy of the anchored range, so the intended duplicate insertion is ambiguous.";
|
|
39
|
+
}
|
|
40
|
+
if (discardedContextContainsAnchorLine({
|
|
41
|
+
leadingLength: contextTrim.leadingLength,
|
|
42
|
+
removedLines,
|
|
43
|
+
replacementLines,
|
|
44
|
+
trailingLength: contextTrim.trailingLength,
|
|
45
|
+
})) {
|
|
46
|
+
return "Exact-context trimming would discard a partial copy of the anchored range, so the intended duplicate insertion is ambiguous.";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return getAdjacentSuggestionRangeReason({
|
|
50
|
+
fileLines,
|
|
51
|
+
removedLines,
|
|
52
|
+
startLine,
|
|
53
|
+
endLine,
|
|
54
|
+
replacementLines: contextTrim.replacementLines,
|
|
55
|
+
});
|
|
56
|
+
}
|
package/package.json
CHANGED