pr-shepherd 0.37.1 → 0.38.0

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.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/cli/help-command-pages.d.mts +2 -2
  3. package/bin/cli/help-command-pages.mjs +2 -2
  4. package/bin/cli/help-top-page.d.mts +1 -1
  5. package/bin/cli/help-top-page.mjs +1 -1
  6. package/bin/cli/help.d.mts +3 -3
  7. package/bin/cli/journal-formatter.mjs +2 -2
  8. package/bin/commands/commit-suggestion-instruction.mjs +3 -0
  9. package/bin/commands/commit-suggestion.mjs +13 -0
  10. package/bin/commands/iterate/render.mjs +1 -1
  11. package/bin/commands/journal/journal-item.d.mts +11 -0
  12. package/bin/commands/journal/journal-item.mjs +29 -0
  13. package/bin/commands/journal/journal-markdown.d.mts +13 -0
  14. package/bin/commands/journal/journal-markdown.mjs +66 -0
  15. package/bin/commands/journal/transform.d.mts +1 -16
  16. package/bin/commands/journal/transform.mjs +118 -95
  17. package/bin/commands/shepherd-journal.d.mts +5 -2
  18. package/bin/commands/shepherd-journal.mjs +6 -3
  19. package/bin/suggestions/lines.d.mts +12 -0
  20. package/bin/suggestions/lines.mjs +56 -0
  21. package/bin/suggestions/patch.mjs +2 -45
  22. package/bin/suggestions/range-adjacent.d.mts +7 -0
  23. package/bin/suggestions/range-adjacent.mjs +100 -0
  24. package/bin/suggestions/range-anchor.d.mts +1 -0
  25. package/bin/suggestions/range-anchor.mjs +40 -0
  26. package/bin/suggestions/range-exact-overlap.d.mts +2 -0
  27. package/bin/suggestions/range-exact-overlap.mjs +28 -0
  28. package/bin/suggestions/range-similarity.d.mts +2 -0
  29. package/bin/suggestions/range-similarity.mjs +135 -0
  30. package/bin/suggestions/range-substantive.d.mts +4 -0
  31. package/bin/suggestions/range-substantive.mjs +12 -0
  32. package/bin/suggestions/range-work.d.mts +2 -0
  33. package/bin/suggestions/range-work.mjs +12 -0
  34. package/bin/suggestions/range.d.mts +14 -0
  35. package/bin/suggestions/range.mjs +56 -0
  36. package/package.json +1 -1
  37. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  38. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  39. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.37.1",
4
+ "version": "0.38.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -50,7 +50,7 @@ Selectors:
50
50
  --help, -h Print this help and exit before GitHub I/O.`;
51
51
  readonly "apply journal": `pr-shepherd apply journal
52
52
 
53
- Append a list item to the ## Shepherd Journal section of a PR body.
53
+ Append a list item to the Shepherd Journal details block of a PR body.
54
54
 
55
55
  Usage:
56
56
  pr-shepherd apply journal [PR] <item> [--dry-run] [--format text|json]
@@ -205,7 +205,7 @@ Flags:
205
205
  Exit code: 0 on success (including a no-op --dry-run on a nonexistent target); nonzero on failure (sysexits.h — see docs/exit-codes.md).`;
206
206
  readonly journal: `pr-shepherd journal
207
207
 
208
- Append a list item to the ## Shepherd Journal section of a PR body.
208
+ Append a list item to the Shepherd Journal details block of a PR body.
209
209
  Creates the section at the end if absent. Idempotent — duplicate items are skipped.
210
210
 
211
211
  Usage:
@@ -53,7 +53,7 @@ Selectors:
53
53
  --help, -h Print this help and exit before GitHub I/O.`,
54
54
  "apply journal": `pr-shepherd apply journal
55
55
 
56
- Append a list item to the ## Shepherd Journal section of a PR body.
56
+ Append a list item to the Shepherd Journal details block of a PR body.
57
57
 
58
58
  Usage:
59
59
  pr-shepherd apply journal [PR] <item> [--dry-run] [--format text|json]
@@ -208,7 +208,7 @@ Flags:
208
208
  Exit code: 0 on success (including a no-op --dry-run on a nonexistent target); nonzero on failure (sysexits.h — see docs/exit-codes.md).`,
209
209
  journal: `pr-shepherd journal
210
210
 
211
- Append a list item to the ## Shepherd Journal section of a PR body.
211
+ Append a list item to the Shepherd Journal details block of a PR body.
212
212
  Creates the section at the end if absent. Idempotent — duplicate items are skipped.
213
213
 
214
214
  Usage:
@@ -1 +1 @@
1
- export declare const TOP_USAGE = "pr-shepherd\n\nAutonomous PR CI monitor and review-comment resolver for agentic coding tools.\n\nUsage:\n pr-shepherd --version | -v\n pr-shepherd --help | -h\n pr-shepherd [PR] [poll-flags] [iterate-flags]\n pr-shepherd iterate [PR] [iterate-flags]\n pr-shepherd apply review [PR] [review-flags]\n pr-shepherd apply files [PR] [files...] [--tests] [--match REGEX]\n pr-shepherd apply journal [PR] <item> [--dry-run] [--format text|json]\n pr-shepherd build-suggestion-patch [PR] --thread-id ID --message MSG [flags]\n pr-shepherd admin clean <pr|branch|current|repo|all> [value] [flags]\n pr-shepherd admin log-file [--format text|json]\n\nCommands:\n [PR] Poll until non-WAIT or timeout. This is the default command.\n iterate Run one iterate tick (single-tick alias).\n apply review Apply review-state mutations after fixes.\n apply files Mark changed files as viewed in GitHub.\n apply journal Append a list item to the ## Shepherd Journal section of a PR body.\n build-suggestion-patch\n Convert one GitHub suggestion thread into a patch and commit instructions.\n admin clean Remove pr-shepherd state files.\n admin log-file Print the per-worktree debug log path.\n\nPR argument:\n PR may be a number such as 42 or a GitHub pull request URL.\n When omitted, pr-shepherd infers the current branch's pull request.\n\nCommon flags:\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields and detailed poll-tick lines.\n --help, -h Print help and exit before any GitHub, git, config, or log I/O.\n\nIterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.\n\nPolling flags:\n --interval <duration> Delay between WAIT ticks. Bare number = seconds. Default: 60s.\n --timeout <duration> Poll wall-clock cap for WAIT ticks. Bare number = seconds. Default: 4.5m.\n --debounce <duration> Settle window after first FIX_CODE before returning. Bare number = seconds. Default: 60s. 0 disables.\n --quiet-status During WAIT polling, print only changed status snapshots.\n --until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.\n\nClean variants:\n pr [number] Remove state for one PR. Defaults to current branch PR.\n branch [name] Remove state for a branch's PR. Defaults to current branch.\n current Alias for branch against the current branch.\n repo Remove all state for the current repository.\n all Remove all pr-shepherd state.\n\nExit codes: 0 done, 10-19 PR state, 64-78 shepherd failed (sysexits.h).\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\nSee docs/exit-codes.md for the full sysexits.h error-code table.\n\nDuration examples: 30s, 4.5m, 1h. A bare number uses each flag's default unit (see above); decimals are allowed with an explicit unit (4.5m).\n\nRun 'pr-shepherd <command> --help' for command-specific details.";
1
+ export declare const TOP_USAGE = "pr-shepherd\n\nAutonomous PR CI monitor and review-comment resolver for agentic coding tools.\n\nUsage:\n pr-shepherd --version | -v\n pr-shepherd --help | -h\n pr-shepherd [PR] [poll-flags] [iterate-flags]\n pr-shepherd iterate [PR] [iterate-flags]\n pr-shepherd apply review [PR] [review-flags]\n pr-shepherd apply files [PR] [files...] [--tests] [--match REGEX]\n pr-shepherd apply journal [PR] <item> [--dry-run] [--format text|json]\n pr-shepherd build-suggestion-patch [PR] --thread-id ID --message MSG [flags]\n pr-shepherd admin clean <pr|branch|current|repo|all> [value] [flags]\n pr-shepherd admin log-file [--format text|json]\n\nCommands:\n [PR] Poll until non-WAIT or timeout. This is the default command.\n iterate Run one iterate tick (single-tick alias).\n apply review Apply review-state mutations after fixes.\n apply files Mark changed files as viewed in GitHub.\n apply journal Append a list item to the Shepherd Journal details block of a PR body.\n build-suggestion-patch\n Convert one GitHub suggestion thread into a patch and commit instructions.\n admin clean Remove pr-shepherd state files.\n admin log-file Print the per-worktree debug log path.\n\nPR argument:\n PR may be a number such as 42 or a GitHub pull request URL.\n When omitted, pr-shepherd infers the current branch's pull request.\n\nCommon flags:\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields and detailed poll-tick lines.\n --help, -h Print help and exit before any GitHub, git, config, or log I/O.\n\nIterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.\n\nPolling flags:\n --interval <duration> Delay between WAIT ticks. Bare number = seconds. Default: 60s.\n --timeout <duration> Poll wall-clock cap for WAIT ticks. Bare number = seconds. Default: 4.5m.\n --debounce <duration> Settle window after first FIX_CODE before returning. Bare number = seconds. Default: 60s. 0 disables.\n --quiet-status During WAIT polling, print only changed status snapshots.\n --until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.\n\nClean variants:\n pr [number] Remove state for one PR. Defaults to current branch PR.\n branch [name] Remove state for a branch's PR. Defaults to current branch.\n current Alias for branch against the current branch.\n repo Remove all state for the current repository.\n all Remove all pr-shepherd state.\n\nExit codes: 0 done, 10-19 PR state, 64-78 shepherd failed (sysexits.h).\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\nSee docs/exit-codes.md for the full sysexits.h error-code table.\n\nDuration examples: 30s, 4.5m, 1h. A bare number uses each flag's default unit (see above); decimals are allowed with an explicit unit (4.5m).\n\nRun 'pr-shepherd <command> --help' for command-specific details.";
@@ -19,7 +19,7 @@ Commands:
19
19
  iterate Run one iterate tick (single-tick alias).
20
20
  apply review Apply review-state mutations after fixes.
21
21
  apply files Mark changed files as viewed in GitHub.
22
- apply journal Append a list item to the ## Shepherd Journal section of a PR body.
22
+ apply journal Append a list item to the Shepherd Journal details block of a PR body.
23
23
  build-suggestion-patch
24
24
  Convert one GitHub suggestion thread into a patch and commit instructions.
25
25
  admin clean Remove pr-shepherd state files.
@@ -50,7 +50,7 @@ Selectors:
50
50
  --help, -h Print this help and exit before GitHub I/O.`;
51
51
  readonly "apply journal": `pr-shepherd apply journal
52
52
 
53
- Append a list item to the ## Shepherd Journal section of a PR body.
53
+ Append a list item to the Shepherd Journal details block of a PR body.
54
54
 
55
55
  Usage:
56
56
  pr-shepherd apply journal [PR] <item> [--dry-run] [--format text|json]
@@ -205,7 +205,7 @@ Flags:
205
205
  Exit code: 0 on success (including a no-op --dry-run on a nonexistent target); nonzero on failure (sysexits.h — see docs/exit-codes.md).`;
206
206
  readonly journal: `pr-shepherd journal
207
207
 
208
- Append a list item to the ## Shepherd Journal section of a PR body.
208
+ Append a list item to the Shepherd Journal details block of a PR body.
209
209
  Creates the section at the end if absent. Idempotent — duplicate items are skipped.
210
210
 
211
211
  Usage:
@@ -228,7 +228,7 @@ Flags:
228
228
 
229
229
  Exit code: 0 on success (including no-change no-op); nonzero on failure (sysexits.h — see docs/exit-codes.md).`;
230
230
  readonly "log-file": "pr-shepherd log-file\n\nPrint the per-worktree append-only debug log path for the current repository.\nThe log is created by the first non-help pr-shepherd command that initializes logging.\n\nUsage:\n pr-shepherd log-file [--format text|json]\n\nFlags:\n --format text|json Print a raw path or {\"path\": \"...\"} JSON. Default: text.\n --help, -h Print this help and exit before logging setup.\n\nEnvironment:\n PR_SHEPHERD_LOG_DISABLED=1 disables logging.\n PR_SHEPHERD_STATE_DIR overrides the base state directory.\n\nExit code: 0 on success; 1 if repository identity cannot be resolved.";
231
- readonly top: "pr-shepherd\n\nAutonomous PR CI monitor and review-comment resolver for agentic coding tools.\n\nUsage:\n pr-shepherd --version | -v\n pr-shepherd --help | -h\n pr-shepherd [PR] [poll-flags] [iterate-flags]\n pr-shepherd iterate [PR] [iterate-flags]\n pr-shepherd apply review [PR] [review-flags]\n pr-shepherd apply files [PR] [files...] [--tests] [--match REGEX]\n pr-shepherd apply journal [PR] <item> [--dry-run] [--format text|json]\n pr-shepherd build-suggestion-patch [PR] --thread-id ID --message MSG [flags]\n pr-shepherd admin clean <pr|branch|current|repo|all> [value] [flags]\n pr-shepherd admin log-file [--format text|json]\n\nCommands:\n [PR] Poll until non-WAIT or timeout. This is the default command.\n iterate Run one iterate tick (single-tick alias).\n apply review Apply review-state mutations after fixes.\n apply files Mark changed files as viewed in GitHub.\n apply journal Append a list item to the ## Shepherd Journal section of a PR body.\n build-suggestion-patch\n Convert one GitHub suggestion thread into a patch and commit instructions.\n admin clean Remove pr-shepherd state files.\n admin log-file Print the per-worktree debug log path.\n\nPR argument:\n PR may be a number such as 42 or a GitHub pull request URL.\n When omitted, pr-shepherd infers the current branch's pull request.\n\nCommon flags:\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields and detailed poll-tick lines.\n --help, -h Print help and exit before any GitHub, git, config, or log I/O.\n\nIterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.\n\nPolling flags:\n --interval <duration> Delay between WAIT ticks. Bare number = seconds. Default: 60s.\n --timeout <duration> Poll wall-clock cap for WAIT ticks. Bare number = seconds. Default: 4.5m.\n --debounce <duration> Settle window after first FIX_CODE before returning. Bare number = seconds. Default: 60s. 0 disables.\n --quiet-status During WAIT polling, print only changed status snapshots.\n --until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.\n\nClean variants:\n pr [number] Remove state for one PR. Defaults to current branch PR.\n branch [name] Remove state for a branch's PR. Defaults to current branch.\n current Alias for branch against the current branch.\n repo Remove all state for the current repository.\n all Remove all pr-shepherd state.\n\nExit codes: 0 done, 10-19 PR state, 64-78 shepherd failed (sysexits.h).\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\nSee docs/exit-codes.md for the full sysexits.h error-code table.\n\nDuration examples: 30s, 4.5m, 1h. A bare number uses each flag's default unit (see above); decimals are allowed with an explicit unit (4.5m).\n\nRun 'pr-shepherd <command> --help' for command-specific details.";
231
+ readonly top: "pr-shepherd\n\nAutonomous PR CI monitor and review-comment resolver for agentic coding tools.\n\nUsage:\n pr-shepherd --version | -v\n pr-shepherd --help | -h\n pr-shepherd [PR] [poll-flags] [iterate-flags]\n pr-shepherd iterate [PR] [iterate-flags]\n pr-shepherd apply review [PR] [review-flags]\n pr-shepherd apply files [PR] [files...] [--tests] [--match REGEX]\n pr-shepherd apply journal [PR] <item> [--dry-run] [--format text|json]\n pr-shepherd build-suggestion-patch [PR] --thread-id ID --message MSG [flags]\n pr-shepherd admin clean <pr|branch|current|repo|all> [value] [flags]\n pr-shepherd admin log-file [--format text|json]\n\nCommands:\n [PR] Poll until non-WAIT or timeout. This is the default command.\n iterate Run one iterate tick (single-tick alias).\n apply review Apply review-state mutations after fixes.\n apply files Mark changed files as viewed in GitHub.\n apply journal Append a list item to the Shepherd Journal details block of a PR body.\n build-suggestion-patch\n Convert one GitHub suggestion thread into a patch and commit instructions.\n admin clean Remove pr-shepherd state files.\n admin log-file Print the per-worktree debug log path.\n\nPR argument:\n PR may be a number such as 42 or a GitHub pull request URL.\n When omitted, pr-shepherd infers the current branch's pull request.\n\nCommon flags:\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields and detailed poll-tick lines.\n --help, -h Print help and exit before any GitHub, git, config, or log I/O.\n\nIterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.\n\nPolling flags:\n --interval <duration> Delay between WAIT ticks. Bare number = seconds. Default: 60s.\n --timeout <duration> Poll wall-clock cap for WAIT ticks. Bare number = seconds. Default: 4.5m.\n --debounce <duration> Settle window after first FIX_CODE before returning. Bare number = seconds. Default: 60s. 0 disables.\n --quiet-status During WAIT polling, print only changed status snapshots.\n --until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.\n\nClean variants:\n pr [number] Remove state for one PR. Defaults to current branch PR.\n branch [name] Remove state for a branch's PR. Defaults to current branch.\n current Alias for branch against the current branch.\n repo Remove all state for the current repository.\n all Remove all pr-shepherd state.\n\nExit codes: 0 done, 10-19 PR state, 64-78 shepherd failed (sysexits.h).\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\nSee docs/exit-codes.md for the full sysexits.h error-code table.\n\nDuration examples: 30s, 4.5m, 1h. A bare number uses each flag's default unit (see above); decimals are allowed with an explicit unit (4.5m).\n\nRun 'pr-shepherd <command> --help' for command-specific details.";
232
232
  };
233
233
  /** Resolve help keys for nested public commands before any command I/O. */
234
234
  export declare function helpKeyForArgs(args: string[]): keyof typeof USAGE;
@@ -9,7 +9,7 @@ export function formatJournalResult(result) {
9
9
  if (!result.mutated)
10
10
  return "No change — entry already present.";
11
11
  if (!result.sectionExisted) {
12
- return `Created ## Shepherd Journal section in PR #${result.prNumber}.`;
12
+ return `Created Shepherd Journal details in PR #${result.prNumber}.`;
13
13
  }
14
- return `Appended to ## Shepherd Journal in PR #${result.prNumber}.`;
14
+ return `Appended to Shepherd Journal details in PR #${result.prNumber}.`;
15
15
  }
@@ -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("For a manual `[suggestion]` fix, 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.");
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,11 @@
1
+ type ValidationOk = {
2
+ ok: true;
3
+ item: string;
4
+ };
5
+ type ValidationError = {
6
+ ok: false;
7
+ error: string;
8
+ };
9
+ export type ValidationResult = ValidationOk | ValidationError;
10
+ export declare function validateJournalItem(input: string): ValidationResult;
11
+ export {};
@@ -0,0 +1,29 @@
1
+ import { isJournalLikeSummary, isReservedJournalMarker } from "./journal-markdown.mjs";
2
+ export function validateJournalItem(input) {
3
+ const lines = input.split("\n").map((line) => line.trimEnd());
4
+ const nonBlank = lines.filter((line) => line.trim() !== "");
5
+ if (nonBlank.length === 0) {
6
+ return { ok: false, error: 'journal item must not be empty; expected a "- <text>" list item' };
7
+ }
8
+ if (!/^- \S/.test(nonBlank[0])) {
9
+ return {
10
+ ok: false,
11
+ error: `journal item must start with "- <text>"; got: ${JSON.stringify(nonBlank[0].slice(0, 40))}`,
12
+ };
13
+ }
14
+ for (const line of nonBlank.slice(1)) {
15
+ if (line.startsWith("#")) {
16
+ return {
17
+ ok: false,
18
+ error: "journal item lines must not start with # (would break section structure)",
19
+ };
20
+ }
21
+ if (isReservedJournalMarker(line.trim()) || isJournalLikeSummary(line.trim())) {
22
+ return {
23
+ ok: false,
24
+ error: `journal item must not contain standalone journal container marker ${JSON.stringify(line.trim())}`,
25
+ };
26
+ }
27
+ }
28
+ return { ok: true, item: lines.join("\n").trim() };
29
+ }
@@ -0,0 +1,13 @@
1
+ type Fence = {
2
+ marker: "`" | "~";
3
+ length: number;
4
+ };
5
+ export type MarkdownScanState = {
6
+ comment: boolean;
7
+ fence: Fence | null;
8
+ };
9
+ export declare function findDetailsClose(lines: string[], startIdx: number): number;
10
+ export declare function isJournalLikeSummary(line: string): boolean;
11
+ export declare function isReservedJournalMarker(line: string): boolean;
12
+ export declare function skipMarkdownLine(state: MarkdownScanState, line: string): boolean;
13
+ export {};
@@ -0,0 +1,66 @@
1
+ import { SHEPHERD_JOURNAL_DETAILS_CLOSE, SHEPHERD_JOURNAL_DETAILS_SUMMARY, } from "../shepherd-journal.mjs";
2
+ export function findDetailsClose(lines, startIdx) {
3
+ let depth = 1;
4
+ const state = { fence: null, comment: false };
5
+ for (let i = startIdx; i < lines.length; i++) {
6
+ if (skipMarkdownLine(state, lines[i]))
7
+ continue;
8
+ if (isJournalLikeSummary(lines[i].trimStart())) {
9
+ throw new Error("duplicate Shepherd Journal details summary inside canonical container");
10
+ }
11
+ if (isDetailsOpening(lines[i]))
12
+ depth++;
13
+ if (lines[i].trim() === SHEPHERD_JOURNAL_DETAILS_CLOSE) {
14
+ if (lines[i] !== SHEPHERD_JOURNAL_DETAILS_CLOSE) {
15
+ throw new Error("malformed Shepherd Journal details container: closing marker must be unindented");
16
+ }
17
+ if (--depth === 0)
18
+ return i;
19
+ }
20
+ }
21
+ throw new Error("unterminated or unsafe nested Shepherd Journal details container");
22
+ }
23
+ export function isJournalLikeSummary(line) {
24
+ return /^<summary>\s*Shepherd\s+Journal\b/i.test(line);
25
+ }
26
+ export function isReservedJournalMarker(line) {
27
+ return (isDetailsOpening(line) ||
28
+ line === SHEPHERD_JOURNAL_DETAILS_SUMMARY ||
29
+ line === SHEPHERD_JOURNAL_DETAILS_CLOSE);
30
+ }
31
+ function isDetailsOpening(line) {
32
+ return /^<details(?:\s+[^>]*)?>$/.test(line);
33
+ }
34
+ function advanceHtmlComment(active, line) {
35
+ return active ? !line.includes("-->") : line.includes("<!--") && !line.includes("-->");
36
+ }
37
+ export function skipMarkdownLine(state, line) {
38
+ if (state.comment) {
39
+ state.comment = advanceHtmlComment(true, line);
40
+ return true;
41
+ }
42
+ if (state.fence) {
43
+ state.fence = advanceFence(state.fence, line);
44
+ return true;
45
+ }
46
+ if (line.includes("<!--")) {
47
+ state.comment = advanceHtmlComment(false, line);
48
+ return true;
49
+ }
50
+ state.fence = advanceFence(null, line);
51
+ return state.fence !== null;
52
+ }
53
+ function advanceFence(activeFence, line) {
54
+ const match = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
55
+ if (!activeFence) {
56
+ if (match?.[1][0] === "`" && match[2].includes("`"))
57
+ return null;
58
+ return match ? { marker: match[1][0], length: match[1].length } : null;
59
+ }
60
+ return match &&
61
+ match[1][0] === activeFence.marker &&
62
+ match[1].length >= activeFence.length &&
63
+ /^[ \t]*$/.test(match[2])
64
+ ? null
65
+ : activeFence;
66
+ }
@@ -1,22 +1,7 @@
1
+ export { validateJournalItem } from "./journal-item.mts";
1
2
  export interface AppendResult {
2
3
  body: string;
3
4
  mutated: boolean;
4
5
  sectionExisted: boolean;
5
6
  }
6
- type ValidationOk = {
7
- ok: true;
8
- item: string;
9
- };
10
- type ValidationError = {
11
- ok: false;
12
- error: string;
13
- };
14
- export type ValidationResult = ValidationOk | ValidationError;
15
- /** Validates that the input is a properly formed markdown list item. */
16
- export declare function validateJournalItem(input: string): ValidationResult;
17
- /**
18
- * Appends a validated list item to the ## Shepherd Journal section of a PR body.
19
- * Creates the section at the end if absent. Skips if the exact item is already present (idempotent).
20
- */
21
7
  export declare function appendJournalItem(body: string, item: string): AppendResult;
22
- export {};
@@ -1,112 +1,135 @@
1
- import { SHEPHERD_JOURNAL_SECTION, SHEPHERD_JOURNAL_SECTION_PATTERN, } from "../shepherd-journal.mjs";
2
- /** Validates that the input is a properly formed markdown list item. */
3
- export function validateJournalItem(input) {
4
- const lines = input.split("\n").map((l) => l.trimEnd());
5
- const nonBlank = lines.filter((l) => l.trim() !== "");
6
- if (nonBlank.length === 0) {
7
- return { ok: false, error: 'journal item must not be empty; expected a "- <text>" list item' };
8
- }
9
- if (!/^- \S/.test(nonBlank[0])) {
10
- return {
11
- ok: false,
12
- error: `journal item must start with "- <text>"; got: ${JSON.stringify(nonBlank[0].slice(0, 40))}`,
13
- };
14
- }
15
- for (const line of nonBlank.slice(1)) {
16
- if (line.startsWith("#")) {
17
- return {
18
- ok: false,
19
- error: "journal item lines must not start with # (would break section structure)",
20
- };
21
- }
22
- }
23
- const trimmed = lines
24
- .map((l) => l.trimEnd())
25
- .join("\n")
26
- .trim();
27
- return { ok: true, item: trimmed };
28
- }
29
- /**
30
- * Appends a validated list item to the ## Shepherd Journal section of a PR body.
31
- * Creates the section at the end if absent. Skips if the exact item is already present (idempotent).
32
- */
1
+ import { SHEPHERD_JOURNAL_DETAILS_OPEN, SHEPHERD_JOURNAL_DETAILS_CLOSE, SHEPHERD_JOURNAL_DETAILS_SUMMARY, SHEPHERD_JOURNAL_SECTION_PATTERN, } from "../shepherd-journal.mjs";
2
+ import { findDetailsClose, isJournalLikeSummary, skipMarkdownLine, } from "./journal-markdown.mjs";
3
+ export { validateJournalItem } from "./journal-item.mjs";
33
4
  export function appendJournalItem(body, item) {
34
- const lines = body.split("\n");
35
- const bounds = findSectionBounds(lines);
36
- if (!bounds) {
37
- return createSection(lines, item);
38
- }
39
- const { headingIdx, endIdx } = bounds;
40
- const sectionLines = lines.slice(headingIdx + 1, endIdx);
41
- if (itemAlreadyPresent(sectionLines, item)) {
42
- return { body, mutated: false, sectionExisted: true };
5
+ const lines = body.replaceAll("\r\n", "\n").split("\n");
6
+ const canonical = findCanonicalJournal(lines);
7
+ const legacy = findLegacyJournal(lines);
8
+ if (canonical && legacy)
9
+ throw new Error("ambiguous Shepherd Journal: both canonical details and legacy ## section exist");
10
+ if (canonical)
11
+ return appendToCanonical(lines, canonical, item, body);
12
+ if (legacy)
13
+ return migrateLegacyJournal(lines, legacy, item);
14
+ return createCanonicalJournal(lines, item);
15
+ }
16
+ function findCanonicalJournal(lines) {
17
+ const journals = [];
18
+ const state = { fence: null, comment: false };
19
+ for (let i = 0; i < lines.length; i++) {
20
+ if (skipMarkdownLine(state, lines[i]) || !isJournalLikeSummary(lines[i].trimStart()))
21
+ continue;
22
+ if (lines[i] !== SHEPHERD_JOURNAL_DETAILS_SUMMARY) {
23
+ throw new Error("malformed Shepherd Journal details container: expected exact summary line");
24
+ }
25
+ if (i === 0 || lines[i - 1] !== SHEPHERD_JOURNAL_DETAILS_OPEN) {
26
+ throw new Error("malformed Shepherd Journal details container: summary must immediately follow <details>");
27
+ }
28
+ if (lines[i + 1] !== "") {
29
+ throw new Error("malformed Shepherd Journal details container: expected blank line after summary");
30
+ }
31
+ const closeIdx = findDetailsClose(lines, i + 1);
32
+ journals.push({ summaryIdx: i, closeIdx });
33
+ i = closeIdx;
43
34
  }
44
- return appendToSection(lines, headingIdx, endIdx, sectionLines, item);
35
+ if (journals.length > 1)
36
+ throw new Error("duplicate Shepherd Journal details containers");
37
+ return journals[0] ?? null;
45
38
  }
46
- function findSectionBounds(lines) {
47
- let inFence = false;
39
+ function findLegacyJournal(lines) {
40
+ const state = { fence: null, comment: false };
48
41
  let headingIdx = -1;
42
+ let endIdx = null;
49
43
  for (let i = 0; i < lines.length; i++) {
50
- const trimmed = lines[i].trim();
51
- if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
52
- inFence = !inFence;
44
+ if (skipMarkdownLine(state, lines[i]))
53
45
  continue;
46
+ if (SHEPHERD_JOURNAL_SECTION_PATTERN.test(lines[i].trimEnd())) {
47
+ if (headingIdx !== -1)
48
+ throw new Error("duplicate legacy Shepherd Journal sections");
49
+ headingIdx = i;
50
+ }
51
+ else if (headingIdx !== -1 &&
52
+ endIdx === null &&
53
+ lines[i].trim() === SHEPHERD_JOURNAL_DETAILS_CLOSE) {
54
+ throw new Error("unsafe legacy Shepherd Journal section: standalone </details> line");
54
55
  }
55
- if (!inFence) {
56
- if (headingIdx === -1) {
57
- if (SHEPHERD_JOURNAL_SECTION_PATTERN.test(lines[i].trimEnd())) {
58
- headingIdx = i;
59
- }
60
- }
61
- else if (/^#{1,2} /.test(lines[i])) {
62
- return { headingIdx, endIdx: i };
63
- }
56
+ else if (headingIdx !== -1 && endIdx === null && /^#{1,2} /.test(lines[i])) {
57
+ endIdx = i;
64
58
  }
65
59
  }
66
- if (headingIdx === -1)
67
- return null;
68
- return { headingIdx, endIdx: lines.length };
60
+ return headingIdx === -1 ? null : { headingIdx, endIdx: endIdx ?? lines.length };
69
61
  }
70
- function itemAlreadyPresent(sectionLines, item) {
71
- const itemLines = item.split("\n").map((l) => l.trimEnd());
72
- const normalizedSection = sectionLines.map((l) => l.trimEnd());
73
- for (let i = 0; i <= normalizedSection.length - itemLines.length; i++) {
74
- let match = true;
75
- for (let j = 0; j < itemLines.length; j++) {
76
- if (normalizedSection[i + j] !== itemLines[j]) {
77
- match = false;
78
- break;
79
- }
80
- }
81
- if (match)
62
+ function appendToCanonical(lines, bounds, item, originalBody) {
63
+ const journalLines = lines.slice(bounds.summaryIdx + 1, bounds.closeIdx);
64
+ if (itemAlreadyPresent(journalLines, item))
65
+ return { body: originalBody, mutated: false, sectionExisted: true };
66
+ const trimmed = trimTrailingBlankLines(journalLines);
67
+ const content = trimmed.length === 0 ? ["", ...item.split("\n")] : [...trimmed, ...item.split("\n")];
68
+ return {
69
+ body: [
70
+ ...lines.slice(0, bounds.summaryIdx + 1),
71
+ ...content,
72
+ ...lines.slice(bounds.closeIdx),
73
+ ].join("\n"),
74
+ mutated: true,
75
+ sectionExisted: true,
76
+ };
77
+ }
78
+ function migrateLegacyJournal(lines, bounds, item) {
79
+ const journalLines = trimBlankLines(lines.slice(bounds.headingIdx + 1, bounds.endIdx));
80
+ const content = itemAlreadyPresent(journalLines, item)
81
+ ? journalLines
82
+ : journalLines.length === 0
83
+ ? ["", ...item.split("\n")]
84
+ : [...journalLines, ...item.split("\n")];
85
+ const canonical = [
86
+ SHEPHERD_JOURNAL_DETAILS_OPEN,
87
+ SHEPHERD_JOURNAL_DETAILS_SUMMARY,
88
+ "",
89
+ ...content,
90
+ SHEPHERD_JOURNAL_DETAILS_CLOSE,
91
+ ];
92
+ const after = lines.slice(bounds.endIdx);
93
+ return {
94
+ body: [
95
+ ...lines.slice(0, bounds.headingIdx),
96
+ ...canonical,
97
+ ...(after.length > 0 ? ["", ...after] : []),
98
+ ].join("\n"),
99
+ mutated: true,
100
+ sectionExisted: true,
101
+ };
102
+ }
103
+ function createCanonicalJournal(lines, item) {
104
+ const existing = trimTrailingBlankLines(lines);
105
+ const canonical = [
106
+ SHEPHERD_JOURNAL_DETAILS_OPEN,
107
+ SHEPHERD_JOURNAL_DETAILS_SUMMARY,
108
+ "",
109
+ ...item.split("\n"),
110
+ SHEPHERD_JOURNAL_DETAILS_CLOSE,
111
+ ];
112
+ return {
113
+ body: [...existing, ...(existing.length > 0 ? [""] : []), ...canonical].join("\n"),
114
+ mutated: true,
115
+ sectionExisted: false,
116
+ };
117
+ }
118
+ function itemAlreadyPresent(journalLines, item) {
119
+ const itemLines = item.split("\n").map((line) => line.trimEnd());
120
+ for (let i = 0; i <= journalLines.length - itemLines.length; i++) {
121
+ if (itemLines.every((line, offset) => journalLines[i + offset].trimEnd() === line))
82
122
  return true;
83
123
  }
84
124
  return false;
85
125
  }
86
- function appendToSection(lines, headingIdx, endIdx, sectionLines, item) {
87
- const before = lines.slice(0, headingIdx + 1);
88
- const after = lines.slice(endIdx);
89
- // Strip trailing blank lines from section body.
90
- let sectionEnd = sectionLines.length;
91
- while (sectionEnd > 0 && sectionLines[sectionEnd - 1].trim() === "") {
92
- sectionEnd--;
93
- }
94
- const trimmedSection = sectionLines.slice(0, sectionEnd);
95
- // Insert blank line after heading when section was empty, then the item.
96
- const newSection = trimmedSection.length === 0
97
- ? ["", ...item.split("\n")]
98
- : [...trimmedSection, ...item.split("\n")];
99
- // One blank line before the next section (or trailing newline at EOF).
100
- const newBody = [...before, ...newSection, ...(after.length > 0 ? ["", ...after] : [])].join("\n");
101
- return { body: newBody, mutated: true, sectionExisted: true };
102
- }
103
- function createSection(lines, item) {
104
- // Strip trailing blank lines from the existing body.
126
+ function trimTrailingBlankLines(lines) {
105
127
  let end = lines.length;
106
- while (end > 0 && lines[end - 1].trim() === "") {
128
+ while (end > 0 && lines[end - 1].trim() === "")
107
129
  end--;
108
- }
109
- const trimmedLines = lines.slice(0, end);
110
- const newBody = [...trimmedLines, "", SHEPHERD_JOURNAL_SECTION, "", ...item.split("\n")].join("\n");
111
- return { body: newBody, mutated: true, sectionExisted: false };
130
+ return lines.slice(0, end);
131
+ }
132
+ function trimBlankLines(lines) {
133
+ const first = lines.findIndex((line) => line.trim() !== "");
134
+ return first === -1 ? [] : trimTrailingBlankLines(lines.slice(first));
112
135
  }
@@ -1,6 +1,9 @@
1
- export declare const SHEPHERD_JOURNAL_SECTION = "## Shepherd Journal";
1
+ export declare const SHEPHERD_JOURNAL_SECTION = "Shepherd Journal";
2
2
  export declare const SHEPHERD_JOURNAL_SECTION_PATTERN: RegExp;
3
- export declare const SHEPHERD_JOURNAL_APPEND_HINT = "If this section already exists, append your entries under it instead of creating a duplicate heading.";
3
+ export declare const SHEPHERD_JOURNAL_DETAILS_OPEN = "<details>";
4
+ export declare const SHEPHERD_JOURNAL_DETAILS_SUMMARY = "<summary>Shepherd Journal</summary>";
5
+ export declare const SHEPHERD_JOURNAL_DETAILS_CLOSE = "</details>";
6
+ export declare const SHEPHERD_JOURNAL_APPEND_HINT = "If Shepherd Journal details already exist, append entries inside them instead of creating another container.";
4
7
  export declare const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review each body under `## Review summaries (first look)`. Eligible non-human IDs are already in `--minimize-comment-ids`. Record any warranted Shepherd Journal note before review mutations.";
5
8
  export declare function buildShepherdJournalInstruction(prNumber: number, itemReferenceGuidance: string): string[];
6
9
  export declare const SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS = "Link threads and comments from their headings. Cite reviews by ID.";
@@ -1,10 +1,13 @@
1
- export const SHEPHERD_JOURNAL_SECTION = "## Shepherd Journal";
1
+ export const SHEPHERD_JOURNAL_SECTION = "Shepherd Journal";
2
2
  export const SHEPHERD_JOURNAL_SECTION_PATTERN = /^##\s+Shepherd\s+Journal$/;
3
- export const SHEPHERD_JOURNAL_APPEND_HINT = "If this section already exists, append your entries under it instead of creating a duplicate heading.";
3
+ export const SHEPHERD_JOURNAL_DETAILS_OPEN = "<details>";
4
+ export const SHEPHERD_JOURNAL_DETAILS_SUMMARY = "<summary>Shepherd Journal</summary>";
5
+ export const SHEPHERD_JOURNAL_DETAILS_CLOSE = "</details>";
6
+ export const SHEPHERD_JOURNAL_APPEND_HINT = "If Shepherd Journal details already exist, append entries inside them instead of creating another container.";
4
7
  export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review each body under `## Review summaries (first look)`. Eligible non-human IDs are already in `--minimize-comment-ids`. Record any warranted Shepherd Journal note before review mutations.";
5
8
  export function buildShepherdJournalInstruction(prNumber, itemReferenceGuidance) {
6
9
  return [
7
- `For any substantial decision or rejection, append \`- <decision>\` to \`${SHEPHERD_JOURNAL_SECTION}\` with \`pr-shepherd apply journal ${prNumber} '- <decision>'\`.`,
10
+ `For any substantial decision or rejection, append \`- <decision>\` to Shepherd Journal with \`pr-shepherd apply journal ${prNumber} '- <decision>'\`.`,
8
11
  itemReferenceGuidance,
9
12
  ];
10
13
  }
@@ -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 body = endsWithNewline ? originalContent.slice(0, -1) : originalContent;
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,2 @@
1
+ export declare function sharesExactProperPrefix(replacementLines: readonly string[], adjacentLines: readonly string[]): boolean;
2
+ export declare function sharesExactProperSuffix(replacementLines: readonly string[], adjacentLines: readonly string[]): boolean;
@@ -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,2 @@
1
+ export type ChargeScanWork = (replacementLineCount: number, availableLineCount: number) => boolean;
2
+ export declare function createScanWorkBudget(): ChargeScanWork;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.37.1",
3
+ "version": "0.38.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "keywords": [
6
6
  "automation",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.37.1",
3
+ "version": "0.38.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "pr-shepherd": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "pr-shepherd@0.37.1", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@0.38.0", "pr-shepherd-mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pr-shepherd": {
3
3
  "command": "npx",
4
- "args": ["--yes", "--package", "pr-shepherd@0.37.1", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@0.38.0", "pr-shepherd-mcp"]
5
5
  }
6
6
  }