refactor(hashline): switched header syntax to bracketed [path#tag]
- Replaced `¶path#hash` prefix with `[path#hash]` delimiters across parser, tokenizer, and grammar. - Updated prompts, docs, and recovery paths to the new bracketed form.
This commit is contained in:
@@ -1864,7 +1864,7 @@ export const SETTINGS_SCHEMA = {
|
||||
tab: "editing",
|
||||
label: "Hash Lines",
|
||||
description:
|
||||
"Include snapshot-tag headers and line numbers in read output for hashline edit mode (¶PATH#tag plus LINE:content)",
|
||||
"Include snapshot-tag headers and line numbers in read output for hashline edit mode ([PATH#TAG] plus LINE:content)",
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { normalizeToLF } from "./normalize";
|
||||
/**
|
||||
* Upper bound on the file size we snapshot. A section tag is a content hash of
|
||||
* the *whole* file, so minting one means holding the full normalized text in
|
||||
* the store. Files above this cap emit no `¶path#tag` header — line-anchored
|
||||
* the store. Files above this cap emit no `[path#tag]` header — line-anchored
|
||||
* editing of multi-megabyte files is out of scope under the full-content model.
|
||||
*/
|
||||
export const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
@@ -275,7 +275,7 @@ function extractApprovalPath(args: unknown): string {
|
||||
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
||||
const input = typeof record.input === "string" ? record.input : undefined;
|
||||
if (input) {
|
||||
const hashlineMatch = /^(?:¶|§|@)([^\s#]+)/m.exec(input);
|
||||
const hashlineMatch = /^\[([^#\r\n]+)(?:#[0-9a-fA-F]{4})?\]/m.exec(input);
|
||||
if (hashlineMatch?.[1]) return hashlineMatch[1];
|
||||
|
||||
const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Edit tool renderer and LSP batching helpers.
|
||||
*/
|
||||
|
||||
import { HL_FILE_PREFIX } from "@oh-my-pi/hashline";
|
||||
import { HL_FILE_PREFIX, HL_FILE_SUFFIX } from "@oh-my-pi/hashline";
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
||||
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
||||
@@ -328,12 +328,12 @@ function normalizeHashlineInputPreviewPath(rawPath: string): string {
|
||||
}
|
||||
|
||||
function parseHashlineInputPreviewHeader(line: string): string | null {
|
||||
if (!line.startsWith(HL_FILE_PREFIX)) return null;
|
||||
// Mirror hashline/input.ts: strip every leading file marker so canonical
|
||||
// `¶ PATH` headers and stray `¶¶ PATH` / `¶¶¶PATH` runs render clean paths.
|
||||
let prefixEnd = 0;
|
||||
while (prefixEnd < line.length && line[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
|
||||
const body = line.slice(prefixEnd).trim();
|
||||
const trimmed = line.trimEnd();
|
||||
if (!trimmed.startsWith(HL_FILE_PREFIX)) return null;
|
||||
// Keep streaming previews tolerant while the closing bracket is still
|
||||
// being generated; the parser enforces the final `[path#TAG]` shape.
|
||||
const bodyEnd = trimmed.endsWith(HL_FILE_SUFFIX) ? trimmed.length - HL_FILE_SUFFIX.length : trimmed.length;
|
||||
const body = trimmed.slice(HL_FILE_PREFIX.length, bodyEnd).trim();
|
||||
const previewPath = normalizeHashlineInputPreviewPath(body);
|
||||
return previewPath.length > 0 ? previewPath : null;
|
||||
}
|
||||
|
||||
@@ -424,7 +424,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
||||
return previews.length > 0 ? previews : null;
|
||||
},
|
||||
renderStreamingFallback() {
|
||||
// Never leak raw hashline syntax (`64:`, `|payload`, `¶path#hash`)
|
||||
// Never leak raw hashline syntax (`64:`, `|payload`, `[path#hash]`)
|
||||
// to the user — the streaming preview already projects every
|
||||
// parseable op onto the real file via applyPartialTo, and an
|
||||
// unparseable trailing chunk renders as "no preview yet" rather
|
||||
|
||||
@@ -14,7 +14,7 @@ Performs structural AST-aware rewrites via native ast-grep.
|
||||
</instruction>
|
||||
|
||||
<output>
|
||||
- Replacement summary, per-file replacement counts, and change diffs as `¶src/foo.ts#0a`, `-12:before`, `+12:after` lines in hashline mode
|
||||
- Replacement summary, per-file replacement counts, and change diffs as `[src/foo.ts#1A2B]`, `-12:before`, `+12:after` lines in hashline mode
|
||||
- Parse issues when files cannot be processed
|
||||
</output>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Performs structural code search using AST matching via native ast-grep.
|
||||
|
||||
<output>
|
||||
- Grouped matches with file path, byte range, line/column ranges, metavariable captures
|
||||
- Match lines are numbered under a file snapshot tag header in hashline mode: `¶src/foo.ts#0a`, `*42:content` for the matched line, ` 43:content` for context
|
||||
- Match lines are numbered under a file snapshot tag header in hashline mode: `[src/foo.ts#1A2B]`, `*42:content` for the matched line, ` 43:content` for context
|
||||
- Summary counts (`totalMatches`, `filesWithMatches`, `filesSearched`) and parse issues when present
|
||||
</output>
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Append `:<sel>` to `path`. The bare path falls back to the default mode.
|
||||
|
||||
- Reading a directory path returns a depth-limited dirent listing.
|
||||
{{#if IS_HL_MODE}}
|
||||
- Reading a file with an explicit selector emits a file snapshot tag header and numbered lines: `¶src/foo.ts#0a` then `41:def alpha():`. Copy the `¶PATH#TAG` header for anchored edits; ops use bare line numbers. NEVER fabricate the tag.
|
||||
- Reading a file with an explicit selector emits a file snapshot tag header and numbered lines: `[src/foo.ts#1A2B]` then `41:def alpha():`. Copy the `[PATH#TAG]` header for anchored edits; ops use bare line numbers. NEVER fabricate the tag.
|
||||
{{else}}
|
||||
{{#if IS_LINE_NUMBER_MODE}}
|
||||
- Reading a file with an explicit selector returns lines prefixed with line numbers: `41|def alpha():`.
|
||||
|
||||
@@ -9,7 +9,7 @@ Searches files using powerful regex matching.
|
||||
|
||||
<output>
|
||||
{{#if IS_HL_MODE}}
|
||||
- Text output emits a file snapshot tag header per matched file plus numbered lines: `¶src/login.ts#1f`, `*42:if (user.id) {` (match), ` 43:return user;` (context). Copy the header for anchored edits; ops use bare line numbers.
|
||||
- Text output emits a file snapshot tag header per matched file plus numbered lines: `[src/login.ts#1A2B]`, `*42:if (user.id) {` (match), ` 43:return user;` (context). Copy the header for anchored edits; ops use bare line numbers.
|
||||
{{else}}
|
||||
{{#if IS_LINE_NUMBER_MODE}}
|
||||
- Text output is line-number-prefixed
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
import { ToolError } from "./tool-errors";
|
||||
import { toolResult } from "./tool-result";
|
||||
|
||||
const LOOSE_HASHLINE_HEADER_RE = /^\s*¶\S+#[^ \t\r\n]*\s*$/;
|
||||
const LOOSE_HASHLINE_HEADER_RE = /^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$/;
|
||||
|
||||
let fflateModulePromise: Promise<typeof import("fflate")> | undefined;
|
||||
async function loadFflate(): Promise<typeof import("fflate")> {
|
||||
@@ -109,7 +109,7 @@ function stripWriteContentWithPotentialLooseHeader(lines: string[]): { text: str
|
||||
/**
|
||||
* Strip hashline display prefixes from write content.
|
||||
*
|
||||
* Only active when hashline edit mode is enabled — the model sees `¶PATH#HASH`
|
||||
* Only active when hashline edit mode is enabled — the model sees `[PATH#HASH]`
|
||||
* headers plus `LINE:` prefixes in read output and sometimes copies them into write content.
|
||||
*/
|
||||
function stripWriteContent(session: ToolSession, content: string): { text: string; stripped: boolean } {
|
||||
@@ -122,7 +122,7 @@ function stripWriteContent(session: ToolSession, content: string): { text: strin
|
||||
/**
|
||||
* Record a snapshot of the freshly-written `content` for `absolutePath`
|
||||
* so subsequent hashline edits address the new file with a current tag,
|
||||
* and return the matching `¶displayPath#TAG` header. Returns `undefined`
|
||||
* and return the matching `[displayPath#TAG]` header. Returns `undefined`
|
||||
* when the session is not in hashline mode so callers can no-op cheaply.
|
||||
*
|
||||
* Mirrors the post-commit snapshot recording the hashline patcher performs
|
||||
@@ -770,7 +770,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
||||
context?: AgentToolContext,
|
||||
): Promise<AgentToolResult<WriteToolDetails>> {
|
||||
return untilAborted(signal, async () => {
|
||||
// Strip hashline display prefixes (¶PATH#HASH + LINE:) if the model copied them from read output
|
||||
// Strip hashline display prefixes ([PATH#HASH] + LINE:) if the model copied them from read output
|
||||
const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
|
||||
const internalRouter = InternalUrlRouter.instance();
|
||||
if (internalRouter.canHandle(path)) {
|
||||
|
||||
@@ -184,7 +184,7 @@ describe("hashline normalization", () => {
|
||||
|
||||
describe("hashline parser — range-anchor syntax", () => {
|
||||
it("keeps parsed edits reusable across different target snapshots", () => {
|
||||
const section = Patch.parseSingle(["¶a.ts", `insert after ${tag(2, "bbb")}:`, repl("tail")].join("\n"));
|
||||
const section = Patch.parseSingle(["[a.ts]", `insert after ${tag(2, "bbb")}:`, repl("tail")].join("\n"));
|
||||
|
||||
expect(section.applyTo("aaa\nbbb").text).toBe("aaa\nbbb\ntail");
|
||||
expect(section.applyTo("aaa\nbbb\nccc").text).toBe("aaa\nbbb\ntail\nccc");
|
||||
@@ -546,9 +546,9 @@ describe("hashline — snapshot tag binding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitHashlineInput — @ headers", () => {
|
||||
it("extracts path, snapshot tag, and diff body from @path#tag header", () => {
|
||||
const input = [`¶src/foo.ts#1A2B`, `${sameLineRange(tag(2, "bbb"))}`, repl("BBB")].join("\n");
|
||||
describe("splitHashlineInput — bracket headers", () => {
|
||||
it("extracts path, snapshot tag, and diff body from [path#tag] header", () => {
|
||||
const input = [`[src/foo.ts#1A2B]`, `${sameLineRange(tag(2, "bbb"))}`, repl("BBB")].join("\n");
|
||||
expect(splitHashlineInput(input)).toEqual({
|
||||
path: "src/foo.ts",
|
||||
fileHash: "1A2B",
|
||||
@@ -557,7 +557,7 @@ describe("splitHashlineInput — @ headers", () => {
|
||||
});
|
||||
|
||||
it("strips leading blank lines", () => {
|
||||
expect(splitHashlineInput(`\n¶foo.ts\ninsert head:\n${repl("x")}`)).toEqual({
|
||||
expect(splitHashlineInput(`\n[foo.ts]\ninsert head:\n${repl("x")}`)).toEqual({
|
||||
path: "foo.ts",
|
||||
diff: `insert head:\n${repl("x")}`,
|
||||
});
|
||||
@@ -566,7 +566,7 @@ describe("splitHashlineInput — @ headers", () => {
|
||||
it("normalizes cwd-prefixed absolute paths to cwd-relative paths", () => {
|
||||
const cwd = process.cwd();
|
||||
const absolute = path.join(cwd, "src", "foo.ts");
|
||||
expect(splitHashlineInput(`¶${absolute}\ninsert head:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts");
|
||||
expect(splitHashlineInput(`[${absolute}]\ninsert head:\n${repl("x")}`, { cwd }).path).toBe("src/foo.ts");
|
||||
});
|
||||
|
||||
it("uses explicit fallback path only when input has recognizable operations", () => {
|
||||
@@ -578,7 +578,7 @@ describe("splitHashlineInput — @ headers", () => {
|
||||
});
|
||||
|
||||
it("splits multiple edit sections", () => {
|
||||
const input = ["¶a.ts", "insert head:", repl("a"), "¶b.ts", "insert tail:", repl("b")].join("\n");
|
||||
const input = ["[a.ts]", "insert head:", repl("a"), "[b.ts]", "insert tail:", repl("b")].join("\n");
|
||||
expect(splitHashlineInputs(input)).toEqual([
|
||||
{ path: "a.ts", diff: `insert head:\n${repl("a")}` },
|
||||
{ path: "b.ts", diff: `insert tail:\n${repl("b")}` },
|
||||
@@ -595,7 +595,7 @@ describe("splitHashlineInput — @ headers", () => {
|
||||
});
|
||||
|
||||
it("silently drops a trailing header with no operations", () => {
|
||||
const input = ["¶a.ts", "insert head:", repl("a"), "¶b.ts"].join("\n");
|
||||
const input = ["[a.ts]", "insert head:", repl("a"), "[b.ts]"].join("\n");
|
||||
expect(splitHashlineInputs(input)).toEqual([{ path: "a.ts", diff: `insert head:\n${repl("a")}` }]);
|
||||
});
|
||||
});
|
||||
@@ -630,7 +630,7 @@ it("preflights write policy for every section before committing a batch", async
|
||||
describe("hashline executor", () => {
|
||||
it("rejects file creation and directs to the write tool", async () => {
|
||||
await withTempDir(async tempDir => {
|
||||
const input = `¶new.ts\ninsert head:\n${repl("export const x = 1;")}\n`;
|
||||
const input = `[new.ts]\ninsert head:\n${repl("export const x = 1;")}\n`;
|
||||
await expect(executeHashlineSingle(hashlineExecuteOptions(tempDir, input))).rejects.toThrow(/write tool/);
|
||||
expect(await Bun.file(path.join(tempDir, "new.ts")).exists()).toBe(false);
|
||||
});
|
||||
@@ -681,7 +681,7 @@ describe("hashline executor", () => {
|
||||
await Bun.write(bPath, "bbb\n");
|
||||
const session = makeHashlineSession(tempDir);
|
||||
const aTag = recordFullSnapshot(getFileReadCache(session), aPath, "aaa\n");
|
||||
const bHeader = "¶b.ts#FFFF";
|
||||
const bHeader = "[b.ts#FFFF]";
|
||||
const input = [
|
||||
header("a.ts", aTag),
|
||||
`${sameLineRange(tag(1, "aaa"))}`,
|
||||
@@ -795,14 +795,14 @@ describe("hashlineEditParamsSchema — payload shape", () => {
|
||||
|
||||
it("tolerates provider extra fields without declaring `path`", () => {
|
||||
expect(
|
||||
hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `¶x.ts\ninsert head:\n${repl("x")}` }).success,
|
||||
hashlineEditParamsSchema.safeParse({ path: "x.ts", input: `[x.ts]\ninsert head:\n${repl("x")}` }).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts `_input` as a provider-emitted alias for `input`", () => {
|
||||
const parsed = hashlineEditParamsSchema.safeParse({ _input: `¶x.ts\ninsert head:\n${repl("x")}` });
|
||||
const parsed = hashlineEditParamsSchema.safeParse({ _input: `[x.ts]\ninsert head:\n${repl("x")}` });
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) expect(parsed.data.input).toBe(`¶x.ts\ninsert head:\n${repl("x")}`);
|
||||
if (parsed.success) expect(parsed.data.input).toBe(`[x.ts]\ninsert head:\n${repl("x")}`);
|
||||
});
|
||||
|
||||
it("still requires `input`", () => {
|
||||
@@ -1088,11 +1088,11 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () =>
|
||||
|
||||
it("splitter respects *** Abort like *** End Patch", () => {
|
||||
const input = [
|
||||
`¶a.ts`,
|
||||
`[a.ts]`,
|
||||
`insert after ${tag(1, "alpha")}:`,
|
||||
repl("a-payload"),
|
||||
sentinel,
|
||||
`¶b.ts`,
|
||||
`[b.ts]`,
|
||||
`insert after ${tag(1, "beta")}:`,
|
||||
repl("never-emitted"),
|
||||
].join("\n");
|
||||
@@ -1112,33 +1112,33 @@ describe("hashline *** Abort recovery sentinel (harmony-leak mitigation)", () =>
|
||||
describe("hashline parser — delete and empty-block semantics", () => {
|
||||
it("inline delete deletes a single line", () => {
|
||||
const text = "line1\nline2\nline3\n";
|
||||
const { diff } = splitHashlineInput(`¶a.ts\ndelete 2\n`);
|
||||
const { diff } = splitHashlineInput(`[a.ts]\ndelete 2\n`);
|
||||
expect(applyDiff(text, diff)).toBe("line1\nline3\n");
|
||||
});
|
||||
|
||||
it("inline delete deletes the range", () => {
|
||||
const text = "line1\nline2\nline3\nline4\n";
|
||||
const { diff } = splitHashlineInput(`¶a.ts\ndelete 2..3\n`);
|
||||
const { diff } = splitHashlineInput(`[a.ts]\ndelete 2..3\n`);
|
||||
expect(applyDiff(text, diff)).toBe("line1\nline4\n");
|
||||
});
|
||||
|
||||
it("empty replace removes the range", () => {
|
||||
const text = "line1\nline2\nline3\n";
|
||||
const { diff } = splitHashlineInput(`¶a.ts\nreplace 2..2:\n`);
|
||||
const { diff } = splitHashlineInput(`[a.ts]\nreplace 2..2:\n`);
|
||||
expect(applyDiff(text, diff)).toBe("line1\nline3\n");
|
||||
});
|
||||
|
||||
it("`2..2=replacement` (old format) parses as orphan body, not as inline payload", () => {
|
||||
const { diff } = splitHashlineInput(`¶a.ts\n2..2=replacement\n`);
|
||||
const { diff } = splitHashlineInput(`[a.ts]\n2..2=replacement\n`);
|
||||
expect(() => parseHashline(diff)).toThrow(/payload line has no preceding hunk header/);
|
||||
});
|
||||
|
||||
it("explicit empty literal rows insert blank lines when the anchor is repeated", () => {
|
||||
const text = "line1\nline2\nline3\n";
|
||||
const aboveDiff = splitHashlineInput(`¶a.ts\ninsert before 2:\n${repl("")}\n`).diff;
|
||||
const aboveDiff = splitHashlineInput(`[a.ts]\ninsert before 2:\n${repl("")}\n`).diff;
|
||||
expect(applyDiff(text, aboveDiff)).toBe("line1\n\nline2\nline3\n");
|
||||
|
||||
const belowDiff = splitHashlineInput(`¶a.ts\ninsert after 2:\n${repl("")}\n`).diff;
|
||||
const belowDiff = splitHashlineInput(`[a.ts]\ninsert after 2:\n${repl("")}\n`).diff;
|
||||
expect(applyDiff(text, belowDiff)).toBe("line1\nline2\n\nline3\n");
|
||||
});
|
||||
});
|
||||
@@ -1146,28 +1146,28 @@ describe("hashline parser — delete and empty-block semantics", () => {
|
||||
describe("hashline parser — explicit blank payload rows", () => {
|
||||
it("raw blank lines between ops are ignored", () => {
|
||||
const text = "a\nb\nc\nd\ne\n";
|
||||
const ops = `¶a.ts\nreplace 1..1:\n${repl("A")}\n\nreplace 3..3:\n${repl("C")}\n`;
|
||||
const ops = `[a.ts]\nreplace 1..1:\n${repl("A")}\n\nreplace 3..3:\n${repl("C")}\n`;
|
||||
const { diff } = splitHashlineInput(ops);
|
||||
expect(applyDiff(text, diff)).toBe("A\nb\nC\nd\ne\n");
|
||||
});
|
||||
|
||||
it("empty replacement payload rows are appended as blank payload lines", () => {
|
||||
const text = "a\nb\nc\nd\ne\n";
|
||||
const ops = `¶a.ts\nreplace 1..1:\n${repl("A")}\n${repl("")}\n${repl("")}\nreplace 3..3:\n${repl("C")}\n`;
|
||||
const ops = `[a.ts]\nreplace 1..1:\n${repl("A")}\n${repl("")}\n${repl("")}\nreplace 3..3:\n${repl("C")}\n`;
|
||||
const { diff } = splitHashlineInput(ops);
|
||||
expect(applyDiff(text, diff)).toBe("A\n\n\nb\nC\nd\ne\n");
|
||||
});
|
||||
|
||||
it("`replace N..N:` followed by two empty replace rows replaces the line with two blanks", () => {
|
||||
const text = "a\nb\nc\nd\ne\n";
|
||||
const ops = `¶a.ts\nreplace 2..2:\n${repl("")}\n${repl("")}\nreplace 4..4:\n${repl("D")}\n`;
|
||||
const ops = `[a.ts]\nreplace 2..2:\n${repl("")}\n${repl("")}\nreplace 4..4:\n${repl("D")}\n`;
|
||||
const { diff } = splitHashlineInput(ops);
|
||||
expect(applyDiff(text, diff)).toBe("a\n\n\nc\nD\ne\n");
|
||||
});
|
||||
|
||||
it("empty replace row inside payload between two content lines is preserved", () => {
|
||||
const text = "a\nb\nc\n";
|
||||
const ops = `¶a.ts\nreplace 2..2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`;
|
||||
const ops = `[a.ts]\nreplace 2..2:\n${repl("first")}\n${repl("")}\n${repl("second")}\n`;
|
||||
const { diff } = splitHashlineInput(ops);
|
||||
expect(applyDiff(text, diff)).toBe("a\nfirst\n\nsecond\nc\n");
|
||||
});
|
||||
|
||||
@@ -276,7 +276,7 @@ describe("computeHashlineDiff", () => {
|
||||
// preview/diff path MUST emit the SAME rejection so a successful preview
|
||||
// never precedes a failing apply.
|
||||
const result = await computeHashlineDiff(
|
||||
{ input: `¶${relativePath}\ninsert tail:\n+second` },
|
||||
{ input: `[${relativePath}]\ninsert tail:\n+second` },
|
||||
tempDir,
|
||||
new InMemorySnapshotStore(),
|
||||
);
|
||||
@@ -287,7 +287,7 @@ describe("computeHashlineDiff", () => {
|
||||
});
|
||||
test("returns a handled error when the source path is a local URL", async () => {
|
||||
const result = await computeHashlineDiff(
|
||||
{ input: "¶local://PLAN.md\ninsert tail:\n+x" },
|
||||
{ input: "[local://PLAN.md]\ninsert tail:\n+x" },
|
||||
tempDir,
|
||||
new InMemorySnapshotStore(),
|
||||
);
|
||||
|
||||
@@ -136,7 +136,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => {
|
||||
});
|
||||
|
||||
test("does not surface stale hash errors while streaming", async () => {
|
||||
const input = "¶a.ts#FFFF\nreplace 2..2:\n+const b = 22";
|
||||
const input = "[a.ts#FFFF]\nreplace 2..2:\n+const b = 22";
|
||||
const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir) as never);
|
||||
expect(previews).toHaveLength(1);
|
||||
expect(previews?.[0]?.error).toBeUndefined();
|
||||
@@ -170,7 +170,7 @@ describe("hashline streaming preview (single-op trailing payload)", () => {
|
||||
});
|
||||
|
||||
test("surfaces stale hash errors once streaming is complete", async () => {
|
||||
const input = "¶a.ts#FFFF\nreplace 2..2:\n+const b = 22\n";
|
||||
const input = "[a.ts#FFFF]\nreplace 2..2:\n+const b = 22\n";
|
||||
const previews = await strategy.computeDiffPreview({ input } as never, ctx(tmpDir, false) as never);
|
||||
expect(previews).toHaveLength(1);
|
||||
expect(previews?.[0]?.error).toContain("not from this session");
|
||||
@@ -245,12 +245,12 @@ describe("apply_patch streaming preview (trailing partial line)", () => {
|
||||
|
||||
describe("matcherDigest", () => {
|
||||
test("hashline: digests stripped `+` body rows only, never headers or op lines", () => {
|
||||
const input = ["¶a.ts#AB12", "replace 1..2:", "+const x = 1;", "+const y = 2;", "delete 5", ""].join("\n");
|
||||
const input = ["[a.ts#AB12]", "replace 1..2:", "+const x = 1;", "+const y = 2;", "delete 5", ""].join("\n");
|
||||
expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input })).toBe("const x = 1;\nconst y = 2;");
|
||||
});
|
||||
|
||||
test("hashline: grammar-only payload digests to empty, missing input to undefined", () => {
|
||||
expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input: "¶a.ts#AB12\ndelete 3\n" })).toBe("");
|
||||
expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input: "[a.ts#AB12]\ndelete 3\n" })).toBe("");
|
||||
expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({})).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import type { ReadToolDetails } from "@oh-my-pi/pi-coding-agent/tools/read";
|
||||
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
|
||||
|
||||
const HASHLINE_HEADER_LINE = /^¶(\S+)#([0-9A-F]{4})$/m;
|
||||
const HASHLINE_HEADER_LINE = /^\[([^#\r\n]+)#([0-9A-F]{4})\]$/m;
|
||||
const COLUMN_CAP = 64;
|
||||
const LONG_LINE_LEN = COLUMN_CAP * 3;
|
||||
|
||||
|
||||
@@ -511,7 +511,7 @@ describe("write resolves conflicts via conflict://N", () => {
|
||||
await read.execute("read-hashed", { path: "hashed.ts" });
|
||||
const result = await write.execute("write-hashed", {
|
||||
path: "conflict://1",
|
||||
content: "¶hashed.ts#1a2b\n42:cleanline\n",
|
||||
content: "[hashed.ts#1a2b]\n42:cleanline\n",
|
||||
});
|
||||
expect(getText(result)).toContain("auto-stripped hashline display prefixes");
|
||||
const after = await Bun.file(filePath).text();
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("editToolRenderer", () => {
|
||||
const uiTheme = await getUiTheme();
|
||||
const component = editToolRenderer.renderCall(
|
||||
{
|
||||
input: "¶packages/coding-agent/src/edit/renderer.ts\nEOF:\n|// preview",
|
||||
input: "[packages/coding-agent/src/edit/renderer.ts]\ninsert tail:\n+// preview",
|
||||
},
|
||||
{ expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
|
||||
uiTheme,
|
||||
@@ -75,9 +75,12 @@ describe("editToolRenderer", () => {
|
||||
const component = new ToolExecutionComponent(
|
||||
"edit",
|
||||
{
|
||||
input: ["*** Begin Patch", "¶crates/pi-natives/src/shell.rs", "EOF:", "|pub fn streaming_preview() {"].join(
|
||||
"\n",
|
||||
),
|
||||
input: [
|
||||
"*** Begin Patch",
|
||||
"[crates/pi-natives/src/shell.rs]",
|
||||
"insert tail:",
|
||||
"+pub fn streaming_preview() {",
|
||||
].join("\n"),
|
||||
},
|
||||
{},
|
||||
hashlineTool,
|
||||
@@ -86,8 +89,8 @@ describe("editToolRenderer", () => {
|
||||
|
||||
const rendered = Bun.stripANSI(component.render(160).join("\n"));
|
||||
expect(rendered).toContain("crates/pi-natives/src/shell.rs");
|
||||
expect(rendered).not.toContain("EOF:");
|
||||
expect(rendered).not.toContain("|pub fn streaming_preview() {");
|
||||
expect(rendered).not.toContain("insert tail:");
|
||||
expect(rendered).not.toContain("+pub fn streaming_preview() {");
|
||||
expect(rendered).not.toContain("*** Begin Patch");
|
||||
});
|
||||
|
||||
@@ -95,7 +98,7 @@ describe("editToolRenderer", () => {
|
||||
const uiTheme = await getUiTheme();
|
||||
const compactComponent = editToolRenderer.renderCall(
|
||||
{
|
||||
input: "¶foo bar.ts\nBOF:\n|// preview",
|
||||
input: "[foo bar.ts]\ninsert head:\n+// preview",
|
||||
},
|
||||
{ expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
|
||||
uiTheme,
|
||||
@@ -103,7 +106,7 @@ describe("editToolRenderer", () => {
|
||||
|
||||
const quotedComponent = editToolRenderer.renderCall(
|
||||
{
|
||||
input: "¶'baz qux.ts'\nBOF:\n|// preview",
|
||||
input: "['baz qux.ts']\ninsert head:\n+// preview",
|
||||
},
|
||||
{ expanded: false, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
|
||||
uiTheme,
|
||||
@@ -115,33 +118,33 @@ describe("editToolRenderer", () => {
|
||||
expect(quotedRendered).toContain("baz qux.ts");
|
||||
});
|
||||
|
||||
it("strips canonical `¶` and longer `¶` runs from hashline input headers", async () => {
|
||||
it("strips bracket delimiters from hashline input headers", async () => {
|
||||
const uiTheme = await getUiTheme();
|
||||
|
||||
// Canonical `¶PATH` form — the parser strips the marker and the
|
||||
// Canonical `[PATH]` form — the parser strips the delimiters and the
|
||||
// renderer keeps the title clean.
|
||||
const canonical = editToolRenderer.renderCall(
|
||||
{
|
||||
input: "¶packages/coding-agent/src/slash-commands/builtin-registry.ts\nBOF:\n|// preview",
|
||||
input: "[packages/coding-agent/src/slash-commands/builtin-registry.ts]\ninsert head:\n+// preview",
|
||||
},
|
||||
{ expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
|
||||
uiTheme,
|
||||
);
|
||||
|
||||
// Even longer runs should still produce the clean path.
|
||||
const triple = editToolRenderer.renderCall(
|
||||
{ input: "¶¶¶a/b/c.ts\nBOF:\n|// preview" },
|
||||
// While streaming, the closing bracket may not have arrived yet.
|
||||
const partial = editToolRenderer.renderCall(
|
||||
{ input: "[a/b/c.ts\ninsert head:\n+// preview" },
|
||||
{ expanded: true, isPartial: true, spinnerFrame: 0, renderContext: { editMode: "hashline" } },
|
||||
uiTheme,
|
||||
);
|
||||
|
||||
const canonicalRendered = Bun.stripANSI(canonical.render(160).join("\n"));
|
||||
const tripleRendered = Bun.stripANSI(triple.render(160).join("\n"));
|
||||
const partialRendered = Bun.stripANSI(partial.render(160).join("\n"));
|
||||
|
||||
expect(canonicalRendered).toContain("packages/coding-agent/src/slash-commands/builtin-registry.ts");
|
||||
expect(canonicalRendered).not.toMatch(/¶packages\/coding-agent/);
|
||||
expect(tripleRendered).toContain("a/b/c.ts");
|
||||
expect(tripleRendered).not.toMatch(/¶+a\/b\/c\.ts/);
|
||||
expect(canonicalRendered).not.toMatch(/\[packages\/coding-agent/);
|
||||
expect(partialRendered).toContain("a/b/c.ts");
|
||||
expect(partialRendered).not.toMatch(/\[a\/b\/c\.ts/);
|
||||
});
|
||||
|
||||
it("uses hashline input headers for completed single-file result path", async () => {
|
||||
@@ -157,7 +160,7 @@ describe("editToolRenderer", () => {
|
||||
{ expanded: false, isPartial: false, renderContext: { editMode: "hashline" } },
|
||||
uiTheme,
|
||||
{
|
||||
input: "¶packages/coding-agent/src/edit/renderer.ts\nEOF:\n|// preview",
|
||||
input: "[packages/coding-agent/src/edit/renderer.ts]\ninsert tail:\n+// preview",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -182,7 +185,7 @@ describe("editToolRenderer", () => {
|
||||
// The trailing payload line carries no newline — the common shape for a
|
||||
// single-line edit. The streaming pass trims that in-flight line, so the
|
||||
// preview only becomes computable once args are marked complete.
|
||||
const input = `¶memory.ts#${tag}\nreplace 2..2:\n+export const b = 22;`;
|
||||
const input = `[memory.ts#${tag}]\nreplace 2..2:\n+export const b = 22;`;
|
||||
const component = new ToolExecutionComponent("edit", { input }, { snapshots }, hashlineTool, uiStub, tmpDir);
|
||||
|
||||
component.setArgsComplete();
|
||||
@@ -208,7 +211,7 @@ describe("editToolRenderer", () => {
|
||||
|
||||
const snapshots = new InMemorySnapshotStore();
|
||||
const tag = snapshots.record(filePath, content);
|
||||
const input = `¶memory.ts#${tag}\nreplace 2..2:\n+export const b = 22;\n`;
|
||||
const input = `[memory.ts#${tag}]\nreplace 2..2:\n+export const b = 22;\n`;
|
||||
const component = new ToolExecutionComponent(
|
||||
"edit",
|
||||
{ __partialJson: input },
|
||||
|
||||
@@ -251,7 +251,7 @@ describe("SearchTool internal URL resolution", () => {
|
||||
const text = getResultText(result);
|
||||
expect(text).toContain("needle");
|
||||
// No hashline section headers or numbered editable lines for immutable sources.
|
||||
expect(text).not.toMatch(/^¶.*#[0-9A-F]{4}$/m);
|
||||
expect(text).not.toMatch(/^\[[^#\r\n]+#[0-9A-F]{4}\]$/m);
|
||||
expect(text).not.toMatch(/^\*?\s*\d+:/m);
|
||||
});
|
||||
|
||||
@@ -291,7 +291,7 @@ describe("SearchTool internal URL resolution", () => {
|
||||
const text = getResultText(result);
|
||||
expect(text).toContain("needle");
|
||||
// Mutable local:// sources keep a hashline section header plus numbered match lines.
|
||||
expect(text).toMatch(/^¶.*#[0-9A-F]{4}$/m);
|
||||
expect(text).toMatch(/^\[[^#\r\n]+#[0-9A-F]{4}\]$/m);
|
||||
expect(text).toMatch(/^\*\d+:.*needle/m);
|
||||
});
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ describe("tool path arrays", () => {
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; missingPaths?: string[] } | undefined;
|
||||
|
||||
expect(text).toMatch(/^¶packages\/grep\.txt#[0-9A-F]{4}/m);
|
||||
expect(text).toMatch(/^\[packages\/grep\.txt#[0-9A-F]{4}\]/m);
|
||||
expect(text).toContain("Skipped missing paths: missing.txt");
|
||||
expect(text).not.toContain("apps");
|
||||
expect(details?.fileCount).toBe(1);
|
||||
|
||||
@@ -349,7 +349,7 @@ describe("TtsrManager snapshot matching", () => {
|
||||
streamKey: "toolcall:tc-1",
|
||||
};
|
||||
const patch = [
|
||||
"¶src/repo.ts#AB12",
|
||||
"[src/repo.ts#AB12]",
|
||||
"replace block 1:",
|
||||
"+export async function isRepository(cwd: string): Promise<boolean> {",
|
||||
"+\treturn repo.isRepository(cwd);",
|
||||
|
||||
@@ -30,7 +30,7 @@ function resultText(result: { content: { type: string; text?: string }[] }): str
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const HASHLINE_HEADER_LINE = /^¶(\S+)#([0-9A-F]{4})$/;
|
||||
const HASHLINE_HEADER_LINE = /^\[([^#\r\n]+)#([0-9A-F]{4})\]$/;
|
||||
|
||||
describe("write tool hashline header", () => {
|
||||
let tmpDir: string;
|
||||
@@ -47,7 +47,7 @@ describe("write tool hashline header", () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("insert heads a fresh ¶path#TAG header that maps to the written content", async () => {
|
||||
it("inserts a fresh [path#TAG] header that maps to the written content", async () => {
|
||||
const filePath = path.join(tmpDir, "module.ts");
|
||||
const session = createSession(tmpDir);
|
||||
const tool = new WriteTool(session);
|
||||
@@ -111,7 +111,7 @@ describe("write tool hashline header", () => {
|
||||
|
||||
const result = await tool.execute("call-1", { path: filePath, content });
|
||||
const text = resultText(result);
|
||||
expect(text.startsWith("¶")).toBe(false);
|
||||
expect(text.startsWith("[")).toBe(false);
|
||||
expect(text).toBe(`Successfully wrote ${content.length} bytes to ${path.relative(tmpDir, filePath)}`);
|
||||
});
|
||||
});
|
||||
|
||||
+17
-16
@@ -23,10 +23,10 @@ const snapshots = new InMemorySnapshotStore();
|
||||
const before = `const greeting = "hi";\nexport { greeting };\n`;
|
||||
await fs.writeText("hello.ts", before);
|
||||
|
||||
const tag = snapshots.recordContiguous("hello.ts", 1, before.split("\n"), { fullText: before });
|
||||
const tag = snapshots.record("hello.ts", before);
|
||||
const patcher = new Patcher({ fs, snapshots });
|
||||
const patch = Patch.parse(String.raw`¶hello.ts#${tag}
|
||||
@@ 1..1 @@
|
||||
const patch = Patch.parse(String.raw`[hello.ts#${tag}]
|
||||
replace 1..1:
|
||||
+const greeting = "hello";`);
|
||||
const result = await patcher.apply(patch);
|
||||
|
||||
@@ -39,19 +39,19 @@ console.log(await fs.readText("hello.ts"));
|
||||
See [`src/prompt.md`](./src/prompt.md) for the user-facing description and
|
||||
[`src/grammar.lark`](./src/grammar.lark) for the formal grammar.
|
||||
|
||||
Each file section starts with `¶PATH#TAG`. The tag is a 3-hex opaque
|
||||
pointer into the `SnapshotStore` that minted it; it is not content-derived
|
||||
and is not meaningful outside that store. The patcher protects against
|
||||
stale anchors by resolving the tag, verifying the recorded snapshot lines
|
||||
against live file content, and refusing or attempting session-aware
|
||||
recovery on mismatch.
|
||||
Each file section starts with `[PATH#TAG]`. The tag is a 4-hex
|
||||
content hash of the full normalized file text recorded by the
|
||||
`SnapshotStore`, and it is not meaningful outside that store. The patcher
|
||||
protects against stale anchors by resolving the tag, verifying the live file
|
||||
still matches the recorded content hash, and refusing or attempting
|
||||
session-aware recovery on mismatch.
|
||||
|
||||
Inside a section:
|
||||
- `@@ A..B @@` — open a hunk on lines A..B (use `@@ A,A @@` for a single line; bare `@@ A @@` is also accepted).
|
||||
- `@@ BOF @@` / `@@ EOF @@` — virtual hunks at the beginning/end of file.
|
||||
- `replace A..B:` — replace lines A..B with following `+TEXT` body rows.
|
||||
- `replace block A:` — replace the syntactic block beginning on line A.
|
||||
- `delete A..B` / `delete block A` — delete concrete lines or a resolved block.
|
||||
- `insert before A:` / `insert after A:` / `insert head:` / `insert tail:` — insert following body rows.
|
||||
- `+TEXT` — literal body row (use `+` alone for a blank line).
|
||||
- `&A..B` — repeat original file lines A..B inline (`&A` for one line).
|
||||
- Empty body — delete the selected range.
|
||||
|
||||
## Abstractions
|
||||
|
||||
@@ -67,9 +67,10 @@ text-document protocol, a Git tree, anything.
|
||||
|
||||
### `SnapshotStore`
|
||||
|
||||
Required. Hashline tags are opaque store pointers, so `Patcher` must receive
|
||||
the store that minted them. Recovery replays edits against the cached pre-edit
|
||||
snapshot and 3-way-merges onto current content when the live file diverged.
|
||||
Required. Hashline tags are full-file content hashes recorded per path, so
|
||||
`Patcher` must receive the store that observed them. Recovery replays edits
|
||||
against the cached pre-edit snapshot and 3-way-merges onto current content
|
||||
when the live file diverged.
|
||||
|
||||
### `Patcher`
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
import type { Cursor } from "./types";
|
||||
|
||||
/** File-section header prefix: `¶path#hash`. */
|
||||
export const HL_FILE_PREFIX = "¶";
|
||||
/** File-section header delimiters: `[path#hash]`. */
|
||||
export const HL_FILE_PREFIX = "[";
|
||||
export const HL_FILE_SUFFIX = "]";
|
||||
|
||||
/** Payload sigil for literal body rows. */
|
||||
export const HL_PAYLOAD_REPLACE = "+";
|
||||
@@ -118,7 +119,7 @@ export function describeAnchorExamples(linePrefix = ""): string {
|
||||
|
||||
/** Format a hashline section header for a file path and snapshot tag. */
|
||||
export function formatHashlineHeader(filePath: string, fileHash: string): string {
|
||||
return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}`;
|
||||
return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}${HL_FILE_SUFFIX}`;
|
||||
}
|
||||
|
||||
/** Formats a single numbered line as `LINE:TEXT`. */
|
||||
|
||||
@@ -3,7 +3,7 @@ begin_patch: "*** Begin Patch" LF
|
||||
end_patch: "*** End Patch" LF?
|
||||
|
||||
file_patch: file_header hunk+
|
||||
file_header: "¶" filename "#" file_hash LF
|
||||
file_header: "[" filename "#" file_hash "]" LF
|
||||
file_hash: /[0-9A-F]{4}/
|
||||
filename: /[^#\r\n]+/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Top-level patch parser. Splits an authored hashline input into a list of
|
||||
* {@link PatchSection}s, each rooted at a `¶PATH#HASH` header, then exposes
|
||||
* {@link PatchSection}s, each rooted at a `[PATH#HASH]` header, then exposes
|
||||
* a {@link Patch} class that gives lazy access to the parsed edits per
|
||||
* section.
|
||||
*
|
||||
@@ -10,7 +10,7 @@
|
||||
import * as path from "node:path";
|
||||
import { applyEdits } from "./apply";
|
||||
import { resolveBlockEdits } from "./block";
|
||||
import { HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
|
||||
import { HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
|
||||
import { parsePatch, parsePatchStreaming } from "./parser";
|
||||
import { Tokenizer } from "./tokenizer";
|
||||
import type { ApplyResult, BlockResolver, Edit, SplitOptions } from "./types";
|
||||
@@ -47,15 +47,17 @@ function stripApplyPatchPathNoise(pathText: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort recovery for `¶`-prefixed lines the strict tokenizer
|
||||
* Best-effort recovery for bracketed header lines the strict tokenizer
|
||||
* rejects. Strips apply_patch keyword noise (`Update File:`, `Update:`,
|
||||
* etc.) and an extra leading `***` (some models emit a hybrid `¶***foo.ts`
|
||||
* shape), then expects `PATH(#HASH)?`.
|
||||
* etc.) and an extra leading `***` (some models emit a hybrid
|
||||
* `[***foo.ts#HASH]` shape), then expects `PATH(#HASH)?`.
|
||||
* Returns `null` when no clean path can be salvaged.
|
||||
*/
|
||||
function tryParseRecoveryHeader(line: string, cwd?: string): RawSection | null {
|
||||
if (!line.startsWith(HL_FILE_PREFIX)) return null;
|
||||
const body = stripApplyPatchPathNoise(line.slice(HL_FILE_PREFIX.length).trim());
|
||||
if (!line.startsWith(HL_FILE_PREFIX) || !line.endsWith(HL_FILE_SUFFIX)) return null;
|
||||
const body = stripApplyPatchPathNoise(
|
||||
line.slice(HL_FILE_PREFIX.length, line.length - HL_FILE_SUFFIX.length).trim(),
|
||||
);
|
||||
if (body.length === 0) return null;
|
||||
|
||||
// Trailing `#XXXX` is the tag; everything before it is the path. The
|
||||
@@ -99,9 +101,9 @@ interface RawSection {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `¶PATH[#hash]` header line. Returns `null` for lines that do
|
||||
* not start with `¶`. Throws the strict "Input header must be …" error
|
||||
* when a `¶`-prefixed line fails the strict shape (so malformed paths
|
||||
* Parse a `[PATH]` or `[PATH#hash]` header line. Returns `null` for lines that do
|
||||
* not start with `[`. Throws the strict "Input header must be …" error
|
||||
* when a bracketed line fails the strict shape (so malformed paths
|
||||
* surface immediately instead of being silently re-classified as payload).
|
||||
*/
|
||||
function parseHashlineHeaderLine(line: string, cwd?: string): RawSection | null {
|
||||
@@ -111,18 +113,18 @@ function parseHashlineHeaderLine(line: string, cwd?: string): RawSection | null
|
||||
const token = TOKENIZER.tokenize(trimmed);
|
||||
if (token.kind !== "header") {
|
||||
// Recovery: try to extract a path from the raw line after stripping
|
||||
// apply_patch noise. This handles `*** Update File:foo.ts#CB5` and
|
||||
// apply_patch noise. This handles `[*** Update File:foo.ts#CB5A]` and
|
||||
// the half-dozen variants models actually emit.
|
||||
const recovered = tryParseRecoveryHeader(trimmed, cwd);
|
||||
if (recovered !== null) return recovered;
|
||||
throw new Error(
|
||||
`Input header must be ${HL_FILE_PREFIX}PATH or ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}TAG with a ${HL_FILE_HASH_LENGTH}-hex content-hash tag; got ${JSON.stringify(trimmed)}.`,
|
||||
`Input header must be ${HL_FILE_PREFIX}PATH${HL_FILE_SUFFIX} or ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}TAG${HL_FILE_SUFFIX} with a ${HL_FILE_HASH_LENGTH}-hex content-hash tag; got ${JSON.stringify(trimmed)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const parsedPath = normalizeHashlinePath(token.path, cwd);
|
||||
if (parsedPath.length === 0) {
|
||||
throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`);
|
||||
throw new Error(`Input header "${HL_FILE_PREFIX}${HL_FILE_SUFFIX}" is empty; provide a file path.`);
|
||||
}
|
||||
return token.fileHash !== undefined
|
||||
? { path: parsedPath, fileHash: token.fileHash, diff: "" }
|
||||
@@ -165,7 +167,7 @@ function normalizeFallbackInput(input: string, options: SplitOptions): string {
|
||||
if (!options.path || !containsRecognizableHashlineOperations(input)) return input;
|
||||
const fallbackPath = normalizeHashlinePath(options.path, options.cwd);
|
||||
if (fallbackPath.length === 0) return input;
|
||||
return `${HL_FILE_PREFIX}${fallbackPath}\n${input}`;
|
||||
return `${HL_FILE_PREFIX}${fallbackPath}${HL_FILE_SUFFIX}\n${input}`;
|
||||
}
|
||||
|
||||
function splitRawSections(input: string, options: SplitOptions = {}): RawSection[] {
|
||||
@@ -180,13 +182,13 @@ function splitRawSections(input: string, options: SplitOptions = {}): RawSection
|
||||
if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(firstTrimmed)) {
|
||||
throw new Error(
|
||||
"unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " +
|
||||
"File sections start with `¶path#HASH`; use `replace`, `delete`, or `insert` ops.",
|
||||
`File sections start with \`${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}HASH${HL_FILE_SUFFIX}\`; use \`replace\`, \`delete\`, or \`insert\` ops.`,
|
||||
);
|
||||
}
|
||||
const preview = JSON.stringify(firstLine.slice(0, 120));
|
||||
throw new Error(
|
||||
`input must begin with "${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH" on the first non-blank line for anchored edits; got: ${preview}. ` +
|
||||
`Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}0A3" then edit ops.`,
|
||||
`input must begin with "${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH${HL_FILE_SUFFIX}" on the first non-blank line for anchored edits; got: ${preview}. ` +
|
||||
`Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]}${HL_FILE_SUFFIX}" then edit ops.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -207,7 +209,7 @@ function splitRawSections(input: string, options: SplitOptions = {}): RawSection
|
||||
if (token.kind === "envelope-end" || token.kind === "abort") break;
|
||||
if (token.kind === "envelope-begin") continue;
|
||||
|
||||
// Route every `¶`-prefixed line through parseHashlineHeaderLine so
|
||||
// Route every bracket-prefixed line through parseHashlineHeaderLine so
|
||||
// malformed headers still raise the strict "Input header must be …"
|
||||
// diagnostic (the tokenizer alone would silently classify them as
|
||||
// payload).
|
||||
@@ -343,7 +345,7 @@ export class PatchSection {
|
||||
|
||||
/**
|
||||
* A parsed hashline patch — zero or more {@link PatchSection}s, each rooted
|
||||
* at a `¶PATH#HASH` header. Construct via {@link Patch.parse}.
|
||||
* at a `[PATH#HASH]` header. Construct via {@link Patch.parse}.
|
||||
*
|
||||
* `Patch` is pure data: parsing is line-anchored and does not look at the
|
||||
* filesystem. To apply a patch, hand it to {@link Patcher.apply}.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* them.
|
||||
*/
|
||||
|
||||
import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
|
||||
import { HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
|
||||
|
||||
/** Lines of context shown either side of a hash mismatch. */
|
||||
export const MISMATCH_CONTEXT = 2;
|
||||
@@ -124,5 +124,5 @@ export const HEADTAIL_DRIFT_WARNING =
|
||||
* this single builder to stay in lockstep.
|
||||
*/
|
||||
export function missingSnapshotTagMessage(sectionPath: string): string {
|
||||
return `Missing hashline snapshot tag for edit to ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}tag\` from your latest read/search output. To create a new file, use the write tool.`;
|
||||
return `Missing hashline snapshot tag for edit to ${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.`;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* plus a couple of lines of surrounding context. The {@link MismatchError}
|
||||
* formats this into a message at construction time.
|
||||
*/
|
||||
import { formatNumberedLine, HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
|
||||
import { formatNumberedLine, HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
|
||||
import { MISMATCH_CONTEXT } from "./messages";
|
||||
|
||||
const LINE_REF_RE = /^\s*[>+\-*]*\s*(\d+)(?::.*)?\s*$/;
|
||||
@@ -15,7 +15,7 @@ export function formatFullAnchorRequirement(raw?: string): string {
|
||||
const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
|
||||
return (
|
||||
`a bare line number from read/search output plus the section header content-hash tag ` +
|
||||
`(for example ${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]} and line "160")${received}`
|
||||
`(for example ${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]}${HL_FILE_SUFFIX} and line "160")${received}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,12 +99,12 @@ export class MismatchError extends Error {
|
||||
if (!hashRecognized) {
|
||||
return [
|
||||
`Edit rejected${pathText}: hash ${HL_FILE_HASH_SEP}${details.expectedFileHash} is not from this session.`,
|
||||
`The current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. Re-read the file with \`read\` to copy a current ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}tag header — never invent the tag and never reuse one from a prior session.`,
|
||||
`The current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. Re-read the file with \`read\` to copy a current ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}tag${HL_FILE_SUFFIX} header — never invent the tag and never reuse one from a prior session.`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`Edit rejected${pathText}: file changed between read and edit.`,
|
||||
`Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. If a prior edit in this session modified this file, copy the ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}newhash header from that edit's response; otherwise re-read the file with \`read\` to refresh the tag before retrying.`,
|
||||
`Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. If a prior edit in this session modified this file, copy the ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}newhash${HL_FILE_SUFFIX} header from that edit's response; otherwise re-read the file with \`read\` to refresh the tag before retrying.`,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri
|
||||
const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed;
|
||||
return (
|
||||
`apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` +
|
||||
"File sections start with `¶path#HASH` (no `Update File:` / `Add File:` keyword). " +
|
||||
"File sections start with `[path#HASH]` (no `Update File:` / `Add File:` keyword). " +
|
||||
"Use `replace N..M:`, `delete N..M`, or `insert before|after|head|tail:` ops."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,9 +64,9 @@ export interface PatchSectionResult {
|
||||
persisted: string;
|
||||
/** Final text that the {@link Filesystem} actually wrote (may differ if the FS transformed it). */
|
||||
written: string;
|
||||
/** 3-hex opaque snapshot tag for `after`. Use to anchor follow-up edits. */
|
||||
/** 4-hex content-hash tag for `after`. Use to anchor follow-up edits. */
|
||||
fileHash: string;
|
||||
/** Hashline section header (`¶path#tag`) of the post-edit content. */
|
||||
/** Hashline section header (`[path#tag]`) of the post-edit content. */
|
||||
header: string;
|
||||
/** 1-indexed first changed line in `after`, or `undefined` for noops. */
|
||||
firstChangedLine?: number;
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
* otherwise turn every content line into a (malformed) op.
|
||||
*/
|
||||
|
||||
import { HL_FILE_HASH_LENGTH } from "./format";
|
||||
|
||||
const HL_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:[+*-]\s*)?\d+:/;
|
||||
const HL_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*\d+:/;
|
||||
const HL_HEADER_RE = /^\s*¶\S+#[0-9a-fA-F]{3}\s*$/;
|
||||
const HL_HEADER_RE = new RegExp(`^\\s*\\[[^#\\r\\n]+#[0-9a-fA-F]{${HL_FILE_HASH_LENGTH}}\\]\\s*$`);
|
||||
const DIFF_PLUS_RE = /^[+](?![+])/;
|
||||
const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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.
|
||||
|
||||
<headers>
|
||||
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.
|
||||
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.
|
||||
</headers>
|
||||
|
||||
<ops>
|
||||
@@ -23,9 +23,9 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
|
||||
</body-rows>
|
||||
|
||||
<rules>
|
||||
- Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the `¶PATH#TAG` header; use the bare LINE numbers.
|
||||
- Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the `[PATH#TAG]` header; use the bare LINE numbers.
|
||||
- Numbers refer to the ORIGINAL file and stay valid for the whole patch — they do not shift as hunks apply.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- One hunk per range; the body is the final content, never an old/new pair.
|
||||
@@ -37,7 +37,7 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
|
||||
<example>
|
||||
Original (the exact shape `read` returns):
|
||||
```
|
||||
¶greet.py#A1B2
|
||||
[greet.py#A1B2]
|
||||
1:def greet(name):
|
||||
2: msg = "Hello, " + name
|
||||
3: print(msg)
|
||||
@@ -46,14 +46,14 @@ Original (the exact shape `read` returns):
|
||||
|
||||
Insert a guard after line 1:
|
||||
```
|
||||
¶greet.py#A1B2
|
||||
[greet.py#A1B2]
|
||||
insert after 1:
|
||||
+ if not name: name = "stranger"
|
||||
```
|
||||
|
||||
Replace line 2 with two lines:
|
||||
```
|
||||
¶greet.py#A1B2
|
||||
[greet.py#A1B2]
|
||||
replace 2..2:
|
||||
+ greeting = "Hi"
|
||||
+ msg = f"{greeting}, {name}"
|
||||
@@ -61,13 +61,13 @@ replace 2..2:
|
||||
|
||||
Delete line 3:
|
||||
```
|
||||
¶greet.py#A1B2
|
||||
[greet.py#A1B2]
|
||||
delete 3
|
||||
```
|
||||
|
||||
Add a header and trailer:
|
||||
```
|
||||
¶greet.py#A1B2
|
||||
[greet.py#A1B2]
|
||||
insert head:
|
||||
+# generated header
|
||||
insert tail:
|
||||
@@ -76,7 +76,7 @@ insert tail:
|
||||
|
||||
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:
|
||||
```
|
||||
¶greet.py#A1B2
|
||||
[greet.py#A1B2]
|
||||
replace block 1:
|
||||
+def greet(name):
|
||||
+ print(f"Hello, {name}")
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Format shape:
|
||||
* ```
|
||||
* ¶path/to/file.ts#0A3
|
||||
* [path/to/file.ts#1A2B]
|
||||
* replace 5..7:
|
||||
* +literal new line
|
||||
* ```
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
HL_FILE_HASH_LENGTH,
|
||||
HL_FILE_HASH_SEP,
|
||||
HL_FILE_PREFIX,
|
||||
HL_FILE_SUFFIX,
|
||||
HL_HEADER_COLON,
|
||||
HL_INSERT_AFTER,
|
||||
HL_INSERT_BEFORE,
|
||||
@@ -45,6 +46,7 @@ const CHAR_LOWER_F = 102;
|
||||
const CHAR_PAYLOAD_REPLACE = HL_PAYLOAD_REPLACE.charCodeAt(0);
|
||||
const CHAR_COLON = HL_HEADER_COLON.charCodeAt(0);
|
||||
const FILE_PREFIX_LENGTH = HL_FILE_PREFIX.length;
|
||||
const FILE_SUFFIX_LENGTH = HL_FILE_SUFFIX.length;
|
||||
|
||||
function isDigitCode(code: number): boolean {
|
||||
return code >= CHAR_ZERO && code <= CHAR_NINE;
|
||||
@@ -137,7 +139,7 @@ export function parseLid(raw: string, lineNum: number): Anchor {
|
||||
if (number === null || skipWhitespace(raw, number.nextIndex, end) !== end) {
|
||||
throw new Error(
|
||||
`line ${lineNum}: expected a line number such as ${describeAnchorExamples("119")}; ` +
|
||||
`got ${JSON.stringify(raw)}. Use ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}hash from your latest read for file-version binding.`,
|
||||
`got ${JSON.stringify(raw)}. Use ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}hash${HL_FILE_SUFFIX} from your latest read for file-version binding.`,
|
||||
);
|
||||
}
|
||||
return { line: number.line };
|
||||
@@ -312,17 +314,20 @@ function tryParseHunkHeader(line: string): ParsedHunkHeader | null {
|
||||
function tryParseHeader(line: string): { path: string; fileHash?: string } | null {
|
||||
if (!line.startsWith(HL_FILE_PREFIX)) return null;
|
||||
const end = trimEndIndex(line);
|
||||
if (FILE_PREFIX_LENGTH >= end) return null;
|
||||
if (FILE_PREFIX_LENGTH + FILE_SUFFIX_LENGTH >= end) return null;
|
||||
if (!line.endsWith(HL_FILE_SUFFIX, end)) return null;
|
||||
const bodyEnd = end - FILE_SUFFIX_LENGTH;
|
||||
if (FILE_PREFIX_LENGTH >= bodyEnd) return null;
|
||||
|
||||
// The snapshot tag, when present, is the trailing `#XXXX` block. We
|
||||
// detect it from the suffix so the path may legitimately contain
|
||||
// whitespace (e.g. `OneDrive - Company/file.ts`).
|
||||
let pathEnd = end;
|
||||
// The snapshot tag, when present, is the trailing `#XXXX` block inside the
|
||||
// bracketed header. We detect it from the suffix so the path may
|
||||
// legitimately contain whitespace (e.g. `OneDrive - Company/file.ts`).
|
||||
let pathEnd = bodyEnd;
|
||||
let fileHash: string | undefined;
|
||||
const trailingHashStart = end - HL_FILE_HASH_LENGTH - 1;
|
||||
const trailingHashStart = bodyEnd - HL_FILE_HASH_LENGTH - 1;
|
||||
if (trailingHashStart >= FILE_PREFIX_LENGTH && line.charCodeAt(trailingHashStart) === CHAR_HASH) {
|
||||
let allHex = true;
|
||||
for (let probe = trailingHashStart + 1; probe < end; probe++) {
|
||||
for (let probe = trailingHashStart + 1; probe < bodyEnd; probe++) {
|
||||
if (!isHexDigitCode(line.charCodeAt(probe))) {
|
||||
allHex = false;
|
||||
break;
|
||||
@@ -330,7 +335,7 @@ function tryParseHeader(line: string): { path: string; fileHash?: string } | nul
|
||||
}
|
||||
if (allHex) {
|
||||
pathEnd = trailingHashStart;
|
||||
fileHash = line.slice(trailingHashStart + 1, end).toUpperCase();
|
||||
fileHash = line.slice(trailingHashStart + 1, bodyEnd).toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export interface SplitOptions {
|
||||
/** Resolves absolute paths inside hashline headers to cwd-relative form. */
|
||||
cwd?: string;
|
||||
/**
|
||||
* Fallback path used when the input lacks a `¶PATH` header but contains
|
||||
* Fallback path used when the input lacks a `[PATH]` header but contains
|
||||
* recognizable hashline operations. Lets streaming previews work before
|
||||
* the model has written the header.
|
||||
*/
|
||||
|
||||
@@ -91,8 +91,8 @@ describe("PatchSection.applyTo / applyPartialTo with block edits", () => {
|
||||
const text = "function x() {\n if (y) {\n }\n}\n";
|
||||
|
||||
it("applyTo resolves a block edit and matches the equivalent `replace`", () => {
|
||||
const blockSection = Patch.parseSingle(`¶${PATH}#1A2B\nreplace block 2:\n+ if (y || z) {\n+ }`);
|
||||
const replaceSection = Patch.parseSingle(`¶${PATH}#1A2B\nreplace 2..3:\n+ if (y || z) {\n+ }`);
|
||||
const blockSection = Patch.parseSingle(`[${PATH}#1A2B]\nreplace block 2:\n+ if (y || z) {\n+ }`);
|
||||
const replaceSection = Patch.parseSingle(`[${PATH}#1A2B]\nreplace 2..3:\n+ if (y || z) {\n+ }`);
|
||||
|
||||
const blockResult = blockSection.applyTo(text, stubResolver);
|
||||
const replaceResult = replaceSection.applyTo(text);
|
||||
@@ -102,12 +102,12 @@ describe("PatchSection.applyTo / applyPartialTo with block edits", () => {
|
||||
});
|
||||
|
||||
it("applyTo throws when a block edit has no resolver", () => {
|
||||
const section = Patch.parseSingle(`¶${PATH}#1A2B\nreplace block 2:\n+X`);
|
||||
const section = Patch.parseSingle(`[${PATH}#1A2B]\nreplace block 2:\n+X`);
|
||||
expect(() => section.applyTo(text)).toThrow("replace block");
|
||||
});
|
||||
|
||||
it("applyPartialTo drops an unresolvable block edit instead of throwing", () => {
|
||||
const section = Patch.parseSingle(`¶${PATH}#1A2B\nreplace block 2:\n+X`);
|
||||
const section = Patch.parseSingle(`[${PATH}#1A2B]\nreplace block 2:\n+X`);
|
||||
// No resolver → drop. The lone block edit vanishes, so the text is unchanged.
|
||||
const result = section.applyPartialTo(text);
|
||||
expect(result.text).toBe(text);
|
||||
@@ -123,7 +123,7 @@ describe("Patcher with a block resolver", () => {
|
||||
const tag = snapshots.record(PATH, text);
|
||||
const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver });
|
||||
|
||||
const result = await patcher.apply(Patch.parse(`¶${PATH}#${tag}\nreplace block 2:\n+ if (y || z) {\n+ }`));
|
||||
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace block 2:\n+ if (y || z) {\n+ }`));
|
||||
|
||||
expect(result.sections[0]?.op).toBe("update");
|
||||
expect(fs.get(PATH)).toBe("function x() {\n if (y || z) {\n }\n}\n");
|
||||
@@ -140,7 +140,7 @@ describe("Patcher with a block resolver", () => {
|
||||
|
||||
// `block 2` resolves against the SNAPSHOT → span [2,3] → replace
|
||||
// "line1","line2"; recovery 3-way-merges the change onto the live file.
|
||||
const result = await patcher.apply(Patch.parse(`¶${PATH}#${tag}\nreplace block 2:\n+NEW`));
|
||||
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace block 2:\n+NEW`));
|
||||
|
||||
expect(result.sections[0]?.op).toBe("update");
|
||||
expect(fs.get(PATH)).toBe("line0\nNEW\nline3\nline4\nline5\n");
|
||||
@@ -155,7 +155,7 @@ describe("Patcher with a block resolver", () => {
|
||||
const bogus = live === "FFFF" ? "0000" : "FFFF";
|
||||
const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver });
|
||||
|
||||
await expect(patcher.apply(Patch.parse(`¶${PATH}#${bogus}\nreplace block 2:\n+NEW`))).rejects.toBeInstanceOf(
|
||||
await expect(patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nreplace block 2:\n+NEW`))).rejects.toBeInstanceOf(
|
||||
MismatchError,
|
||||
);
|
||||
expect(fs.get(PATH)).toBe(liveText);
|
||||
@@ -167,7 +167,7 @@ describe("Patcher with a block resolver", () => {
|
||||
const tag = snapshots.record(PATH, text);
|
||||
const patcher = new Patcher({ fs, snapshots, blockResolver: () => null });
|
||||
|
||||
await expect(patcher.apply(Patch.parse(`¶${PATH}#${tag}\nreplace block 2:\n+X`))).rejects.toThrow(
|
||||
await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace block 2:\n+X`))).rejects.toThrow(
|
||||
"could not resolve a syntactic block",
|
||||
);
|
||||
expect(fs.get(PATH)).toBe(text);
|
||||
@@ -201,13 +201,13 @@ describe("delete block", () => {
|
||||
});
|
||||
|
||||
it("applyTo deletes the resolved block span", () => {
|
||||
const section = Patch.parseSingle(`¶${PATH}#1A2B\ndelete block 2`);
|
||||
const section = Patch.parseSingle(`[${PATH}#1A2B]\ndelete block 2`);
|
||||
// stub span [2,3] → drop " if (y) {" and " }".
|
||||
expect(section.applyTo(text, stubResolver).text).toBe("function x() {\n}\n");
|
||||
});
|
||||
|
||||
it("applyPartialTo drops an unresolvable delete-block edit instead of throwing", () => {
|
||||
const section = Patch.parseSingle(`¶${PATH}#1A2B\ndelete block 2`);
|
||||
const section = Patch.parseSingle(`[${PATH}#1A2B]\ndelete block 2`);
|
||||
expect(section.applyPartialTo(text).text).toBe(text);
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ describe("delete block", () => {
|
||||
const tag = snapshots.record(PATH, text);
|
||||
const patcher = new Patcher({ fs, snapshots, blockResolver: stubResolver });
|
||||
|
||||
const result = await patcher.apply(Patch.parse(`¶${PATH}#${tag}\ndelete block 2`));
|
||||
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\ndelete block 2`));
|
||||
|
||||
expect(result.sections[0]?.op).toBe("update");
|
||||
expect(fs.get(PATH)).toBe("function x() {\n}\n");
|
||||
|
||||
@@ -9,7 +9,7 @@ const FILE = "a\nb\nc\nd\ne";
|
||||
|
||||
describe("hashline section headers", () => {
|
||||
it("accepts paths with spaces in anchored section headers", () => {
|
||||
const section = Patch.parseSingle("¶dir with spaces/file.ts#1a2b\nreplace 1..1:\n+after");
|
||||
const section = Patch.parseSingle("[dir with spaces/file.ts#1a2b]\nreplace 1..1:\n+after");
|
||||
|
||||
expect(section.path).toBe("dir with spaces/file.ts");
|
||||
expect(section.fileHash).toBe("1A2B");
|
||||
@@ -17,7 +17,7 @@ describe("hashline section headers", () => {
|
||||
});
|
||||
|
||||
it("recovers apply_patch-contaminated headers whose paths contain spaces", () => {
|
||||
const section = Patch.parseSingle("¶*** Update File: dir with spaces/file.ts#1A2B\nreplace 1..1:\n+after");
|
||||
const section = Patch.parseSingle("[*** Update File: dir with spaces/file.ts#1A2B]\nreplace 1..1:\n+after");
|
||||
|
||||
expect(section.path).toBe("dir with spaces/file.ts");
|
||||
expect(section.fileHash).toBe("1A2B");
|
||||
@@ -25,29 +25,41 @@ describe("hashline section headers", () => {
|
||||
});
|
||||
|
||||
it("rejects trailing junk after a snapshot tag", () => {
|
||||
expect(() => Patch.parse("¶src/a.ts#1A2B copied from read\nreplace 1..1:\n+after")).toThrow(
|
||||
expect(() => Patch.parse("[src/a.ts#1A2B copied from read]\nreplace 1..1:\n+after")).toThrow(
|
||||
/Input header must be/,
|
||||
);
|
||||
expect(() => Patch.parse("¶src/a.ts#1A2B:812\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
|
||||
expect(() => Patch.parse("[src/a.ts#1A2B:812]\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
|
||||
});
|
||||
|
||||
it("rejects trailing junk after a snapshot tag even with apply_patch noise", () => {
|
||||
expect(() => Patch.parse("¶Update File: src/a.ts#1A2B copied from read\nreplace 1..1:\n+after")).toThrow(
|
||||
expect(() => Patch.parse("[Update File: src/a.ts#1A2B copied from read]\nreplace 1..1:\n+after")).toThrow(
|
||||
/Input header must be/,
|
||||
);
|
||||
expect(() => Patch.parse("¶Update File: src/a.ts#1A2B:812\nreplace 1..1:\n+after")).toThrow(
|
||||
expect(() => Patch.parse("[Update File: src/a.ts#1A2B:812]\nreplace 1..1:\n+after")).toThrow(
|
||||
/Input header must be/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed snapshot tags", () => {
|
||||
expect(() => Patch.parse("¶src/a.ts#1A2\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
|
||||
expect(() => Patch.parse("¶src/a.ts#1A2G\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
|
||||
expect(() => Patch.parse("¶src/a.ts#1A2B5\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
|
||||
expect(() => Patch.parse("[src/a.ts#1A2]\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
|
||||
expect(() => Patch.parse("[src/a.ts#1A2G]\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
|
||||
expect(() => Patch.parse("[src/a.ts#1A2B5]\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
|
||||
});
|
||||
|
||||
it("rejects malformed snapshot tags even with apply_patch noise", () => {
|
||||
expect(() => Patch.parse("¶Update File: src/a.ts#1A2G\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
|
||||
expect(() => Patch.parse("[Update File: src/a.ts#1A2G]\nreplace 1..1:\n+after")).toThrow(/Input header must be/);
|
||||
});
|
||||
|
||||
it("reports bracket syntax with a 4-hex example when the header is missing", () => {
|
||||
try {
|
||||
Patch.parse("delete 38..40");
|
||||
throw new Error("expected missing-header error");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
expect(message).toContain('input must begin with "[PATH#HASH]"');
|
||||
expect(message).toContain('Example: "[src/foo.ts#1A2B]"');
|
||||
expect(message).not.toContain("#0A3");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("Patcher snapshot tag integrity", () => {
|
||||
const tag = snapshots.record(PATH, "before\n");
|
||||
const patcher = new Patcher({ fs, snapshots });
|
||||
|
||||
const result = await patcher.apply(Patch.parse(`¶${PATH}#${tag}\nreplace 1..1:\n+after`));
|
||||
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace 1..1:\n+after`));
|
||||
|
||||
expect(result.sections[0]?.op).toBe("update");
|
||||
expect(result.sections[0]?.fileHash).toMatch(/^[0-9A-F]{4}$/);
|
||||
@@ -45,14 +45,14 @@ describe("Patcher snapshot tag integrity", () => {
|
||||
expect(snapshots.byHash(PATH, tag)).toBeNull();
|
||||
const patcher = new Patcher({ fs, snapshots });
|
||||
|
||||
const result = await patcher.apply(Patch.parse(`¶${PATH}#${tag}\nreplace 3..3:\n+L3`));
|
||||
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace 3..3:\n+L3`));
|
||||
|
||||
expect(result.sections[0]?.op).toBe("update");
|
||||
expect(fs.get(PATH)).toBe("l1\nl2\nL3\nl4\nl5\n");
|
||||
});
|
||||
|
||||
it("normalizes lowercase section tags while parsing", () => {
|
||||
const section = Patch.parseSingle(`¶${PATH}#1a2b\nreplace 1..1:\n+after`);
|
||||
const section = Patch.parseSingle(`[${PATH}#1a2b]\nreplace 1..1:\n+after`);
|
||||
|
||||
expect(section.fileHash).toBe("1A2B");
|
||||
});
|
||||
@@ -65,7 +65,7 @@ describe("Patcher snapshot tag integrity", () => {
|
||||
const patcher = new Patcher({ fs, snapshots });
|
||||
|
||||
try {
|
||||
await patcher.apply(Patch.parse(`¶${PATH}#${tag}\nreplace 1..1:\n+after`));
|
||||
await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace 1..1:\n+after`));
|
||||
throw new Error("expected MismatchError");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(MismatchError);
|
||||
@@ -89,7 +89,7 @@ describe("Patcher snapshot tag integrity", () => {
|
||||
const bogus = live === "FFFF" ? "0000" : "FFFF";
|
||||
|
||||
try {
|
||||
await patcher.apply(Patch.parse(`¶${PATH}#${bogus}\nreplace 1..1:\n+after`));
|
||||
await patcher.apply(Patch.parse(`[${PATH}#${bogus}]\nreplace 1..1:\n+after`));
|
||||
throw new Error("expected MismatchError");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(MismatchError);
|
||||
@@ -109,7 +109,7 @@ describe("Patcher mandatory snapshot tag policy", () => {
|
||||
const snapshots = new InMemorySnapshotStore();
|
||||
const patcher = new Patcher({ fs, snapshots });
|
||||
|
||||
await expect(patcher.apply(Patch.parse(`¶${PATH}\ninsert tail:\n+c`))).rejects.toThrow(
|
||||
await expect(patcher.apply(Patch.parse(`[${PATH}]\ninsert tail:\n+c`))).rejects.toThrow(
|
||||
/Missing hashline snapshot tag.*use the write tool/s,
|
||||
);
|
||||
expect(fs.get(PATH)).toBe("a\nb\n");
|
||||
@@ -120,7 +120,7 @@ describe("Patcher mandatory snapshot tag policy", () => {
|
||||
const snapshots = new InMemorySnapshotStore();
|
||||
const patcher = new Patcher({ fs, snapshots });
|
||||
|
||||
await expect(patcher.apply(Patch.parse(`¶${PATH}\nreplace 1..1:\n+X`))).rejects.toThrow(
|
||||
await expect(patcher.apply(Patch.parse(`[${PATH}]\nreplace 1..1:\n+X`))).rejects.toThrow(
|
||||
/Missing hashline snapshot tag/,
|
||||
);
|
||||
});
|
||||
@@ -130,7 +130,7 @@ describe("Patcher mandatory snapshot tag policy", () => {
|
||||
const snapshots = new InMemorySnapshotStore();
|
||||
const patcher = new Patcher({ fs, snapshots });
|
||||
|
||||
await expect(patcher.apply(Patch.parse(`¶ghost.ts#1A2B\ninsert tail:\n+c`))).rejects.toThrow(
|
||||
await expect(patcher.apply(Patch.parse(`[ghost.ts#1A2B]\ninsert tail:\n+c`))).rejects.toThrow(
|
||||
/File not found.*use the write tool/is,
|
||||
);
|
||||
});
|
||||
@@ -143,7 +143,7 @@ describe("Patcher mandatory snapshot tag policy", () => {
|
||||
const stale = live === "0000" ? "FFFF" : "0000";
|
||||
const patcher = new Patcher({ fs, snapshots });
|
||||
|
||||
const result = await patcher.apply(Patch.parse(`¶${PATH}#${stale}\ninsert tail:\n+c`));
|
||||
const result = await patcher.apply(Patch.parse(`[${PATH}#${stale}]\ninsert tail:\n+c`));
|
||||
|
||||
const section = result.sections[0];
|
||||
expect(section?.op).toBe("update");
|
||||
@@ -158,7 +158,7 @@ describe("Patcher mandatory snapshot tag policy", () => {
|
||||
const tag = snapshots.record(PATH, content);
|
||||
const patcher = new Patcher({ fs, snapshots });
|
||||
|
||||
const result = await patcher.apply(Patch.parse(`¶${PATH}#${tag}\ninsert tail:\n+c`));
|
||||
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\ninsert tail:\n+c`));
|
||||
|
||||
const section = result.sections[0];
|
||||
expect(section?.op).toBe("update");
|
||||
|
||||
Reference in New Issue
Block a user