@oh-my-pi/hashline 15.5.4
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 +78 -0
- package/dist/types/apply.d.ts +10 -0
- package/dist/types/diff-preview.d.ts +12 -0
- package/dist/types/format.d.ts +65 -0
- package/dist/types/fs.d.ts +80 -0
- package/dist/types/index.d.ts +16 -0
- package/dist/types/input.d.ts +85 -0
- package/dist/types/messages.d.ts +56 -0
- package/dist/types/mismatch.d.ts +35 -0
- package/dist/types/normalize.d.ts +20 -0
- package/dist/types/parser.d.ts +50 -0
- package/dist/types/patcher.d.ts +112 -0
- package/dist/types/prefixes.d.ts +34 -0
- package/dist/types/recovery.d.ts +38 -0
- package/dist/types/snapshots.d.ts +67 -0
- package/dist/types/stream.d.ts +2 -0
- package/dist/types/tokenizer.d.ts +104 -0
- package/dist/types/types.d.ts +93 -0
- package/package.json +61 -0
- package/src/apply.ts +799 -0
- package/src/diff-preview.ts +49 -0
- package/src/format.ts +110 -0
- package/src/fs.ts +167 -0
- package/src/grammar.lark +22 -0
- package/src/index.ts +16 -0
- package/src/input.ts +319 -0
- package/src/messages.ts +76 -0
- package/src/mismatch.ts +121 -0
- package/src/normalize.ts +38 -0
- package/src/parser.ts +350 -0
- package/src/patcher.ts +360 -0
- package/src/prefixes.ts +130 -0
- package/src/prompt.md +83 -0
- package/src/recovery.ts +163 -0
- package/src/snapshots.ts +171 -0
- package/src/stream.ts +132 -0
- package/src/tokenizer.ts +486 -0
- package/src/types.ts +87 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @oh-my-pi/hashline
|
|
2
|
+
|
|
3
|
+
A compact, line-anchored patch language and applier.
|
|
4
|
+
|
|
5
|
+
Hashline is a diff format designed for LLM-driven file edits. It binds every
|
|
6
|
+
hunk to a file-content hash so stale anchors are rejected before they corrupt
|
|
7
|
+
code, and it abstracts over the filesystem so the same patcher works on disk,
|
|
8
|
+
in memory, over the network, or against any custom backend.
|
|
9
|
+
|
|
10
|
+
## Quick start
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import {
|
|
14
|
+
Filesystem,
|
|
15
|
+
InMemoryFilesystem,
|
|
16
|
+
InMemorySnapshotStore,
|
|
17
|
+
Patcher,
|
|
18
|
+
Patch,
|
|
19
|
+
} from "@oh-my-pi/hashline";
|
|
20
|
+
|
|
21
|
+
const fs = new InMemoryFilesystem();
|
|
22
|
+
await fs.writeText(
|
|
23
|
+
"hello.ts",
|
|
24
|
+
`const greeting = "hi";\nexport { greeting };\n`,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const patcher = new Patcher({ fs });
|
|
28
|
+
const patch = Patch.parse(`¶hello.ts\n1:\n+const greeting = "hello";`);
|
|
29
|
+
const result = await patcher.apply(patch);
|
|
30
|
+
|
|
31
|
+
console.log(result.sections[0].op); // "update"
|
|
32
|
+
console.log(await fs.readText("hello.ts"));
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Format
|
|
36
|
+
|
|
37
|
+
See [`src/prompt.md`](./src/prompt.md) for the user-facing description and
|
|
38
|
+
[`src/grammar.lark`](./src/grammar.lark) for the formal grammar.
|
|
39
|
+
|
|
40
|
+
Each hunk starts with a `¶PATH#HASH` header. The hash is a 4-hex-character
|
|
41
|
+
xxHash32 truncation of the file's LF-normalized content. The hash protects
|
|
42
|
+
against stale anchors: if the file changed between the read that produced the
|
|
43
|
+
hash and the edit, the patcher refuses (or, with a `SnapshotStore`, tries
|
|
44
|
+
session-aware recovery).
|
|
45
|
+
|
|
46
|
+
Inside a hunk:
|
|
47
|
+
|
|
48
|
+
|Op|Meaning|
|
|
49
|
+
|---|---|
|
|
50
|
+
|`LINE↑`|Insert before LINE (or `BOF↑` for the beginning of file)|
|
|
51
|
+
|`LINE↓`|Insert after LINE (or `EOF↓` for the end of file)|
|
|
52
|
+
|`A-B:`|Replace lines A..B (single-anchor `A:` is sugar for `A-A:`)|
|
|
53
|
+
|`A-B!`|Delete lines A..B (single-anchor `A!` is sugar for `A-A!`)|
|
|
54
|
+
|`+TEXT`|Payload continuation. The `+` prefix is stripped|
|
|
55
|
+
|
|
56
|
+
## Abstractions
|
|
57
|
+
|
|
58
|
+
### `Filesystem`
|
|
59
|
+
|
|
60
|
+
Read and write text by path. The default implementations:
|
|
61
|
+
|
|
62
|
+
- `InMemoryFilesystem` — backed by a `Map`. Tests, sandboxes.
|
|
63
|
+
- `NodeFilesystem` — disk-backed via `Bun.file`/`Bun.write`. Default for CLIs.
|
|
64
|
+
|
|
65
|
+
Subclass `Filesystem` to wire hashline into any storage: VFS, S3, an LSP
|
|
66
|
+
text-document protocol, a Git tree, anything.
|
|
67
|
+
|
|
68
|
+
### `SnapshotStore`
|
|
69
|
+
|
|
70
|
+
Optional. When provided to `Patcher`, hashline tries to recover from a stale
|
|
71
|
+
section hash by replaying the edit against a cached pre-edit snapshot of the
|
|
72
|
+
file and 3-way-merging onto the current content. See `recovery.ts`.
|
|
73
|
+
|
|
74
|
+
### `Patcher`
|
|
75
|
+
|
|
76
|
+
The orchestration class. Reads, normalizes line endings + BOM, applies edits,
|
|
77
|
+
restores line endings, and writes via the configured `Filesystem`. Multi-section
|
|
78
|
+
patches are preflighted up front so a partial batch never lands.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ApplyOptions, ApplyResult, Edit } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Apply a parsed list of edits to a text body. Pure function — no I/O.
|
|
4
|
+
*
|
|
5
|
+
* Returns the post-edit text, the first changed line number (1-indexed), and
|
|
6
|
+
* any diagnostic warnings produced by the auto-absorb heuristics or by the
|
|
7
|
+
* structural-boundary delete check. Throws if an anchor is out of bounds or a
|
|
8
|
+
* blank-target replace is detected.
|
|
9
|
+
*/
|
|
10
|
+
export declare function applyEdits(text: string, edits: Edit[], options?: ApplyOptions): ApplyResult;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-number a unified diff that uses the `+<lineNum>|content` /
|
|
3
|
+
* `-<lineNum>|content` / ` <lineNum>|content` line format into a compact
|
|
4
|
+
* preview that anchors every line to its post-edit position. Added lines,
|
|
5
|
+
* removed lines, and context lines all end up with a hashline-style anchor
|
|
6
|
+
* so a follow-up edit can reuse them directly.
|
|
7
|
+
*
|
|
8
|
+
* This is intentionally decoupled from the diff producer: anything that
|
|
9
|
+
* emits the `<sign><lineNum>|<content>` shape works.
|
|
10
|
+
*/
|
|
11
|
+
import type { CompactDiffOptions, CompactDiffPreview } from "./types";
|
|
12
|
+
export declare function buildCompactDiffPreview(diff: string, _options?: CompactDiffOptions): CompactDiffPreview;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hashline format primitives: sigils, separators, regex fragments, and the
|
|
3
|
+
* file-hash computation. These are the single source of truth for the
|
|
4
|
+
* parser, the tokenizer, the prompt, and the formal grammar.
|
|
5
|
+
*/
|
|
6
|
+
/** Op sigil used immediately after a line-number anchor to insert before it. */
|
|
7
|
+
export declare const HL_OP_INSERT_BEFORE = "\u2191";
|
|
8
|
+
/** Op sigil used immediately after a line-number anchor to insert after it. */
|
|
9
|
+
export declare const HL_OP_INSERT_AFTER = "\u2193";
|
|
10
|
+
/** Op sigil used after a range (or single anchor) to replace its lines. */
|
|
11
|
+
export declare const HL_OP_REPLACE = ":";
|
|
12
|
+
/** Op sigil used after a range (or single anchor) to delete its lines. */
|
|
13
|
+
export declare const HL_OP_DELETE = "!";
|
|
14
|
+
/** All hashline edit op sigils, concatenated for fast membership tests. */
|
|
15
|
+
export declare const HL_OP_CHARS = "\u2191\u2193:!";
|
|
16
|
+
/** Prefix for payload continuation lines. The prefix itself is not written. */
|
|
17
|
+
export declare const HL_PAYLOAD_PREFIX = "+";
|
|
18
|
+
/** Hashline edit file-section header marker. */
|
|
19
|
+
export declare const HL_FILE_PREFIX = "\u00B6";
|
|
20
|
+
/** Separator between a hashline file path and its file hash. */
|
|
21
|
+
export declare const HL_FILE_HASH_SEP = "#";
|
|
22
|
+
/** Separator between a line number and displayed line content in hashline mode. */
|
|
23
|
+
export declare const HL_LINE_BODY_SEP = ":";
|
|
24
|
+
/**
|
|
25
|
+
* Decoration prefix that may precede a line number in tool output:
|
|
26
|
+
* `>` (context line in grep), `-` (removed line), `*` (match line).
|
|
27
|
+
* Any combination, in any order, surrounded by optional whitespace. Output
|
|
28
|
+
* formatters emit at most one decoration per line; the parser stays liberal
|
|
29
|
+
* because it accepts whatever the model echoes back.
|
|
30
|
+
*/
|
|
31
|
+
export declare const HL_ANCHOR_DECORATION_RE_RAW = "\\s*[>\\-*]*\\s*";
|
|
32
|
+
/** Capture-group regex source for a decorated bare line-number anchor. */
|
|
33
|
+
export declare const HL_ANCHOR_RE_RAW = "\\s*[>\\-*]*\\s*(\\d+)";
|
|
34
|
+
/** Bare positive line-number Lid (no decorations, no captures, no anchors). */
|
|
35
|
+
export declare const HL_LINE_RE_RAW = "[1-9]\\d*";
|
|
36
|
+
/** Capture-group form of {@link HL_LINE_RE_RAW}. */
|
|
37
|
+
export declare const HL_LINE_CAPTURE_RE_RAW = "([1-9]\\d*)";
|
|
38
|
+
/** Four-hex-character file hash carried by a hashline section header. */
|
|
39
|
+
export declare const HL_FILE_HASH_RE_RAW = "[0-9a-f]{4}";
|
|
40
|
+
/** Capture-group form of {@link HL_FILE_HASH_RE_RAW}. */
|
|
41
|
+
export declare const HL_FILE_HASH_CAPTURE_RE_RAW = "([0-9a-f]{4})";
|
|
42
|
+
/** Regex-escaped form of {@link HL_LINE_BODY_SEP}, safe for embedding inside a regex. */
|
|
43
|
+
export declare const HL_LINE_BODY_SEP_RE_RAW: string;
|
|
44
|
+
/**
|
|
45
|
+
* Representative file hashes for use in user-facing error messages and prompt
|
|
46
|
+
* examples.
|
|
47
|
+
*/
|
|
48
|
+
export declare const HL_FILE_HASH_EXAMPLES: readonly ["1a2b", "3c4d", "9f3e"];
|
|
49
|
+
/**
|
|
50
|
+
* Format a comma-separated list of example anchors with an optional line-number
|
|
51
|
+
* prefix, quoted for inclusion in error messages: `"160", "42", "7"`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function describeAnchorExamples(linePrefix?: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* Compute the 4-hex-character hash carried by a hashline section header. The
|
|
56
|
+
* hash normalizes CR characters and trailing whitespace before hashing so
|
|
57
|
+
* platform line endings and display-trimmed lines do not invalidate anchors.
|
|
58
|
+
*/
|
|
59
|
+
export declare function computeFileHash(text: string): string;
|
|
60
|
+
/** Format a hashline section header for a file path and file hash. */
|
|
61
|
+
export declare function formatHashlineHeader(filePath: string, fileHash: string): string;
|
|
62
|
+
/** Formats a single numbered line as `LINE:TEXT`. */
|
|
63
|
+
export declare function formatNumberedLine(lineNumber: number, line: string): string;
|
|
64
|
+
/** Format file text with hashline-mode line-number prefixes for display. */
|
|
65
|
+
export declare function formatNumberedLines(text: string, startLine?: number): string;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result returned by {@link Filesystem.writeText}. The patcher echoes back
|
|
3
|
+
* `text` so adapters that transform on serialization (e.g. notebooks) can
|
|
4
|
+
* report what actually landed on disk.
|
|
5
|
+
*/
|
|
6
|
+
export interface WriteResult {
|
|
7
|
+
/** Final text that was persisted. May differ from the input if the FS transformed it. */
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* ENOENT-like error thrown by {@link Filesystem.readText} when a path is
|
|
12
|
+
* missing. Carrying a `code` property keeps the contract compatible with
|
|
13
|
+
* `node:fs` callers that already check `err.code === "ENOENT"`.
|
|
14
|
+
*/
|
|
15
|
+
export declare class NotFoundError extends Error {
|
|
16
|
+
readonly code = "ENOENT";
|
|
17
|
+
constructor(path: string, cause?: unknown);
|
|
18
|
+
}
|
|
19
|
+
/** Type guard for {@link NotFoundError} and structurally-compatible errors. */
|
|
20
|
+
export declare function isNotFound(error: unknown): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Abstract storage backend the {@link Patcher} reads from and writes to.
|
|
23
|
+
* Subclass for new backends; the package ships {@link InMemoryFilesystem} and
|
|
24
|
+
* {@link NodeFilesystem} for the most common cases.
|
|
25
|
+
*
|
|
26
|
+
* Implementations work with raw text — the patcher handles BOM stripping and
|
|
27
|
+
* line-ending normalization itself. `readText` MUST throw {@link
|
|
28
|
+
* NotFoundError} (or any error for which {@link isNotFound} returns true)
|
|
29
|
+
* when the path doesn't exist; that's how the patcher detects a create-vs-
|
|
30
|
+
* update.
|
|
31
|
+
*/
|
|
32
|
+
export declare abstract class Filesystem {
|
|
33
|
+
/** Read the file's full text content. Throw on missing file. */
|
|
34
|
+
abstract readText(path: string): Promise<string>;
|
|
35
|
+
/** Validate that `path` is writable before a prepared batch starts committing. */
|
|
36
|
+
preflightWrite(_path: string): Promise<void>;
|
|
37
|
+
/** Persist `content` at `path`. Returns the actual final text that was written. */
|
|
38
|
+
abstract writeText(path: string, content: string): Promise<WriteResult>;
|
|
39
|
+
/** Return true when the path exists and can be read. Default: probe via {@link readText}. */
|
|
40
|
+
exists(path: string): Promise<boolean>;
|
|
41
|
+
/**
|
|
42
|
+
* Canonical path used as a key by external caches (e.g. snapshot
|
|
43
|
+
* stores). The default is identity; override to return an absolute or
|
|
44
|
+
* otherwise canonicalised path so producers and consumers of cached
|
|
45
|
+
* snapshots agree on the key without each having to redo the resolution.
|
|
46
|
+
*/
|
|
47
|
+
canonicalPath(path: string): string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* In-memory {@link Filesystem}. Useful for tests, sandboxes, dry-runs, and as
|
|
51
|
+
* a building block for stacked adapters (e.g. an LRU layer on top).
|
|
52
|
+
*/
|
|
53
|
+
export declare class InMemoryFilesystem extends Filesystem {
|
|
54
|
+
#private;
|
|
55
|
+
constructor(initial?: Iterable<readonly [string, string]>);
|
|
56
|
+
readText(path: string): Promise<string>;
|
|
57
|
+
writeText(path: string, content: string): Promise<WriteResult>;
|
|
58
|
+
exists(path: string): Promise<boolean>;
|
|
59
|
+
/** Synchronous helper for setting up fixtures without awaiting. */
|
|
60
|
+
set(path: string, content: string): void;
|
|
61
|
+
/** Synchronous helper for inspecting state without awaiting. */
|
|
62
|
+
get(path: string): string | undefined;
|
|
63
|
+
/** Remove a single entry. Returns true when something was removed. */
|
|
64
|
+
delete(path: string): boolean;
|
|
65
|
+
/** Wipe all entries. */
|
|
66
|
+
clear(): void;
|
|
67
|
+
/** Iterate `[path, content]` pairs. */
|
|
68
|
+
entries(): IterableIterator<[string, string]>;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Disk-backed {@link Filesystem} using Bun's file APIs. The default for CLI
|
|
72
|
+
* use. Paths are accepted as-is; callers responsible for any cwd or
|
|
73
|
+
* jail/sandbox resolution should wrap this with their own subclass.
|
|
74
|
+
*/
|
|
75
|
+
export declare class NodeFilesystem extends Filesystem {
|
|
76
|
+
readText(path: string): Promise<string>;
|
|
77
|
+
writeText(path: string, content: string): Promise<WriteResult>;
|
|
78
|
+
canonicalPath(path: string): string;
|
|
79
|
+
exists(path: string): Promise<boolean>;
|
|
80
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from "./apply";
|
|
2
|
+
export * from "./diff-preview";
|
|
3
|
+
export * from "./format";
|
|
4
|
+
export * from "./fs";
|
|
5
|
+
export * from "./input";
|
|
6
|
+
export * from "./messages";
|
|
7
|
+
export * from "./mismatch";
|
|
8
|
+
export * from "./normalize";
|
|
9
|
+
export * from "./parser";
|
|
10
|
+
export * from "./patcher";
|
|
11
|
+
export * from "./prefixes";
|
|
12
|
+
export * from "./recovery";
|
|
13
|
+
export * from "./snapshots";
|
|
14
|
+
export * from "./stream";
|
|
15
|
+
export * from "./tokenizer";
|
|
16
|
+
export * from "./types";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ApplyOptions, ApplyResult, Edit, SplitOptions } from "./types";
|
|
2
|
+
interface RawSection {
|
|
3
|
+
path: string;
|
|
4
|
+
fileHash?: string;
|
|
5
|
+
diff: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Returns true when the input contains at least one line that the tokenizer
|
|
9
|
+
* recognizes as a hashline op. Used by streaming previews to decide whether
|
|
10
|
+
* the partial input is worth treating as a hashline patch yet.
|
|
11
|
+
*/
|
|
12
|
+
export declare function containsRecognizableHashlineOperations(input: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Snapshot of one section in a parsed {@link Patch}: a target file plus the
|
|
15
|
+
* lazily-parsed list of edits that should land on it. Constructed by
|
|
16
|
+
* {@link Patch.parse}; consumers usually iterate `patch.sections` rather
|
|
17
|
+
* than build these directly.
|
|
18
|
+
*/
|
|
19
|
+
export declare class PatchSection {
|
|
20
|
+
#private;
|
|
21
|
+
readonly path: string;
|
|
22
|
+
readonly fileHash: string | undefined;
|
|
23
|
+
readonly diff: string;
|
|
24
|
+
constructor(raw: RawSection);
|
|
25
|
+
/**
|
|
26
|
+
* Parse this section's diff body. Cached: subsequent calls return the
|
|
27
|
+
* same `{ edits, warnings }` object so callers can safely call this from
|
|
28
|
+
* multiple paths (preflight, apply, diff-preview).
|
|
29
|
+
*/
|
|
30
|
+
parse(): {
|
|
31
|
+
edits: Edit[];
|
|
32
|
+
warnings: readonly string[];
|
|
33
|
+
};
|
|
34
|
+
/** Parsed edits for this section. */
|
|
35
|
+
get edits(): readonly Edit[];
|
|
36
|
+
/** Warnings emitted during parsing of this section. */
|
|
37
|
+
get warnings(): readonly string[];
|
|
38
|
+
/**
|
|
39
|
+
* True when at least one edit anchors to a concrete file line (range or
|
|
40
|
+
* before/after_anchor insert). Pure BOF/EOF inserts do not count: those
|
|
41
|
+
* are safe to apply to files that don't yet exist.
|
|
42
|
+
*/
|
|
43
|
+
get hasAnchorScopedEdit(): boolean;
|
|
44
|
+
/** Anchor lines touched by this section, sorted ascending and deduplicated. */
|
|
45
|
+
collectAnchorLines(): readonly number[];
|
|
46
|
+
/**
|
|
47
|
+
* Apply this section's edits to `text` and return the post-edit result.
|
|
48
|
+
* Pure: does no I/O, does not validate the section file hash. The
|
|
49
|
+
* {@link Patcher} owns hash validation and recovery; reach for this
|
|
50
|
+
* method directly when you've already validated the file content and
|
|
51
|
+
* just want the result.
|
|
52
|
+
*/
|
|
53
|
+
applyTo(text: string, options?: ApplyOptions): ApplyResult;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A parsed hashline patch — zero or more {@link PatchSection}s, each rooted
|
|
57
|
+
* at a `¶PATH#HASH` header. Construct via {@link Patch.parse}.
|
|
58
|
+
*
|
|
59
|
+
* `Patch` is pure data: parsing is line-anchored and does not look at the
|
|
60
|
+
* filesystem. To apply a patch, hand it to {@link Patcher.apply}.
|
|
61
|
+
*/
|
|
62
|
+
export declare class Patch {
|
|
63
|
+
readonly sections: readonly PatchSection[];
|
|
64
|
+
private constructor();
|
|
65
|
+
/**
|
|
66
|
+
* Parse `input` into a {@link Patch}. `options.cwd` resolves absolute
|
|
67
|
+
* paths inside headers to cwd-relative form; `options.path` provides a
|
|
68
|
+
* fallback when the input lacks a header but contains hashline ops
|
|
69
|
+
* (useful for streaming previews).
|
|
70
|
+
*
|
|
71
|
+
* Consecutive sections targeting the same path are merged into a single
|
|
72
|
+
* section with concatenated diff bodies. Anchors authored against the
|
|
73
|
+
* same file snapshot must be applied as one batch; otherwise the first
|
|
74
|
+
* sub-edit shifts line numbers out from under the second's anchors and
|
|
75
|
+
* validation fails.
|
|
76
|
+
*/
|
|
77
|
+
static parse(input: string, options?: SplitOptions): Patch;
|
|
78
|
+
/**
|
|
79
|
+
* Parse `input` and return only the first section. Throws if the input
|
|
80
|
+
* has zero sections. Convenience for the single-section case where the
|
|
81
|
+
* caller already knows the patch is one hunk.
|
|
82
|
+
*/
|
|
83
|
+
static parseSingle(input: string, options?: SplitOptions): PatchSection;
|
|
84
|
+
}
|
|
85
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
*/
|
|
7
|
+
/** Lines of context shown either side of a hash mismatch. */
|
|
8
|
+
export declare const MISMATCH_CONTEXT = 2;
|
|
9
|
+
/** Optional patch envelope start marker; silently consumed when present. */
|
|
10
|
+
export declare const BEGIN_PATCH_MARKER = "*** Begin Patch";
|
|
11
|
+
/** Optional patch envelope end marker; terminates parsing when encountered. */
|
|
12
|
+
export declare const END_PATCH_MARKER = "*** End Patch";
|
|
13
|
+
/**
|
|
14
|
+
* Recovery sentinel emitted by an agent loop when a contaminated tool-call
|
|
15
|
+
* stream is truncated mid-call. Behaves like {@link END_PATCH_MARKER} for
|
|
16
|
+
* parsing — terminates the line loop — and additionally surfaces a warning
|
|
17
|
+
* so the caller knows to re-issue any remaining edits.
|
|
18
|
+
*/
|
|
19
|
+
export declare const ABORT_MARKER = "*** Abort";
|
|
20
|
+
/** Warning text appended to the tool result when {@link ABORT_MARKER} terminates parsing. */
|
|
21
|
+
export declare const ABORT_WARNING = "Tool stream truncated mid-call due to detected output corruption. Applied ops above are valid. Re-issue any remaining edits.";
|
|
22
|
+
/**
|
|
23
|
+
* Warning text appended when two consecutive `A-B:` ops on the exact same
|
|
24
|
+
* range get coalesced (model painted a before/after pair). The second op wins;
|
|
25
|
+
* the first op's payload is silently discarded.
|
|
26
|
+
*/
|
|
27
|
+
export declare const REPLACE_PAIR_COALESCED_WARNING = "Detected an identical-range before/after replace pair; kept only the second block's payload. Issue ONE op per range \u2014 the payload is the final desired content, never both old and new.";
|
|
28
|
+
/**
|
|
29
|
+
* Warning text appended when un-prefixed continuation lines are accepted as
|
|
30
|
+
* implicit payload (lenient legacy behavior). The author wrote a multi-line
|
|
31
|
+
* replace without `+` prefixes; the parser accepted it because the lines did
|
|
32
|
+
* not classify as ops/headers/payloads, but the canonical syntax requires `+`
|
|
33
|
+
* on every continuation line after the op.
|
|
34
|
+
*/
|
|
35
|
+
export declare const IMPLICIT_CONTINUATION_WARNING = "Accepted continuation line(s) without the `+` prefix as implicit payload. Canonical syntax is `A-B:` followed by `+` on every continuation row; without `+`, lines that look like ops will be parsed as new ops instead of payload. Prefer the explicit form.";
|
|
36
|
+
/**
|
|
37
|
+
* Warning text appended when an inner `LINE:TEXT` (or sub-range `A-B:TEXT`)
|
|
38
|
+
* op arrives while an outer `A-B:` replace is still pending and the inner
|
|
39
|
+
* anchor falls inside the outer range. The author used the read-output
|
|
40
|
+
* `LINE:TEXT` format as if it were a payload-continuation line; we strip the
|
|
41
|
+
* `LINE:` prefix and append the body to the pending payload, but warn so the
|
|
42
|
+
* canonical `+`-continuation form remains preferred.
|
|
43
|
+
*/
|
|
44
|
+
export declare const PAYLOAD_LINE_PREFIX_DEMOTED_WARNING = "Detected one or more `LINE:TEXT` lines whose anchors fell inside a pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside an `A-B:` block, every payload line must be on its own row prefixed with `+` \u2014 never reuse the read-output gutter format.";
|
|
45
|
+
/**
|
|
46
|
+
* Warning text appended when an op carries an inline payload (`LINE:TEXT`,
|
|
47
|
+
* `LINE↑CONTENT`, `LINE↓CONTENT`). Canonical syntax is the bare op followed
|
|
48
|
+
* by `+`-prefixed payload rows on the next line(s).
|
|
49
|
+
*/
|
|
50
|
+
export declare const INLINE_PAYLOAD_ACCEPTED_WARNING = "Accepted inline payload on the op line (e.g. `LINE:CONTENT`, `LINE\u2191CONTENT`). Canonical syntax is the bare op followed by `+`-prefixed payload rows on the next line(s). Prefer the explicit form.";
|
|
51
|
+
/** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
|
|
52
|
+
export declare const RECOVERY_EXTERNAL_WARNING = "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
|
|
53
|
+
/** Warning text emitted by `Recovery` when a prior in-session edit advanced the hash. */
|
|
54
|
+
export declare const RECOVERY_SESSION_CHAIN_WARNING = "Recovered from a stale file hash using an earlier in-session snapshot (the file hash advanced after a prior edit in this session).";
|
|
55
|
+
/** Warning text emitted by `Recovery` when the session-chain fast-path was taken. */
|
|
56
|
+
export declare const RECOVERY_SESSION_REPLAY_WARNING = "Recovered by replaying your edits onto the current file content \u2014 your previous edit in this session changed line(s) you re-targeted with a stale hash. Verify the diff matches your intent before continuing.";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Format the required-shape diagnostic shown when a line reference is malformed. */
|
|
2
|
+
export declare function formatFullAnchorRequirement(raw?: string): string;
|
|
3
|
+
/** Parse a decorated bare line-number anchor like `42`, `*42:foo`, ` > 7`. */
|
|
4
|
+
export declare function parseTag(ref: string): {
|
|
5
|
+
line: number;
|
|
6
|
+
};
|
|
7
|
+
export interface MismatchDetails {
|
|
8
|
+
path?: string;
|
|
9
|
+
expectedFileHash: string;
|
|
10
|
+
actualFileHash: string;
|
|
11
|
+
fileLines: string[];
|
|
12
|
+
anchorLines?: readonly number[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Raised when a hashline section's file hash doesn't match the live file's
|
|
16
|
+
* content (and recovery, if configured, declined the merge). Carries the
|
|
17
|
+
* file lines plus anchored lines so renderers can produce a richer
|
|
18
|
+
* diagnostic via {@link MismatchError.displayMessage}.
|
|
19
|
+
*/
|
|
20
|
+
export declare class MismatchError extends Error {
|
|
21
|
+
readonly path: string | undefined;
|
|
22
|
+
readonly expectedFileHash: string;
|
|
23
|
+
readonly actualFileHash: string;
|
|
24
|
+
readonly fileLines: string[];
|
|
25
|
+
readonly anchorLines: readonly number[];
|
|
26
|
+
constructor(details: MismatchDetails);
|
|
27
|
+
get displayMessage(): string;
|
|
28
|
+
static rejectionHeader(details: MismatchDetails): string[];
|
|
29
|
+
static formatDisplayMessage(details: MismatchDetails): string;
|
|
30
|
+
static formatMessage(details: MismatchDetails): string;
|
|
31
|
+
}
|
|
32
|
+
/** Throws when the line reference is out of bounds for the given file. */
|
|
33
|
+
export declare function validateLineRef(ref: {
|
|
34
|
+
line: number;
|
|
35
|
+
}, fileLines: string[]): void;
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
export type LineEnding = "\r\n" | "\n";
|
|
7
|
+
/** Detect the first line ending style in `content`. Defaults to LF when neither is present. */
|
|
8
|
+
export declare function detectLineEnding(content: string): LineEnding;
|
|
9
|
+
/** Normalize every line ending to LF. */
|
|
10
|
+
export declare function normalizeToLF(text: string): string;
|
|
11
|
+
/** Re-encode LF text with the requested line ending. */
|
|
12
|
+
export declare function restoreLineEndings(text: string, ending: LineEnding): string;
|
|
13
|
+
export interface BomResult {
|
|
14
|
+
/** Either the empty string or the BOM sequence (currently UTF-8 BOM). */
|
|
15
|
+
bom: string;
|
|
16
|
+
/** Text with any leading BOM removed. */
|
|
17
|
+
text: string;
|
|
18
|
+
}
|
|
19
|
+
/** Strip a UTF-8 BOM if present and return both the BOM and the trailing text. */
|
|
20
|
+
export declare function stripBom(content: string): BomResult;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type Token } from "./tokenizer";
|
|
2
|
+
import type { Edit } from "./types";
|
|
3
|
+
/**
|
|
4
|
+
* Token-driven state machine that turns a stream of {@link Token}s into a
|
|
5
|
+
* flat list of {@link Edit}s.
|
|
6
|
+
*
|
|
7
|
+
* `feed()` accepts tokens one at a time; multi-line payloads accumulate
|
|
8
|
+
* until the next op or {@link end} flushes them. After `terminated` flips
|
|
9
|
+
* true (on `envelope-end` or `abort`) subsequent feeds are silently ignored
|
|
10
|
+
* so callers can keep draining their tokenizer.
|
|
11
|
+
*/
|
|
12
|
+
export declare class Executor {
|
|
13
|
+
#private;
|
|
14
|
+
/** True once an `envelope-end` or `abort` token has been observed. */
|
|
15
|
+
get terminated(): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Consume one token. After `terminated` flips true subsequent feeds are
|
|
18
|
+
* silently ignored so callers can keep draining their tokenizer without
|
|
19
|
+
* explicit early-exit guards.
|
|
20
|
+
*/
|
|
21
|
+
feed(token: Token): void;
|
|
22
|
+
/**
|
|
23
|
+
* Flush any open pending op (with its full accumulated payload, including
|
|
24
|
+
* explicit `+` blank lines) and return the accumulated edits and
|
|
25
|
+
* warnings. The executor is single-use; {@link reset} is required for
|
|
26
|
+
* reuse.
|
|
27
|
+
*
|
|
28
|
+
* Throws if two replace/delete ops target the same line with non-identical
|
|
29
|
+
* shapes (different ranges, replace+delete, delete+delete). Identical-range
|
|
30
|
+
* `A-B:` pairs in the same hunk are coalesced last-wins by `feed()` with a
|
|
31
|
+
* warning, so they never reach the validator.
|
|
32
|
+
*/
|
|
33
|
+
end(): {
|
|
34
|
+
edits: Edit[];
|
|
35
|
+
warnings: string[];
|
|
36
|
+
};
|
|
37
|
+
/** Reset to a fresh state so the same instance can drive another parse. */
|
|
38
|
+
reset(): void;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Drive a full hashline diff through the tokenizer + executor pipeline and
|
|
42
|
+
* return the resulting edits plus any parse-time warnings. This is the
|
|
43
|
+
* convenience entry point most callers want; reach for {@link Tokenizer} /
|
|
44
|
+
* {@link Executor} directly only when you need streaming feeds, cross-section
|
|
45
|
+
* state, or custom token handling.
|
|
46
|
+
*/
|
|
47
|
+
export declare function parsePatch(diff: string): {
|
|
48
|
+
edits: Edit[];
|
|
49
|
+
warnings: string[];
|
|
50
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Filesystem } from "./fs";
|
|
2
|
+
import type { Patch, PatchSection } from "./input";
|
|
3
|
+
import { type LineEnding } from "./normalize";
|
|
4
|
+
import { Recovery } from "./recovery";
|
|
5
|
+
import type { SnapshotStore } from "./snapshots";
|
|
6
|
+
import type { ApplyOptions, ApplyResult } from "./types";
|
|
7
|
+
export interface PatcherOptions {
|
|
8
|
+
/** Storage backend used for all reads and writes. */
|
|
9
|
+
fs: Filesystem;
|
|
10
|
+
/**
|
|
11
|
+
* Optional snapshot store that enables stale-hash recovery. When set, a
|
|
12
|
+
* section with a stale hash tries a 3-way merge against a cached
|
|
13
|
+
* snapshot before the apply fails with {@link MismatchError}.
|
|
14
|
+
*/
|
|
15
|
+
snapshots?: SnapshotStore;
|
|
16
|
+
/**
|
|
17
|
+
* Optional default {@link ApplyOptions} forwarded to every section.
|
|
18
|
+
* Per-call overrides win on a key-by-key basis.
|
|
19
|
+
*/
|
|
20
|
+
applyOptions?: ApplyOptions;
|
|
21
|
+
}
|
|
22
|
+
/** Per-section result returned by {@link Patcher.apply} / {@link Patcher.commit}. */
|
|
23
|
+
export interface PatchSectionResult {
|
|
24
|
+
/** Section path (as authored, after cwd-resolution at parse time). */
|
|
25
|
+
path: string;
|
|
26
|
+
/** Filesystem-canonical key for this section (e.g. absolute path). */
|
|
27
|
+
canonicalPath: string;
|
|
28
|
+
/** `"noop"` when the apply produced no change; otherwise `"create"` / `"update"`. */
|
|
29
|
+
op: "create" | "update" | "noop";
|
|
30
|
+
/** Pre-edit text (LF-normalized, BOM-stripped). */
|
|
31
|
+
before: string;
|
|
32
|
+
/** Post-edit text (LF-normalized, BOM-stripped). For `"noop"` equals `before`. */
|
|
33
|
+
after: string;
|
|
34
|
+
/** Same text as `after` but with the original BOM and line ending restored. */
|
|
35
|
+
persisted: string;
|
|
36
|
+
/** Final text that the {@link Filesystem} actually wrote (may differ if the FS transformed it). */
|
|
37
|
+
written: string;
|
|
38
|
+
/** 4-hex hash of `after`. Use to anchor follow-up edits. */
|
|
39
|
+
fileHash: string;
|
|
40
|
+
/** Hashline section header (`¶path#hash`) of the post-edit content. */
|
|
41
|
+
header: string;
|
|
42
|
+
/** 1-indexed first changed line in `after`, or `undefined` for noops. */
|
|
43
|
+
firstChangedLine?: number;
|
|
44
|
+
/** Warnings collected by the parser, applier, and (optionally) recovery. */
|
|
45
|
+
warnings: string[];
|
|
46
|
+
}
|
|
47
|
+
export interface PatcherApplyResult {
|
|
48
|
+
sections: PatchSectionResult[];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Opaque token returned by {@link Patcher.prepare}. Carries the section, the
|
|
52
|
+
* raw file content read off disk, and the in-memory apply result.
|
|
53
|
+
* {@link Patcher.commit} just writes the {@link PreparedSection.applyResult}.
|
|
54
|
+
*/
|
|
55
|
+
export declare class PreparedSection {
|
|
56
|
+
readonly section: PatchSection;
|
|
57
|
+
readonly canonicalPath: string;
|
|
58
|
+
readonly exists: boolean;
|
|
59
|
+
readonly rawContent: string;
|
|
60
|
+
readonly bom: string;
|
|
61
|
+
readonly lineEnding: LineEnding;
|
|
62
|
+
readonly normalized: string;
|
|
63
|
+
readonly applyResult: ApplyResult;
|
|
64
|
+
readonly parseWarnings: readonly string[];
|
|
65
|
+
/** @internal */
|
|
66
|
+
constructor(section: PatchSection, canonicalPath: string, exists: boolean, rawContent: string, bom: string, lineEnding: LineEnding, normalized: string, applyResult: ApplyResult, parseWarnings: readonly string[]);
|
|
67
|
+
/** Convenience: returns true when the apply produced no change. */
|
|
68
|
+
get isNoop(): boolean;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* High-level patcher. Wires a {@link Filesystem} and an optional
|
|
72
|
+
* {@link SnapshotStore} together with the parsing + applying core.
|
|
73
|
+
*
|
|
74
|
+
* Construct once per FS configuration; reuse across patches.
|
|
75
|
+
*/
|
|
76
|
+
export declare class Patcher {
|
|
77
|
+
#private;
|
|
78
|
+
readonly fs: Filesystem;
|
|
79
|
+
readonly snapshots: SnapshotStore | undefined;
|
|
80
|
+
readonly recovery: Recovery | undefined;
|
|
81
|
+
readonly applyOptions: ApplyOptions;
|
|
82
|
+
constructor(options: PatcherOptions);
|
|
83
|
+
/**
|
|
84
|
+
* Apply every section in `patch`. `prepare` runs the full apply for each
|
|
85
|
+
* section in memory before any write hits the filesystem, so a
|
|
86
|
+
* multi-section batch is naturally all-or-nothing. Returns one
|
|
87
|
+
* {@link PatchSectionResult} per section in the original patch order.
|
|
88
|
+
*/
|
|
89
|
+
apply(patch: Patch, options?: ApplyOptions): Promise<PatcherApplyResult>;
|
|
90
|
+
/**
|
|
91
|
+
* Run the preflight pass only: read, parse, validate, apply-in-memory.
|
|
92
|
+
* No writes hit the filesystem. Use for CI checks and dry runs.
|
|
93
|
+
*/
|
|
94
|
+
preflight(patch: Patch, options?: ApplyOptions): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* Read a section's target file, parse the section, validate the file
|
|
97
|
+
* hash (with recovery), and apply the edits in memory. Returns a
|
|
98
|
+
* {@link PreparedSection} which can be fed to {@link commit} to land
|
|
99
|
+
* the result on the filesystem.
|
|
100
|
+
*
|
|
101
|
+
* Throws on parse error, missing-file-for-anchored-edit, or unrecovered
|
|
102
|
+
* hash mismatch ({@link MismatchError}).
|
|
103
|
+
*/
|
|
104
|
+
prepare(section: PatchSection, options?: ApplyOptions): Promise<PreparedSection>;
|
|
105
|
+
/**
|
|
106
|
+
* Commit a previously {@link prepare}d section to the filesystem.
|
|
107
|
+
* Restores line endings and BOM, writes via the {@link Filesystem}, and
|
|
108
|
+
* records a fresh snapshot in the {@link SnapshotStore} (when
|
|
109
|
+
* configured) keyed by the filesystem-canonical path.
|
|
110
|
+
*/
|
|
111
|
+
commit(prepared: PreparedSection): Promise<PatchSectionResult>;
|
|
112
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
* Strip whichever prefix scheme the lines appear to be carrying:
|
|
18
|
+
* - hashline line-number prefixes (`123:`) when every content line has one
|
|
19
|
+
* - leading `+` (diff style) when at least half the lines have one
|
|
20
|
+
* - mixed `+<n>:` form when present
|
|
21
|
+
*
|
|
22
|
+
* Returns the lines untouched if no scheme is recognized.
|
|
23
|
+
*/
|
|
24
|
+
export declare function stripNewLinePrefixes(lines: string[]): string[];
|
|
25
|
+
/**
|
|
26
|
+
* Strict variant: strip hashline prefixes only when every content line is
|
|
27
|
+
* hashline-prefixed. Returns the lines unchanged otherwise.
|
|
28
|
+
*/
|
|
29
|
+
export declare function stripHashlinePrefixes(lines: string[]): string[];
|
|
30
|
+
/**
|
|
31
|
+
* Normalize line payloads by stripping read/search line prefixes. `null` /
|
|
32
|
+
* `undefined` yield `[]`; a single multiline string is split on `\n`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function hashlineParseText(edit: string[] | string | null | undefined): string[];
|