jeopi-hashline 16.2.13
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 +343 -0
- package/README.md +82 -0
- package/dist/types/apply.d.ts +10 -0
- package/dist/types/block.d.ts +39 -0
- package/dist/types/diff-preview.d.ts +14 -0
- package/dist/types/format.d.ts +83 -0
- package/dist/types/fs.d.ts +109 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/input.d.ts +110 -0
- package/dist/types/messages.d.ts +127 -0
- package/dist/types/mismatch.d.ts +44 -0
- package/dist/types/normalize.d.ts +20 -0
- package/dist/types/parser.d.ts +27 -0
- package/dist/types/patcher.d.ts +118 -0
- package/dist/types/prefixes.d.ts +42 -0
- package/dist/types/recovery.d.ts +44 -0
- package/dist/types/snapshots.d.ts +127 -0
- package/dist/types/stream.d.ts +2 -0
- package/dist/types/tokenizer.d.ts +73 -0
- package/dist/types/types.d.ts +172 -0
- package/package.json +64 -0
- package/src/apply.ts +1281 -0
- package/src/block.ts +168 -0
- package/src/diff-preview.ts +124 -0
- package/src/format.ts +141 -0
- package/src/fs.ts +246 -0
- package/src/grammar.lark +29 -0
- package/src/index.ts +17 -0
- package/src/input.ts +462 -0
- package/src/messages.ts +267 -0
- package/src/mismatch.ts +118 -0
- package/src/normalize.ts +38 -0
- package/src/parser.ts +456 -0
- package/src/patcher.ts +608 -0
- package/src/prefixes.ts +142 -0
- package/src/prompt.md +172 -0
- package/src/recovery.ts +418 -0
- package/src/snapshots.ts +282 -0
- package/src/stream.ts +132 -0
- package/src/tokenizer.ts +557 -0
- package/src/types.ts +172 -0
package/src/messages.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/** Centralized error/warning text for the hashline parser, applier, and patcher. */
|
|
2
|
+
|
|
3
|
+
import { formatNumberedLine, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX, HL_RANGE_SEP } from "./format";
|
|
4
|
+
|
|
5
|
+
/** Lines of context shown either side of a hash mismatch. */
|
|
6
|
+
export const MISMATCH_CONTEXT = 2;
|
|
7
|
+
|
|
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. */
|
|
34
|
+
export const BEGIN_PATCH_MARKER = "*** Begin Patch";
|
|
35
|
+
|
|
36
|
+
/** Optional patch envelope end marker; terminates parsing. */
|
|
37
|
+
export const END_PATCH_MARKER = "*** End Patch";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Truncation sentinel emitted by an agent loop mid-call. Ends parsing like
|
|
41
|
+
* {@link END_PATCH_MARKER}, without a warning.
|
|
42
|
+
*/
|
|
43
|
+
export const ABORT_MARKER = "*** Abort";
|
|
44
|
+
|
|
45
|
+
/** Two consecutive hunks targeted the exact same concrete range. */
|
|
46
|
+
export const REPLACE_PAIR_COALESCED_WARNING = `Two hunks targeted the same range; kept only the second. One \`SWAP N${HL_RANGE_SEP}M:\` hunk per range — the body is the final content, never old+new.`;
|
|
47
|
+
|
|
48
|
+
/** Bare bodyless hunk followed by an overlapping concrete hunk. */
|
|
49
|
+
`Dropped a bare hunk overlapped by the concrete hunk after it. One \`SWAP N${HL_RANGE_SEP}M:\` hunk per range — the body is the final content, never old+new.`;
|
|
50
|
+
|
|
51
|
+
/** Bare body rows auto-converted to literal `+` rows. */
|
|
52
|
+
export const BARE_BODY_AUTO_PIPED_WARNING =
|
|
53
|
+
"Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines.";
|
|
54
|
+
|
|
55
|
+
/** Unified-diff-style `-` row in a hunk body. */
|
|
56
|
+
export const MINUS_ROW_REJECTED =
|
|
57
|
+
"`-` rows are not valid; the range already names the lines being changed. For Markdown bullets or other literal `-` lines, prefix the literal row with `+`: `+- item`.";
|
|
58
|
+
|
|
59
|
+
/** Replace hunk with no body. */
|
|
60
|
+
export const EMPTY_REPLACE = `\`SWAP N${HL_RANGE_SEP}M:\` needs at least one \`+TEXT\` body row. To delete lines, use \`DEL N${HL_RANGE_SEP}M\`.`;
|
|
61
|
+
|
|
62
|
+
/** `replace_block N:` hunk with no body. */
|
|
63
|
+
export const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`.";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Block-anchored replace/delete could not resolve to a syntactic block
|
|
67
|
+
* (unsupported language, blank/out-of-range line, no node beginning on N, or
|
|
68
|
+
* parse error). Appends a {@link formatAnchoredContext} preview when
|
|
69
|
+
* `fileLines` is given. `insert_after_block N:` never reaches this — it is
|
|
70
|
+
* lowered to plain `insert after N:` instead (see
|
|
71
|
+
* {@link insertAfterBlockUnresolvedLoweredWarning}).
|
|
72
|
+
*/
|
|
73
|
+
export function blockUnresolvedMessage(
|
|
74
|
+
line: number,
|
|
75
|
+
op: "replace" | "delete" = "replace",
|
|
76
|
+
fileLines?: readonly string[],
|
|
77
|
+
): string {
|
|
78
|
+
const phrase = op === "delete" ? `DEL.BLK ${line}` : `SWAP.BLK ${line}:`;
|
|
79
|
+
const fallback = op === "delete" ? `DEL ${line}${HL_RANGE_SEP}M` : `SWAP ${line}${HL_RANGE_SEP}M:`;
|
|
80
|
+
let message =
|
|
81
|
+
`\`${phrase}\` could not resolve a syntactic block beginning on line ${line} ` +
|
|
82
|
+
`(unsupported language, blank/closer line, or parse error). Use \`${fallback}\` with explicit lines.`;
|
|
83
|
+
if (fileLines) {
|
|
84
|
+
const context = formatAnchoredContext([line], fileLines);
|
|
85
|
+
if (context.length > 0) message += `\n\n${context.join("\n")}`;
|
|
86
|
+
}
|
|
87
|
+
return message;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Block-anchored edit reached a path with no {@link BlockResolver} wired in — a host-configuration bug. */
|
|
91
|
+
export const BLOCK_RESOLVER_UNAVAILABLE =
|
|
92
|
+
"`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` are not available here (no block resolver configured). Use a concrete line range.";
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `insert_after_block N:` anchored on a closing-delimiter line, lowered to
|
|
96
|
+
* plain `insert after N:` — the closer ends a block, and inserting after it
|
|
97
|
+
* is exactly what the plain form does.
|
|
98
|
+
*/
|
|
99
|
+
export function insertAfterBlockCloserLoweredWarning(line: number): string {
|
|
100
|
+
return `\`INS.BLK.POST ${line}:\` anchors on a closing delimiter, so it was applied as plain \`INS.POST ${line}:\`. Anchor on the line that OPENS the construct.`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `insert_after_block N:` anchor unresolvable (unsupported language, blank
|
|
105
|
+
* line, parse error, or no resolver), lowered to plain `insert after N:` —
|
|
106
|
+
* applying with a warning beats failing the patch.
|
|
107
|
+
*/
|
|
108
|
+
export function insertAfterBlockUnresolvedLoweredWarning(line: number): string {
|
|
109
|
+
return `\`INS.BLK.POST ${line}:\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`INS.POST ${line}:\`. Verify the landing line; anchor on a line that OPENS a construct.`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Internal invariant: `applyEdits` received an unresolved `replace_block N:`
|
|
114
|
+
* edit; `resolveBlockEdits` must run first. Wiring bug, not authored input.
|
|
115
|
+
*/
|
|
116
|
+
export const UNRESOLVED_BLOCK_INTERNAL =
|
|
117
|
+
"internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run).";
|
|
118
|
+
|
|
119
|
+
/** Delete hunk received a body row. */
|
|
120
|
+
export const DELETE_TAKES_NO_BODY = `\`DEL N${HL_RANGE_SEP}M\` does not take body rows. Remove the body, or use \`SWAP N${HL_RANGE_SEP}M:\`.`;
|
|
121
|
+
|
|
122
|
+
/** `REM` received a body row or coexists with line edits. */
|
|
123
|
+
export const REM_TAKES_NO_BODY =
|
|
124
|
+
"`REM` deletes the whole file and takes no body rows or line ops. Issue it alone under the header.";
|
|
125
|
+
|
|
126
|
+
/** `MV` received a body row. */
|
|
127
|
+
export const MOVE_TAKES_NO_BODY =
|
|
128
|
+
"`MV DEST` does not take body rows. Put line edits above the `MV` row; the destination path follows `MV` on the same line.";
|
|
129
|
+
|
|
130
|
+
/** `delete_block N` hunk received a body row. */
|
|
131
|
+
export const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
|
|
132
|
+
|
|
133
|
+
/** Insert hunk with no body. */
|
|
134
|
+
export const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row.";
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `insert after` body indented shallower than the anchor: the landing slid
|
|
138
|
+
* forward past trailing closer lines — the common "anchored on the last line
|
|
139
|
+
* I read instead of after the block" mistake.
|
|
140
|
+
*/
|
|
141
|
+
export function afterInsertLandingShiftWarning(anchorLine: number, landingLine: number, crossed: number): string {
|
|
142
|
+
return `INS.POST ${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.`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `insert_after_block N:` body indented deeper than the block's closer: the
|
|
147
|
+
* landing was pulled inside the block — a deeper body almost always means
|
|
148
|
+
* "append inside the block's body".
|
|
149
|
+
*/
|
|
150
|
+
export function blockInsertLandingShiftWarning(blockStart: number, closerLine: number, landingLine: number): string {
|
|
151
|
+
return `INS.BLK.POST ${blockStart}: body indented deeper than closing line ${closerLine}, so it was placed inside the block, after line ${landingLine}. \`INS.BLK.POST\` lands AFTER the block at sibling depth — if inside was intended, use plain \`INS.POST ${closerLine}:\`.`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `Recovery`: an external write matched a cached snapshot. */
|
|
155
|
+
export const RECOVERY_EXTERNAL_WARNING =
|
|
156
|
+
"Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
|
|
157
|
+
|
|
158
|
+
/** `Recovery`: a prior in-session edit advanced the hash. */
|
|
159
|
+
export const RECOVERY_SESSION_CHAIN_WARNING =
|
|
160
|
+
"Recovered from a stale file hash using an earlier in-session snapshot (a prior edit in this session advanced the hash).";
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `Recovery`: session-chain replay fast-path. Less certain than
|
|
164
|
+
* {@link RECOVERY_SESSION_CHAIN_WARNING} — the 3-way merge refused, the
|
|
165
|
+
* anchor-content gate passed, but a coincidental insert+delete earlier in
|
|
166
|
+
* the chain could still misplace an anchor — hence the verify hedge.
|
|
167
|
+
*/
|
|
168
|
+
export const RECOVERY_SESSION_REPLAY_WARNING =
|
|
169
|
+
"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.";
|
|
170
|
+
|
|
171
|
+
/** `Recovery`: stale anchors were relocated to unchanged live lines after drift. */
|
|
172
|
+
export const RECOVERY_LINE_REMAP_WARNING =
|
|
173
|
+
"Recovered by remapping stale line anchors to unchanged current lines (file changed since the tagged read). Verify the diff matches your intent.";
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* `insert head:`/`insert tail:` applied despite a stale snapshot tag.
|
|
177
|
+
* Head/tail position is content-independent, so drift is non-fatal: apply
|
|
178
|
+
* onto live content and warn instead of hard-failing.
|
|
179
|
+
*/
|
|
180
|
+
export const HEADTAIL_DRIFT_WARNING =
|
|
181
|
+
"Applied the `INS.HEAD:`/`INS.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.";
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Section omitted the mandatory snapshot tag. Shared by the apply
|
|
185
|
+
* ({@link Patcher.prepare}) and preview/diff paths so both stay in lockstep.
|
|
186
|
+
*/
|
|
187
|
+
export function missingSnapshotTagMessage(sectionPath: string): string {
|
|
188
|
+
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.`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A section named a path that does not exist, but its filename and snapshot
|
|
193
|
+
* tag together match exactly one file read earlier this session — the model
|
|
194
|
+
* gave the bare filename (or wrong directory) for a file it just read. The
|
|
195
|
+
* edit was rebound to that file's full path. Surfaced as a warning so the
|
|
196
|
+
* model (and user) learn the corrected path and stop reusing the wrong one.
|
|
197
|
+
*/
|
|
198
|
+
export function pathRecoveredFromTagMessage(authoredPath: string, resolvedPath: string, tag: string): string {
|
|
199
|
+
return (
|
|
200
|
+
`Path "${authoredPath}" does not exist; matched its filename and snapshot tag ` +
|
|
201
|
+
`${HL_FILE_HASH_SEP}${tag} to ${resolvedPath} (read earlier this session). Anchor future edits on ` +
|
|
202
|
+
`${HL_FILE_PREFIX}${resolvedPath}${HL_FILE_HASH_SEP}TAG${HL_FILE_SUFFIX}.`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Compress a line list into a sorted `1-4, 7, 10-12` range string. */
|
|
207
|
+
function formatLineRanges(lines: readonly number[]): string {
|
|
208
|
+
const sorted = [...new Set(lines)].sort((a, b) => a - b);
|
|
209
|
+
if (sorted.length === 0) return "";
|
|
210
|
+
const parts: string[] = [];
|
|
211
|
+
let start = sorted[0];
|
|
212
|
+
let prev = sorted[0];
|
|
213
|
+
for (let i = 1; i <= sorted.length; i++) {
|
|
214
|
+
const current = sorted[i];
|
|
215
|
+
if (current === prev + 1) {
|
|
216
|
+
prev = current;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
parts.push(start === prev ? `${start}` : `${start}-${prev}`);
|
|
220
|
+
start = current;
|
|
221
|
+
prev = current;
|
|
222
|
+
}
|
|
223
|
+
return parts.join(", ");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* An anchored edit referenced lines the read that minted the cited tag never
|
|
228
|
+
* displayed (a partial range, or a structural summary that collapsed bodies).
|
|
229
|
+
* Editing lines you have not read is the off-by-memory failure that mangles
|
|
230
|
+
* files; reject and make the model re-read those exact lines first.
|
|
231
|
+
*/
|
|
232
|
+
export function unseenLinesMessage(sectionPath: string, unseenLines: readonly number[], tag: string): string {
|
|
233
|
+
const ranges = formatLineRanges(unseenLines);
|
|
234
|
+
const selector = ranges.replace(/, /g, ",");
|
|
235
|
+
return (
|
|
236
|
+
`This edit anchors to lines ${ranges} of ${sectionPath} that ` +
|
|
237
|
+
`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}${tag}${HL_FILE_SUFFIX} never displayed (it showed a ` +
|
|
238
|
+
`partial range, a search hit, or a folded summary). Re-read them in full first with a ranged read like ` +
|
|
239
|
+
`\`${sectionPath}:${selector}\` — it skips summarization and mints a fresh tag (a plain re-read just re-folds ` +
|
|
240
|
+
`them) — then re-issue the edit.`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Op kind of a deferred block edit, for {@link blockSingleLineMessage}. */
|
|
245
|
+
export type BlockOp = "replace" | "delete" | "insert_after";
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* A `replace_block`/`delete_block`/`insert_after_block` anchor resolved to a
|
|
249
|
+
* single line — almost always a bare statement the model mis-anchored, not a
|
|
250
|
+
* multi-line construct. The plain op is unambiguous for one line; the block
|
|
251
|
+
* form only earns its keep when it spares counting a closing line you cannot
|
|
252
|
+
* see. Reject and point at both fixes.
|
|
253
|
+
*/
|
|
254
|
+
export function blockSingleLineMessage(line: number, op: BlockOp): string {
|
|
255
|
+
const blockForm = op === "insert_after" ? "INS.BLK.POST" : op === "delete" ? "DEL.BLK" : "SWAP.BLK";
|
|
256
|
+
const plainForm =
|
|
257
|
+
op === "insert_after"
|
|
258
|
+
? `INS.POST ${line}:`
|
|
259
|
+
: op === "delete"
|
|
260
|
+
? `DEL ${line}`
|
|
261
|
+
: `SWAP ${line}${HL_RANGE_SEP}${line}:`;
|
|
262
|
+
return (
|
|
263
|
+
`\`${blockForm} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` +
|
|
264
|
+
`of a multi-line construct. For that one line use \`${plainForm}\`; to act on an enclosing construct, anchor ${blockForm} ` +
|
|
265
|
+
`on the line that OPENS it (e.g. its \`function\`/\`if\`/\`case\` header), never a statement inside it.`
|
|
266
|
+
);
|
|
267
|
+
}
|
package/src/mismatch.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error type raised when a section's snapshot tag does not match the live file
|
|
3
|
+
* content and recovery is unavailable / has failed.
|
|
4
|
+
*
|
|
5
|
+
* Carries enough context to render a useful diagnostic: the anchored lines
|
|
6
|
+
* plus a couple of lines of surrounding context. The {@link MismatchError}
|
|
7
|
+
* formats this into a message at construction time.
|
|
8
|
+
*/
|
|
9
|
+
import { HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
|
|
10
|
+
import { formatAnchoredContext } from "./messages";
|
|
11
|
+
|
|
12
|
+
const LINE_REF_RE = /^\s*[>+\-*]*\s*(\d+)(?::.*)?\s*$/;
|
|
13
|
+
/** Format the required-shape diagnostic shown when a line reference is malformed. */
|
|
14
|
+
export function formatFullAnchorRequirement(raw?: string): string {
|
|
15
|
+
const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
|
|
16
|
+
return (
|
|
17
|
+
`a bare line number from read/search output plus the section header content-hash tag ` +
|
|
18
|
+
`(for example ${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]}${HL_FILE_SUFFIX} and line "160")${received}`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parse a decorated bare line-number anchor like `42`, `*42:foo`, ` > 7`. */
|
|
23
|
+
export function parseTag(ref: string): { line: number } {
|
|
24
|
+
const match = ref.match(LINE_REF_RE);
|
|
25
|
+
if (!match) {
|
|
26
|
+
throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`);
|
|
27
|
+
}
|
|
28
|
+
const line = Number.parseInt(match[1], 10);
|
|
29
|
+
if (line < 1) throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
|
|
30
|
+
return { line };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MismatchDetails {
|
|
34
|
+
path?: string;
|
|
35
|
+
expectedFileHash: string;
|
|
36
|
+
actualFileHash: string;
|
|
37
|
+
fileLines: string[];
|
|
38
|
+
anchorLines?: readonly number[];
|
|
39
|
+
/**
|
|
40
|
+
* `true` when the section's expected hash resolved to a recorded snapshot
|
|
41
|
+
* (file content drifted since that snapshot), `false` when no snapshot
|
|
42
|
+
* was ever recorded for the hash (likely fabricated or carried over from
|
|
43
|
+
* a prior session). Drives a more actionable rejection message; defaults
|
|
44
|
+
* to `true` for backward compatibility with direct callers.
|
|
45
|
+
*/
|
|
46
|
+
hashRecognized?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Raised when a hashline section's snapshot tag doesn't match the live file's
|
|
51
|
+
* content (and recovery, if configured, declined the merge). Carries the
|
|
52
|
+
* file lines plus anchored lines so renderers can produce a richer
|
|
53
|
+
* diagnostic via {@link MismatchError.displayMessage}.
|
|
54
|
+
*/
|
|
55
|
+
export class MismatchError extends Error {
|
|
56
|
+
readonly path: string | undefined;
|
|
57
|
+
readonly expectedFileHash: string;
|
|
58
|
+
readonly actualFileHash: string;
|
|
59
|
+
readonly fileLines: string[];
|
|
60
|
+
readonly anchorLines: readonly number[];
|
|
61
|
+
readonly hashRecognized: boolean;
|
|
62
|
+
|
|
63
|
+
constructor(details: MismatchDetails) {
|
|
64
|
+
super(MismatchError.formatMessage(details));
|
|
65
|
+
this.name = "MismatchError";
|
|
66
|
+
this.path = details.path;
|
|
67
|
+
this.expectedFileHash = details.expectedFileHash;
|
|
68
|
+
this.actualFileHash = details.actualFileHash;
|
|
69
|
+
this.fileLines = details.fileLines;
|
|
70
|
+
this.anchorLines = details.anchorLines ?? [];
|
|
71
|
+
this.hashRecognized = details.hashRecognized ?? true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get displayMessage(): string {
|
|
75
|
+
return MismatchError.formatDisplayMessage({
|
|
76
|
+
path: this.path,
|
|
77
|
+
expectedFileHash: this.expectedFileHash,
|
|
78
|
+
actualFileHash: this.actualFileHash,
|
|
79
|
+
fileLines: this.fileLines,
|
|
80
|
+
anchorLines: this.anchorLines,
|
|
81
|
+
hashRecognized: this.hashRecognized,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
static rejectionHeader(details: MismatchDetails): string[] {
|
|
86
|
+
const pathText = details.path ? ` for ${details.path}` : "";
|
|
87
|
+
const hashRecognized = details.hashRecognized ?? true;
|
|
88
|
+
if (!hashRecognized) {
|
|
89
|
+
return [
|
|
90
|
+
`Edit rejected${pathText}: hash ${HL_FILE_HASH_SEP}${details.expectedFileHash} is not from this session.`,
|
|
91
|
+
`The current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. Re-read the file with \`read\` to copy a current ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}tag${HL_FILE_SUFFIX} header — never invent the tag and never reuse one from a prior session.`,
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
return [
|
|
95
|
+
`Edit rejected${pathText}: file changed between read and edit.`,
|
|
96
|
+
`Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. If a prior edit in this session modified this file, copy the ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}newhash${HL_FILE_SUFFIX} header from that edit's response; otherwise re-read the file with \`read\` to refresh the tag before retrying.`,
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
static formatDisplayMessage(details: MismatchDetails): string {
|
|
101
|
+
return MismatchError.formatMessage(details);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
static formatMessage(details: MismatchDetails): string {
|
|
105
|
+
const lines = MismatchError.rejectionHeader(details);
|
|
106
|
+
const context = formatAnchoredContext(details.anchorLines ?? [], details.fileLines);
|
|
107
|
+
if (context.length === 0) return lines.join("\n");
|
|
108
|
+
lines.push("", ...context);
|
|
109
|
+
return lines.join("\n");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Throws when the line reference is out of bounds for the given file. */
|
|
114
|
+
export function validateLineRef(ref: { line: number }, fileLines: string[]): void {
|
|
115
|
+
if (ref.line < 1 || ref.line > fileLines.length) {
|
|
116
|
+
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal text-shape normalization: line-ending detection / round-trip and
|
|
3
|
+
* BOM stripping. The patcher uses these to canonicalize text to LF before
|
|
4
|
+
* applying edits and to restore the original shape on write-back.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type LineEnding = "\r\n" | "\n";
|
|
8
|
+
|
|
9
|
+
/** Detect the first line ending style in `content`. Defaults to LF when neither is present. */
|
|
10
|
+
export function detectLineEnding(content: string): LineEnding {
|
|
11
|
+
const crlfIdx = content.indexOf("\r\n");
|
|
12
|
+
const lfIdx = content.indexOf("\n");
|
|
13
|
+
if (lfIdx === -1) return "\n";
|
|
14
|
+
if (crlfIdx === -1) return "\n";
|
|
15
|
+
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Normalize every line ending to LF. */
|
|
19
|
+
export function normalizeToLF(text: string): string {
|
|
20
|
+
return text.replace(/\r\n?/g, "\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Re-encode LF text with the requested line ending. */
|
|
24
|
+
export function restoreLineEndings(text: string, ending: LineEnding): string {
|
|
25
|
+
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BomResult {
|
|
29
|
+
/** Either the empty string or the BOM sequence (currently UTF-8 BOM). */
|
|
30
|
+
bom: string;
|
|
31
|
+
/** Text with any leading BOM removed. */
|
|
32
|
+
text: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Strip a UTF-8 BOM if present and return both the BOM and the trailing text. */
|
|
36
|
+
export function stripBom(content: string): BomResult {
|
|
37
|
+
return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
|
|
38
|
+
}
|