@prometheus-ai/hashline 0.5.4 → 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/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 ? relative || "." : unquoted;
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 resolved = resolveBlockEdits(edits, text, this.path, blockResolver, { onUnresolved: "throw" });
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 = warnings.length === 0 ? result.warnings : [...warnings, ...(result.warnings ?? [])];
317
- return merged && merged.length > 0
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 resolved = resolveBlockEdits(edits, text, this.path, blockResolver, { onUnresolved: "drop" });
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 = warnings.length === 0 ? result.warnings : [...warnings, ...(result.warnings ?? [])];
338
- return merged && merged.length > 0
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
- /** Optional patch envelope start marker; silently consumed when present. */
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 when encountered. */
36
+ /** Optional patch envelope end marker; terminates parsing. */
17
37
  export const END_PATCH_MARKER = "*** End Patch";
18
38
 
19
39
  /**
20
- * Recovery sentinel emitted by an agent loop when a contaminated tool-call
21
- * stream is truncated mid-call. Behaves like {@link END_PATCH_MARKER} for
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
- /** Warning text appended when two consecutive hunks target the exact same concrete range. */
45
+ /** Two consecutive hunks targeted the exact same concrete range. */
27
46
  export const REPLACE_PAIR_COALESCED_WARNING =
28
- "Detected two identical-range hashline hunks; kept only the second hunk. Issue ONE `replace N..M:` hunk per range — payload is the final desired content, never both old and new.";
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
- /** Warning text appended when an empty bodyless hunk is followed by an overlapping concrete hunk. */
49
+ /** Bare bodyless hunk followed by an overlapping concrete hunk. */
31
50
  export const REPLACE_PAIR_COALESCED_OVERLAP_WARNING =
32
- "Detected an overlapping bare hashline hunk immediately followed by a concrete hunk; dropped the earlier bare hunk. Issue ONE `replace N..M:` hunk per range — payload is the final desired content, never both old and new.";
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
- /** Warning text appended when bare body rows are auto-converted to literal rows. */
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; pasting raw code as payload is not a portable shape.";
55
+ "Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines.";
37
56
 
38
- /** Error text emitted when a hunk body contains a unified-diff-style `-` row. */
57
+ /** Unified-diff-style `-` row in a hunk body. */
39
58
  export const MINUS_ROW_REJECTED =
40
- "`-` rows are not valid; hashline ranges already name the lines being changed. To insert a literal line starting with `-`, write `+-…`.";
59
+ "`-` rows are not valid; the range already names the lines being changed. For a literal `-` line, write `+-…`.";
41
60
 
42
- /** Error text emitted when a replace hunk has no body. */
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
- /** Error text emitted when a `replace block N:` hunk has no body. */
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 N..M` with the block's line range.";
66
+ "`replace block N:` needs at least one `+TEXT` body row. To delete a block, use `delete block N`.";
48
67
 
49
68
  /**
50
- * Error text emitted when a `replace block N:` anchor cannot be resolved to a
51
- * syntactic block (unrecognized language, blank/out-of-range line, no node
52
- * begins on line N such as a lone closing delimiter, or the resolved block has
53
- * a syntax error). Names the offending line and steers back to an explicit
54
- * `replace N..M:` range.
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(line: number): string {
57
- return (
58
- `\`replace block ${line}:\` could not resolve a syntactic block beginning on line ${line}. ` +
59
- `The language may be unsupported, the line may be blank or a closing delimiter, or the block may not parse. ` +
60
- `Use \`replace ${line}..M:\` with the block's explicit end line instead.`
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
- * Error text emitted when a `replace block N:` edit reaches a code path that
66
- * has no {@link BlockResolver} wired in. Indicates a host-configuration bug
67
- * rather than authored-input error.
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 const BLOCK_RESOLVER_UNAVAILABLE =
70
- "`replace block N:` is not available here (no tree-sitter block resolver is configured). Use `replace N..M:` with an explicit range.";
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 error: `applyEdits` received an unresolved `replace block
74
- * N:` edit. Block edits must be expanded by `resolveBlockEdits` before reaching
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
- /** Error text emitted when a delete hunk receives a body row. */
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
- /** Error text emitted when a `delete block N` hunk receives a body row. */
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:` to replace the block.";
127
+ "`delete block N` does not take body rows. Remove the body, or use `replace block N:`.";
86
128
 
87
- /** Error text emitted when an insert hunk has no body. */
129
+ /** Insert hunk with no body. */
88
130
  export const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
89
131
 
90
- /** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
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
- /** Warning text emitted by `Recovery` when a prior in-session edit advanced the hash. */
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 (the file hash advanced after a prior edit in this session).";
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
- * Warning text emitted by `Recovery` when the session-chain replay
100
- * fast-path was taken. Distinct from {@link RECOVERY_SESSION_CHAIN_WARNING}
101
- * because replay is the less-certain mode: the structured-patch 3-way
102
- * merge refused, the anchor-content gate passed, but a coincidental
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 your previous edit in this session changed line(s) you re-targeted with a stale hash. Verify the diff matches your intent before continuing.";
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
- * Warning emitted when an `insert head:` / `insert tail:` edit is applied to an
112
- * existing file whose snapshot tag is stale (the file drifted since the read).
113
- * Head/tail insert position is content-independent "start"/"end" cannot move
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 an `insert head:`/`insert tail:` edit onto the current file content even though the snapshot tag was stale (the file changed since your read). Head/tail position is content-independent, so the insert was not rejected — but re-read if the drift was unexpected.";
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
- * Error text emitted when a hashline section omits the mandatory snapshot tag.
122
- * The tag is REQUIRED on every section, enforced identically by the apply path
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 edit to ${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.`;
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 { formatNumberedLine, HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
10
- import { MISMATCH_CONTEXT } from "./messages";
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 displayLines = getMismatchDisplayLines(details.anchorLines ?? [], details.fileLines);
119
- if (displayLines.length === 0) return lines.join("\n");
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) return;
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.payloads.push({ kind: "literal", text, lineNum });
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 (const entry of prepared) results.push(await this.commit(entry));
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, { onUnresolved: "throw" });
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
- if (expected === undefined) return applyEdits(normalized, resolved);
369
- // Whole-file unchanged the tag still names the live content, so an
370
- // edit anchored at ANY line (displayed or not) is safe to apply.
371
- if (liveMatches) return applyEdits(normalized, resolved);
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
  }