@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.
@@ -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
@@ -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
@@ -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.17",
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 { HEADTAIL_DRIFT_WARNING, missingSnapshotTagMessage, unseenLinesMessage } from "./messages";
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 { edits, warnings: parseWarnings } = section.parse();
254
+ const parseWarnings = [...section.parse().warnings];
249
255
  assertSectionHashPresent(section.path, section.fileHash);
250
256
 
251
- const canonicalPath = this.fs.canonicalPath(section.path);
252
- await this.fs.preflightWrite(section.path);
253
- const { exists, rawContent } = await this.#tryRead(section.path);
254
- if (!exists) {
255
- throw new Error(`File not found: ${section.path}. Use the write tool to create new files.`);
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
- section,
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`.