@prometheus-ai/hashline 0.5.3 → 0.5.8
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/CHANGELOG.md +7 -0
- package/dist/types/apply.d.ts +2 -0
- package/dist/types/block.d.ts +24 -9
- package/dist/types/diff-preview.d.ts +6 -4
- package/dist/types/messages.d.ts +73 -60
- package/dist/types/patcher.d.ts +7 -1
- package/dist/types/prefixes.d.ts +8 -0
- package/dist/types/snapshots.d.ts +6 -0
- package/dist/types/tokenizer.d.ts +3 -0
- package/dist/types/types.d.ts +45 -9
- package/package.json +3 -1
- package/src/apply.ts +267 -9
- package/src/block.ts +89 -15
- package/src/diff-preview.ts +96 -21
- package/src/grammar.lark +3 -1
- package/src/input.ts +16 -7
- package/src/messages.ts +121 -68
- package/src/mismatch.ts +5 -25
- package/src/parser.ts +89 -5
- package/src/patcher.ts +47 -9
- package/src/prefixes.ts +10 -0
- package/src/prompt.md +53 -19
- package/src/snapshots.ts +17 -1
- package/src/tokenizer.ts +11 -0
- package/src/types.ts +46 -9
package/src/input.ts
CHANGED
|
@@ -88,8 +88,9 @@ function normalizeHashlinePath(rawPath: string, cwd?: string): string {
|
|
|
88
88
|
const unquoted = stripApplyPatchPathNoise(unquoteHashlinePath(rawPath.trim()));
|
|
89
89
|
if (!cwd || !path.isAbsolute(unquoted)) return unquoted;
|
|
90
90
|
const relative = path.relative(path.resolve(cwd), path.resolve(unquoted));
|
|
91
|
+
const normalizedRelative = relative.split(path.sep).join("/");
|
|
91
92
|
const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
92
|
-
return isWithinCwd ?
|
|
93
|
+
return isWithinCwd ? normalizedRelative || "." : unquoted;
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
interface RawSection {
|
|
@@ -309,12 +310,16 @@ export class PatchSection {
|
|
|
309
310
|
*/
|
|
310
311
|
applyTo(text: string, blockResolver?: BlockResolver): ApplyResult {
|
|
311
312
|
const { edits, warnings } = this.parse();
|
|
312
|
-
const
|
|
313
|
+
const resolveWarnings: string[] = [];
|
|
314
|
+
const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, {
|
|
315
|
+
onUnresolved: "throw",
|
|
316
|
+
onWarning: warning => resolveWarnings.push(warning),
|
|
317
|
+
});
|
|
313
318
|
const result = applyEdits(text, resolved);
|
|
314
319
|
// Preserve parse warnings so consumers don't need to call `parse()`
|
|
315
320
|
// separately.
|
|
316
|
-
const merged =
|
|
317
|
-
return merged
|
|
321
|
+
const merged = [...warnings, ...resolveWarnings, ...(result.warnings ?? [])];
|
|
322
|
+
return merged.length > 0
|
|
318
323
|
? { ...result, warnings: merged }
|
|
319
324
|
: { text: result.text, firstChangedLine: result.firstChangedLine };
|
|
320
325
|
}
|
|
@@ -332,10 +337,14 @@ export class PatchSection {
|
|
|
332
337
|
*/
|
|
333
338
|
applyPartialTo(text: string, blockResolver?: BlockResolver): ApplyResult {
|
|
334
339
|
const { edits, warnings } = parsePatchStreaming(this.diff);
|
|
335
|
-
const
|
|
340
|
+
const resolveWarnings: string[] = [];
|
|
341
|
+
const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, {
|
|
342
|
+
onUnresolved: "drop",
|
|
343
|
+
onWarning: warning => resolveWarnings.push(warning),
|
|
344
|
+
});
|
|
336
345
|
const result = applyEdits(text, resolved);
|
|
337
|
-
const merged =
|
|
338
|
-
return merged
|
|
346
|
+
const merged = [...warnings, ...resolveWarnings, ...(result.warnings ?? [])];
|
|
347
|
+
return merged.length > 0
|
|
339
348
|
? { ...result, warnings: merged }
|
|
340
349
|
: { text: result.text, firstChangedLine: result.firstChangedLine };
|
|
341
350
|
}
|
package/src/messages.ts
CHANGED
|
@@ -1,128 +1,181 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Centralized error and warning text emitted by the hashline parser, applier,
|
|
3
|
-
* and patcher. Consolidating these as named constants makes them easy to
|
|
4
|
-
* audit and keeps wording stable across the rendering paths that surface
|
|
5
|
-
* them.
|
|
6
|
-
*/
|
|
1
|
+
/** Centralized error/warning text for the hashline parser, applier, and patcher. */
|
|
7
2
|
|
|
8
|
-
import { HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
|
|
3
|
+
import { formatNumberedLine, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
|
|
9
4
|
|
|
10
5
|
/** Lines of context shown either side of a hash mismatch. */
|
|
11
6
|
export const MISMATCH_CONTEXT = 2;
|
|
12
7
|
|
|
13
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Numbered `LINE:TEXT` rows around `anchorLines` (±{@link MISMATCH_CONTEXT}),
|
|
10
|
+
* `*`-marking anchors, `...` between non-adjacent runs. Out-of-range anchors
|
|
11
|
+
* contribute no rows.
|
|
12
|
+
*/
|
|
13
|
+
export function formatAnchoredContext(anchorLines: readonly number[], fileLines: readonly string[]): string[] {
|
|
14
|
+
const displayLines = new Set<number>();
|
|
15
|
+
for (const line of anchorLines) {
|
|
16
|
+
if (line < 1 || line > fileLines.length) continue;
|
|
17
|
+
const lo = Math.max(1, line - MISMATCH_CONTEXT);
|
|
18
|
+
const hi = Math.min(fileLines.length, line + MISMATCH_CONTEXT);
|
|
19
|
+
for (let lineNum = lo; lineNum <= hi; lineNum++) displayLines.add(lineNum);
|
|
20
|
+
}
|
|
21
|
+
const anchorSet = new Set(anchorLines);
|
|
22
|
+
const rows: string[] = [];
|
|
23
|
+
let previous = -1;
|
|
24
|
+
for (const lineNum of [...displayLines].sort((a, b) => a - b)) {
|
|
25
|
+
if (previous !== -1 && lineNum > previous + 1) rows.push("...");
|
|
26
|
+
previous = lineNum;
|
|
27
|
+
const marker = anchorSet.has(lineNum) ? "*" : " ";
|
|
28
|
+
rows.push(`${marker}${formatNumberedLine(lineNum, fileLines[lineNum - 1] ?? "")}`);
|
|
29
|
+
}
|
|
30
|
+
return rows;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Optional patch envelope start marker; silently consumed. */
|
|
14
34
|
export const BEGIN_PATCH_MARKER = "*** Begin Patch";
|
|
15
35
|
|
|
16
|
-
/** Optional patch envelope end marker; terminates parsing
|
|
36
|
+
/** Optional patch envelope end marker; terminates parsing. */
|
|
17
37
|
export const END_PATCH_MARKER = "*** End Patch";
|
|
18
38
|
|
|
19
39
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* parsing — terminates the line loop — and does not surface a warning.
|
|
40
|
+
* Truncation sentinel emitted by an agent loop mid-call. Ends parsing like
|
|
41
|
+
* {@link END_PATCH_MARKER}, without a warning.
|
|
23
42
|
*/
|
|
24
43
|
export const ABORT_MARKER = "*** Abort";
|
|
25
44
|
|
|
26
|
-
/**
|
|
45
|
+
/** Two consecutive hunks targeted the exact same concrete range. */
|
|
27
46
|
export const REPLACE_PAIR_COALESCED_WARNING =
|
|
28
|
-
"
|
|
47
|
+
"Two hunks targeted the same range; kept only the second. One `replace N..M:` hunk per range — the body is the final content, never old+new.";
|
|
29
48
|
|
|
30
|
-
/**
|
|
49
|
+
/** Bare bodyless hunk followed by an overlapping concrete hunk. */
|
|
31
50
|
export const REPLACE_PAIR_COALESCED_OVERLAP_WARNING =
|
|
32
|
-
"
|
|
51
|
+
"Dropped a bare hunk overlapped by the concrete hunk after it. One `replace N..M:` hunk per range — the body is the final content, never old+new.";
|
|
33
52
|
|
|
34
|
-
/**
|
|
53
|
+
/** Bare body rows auto-converted to literal `+` rows. */
|
|
35
54
|
export const BARE_BODY_AUTO_PIPED_WARNING =
|
|
36
|
-
"Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines
|
|
55
|
+
"Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines.";
|
|
37
56
|
|
|
38
|
-
/**
|
|
57
|
+
/** Unified-diff-style `-` row in a hunk body. */
|
|
39
58
|
export const MINUS_ROW_REJECTED =
|
|
40
|
-
"`-` rows are not valid;
|
|
59
|
+
"`-` rows are not valid; the range already names the lines being changed. For a literal `-` line, write `+-…`.";
|
|
41
60
|
|
|
42
|
-
/**
|
|
61
|
+
/** Replace hunk with no body. */
|
|
43
62
|
export const EMPTY_REPLACE = "`replace N..M:` needs at least one `+TEXT` body row. To delete lines, use `delete N..M`.";
|
|
44
63
|
|
|
45
|
-
/**
|
|
64
|
+
/** `replace block N:` hunk with no body. */
|
|
46
65
|
export const EMPTY_BLOCK =
|
|
47
|
-
"`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete
|
|
66
|
+
"`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete block N`.";
|
|
48
67
|
|
|
49
68
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* `
|
|
69
|
+
* Block-anchored replace/delete could not resolve to a syntactic block
|
|
70
|
+
* (unsupported language, blank/out-of-range line, no node beginning on N, or
|
|
71
|
+
* parse error). Appends a {@link formatAnchoredContext} preview when
|
|
72
|
+
* `fileLines` is given. `insert after block N:` never reaches this — it is
|
|
73
|
+
* lowered to plain `insert after N:` instead (see
|
|
74
|
+
* {@link insertAfterBlockUnresolvedLoweredWarning}).
|
|
55
75
|
*/
|
|
56
|
-
export function blockUnresolvedMessage(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
76
|
+
export function blockUnresolvedMessage(
|
|
77
|
+
line: number,
|
|
78
|
+
op: "replace" | "delete" = "replace",
|
|
79
|
+
fileLines?: readonly string[],
|
|
80
|
+
): string {
|
|
81
|
+
const phrase = op === "delete" ? `delete block ${line}` : `replace block ${line}:`;
|
|
82
|
+
const fallback = op === "delete" ? `delete ${line}..M` : `replace ${line}..M:`;
|
|
83
|
+
let message =
|
|
84
|
+
`\`${phrase}\` could not resolve a syntactic block beginning on line ${line} ` +
|
|
85
|
+
`(unsupported language, blank/closer line, or parse error). Use \`${fallback}\` with explicit lines.`;
|
|
86
|
+
if (fileLines) {
|
|
87
|
+
const context = formatAnchoredContext([line], fileLines);
|
|
88
|
+
if (context.length > 0) message += `\n\n${context.join("\n")}`;
|
|
89
|
+
}
|
|
90
|
+
return message;
|
|
62
91
|
}
|
|
63
92
|
|
|
93
|
+
/** Block-anchored edit reached a path with no {@link BlockResolver} wired in — a host-configuration bug. */
|
|
94
|
+
export const BLOCK_RESOLVER_UNAVAILABLE =
|
|
95
|
+
"`replace block`/`delete block`/`insert after block` are not available here (no block resolver configured). Use a concrete line range.";
|
|
96
|
+
|
|
64
97
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
98
|
+
* `insert after block N:` anchored on a closing-delimiter line, lowered to
|
|
99
|
+
* plain `insert after N:` — the closer ends a block, and inserting after it
|
|
100
|
+
* is exactly what the plain form does.
|
|
68
101
|
*/
|
|
69
|
-
export
|
|
70
|
-
|
|
102
|
+
export function insertAfterBlockCloserLoweredWarning(line: number): string {
|
|
103
|
+
return `\`insert after block ${line}:\` anchors on a closing delimiter, so it was applied as plain \`insert after ${line}:\`. Anchor on the line that OPENS the construct.`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `insert after block N:` anchor unresolvable (unsupported language, blank
|
|
108
|
+
* line, parse error, or no resolver), lowered to plain `insert after N:` —
|
|
109
|
+
* applying with a warning beats failing the patch.
|
|
110
|
+
*/
|
|
111
|
+
export function insertAfterBlockUnresolvedLoweredWarning(line: number): string {
|
|
112
|
+
return `\`insert after block ${line}:\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`insert after ${line}:\`. Verify the landing line; anchor on a line that OPENS a construct.`;
|
|
113
|
+
}
|
|
71
114
|
|
|
72
115
|
/**
|
|
73
|
-
* Internal invariant
|
|
74
|
-
*
|
|
75
|
-
* the applier; hitting this is a wiring bug, not authored-input error.
|
|
116
|
+
* Internal invariant: `applyEdits` received an unresolved `replace block N:`
|
|
117
|
+
* edit; `resolveBlockEdits` must run first. Wiring bug, not authored input.
|
|
76
118
|
*/
|
|
77
119
|
export const UNRESOLVED_BLOCK_INTERNAL =
|
|
78
120
|
"internal error: unresolved `replace block` edit reached the applier (resolveBlockEdits was not run).";
|
|
79
121
|
|
|
80
|
-
/**
|
|
122
|
+
/** Delete hunk received a body row. */
|
|
81
123
|
export const DELETE_TAKES_NO_BODY = "`delete N..M` does not take body rows. Remove the body, or use `replace N..M:`.";
|
|
82
124
|
|
|
83
|
-
/**
|
|
125
|
+
/** `delete block N` hunk received a body row. */
|
|
84
126
|
export const DELETE_BLOCK_TAKES_NO_BODY =
|
|
85
|
-
"`delete block N` does not take body rows. Remove the body, or use `replace block N
|
|
127
|
+
"`delete block N` does not take body rows. Remove the body, or use `replace block N:`.";
|
|
86
128
|
|
|
87
|
-
/**
|
|
129
|
+
/** Insert hunk with no body. */
|
|
88
130
|
export const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
|
|
89
131
|
|
|
90
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* `insert after` body indented shallower than the anchor: the landing slid
|
|
134
|
+
* forward past trailing closer lines — the common "anchored on the last line
|
|
135
|
+
* I read instead of after the block" mistake.
|
|
136
|
+
*/
|
|
137
|
+
export function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string {
|
|
138
|
+
return `insert after ${anchorLine}: body indented shallower than the anchor, so the landing moved past ${crossed} closing line${crossed === 1 ? "" : "s"} to after line ${landingLine}. For the deeper position inside the block, re-issue with the body indented to match.`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `insert after block N:` body indented deeper than the block's closer: the
|
|
143
|
+
* landing was pulled inside the block — a deeper body almost always means
|
|
144
|
+
* "append inside the block's body".
|
|
145
|
+
*/
|
|
146
|
+
export function blockInsertLandingShiftWarning(blockStart: number, closerLine: number, landingLine: number): string {
|
|
147
|
+
return `insert after block ${blockStart}: body indented deeper than closing line ${closerLine}, so it was placed inside the block, after line ${landingLine}. \`insert after block\` lands AFTER the block at sibling depth — if inside was intended, use plain \`insert after ${closerLine}:\`.`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `Recovery`: an external write matched a cached snapshot. */
|
|
91
151
|
export const RECOVERY_EXTERNAL_WARNING =
|
|
92
152
|
"Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
|
|
93
153
|
|
|
94
|
-
/**
|
|
154
|
+
/** `Recovery`: a prior in-session edit advanced the hash. */
|
|
95
155
|
export const RECOVERY_SESSION_CHAIN_WARNING =
|
|
96
|
-
"Recovered from a stale file hash using an earlier in-session snapshot (
|
|
156
|
+
"Recovered from a stale file hash using an earlier in-session snapshot (a prior edit in this session advanced the hash).";
|
|
97
157
|
|
|
98
158
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* insert+delete pair earlier in the chain could still leave an anchor's
|
|
104
|
-
* line number pointing at a duplicated row. Surface the hedge so the
|
|
105
|
-
* model verifies before continuing.
|
|
159
|
+
* `Recovery`: session-chain replay fast-path. Less certain than
|
|
160
|
+
* {@link RECOVERY_SESSION_CHAIN_WARNING} — the 3-way merge refused, the
|
|
161
|
+
* anchor-content gate passed, but a coincidental insert+delete earlier in
|
|
162
|
+
* the chain could still misplace an anchor — hence the verify hedge.
|
|
106
163
|
*/
|
|
107
164
|
export const RECOVERY_SESSION_REPLAY_WARNING =
|
|
108
|
-
"Recovered by replaying your edits onto the current file content
|
|
165
|
+
"Recovered by replaying your edits onto the current file content (a prior in-session edit changed the lines you re-targeted with a stale hash). Verify the diff matches your intent.";
|
|
109
166
|
|
|
110
167
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* with drift — so this is non-fatal: the edit applies onto the live content and
|
|
115
|
-
* we surface the drift instead of hard-failing (unlike an anchored mismatch).
|
|
168
|
+
* `insert head:`/`insert tail:` applied despite a stale snapshot tag.
|
|
169
|
+
* Head/tail position is content-independent, so drift is non-fatal: apply
|
|
170
|
+
* onto live content and warn instead of hard-failing.
|
|
116
171
|
*/
|
|
117
172
|
export const HEADTAIL_DRIFT_WARNING =
|
|
118
|
-
"Applied
|
|
173
|
+
"Applied the `insert head:`/`insert tail:` edit despite a stale snapshot tag (file changed since your read) — head/tail position is content-independent. Re-read if the drift was unexpected.";
|
|
119
174
|
|
|
120
175
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* ({@link Patcher.prepare}) and the preview/diff path, so both surfaces reuse
|
|
124
|
-
* this single builder to stay in lockstep.
|
|
176
|
+
* Section omitted the mandatory snapshot tag. Shared by the apply
|
|
177
|
+
* ({@link Patcher.prepare}) and preview/diff paths so both stay in lockstep.
|
|
125
178
|
*/
|
|
126
179
|
export function missingSnapshotTagMessage(sectionPath: string): string {
|
|
127
|
-
return `Missing hashline snapshot tag for
|
|
180
|
+
return `Missing hashline snapshot tag for ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}tag${HL_FILE_SUFFIX}\` from your latest read/search output. To create a new file, use the write tool.`;
|
|
128
181
|
}
|
package/src/mismatch.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* plus a couple of lines of surrounding context. The {@link MismatchError}
|
|
7
7
|
* formats this into a message at construction time.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
|
|
10
|
+
import { formatAnchoredContext } from "./messages";
|
|
11
11
|
|
|
12
12
|
const LINE_REF_RE = /^\s*[>+\-*]*\s*(\d+)(?::.*)?\s*$/;
|
|
13
13
|
/** Format the required-shape diagnostic shown when a line reference is malformed. */
|
|
@@ -46,17 +46,6 @@ export interface MismatchDetails {
|
|
|
46
46
|
hashRecognized?: boolean;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function getMismatchDisplayLines(anchorLines: readonly number[], fileLines: string[]): number[] {
|
|
50
|
-
const displayLines = new Set<number>();
|
|
51
|
-
for (const line of anchorLines) {
|
|
52
|
-
if (line < 1 || line > fileLines.length) continue;
|
|
53
|
-
const lo = Math.max(1, line - MISMATCH_CONTEXT);
|
|
54
|
-
const hi = Math.min(fileLines.length, line + MISMATCH_CONTEXT);
|
|
55
|
-
for (let lineNum = lo; lineNum <= hi; lineNum++) displayLines.add(lineNum);
|
|
56
|
-
}
|
|
57
|
-
return [...displayLines].sort((a, b) => a - b);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
49
|
/**
|
|
61
50
|
* Raised when a hashline section's snapshot tag doesn't match the live file's
|
|
62
51
|
* content (and recovery, if configured, declined the merge). Carries the
|
|
@@ -113,19 +102,10 @@ export class MismatchError extends Error {
|
|
|
113
102
|
}
|
|
114
103
|
|
|
115
104
|
static formatMessage(details: MismatchDetails): string {
|
|
116
|
-
const anchorSet = new Set(details.anchorLines ?? []);
|
|
117
105
|
const lines = MismatchError.rejectionHeader(details);
|
|
118
|
-
const
|
|
119
|
-
if (
|
|
120
|
-
lines.push("");
|
|
121
|
-
let previous = -1;
|
|
122
|
-
for (const lineNum of displayLines) {
|
|
123
|
-
if (previous !== -1 && lineNum > previous + 1) lines.push("...");
|
|
124
|
-
previous = lineNum;
|
|
125
|
-
const text = details.fileLines[lineNum - 1] ?? "";
|
|
126
|
-
const marker = anchorSet.has(lineNum) ? "*" : " ";
|
|
127
|
-
lines.push(`${marker}${formatNumberedLine(lineNum, text)}`);
|
|
128
|
-
}
|
|
106
|
+
const context = formatAnchoredContext(details.anchorLines ?? [], details.fileLines);
|
|
107
|
+
if (context.length === 0) return lines.join("\n");
|
|
108
|
+
lines.push("", ...context);
|
|
129
109
|
return lines.join("\n");
|
|
130
110
|
}
|
|
131
111
|
}
|
package/src/parser.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
EMPTY_INSERT,
|
|
13
13
|
MINUS_ROW_REJECTED,
|
|
14
14
|
} from "./messages";
|
|
15
|
+
import { stripOneLeadingHashlinePrefix } from "./prefixes";
|
|
15
16
|
import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
|
|
16
17
|
import type { Anchor, Cursor, Edit } from "./types";
|
|
17
18
|
|
|
@@ -31,6 +32,13 @@ function isSkippableCommentLine(line: string): boolean {
|
|
|
31
32
|
return line.trimStart().startsWith("#");
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Stripped remainder of a bare `N: <value>` row that is a lone quoted or
|
|
37
|
+
* numeric literal (optionally comma-terminated) — the shape of a numeric-keyed
|
|
38
|
+
* dict/YAML body rather than read-output paste.
|
|
39
|
+
*/
|
|
40
|
+
const BARE_LITERAL_VALUE_RE = /^\s*(?:"[^"]*"|'[^']*'|[-+]?\d+(?:\.\d+)?)\s*,?\s*$/;
|
|
41
|
+
|
|
34
42
|
function detectApplyPatchContamination(text: string, _hasPending: boolean): string | null {
|
|
35
43
|
const trimmed = text.trimStart();
|
|
36
44
|
if (trimmed.length === 0) return null;
|
|
@@ -81,12 +89,18 @@ interface PendingComment {
|
|
|
81
89
|
text: string;
|
|
82
90
|
}
|
|
83
91
|
|
|
84
|
-
type PayloadRow = { kind: "literal"; text: string; lineNum: number };
|
|
92
|
+
type PayloadRow = { kind: "literal"; text: string; lineNum: number; bare?: boolean };
|
|
85
93
|
|
|
86
94
|
interface Pending {
|
|
87
95
|
target: BlockTarget;
|
|
88
96
|
lineNum: number;
|
|
89
97
|
payloads: PayloadRow[];
|
|
98
|
+
/**
|
|
99
|
+
* Blank rows seen after the body started. Interior blanks are committed to
|
|
100
|
+
* the payload when the next non-blank row arrives; trailing blanks before
|
|
101
|
+
* the next header/op are layout separators and are discarded on flush.
|
|
102
|
+
*/
|
|
103
|
+
deferredBlanks: PayloadRow[];
|
|
90
104
|
}
|
|
91
105
|
|
|
92
106
|
export class Executor {
|
|
@@ -126,6 +140,7 @@ export class Executor {
|
|
|
126
140
|
return;
|
|
127
141
|
case "blank":
|
|
128
142
|
this.#consumePendingSkippableComments();
|
|
143
|
+
this.#handleBlank("", token.lineNum);
|
|
129
144
|
return;
|
|
130
145
|
case "payload-literal":
|
|
131
146
|
this.#consumePendingSkippableComments();
|
|
@@ -145,7 +160,7 @@ export class Executor {
|
|
|
145
160
|
validateRangeOrder(token.target.range, token.lineNum);
|
|
146
161
|
}
|
|
147
162
|
this.#flushPending();
|
|
148
|
-
this.#pending = { target: token.target, lineNum: token.lineNum, payloads: [] };
|
|
163
|
+
this.#pending = { target: token.target, lineNum: token.lineNum, payloads: [], deferredBlanks: [] };
|
|
149
164
|
return;
|
|
150
165
|
}
|
|
151
166
|
}
|
|
@@ -207,6 +222,7 @@ export class Executor {
|
|
|
207
222
|
}
|
|
208
223
|
if (pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
|
|
209
224
|
if (pending.target.kind === "delete_block") throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
|
|
225
|
+
this.#commitDeferredBlanks(pending);
|
|
210
226
|
pending.payloads.push({ kind: "literal", text, lineNum });
|
|
211
227
|
}
|
|
212
228
|
|
|
@@ -214,13 +230,24 @@ export class Executor {
|
|
|
214
230
|
const contamination = detectApplyPatchContamination(text, this.#pending !== undefined);
|
|
215
231
|
if (contamination !== null) throw new Error(`line ${lineNum}: ${contamination}`);
|
|
216
232
|
if (this.#pending) {
|
|
217
|
-
if (text.trim().length === 0)
|
|
233
|
+
if (text.trim().length === 0) {
|
|
234
|
+
this.#handleBlank(text, lineNum);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
218
237
|
if (this.#pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
|
|
219
238
|
if (this.#pending.target.kind === "delete_block")
|
|
220
239
|
throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
|
|
221
240
|
if (text.trimStart().charCodeAt(0) === 45 /* - */) throw new Error(`line ${lineNum}: ${MINUS_ROW_REJECTED}`);
|
|
222
241
|
if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING);
|
|
223
|
-
this.#pending
|
|
242
|
+
this.#commitDeferredBlanks(this.#pending);
|
|
243
|
+
// Defer read-output line-number stripping to #flushPending: a bare
|
|
244
|
+
// "N:text" row is only a copy-paste artifact from snapshot output
|
|
245
|
+
// when *every* bare row in the hunk carries that prefix. Stripping a
|
|
246
|
+
// row in isolation would corrupt a genuine body that merely starts
|
|
247
|
+
// with "digits:" (YAML ports "42:hello", timestamps "12:30") when it
|
|
248
|
+
// sits next to an unprefixed sibling. Rows with an explicit "+" go
|
|
249
|
+
// through #handleLiteralPayload and are never bare, never stripped.
|
|
250
|
+
this.#pending.payloads.push({ kind: "literal", text, lineNum, bare: true });
|
|
224
251
|
return;
|
|
225
252
|
}
|
|
226
253
|
if (text.trim().length === 0) return;
|
|
@@ -230,6 +257,56 @@ export class Executor {
|
|
|
230
257
|
);
|
|
231
258
|
}
|
|
232
259
|
|
|
260
|
+
/**
|
|
261
|
+
* A blank row inside a hunk body is ambiguous: interior blanks are body
|
|
262
|
+
* content (a bare-pasted body legitimately contains empty lines), while
|
|
263
|
+
* blanks before the body starts or trailing into the next op are layout.
|
|
264
|
+
* Defer them; {@link #commitDeferredBlanks} folds them in only when a later
|
|
265
|
+
* non-blank row proves they were interior.
|
|
266
|
+
*/
|
|
267
|
+
#handleBlank(text: string, lineNum: number): void {
|
|
268
|
+
const pending = this.#pending;
|
|
269
|
+
if (!pending) return;
|
|
270
|
+
if (pending.target.kind === "delete" || pending.target.kind === "delete_block") return;
|
|
271
|
+
if (pending.payloads.length === 0) return;
|
|
272
|
+
pending.deferredBlanks.push({ kind: "literal", text, lineNum, bare: true });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#commitDeferredBlanks(pending: Pending): void {
|
|
276
|
+
if (pending.deferredBlanks.length === 0) return;
|
|
277
|
+
if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING);
|
|
278
|
+
pending.payloads.push(...pending.deferredBlanks);
|
|
279
|
+
pending.deferredBlanks = [];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Strip a single read-output line-number prefix (`N:`) from every bare body
|
|
284
|
+
* row, but only when *all* bare rows carry one. A uniform set of prefixes is
|
|
285
|
+
* the signature of content pasted straight from `read`/`search` output; a
|
|
286
|
+
* mixed set means the `N:` is genuine payload content and must stay. Rows
|
|
287
|
+
* authored with an explicit `+` are not bare and are never touched.
|
|
288
|
+
*/
|
|
289
|
+
#stripBarePrefixesIfUniform(payloads: PayloadRow[]): void {
|
|
290
|
+
let sawBare = false;
|
|
291
|
+
let allLiteralValues = true;
|
|
292
|
+
for (const row of payloads) {
|
|
293
|
+
if (!row.bare || row.text.trim().length === 0) continue;
|
|
294
|
+
sawBare = true;
|
|
295
|
+
const stripped = stripOneLeadingHashlinePrefix(row.text);
|
|
296
|
+
if (stripped === row.text) return;
|
|
297
|
+
allLiteralValues &&= BARE_LITERAL_VALUE_RE.test(stripped);
|
|
298
|
+
}
|
|
299
|
+
if (!sawBare) return;
|
|
300
|
+
// A body where every stripped remainder is a lone quoted/numeric literal
|
|
301
|
+
// (optionally comma-terminated) is the shape of a numeric-keyed dict or
|
|
302
|
+
// YAML mapping (`1: "one",`), not read-output paste; stripping the "N:"
|
|
303
|
+
// keys would mangle every line. Leave such bodies untouched.
|
|
304
|
+
if (allLiteralValues) return;
|
|
305
|
+
for (const row of payloads) {
|
|
306
|
+
if (row.bare && row.text.trim().length > 0) row.text = stripOneLeadingHashlinePrefix(row.text);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
233
310
|
#pushInsert(cursor: Cursor, text: string, lineNum: number, mode?: "replacement"): void {
|
|
234
311
|
this.#edits.push({
|
|
235
312
|
kind: "insert",
|
|
@@ -245,11 +322,12 @@ export class Executor {
|
|
|
245
322
|
this.#edits.push({ kind: "delete", anchor: { ...anchor }, lineNum, index: this.#editIndex++ });
|
|
246
323
|
}
|
|
247
324
|
|
|
248
|
-
#pushBlock(anchor: Anchor, payloads: readonly PayloadRow[], lineNum: number): void {
|
|
325
|
+
#pushBlock(anchor: Anchor, payloads: readonly PayloadRow[], lineNum: number, mode?: "insert_after"): void {
|
|
249
326
|
this.#edits.push({
|
|
250
327
|
kind: "block",
|
|
251
328
|
anchor: { ...anchor },
|
|
252
329
|
payloads: payloads.map(payload => payload.text),
|
|
330
|
+
...(mode === undefined ? {} : { mode }),
|
|
253
331
|
lineNum,
|
|
254
332
|
index: this.#editIndex++,
|
|
255
333
|
});
|
|
@@ -263,6 +341,7 @@ export class Executor {
|
|
|
263
341
|
const pending = this.#pending;
|
|
264
342
|
if (!pending) return;
|
|
265
343
|
const { target, lineNum, payloads } = pending;
|
|
344
|
+
this.#stripBarePrefixesIfUniform(payloads);
|
|
266
345
|
this.#pending = undefined;
|
|
267
346
|
if (target.kind === "delete") {
|
|
268
347
|
for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
|
|
@@ -278,6 +357,11 @@ export class Executor {
|
|
|
278
357
|
this.#pushBlock(target.anchor, payloads, lineNum);
|
|
279
358
|
return;
|
|
280
359
|
}
|
|
360
|
+
if (target.kind === "insert_after_block") {
|
|
361
|
+
if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
|
|
362
|
+
this.#pushBlock(target.anchor, payloads, lineNum, "insert_after");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
281
365
|
if (payloads.length === 0) {
|
|
282
366
|
if (target.kind === "replace") {
|
|
283
367
|
for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
|
package/src/patcher.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { MismatchError } from "./mismatch";
|
|
|
33
33
|
import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
|
|
34
34
|
import { Recovery, type RecoveryResult } from "./recovery";
|
|
35
35
|
import type { SnapshotStore } from "./snapshots";
|
|
36
|
-
import type { ApplyResult, BlockResolver, Edit } from "./types";
|
|
36
|
+
import type { ApplyResult, BlockResolution, BlockResolver, Edit } from "./types";
|
|
37
37
|
|
|
38
38
|
export interface PatcherOptions {
|
|
39
39
|
/** Storage backend used for all reads and writes. */
|
|
@@ -72,6 +72,12 @@ export interface PatchSectionResult {
|
|
|
72
72
|
firstChangedLine?: number;
|
|
73
73
|
/** Warnings collected by the parser, applier, and (optionally) recovery. */
|
|
74
74
|
warnings: string[];
|
|
75
|
+
/**
|
|
76
|
+
* Resolved spans for any `replace block`/`delete block` ops, present when the
|
|
77
|
+
* apply matched the tagged content. Undefined for patches with no block ops
|
|
78
|
+
* (and for resolutions routed through drift recovery, where numbers shift).
|
|
79
|
+
*/
|
|
80
|
+
blockResolutions?: BlockResolution[];
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
export interface PatcherApplyResult {
|
|
@@ -193,7 +199,24 @@ export class Patcher {
|
|
|
193
199
|
}
|
|
194
200
|
|
|
195
201
|
const results: PatchSectionResult[] = [];
|
|
196
|
-
for (
|
|
202
|
+
for (let index = 0; index < prepared.length; index++) {
|
|
203
|
+
try {
|
|
204
|
+
results.push(await this.commit(prepared[index]));
|
|
205
|
+
} catch (error) {
|
|
206
|
+
// A mid-batch write failure leaves earlier sections on disk with no
|
|
207
|
+
// rollback; report exactly which sections landed so the caller can
|
|
208
|
+
// re-issue only the missing ones instead of double-applying.
|
|
209
|
+
const written = prepared.slice(0, index).map(entry => entry.section.path);
|
|
210
|
+
const notWritten = prepared.slice(index + 1).map(entry => entry.section.path);
|
|
211
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Failed to write ${prepared[index].section.path}: ${message}` +
|
|
214
|
+
(written.length > 0 ? ` Sections already written: ${written.join(", ")}.` : "") +
|
|
215
|
+
(notWritten.length > 0 ? ` Sections not written: ${notWritten.join(", ")}.` : ""),
|
|
216
|
+
{ cause: error },
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
197
220
|
return { sections: results };
|
|
198
221
|
}
|
|
199
222
|
|
|
@@ -300,6 +323,7 @@ export class Patcher {
|
|
|
300
323
|
fileHash,
|
|
301
324
|
header: formatHashlineHeader(section.path, fileHash),
|
|
302
325
|
firstChangedLine: applyResult.firstChangedLine,
|
|
326
|
+
blockResolutions: applyResult.blockResolutions,
|
|
303
327
|
warnings,
|
|
304
328
|
};
|
|
305
329
|
}
|
|
@@ -355,6 +379,8 @@ export class Patcher {
|
|
|
355
379
|
// resulting ranges flow through the 3-way-merge recovery below.
|
|
356
380
|
// When a block edit needs the tagged snapshot but it is unavailable, the
|
|
357
381
|
// range cannot be placed safely — reject with a MismatchError (re-read).
|
|
382
|
+
const blockResolutions: BlockResolution[] = [];
|
|
383
|
+
const resolveWarnings: string[] = [];
|
|
358
384
|
let resolved: readonly Edit[] = edits;
|
|
359
385
|
if (hasBlockEdit(edits)) {
|
|
360
386
|
const baseText =
|
|
@@ -362,20 +388,32 @@ export class Patcher {
|
|
|
362
388
|
if (baseText === undefined) {
|
|
363
389
|
throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", false);
|
|
364
390
|
}
|
|
365
|
-
resolved = resolveBlockEdits(edits, baseText, section.path, this.blockResolver, {
|
|
391
|
+
resolved = resolveBlockEdits(edits, baseText, section.path, this.blockResolver, {
|
|
392
|
+
onUnresolved: "throw",
|
|
393
|
+
onResolved: resolution => blockResolutions.push(resolution),
|
|
394
|
+
onWarning: warning => resolveWarnings.push(warning),
|
|
395
|
+
});
|
|
366
396
|
}
|
|
397
|
+
const withResolveWarnings = (result: ApplyResult): ApplyResult =>
|
|
398
|
+
resolveWarnings.length === 0
|
|
399
|
+
? result
|
|
400
|
+
: { ...result, warnings: [...resolveWarnings, ...(result.warnings ?? [])] };
|
|
367
401
|
|
|
368
|
-
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
|
|
402
|
+
// No tag, or the tag still names the live content: an edit anchored at any
|
|
403
|
+
// line is safe to apply, and the resolved block spans line up with what
|
|
404
|
+
// the caller read, so echo them back. (A drifted file falls through to
|
|
405
|
+
// recovery below, where line numbers shift, so resolutions are dropped.)
|
|
406
|
+
if (expected === undefined || liveMatches) {
|
|
407
|
+
const result = applyEdits(normalized, resolved);
|
|
408
|
+
return withResolveWarnings(blockResolutions.length > 0 ? { ...result, blockResolutions } : result);
|
|
409
|
+
}
|
|
372
410
|
// Head/tail-only inserts are position-stable: "start"/"end" cannot move
|
|
373
411
|
// with content drift, so a stale tag is non-fatal. Apply onto the live
|
|
374
412
|
// content and warn instead of hard-failing — unlike an anchored
|
|
375
413
|
// mismatch, which cannot be safely relocated and must reject.
|
|
376
414
|
if (!hasAnchorScopedEdit(resolved)) {
|
|
377
415
|
const result = applyEdits(normalized, resolved);
|
|
378
|
-
return { ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] };
|
|
416
|
+
return withResolveWarnings({ ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] });
|
|
379
417
|
}
|
|
380
418
|
// File drifted: try to replay the edit against the version the tag
|
|
381
419
|
// names and 3-way-merge it onto the live content.
|
|
@@ -385,7 +423,7 @@ export class Patcher {
|
|
|
385
423
|
fileHash: expected,
|
|
386
424
|
edits: resolved,
|
|
387
425
|
});
|
|
388
|
-
if (recovered) return recoveryToApplyResult(recovered);
|
|
426
|
+
if (recovered) return withResolveWarnings(recoveryToApplyResult(recovered));
|
|
389
427
|
const hashRecognized = this.snapshots.byHash(canonicalPath, expected) !== null;
|
|
390
428
|
throw this.#mismatchError(section, canonicalPath, normalized, expected, hashRecognized);
|
|
391
429
|
}
|