dsh-hashline-edit 0.1.0 → 0.2.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 (131) hide show
  1. package/README.md +72 -70
  2. package/cordis.patch.yml +1 -1
  3. package/lib/index.js +4494 -4813
  4. package/lib/types/dsh/contracts.d.ts +69 -6
  5. package/lib/types/dsh/fs.d.ts +38 -3
  6. package/lib/types/dsh/guidance.d.ts +5 -5
  7. package/lib/types/dsh/hide.d.ts +78 -0
  8. package/lib/types/dsh/host-compat.d.ts +7 -78
  9. package/lib/types/dsh/index.d.ts +17 -7
  10. package/lib/types/dsh/metrics/events.d.ts +8 -1
  11. package/lib/types/dsh/metrics/instrument.d.ts +20 -14
  12. package/lib/types/dsh/metrics/observer.d.ts +28 -0
  13. package/lib/types/dsh/sandbox.d.ts +2 -2
  14. package/lib/types/dsh/shell-hook.d.ts +17 -8
  15. package/lib/types/dsh/shell-view.d.ts +65 -3
  16. package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
  17. package/lib/types/dsh/tools/context.d.ts +19 -1
  18. package/lib/types/dsh/tools/output.d.ts +32 -4
  19. package/lib/types/dsh/tools/read.d.ts +12 -15
  20. package/lib/types/dsh/tools/undo.d.ts +5 -0
  21. package/lib/types/dsh/write-hook.d.ts +8 -22
  22. package/lib/types/src/apply-patch/commit.d.ts +49 -0
  23. package/lib/types/src/apply-patch/grammar.d.ts +29 -0
  24. package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
  25. package/lib/types/src/apply-patch/plan.d.ts +50 -0
  26. package/lib/types/src/apply-patch/render.d.ts +51 -0
  27. package/lib/types/src/apply-patch/resolve.d.ts +129 -0
  28. package/lib/types/src/apply-patch/tool.d.ts +54 -0
  29. package/lib/types/src/apply-patch/types.d.ts +362 -0
  30. package/lib/types/src/bash-view-banner.d.ts +20 -0
  31. package/lib/types/src/domain-errors.d.ts +16 -2
  32. package/lib/types/src/edit-undo.d.ts +11 -2
  33. package/lib/types/src/file-content/detection.d.ts +17 -0
  34. package/lib/types/src/file-content/index.d.ts +2 -2
  35. package/lib/types/src/file-content/preview.d.ts +1 -1
  36. package/lib/types/src/fs-lifecycle.d.ts +44 -0
  37. package/lib/types/src/hashline/resolve.d.ts +6 -0
  38. package/lib/types/src/host-utils.d.ts +63 -0
  39. package/lib/types/src/mutation-engine/batch-span-gate.d.ts +6 -0
  40. package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
  41. package/lib/types/src/mutation-engine/pipeline.d.ts +18 -3
  42. package/lib/types/src/mutation-engine/types.d.ts +50 -58
  43. package/lib/types/src/read-expansion/grammar-manifest.d.ts +4 -2
  44. package/lib/types/src/read-expansion/java-decoration.d.ts +21 -0
  45. package/lib/types/src/read-expansion/span-expansion.d.ts +6 -2
  46. package/lib/types/src/read-expansion/span-kind.d.ts +3 -2
  47. package/lib/types/src/read-expansion/vue-decoration.d.ts +14 -0
  48. package/lib/types/src/served-session/serve-render.d.ts +9 -3
  49. package/lib/types/src/undo-store.d.ts +17 -1
  50. package/package.json +10 -22
  51. package/prompts/apply-patch-guidelines.md +10 -0
  52. package/prompts/apply-patch.md +1 -0
  53. package/prompts/read-guidelines.md +5 -5
  54. package/prompts/read.md +1 -1
  55. package/prompts/undo-last-edit-guidelines.md +2 -2
  56. package/prompts/undo-last-edit-snippet.md +1 -1
  57. package/prompts/undo-last-edit.md +1 -1
  58. package/src/apply-patch/commit.ts +190 -0
  59. package/src/apply-patch/grammar.ts +620 -0
  60. package/src/apply-patch/index.ts +17 -0
  61. package/src/apply-patch/lifecycle.ts +195 -0
  62. package/src/apply-patch/plan.ts +800 -0
  63. package/src/apply-patch/render.ts +137 -0
  64. package/src/apply-patch/resolve.ts +470 -0
  65. package/src/apply-patch/tool.ts +257 -0
  66. package/src/apply-patch/types.ts +387 -0
  67. package/src/cut-repair.ts +77 -11
  68. package/src/domain-errors.ts +58 -10
  69. package/src/edit-undo.ts +379 -25
  70. package/src/file-content/detection.ts +34 -0
  71. package/src/file-content/index.ts +2 -2
  72. package/src/file-content/preview.ts +1 -1
  73. package/src/file-content/served-header.ts +1 -1
  74. package/src/fs-lifecycle.ts +201 -0
  75. package/src/hash-store.ts +18 -1
  76. package/src/hashline/lease-resolve.ts +14 -8
  77. package/src/hashline/resolve.ts +30 -27
  78. package/src/host-utils.ts +224 -0
  79. package/src/mutation-engine/batch-span-gate.ts +17 -4
  80. package/src/mutation-engine/edit-request.ts +39 -0
  81. package/src/mutation-engine/pipeline.ts +535 -63
  82. package/src/mutation-engine/types.ts +49 -65
  83. package/src/mutation-queue.ts +1 -1
  84. package/src/prompts.ts +0 -28
  85. package/src/read-expansion/ast-provider.ts +4 -0
  86. package/src/read-expansion/grammar-manifest.ts +21 -2
  87. package/src/read-expansion/java-decoration.ts +34 -0
  88. package/src/read-expansion/span-expansion.ts +6 -2
  89. package/src/read-expansion/span-kind.ts +3 -1
  90. package/src/read-expansion/vue-decoration.ts +43 -0
  91. package/src/served-session/serve-render.ts +5 -5
  92. package/src/undo-store.ts +74 -5
  93. package/lib/types/dsh/encoding-bridge.d.ts +0 -25
  94. package/lib/types/dsh/encoding.d.ts +0 -144
  95. package/lib/types/dsh/tools/edit.d.ts +0 -10
  96. package/lib/types/src/edit-tool.d.ts +0 -41
  97. package/lib/types/src/mutation-engine/engine.d.ts +0 -31
  98. package/lib/types/src/payload-contract.d.ts +0 -124
  99. package/prompts/edit-guidelines.md +0 -10
  100. package/prompts/edit-snippet.md +0 -1
  101. package/prompts/edit.md +0 -1
  102. package/src/bash-classifier.ts +0 -1626
  103. package/src/bash-conversion/decide.ts +0 -812
  104. package/src/bash-display-transform.ts +0 -50
  105. package/src/bash-multifile-config.ts +0 -239
  106. package/src/bash-view-config.ts +0 -158
  107. package/src/edit-normalize.ts +0 -8
  108. package/src/edit-render.ts +0 -161
  109. package/src/edit-tool.ts +0 -116
  110. package/src/edit.ts +0 -136
  111. package/src/integrations/pi-lens/command.ts +0 -712
  112. package/src/integrations/pi-lens/config.ts +0 -148
  113. package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
  114. package/src/integrations/pi-lens/io-bridge.ts +0 -112
  115. package/src/integrations/pi-lens/settings-overlay.ts +0 -272
  116. package/src/lifecycle-hooks/index.ts +0 -1992
  117. package/src/lifecycle-hooks/types.ts +0 -64
  118. package/src/mutated-files.ts +0 -75
  119. package/src/mutation-engine/engine.ts +0 -173
  120. package/src/mutation-engine/index.ts +0 -17
  121. package/src/payload-contract.ts +0 -637
  122. package/src/preview-controller.ts +0 -84
  123. package/src/read.ts +0 -865
  124. package/src/search-hook.ts +0 -41
  125. package/src/store-config.ts +0 -73
  126. package/src/store-janitor-warning.ts +0 -169
  127. package/src/store-janitor.ts +0 -539
  128. package/src/store-kernel/settings.ts +0 -233
  129. package/src/store.ts +0 -154
  130. package/src/tui-presenter.ts +0 -134
  131. package/src/write-hook.ts +0 -130
@@ -0,0 +1,137 @@
1
+ /**
2
+ * apply_patch v4 — renderer.
3
+ *
4
+ * One renderer owns both surfaces:
5
+ *
6
+ * - success: file-level summary lines first (`A`/`M`/`R`/`D`), then the
7
+ * changed-line anchor rows (`HASH│content`) per file, so a second call needs
8
+ * no `read`;
9
+ * - rejection: every problem as `- patch line N ([E_*], path): message`,
10
+ * followed by each problem file's current rows.
11
+ *
12
+ * @module dsh-hashline-edit/apply-patch/render
13
+ */
14
+ import type {
15
+ ApplyPatchFailure,
16
+ ApplyPatchResult,
17
+ ApplyPatchSuccess,
18
+ ApplyPatchToolResult,
19
+ AppliedFileSummary,
20
+ PatchMetrics,
21
+ PatchProblem,
22
+ ServedFileBlock,
23
+ } from "./types.js";
24
+
25
+ const ZERO_METRICS: PatchMetrics = {
26
+ files: 0,
27
+ hunks: 0,
28
+ addedLines: 0,
29
+ removedLines: 0,
30
+ };
31
+
32
+ /**
33
+ * Render one apply_patch outcome into the model-facing tool envelope.
34
+ * @param result - the committed success or the collected rejection.
35
+ * @returns `{ content, details }` ready for the dsh tool registry.
36
+ */
37
+ export function renderApplyPatchResult(result: ApplyPatchResult): ApplyPatchToolResult {
38
+ return result.ok ? renderSuccess(result) : renderFailure(result);
39
+ }
40
+
41
+ /**
42
+ * Render a successful outcome.
43
+ * @param success - committed file facts plus new anchor rows.
44
+ * @returns the model-facing envelope.
45
+ */
46
+ export function renderSuccess(success: ApplyPatchSuccess): ApplyPatchToolResult {
47
+ const summary = renderFileSummary(success.files);
48
+ const servedText = renderServedBlocks(success.served);
49
+ const text = servedText === "" ? summary : `${summary}\n\n${servedText}`;
50
+ return {
51
+ content: [{ type: "text", text }],
52
+ details: {
53
+ summary,
54
+ diff: success.diff,
55
+ files: success.files,
56
+ servedByPath: success.served,
57
+ warnings: success.warnings,
58
+ metrics: success.metrics,
59
+ },
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Render a rejected outcome: all problems first, then the served rows.
65
+ * @param failure - the collected problems and current rows.
66
+ * @returns the model-facing envelope (as an `isError` tool result at the host seam).
67
+ */
68
+ export function renderFailure(failure: ApplyPatchFailure): ApplyPatchToolResult {
69
+ const count = failure.problems.length;
70
+ const headline =
71
+ failure.changedNothing && failure.recovery === undefined
72
+ ? count === 0
73
+ ? failure.message
74
+ : `Error: nothing changed (${count} problem${count === 1 ? "" : "s"})`
75
+ : failure.message;
76
+ const problemLines = failure.problems.map((problem) => formatPatchProblem(problem));
77
+ const problemText = problemLines.join("\n");
78
+ const servedText = renderServedBlocks(failure.served);
79
+ const head = [headline, problemText].filter((section) => section !== "").join("\n");
80
+ const text = servedText === "" ? head : `${head}\n\n${servedText}`;
81
+ return {
82
+ content: [{ type: "text", text }],
83
+ details: {
84
+ summary:
85
+ failure.changedNothing && failure.recovery === undefined
86
+ ? count === 0
87
+ ? failure.message
88
+ : `Nothing changed (${count} problem${count === 1 ? "" : "s"})`
89
+ : failure.message,
90
+ diff: "",
91
+ files: [],
92
+ servedByPath: failure.served,
93
+ warnings: [],
94
+ metrics: ZERO_METRICS,
95
+ },
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Render the file-level summary block: `Updated N files:` then one status line
101
+ * per file (`R src/old.ts -> src/new.ts` for a rename).
102
+ * @param files - the committed file summaries.
103
+ * @returns the summary text.
104
+ */
105
+ export function renderFileSummary(files: readonly AppliedFileSummary[]): string {
106
+ const count = files.length;
107
+ const header = `Updated ${count} file${count === 1 ? "" : "s"}:`;
108
+ const lines = files.map((file) => {
109
+ if (file.status === "R") {
110
+ return `R ${file.fromPath ?? ""} -> ${file.path}`;
111
+ }
112
+ return `${file.status} ${file.path}`;
113
+ });
114
+ return [header, ...lines].join("\n");
115
+ }
116
+
117
+ /**
118
+ * Render the new-anchor blocks for every changed file.
119
+ * @param blocks - per-file served rows.
120
+ * @returns one `<path>\nHASH│content` section per block.
121
+ */
122
+ export function renderServedBlocks(blocks: readonly ServedFileBlock[]): string {
123
+ return blocks
124
+ .filter((block) => block.text !== "")
125
+ .map((block) => `${block.path}\n${block.text}`)
126
+ .join("\n\n");
127
+ }
128
+
129
+ /**
130
+ * Render one problem line: `- patch line N ([E_*], path): message`.
131
+ * @param problem - one collected problem.
132
+ * @returns the single-line rendering, without a trailing newline.
133
+ */
134
+ export function formatPatchProblem(problem: PatchProblem): string {
135
+ const location = problem.path === undefined ? "" : `, ${problem.path}`;
136
+ return `- patch line ${problem.patchLine} ([${problem.code}]${location}): ${problem.message}`;
137
+ }
@@ -0,0 +1,470 @@
1
+ /**
2
+ * apply_patch v4 — resolution layer.
3
+ *
4
+ * Two addressing families meet here:
5
+ *
6
+ * - **fragment mode** is content matching against the pre-call baseline. The
7
+ * `oldLines` sequence (' ' and '-' lines) must match exactly one place in the
8
+ * hunk's window; `@@ <context>` must itself match exactly one line and moves
9
+ * the window start after it. Zero or multiple hits reject with candidate rows.
10
+ * - **identity mode** is served-lease addressing. Both anchors must belong to
11
+ * the `(session, file, anchor)` served set; a missing, retired or foreign
12
+ * anchor rejects with the file's current rows served back.
13
+ *
14
+ * The layer is pure over `FileBaseline` + served anchors: no I/O, no store
15
+ * access. `plan.ts` supplies the baselines and the lease source.
16
+ *
17
+ * @module dsh-hashline-edit/apply-patch/resolve
18
+ */
19
+ import {
20
+ resolveLineIdentity,
21
+ uniqueServedPosition,
22
+ type LeaseSpanSource,
23
+ } from "../hashline/resolve.js";
24
+ import { splitLines } from "../utils.js";
25
+ import type {
26
+ Anchor,
27
+ FileBaseline,
28
+ FragmentHunk,
29
+ IdentityHunk,
30
+ LineRange,
31
+ PatchCandidate,
32
+ PatchPath,
33
+ PatchProblem,
34
+ PlannedMutation,
35
+ PlannedReferenceUse,
36
+ ReferenceHunk,
37
+ } from "./types.js";
38
+
39
+ /** A file's served anchors, parallel to its baseline lines; `null` = never served. */
40
+ export type ServedAnchors = readonly (string | null)[];
41
+
42
+ /** The inputs one hunk resolution reads. */
43
+ export interface ResolveContext {
44
+ readonly baseline: FileBaseline;
45
+ readonly served: ServedAnchors;
46
+ /**
47
+ * Read-only lease identity source for identity mode. When present it is
48
+ * authoritative (MVCC lease identity, including rebasing); the `served`
49
+ * mirror is only the fallback for tests/callers with no store lease view.
50
+ */
51
+ readonly identity?: LeaseSpanSource;
52
+ /**
53
+ * Session-wide anchor homes — other files that served this anchor. Used only
54
+ * to diagnose a foreign anchor; a cross-file anchor is refused either way.
55
+ */
56
+ readonly homesOf?: (anchor: Anchor) => readonly PatchPath[];
57
+ }
58
+
59
+ /** A successful fragment match in baseline coordinates. */
60
+ export interface FragmentMatch {
61
+ /** Inclusive range covering the hunk's oldLines (' ' + '-'); zero-width when oldLines is empty. */
62
+ readonly range: LineRange;
63
+ /** 1-based line where the search window began (after `@@ <context>`, else 1). */
64
+ readonly windowStart: number;
65
+ }
66
+
67
+ /** Fragment match failure: no occurrence or more than one, with candidate rows. */
68
+ export type FragmentMatchResult =
69
+ | { readonly ok: true; readonly match: FragmentMatch }
70
+ | {
71
+ readonly ok: false;
72
+ readonly reason: "no-match" | "ambiguous";
73
+ readonly candidates: readonly PatchCandidate[];
74
+ };
75
+
76
+ /** Identity resolution outcome. */
77
+ export type IdentityResolveResult =
78
+ | { readonly ok: true; readonly range: LineRange }
79
+ | { readonly ok: false; readonly problems: readonly PatchProblem[] };
80
+
81
+ /** Reference resolution outcome. */
82
+ export type ReferenceResolveResult =
83
+ | {
84
+ readonly ok: true;
85
+ readonly reference: PlannedReferenceUse;
86
+ /** The destination replacement this reference materializes. */
87
+ readonly mutation: PlannedMutation;
88
+ }
89
+ | { readonly ok: false; readonly problems: readonly PatchProblem[] };
90
+
91
+ /** The baseline's lines; an empty file is zero lines, never one empty line. */
92
+ export function baselineLinesOf(baseline: FileBaseline): string[] {
93
+ return baseline.text === "" ? [] : splitLines(baseline.text);
94
+ }
95
+
96
+ function oldLinesOf(hunk: FragmentHunk): string[] {
97
+ return hunk.lines.filter((line) => line.op !== "add").map((line) => line.text);
98
+ }
99
+
100
+ function candidateRows(
101
+ baseline: FileBaseline,
102
+ fileLines: readonly string[],
103
+ matchIndex: number,
104
+ oldLines: readonly string[],
105
+ ): PatchCandidate[] {
106
+ const rows: PatchCandidate[] = [];
107
+ for (let offset = 0; offset < oldLines.length; offset++) {
108
+ const line = matchIndex + offset + 1;
109
+ rows.push({
110
+ line,
111
+ hash: baseline.hashes[matchIndex + offset] ?? "",
112
+ text: fileLines[matchIndex + offset] ?? oldLines[offset] ?? "",
113
+ });
114
+ }
115
+ return rows;
116
+ }
117
+
118
+ function uniqueContextRows(
119
+ baseline: FileBaseline,
120
+ fileLines: readonly string[],
121
+ indexes: readonly number[],
122
+ text: string,
123
+ ): PatchCandidate[] {
124
+ return indexes.map((index) => ({
125
+ line: index + 1,
126
+ hash: baseline.hashes[index] ?? "",
127
+ text: fileLines[index] ?? text,
128
+ }));
129
+ }
130
+
131
+ /**
132
+ * Match one fragment hunk against its baseline.
133
+ *
134
+ * Byte-exact: no whitespace, Unicode or newline normalization. The window is
135
+ * `[windowStart, end]` and every subsequent hunk's window begins after the
136
+ * previous hunk's end (the planner enforces the monotonic cursor).
137
+ *
138
+ * @param hunk - the parsed fragment hunk.
139
+ * @param context - baseline plus served anchors (served is unused here; kept for one call shape).
140
+ * @param windowStart - 1-based line the search starts at; defaults to 1.
141
+ * @returns the unique match or the rejection with candidates.
142
+ */
143
+ export function matchFragmentHunk(
144
+ hunk: FragmentHunk,
145
+ context: ResolveContext,
146
+ windowStart?: number,
147
+ ): FragmentMatchResult {
148
+ const baseline = context.baseline;
149
+ const fileLines = baselineLinesOf(baseline);
150
+ const requestedStart = windowStart ?? 1;
151
+ let startLine = Math.max(1, Math.min(requestedStart, fileLines.length + 1));
152
+
153
+ if (hunk.address.kind === "context") {
154
+ const contextText = hunk.address.text;
155
+ const hits: number[] = [];
156
+ for (let index = startLine - 1; index < fileLines.length; index++) {
157
+ if (fileLines[index] === contextText) hits.push(index);
158
+ }
159
+ if (hits.length === 0) {
160
+ return { ok: false, reason: "no-match", candidates: [] };
161
+ }
162
+ if (hits.length > 1) {
163
+ return {
164
+ ok: false,
165
+ reason: "ambiguous",
166
+ candidates: uniqueContextRows(baseline, fileLines, hits, contextText),
167
+ };
168
+ }
169
+ startLine = hits[0]! + 2;
170
+ }
171
+
172
+ const oldLines = oldLinesOf(hunk);
173
+ if (oldLines.length === 0) {
174
+ // WHY: `*** End of File` with no removable lines means append at the real end of the file;
175
+ // WHY: the plain window start would insert at the beginning and silently change the patch.
176
+ const point = hunk.endOfFile !== undefined ? fileLines.length : startLine - 1;
177
+ return {
178
+ ok: true,
179
+ match: {
180
+ range: { startLine: point + 1, endLine: point },
181
+ windowStart: startLine,
182
+ },
183
+ };
184
+ }
185
+
186
+ const matches: number[] = [];
187
+ const lastStart = fileLines.length - oldLines.length;
188
+ for (let index = startLine - 1; index <= lastStart; index++) {
189
+ let matched = true;
190
+ for (let offset = 0; offset < oldLines.length; offset++) {
191
+ if (fileLines[index + offset] !== oldLines[offset]) {
192
+ matched = false;
193
+ break;
194
+ }
195
+ }
196
+ if (matched) matches.push(index);
197
+ }
198
+
199
+ const eofPinned = hunk.endOfFile !== undefined;
200
+ const eligible = eofPinned
201
+ ? matches.filter((index) => index + oldLines.length === fileLines.length)
202
+ : matches;
203
+
204
+ if (eligible.length === 0) {
205
+ const candidates =
206
+ eofPinned && matches.length > 0
207
+ ? matches.flatMap((index) => candidateRows(baseline, fileLines, index, oldLines))
208
+ : [];
209
+ return { ok: false, reason: "no-match", candidates };
210
+ }
211
+ if (eligible.length > 1) {
212
+ return {
213
+ ok: false,
214
+ reason: "ambiguous",
215
+ candidates: eligible.flatMap((index) => candidateRows(baseline, fileLines, index, oldLines)),
216
+ };
217
+ }
218
+
219
+ const matchIndex = eligible[0]!;
220
+ return {
221
+ ok: true,
222
+ match: {
223
+ range: { startLine: matchIndex + 1, endLine: matchIndex + oldLines.length },
224
+ windowStart: startLine,
225
+ },
226
+ };
227
+ }
228
+
229
+ type AnchorLocation =
230
+ | { readonly kind: "line"; readonly line: number }
231
+ | { readonly kind: "missing"; readonly homes: readonly PatchPath[] }
232
+ | { readonly kind: "stale"; readonly servedLine: number };
233
+
234
+ function locateAnchor(anchor: Anchor, context: ResolveContext): AnchorLocation {
235
+ const identity = context.identity;
236
+ if (identity !== undefined) {
237
+ const lease = identity.leaseFor(anchor);
238
+ if (lease === undefined) {
239
+ return { kind: "missing", homes: identity.anchorHomes?.(anchor) ?? [] };
240
+ }
241
+ const decision = resolveLineIdentity(lease, identity);
242
+ if (decision.kind === "stale") {
243
+ return { kind: "stale", servedLine: lease.servedLineNumber };
244
+ }
245
+ return { kind: "line", line: decision.line };
246
+ }
247
+ const position = uniqueServedPosition(context.served, anchor);
248
+ if (position === undefined) {
249
+ return { kind: "missing", homes: context.homesOf?.(anchor) ?? [] };
250
+ }
251
+ return { kind: "line", line: position };
252
+ }
253
+
254
+ function anchorProblem(
255
+ anchor: Anchor,
256
+ location: AnchorLocation,
257
+ context: ResolveContext,
258
+ patchLine: number,
259
+ ): PatchProblem {
260
+ const path = context.baseline.path;
261
+ if (location.kind === "missing") {
262
+ const homes = [...new Set(location.homes)];
263
+ const message =
264
+ homes.length > 0
265
+ ? `anchor "${anchor}" was served for ${homes.join(", ")}, not for ${path}`
266
+ : `anchor "${anchor}" was not served for ${path} in this session`;
267
+ return { code: "E_STALE_ANCHOR", patchLine, path, message };
268
+ }
269
+ return {
270
+ code: "E_STALE_ANCHOR",
271
+ patchLine,
272
+ path,
273
+ message: `anchor "${anchor}" is stale: its line identity was retired or no longer resolves`,
274
+ ...(location.kind === "stale" ? { line: location.servedLine } : {}),
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Resolve one identity hunk (`@@<a>` / `@@<a>-<b>`) against the served set.
280
+ *
281
+ * The anchors must be present in `context.served` at exactly one position each
282
+ * (or hold a live lease when `context.identity` is present), `from` not after
283
+ * `to`. Every failure uses `E_STALE_ANCHOR` with the anchor named, so the
284
+ * planner can serve the file's current rows for a retry.
285
+ *
286
+ * @param hunk - the parsed identity hunk.
287
+ * @param context - baseline plus served anchors / lease source.
288
+ * @returns the resolved inclusive range, or all problems for this hunk.
289
+ */
290
+ export function resolveIdentityHunk(
291
+ hunk: IdentityHunk,
292
+ context: ResolveContext,
293
+ ): IdentityResolveResult {
294
+ const anchors = [...new Set([hunk.span.from, hunk.span.to])];
295
+ const problems: PatchProblem[] = [];
296
+ const locations = new Map<Anchor, AnchorLocation>();
297
+ for (const anchor of anchors) {
298
+ const location = locateAnchor(anchor, context);
299
+ locations.set(anchor, location);
300
+ if (location.kind !== "line") {
301
+ problems.push(anchorProblem(anchor, location, context, hunk.headerLine));
302
+ }
303
+ }
304
+ if (problems.length > 0) return { ok: false, problems };
305
+
306
+ const fromLine = (locations.get(hunk.span.from) as { line: number }).line;
307
+ const toLine = (locations.get(hunk.span.to) as { line: number }).line;
308
+ if (fromLine > toLine) {
309
+ return {
310
+ ok: false,
311
+ problems: [
312
+ {
313
+ code: "E_PATCH_SYNTAX",
314
+ patchLine: hunk.headerLine,
315
+ path: context.baseline.path,
316
+ message: `identity span is reversed: anchor "${hunk.span.from}" resolves after "${hunk.span.to}"`,
317
+ line: fromLine,
318
+ },
319
+ ],
320
+ };
321
+ }
322
+ return { ok: true, range: { startLine: fromLine, endLine: toLine } };
323
+ }
324
+
325
+ /**
326
+ * Resolve one `*** Copy From:` / `*** Cut From:` hunk.
327
+ *
328
+ * The target addressing must be identity mode (`@@<a>` / `@@<a>-<b>`): a
329
+ * fragment-mode reference hunk is refused with `E_PATCH_SYNTAX` at the
330
+ * reference line. The source span must be served for the declared source file;
331
+ * the destination is replaced by the source bytes. A `cut` also plans the
332
+ * source retirement, in the same transaction, as a deletion over the same
333
+ * anchors.
334
+ *
335
+ * @param hunk - the parsed reference hunk.
336
+ * @param destination - the target file's baseline + served anchors / lease source.
337
+ * @param source - the source file's baseline + served anchors / lease source.
338
+ * @returns the materialized reference and destination mutation, or the problems.
339
+ */
340
+ export function resolveReferenceHunk(
341
+ hunk: ReferenceHunk,
342
+ destination: ResolveContext,
343
+ source: ResolveContext | FileBaseline,
344
+ sourceServed?: ServedAnchors,
345
+ ): ReferenceResolveResult {
346
+ const sourceContext: ResolveContext =
347
+ "baseline" in source ? source : { baseline: source, served: sourceServed ?? [] };
348
+ if (hunk.address.kind !== "anchors") {
349
+ return {
350
+ ok: false,
351
+ problems: [
352
+ {
353
+ code: "E_PATCH_SYNTAX",
354
+ patchLine: hunk.headerLine,
355
+ path: destination.baseline.path,
356
+ message:
357
+ "Copy From / Cut From requires an identity target (@@<a> or @@<a>-<b>); fragment addressing is not allowed",
358
+ },
359
+ ],
360
+ };
361
+ }
362
+ const destinationResolved = resolveIdentityHunk(
363
+ {
364
+ kind: "identity",
365
+ span: hunk.address.span,
366
+ headerLine: hunk.headerLine,
367
+ addedLines: [],
368
+ },
369
+ destination,
370
+ );
371
+ if (!destinationResolved.ok) return { ok: false, problems: destinationResolved.problems };
372
+
373
+ const sourceResolved = resolveIdentityHunk(
374
+ {
375
+ kind: "identity",
376
+ span: hunk.source.span,
377
+ headerLine: hunk.source.line,
378
+ addedLines: [],
379
+ },
380
+ sourceContext,
381
+ );
382
+ if (!sourceResolved.ok) return { ok: false, problems: sourceResolved.problems };
383
+
384
+ const sourceLines = baselineLinesOf(sourceContext.baseline).slice(
385
+ sourceResolved.range.startLine - 1,
386
+ sourceResolved.range.endLine,
387
+ );
388
+ return {
389
+ ok: true,
390
+ reference: {
391
+ mode: hunk.source.mode,
392
+ path: sourceContext.baseline.path,
393
+ absolutePath: sourceContext.baseline.absolutePath,
394
+ range: sourceResolved.range,
395
+ lines: sourceLines,
396
+ patchLine: hunk.source.line,
397
+ },
398
+ mutation: {
399
+ kind: "replace",
400
+ range: destinationResolved.range,
401
+ lines: sourceLines,
402
+ patchLine: hunk.headerLine,
403
+ },
404
+ };
405
+ }
406
+
407
+ function isZeroWidth(range: LineRange): boolean {
408
+ return range.startLine > range.endLine;
409
+ }
410
+
411
+ function insertionPoint(range: LineRange): number {
412
+ return range.startLine - 1;
413
+ }
414
+
415
+ function rangesIntersect(left: LineRange, right: LineRange): boolean {
416
+ if (isZeroWidth(left) && isZeroWidth(right)) {
417
+ // WHY: multiple insertions at one point are ordered by submission, never an overlap.
418
+ return false;
419
+ }
420
+ if (isZeroWidth(left)) {
421
+ const point = insertionPoint(left);
422
+ return right.startLine <= point && point <= right.endLine;
423
+ }
424
+ if (isZeroWidth(right)) {
425
+ const point = insertionPoint(right);
426
+ return left.startLine <= point && point <= left.endLine;
427
+ }
428
+ return Math.max(left.startLine, right.startLine) <= Math.min(left.endLine, right.endLine);
429
+ }
430
+
431
+ function rangeLabel(range: LineRange): string {
432
+ return isZeroWidth(range)
433
+ ? `insertion after line ${insertionPoint(range)}`
434
+ : `lines ${range.startLine}-${range.endLine}`;
435
+ }
436
+
437
+ /**
438
+ * Check one file's resolved mutations for overlap.
439
+ *
440
+ * Two mutations whose target ranges intersect reject the whole call with
441
+ * `E_BATCH_ABORT`-shaped evidence (both patch lines named). Zero-width
442
+ * insertions at the same point are ordered by submission, not treated as
443
+ * overlap; every other intersection is a rejection.
444
+ *
445
+ * @param mutations - one file's mutations, in submission order.
446
+ * @param path - the target file for the problem's path field.
447
+ * @returns every overlap problem found (empty when disjoint).
448
+ */
449
+ export function checkMutationOverlaps(
450
+ mutations: readonly PlannedMutation[],
451
+ path?: PatchPath,
452
+ ): readonly PatchProblem[] {
453
+ const problems: PatchProblem[] = [];
454
+ for (let left = 0; left < mutations.length; left++) {
455
+ for (let right = left + 1; right < mutations.length; right++) {
456
+ const a = mutations[left]!;
457
+ const b = mutations[right]!;
458
+ if (!rangesIntersect(a.range, b.range)) continue;
459
+ problems.push({
460
+ code: "E_BATCH_ABORT",
461
+ patchLine: b.patchLine,
462
+ ...(path !== undefined ? { path } : {}),
463
+ message:
464
+ `overlapping spans: mutation at patch line ${a.patchLine} (${rangeLabel(a.range)}) ` +
465
+ `overlaps mutation at patch line ${b.patchLine} (${rangeLabel(b.range)}); nothing was written`,
466
+ });
467
+ }
468
+ }
469
+ return problems;
470
+ }