@oh-my-pi/hashline 16.1.17 → 16.1.19
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/dist/types/fs.d.ts +12 -0
- package/dist/types/input.d.ts +7 -0
- package/dist/types/messages.d.ts +8 -0
- package/dist/types/snapshots.d.ts +11 -0
- package/package.json +1 -1
- package/src/fs.ts +15 -0
- package/src/input.ts +16 -0
- package/src/messages.ts +15 -0
- package/src/patcher.ts +70 -14
- package/src/snapshots.ts +23 -0
package/dist/types/fs.d.ts
CHANGED
|
@@ -45,6 +45,18 @@ export declare abstract class Filesystem {
|
|
|
45
45
|
* snapshots agree on the key without each having to redo the resolution.
|
|
46
46
|
*/
|
|
47
47
|
canonicalPath(path: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Whether a section whose authored path is missing may be redirected to
|
|
50
|
+
* the file its snapshot tag names (tag-based path recovery in
|
|
51
|
+
* {@link Patcher.prepare}). `resolvedPath` is the canonical path the
|
|
52
|
+
* redirect would read and write. Default: allow.
|
|
53
|
+
*
|
|
54
|
+
* Hosts that grant write privileges by path shape override this to refuse
|
|
55
|
+
* redirects that could escalate beyond what the caller approved — e.g. an
|
|
56
|
+
* internal-URL authored target (approved read-only), or a `resolvedPath`
|
|
57
|
+
* outside the working tree (a sandbox/vault/out-of-tree write).
|
|
58
|
+
*/
|
|
59
|
+
allowTagPathRecovery(_authoredPath: string, _resolvedPath: string): boolean;
|
|
48
60
|
}
|
|
49
61
|
/**
|
|
50
62
|
* In-memory {@link Filesystem}. Useful for tests, sandboxes, dry-runs, and as
|
package/dist/types/input.d.ts
CHANGED
|
@@ -66,6 +66,13 @@ export declare class PatchSection {
|
|
|
66
66
|
* throw mid-stream.
|
|
67
67
|
*/
|
|
68
68
|
applyPartialTo(text: string, blockResolver?: BlockResolver): ApplyResult;
|
|
69
|
+
/**
|
|
70
|
+
* A copy of this section rebound to a different target `path`, preserving
|
|
71
|
+
* the snapshot tag, diff body, and any cached parse result. Used by the
|
|
72
|
+
* patcher's tag-based path recovery to redirect an edit whose authored
|
|
73
|
+
* path does not exist onto the file its snapshot tag actually names.
|
|
74
|
+
*/
|
|
75
|
+
withPath(path: string): PatchSection;
|
|
69
76
|
}
|
|
70
77
|
/**
|
|
71
78
|
* A parsed hashline patch — zero or more {@link PatchSection}s, each rooted
|
package/dist/types/messages.d.ts
CHANGED
|
@@ -94,6 +94,14 @@ export declare const HEADTAIL_DRIFT_WARNING = "Applied the `INS.HEAD:`/`INS.TAIL
|
|
|
94
94
|
* ({@link Patcher.prepare}) and preview/diff paths so both stay in lockstep.
|
|
95
95
|
*/
|
|
96
96
|
export declare function missingSnapshotTagMessage(sectionPath: string): string;
|
|
97
|
+
/**
|
|
98
|
+
* A section named a path that does not exist, but its filename and snapshot
|
|
99
|
+
* tag together match exactly one file read earlier this session — the model
|
|
100
|
+
* gave the bare filename (or wrong directory) for a file it just read. The
|
|
101
|
+
* edit was rebound to that file's full path. Surfaced as a warning so the
|
|
102
|
+
* model (and user) learn the corrected path and stop reusing the wrong one.
|
|
103
|
+
*/
|
|
104
|
+
export declare function pathRecoveredFromTagMessage(authoredPath: string, resolvedPath: string, tag: string): string;
|
|
97
105
|
/**
|
|
98
106
|
* An anchored edit referenced lines the read that minted the cited tag never
|
|
99
107
|
* displayed (a partial range, or a structural summary that collapsed bodies).
|
|
@@ -31,6 +31,16 @@ export declare abstract class SnapshotStore {
|
|
|
31
31
|
abstract head(path: string): Snapshot | null;
|
|
32
32
|
/** Recorded version for `path` whose tag equals `hash`, or `null`. */
|
|
33
33
|
abstract byHash(path: string, hash: string): Snapshot | null;
|
|
34
|
+
/**
|
|
35
|
+
* Every retained version whose tag equals `hash`, across all tracked
|
|
36
|
+
* paths. The patcher uses this to recover the intended file when a section
|
|
37
|
+
* names a path that does not exist on disk but carries a tag the store
|
|
38
|
+
* minted — the model mistyped the path of a file it read this session.
|
|
39
|
+
*
|
|
40
|
+
* The base returns no matches (recovery disabled); stores that can
|
|
41
|
+
* enumerate their contents override it to enable tag-based path recovery.
|
|
42
|
+
*/
|
|
43
|
+
findByHash(_hash: string): Snapshot[];
|
|
34
44
|
/**
|
|
35
45
|
* Record the full normalized text of `path` and return its content tag.
|
|
36
46
|
* `seenLines` (optional) are the 1-indexed lines the producer displayed;
|
|
@@ -75,6 +85,7 @@ export declare class InMemorySnapshotStore extends SnapshotStore {
|
|
|
75
85
|
constructor(options?: InMemorySnapshotStoreOptions);
|
|
76
86
|
head(path: string): Snapshot | null;
|
|
77
87
|
byHash(path: string, hash: string): Snapshot | null;
|
|
88
|
+
findByHash(hash: string): Snapshot[];
|
|
78
89
|
record(path: string, fullText: string, seenLines?: Iterable<number>): string;
|
|
79
90
|
recordSeenLines(path: string, hash: string, lines: Iterable<number>): void;
|
|
80
91
|
invalidate(path: string): void;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "16.1.
|
|
4
|
+
"version": "16.1.19",
|
|
5
5
|
"description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
package/src/fs.ts
CHANGED
|
@@ -83,6 +83,21 @@ export abstract class Filesystem {
|
|
|
83
83
|
canonicalPath(path: string): string {
|
|
84
84
|
return path;
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether a section whose authored path is missing may be redirected to
|
|
89
|
+
* the file its snapshot tag names (tag-based path recovery in
|
|
90
|
+
* {@link Patcher.prepare}). `resolvedPath` is the canonical path the
|
|
91
|
+
* redirect would read and write. Default: allow.
|
|
92
|
+
*
|
|
93
|
+
* Hosts that grant write privileges by path shape override this to refuse
|
|
94
|
+
* redirects that could escalate beyond what the caller approved — e.g. an
|
|
95
|
+
* internal-URL authored target (approved read-only), or a `resolvedPath`
|
|
96
|
+
* outside the working tree (a sandbox/vault/out-of-tree write).
|
|
97
|
+
*/
|
|
98
|
+
allowTagPathRecovery(_authoredPath: string, _resolvedPath: string): boolean {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
86
101
|
}
|
|
87
102
|
|
|
88
103
|
/**
|
package/src/input.ts
CHANGED
|
@@ -348,6 +348,22 @@ export class PatchSection {
|
|
|
348
348
|
? { ...result, warnings: merged }
|
|
349
349
|
: { text: result.text, firstChangedLine: result.firstChangedLine };
|
|
350
350
|
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* A copy of this section rebound to a different target `path`, preserving
|
|
354
|
+
* the snapshot tag, diff body, and any cached parse result. Used by the
|
|
355
|
+
* patcher's tag-based path recovery to redirect an edit whose authored
|
|
356
|
+
* path does not exist onto the file its snapshot tag actually names.
|
|
357
|
+
*/
|
|
358
|
+
withPath(path: string): PatchSection {
|
|
359
|
+
const next = new PatchSection({
|
|
360
|
+
path,
|
|
361
|
+
...(this.fileHash !== undefined ? { fileHash: this.fileHash } : {}),
|
|
362
|
+
diff: this.diff,
|
|
363
|
+
});
|
|
364
|
+
next.#parsed = this.#parsed;
|
|
365
|
+
return next;
|
|
366
|
+
}
|
|
351
367
|
}
|
|
352
368
|
|
|
353
369
|
/**
|
package/src/messages.ts
CHANGED
|
@@ -176,6 +176,21 @@ export function missingSnapshotTagMessage(sectionPath: string): string {
|
|
|
176
176
|
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.`;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* A section named a path that does not exist, but its filename and snapshot
|
|
181
|
+
* tag together match exactly one file read earlier this session — the model
|
|
182
|
+
* gave the bare filename (or wrong directory) for a file it just read. The
|
|
183
|
+
* edit was rebound to that file's full path. Surfaced as a warning so the
|
|
184
|
+
* model (and user) learn the corrected path and stop reusing the wrong one.
|
|
185
|
+
*/
|
|
186
|
+
export function pathRecoveredFromTagMessage(authoredPath: string, resolvedPath: string, tag: string): string {
|
|
187
|
+
return (
|
|
188
|
+
`Path "${authoredPath}" does not exist; matched its filename and snapshot tag ` +
|
|
189
|
+
`${HL_FILE_HASH_SEP}${tag} to ${resolvedPath} (read earlier this session). Anchor future edits on ` +
|
|
190
|
+
`${HL_FILE_PREFIX}${resolvedPath}${HL_FILE_HASH_SEP}TAG${HL_FILE_SUFFIX}.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
179
194
|
/** Compress a line list into a sorted `1-4, 7, 10-12` range string. */
|
|
180
195
|
function formatLineRanges(lines: readonly number[]): string {
|
|
181
196
|
const sorted = [...new Set(lines)].sort((a, b) => a - b);
|
package/src/patcher.ts
CHANGED
|
@@ -22,13 +22,19 @@
|
|
|
22
22
|
* The patcher itself is stateless across calls; reuse one instance per
|
|
23
23
|
* filesystem configuration.
|
|
24
24
|
*/
|
|
25
|
+
import * as path from "node:path";
|
|
25
26
|
import { applyEdits } from "./apply";
|
|
26
27
|
import { hasBlockEdit, resolveBlockEdits } from "./block";
|
|
27
28
|
import { computeFileHash, formatHashlineHeader } from "./format";
|
|
28
29
|
import type { Filesystem, WriteResult } from "./fs";
|
|
29
30
|
import { isNotFound } from "./fs";
|
|
30
31
|
import type { Patch, PatchSection } from "./input";
|
|
31
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
HEADTAIL_DRIFT_WARNING,
|
|
34
|
+
missingSnapshotTagMessage,
|
|
35
|
+
pathRecoveredFromTagMessage,
|
|
36
|
+
unseenLinesMessage,
|
|
37
|
+
} from "./messages";
|
|
32
38
|
import { MismatchError } from "./mismatch";
|
|
33
39
|
import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
|
|
34
40
|
import { Recovery, type RecoveryResult } from "./recovery";
|
|
@@ -245,33 +251,51 @@ export class Patcher {
|
|
|
245
251
|
* tag mismatch ({@link MismatchError}).
|
|
246
252
|
*/
|
|
247
253
|
async prepare(section: PatchSection): Promise<PreparedSection> {
|
|
248
|
-
const
|
|
254
|
+
const parseWarnings = [...section.parse().warnings];
|
|
249
255
|
assertSectionHashPresent(section.path, section.fileHash);
|
|
250
256
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
257
|
+
let target = section;
|
|
258
|
+
let canonicalPath = this.fs.canonicalPath(target.path);
|
|
259
|
+
await this.fs.preflightWrite(target.path);
|
|
260
|
+
let read = await this.#tryRead(target.path);
|
|
261
|
+
|
|
262
|
+
// Path recovery: the authored path doesn't exist on disk, but its
|
|
263
|
+
// filename + snapshot tag may name a file the model read this session
|
|
264
|
+
// (it supplied a bare filename, or the wrong directory). Rebind to that
|
|
265
|
+
// file so the edit lands where the tag points, and warn.
|
|
266
|
+
if (!read.exists) {
|
|
267
|
+
const recovered = this.#recoverSectionPathFromTag(target, canonicalPath);
|
|
268
|
+
if (recovered && this.fs.allowTagPathRecovery(target.path, recovered.section.path)) {
|
|
269
|
+
parseWarnings.push(
|
|
270
|
+
pathRecoveredFromTagMessage(target.path, recovered.section.path, target.fileHash as string),
|
|
271
|
+
);
|
|
272
|
+
target = recovered.section;
|
|
273
|
+
canonicalPath = recovered.canonicalPath;
|
|
274
|
+
await this.fs.preflightWrite(target.path);
|
|
275
|
+
read = await this.#tryRead(target.path);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (!read.exists) {
|
|
279
|
+
throw new Error(`File not found: ${target.path}. Use the write tool to create new files.`);
|
|
256
280
|
}
|
|
257
281
|
|
|
258
|
-
const { bom, text } = stripBom(rawContent);
|
|
282
|
+
const { bom, text } = stripBom(read.rawContent);
|
|
259
283
|
const lineEnding = detectLineEnding(text);
|
|
260
284
|
const normalized = normalizeToLF(text);
|
|
261
285
|
|
|
262
286
|
const applyResult = this.#applyWithRecovery({
|
|
263
|
-
section,
|
|
287
|
+
section: target,
|
|
264
288
|
canonicalPath,
|
|
265
|
-
exists,
|
|
289
|
+
exists: read.exists,
|
|
266
290
|
normalized,
|
|
267
|
-
edits,
|
|
291
|
+
edits: target.parse().edits,
|
|
268
292
|
});
|
|
269
293
|
|
|
270
294
|
return new PreparedSection(
|
|
271
|
-
|
|
295
|
+
target,
|
|
272
296
|
canonicalPath,
|
|
273
|
-
exists,
|
|
274
|
-
rawContent,
|
|
297
|
+
read.exists,
|
|
298
|
+
read.rawContent,
|
|
275
299
|
bom,
|
|
276
300
|
lineEnding,
|
|
277
301
|
normalized,
|
|
@@ -280,6 +304,38 @@ export class Patcher {
|
|
|
280
304
|
);
|
|
281
305
|
}
|
|
282
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Resolve a missing authored path to a file read this session by matching
|
|
309
|
+
* its filename and snapshot tag. Returns the section rebound to that file's
|
|
310
|
+
* canonical path, or `null` when no unique filename+tag match exists.
|
|
311
|
+
*
|
|
312
|
+
* Resolution requires BOTH the bare filename (basename) and the section tag
|
|
313
|
+
* to match a single retained file: a whole-file content hash plus an exact
|
|
314
|
+
* filename is a strong identity signal, so the model almost certainly meant
|
|
315
|
+
* that file but gave the wrong directory (or only the filename). A tie — two
|
|
316
|
+
* retained files sharing the filename and tag — declines recovery. The
|
|
317
|
+
* recorded path of the authored file itself is excluded so a deleted file
|
|
318
|
+
* does not "recover" onto its own stale snapshot.
|
|
319
|
+
*/
|
|
320
|
+
#recoverSectionPathFromTag(
|
|
321
|
+
section: PatchSection,
|
|
322
|
+
originalCanonicalPath: string,
|
|
323
|
+
): { section: PatchSection; canonicalPath: string } | null {
|
|
324
|
+
if (section.fileHash === undefined) return null;
|
|
325
|
+
const authoredName = path.basename(section.path);
|
|
326
|
+
const candidates = [
|
|
327
|
+
...new Set(
|
|
328
|
+
this.snapshots
|
|
329
|
+
.findByHash(section.fileHash)
|
|
330
|
+
.filter(snapshot => path.basename(snapshot.path) === authoredName)
|
|
331
|
+
.map(snapshot => snapshot.path),
|
|
332
|
+
),
|
|
333
|
+
].filter(candidate => this.fs.canonicalPath(candidate) !== originalCanonicalPath);
|
|
334
|
+
if (candidates.length !== 1) return null;
|
|
335
|
+
const resolved = candidates[0];
|
|
336
|
+
return { section: section.withPath(resolved), canonicalPath: this.fs.canonicalPath(resolved) };
|
|
337
|
+
}
|
|
338
|
+
|
|
283
339
|
/**
|
|
284
340
|
* Commit a previously {@link prepare}d section to the filesystem.
|
|
285
341
|
* Restores line endings and BOM, writes via the {@link Filesystem}, and
|
package/src/snapshots.ts
CHANGED
|
@@ -59,6 +59,19 @@ export abstract class SnapshotStore {
|
|
|
59
59
|
/** Recorded version for `path` whose tag equals `hash`, or `null`. */
|
|
60
60
|
abstract byHash(path: string, hash: string): Snapshot | null;
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Every retained version whose tag equals `hash`, across all tracked
|
|
64
|
+
* paths. The patcher uses this to recover the intended file when a section
|
|
65
|
+
* names a path that does not exist on disk but carries a tag the store
|
|
66
|
+
* minted — the model mistyped the path of a file it read this session.
|
|
67
|
+
*
|
|
68
|
+
* The base returns no matches (recovery disabled); stores that can
|
|
69
|
+
* enumerate their contents override it to enable tag-based path recovery.
|
|
70
|
+
*/
|
|
71
|
+
findByHash(_hash: string): Snapshot[] {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
/**
|
|
63
76
|
* Record the full normalized text of `path` and return its content tag.
|
|
64
77
|
* `seenLines` (optional) are the 1-indexed lines the producer displayed;
|
|
@@ -142,6 +155,16 @@ export class InMemorySnapshotStore extends SnapshotStore {
|
|
|
142
155
|
return history?.find(version => version.hash === hash) ?? null;
|
|
143
156
|
}
|
|
144
157
|
|
|
158
|
+
findByHash(hash: string): Snapshot[] {
|
|
159
|
+
const matches: Snapshot[] = [];
|
|
160
|
+
for (const history of this.#versions.values()) {
|
|
161
|
+
for (const version of history) {
|
|
162
|
+
if (version.hash === hash) matches.push(version);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return matches;
|
|
166
|
+
}
|
|
167
|
+
|
|
145
168
|
record(path: string, fullText: string, seenLines?: Iterable<number>): string {
|
|
146
169
|
const hash = computeFileHash(fullText);
|
|
147
170
|
// `get` refreshes LRU recency for `path`.
|