@prometheus-ai/hashline 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -0
- package/dist/types/apply.d.ts +8 -0
- package/dist/types/block.d.ts +24 -0
- package/dist/types/diff-preview.d.ts +12 -0
- package/dist/types/format.d.ts +76 -0
- package/dist/types/fs.d.ts +80 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/input.d.ts +100 -0
- package/dist/types/messages.d.ts +85 -0
- package/dist/types/mismatch.d.ts +44 -0
- package/dist/types/normalize.d.ts +20 -0
- package/dist/types/parser.d.ts +23 -0
- package/dist/types/patcher.d.ts +109 -0
- package/dist/types/prefixes.d.ts +34 -0
- package/dist/types/recovery.d.ts +40 -0
- package/dist/types/snapshots.d.ts +55 -0
- package/dist/types/stream.d.ts +2 -0
- package/dist/types/tokenizer.d.ts +65 -0
- package/dist/types/types.d.ts +129 -0
- package/package.json +62 -0
- package/src/apply.ts +586 -0
- package/src/block.ts +84 -0
- package/src/diff-preview.ts +49 -0
- package/src/format.ts +134 -0
- package/src/fs.ts +167 -0
- package/src/grammar.lark +25 -0
- package/src/index.ts +17 -0
- package/src/input.ts +423 -0
- package/src/messages.ts +128 -0
- package/src/mismatch.ts +138 -0
- package/src/normalize.ts +38 -0
- package/src/parser.ts +325 -0
- package/src/patcher.ts +392 -0
- package/src/prefixes.ts +132 -0
- package/src/prompt.md +109 -0
- package/src/recovery.ts +186 -0
- package/src/snapshots.ts +128 -0
- package/src/stream.ts +132 -0
- package/src/tokenizer.ts +471 -0
- package/src/types.ts +132 -0
package/src/patcher.ts
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level patch orchestrator. Reads each section's target file via the
|
|
3
|
+
* configured {@link Filesystem}, strips BOM and normalizes line endings,
|
|
4
|
+
* validates the section snapshot tag (with {@link Recovery}), applies the
|
|
5
|
+
* result back through the same {@link Filesystem}.
|
|
6
|
+
*
|
|
7
|
+
* Two layers:
|
|
8
|
+
*
|
|
9
|
+
* - {@link Patcher.apply} — high-level, all-or-nothing. Preflights every
|
|
10
|
+
* section in memory before any write hits disk, then commits in order.
|
|
11
|
+
* - {@link Patcher.prepare} / {@link Patcher.commit} — granular primitives
|
|
12
|
+
* for callers that need per-section control (e.g. batched LSP flush,
|
|
13
|
+
* custom interleaving). `prepare` performs all the read-side work,
|
|
14
|
+
* validates the section snapshot tag (with recovery), and applies the
|
|
15
|
+
* edits in memory. `commit` writes the prepared result and records a
|
|
16
|
+
* fresh snapshot.
|
|
17
|
+
*
|
|
18
|
+
* Because `prepare` already runs the full apply, a multi-section batch is
|
|
19
|
+
* naturally all-or-nothing: by the time any `commit` runs, every section
|
|
20
|
+
* has been validated.
|
|
21
|
+
*
|
|
22
|
+
* The patcher itself is stateless across calls; reuse one instance per
|
|
23
|
+
* filesystem configuration.
|
|
24
|
+
*/
|
|
25
|
+
import { applyEdits } from "./apply";
|
|
26
|
+
import { hasBlockEdit, resolveBlockEdits } from "./block";
|
|
27
|
+
import { computeFileHash, formatHashlineHeader } from "./format";
|
|
28
|
+
import type { Filesystem, WriteResult } from "./fs";
|
|
29
|
+
import { isNotFound } from "./fs";
|
|
30
|
+
import type { Patch, PatchSection } from "./input";
|
|
31
|
+
import { HEADTAIL_DRIFT_WARNING, missingSnapshotTagMessage } from "./messages";
|
|
32
|
+
import { MismatchError } from "./mismatch";
|
|
33
|
+
import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
|
|
34
|
+
import { Recovery, type RecoveryResult } from "./recovery";
|
|
35
|
+
import type { SnapshotStore } from "./snapshots";
|
|
36
|
+
import type { ApplyResult, BlockResolver, Edit } from "./types";
|
|
37
|
+
|
|
38
|
+
export interface PatcherOptions {
|
|
39
|
+
/** Storage backend used for all reads and writes. */
|
|
40
|
+
fs: Filesystem;
|
|
41
|
+
/** Snapshot store that minted and resolves hashline section tags. Required. */
|
|
42
|
+
snapshots: SnapshotStore;
|
|
43
|
+
/**
|
|
44
|
+
* Resolves `replace block N:` anchors to concrete line spans via tree-sitter.
|
|
45
|
+
* Optional: when omitted, any `replace block N:` edit throws on apply (the
|
|
46
|
+
* host did not wire a resolver). Plain line-range ops never need it.
|
|
47
|
+
*/
|
|
48
|
+
blockResolver?: BlockResolver;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Per-section result returned by {@link Patcher.apply} / {@link Patcher.commit}. */
|
|
52
|
+
export interface PatchSectionResult {
|
|
53
|
+
/** Section path (as authored, after cwd-resolution at parse time). */
|
|
54
|
+
path: string;
|
|
55
|
+
/** Filesystem-canonical key for this section (e.g. absolute path). */
|
|
56
|
+
canonicalPath: string;
|
|
57
|
+
/** `"noop"` when the apply produced no change; otherwise `"create"` / `"update"`. */
|
|
58
|
+
op: "create" | "update" | "noop";
|
|
59
|
+
/** Pre-edit text (LF-normalized, BOM-stripped). */
|
|
60
|
+
before: string;
|
|
61
|
+
/** Post-edit text (LF-normalized, BOM-stripped). For `"noop"` equals `before`. */
|
|
62
|
+
after: string;
|
|
63
|
+
/** Same text as `after` but with the original BOM and line ending restored. */
|
|
64
|
+
persisted: string;
|
|
65
|
+
/** Final text that the {@link Filesystem} actually wrote (may differ if the FS transformed it). */
|
|
66
|
+
written: string;
|
|
67
|
+
/** 4-hex content-hash tag for `after`. Use to anchor follow-up edits. */
|
|
68
|
+
fileHash: string;
|
|
69
|
+
/** Hashline section header (`[path#tag]`) of the post-edit content. */
|
|
70
|
+
header: string;
|
|
71
|
+
/** 1-indexed first changed line in `after`, or `undefined` for noops. */
|
|
72
|
+
firstChangedLine?: number;
|
|
73
|
+
/** Warnings collected by the parser, applier, and (optionally) recovery. */
|
|
74
|
+
warnings: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PatcherApplyResult {
|
|
78
|
+
sections: PatchSectionResult[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Opaque token returned by {@link Patcher.prepare}. Carries the section, the
|
|
83
|
+
* raw file content read off disk, and the in-memory apply result.
|
|
84
|
+
* {@link Patcher.commit} just writes the {@link PreparedSection.applyResult}.
|
|
85
|
+
*/
|
|
86
|
+
export class PreparedSection {
|
|
87
|
+
/** @internal */
|
|
88
|
+
constructor(
|
|
89
|
+
readonly section: PatchSection,
|
|
90
|
+
readonly canonicalPath: string,
|
|
91
|
+
readonly exists: boolean,
|
|
92
|
+
readonly rawContent: string,
|
|
93
|
+
readonly bom: string,
|
|
94
|
+
readonly lineEnding: LineEnding,
|
|
95
|
+
readonly normalized: string,
|
|
96
|
+
readonly applyResult: ApplyResult,
|
|
97
|
+
readonly parseWarnings: readonly string[],
|
|
98
|
+
) {}
|
|
99
|
+
|
|
100
|
+
/** Convenience: returns true when the apply produced no change. */
|
|
101
|
+
get isNoop(): boolean {
|
|
102
|
+
return this.applyResult.text === this.normalized;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
|
|
107
|
+
return edits.some(edit => {
|
|
108
|
+
if (edit.kind === "delete") return true;
|
|
109
|
+
// A `replace block N:` edit anchors to concrete content on line N.
|
|
110
|
+
if (edit.kind === "block") return true;
|
|
111
|
+
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function assertSectionHashPresent(sectionPath: string, fileHash: string | undefined): void {
|
|
116
|
+
if (fileHash !== undefined) return;
|
|
117
|
+
throw new Error(missingSnapshotTagMessage(sectionPath));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function recoveryToApplyResult(result: RecoveryResult): ApplyResult {
|
|
121
|
+
return {
|
|
122
|
+
text: result.text,
|
|
123
|
+
firstChangedLine: result.firstChangedLine,
|
|
124
|
+
warnings: result.warnings,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function mergeWarnings(...sources: ReadonlyArray<readonly string[] | undefined>): string[] {
|
|
128
|
+
const out: string[] = [];
|
|
129
|
+
for (const source of sources) {
|
|
130
|
+
if (!source) continue;
|
|
131
|
+
for (const warning of source) out.push(warning);
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function assertUniqueCanonicalPaths(prepared: readonly PreparedSection[]): void {
|
|
137
|
+
const seen = new Map<string, string>();
|
|
138
|
+
for (const entry of prepared) {
|
|
139
|
+
const previous = seen.get(entry.canonicalPath);
|
|
140
|
+
if (previous !== undefined) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Multiple hashline sections resolve to the same file (${previous} and ${entry.section.path}). Merge their ops under one header before applying.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
seen.set(entry.canonicalPath, entry.section.path);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* High-level patcher. Wires a {@link Filesystem} and a required
|
|
151
|
+
* {@link SnapshotStore} together with the parsing + applying core.
|
|
152
|
+
*
|
|
153
|
+
* Construct once per FS configuration; reuse across patches.
|
|
154
|
+
*/
|
|
155
|
+
export class Patcher {
|
|
156
|
+
readonly fs: Filesystem;
|
|
157
|
+
readonly snapshots: SnapshotStore;
|
|
158
|
+
readonly recovery: Recovery;
|
|
159
|
+
readonly blockResolver: BlockResolver | undefined;
|
|
160
|
+
|
|
161
|
+
constructor(options: PatcherOptions) {
|
|
162
|
+
if (!options.snapshots) {
|
|
163
|
+
throw new Error("Hashline Patcher requires a SnapshotStore; section tags are opaque store pointers.");
|
|
164
|
+
}
|
|
165
|
+
this.fs = options.fs;
|
|
166
|
+
this.snapshots = options.snapshots;
|
|
167
|
+
this.recovery = new Recovery(options.snapshots);
|
|
168
|
+
this.blockResolver = options.blockResolver;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Apply every section in `patch`. `prepare` runs the full apply for each
|
|
173
|
+
* section in memory before any write hits the filesystem, so a
|
|
174
|
+
* multi-section batch is naturally all-or-nothing. Returns one
|
|
175
|
+
* {@link PatchSectionResult} per section in the original patch order.
|
|
176
|
+
*/
|
|
177
|
+
async apply(patch: Patch): Promise<PatcherApplyResult> {
|
|
178
|
+
// Single-section fast path.
|
|
179
|
+
if (patch.sections.length === 1) {
|
|
180
|
+
const prepared = await this.prepare(patch.sections[0]);
|
|
181
|
+
return { sections: [await this.commit(prepared)] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Prepare every section first so any failure (stale hash, missing
|
|
185
|
+
// file, parse error, in-memory no-op) surfaces before any write.
|
|
186
|
+
const prepared: PreparedSection[] = [];
|
|
187
|
+
for (const section of patch.sections) prepared.push(await this.prepare(section));
|
|
188
|
+
assertUniqueCanonicalPaths(prepared);
|
|
189
|
+
for (const entry of prepared) {
|
|
190
|
+
if (entry.isNoop) {
|
|
191
|
+
throw new Error(`Edits to ${entry.section.path} resulted in no changes being made.`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const results: PatchSectionResult[] = [];
|
|
196
|
+
for (const entry of prepared) results.push(await this.commit(entry));
|
|
197
|
+
return { sections: results };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Run the preflight pass only: read, parse, validate, apply-in-memory.
|
|
202
|
+
* No writes hit the filesystem. Use for CI checks and dry runs.
|
|
203
|
+
*/
|
|
204
|
+
async preflight(patch: Patch): Promise<void> {
|
|
205
|
+
const prepared: PreparedSection[] = [];
|
|
206
|
+
for (const section of patch.sections) prepared.push(await this.prepare(section));
|
|
207
|
+
assertUniqueCanonicalPaths(prepared);
|
|
208
|
+
for (const entry of prepared) {
|
|
209
|
+
if (entry.isNoop) {
|
|
210
|
+
throw new Error(`Edits to ${entry.section.path} resulted in no changes being made.`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Read a section's target file, parse the section, validate the snapshot
|
|
217
|
+
* tag (with recovery), and apply the edits in memory. Returns a
|
|
218
|
+
* {@link PreparedSection} which can be fed to {@link commit} to land
|
|
219
|
+
* the result on the filesystem.
|
|
220
|
+
*
|
|
221
|
+
* Throws on parse error, missing-file-for-anchored-edit, or unrecovered
|
|
222
|
+
* tag mismatch ({@link MismatchError}).
|
|
223
|
+
*/
|
|
224
|
+
async prepare(section: PatchSection): Promise<PreparedSection> {
|
|
225
|
+
const { edits, warnings: parseWarnings } = section.parse();
|
|
226
|
+
assertSectionHashPresent(section.path, section.fileHash);
|
|
227
|
+
|
|
228
|
+
const canonicalPath = this.fs.canonicalPath(section.path);
|
|
229
|
+
await this.fs.preflightWrite(section.path);
|
|
230
|
+
const { exists, rawContent } = await this.#tryRead(section.path);
|
|
231
|
+
if (!exists) {
|
|
232
|
+
throw new Error(`File not found: ${section.path}. Use the write tool to create new files.`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const { bom, text } = stripBom(rawContent);
|
|
236
|
+
const lineEnding = detectLineEnding(text);
|
|
237
|
+
const normalized = normalizeToLF(text);
|
|
238
|
+
|
|
239
|
+
const applyResult = this.#applyWithRecovery({
|
|
240
|
+
section,
|
|
241
|
+
canonicalPath,
|
|
242
|
+
exists,
|
|
243
|
+
normalized,
|
|
244
|
+
edits,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return new PreparedSection(
|
|
248
|
+
section,
|
|
249
|
+
canonicalPath,
|
|
250
|
+
exists,
|
|
251
|
+
rawContent,
|
|
252
|
+
bom,
|
|
253
|
+
lineEnding,
|
|
254
|
+
normalized,
|
|
255
|
+
applyResult,
|
|
256
|
+
parseWarnings,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Commit a previously {@link prepare}d section to the filesystem.
|
|
262
|
+
* Restores line endings and BOM, writes via the {@link Filesystem}, and
|
|
263
|
+
* records a fresh snapshot in the {@link SnapshotStore} keyed by the
|
|
264
|
+
* filesystem-canonical path.
|
|
265
|
+
*/
|
|
266
|
+
async commit(prepared: PreparedSection): Promise<PatchSectionResult> {
|
|
267
|
+
const { section, normalized, bom, lineEnding, parseWarnings, exists, applyResult, canonicalPath } = prepared;
|
|
268
|
+
const after = applyResult.text;
|
|
269
|
+
const warnings = mergeWarnings(parseWarnings, applyResult.warnings);
|
|
270
|
+
|
|
271
|
+
if (after === normalized) {
|
|
272
|
+
const hash = this.#recordFullSnapshot(canonicalPath, normalized);
|
|
273
|
+
return {
|
|
274
|
+
path: section.path,
|
|
275
|
+
canonicalPath,
|
|
276
|
+
op: "noop",
|
|
277
|
+
before: normalized,
|
|
278
|
+
after: normalized,
|
|
279
|
+
persisted: prepared.rawContent,
|
|
280
|
+
written: prepared.rawContent,
|
|
281
|
+
fileHash: hash,
|
|
282
|
+
header: formatHashlineHeader(section.path, hash),
|
|
283
|
+
warnings,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const persisted = bom + restoreLineEndings(after, lineEnding);
|
|
288
|
+
const write: WriteResult = await this.fs.writeText(section.path, persisted);
|
|
289
|
+
const fileHash = this.#recordFullSnapshot(canonicalPath, after);
|
|
290
|
+
const op = exists ? "update" : "create";
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
path: section.path,
|
|
294
|
+
canonicalPath,
|
|
295
|
+
op,
|
|
296
|
+
before: normalized,
|
|
297
|
+
after,
|
|
298
|
+
persisted,
|
|
299
|
+
written: write.text,
|
|
300
|
+
fileHash,
|
|
301
|
+
header: formatHashlineHeader(section.path, fileHash),
|
|
302
|
+
firstChangedLine: applyResult.firstChangedLine,
|
|
303
|
+
warnings,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async #tryRead(path: string): Promise<{ exists: boolean; rawContent: string }> {
|
|
308
|
+
try {
|
|
309
|
+
const content = await this.fs.readText(path);
|
|
310
|
+
return { exists: true, rawContent: content };
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (isNotFound(error)) return { exists: false, rawContent: "" };
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#recordFullSnapshot(canonicalPath: string, normalized: string): string {
|
|
318
|
+
return this.snapshots.record(canonicalPath, normalized);
|
|
319
|
+
}
|
|
320
|
+
#mismatchError(
|
|
321
|
+
section: PatchSection,
|
|
322
|
+
canonicalPath: string,
|
|
323
|
+
normalized: string,
|
|
324
|
+
expected: string,
|
|
325
|
+
hashRecognized: boolean,
|
|
326
|
+
): MismatchError {
|
|
327
|
+
const actualFileHash = this.#recordFullSnapshot(canonicalPath, normalized);
|
|
328
|
+
return new MismatchError({
|
|
329
|
+
path: section.path,
|
|
330
|
+
expectedFileHash: expected,
|
|
331
|
+
actualFileHash,
|
|
332
|
+
fileLines: normalized.split("\n"),
|
|
333
|
+
anchorLines: section.collectAnchorLines(),
|
|
334
|
+
hashRecognized,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
#applyWithRecovery(args: {
|
|
339
|
+
section: PatchSection;
|
|
340
|
+
canonicalPath: string;
|
|
341
|
+
exists: boolean;
|
|
342
|
+
normalized: string;
|
|
343
|
+
edits: readonly Edit[];
|
|
344
|
+
}): ApplyResult {
|
|
345
|
+
const { section, canonicalPath, exists, normalized, edits } = args;
|
|
346
|
+
const expected = exists ? section.fileHash : undefined;
|
|
347
|
+
const liveMatches = expected !== undefined && computeFileHash(normalized) === expected;
|
|
348
|
+
|
|
349
|
+
// Resolve `replace block N:` edits to concrete ranges before recovery
|
|
350
|
+
// runs. Block anchors are expressed against the snapshot the section tag
|
|
351
|
+
// names, so resolve against that exact text:
|
|
352
|
+
// - live content matches the tag (or there is no tag) → resolve against
|
|
353
|
+
// the live, normalized content;
|
|
354
|
+
// - the file drifted → resolve against the tagged snapshot's text so the
|
|
355
|
+
// resulting ranges flow through the 3-way-merge recovery below.
|
|
356
|
+
// When a block edit needs the tagged snapshot but it is unavailable, the
|
|
357
|
+
// range cannot be placed safely — reject with a MismatchError (re-read).
|
|
358
|
+
let resolved: readonly Edit[] = edits;
|
|
359
|
+
if (hasBlockEdit(edits)) {
|
|
360
|
+
const baseText =
|
|
361
|
+
expected === undefined || liveMatches ? normalized : this.snapshots.byHash(canonicalPath, expected)?.text;
|
|
362
|
+
if (baseText === undefined) {
|
|
363
|
+
throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", false);
|
|
364
|
+
}
|
|
365
|
+
resolved = resolveBlockEdits(edits, baseText, section.path, this.blockResolver, { onUnresolved: "throw" });
|
|
366
|
+
}
|
|
367
|
+
|
|
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);
|
|
372
|
+
// Head/tail-only inserts are position-stable: "start"/"end" cannot move
|
|
373
|
+
// with content drift, so a stale tag is non-fatal. Apply onto the live
|
|
374
|
+
// content and warn instead of hard-failing — unlike an anchored
|
|
375
|
+
// mismatch, which cannot be safely relocated and must reject.
|
|
376
|
+
if (!hasAnchorScopedEdit(resolved)) {
|
|
377
|
+
const result = applyEdits(normalized, resolved);
|
|
378
|
+
return { ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] };
|
|
379
|
+
}
|
|
380
|
+
// File drifted: try to replay the edit against the version the tag
|
|
381
|
+
// names and 3-way-merge it onto the live content.
|
|
382
|
+
const recovered = this.recovery.tryRecover({
|
|
383
|
+
path: canonicalPath,
|
|
384
|
+
currentText: normalized,
|
|
385
|
+
fileHash: expected,
|
|
386
|
+
edits: resolved,
|
|
387
|
+
});
|
|
388
|
+
if (recovered) return recoveryToApplyResult(recovered);
|
|
389
|
+
const hashRecognized = this.snapshots.byHash(canonicalPath, expected) !== null;
|
|
390
|
+
throw this.#mismatchError(section, canonicalPath, normalized, expected, hashRecognized);
|
|
391
|
+
}
|
|
392
|
+
}
|
package/src/prefixes.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a hashline payload is authored against `read`/`search` output, each
|
|
3
|
+
* line is prefixed with either a hashline-mode line number (`123:`) or, for
|
|
4
|
+
* diff-style echoes, a leading `+`. These helpers detect that and recover
|
|
5
|
+
* the raw text. Two strip modes are exposed:
|
|
6
|
+
*
|
|
7
|
+
* - {@link stripNewLinePrefixes} — opportunistic: strips when the input
|
|
8
|
+
* clearly carries hashline or diff prefixes, leaves it alone otherwise.
|
|
9
|
+
* - {@link stripHashlinePrefixes} — strict: only strips when every non-empty
|
|
10
|
+
* content line is hashline-prefixed.
|
|
11
|
+
*
|
|
12
|
+
* These run *before* the tokenizer; they exist because hashline mode is the
|
|
13
|
+
* common case for echoed file content, and erroneously echoed prefixes will
|
|
14
|
+
* otherwise turn every content line into a (malformed) op.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { HL_FILE_HASH_LENGTH } from "./format";
|
|
18
|
+
|
|
19
|
+
const HL_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:[+*-]\s*)?\d+:/;
|
|
20
|
+
const HL_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*\d+:/;
|
|
21
|
+
const HL_HEADER_RE = new RegExp(`^\\s*\\[[^#\\r\\n]+#[0-9a-fA-F]{${HL_FILE_HASH_LENGTH}}\\]\\s*$`);
|
|
22
|
+
const DIFF_PLUS_RE = /^[+](?![+])/;
|
|
23
|
+
const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/;
|
|
24
|
+
|
|
25
|
+
function stripLeadingHashlinePrefixes(line: string): string {
|
|
26
|
+
let result = line;
|
|
27
|
+
let previous: string;
|
|
28
|
+
do {
|
|
29
|
+
previous = result;
|
|
30
|
+
result = result.replace(HL_PREFIX_RE, "");
|
|
31
|
+
} while (result !== previous);
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface LinePrefixStats {
|
|
36
|
+
nonEmpty: number;
|
|
37
|
+
headerCount: number;
|
|
38
|
+
hashPrefixCount: number;
|
|
39
|
+
diffPlusHashPrefixCount: number;
|
|
40
|
+
diffPlusCount: number;
|
|
41
|
+
truncationNoticeCount: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function collectLinePrefixStats(lines: string[]): LinePrefixStats {
|
|
45
|
+
const stats: LinePrefixStats = {
|
|
46
|
+
nonEmpty: 0,
|
|
47
|
+
headerCount: 0,
|
|
48
|
+
hashPrefixCount: 0,
|
|
49
|
+
diffPlusHashPrefixCount: 0,
|
|
50
|
+
diffPlusCount: 0,
|
|
51
|
+
truncationNoticeCount: 0,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
if (line.length === 0) continue;
|
|
56
|
+
if (READ_TRUNCATION_NOTICE_RE.test(line)) {
|
|
57
|
+
stats.truncationNoticeCount++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (HL_HEADER_RE.test(line)) {
|
|
61
|
+
stats.nonEmpty++;
|
|
62
|
+
stats.headerCount++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
stats.nonEmpty++;
|
|
66
|
+
if (HL_PREFIX_RE.test(line)) stats.hashPrefixCount++;
|
|
67
|
+
if (HL_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
|
|
68
|
+
if (DIFF_PLUS_RE.test(line)) stats.diffPlusCount++;
|
|
69
|
+
}
|
|
70
|
+
return stats;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Strip whichever prefix scheme the lines appear to be carrying:
|
|
75
|
+
* - hashline line-number prefixes (`123:`) when every content line has one
|
|
76
|
+
* - leading `+` (diff style) when at least half the lines have one
|
|
77
|
+
* - mixed `+<n>:` form when present
|
|
78
|
+
*
|
|
79
|
+
* Returns the lines untouched if no scheme is recognized.
|
|
80
|
+
*/
|
|
81
|
+
export function stripNewLinePrefixes(lines: string[]): string[] {
|
|
82
|
+
const stats = collectLinePrefixStats(lines);
|
|
83
|
+
if (stats.nonEmpty === 0) return lines;
|
|
84
|
+
|
|
85
|
+
const contentLineCount = stats.nonEmpty - stats.headerCount;
|
|
86
|
+
const stripHash = contentLineCount > 0 && stats.hashPrefixCount === contentLineCount;
|
|
87
|
+
const stripPlus =
|
|
88
|
+
!stripHash &&
|
|
89
|
+
stats.diffPlusHashPrefixCount === 0 &&
|
|
90
|
+
stats.diffPlusCount > 0 &&
|
|
91
|
+
stats.diffPlusCount >= stats.nonEmpty * 0.5;
|
|
92
|
+
|
|
93
|
+
if (!stripHash && !stripPlus && stats.diffPlusHashPrefixCount === 0) return lines;
|
|
94
|
+
|
|
95
|
+
return lines
|
|
96
|
+
.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line) && !(stripHash && HL_HEADER_RE.test(line)))
|
|
97
|
+
.map(line => {
|
|
98
|
+
if (stripHash) return stripLeadingHashlinePrefixes(line);
|
|
99
|
+
if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
|
|
100
|
+
if (stats.diffPlusHashPrefixCount > 0 && HL_PREFIX_PLUS_RE.test(line)) {
|
|
101
|
+
return line.replace(HL_PREFIX_RE, "");
|
|
102
|
+
}
|
|
103
|
+
return line;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Strict variant: strip hashline prefixes only when every content line is
|
|
109
|
+
* hashline-prefixed. Returns the lines unchanged otherwise.
|
|
110
|
+
*/
|
|
111
|
+
export function stripHashlinePrefixes(lines: string[]): string[] {
|
|
112
|
+
const stats = collectLinePrefixStats(lines);
|
|
113
|
+
if (stats.nonEmpty === 0) return lines;
|
|
114
|
+
const contentLineCount = stats.nonEmpty - stats.headerCount;
|
|
115
|
+
if (contentLineCount === 0 || stats.hashPrefixCount !== contentLineCount) return lines;
|
|
116
|
+
return lines
|
|
117
|
+
.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line) && !HL_HEADER_RE.test(line))
|
|
118
|
+
.map(line => stripLeadingHashlinePrefixes(line));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Normalize line payloads by stripping read/search line prefixes. `null` /
|
|
123
|
+
* `undefined` yield `[]`; a single multiline string is split on `\n`.
|
|
124
|
+
*/
|
|
125
|
+
export function hashlineParseText(edit: string[] | string | null | undefined): string[] {
|
|
126
|
+
if (edit == null) return [];
|
|
127
|
+
if (typeof edit === "string") {
|
|
128
|
+
const trimmed = edit.endsWith("\n") ? edit.slice(0, -1) : edit;
|
|
129
|
+
edit = trimmed.replaceAll("\r", "").split("\n");
|
|
130
|
+
}
|
|
131
|
+
return stripNewLinePrefixes(edit);
|
|
132
|
+
}
|
package/src/prompt.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `delete` has no body.
|
|
2
|
+
|
|
3
|
+
<headers>
|
|
4
|
+
Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag from your latest `read`/`search`, and is REQUIRED on every section — there is no hashless form. To create a new file, use the `write` tool; hashline only edits files that already exist.
|
|
5
|
+
</headers>
|
|
6
|
+
|
|
7
|
+
<ops>
|
|
8
|
+
replace N..M: replace original lines N..M with the body rows below.
|
|
9
|
+
replace block N: replace the whole syntactic block that BEGINS on line N — its header line through its closing line — resolved with tree-sitter. Body rows below. Point N at the line that OPENS the construct (the `if`/`function`/`def`/`{`-bearing line), not a closing `}` or a blank line.
|
|
10
|
+
delete N..M delete original lines N..M. No body.
|
|
11
|
+
delete block N delete the whole syntactic block that BEGINS on line N.
|
|
12
|
+
insert before N: insert the body rows immediately before line N.
|
|
13
|
+
insert after N: insert the body rows immediately after line N.
|
|
14
|
+
insert head: insert the body rows at the very start of the file.
|
|
15
|
+
insert tail: insert the body rows at the very end of the file.
|
|
16
|
+
Single line: `replace N..N:` / `delete N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `replace N..N:`).
|
|
17
|
+
</ops>
|
|
18
|
+
|
|
19
|
+
<body-rows>
|
|
20
|
+
Body rows appear only under a `:` header. Every body row is:
|
|
21
|
+
+TEXT add a new literal line `TEXT`, verbatim (leading whitespace kept). `+` alone adds a blank line.
|
|
22
|
+
There is NO other body row kind. NEVER write `-old` or a bare/context line. To keep a line, leave it out of every range. To insert a literal line starting with `-` or `+`, prefix it: `+-x`, `++x`.
|
|
23
|
+
</body-rows>
|
|
24
|
+
|
|
25
|
+
<rules>
|
|
26
|
+
- Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the `[PATH#TAG]` header; use the bare LINE numbers.
|
|
27
|
+
- Numbers refer to the ORIGINAL file and stay valid for the whole patch — they do not shift as hunks apply.
|
|
28
|
+
- Across calls they do NOT survive: each applied edit mints a fresh `#TAG` and renumbers the file, so the tag and line numbers you just used are dead. Anchor the next edit on the `[PATH#TAG]` and lines from the edit response (or re-`read`), never on pre-edit numbers.
|
|
29
|
+
- A line number is an offset, not a structural boundary: never `insert after N` into a construct you have not read, and never start or end a `replace`/`delete` range mid-expression or mid-block. If unsure what is on those lines, `read` them first.
|
|
30
|
+
- On a stale-tag rejection — or any result you cannot fully account for — STOP and re-`read`. Never stack more line-numbered edits onto output you have not re-grounded; that compounds corruption.
|
|
31
|
+
- One hunk per range; the body is the final content, never an old/new pair.
|
|
32
|
+
- Keep every range as tight as the change: a range must cover ONLY lines whose content actually changes. Never widen it to swallow an unchanged signature, brace, or neighboring statement just to rewrite a few lines inside — change one line with `replace N..N`, not the whole block around it. (A range where every line genuinely changes is correctly long; tightness is about excluding unchanged lines, not about being short.) This bounds the blast radius if a number is off: a stale single-line replace corrupts one line, while a stale block replace shreds the whole block and its structure.
|
|
33
|
+
- To change lines 2 and 5 while keeping 3–4, issue two hunks (`replace 2..2:` and `replace 5..5:`). Untouched lines are simply absent from every range.
|
|
34
|
+
- NEVER use this tool to format code — reordering imports, re-indenting, aligning columns, or any mechanical restyling. That is the project formatter's job; run it instead of hand-editing layout here.
|
|
35
|
+
</rules>
|
|
36
|
+
|
|
37
|
+
<example>
|
|
38
|
+
Original (the exact shape `read` returns):
|
|
39
|
+
```
|
|
40
|
+
[greet.py#A1B2]
|
|
41
|
+
1:def greet(name):
|
|
42
|
+
2: msg = "Hello, " + name
|
|
43
|
+
3: print(msg)
|
|
44
|
+
4:greet("world")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Insert a guard after line 1:
|
|
48
|
+
```
|
|
49
|
+
[greet.py#A1B2]
|
|
50
|
+
insert after 1:
|
|
51
|
+
+ if not name: name = "stranger"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Replace line 2 with two lines:
|
|
55
|
+
```
|
|
56
|
+
[greet.py#A1B2]
|
|
57
|
+
replace 2..2:
|
|
58
|
+
+ greeting = "Hi"
|
|
59
|
+
+ msg = f"{greeting}, {name}"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Delete line 3:
|
|
63
|
+
```
|
|
64
|
+
[greet.py#A1B2]
|
|
65
|
+
delete 3
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Add a header and trailer:
|
|
69
|
+
```
|
|
70
|
+
[greet.py#A1B2]
|
|
71
|
+
insert head:
|
|
72
|
+
+# generated header
|
|
73
|
+
insert tail:
|
|
74
|
+
+greet("everyone")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Replace the whole `greet` function block — `replace block 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays:
|
|
78
|
+
```
|
|
79
|
+
[greet.py#A1B2]
|
|
80
|
+
replace block 1:
|
|
81
|
+
+def greet(name):
|
|
82
|
+
+ print(f"Hello, {name}")
|
|
83
|
+
```
|
|
84
|
+
</example>
|
|
85
|
+
|
|
86
|
+
<anti-patterns>
|
|
87
|
+
# WRONG — empty `replace` to delete. RIGHT: delete 4
|
|
88
|
+
replace 4..4:
|
|
89
|
+
|
|
90
|
+
# WRONG — range describes post-edit size. RIGHT: replace 1..1: (body length is irrelevant)
|
|
91
|
+
replace 1..2:
|
|
92
|
+
+def greet(name):
|
|
93
|
+
|
|
94
|
+
# WRONG — `-` rows / bare context lines do not exist. The range deletes; the body is only the new content.
|
|
95
|
+
replace 3..3:
|
|
96
|
+
msg = "Hello, " + name
|
|
97
|
+
- print(msg)
|
|
98
|
+
+ return msg
|
|
99
|
+
# RIGHT
|
|
100
|
+
replace 3..3:
|
|
101
|
+
+ return msg
|
|
102
|
+
</anti-patterns>
|
|
103
|
+
|
|
104
|
+
<critical>
|
|
105
|
+
If you remember nothing else:
|
|
106
|
+
1. RE-GROUND AFTER EVERY EDIT. Each applied edit mints a fresh `#TAG` and renumbers the file — the tag and line numbers you just used are now dead. Take the next edit's numbers from the edit response or a fresh `read`, never from pre-edit memory. On a stale-tag rejection or any unexpected result, STOP and re-`read`.
|
|
107
|
+
2. RANGES ARE TIGHT AND IN-BOUNDS. Cover only lines whose content actually changes; never widen a range to swallow an unchanged signature, brace, or statement, and never start or end a range mid-expression or mid-block. A stale single-line replace corrupts one line; a stale block replace shreds the whole block.
|
|
108
|
+
3. THE BODY IS THE FINAL CONTENT. Only `+TEXT` rows under a `:` header — never `-old`/bare context lines, never an old/new pair. The range does the deleting.
|
|
109
|
+
</critical>
|