dsh-hashline-edit 0.2.2 → 0.3.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.
- package/README.md +57 -45
- package/cordis.patch.yml +9 -3
- package/lib/client.js +231 -0
- package/lib/index.js +3298 -3957
- package/lib/types/dsh/contracts.d.ts +12 -3
- package/lib/types/dsh/fs.d.ts +3 -0
- package/lib/types/dsh/guidance.d.ts +12 -6
- package/lib/types/dsh/hide.d.ts +79 -0
- package/lib/types/dsh/host-compat.d.ts +7 -78
- package/lib/types/dsh/index.d.ts +53 -10
- package/lib/types/dsh/metrics/events.d.ts +1 -1
- package/lib/types/dsh/metrics/observer.d.ts +1 -1
- package/lib/types/dsh/sandbox.d.ts +2 -2
- package/lib/types/dsh/shell-hook.d.ts +1 -1
- package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
- package/lib/types/dsh/tools/read.d.ts +3 -3
- package/lib/types/src/apply-patch/commit.d.ts +49 -0
- package/lib/types/src/apply-patch/grammar.d.ts +29 -0
- package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
- package/lib/types/src/apply-patch/plan.d.ts +50 -0
- package/lib/types/src/apply-patch/render.d.ts +51 -0
- package/lib/types/src/apply-patch/resolve.d.ts +129 -0
- package/lib/types/src/apply-patch/tool.d.ts +54 -0
- package/lib/types/src/apply-patch/types.d.ts +362 -0
- package/lib/types/src/domain-errors.d.ts +16 -2
- package/lib/types/src/edit-undo.d.ts +11 -2
- package/lib/types/src/file-content/index.d.ts +1 -1
- package/lib/types/src/file-content/preview.d.ts +1 -1
- package/lib/types/src/fs-lifecycle.d.ts +44 -0
- package/lib/types/src/hashline/alphabet.d.ts +4 -3
- package/lib/types/src/hashline/hash-identity.d.ts +12 -7
- package/lib/types/src/host-utils.d.ts +63 -0
- package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
- package/lib/types/src/mutation-engine/pipeline.d.ts +8 -1
- package/lib/types/src/mutation-engine/types.d.ts +40 -50
- package/lib/types/src/served-session/serve-render.d.ts +6 -0
- package/lib/types/src/undo-store.d.ts +17 -1
- package/package.json +37 -21
- package/prompts/apply-patch-guidelines.md +4 -0
- package/prompts/apply-patch.md +1 -0
- package/prompts/read-guidelines.md +6 -6
- package/prompts/read.md +1 -1
- package/prompts/undo-last-edit-guidelines.md +2 -2
- package/prompts/undo-last-edit-snippet.md +1 -1
- package/prompts/undo-last-edit.md +1 -1
- package/prompts/zh/apply-patch-guidelines.md +4 -0
- package/prompts/zh/apply-patch.md +1 -0
- package/prompts/zh/read-guidelines.md +6 -0
- package/prompts/zh/read-snippet.md +1 -0
- package/prompts/zh/read.md +1 -0
- package/prompts/zh/undo-last-edit-guidelines.md +2 -0
- package/prompts/zh/undo-last-edit-snippet.md +1 -0
- package/prompts/zh/undo-last-edit.md +1 -0
- package/src/apply-patch/commit.ts +190 -0
- package/src/apply-patch/grammar.ts +620 -0
- package/src/apply-patch/index.ts +17 -0
- package/src/apply-patch/lifecycle.ts +195 -0
- package/src/apply-patch/plan.ts +800 -0
- package/src/apply-patch/render.ts +137 -0
- package/src/apply-patch/resolve.ts +470 -0
- package/src/apply-patch/tool.ts +257 -0
- package/src/apply-patch/types.ts +387 -0
- package/src/cut-repair.ts +77 -11
- package/src/domain-errors.ts +35 -3
- package/src/edit-undo.ts +379 -25
- package/src/file-content/index.ts +1 -1
- package/src/file-content/preview.ts +1 -1
- package/src/file-content/served-header.ts +1 -1
- package/src/fs-lifecycle.ts +201 -0
- package/src/hash-store.ts +18 -1
- package/src/hashline/alphabet.ts +7 -7
- package/src/hashline/hash-identity.ts +12 -7
- package/src/host-utils.ts +224 -0
- package/src/mutation-engine/edit-request.ts +39 -0
- package/src/mutation-engine/pipeline.ts +198 -27
- package/src/mutation-engine/types.ts +39 -57
- package/src/mutation-queue.ts +1 -1
- package/src/prompts.ts +3 -30
- package/src/served-session/serve-render.ts +2 -2
- package/src/undo-store.ts +74 -5
- package/lib/types/dsh/tools/edit.d.ts +0 -10
- package/lib/types/src/edit-tool.d.ts +0 -41
- package/lib/types/src/mutation-engine/engine.d.ts +0 -31
- package/lib/types/src/payload-contract.d.ts +0 -135
- package/prompts/edit-guidelines.md +0 -10
- package/prompts/edit-snippet.md +0 -1
- package/prompts/edit.md +0 -1
- package/prompts/read-snippet.md +0 -1
- package/src/bash-classifier.ts +0 -1626
- package/src/bash-conversion/decide.ts +0 -812
- package/src/bash-display-transform.ts +0 -50
- package/src/bash-multifile-config.ts +0 -239
- package/src/bash-view-config.ts +0 -158
- package/src/edit-normalize.ts +0 -8
- package/src/edit-render.ts +0 -161
- package/src/edit-tool.ts +0 -118
- package/src/edit.ts +0 -136
- package/src/integrations/pi-lens/command.ts +0 -712
- package/src/integrations/pi-lens/config.ts +0 -148
- package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
- package/src/integrations/pi-lens/io-bridge.ts +0 -112
- package/src/integrations/pi-lens/settings-overlay.ts +0 -272
- package/src/lifecycle-hooks/index.ts +0 -1992
- package/src/lifecycle-hooks/types.ts +0 -64
- package/src/mutated-files.ts +0 -75
- package/src/mutation-engine/engine.ts +0 -239
- package/src/mutation-engine/index.ts +0 -17
- package/src/payload-contract.ts +0 -685
- package/src/preview-controller.ts +0 -84
- package/src/read.ts +0 -865
- package/src/search-hook.ts +0 -41
- package/src/store-config.ts +0 -73
- package/src/store-janitor-warning.ts +0 -169
- package/src/store-janitor.ts +0 -539
- package/src/store-kernel/settings.ts +0 -233
- package/src/store.ts +0 -154
- package/src/tui-presenter.ts +0 -134
- package/src/write-hook.ts +0 -130
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apply_patch v4 — planner.
|
|
3
|
+
*
|
|
4
|
+
* Turns a parsed `PatchDocument` into a `PatchPlan`: loads one baseline per
|
|
5
|
+
* touched file, resolves every hunk against that baseline, validates paths,
|
|
6
|
+
* lifecycle targets, served anchors and overlap, and materializes Copy/Cut
|
|
7
|
+
* sources. Planning is read-only; the commit seam consumes the plan.
|
|
8
|
+
*
|
|
9
|
+
* Data flow: grammar.parse → **plan** → commit → render.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-hashline-edit/apply-patch/plan
|
|
12
|
+
*/
|
|
13
|
+
import { stat } from "node:fs/promises";
|
|
14
|
+
import { dirname, isAbsolute, relative as relativePath, resolve as resolvePath } from "node:path";
|
|
15
|
+
import { DomainError, type DomainErrorCode } from "../domain-errors.js";
|
|
16
|
+
import { SERVED_ROWS_CAP } from "../constants.js";
|
|
17
|
+
import { decodeNormText } from "../file-content/index.js";
|
|
18
|
+
import { isPathWithinWorkspace } from "../fs-lifecycle.js";
|
|
19
|
+
import { defaultHashIdentity } from "../hashline/hash-identity.js";
|
|
20
|
+
import type { LeaseSpanSource } from "../hashline/resolve.js";
|
|
21
|
+
import { loadHashStore, type HashStore } from "../hash-store.js";
|
|
22
|
+
import { leaseSpanSource } from "../mutation-engine/edit-source.js";
|
|
23
|
+
import {
|
|
24
|
+
buildRangeServeBlock,
|
|
25
|
+
fmtServedRows,
|
|
26
|
+
paginationHint,
|
|
27
|
+
} from "../served-session/serve-render.js";
|
|
28
|
+
import { createSessionHandle } from "../served-session/session.js";
|
|
29
|
+
import { errCode } from "../utils.js";
|
|
30
|
+
import {
|
|
31
|
+
baselineLinesOf,
|
|
32
|
+
checkMutationOverlaps,
|
|
33
|
+
matchFragmentHunk,
|
|
34
|
+
resolveIdentityHunk,
|
|
35
|
+
resolveReferenceHunk,
|
|
36
|
+
type ResolveContext,
|
|
37
|
+
type ServedAnchors,
|
|
38
|
+
} from "./resolve.js";
|
|
39
|
+
import type {
|
|
40
|
+
ApplyPatchEngineOptions,
|
|
41
|
+
FileBaseline,
|
|
42
|
+
LifecyclePlan,
|
|
43
|
+
PatchDocument,
|
|
44
|
+
PatchPath,
|
|
45
|
+
PatchPlan,
|
|
46
|
+
PatchProblem,
|
|
47
|
+
PatchStats,
|
|
48
|
+
PlannedMutation,
|
|
49
|
+
PlannedReferenceUse,
|
|
50
|
+
PlanPatchResult,
|
|
51
|
+
} from "./types.js";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The baseline/served loader port.
|
|
55
|
+
*
|
|
56
|
+
* Production wires `readBaseline` to the file-content decode path,
|
|
57
|
+
* `readServed` to `createSessionHandle(...).load()` and `readLeaseSource` to
|
|
58
|
+
* the read-only lease identity source. Tests inject a pure in-memory
|
|
59
|
+
* implementation, which is what makes the planner testable without a store.
|
|
60
|
+
*/
|
|
61
|
+
export interface PlanFileLoader {
|
|
62
|
+
/** Load one file's baseline (LF-normalized text + anchors + BOM + ending). */
|
|
63
|
+
readBaseline(path: PatchPath, absolutePath: string, signal?: AbortSignal): Promise<FileBaseline>;
|
|
64
|
+
/** Load this session's served anchors for one file, parallel to its lines. */
|
|
65
|
+
readServed(absolutePath: string, sessionKey: string, store?: HashStore): Promise<ServedAnchors>;
|
|
66
|
+
/** Session-wide anchor homes, for foreign-anchor diagnosis (optional legacy port). */
|
|
67
|
+
anchorHomes?(anchor: string, sessionKey: string, store?: HashStore): readonly PatchPath[];
|
|
68
|
+
/** Build the read-only lease identity source for one file's baseline content. */
|
|
69
|
+
readLeaseSource?(
|
|
70
|
+
absolutePath: string,
|
|
71
|
+
sessionKey: string,
|
|
72
|
+
content: string,
|
|
73
|
+
store?: HashStore,
|
|
74
|
+
): Promise<LeaseSpanSource>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Options for {@link planApplyPatch}; `loader` is the test seam. */
|
|
78
|
+
export interface PlanApplyPatchOptions extends ApplyPatchEngineOptions {
|
|
79
|
+
readonly loader?: PlanFileLoader;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A baseline for a file that does not exist yet (Add File / missing target). */
|
|
83
|
+
export function absentBaseline(path: PatchPath, absolutePath: string): FileBaseline {
|
|
84
|
+
return {
|
|
85
|
+
path,
|
|
86
|
+
absolutePath,
|
|
87
|
+
text: "",
|
|
88
|
+
hashes: [],
|
|
89
|
+
bom: "",
|
|
90
|
+
ending: "\n",
|
|
91
|
+
exists: false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The production loader over node:fs, the file-content decoder and the hash store. */
|
|
96
|
+
export function createDefaultPlanFileLoader(): PlanFileLoader {
|
|
97
|
+
return {
|
|
98
|
+
async readBaseline(path, absolutePath, signal) {
|
|
99
|
+
let info;
|
|
100
|
+
try {
|
|
101
|
+
info = await stat(absolutePath);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (errCode(error) === "ENOENT") return absentBaseline(path, absolutePath);
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
if (!info.isFile()) {
|
|
107
|
+
throw new DomainError("E_UNSUPPORTED_FILE", {
|
|
108
|
+
path,
|
|
109
|
+
kind: info.isDirectory() ? "directory" : "binary",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const normalized = await decodeNormText(
|
|
113
|
+
absolutePath,
|
|
114
|
+
dirname(absolutePath),
|
|
115
|
+
signal === undefined ? undefined : { signal },
|
|
116
|
+
);
|
|
117
|
+
if (normalized.hadUtf8DecodeErrors) {
|
|
118
|
+
throw new DomainError("E_LOSSY_TEXT", { path });
|
|
119
|
+
}
|
|
120
|
+
const hashes = await defaultHashIdentity.hashesFor(normalized.normalized, {
|
|
121
|
+
path: normalized.absolutePath,
|
|
122
|
+
persist: false,
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
path,
|
|
126
|
+
absolutePath: normalized.absolutePath,
|
|
127
|
+
text: normalized.normalized,
|
|
128
|
+
hashes,
|
|
129
|
+
bom: normalized.bom,
|
|
130
|
+
ending: normalized.originalEnding,
|
|
131
|
+
exists: true,
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
async readServed(absolutePath, sessionKey, store) {
|
|
135
|
+
const hashStore = store ?? (await loadHashStore());
|
|
136
|
+
return createSessionHandle(sessionKey, absolutePath, hashStore).load();
|
|
137
|
+
},
|
|
138
|
+
async readLeaseSource(absolutePath, sessionKey, content, store) {
|
|
139
|
+
const hashStore = store ?? (await loadHashStore());
|
|
140
|
+
return leaseSpanSource({ store: hashStore, sessionKey, absolutePath, content });
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface ResolvedRequestPath {
|
|
146
|
+
readonly path: PatchPath;
|
|
147
|
+
readonly absolutePath: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveRequestPath(
|
|
151
|
+
path: PatchPath,
|
|
152
|
+
cwd: string,
|
|
153
|
+
):
|
|
154
|
+
| { readonly ok: true; readonly value: ResolvedRequestPath }
|
|
155
|
+
| { readonly ok: false; readonly message: string } {
|
|
156
|
+
if (path.length === 0) return { ok: false, message: "path must not be empty" };
|
|
157
|
+
if (path.includes("\0")) return { ok: false, message: "path contains a null byte" };
|
|
158
|
+
const segments = path.split(/[\\/]+/u).filter((segment) => segment.length > 0);
|
|
159
|
+
if (segments.includes("..")) {
|
|
160
|
+
return { ok: false, message: 'path must stay inside the workspace: ".." is not allowed' };
|
|
161
|
+
}
|
|
162
|
+
if (segments.some((segment) => segment.toLowerCase() === ".git")) {
|
|
163
|
+
return { ok: false, message: "path may not name a .git entry" };
|
|
164
|
+
}
|
|
165
|
+
const cwdAbsolute = resolvePath(cwd);
|
|
166
|
+
const absolutePath = isAbsolute(path) ? resolvePath(path) : resolvePath(cwdAbsolute, path);
|
|
167
|
+
const relative = relativePath(cwdAbsolute, absolutePath);
|
|
168
|
+
if (relative.startsWith("..") || isAbsolute(relative)) {
|
|
169
|
+
return { ok: false, message: "path must stay inside the workspace" };
|
|
170
|
+
}
|
|
171
|
+
return { ok: true, value: { path, absolutePath } };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Reject a target whose fully-resolved location escapes the workspace. The check runs before any
|
|
176
|
+
* baseline load or mkdir, so an Add/Move destination under a symlink/junction ancestor can never
|
|
177
|
+
* be created outside `cwd`.
|
|
178
|
+
*/
|
|
179
|
+
async function workspaceEscapeMessage(
|
|
180
|
+
cwd: string,
|
|
181
|
+
absolutePath: string,
|
|
182
|
+
): Promise<string | undefined> {
|
|
183
|
+
try {
|
|
184
|
+
if (await isPathWithinWorkspace(cwd, absolutePath)) return undefined;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return `path boundary could not be checked: ${messageOf(error)}`;
|
|
187
|
+
}
|
|
188
|
+
return "path escapes the workspace through a symlink or junction";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function messageOf(error: unknown): string {
|
|
192
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
193
|
+
return raw.replace(/^\[(?:MODEL|USER)\] \[[EW]_[A-Z0-9_]+\] /u, "");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function codeOf(error: unknown): DomainErrorCode | "E_PATCH_SYNTAX" {
|
|
197
|
+
return error instanceof DomainError ? error.code : "E_PATCH_SYNTAX";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Apply resolved baseline-coordinate mutations to a line array (descending, so coordinates stay valid). */
|
|
201
|
+
export function applyPlannedMutations(
|
|
202
|
+
lines: readonly string[],
|
|
203
|
+
mutations: readonly PlannedMutation[],
|
|
204
|
+
): string[] {
|
|
205
|
+
const result = [...lines];
|
|
206
|
+
const ordered = mutations
|
|
207
|
+
.map((mutation, index) => ({ mutation, index }))
|
|
208
|
+
.sort((left, right) => {
|
|
209
|
+
const a = left.mutation.range;
|
|
210
|
+
const b = right.mutation.range;
|
|
211
|
+
if (a.startLine !== b.startLine) return b.startLine - a.startLine;
|
|
212
|
+
const aInsert = a.startLine > a.endLine;
|
|
213
|
+
const bInsert = b.startLine > b.endLine;
|
|
214
|
+
// WHY: a replacement owns the line at this coordinate; it must land before zero-width
|
|
215
|
+
// WHY: insertions that name the position immediately before it.
|
|
216
|
+
if (aInsert !== bInsert) return aInsert ? 1 : -1;
|
|
217
|
+
// WHY: equal-point insertions are applied in reverse submission order so splice() leaves
|
|
218
|
+
// WHY: them in submission order in the final text.
|
|
219
|
+
if (aInsert && bInsert) return right.index - left.index;
|
|
220
|
+
const endDiff = b.endLine - a.endLine;
|
|
221
|
+
return endDiff !== 0 ? endDiff : right.index - left.index;
|
|
222
|
+
})
|
|
223
|
+
.map((entry) => entry.mutation);
|
|
224
|
+
for (const mutation of ordered) {
|
|
225
|
+
const insertAt = Math.max(0, mutation.range.startLine - 1);
|
|
226
|
+
const removeCount = Math.max(0, mutation.range.endLine - mutation.range.startLine + 1);
|
|
227
|
+
result.splice(insertAt, removeCount, ...mutation.lines);
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function mutationDelta(
|
|
233
|
+
baselineLines: readonly string[],
|
|
234
|
+
mutation: PlannedMutation,
|
|
235
|
+
): { added: number; removed: number } {
|
|
236
|
+
const start = Math.max(0, mutation.range.startLine - 1);
|
|
237
|
+
const end = Math.max(start, mutation.range.endLine);
|
|
238
|
+
const oldSlice = baselineLines.slice(start, end);
|
|
239
|
+
const newLines = mutation.lines;
|
|
240
|
+
let prefix = 0;
|
|
241
|
+
while (
|
|
242
|
+
prefix < oldSlice.length &&
|
|
243
|
+
prefix < newLines.length &&
|
|
244
|
+
oldSlice[prefix] === newLines[prefix]
|
|
245
|
+
) {
|
|
246
|
+
prefix += 1;
|
|
247
|
+
}
|
|
248
|
+
let suffix = 0;
|
|
249
|
+
while (
|
|
250
|
+
suffix < oldSlice.length - prefix &&
|
|
251
|
+
suffix < newLines.length - prefix &&
|
|
252
|
+
oldSlice[oldSlice.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
|
253
|
+
) {
|
|
254
|
+
suffix += 1;
|
|
255
|
+
}
|
|
256
|
+
return { added: newLines.length - prefix - suffix, removed: oldSlice.length - prefix - suffix };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function computeStats(entries: readonly PlannedFileEntry[]): PatchStats {
|
|
260
|
+
let filesChanged = 0;
|
|
261
|
+
let addedLines = 0;
|
|
262
|
+
let removedLines = 0;
|
|
263
|
+
for (const entry of entries) {
|
|
264
|
+
if (!entry.writes) continue;
|
|
265
|
+
filesChanged += 1;
|
|
266
|
+
if (entry.lifecycle.kind === "delete") {
|
|
267
|
+
removedLines += baselineLinesOf(entry.baseline).length;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const baselineLines = baselineLinesOf(entry.baseline);
|
|
271
|
+
for (const mutation of entry.mutations) {
|
|
272
|
+
const delta = mutationDelta(baselineLines, mutation);
|
|
273
|
+
addedLines += delta.added;
|
|
274
|
+
removedLines += delta.removed;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return { filesChanged, addedLines, removedLines };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
interface PlannedFileEntry {
|
|
281
|
+
readonly path: PatchPath;
|
|
282
|
+
readonly absolutePath: string;
|
|
283
|
+
readonly baseline: FileBaseline;
|
|
284
|
+
lifecycle: LifecyclePlan;
|
|
285
|
+
readonly mutations: PlannedMutation[];
|
|
286
|
+
readonly references: PlannedReferenceUse[];
|
|
287
|
+
writes: boolean;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function entryFor(
|
|
291
|
+
path: PatchPath,
|
|
292
|
+
absolutePath: string,
|
|
293
|
+
baseline: FileBaseline,
|
|
294
|
+
lifecycle: LifecyclePlan,
|
|
295
|
+
): PlannedFileEntry {
|
|
296
|
+
return {
|
|
297
|
+
path,
|
|
298
|
+
absolutePath,
|
|
299
|
+
baseline,
|
|
300
|
+
lifecycle,
|
|
301
|
+
mutations: [],
|
|
302
|
+
references: [],
|
|
303
|
+
writes: lifecycle.kind !== "keep",
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function markWrites(entry: PlannedFileEntry): void {
|
|
308
|
+
if (entry.mutations.length > 0) entry.writes = true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Plan one apply_patch call without writing anything.
|
|
313
|
+
*
|
|
314
|
+
* All hunks resolve against the pre-call baseline (never chained application);
|
|
315
|
+
* a later hunk's fragment window begins after the previous hunk's end. Any
|
|
316
|
+
* problem aborts the whole call: the returned list is every independently
|
|
317
|
+
* diagnosable problem, not just the first, together with the current rows of
|
|
318
|
+
* every named file (reject-and-serve).
|
|
319
|
+
*
|
|
320
|
+
* @param document - the parsed patch AST.
|
|
321
|
+
* @param options - cwd/session/store plus the optional baseline loader.
|
|
322
|
+
* @returns the plan, or every problem found plus the rows to serve.
|
|
323
|
+
*/
|
|
324
|
+
export async function planApplyPatch(
|
|
325
|
+
document: PatchDocument,
|
|
326
|
+
options: PlanApplyPatchOptions,
|
|
327
|
+
): Promise<PlanPatchResult> {
|
|
328
|
+
const loader = options.loader ?? createDefaultPlanFileLoader();
|
|
329
|
+
const problems: PatchProblem[] = [];
|
|
330
|
+
const entries: PlannedFileEntry[] = [];
|
|
331
|
+
const byAbsolute = new Map<string, PlannedFileEntry>();
|
|
332
|
+
const baselines = new Map<string, FileBaseline>();
|
|
333
|
+
const pathToAbsolute = new Map<string, string>();
|
|
334
|
+
const documentPaths = new Set<string>();
|
|
335
|
+
const servedCache = new Map<string, ServedAnchors>();
|
|
336
|
+
const identityCache = new Map<string, LeaseSpanSource>();
|
|
337
|
+
const referenceBaselines = new Map<string, FileBaseline>();
|
|
338
|
+
|
|
339
|
+
const loadBaseline = async (
|
|
340
|
+
path: PatchPath,
|
|
341
|
+
absolutePath: string,
|
|
342
|
+
patchLine: number,
|
|
343
|
+
): Promise<FileBaseline | undefined> => {
|
|
344
|
+
const cached = baselines.get(absolutePath);
|
|
345
|
+
if (cached !== undefined) return cached;
|
|
346
|
+
try {
|
|
347
|
+
const baseline = await loader.readBaseline(path, absolutePath, options.signal);
|
|
348
|
+
const requested = resolvePath(absolutePath);
|
|
349
|
+
const resolvedBaseline =
|
|
350
|
+
process.platform === "win32" ? baseline.absolutePath.toLowerCase() : baseline.absolutePath;
|
|
351
|
+
const requestedBaseline = process.platform === "win32" ? requested.toLowerCase() : requested;
|
|
352
|
+
if (resolvedBaseline !== requestedBaseline) {
|
|
353
|
+
problems.push({
|
|
354
|
+
code: "E_PATCH_SYNTAX",
|
|
355
|
+
patchLine,
|
|
356
|
+
path,
|
|
357
|
+
message: `symbolic link target is not allowed: ${path}`,
|
|
358
|
+
});
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
baselines.set(absolutePath, baseline);
|
|
362
|
+
pathToAbsolute.set(path, absolutePath);
|
|
363
|
+
return baseline;
|
|
364
|
+
} catch (error) {
|
|
365
|
+
problems.push({
|
|
366
|
+
code: codeOf(error),
|
|
367
|
+
patchLine,
|
|
368
|
+
path,
|
|
369
|
+
message: messageOf(error),
|
|
370
|
+
});
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const contextFor = async (
|
|
376
|
+
baseline: FileBaseline,
|
|
377
|
+
needServed: boolean,
|
|
378
|
+
): Promise<ResolveContext> => {
|
|
379
|
+
const absolutePath = baseline.absolutePath;
|
|
380
|
+
let served = needServed ? servedCache.get(absolutePath) : [];
|
|
381
|
+
if (served === undefined) {
|
|
382
|
+
served = await loader.readServed(absolutePath, options.sessionKey, options.store);
|
|
383
|
+
servedCache.set(absolutePath, served);
|
|
384
|
+
}
|
|
385
|
+
let identity = identityCache.get(absolutePath);
|
|
386
|
+
if (needServed && identity === undefined && loader.readLeaseSource !== undefined) {
|
|
387
|
+
identity = await loader.readLeaseSource(
|
|
388
|
+
absolutePath,
|
|
389
|
+
options.sessionKey,
|
|
390
|
+
baseline.text,
|
|
391
|
+
options.store,
|
|
392
|
+
);
|
|
393
|
+
identityCache.set(absolutePath, identity);
|
|
394
|
+
}
|
|
395
|
+
return { baseline, served, ...(identity !== undefined ? { identity } : {}) };
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const ensureSourceEntry = async (
|
|
399
|
+
path: PatchPath,
|
|
400
|
+
absolutePath: string,
|
|
401
|
+
patchLine: number,
|
|
402
|
+
): Promise<PlannedFileEntry | undefined> => {
|
|
403
|
+
const existing = byAbsolute.get(absolutePath);
|
|
404
|
+
if (existing !== undefined) return existing;
|
|
405
|
+
const baseline = await loadBaseline(path, absolutePath, patchLine);
|
|
406
|
+
if (baseline === undefined) return undefined;
|
|
407
|
+
if (!baseline.exists) {
|
|
408
|
+
problems.push({
|
|
409
|
+
code: "E_NOT_FOUND",
|
|
410
|
+
patchLine,
|
|
411
|
+
path,
|
|
412
|
+
message: `source file not found: ${path}`,
|
|
413
|
+
});
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
const entry = entryFor(path, absolutePath, baseline, { kind: "keep" });
|
|
417
|
+
entries.push(entry);
|
|
418
|
+
byAbsolute.set(absolutePath, entry);
|
|
419
|
+
documentPaths.add(absolutePath);
|
|
420
|
+
return entry;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
for (const op of document.files) {
|
|
424
|
+
const resolved = resolveRequestPath(op.path, options.cwd);
|
|
425
|
+
if (!resolved.ok) {
|
|
426
|
+
problems.push({
|
|
427
|
+
code: "E_PATCH_SYNTAX",
|
|
428
|
+
patchLine: op.headerLine,
|
|
429
|
+
path: op.path,
|
|
430
|
+
message: resolved.message,
|
|
431
|
+
});
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (documentPaths.has(resolved.value.absolutePath)) {
|
|
435
|
+
problems.push({
|
|
436
|
+
code: "E_PATCH_SYNTAX",
|
|
437
|
+
patchLine: op.headerLine,
|
|
438
|
+
path: op.path,
|
|
439
|
+
message: `file is named more than once in this patch: ${op.path}`,
|
|
440
|
+
});
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const escapedTarget = await workspaceEscapeMessage(options.cwd, resolved.value.absolutePath);
|
|
444
|
+
if (escapedTarget !== undefined) {
|
|
445
|
+
problems.push({
|
|
446
|
+
code: "E_PATCH_SYNTAX",
|
|
447
|
+
patchLine: op.headerLine,
|
|
448
|
+
path: op.path,
|
|
449
|
+
message: escapedTarget,
|
|
450
|
+
});
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
documentPaths.add(resolved.value.absolutePath);
|
|
454
|
+
|
|
455
|
+
if (op.kind === "add") {
|
|
456
|
+
const baseline = await loadBaseline(op.path, resolved.value.absolutePath, op.headerLine);
|
|
457
|
+
if (baseline === undefined) continue;
|
|
458
|
+
if (baseline.exists) {
|
|
459
|
+
problems.push({
|
|
460
|
+
code: "E_PATCH_SYNTAX",
|
|
461
|
+
patchLine: op.headerLine,
|
|
462
|
+
path: op.path,
|
|
463
|
+
message: `Add File target already exists: ${op.path}`,
|
|
464
|
+
});
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const entry = entryFor(op.path, resolved.value.absolutePath, baseline, { kind: "add" });
|
|
468
|
+
entry.mutations.push({
|
|
469
|
+
kind: "insert",
|
|
470
|
+
range: { startLine: 1, endLine: 0 },
|
|
471
|
+
lines: [...op.contentLines],
|
|
472
|
+
patchLine: op.headerLine,
|
|
473
|
+
});
|
|
474
|
+
markWrites(entry);
|
|
475
|
+
entries.push(entry);
|
|
476
|
+
byAbsolute.set(resolved.value.absolutePath, entry);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (op.kind === "delete") {
|
|
481
|
+
const baseline = await loadBaseline(op.path, resolved.value.absolutePath, op.headerLine);
|
|
482
|
+
if (baseline === undefined) continue;
|
|
483
|
+
if (!baseline.exists) {
|
|
484
|
+
problems.push({
|
|
485
|
+
code: "E_NOT_FOUND",
|
|
486
|
+
patchLine: op.headerLine,
|
|
487
|
+
path: op.path,
|
|
488
|
+
message: `Delete File target not found: ${op.path}`,
|
|
489
|
+
});
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
const entry = entryFor(op.path, resolved.value.absolutePath, baseline, { kind: "delete" });
|
|
493
|
+
entries.push(entry);
|
|
494
|
+
byAbsolute.set(resolved.value.absolutePath, entry);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const baseline = await loadBaseline(op.path, resolved.value.absolutePath, op.headerLine);
|
|
499
|
+
if (baseline === undefined) continue;
|
|
500
|
+
if (!baseline.exists) {
|
|
501
|
+
problems.push({
|
|
502
|
+
code: "E_NOT_FOUND",
|
|
503
|
+
patchLine: op.headerLine,
|
|
504
|
+
path: op.path,
|
|
505
|
+
message: `Update File target not found: ${op.path}`,
|
|
506
|
+
});
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
let lifecycle: LifecyclePlan = { kind: "keep" };
|
|
511
|
+
if (op.moveTo !== undefined) {
|
|
512
|
+
const destination = resolveRequestPath(op.moveTo.path, options.cwd);
|
|
513
|
+
const destEscape = destination.ok
|
|
514
|
+
? await workspaceEscapeMessage(options.cwd, destination.value.absolutePath)
|
|
515
|
+
: undefined;
|
|
516
|
+
if (!destination.ok) {
|
|
517
|
+
problems.push({
|
|
518
|
+
code: "E_PATCH_SYNTAX",
|
|
519
|
+
patchLine: op.moveTo.line,
|
|
520
|
+
path: op.moveTo.path,
|
|
521
|
+
message: destination.message,
|
|
522
|
+
});
|
|
523
|
+
} else if (destEscape !== undefined) {
|
|
524
|
+
problems.push({
|
|
525
|
+
code: "E_PATCH_SYNTAX",
|
|
526
|
+
patchLine: op.moveTo.line,
|
|
527
|
+
path: op.moveTo.path,
|
|
528
|
+
message: destEscape,
|
|
529
|
+
});
|
|
530
|
+
} else if (destination.value.absolutePath === resolved.value.absolutePath) {
|
|
531
|
+
problems.push({
|
|
532
|
+
code: "E_PATCH_SYNTAX",
|
|
533
|
+
patchLine: op.moveTo.line,
|
|
534
|
+
path: op.moveTo.path,
|
|
535
|
+
message: "Move to names the same file",
|
|
536
|
+
});
|
|
537
|
+
} else if (documentPaths.has(destination.value.absolutePath)) {
|
|
538
|
+
problems.push({
|
|
539
|
+
code: "E_PATCH_SYNTAX",
|
|
540
|
+
patchLine: op.moveTo.line,
|
|
541
|
+
path: op.moveTo.path,
|
|
542
|
+
message: `Move to target is another member of this patch: ${op.moveTo.path}`,
|
|
543
|
+
});
|
|
544
|
+
} else {
|
|
545
|
+
const destinationBaseline = await loadBaseline(
|
|
546
|
+
op.moveTo.path,
|
|
547
|
+
destination.value.absolutePath,
|
|
548
|
+
op.moveTo.line,
|
|
549
|
+
);
|
|
550
|
+
if (destinationBaseline !== undefined && destinationBaseline.exists) {
|
|
551
|
+
problems.push({
|
|
552
|
+
code: "E_PATCH_SYNTAX",
|
|
553
|
+
patchLine: op.moveTo.line,
|
|
554
|
+
path: op.moveTo.path,
|
|
555
|
+
message: `Move to target already exists: ${op.moveTo.path}`,
|
|
556
|
+
});
|
|
557
|
+
} else if (destinationBaseline !== undefined) {
|
|
558
|
+
lifecycle = {
|
|
559
|
+
kind: "move",
|
|
560
|
+
to: op.moveTo.path,
|
|
561
|
+
toAbsolutePath: destination.value.absolutePath,
|
|
562
|
+
};
|
|
563
|
+
documentPaths.add(destination.value.absolutePath);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const entry = entryFor(op.path, resolved.value.absolutePath, baseline, lifecycle);
|
|
569
|
+
entries.push(entry);
|
|
570
|
+
byAbsolute.set(resolved.value.absolutePath, entry);
|
|
571
|
+
// WHY: fragment mode needs no served state; identity and reference hunks do.
|
|
572
|
+
const needsServed = op.hunks.some((hunk) => hunk.kind !== "fragment");
|
|
573
|
+
const context = await contextFor(baseline, needsServed);
|
|
574
|
+
let cursor = 1;
|
|
575
|
+
|
|
576
|
+
for (const hunk of op.hunks) {
|
|
577
|
+
if (hunk.kind === "fragment") {
|
|
578
|
+
const match = matchFragmentHunk(hunk, context, cursor);
|
|
579
|
+
if (!match.ok) {
|
|
580
|
+
const candidateCount = new Set(match.candidates.map((row) => row.line)).size;
|
|
581
|
+
problems.push({
|
|
582
|
+
code: "E_UNIQUE_MATCH",
|
|
583
|
+
patchLine: hunk.headerLine,
|
|
584
|
+
path: op.path,
|
|
585
|
+
message:
|
|
586
|
+
match.reason === "ambiguous"
|
|
587
|
+
? `fragment hunk matches more than one place in ${op.path} (${candidateCount} candidate rows); nothing was written`
|
|
588
|
+
: `fragment hunk does not match ${op.path} byte-exactly; nothing was written`,
|
|
589
|
+
...(match.candidates.length > 0 ? { candidates: match.candidates } : {}),
|
|
590
|
+
});
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const oldLines = hunk.lines.filter((line) => line.op !== "add");
|
|
594
|
+
// WHY: replacement is the body's context and added lines in submission order; only removed
|
|
595
|
+
// WHY: lines are dropped. Dropping context lines silently deleted them from the result.
|
|
596
|
+
const replacementLines = hunk.lines
|
|
597
|
+
.filter((line) => line.op !== "remove")
|
|
598
|
+
.map((line) => line.text);
|
|
599
|
+
const oldLineTexts = oldLines.map((line) => line.text);
|
|
600
|
+
// WHY: a context-only hunk is a no-op: the bytes are unchanged, so only the cursor moves.
|
|
601
|
+
if (
|
|
602
|
+
replacementLines.length === oldLineTexts.length &&
|
|
603
|
+
replacementLines.every((line, index) => line === oldLineTexts[index])
|
|
604
|
+
) {
|
|
605
|
+
cursor = match.match.range.endLine + 1;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
entry.mutations.push({
|
|
609
|
+
kind: oldLines.length === 0 ? "insert" : "replace",
|
|
610
|
+
range: match.match.range,
|
|
611
|
+
lines: replacementLines,
|
|
612
|
+
patchLine: hunk.headerLine,
|
|
613
|
+
});
|
|
614
|
+
cursor = match.match.range.endLine + 1;
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (hunk.kind === "identity") {
|
|
619
|
+
const resolvedIdentity = resolveIdentityHunk(hunk, context);
|
|
620
|
+
if (!resolvedIdentity.ok) {
|
|
621
|
+
problems.push(...resolvedIdentity.problems);
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
entry.mutations.push({
|
|
625
|
+
kind: hunk.addedLines.length === 0 ? "delete" : "replace",
|
|
626
|
+
range: resolvedIdentity.range,
|
|
627
|
+
lines: [...hunk.addedLines],
|
|
628
|
+
patchLine: hunk.headerLine,
|
|
629
|
+
});
|
|
630
|
+
cursor = Math.max(cursor, resolvedIdentity.range.endLine + 1);
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (hunk.address.kind !== "anchors") {
|
|
635
|
+
problems.push({
|
|
636
|
+
code: "E_PATCH_SYNTAX",
|
|
637
|
+
patchLine: hunk.headerLine,
|
|
638
|
+
path: op.path,
|
|
639
|
+
message:
|
|
640
|
+
"Copy From / Cut From requires an identity target (@@<a> or @@<a>-<b>); fragment addressing is not allowed",
|
|
641
|
+
});
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
const sourcePath = resolveRequestPath(hunk.source.path, options.cwd);
|
|
645
|
+
if (!sourcePath.ok) {
|
|
646
|
+
problems.push({
|
|
647
|
+
code: "E_PATCH_SYNTAX",
|
|
648
|
+
patchLine: hunk.source.line,
|
|
649
|
+
path: hunk.source.path,
|
|
650
|
+
message: sourcePath.message,
|
|
651
|
+
});
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
const sourceEscape = await workspaceEscapeMessage(options.cwd, sourcePath.value.absolutePath);
|
|
655
|
+
if (sourceEscape !== undefined) {
|
|
656
|
+
problems.push({
|
|
657
|
+
code: "E_PATCH_SYNTAX",
|
|
658
|
+
patchLine: hunk.source.line,
|
|
659
|
+
path: hunk.source.path,
|
|
660
|
+
message: sourceEscape,
|
|
661
|
+
});
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
const sourceBaseline = await loadBaseline(
|
|
665
|
+
hunk.source.path,
|
|
666
|
+
sourcePath.value.absolutePath,
|
|
667
|
+
hunk.source.line,
|
|
668
|
+
);
|
|
669
|
+
if (sourceBaseline === undefined) continue;
|
|
670
|
+
if (!sourceBaseline.exists) {
|
|
671
|
+
problems.push({
|
|
672
|
+
code: "E_NOT_FOUND",
|
|
673
|
+
patchLine: hunk.source.line,
|
|
674
|
+
path: hunk.source.path,
|
|
675
|
+
message: `reference source file not found: ${hunk.source.path}`,
|
|
676
|
+
});
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
const sourceContext = await contextFor(sourceBaseline, true);
|
|
680
|
+
const reference = resolveReferenceHunk(hunk, context, sourceContext);
|
|
681
|
+
if (!reference.ok) {
|
|
682
|
+
problems.push(...reference.problems);
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
entry.mutations.push(reference.mutation);
|
|
686
|
+
entry.references.push(reference.reference);
|
|
687
|
+
cursor = Math.max(cursor, reference.mutation.range.endLine + 1);
|
|
688
|
+
|
|
689
|
+
if (hunk.source.mode === "cut") {
|
|
690
|
+
const sourceEntry = await ensureSourceEntry(
|
|
691
|
+
hunk.source.path,
|
|
692
|
+
sourcePath.value.absolutePath,
|
|
693
|
+
hunk.source.line,
|
|
694
|
+
);
|
|
695
|
+
if (sourceEntry !== undefined) {
|
|
696
|
+
sourceEntry.mutations.push({
|
|
697
|
+
kind: "delete",
|
|
698
|
+
range: reference.reference.range,
|
|
699
|
+
lines: [],
|
|
700
|
+
patchLine: hunk.source.line,
|
|
701
|
+
});
|
|
702
|
+
markWrites(sourceEntry);
|
|
703
|
+
}
|
|
704
|
+
} else if (!byAbsolute.has(sourcePath.value.absolutePath)) {
|
|
705
|
+
referenceBaselines.set(sourcePath.value.absolutePath, sourceBaseline);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const overlaps = checkMutationOverlaps(entry.mutations, entry.path);
|
|
710
|
+
if (overlaps.length > 0) problems.push(...overlaps);
|
|
711
|
+
markWrites(entry);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (problems.length > 0) {
|
|
715
|
+
return { ok: false, problems, served: buildServedBlocks(problems, baselines, pathToAbsolute) };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const planFiles = entries
|
|
719
|
+
.filter((entry) => entry.writes)
|
|
720
|
+
.map((entry): import("./types.js").PlannedFile => ({
|
|
721
|
+
path: entry.path,
|
|
722
|
+
absolutePath: entry.absolutePath,
|
|
723
|
+
lifecycle: entry.lifecycle,
|
|
724
|
+
baseline: entry.baseline,
|
|
725
|
+
mutations: [...entry.mutations],
|
|
726
|
+
references: [...entry.references],
|
|
727
|
+
writes: true,
|
|
728
|
+
}));
|
|
729
|
+
const plan: PatchPlan = {
|
|
730
|
+
files: planFiles,
|
|
731
|
+
referenceFiles: [...referenceBaselines.values()],
|
|
732
|
+
stats: computeStats(entries),
|
|
733
|
+
};
|
|
734
|
+
return { ok: true, plan };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Build the current-row blocks a rejection serves back, one per problem file. */
|
|
738
|
+
function buildServedBlocks(
|
|
739
|
+
problems: readonly PatchProblem[],
|
|
740
|
+
baselines: Map<string, FileBaseline>,
|
|
741
|
+
pathToAbsolute: Map<string, string>,
|
|
742
|
+
): readonly import("./types.js").ServedFileBlock[] {
|
|
743
|
+
const grouped = new Map<string, PatchProblem[]>();
|
|
744
|
+
for (const problem of problems) {
|
|
745
|
+
if (problem.path === undefined) continue;
|
|
746
|
+
const list = grouped.get(problem.path) ?? [];
|
|
747
|
+
list.push(problem);
|
|
748
|
+
grouped.set(problem.path, list);
|
|
749
|
+
}
|
|
750
|
+
const blocks: import("./types.js").ServedFileBlock[] = [];
|
|
751
|
+
for (const [path, list] of grouped) {
|
|
752
|
+
const absolutePath = pathToAbsolute.get(path);
|
|
753
|
+
const baseline = absolutePath === undefined ? undefined : baselines.get(absolutePath);
|
|
754
|
+
if (baseline === undefined || !baseline.exists) continue;
|
|
755
|
+
const lines = baselineLinesOf(baseline);
|
|
756
|
+
if (lines.length === 0) continue;
|
|
757
|
+
const candidateRows = list.flatMap((problem) => problem.candidates ?? []);
|
|
758
|
+
if (candidateRows.length > 0) {
|
|
759
|
+
// WHY: ambiguity candidates are evidence rows, not a contiguous range. Collapsing them to
|
|
760
|
+
// WHY: [first..last] let a distant second candidate fall outside the 150-row cap.
|
|
761
|
+
const byLine = new Map<number, import("../domain-errors.js").ServedRow>();
|
|
762
|
+
for (const candidate of candidateRows) {
|
|
763
|
+
if (candidate.line < 1) continue;
|
|
764
|
+
byLine.set(candidate.line, { position: candidate.line - 1, hash: candidate.hash });
|
|
765
|
+
}
|
|
766
|
+
let rows = [...byLine.values()].sort((left, right) => left.position - right.position);
|
|
767
|
+
if (rows.length > 0) {
|
|
768
|
+
let text = fmtServedRows(rows, lines);
|
|
769
|
+
if (rows.length > SERVED_ROWS_CAP) {
|
|
770
|
+
const next = rows[SERVED_ROWS_CAP]!;
|
|
771
|
+
text += "\n" + paginationHint(next.position + 1, rows.length - SERVED_ROWS_CAP);
|
|
772
|
+
rows = rows.slice(0, SERVED_ROWS_CAP);
|
|
773
|
+
}
|
|
774
|
+
blocks.push({ path, rows, text });
|
|
775
|
+
}
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
const hintLine = list
|
|
779
|
+
.map((problem) => problem.line)
|
|
780
|
+
.filter((line): line is number => typeof line === "number")
|
|
781
|
+
.sort((left, right) => left - right)[0];
|
|
782
|
+
let startLine = 1;
|
|
783
|
+
let endLine = lines.length;
|
|
784
|
+
if (hintLine !== undefined) {
|
|
785
|
+
// WHY: a stale anchor can sit anywhere in a large file; serve a local window that contains
|
|
786
|
+
// WHY: the offending line so retrying needs no read, instead of the first 150 rows only.
|
|
787
|
+
startLine = Math.max(1, hintLine - 2);
|
|
788
|
+
endLine = Math.max(startLine, lines.length);
|
|
789
|
+
}
|
|
790
|
+
const { servedRows, rendered } = buildRangeServeBlock(
|
|
791
|
+
startLine,
|
|
792
|
+
endLine,
|
|
793
|
+
[...baseline.hashes],
|
|
794
|
+
lines,
|
|
795
|
+
);
|
|
796
|
+
if (servedRows.length === 0) continue;
|
|
797
|
+
blocks.push({ path, rows: servedRows, text: rendered });
|
|
798
|
+
}
|
|
799
|
+
return blocks;
|
|
800
|
+
}
|