fix(hashline): hardened hashline editing with seen-line and block-anchor validation
- Tracked seen-line provenance in snapshots and propagated it from read/search/ast-grep rows. - Rejected hashline edits on unseen lines before patching, throwing unseen-line errors. - Rejected single-line block anchors in strict mode and dropped them in unresolved lenient mode. - Trimmed one-sided keeper-echo duplicates during multi-line replacements with warning output.
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added isolated profile support via `--profile <name>` / `OMP_PROFILE` and shell alias bootstrap via `--alias <command>`, including launch/ACP bootstrap handling, extension-flag-safe parsing, profile-scoped user config discovery, and symlinked extension-directory discovery.
|
||||
@@ -13,6 +12,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed hashline edits from `read`, `search`, and `ast-grep` so replacements are rejected when they target lines not shown in the tool output
|
||||
- Fixed `/tan` refusing to launch while the main response was still streaming. The command exists to fork tangential work *alongside* an active session, so it now dispatches mid-stream and queues its handoff breadcrumb for the next turn instead of steering the in-flight one.
|
||||
- Fixed `/tan` background agents never staying in the Agent Hub. The forked clone now uses an `<agentId>.jsonl` session file (so the persisted-subagent scan keys it by the same id the live ref uses) and is parked rather than unregistered on completion, so it stays listed and its transcript stays readable.
|
||||
- Fixed the `/tan` dispatch breadcrumb rendering its full raw `<system-notice>` block in the transcript. It now shows a single compact line (`Tangent dispatched [task] <jobId> — <work>`), styled as a sibling of the "Background job completed" line.
|
||||
@@ -11691,4 +11691,4 @@ Initial public release.
|
||||
|
||||
## [0.7.6] - 2025-11-13
|
||||
|
||||
Previous releases did not maintain a changelog.
|
||||
Previous releases did not maintain a changelog.
|
||||
@@ -89,3 +89,46 @@ export async function recordFileSnapshot(
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading line-number prefix the hashline/summary/grep formatters stamp on
|
||||
* every displayed body line: `NN:` or a collapsed summary `NN-MM:` from `read`,
|
||||
* optionally preceded by a grep `*` (match) / space (context) marker from
|
||||
* `search`/`ast-grep`. Anchored at line start, so source content after the
|
||||
* colon never matches.
|
||||
*/
|
||||
const HASHLINE_LINE_PREFIX = /^[ *]?(\d+)(?:-(\d+))?:/;
|
||||
|
||||
/**
|
||||
* The 1-indexed file lines a hashline-formatted body actually displayed.
|
||||
* Single `NN:` rows contribute that line; a collapsed summary `NN-MM:` row
|
||||
* (a `{ .. }` brace pair) contributes only its boundary lines `NN` and `MM` —
|
||||
* the elided interior was never shown, so editing inside it must be rejected.
|
||||
*/
|
||||
export function parseSeenLinesFromHashlineBody(body: string): number[] {
|
||||
const seen: number[] = [];
|
||||
for (const row of body.split("\n")) {
|
||||
const match = HASHLINE_LINE_PREFIX.exec(row);
|
||||
if (!match) continue;
|
||||
seen.push(Number(match[1]));
|
||||
if (match[2] !== undefined) seen.push(Number(match[2]));
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the lines a read displayed to the snapshot it minted, so the patcher
|
||||
* can reject edits anchored on lines the model never saw. Best-effort: a no-op
|
||||
* when the body has no numbered rows or the snapshot already aged out. `tag`
|
||||
* must be the tag returned when this exact content was recorded.
|
||||
*/
|
||||
export function recordSeenLinesFromBody(
|
||||
session: FileSnapshotStoreOwner,
|
||||
absolutePath: string,
|
||||
tag: string,
|
||||
body: string,
|
||||
): void {
|
||||
const seen = parseSeenLinesFromHashlineBody(body);
|
||||
if (seen.length === 0) return;
|
||||
getFileSnapshotStore(session).recordSeenLines(canonicalSnapshotKey(absolutePath), tag, seen);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Text } from "@oh-my-pi/pi-tui";
|
||||
import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import { z } from "zod/v4";
|
||||
import { recordFileSnapshot } from "../edit/file-snapshot-store";
|
||||
import { recordFileSnapshot, recordSeenLinesFromBody } from "../edit/file-snapshot-store";
|
||||
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
||||
import type { Theme } from "../modes/theme/theme";
|
||||
import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
|
||||
@@ -270,6 +270,10 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
}
|
||||
fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
|
||||
}
|
||||
if (hashContext?.tag) {
|
||||
const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
|
||||
recordSeenLinesFromBody(this.session, absoluteFilePath, hashContext.tag, modelOut.join("\n"));
|
||||
}
|
||||
return { model: modelOut, display: displayOut };
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
canonicalSnapshotKey,
|
||||
getFileSnapshotStore,
|
||||
recordFileSnapshot,
|
||||
recordSeenLinesFromBody,
|
||||
SNAPSHOT_MAX_BYTES,
|
||||
} from "../edit/file-snapshot-store";
|
||||
import { normalizeToLF } from "../edit/normalize";
|
||||
@@ -1356,6 +1357,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
if (shouldAddHashLines && outputText) {
|
||||
const tag = await recordFileSnapshot(this.session, absolutePath);
|
||||
if (tag) {
|
||||
recordSeenLinesFromBody(this.session, absolutePath, tag, outputText);
|
||||
outputText = `${formatHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
|
||||
}
|
||||
}
|
||||
@@ -2059,6 +2061,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
: undefined;
|
||||
const bodyText = footer ? `${renderedSummary.text}\n\n${footer}` : renderedSummary.text;
|
||||
const modelText = prependHashlineHeader(bodyText, summaryHashContext);
|
||||
if (summaryHashContext?.tag) {
|
||||
recordSeenLinesFromBody(this.session, absolutePath, summaryHashContext.tag, renderedSummary.text);
|
||||
}
|
||||
details = {
|
||||
displayContent: { text: renderedSummary.displayText, startLine: 1 },
|
||||
summary: {
|
||||
@@ -2354,6 +2359,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
sourcePath = absolutePath;
|
||||
}
|
||||
|
||||
if (hashContext?.tag) {
|
||||
recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText);
|
||||
}
|
||||
|
||||
if (capturedDisplayContent) {
|
||||
details.displayContent = capturedDisplayContent;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Text } from "@oh-my-pi/pi-tui";
|
||||
import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import { z } from "zod/v4";
|
||||
import { recordFileSnapshot } from "../edit/file-snapshot-store";
|
||||
import { recordFileSnapshot, recordSeenLinesFromBody } from "../edit/file-snapshot-store";
|
||||
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
||||
import type { LocalProtocolOptions } from "../internal-urls/local-protocol";
|
||||
import { InternalUrlRouter } from "../internal-urls/router";
|
||||
@@ -1197,6 +1197,10 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
||||
}
|
||||
fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
|
||||
}
|
||||
if (hashContext?.tag) {
|
||||
const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
|
||||
recordSeenLinesFromBody(this.session, absoluteFilePath, hashContext.tag, modelOut.join("\n"));
|
||||
}
|
||||
return { model: modelOut, display: displayOut };
|
||||
};
|
||||
const useGroupedOutput = isDirectory || isMultiScope;
|
||||
|
||||
@@ -3,7 +3,11 @@ import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
||||
import { canonicalSnapshotKey, getFileSnapshotStore } from "@oh-my-pi/pi-coding-agent/edit/file-snapshot-store";
|
||||
import {
|
||||
canonicalSnapshotKey,
|
||||
getFileSnapshotStore,
|
||||
parseSeenLinesFromHashlineBody,
|
||||
} from "@oh-my-pi/pi-coding-agent/edit/file-snapshot-store";
|
||||
|
||||
interface SessionOwner {
|
||||
fileSnapshotStore?: InMemorySnapshotStore;
|
||||
@@ -65,3 +69,30 @@ describe("snapshot store fusion via canonical keys", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSeenLinesFromHashlineBody", () => {
|
||||
it("collects single NN: line numbers and skips the header and footer rows", () => {
|
||||
const body = ["[src/x.ts#1A2B]", "300:function f() {", "301:\treturn 1;", "302:}", "[2 lines elided; …]"].join(
|
||||
"\n",
|
||||
);
|
||||
expect(parseSeenLinesFromHashlineBody(body)).toEqual([300, 301, 302]);
|
||||
});
|
||||
|
||||
it("adds only the boundary lines of a collapsed NN-MM: summary row, never the interior", () => {
|
||||
const body = [
|
||||
"30-39:export interface Snapshot { .. }",
|
||||
"40:",
|
||||
"46-61:export abstract class SnapshotStore { .. }",
|
||||
].join("\n");
|
||||
expect(parseSeenLinesFromHashlineBody(body)).toEqual([30, 39, 40, 46, 61]);
|
||||
});
|
||||
|
||||
it("anchors the prefix at line start, ignoring colons inside line content", () => {
|
||||
expect(parseSeenLinesFromHashlineBody("305:const t = a ? 1 : 2; // 42: note")).toEqual([305]);
|
||||
});
|
||||
|
||||
it("tolerates grep `*`/space match markers before the line number (search/ast-grep output)", () => {
|
||||
const body = ["*73:matched line", " 74:context line", "...", " 75:more context"].join("\n");
|
||||
expect(parseSeenLinesFromHashlineBody(body)).toEqual([73, 74, 75]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { type ExecuteHashlineSingleOptions, executeHashlineSingle } from "@oh-my-pi/pi-coding-agent/edit";
|
||||
import { canonicalSnapshotKey, getFileSnapshotStore } from "@oh-my-pi/pi-coding-agent/edit/file-snapshot-store";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
|
||||
import { SearchTool } from "@oh-my-pi/pi-coding-agent/tools/search";
|
||||
|
||||
function createSession(cwd: string): ToolSession {
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => path.join(cwd, "session.jsonl"),
|
||||
getSessionSpawns: () => "*",
|
||||
getArtifactsDir: () => path.join(cwd, "artifacts"),
|
||||
allocateOutputArtifact: async () => ({ id: "artifact-1", path: path.join(cwd, "artifact-1.log") }),
|
||||
settings: Settings.isolated(),
|
||||
enableLsp: false,
|
||||
} as ToolSession;
|
||||
}
|
||||
|
||||
function execOptions(input: string, session: ToolSession): ExecuteHashlineSingleOptions {
|
||||
return {
|
||||
session,
|
||||
input,
|
||||
writethrough: async (targetPath, content) => {
|
||||
await Bun.write(targetPath, content);
|
||||
return undefined;
|
||||
},
|
||||
beginDeferredDiagnosticsForPath: () => ({
|
||||
onDeferredDiagnostics: () => {},
|
||||
signal: new AbortController().signal,
|
||||
finalize: () => {},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const HEADER = /^\[([^#\r\n]+)#([0-9A-F]{4})\]$/m;
|
||||
|
||||
function resultText(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content
|
||||
.filter((b): b is { type: "text"; text: string } => b.type === "text" && typeof b.text === "string")
|
||||
.map(b => b.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function tagFromOutput(text: string): string {
|
||||
const match = HEADER.exec(text);
|
||||
if (!match) throw new Error(`no hashline header in read output:\n${text}`);
|
||||
return match[2];
|
||||
}
|
||||
|
||||
// Flat plain-text lines so bracket-context never pulls a distant boundary line
|
||||
// into the displayed window — the seen set stays exactly the read range (+context).
|
||||
const CONTENT = `${Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join("\n")}\n`;
|
||||
|
||||
describe("read → edit seen-line guard", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await Settings.init({ inMemory: true });
|
||||
});
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "seen-line-guard-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("records the displayed range as seen and excludes far lines", async () => {
|
||||
const file = path.join(tmpDir, "notes.txt");
|
||||
await Bun.write(file, CONTENT);
|
||||
const session = createSession(tmpDir);
|
||||
|
||||
const read = await new ReadTool(session).execute("r1", { path: `${file}:1-3` });
|
||||
const tag = tagFromOutput(resultText(read));
|
||||
|
||||
const seen = getFileSnapshotStore(session).byHash(canonicalSnapshotKey(file), tag)?.seenLines;
|
||||
expect(seen?.has(1)).toBe(true);
|
||||
expect(seen?.has(3)).toBe(true);
|
||||
expect(seen?.has(12)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an edit on a line the partial read never displayed", async () => {
|
||||
const file = path.join(tmpDir, "notes.txt");
|
||||
await Bun.write(file, CONTENT);
|
||||
const session = createSession(tmpDir);
|
||||
|
||||
const read = await new ReadTool(session).execute("r1", { path: `${file}:1-3` });
|
||||
const tag = tagFromOutput(resultText(read));
|
||||
|
||||
await expect(
|
||||
executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nreplace 12..12:\n+EDITED`, session)),
|
||||
).rejects.toThrow(/were not shown in the read\/search output/);
|
||||
// The reject left the file untouched.
|
||||
expect(await Bun.file(file).text()).toBe(CONTENT);
|
||||
});
|
||||
|
||||
it("applies an edit on a displayed line", async () => {
|
||||
const file = path.join(tmpDir, "notes.txt");
|
||||
await Bun.write(file, CONTENT);
|
||||
const session = createSession(tmpDir);
|
||||
|
||||
const read = await new ReadTool(session).execute("r1", { path: `${file}:1-3` });
|
||||
const tag = tagFromOutput(resultText(read));
|
||||
|
||||
await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nreplace 2..2:\n+EDITED`, session));
|
||||
expect(await Bun.file(file).text()).toContain("EDITED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search → edit seen-line guard", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await Settings.init({ inMemory: true });
|
||||
});
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "seen-line-search-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function searchSession(cwd: string): ToolSession {
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
hasEditTool: true,
|
||||
getSessionFile: () => path.join(cwd, "session.jsonl"),
|
||||
getSessionSpawns: () => "*",
|
||||
getArtifactsDir: () => path.join(cwd, "artifacts"),
|
||||
allocateOutputArtifact: async () => ({ id: "artifact-1", path: path.join(cwd, "artifact-1.log") }),
|
||||
// Zero context so the seen set is exactly the matched lines.
|
||||
settings: Settings.isolated({ "search.contextBefore": 0, "search.contextAfter": 0 }),
|
||||
enableLsp: false,
|
||||
} as ToolSession;
|
||||
}
|
||||
|
||||
it("records matched lines as seen and rejects an edit on an unsearched line", async () => {
|
||||
const file = path.join(tmpDir, "code.txt");
|
||||
const lines = ["a", "b", "c", "NEEDLE here", "e", "f", "g", "h"];
|
||||
await Bun.write(file, `${lines.join("\n")}\n`);
|
||||
const session = searchSession(tmpDir);
|
||||
|
||||
const search = await new SearchTool(session).execute("s1", { pattern: "NEEDLE", paths: [file] });
|
||||
const tag = tagFromOutput(resultText(search));
|
||||
|
||||
const seen = getFileSnapshotStore(session).byHash(canonicalSnapshotKey(file), tag)?.seenLines;
|
||||
expect(seen?.has(4)).toBe(true);
|
||||
expect(seen?.has(8)).toBe(false);
|
||||
|
||||
// The matched line is in the seen set, so editing it applies.
|
||||
await executeHashlineSingle(execOptions(`[code.txt#${tag}]\nreplace 4..4:\n+NEEDLE edited`, session));
|
||||
expect(await Bun.file(file).text()).toContain("NEEDLE edited");
|
||||
});
|
||||
|
||||
it("rejects editing an unsearched line under a search-minted tag", async () => {
|
||||
const file = path.join(tmpDir, "code.txt");
|
||||
const lines = ["a", "b", "c", "NEEDLE here", "e", "f", "g", "h"];
|
||||
await Bun.write(file, `${lines.join("\n")}\n`);
|
||||
const session = searchSession(tmpDir);
|
||||
|
||||
const search = await new SearchTool(session).execute("s1", { pattern: "NEEDLE", paths: [file] });
|
||||
const tag = tagFromOutput(resultText(search));
|
||||
|
||||
await expect(executeHashlineSingle(execOptions(`[code.txt#${tag}]\nreplace 8..8:\n+X`, session))).rejects.toThrow(
|
||||
/were not shown in the read\/search output/,
|
||||
);
|
||||
expect(await Bun.file(file).text()).toBe(`${lines.join("\n")}\n`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { beforeAll, describe, expect, it } from "bun:test";
|
||||
import { createBackgroundTanDispatchBlock } from "@oh-my-pi/pi-coding-agent/modes/components/background-tan-message";
|
||||
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import { BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE, type CustomMessage } from "@oh-my-pi/pi-coding-agent/session/messages";
|
||||
|
||||
function dispatchMessage(details: { jobId: string; work: string; sessionFile: string }): CustomMessage<unknown> {
|
||||
return {
|
||||
role: "custom",
|
||||
customType: BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE,
|
||||
// The persisted content is the full system-notice the model reads; the
|
||||
// renderer must NOT surface it in the transcript.
|
||||
content: '<system-notice reason="background_task_dispatched">raw block</system-notice>',
|
||||
display: true,
|
||||
details,
|
||||
attribution: "user",
|
||||
timestamp: Date.now(),
|
||||
} as CustomMessage<unknown>;
|
||||
}
|
||||
|
||||
describe("createBackgroundTanDispatchBlock", () => {
|
||||
beforeAll(async () => {
|
||||
await initTheme(false);
|
||||
});
|
||||
|
||||
it("renders one compact line with the job id and work preview, not the raw notice", () => {
|
||||
const block = createBackgroundTanDispatchBlock(
|
||||
dispatchMessage({ jobId: "job-42", work: "investigate the cache reuse path", sessionFile: "/x/Tan-1.jsonl" }),
|
||||
);
|
||||
|
||||
const lines = block.render(120).filter(line => line.trim().length > 0);
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toContain("job-42");
|
||||
expect(lines[0]).toContain("investigate the cache reuse path");
|
||||
expect(lines[0]).not.toContain("system-notice");
|
||||
});
|
||||
|
||||
it("truncates an overlong work preview so the line stays a single pill", () => {
|
||||
const block = createBackgroundTanDispatchBlock(
|
||||
dispatchMessage({ jobId: "job-7", work: "x".repeat(200), sessionFile: "/x/Tan-2.jsonl" }),
|
||||
);
|
||||
|
||||
const line = block.render(120).find(rendered => rendered.includes("job-7")) ?? "";
|
||||
|
||||
expect(line).toContain("…");
|
||||
expect(line).not.toContain("x".repeat(80));
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Breaking Changes
|
||||
|
||||
- Rejected edits anchored to lines not displayed in the tagged read/search output, requiring unseen ranges to be re-read before reapplying
|
||||
|
||||
### Changed
|
||||
|
||||
- Rejected `replace block`, `delete block`, and `insert after block` operations that resolve to a single line and instructed users to use the plain single-line form or anchor the true construct opener
|
||||
|
||||
### Fixed
|
||||
|
||||
- Auto-repaired one-sided multi-line boundary echoes by dropping delimiter-neutral duplicated boundary lines and emitted a boundary-echo warning
|
||||
- Normalized cwd-relative hashline paths to forward-slash form on Windows.
|
||||
- Parser now treats a leading `\` on inline payload bodies as the payload delimiter, matching standalone payload rows.
|
||||
- Restored the warning emitted when escaped indented payload rows (`\\ TEXT`) are accepted as payload delimiters.
|
||||
|
||||
## [15.13.0] - 2026-06-14
|
||||
|
||||
@@ -217,8 +227,6 @@
|
||||
### Fixed
|
||||
|
||||
- Parser now skips markdown-style `# ...` lines when they directly precede a hashline operation, making model-generated explanatory rows in prompt examples non-blocking.
|
||||
- Parser now treats a leading `\` on inline payload bodies as the payload delimiter, matching standalone payload rows.
|
||||
- Restored the warning emitted when escaped indented payload rows (`\\ TEXT`) are accepted as payload delimiters.
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -243,4 +251,4 @@ All notable changes to this package will be documented in this file.
|
||||
|
||||
- Fixed repeated patch application mutating cached `after_anchor` edits between target snapshots
|
||||
- Fixed multi-section patching to preflight write policies and reject duplicate canonical targets before any section is committed
|
||||
- Fixed mixed line-ending restoration to preserve the first newline style instead of rewriting ties to LF
|
||||
- Fixed mixed line-ending restoration to preserve the first newline style instead of rewriting ties to LF
|
||||
@@ -443,6 +443,50 @@ function describeBoundaryRepair(group: ReplacementGroup, action: string): string
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-sided boundary echo in an otherwise delimiter-balanced *multi-line*
|
||||
* replacement: the payload's leading XOR trailing edge exactly restates the
|
||||
* surviving line(s) just outside the range — the off-by-one "range one line
|
||||
* short of the keeper I retyped" mistake (e.g. att: payload ends with
|
||||
* `const x = [];` and line B+1 is the same `const x = [];`). Two-sided echoes
|
||||
* are handled by {@link findBoundaryEcho}; delimiter-imbalanced one-sided echoes
|
||||
* by {@link findDuplicateSuffix}/{@link findDuplicatePrefix}.
|
||||
*
|
||||
* Scoped to multi-line ranges (a construct rewrite) on purpose: a single-line
|
||||
* `replace N..N` expanding into several lines is an *expansion* where every
|
||||
* payload line is intentional new content, so a payload line that happens to
|
||||
* equal a neighbor stays — only a genuine block rewrite retypes a boundary
|
||||
* keeper by mistake. The dropped lines must be delimiter-neutral so removing the
|
||||
* duplicate keeps the already-balanced result balanced, and must not consume the
|
||||
* whole payload.
|
||||
*/
|
||||
function findOneSidedBoundaryEcho(
|
||||
group: ReplacementGroup,
|
||||
fileLines: readonly string[],
|
||||
): { side: "leading" | "trailing"; count: number } | undefined {
|
||||
if (group.deleteIndices.length <= 1) return undefined;
|
||||
const leading = countDuplicateLeadingBoundaryLines(group, fileLines);
|
||||
const trailing = countDuplicateTrailingBoundaryLines(group, fileLines);
|
||||
if (leading > 0 === trailing > 0) return undefined;
|
||||
const side = leading > 0 ? "leading" : "trailing";
|
||||
const count = leading > 0 ? leading : trailing;
|
||||
if (count >= group.payload.length) return undefined;
|
||||
const echoLines =
|
||||
side === "leading" ? group.payload.slice(0, count) : group.payload.slice(group.payload.length - count);
|
||||
if (!balanceIsZero(computeDelimiterBalance(echoLines))) return undefined;
|
||||
return { side, count };
|
||||
}
|
||||
|
||||
function describeOneSidedEchoRepair(group: ReplacementGroup, side: "leading" | "trailing", count: number): string {
|
||||
const where = side === "leading" ? "above" : "below";
|
||||
return (
|
||||
`Auto-repaired a replacement boundary echo at line ${group.startLine}: ` +
|
||||
`dropped ${count} ${side} payload line(s) identical to the surviving line(s) just ${where} the range. ` +
|
||||
`The range was one line short of the content you retyped — issue the payload as the final content for the ` +
|
||||
`selected range only, and widen the range to consume any keeper you restate.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize replacement groups so common off-by-one boundaries do not duplicate
|
||||
* unchanged surrounding lines or structural closers. Returns the repaired edit
|
||||
@@ -481,6 +525,16 @@ function repairReplacementBoundaries(
|
||||
computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
|
||||
);
|
||||
if (balanceIsZero(delta)) {
|
||||
const oneSided = findOneSidedBoundaryEcho(group, fileLines);
|
||||
if (oneSided) {
|
||||
warnings.push(describeOneSidedEchoRepair(group, oneSided.side, oneSided.count));
|
||||
const trimmed =
|
||||
oneSided.side === "leading"
|
||||
? inserts.slice(oneSided.count)
|
||||
: inserts.slice(0, inserts.length - oneSided.count);
|
||||
out.push(...trimmed, ...deletes);
|
||||
continue;
|
||||
}
|
||||
out.push(...inserts, ...deletes);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { STRUCTURAL_CLOSER_RE } from "./apply";
|
||||
import {
|
||||
BLOCK_RESOLVER_UNAVAILABLE,
|
||||
blockSingleLineMessage,
|
||||
blockUnresolvedMessage,
|
||||
insertAfterBlockCloserLoweredWarning,
|
||||
insertAfterBlockUnresolvedLoweredWarning,
|
||||
@@ -110,6 +111,15 @@ export function resolveBlockEdits(
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (span.start === span.end) {
|
||||
// A single-line block resolution means line N is a bare statement, not
|
||||
// the opening line of a multi-line construct — the common mis-anchor
|
||||
// that lands a body in the wrong scope (e.g. between a `case` body line
|
||||
// and its `break;`). The plain op is exact for one line, so reject and
|
||||
// point at it; drop instead on the lenient preview path.
|
||||
if (onUnresolved === "drop") continue;
|
||||
throw new Error(`line ${edit.lineNum}: ${blockSingleLineMessage(edit.anchor.line, op)}`);
|
||||
}
|
||||
options.onResolved?.({
|
||||
anchorLine: edit.anchor.line,
|
||||
start: span.start,
|
||||
|
||||
@@ -179,3 +179,64 @@ export const HEADTAIL_DRIFT_WARNING =
|
||||
export function missingSnapshotTagMessage(sectionPath: string): string {
|
||||
return `Missing hashline snapshot tag for ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}tag${HL_FILE_SUFFIX}\` from your latest read/search output. To create a new file, use the write tool.`;
|
||||
}
|
||||
|
||||
/** Compress a line list into a sorted `1-4, 7, 10-12` range string. */
|
||||
function formatLineRanges(lines: readonly number[]): string {
|
||||
const sorted = [...new Set(lines)].sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return "";
|
||||
const parts: string[] = [];
|
||||
let start = sorted[0];
|
||||
let prev = sorted[0];
|
||||
for (let i = 1; i <= sorted.length; i++) {
|
||||
const current = sorted[i];
|
||||
if (current === prev + 1) {
|
||||
prev = current;
|
||||
continue;
|
||||
}
|
||||
parts.push(start === prev ? `${start}` : `${start}-${prev}`);
|
||||
start = current;
|
||||
prev = current;
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* An anchored edit referenced lines the read that minted the cited tag never
|
||||
* displayed (a partial range, or a structural summary that collapsed bodies).
|
||||
* Editing lines you have not read is the off-by-memory failure that mangles
|
||||
* files; reject and make the model re-read those exact lines first.
|
||||
*/
|
||||
export function unseenLinesMessage(sectionPath: string, unseenLines: readonly number[], tag: string): string {
|
||||
return (
|
||||
`This edit targets line(s) ${formatLineRanges(unseenLines)} of ${sectionPath} that were not shown in the ` +
|
||||
`read/search output for ${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}${tag}${HL_FILE_SUFFIX} — a partial ` +
|
||||
`range, a search hit, or a structural summary that collapsed bodies was displayed, not those exact lines. ` +
|
||||
`Re-read those lines, then re-issue the edit against the fresh tag. NEVER author hunks against line numbers ` +
|
||||
`you have not seen in the current snapshot.`
|
||||
);
|
||||
}
|
||||
|
||||
/** Op kind of a deferred block edit, for {@link blockSingleLineMessage}. */
|
||||
export type BlockOp = "replace" | "delete" | "insert_after";
|
||||
|
||||
/**
|
||||
* A `replace block`/`delete block`/`insert after block` anchor resolved to a
|
||||
* single line — almost always a bare statement the model mis-anchored, not a
|
||||
* multi-line construct. The plain op is unambiguous for one line; the block
|
||||
* form only earns its keep when it spares counting a closing line you cannot
|
||||
* see. Reject and point at both fixes.
|
||||
*/
|
||||
export function blockSingleLineMessage(line: number, op: BlockOp): string {
|
||||
const blockForm = op === "insert_after" ? "insert after block" : op === "delete" ? "delete block" : "replace block";
|
||||
const plainForm =
|
||||
op === "insert_after"
|
||||
? `insert after ${line}:`
|
||||
: op === "delete"
|
||||
? `delete ${line}`
|
||||
: `replace ${line}..${line}:`;
|
||||
return (
|
||||
`\`${blockForm} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` +
|
||||
`of a multi-line construct. For that one line use \`${plainForm}\`; to act on an enclosing construct, anchor ${blockForm} ` +
|
||||
`on the line that OPENS it (e.g. its \`function\`/\`if\`/\`case\` header), never a statement inside it.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { computeFileHash, formatHashlineHeader } from "./format";
|
||||
import type { Filesystem, WriteResult } from "./fs";
|
||||
import { isNotFound } from "./fs";
|
||||
import type { Patch, PatchSection } from "./input";
|
||||
import { HEADTAIL_DRIFT_WARNING, missingSnapshotTagMessage } from "./messages";
|
||||
import { HEADTAIL_DRIFT_WARNING, missingSnapshotTagMessage, unseenLinesMessage } from "./messages";
|
||||
import { MismatchError } from "./mismatch";
|
||||
import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
|
||||
import { Recovery, type RecoveryResult } from "./recovery";
|
||||
@@ -341,6 +341,22 @@ export class Patcher {
|
||||
#recordFullSnapshot(canonicalPath: string, normalized: string): string {
|
||||
return this.snapshots.record(canonicalPath, normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an anchored edit that references a line the read which minted
|
||||
* `expected` never displayed. The snapshot's `seenLines` is the set of
|
||||
* 1-indexed lines a producer (read/search) actually showed under that tag;
|
||||
* absent or empty means no provenance was recorded, so the edit applies as
|
||||
* before. Only runs on the no-drift path, where anchor line numbers index
|
||||
* the tagged content 1:1.
|
||||
*/
|
||||
#assertSeenLines(section: PatchSection, canonicalPath: string, expected: string): void {
|
||||
const seen = this.snapshots.byHash(canonicalPath, expected)?.seenLines;
|
||||
if (!seen || seen.size === 0) return;
|
||||
const unseen = section.collectAnchorLines().filter(line => !seen.has(line));
|
||||
if (unseen.length === 0) return;
|
||||
throw new Error(unseenLinesMessage(section.path, unseen, expected));
|
||||
}
|
||||
#mismatchError(
|
||||
section: PatchSection,
|
||||
canonicalPath: string,
|
||||
@@ -404,6 +420,10 @@ export class Patcher {
|
||||
// the caller read, so echo them back. (A drifted file falls through to
|
||||
// recovery below, where line numbers shift, so resolutions are dropped.)
|
||||
if (expected === undefined || liveMatches) {
|
||||
// The line numbers in `edits` index the exact content the tag names.
|
||||
// Reject any anchor the read never displayed: editing lines the model
|
||||
// has not seen is the off-by-memory mistake that mangles files.
|
||||
if (expected !== undefined) this.#assertSeenLines(section, canonicalPath, expected);
|
||||
const result = applyEdits(normalized, resolved);
|
||||
return withResolveWarnings(blockResolutions.length > 0 ? { ...result, blockResolutions } : result);
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
|
||||
- Line numbers and the `[PATH#TAG]` header come from your latest `read`/`search` (`LINE:TEXT` rows).
|
||||
- Numbers refer to the ORIGINAL file; they do not shift as hunks apply.
|
||||
- They die with the call: every applied edit mints a fresh `#TAG` and renumbers — anchor the next edit on the edit response or a fresh `read`.
|
||||
- Touch only lines you literally saw as `LINE:TEXT`; the tag certifies the snapshot, not your knowledge of it.
|
||||
- Elided regions (`…`) are UNSEEN — never place or span a hunk across one; `read` it first.
|
||||
- Touch only lines your latest `read`/`search` literally displayed as `LINE:TEXT`; the tag certifies the snapshot, not your memory of it. A hunk anchored on a line you never displayed is REJECTED — re-`read` those exact lines first. (Seeing a line ≠ it holding the code you mean: confirm the numbers map to the construct you intend, especially far from your last-read window.)
|
||||
- Elided regions are UNSEEN: `…`/`..` markers and a collapsed `N-M:` summary row (only boundary lines N and M were shown) hide their interior. NEVER place or span a hunk inside one — `read` the range first.
|
||||
- Never start or end a range mid-expression or mid-block.
|
||||
- Indent body rows exactly for the depth they should live at.
|
||||
- On a stale-tag rejection or any surprising result: STOP and re-`read` before further edits.
|
||||
@@ -36,9 +36,9 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
|
||||
- Ranges cover ONLY lines whose content changes. Never widen over unchanged lines — a stale wide range shreds everything it spans.
|
||||
- Whole construct → `replace block N` (tree-sitter resolves the end); lines inside it → `replace N..M`.
|
||||
- `replace block N` resolves EXACTLY the node at N. Leading decorators/attributes/doc-comments are separate nodes: point N at the FIRST decorator to sweep both; standalone line-comments are never swept — use `replace N..M`.
|
||||
- `insert after block N`: N is the opener, never the closer or last visible line; saw the closer? Use plain `insert after M:`.
|
||||
- Block ops (`replace block`/`delete block`/`insert after block`) anchor the OPENING line of a MULTI-LINE construct — never its closer, its last line, or a bare statement inside it. Anchoring a single statement resolves to ONE line and is REJECTED: use the plain op (`replace N..N` / `delete N` / `insert after N`) for one line, or point N at the real opener. Saw the closer? Use plain `insert after M:`.
|
||||
- Non-adjacent changes = separate hunks; untouched lines stay out of every range.
|
||||
- Pure additions use `insert`, never a widened `replace` — retyped keepers are exactly what gets dropped.
|
||||
- Pure additions use `insert`, never a widened `replace` — retyped keepers are exactly what gets dropped. A multi-line `replace` whose body restates the line just outside the range is auto-dropped as an off-by-one keeper (with a warning), but issue the payload as the final content for the range only and never lean on the repair.
|
||||
- NEVER format/restyle code with this tool; run the project formatter instead.
|
||||
</rules>
|
||||
|
||||
|
||||
@@ -36,6 +36,15 @@ export interface Snapshot {
|
||||
readonly hash: string;
|
||||
/** Timestamp (ms since epoch) the version was recorded. */
|
||||
recordedAt: number;
|
||||
/**
|
||||
* 1-indexed file lines a producer (read/search) actually *displayed* under
|
||||
* this tag. A partial read (range, or a structural summary that collapsed
|
||||
* bodies) leaves this sparse; a whole-file read fills every line. Multiple
|
||||
* reads of the same content union into one set. `undefined` means "no
|
||||
* provenance recorded" — the patcher then skips the seen-line check and
|
||||
* applies as before. Mutated in place as more of the same content is read.
|
||||
*/
|
||||
seenLines?: Set<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,8 +59,20 @@ export abstract class SnapshotStore {
|
||||
/** Recorded version for `path` whose tag equals `hash`, or `null`. */
|
||||
abstract byHash(path: string, hash: string): Snapshot | null;
|
||||
|
||||
/** Record the full normalized text of `path` and return its content tag. */
|
||||
abstract record(path: string, fullText: string): string;
|
||||
/**
|
||||
* Record the full normalized text of `path` and return its content tag.
|
||||
* `seenLines` (optional) are the 1-indexed lines the producer displayed;
|
||||
* they merge into {@link Snapshot.seenLines} across reads of identical text.
|
||||
*/
|
||||
abstract record(path: string, fullText: string, seenLines?: Iterable<number>): string;
|
||||
|
||||
/**
|
||||
* Merge `lines` into the {@link Snapshot.seenLines} of the version whose tag
|
||||
* equals `hash`. No-op when no such version is retained (the content aged
|
||||
* out or was overwritten). Lets producers attach displayed lines after the
|
||||
* tag was already minted (the body is formatted after the hash is computed).
|
||||
*/
|
||||
abstract recordSeenLines(path: string, hash: string, lines: Iterable<number>): void;
|
||||
|
||||
/** Drop the version history for a single path. */
|
||||
abstract invalidate(path: string): void;
|
||||
@@ -65,6 +86,13 @@ const DEFAULT_MAX_VERSIONS_PER_PATH = 4;
|
||||
/** Global ceiling on retained snapshot text across all paths (UTF-16 code units). */
|
||||
const DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** Union `lines` into `snapshot.seenLines`, lazily creating the set. */
|
||||
function mergeSeenLines(snapshot: Snapshot, lines: Iterable<number> | undefined): void {
|
||||
if (lines === undefined) return;
|
||||
if (snapshot.seenLines === undefined) snapshot.seenLines = new Set<number>();
|
||||
for (const line of lines) snapshot.seenLines.add(line);
|
||||
}
|
||||
|
||||
export interface InMemorySnapshotStoreOptions {
|
||||
/** Maximum number of distinct paths tracked at once (default 30). LRU eviction. */
|
||||
maxPaths?: number;
|
||||
@@ -114,15 +142,17 @@ export class InMemorySnapshotStore extends SnapshotStore {
|
||||
return history?.find(version => version.hash === hash) ?? null;
|
||||
}
|
||||
|
||||
record(path: string, fullText: string): string {
|
||||
record(path: string, fullText: string, seenLines?: Iterable<number>): string {
|
||||
const hash = computeFileHash(fullText);
|
||||
// `get` refreshes LRU recency for `path`.
|
||||
const history = this.#versions.get(path) ?? [];
|
||||
const existing = history.find(version => version.hash === hash);
|
||||
if (existing) {
|
||||
// Same content state observed again: refresh recency and promote to
|
||||
// head (it is the current file content), then reuse the tag.
|
||||
// head (it is the current file content), then reuse the tag. Union any
|
||||
// newly-displayed lines so re-reading more of the file widens coverage.
|
||||
existing.recordedAt = Date.now();
|
||||
mergeSeenLines(existing, seenLines);
|
||||
if (history[0] !== existing) {
|
||||
this.#versions.set(path, [existing, ...history.filter(version => version !== existing)]);
|
||||
}
|
||||
@@ -130,10 +160,16 @@ export class InMemorySnapshotStore extends SnapshotStore {
|
||||
}
|
||||
|
||||
const snapshot: Snapshot = { path, text: fullText, hash, recordedAt: Date.now() };
|
||||
mergeSeenLines(snapshot, seenLines);
|
||||
this.#versions.set(path, [snapshot, ...history].slice(0, this.#maxVersionsPerPath));
|
||||
return hash;
|
||||
}
|
||||
|
||||
recordSeenLines(path: string, hash: string, lines: Iterable<number>): void {
|
||||
const version = this.#versions.get(path)?.find(snapshot => snapshot.hash === hash);
|
||||
if (version) mergeSeenLines(version, lines);
|
||||
}
|
||||
|
||||
invalidate(path: string): void {
|
||||
this.#versions.delete(path);
|
||||
}
|
||||
|
||||
@@ -140,6 +140,30 @@ describe("resolveBlockEdits", () => {
|
||||
});
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
|
||||
// A single-line resolution means the anchor was a bare statement, not a
|
||||
// multi-line construct opener (the att#1 `insert after block 678` shape,
|
||||
// where line 678 was `options.mode = "check";`). Reject and point at the
|
||||
// plain form rather than silently landing a body in the wrong scope.
|
||||
const singleLineResolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line });
|
||||
|
||||
it("rejects a `replace block` that resolves to a single line", () => {
|
||||
const edits = parsePatch("replace block 2:\n+X").edits;
|
||||
expect(() => resolveBlockEdits(edits, "a\nb\nc", PATH, singleLineResolver)).toThrow(
|
||||
/resolved a single-line block/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an `insert after block` that resolves to a single line", () => {
|
||||
const edits = parsePatch("insert after block 2:\n+X").edits;
|
||||
expect(() => resolveBlockEdits(edits, "a\nb\nc", PATH, singleLineResolver)).toThrow(/single-line block/);
|
||||
});
|
||||
|
||||
it("drops a single-line block resolution on the lenient preview path", () => {
|
||||
const edits = parsePatch("replace block 2:\n+X").edits;
|
||||
const resolved = resolveBlockEdits(edits, "a\nb\nc", PATH, singleLineResolver, { onUnresolved: "drop" });
|
||||
expect(resolved).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PatchSection.applyTo / applyPartialTo with block edits", () => {
|
||||
|
||||
@@ -259,6 +259,27 @@ describe("boundary-balance repair", () => {
|
||||
expect(text).toBe(['const a = "}";', 'const b = "}}}";', 'const c = "y";'].join("\n"));
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
// A MULTI-line construct rewrite whose payload restates the keeper that
|
||||
// survives just below the range — the att#1 `replace 639..644` shape where
|
||||
// the range was one line short of the `const changedFiles` it retyped.
|
||||
it("drops a one-sided trailing keeper echo in a multi-line rewrite", () => {
|
||||
const file = ["function f() {", " a();", " b();", " const out = [];", " return out;", "}"].join("\n");
|
||||
const diff = ["replace 2..3:", "+ a2();", "+ b2();", "+ const out = [];"].join("\n");
|
||||
const { text, warnings } = apply(file, diff);
|
||||
expect(text).toBe(["function f() {", " a2();", " b2();", " const out = [];", " return out;", "}"].join("\n"));
|
||||
expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true);
|
||||
});
|
||||
|
||||
// Mirror direction: the payload restates the keeper that survives just above
|
||||
// the multi-line range (range one line low instead of one short).
|
||||
it("drops a one-sided leading keeper echo in a multi-line rewrite", () => {
|
||||
const file = ["setup();", "a();", "b();", "c();"].join("\n");
|
||||
const diff = ["replace 3..4:", "+a();", "+B();", "+C();"].join("\n");
|
||||
const { text, warnings } = apply(file, diff);
|
||||
expect(text).toBe(["setup();", "a();", "B();", "C();"].join("\n"));
|
||||
expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("boundary-balance repair through stale-snapshot recovery", () => {
|
||||
|
||||
@@ -165,3 +165,57 @@ describe("Patcher mandatory snapshot tag policy", () => {
|
||||
expect(section?.warnings ?? []).not.toContain(HEADTAIL_DRIFT_WARNING);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Patcher seen-line provenance", () => {
|
||||
const CONTENT = "l1\nl2\nl3\nl4\nl5\n";
|
||||
|
||||
it("rejects an edit anchored on a line the read never displayed", async () => {
|
||||
const fs = new InMemoryFilesystem([[PATH, CONTENT]]);
|
||||
const snapshots = new InMemorySnapshotStore();
|
||||
// A partial read displayed only lines 1-2 under this tag.
|
||||
const tag = snapshots.record(PATH, CONTENT, [1, 2]);
|
||||
const patcher = new Patcher({ fs, snapshots });
|
||||
|
||||
await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace 4..4:\n+L4`))).rejects.toThrow(
|
||||
/were not shown in the read\/search output/,
|
||||
);
|
||||
expect(fs.get(PATH)).toBe(CONTENT);
|
||||
});
|
||||
|
||||
it("applies an edit anchored on a displayed line", async () => {
|
||||
const fs = new InMemoryFilesystem([[PATH, CONTENT]]);
|
||||
const snapshots = new InMemorySnapshotStore();
|
||||
const tag = snapshots.record(PATH, CONTENT, [1, 2]);
|
||||
const patcher = new Patcher({ fs, snapshots });
|
||||
|
||||
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace 2..2:\n+L2`));
|
||||
|
||||
expect(result.sections[0]?.op).toBe("update");
|
||||
expect(fs.get(PATH)).toBe("l1\nL2\nl3\nl4\nl5\n");
|
||||
});
|
||||
|
||||
it("widens coverage when more of the same content is re-read (read fusion)", async () => {
|
||||
const fs = new InMemoryFilesystem([[PATH, CONTENT]]);
|
||||
const snapshots = new InMemorySnapshotStore();
|
||||
const tag = snapshots.record(PATH, CONTENT, [1, 2]);
|
||||
// Second read of identical content displays lines 4-5: union into the tag.
|
||||
snapshots.record(PATH, CONTENT, [4, 5]);
|
||||
const patcher = new Patcher({ fs, snapshots });
|
||||
|
||||
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace 4..4:\n+L4`));
|
||||
|
||||
expect(result.sections[0]?.op).toBe("update");
|
||||
expect(fs.get(PATH)).toBe("l1\nl2\nl3\nL4\nl5\n");
|
||||
});
|
||||
|
||||
it("skips the check when no seen lines were recorded (absent → allow)", async () => {
|
||||
const fs = new InMemoryFilesystem([[PATH, CONTENT]]);
|
||||
const snapshots = new InMemorySnapshotStore();
|
||||
const tag = snapshots.record(PATH, CONTENT);
|
||||
const patcher = new Patcher({ fs, snapshots });
|
||||
|
||||
const result = await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nreplace 4..4:\n+L4`));
|
||||
|
||||
expect(result.sections[0]?.op).toBe("update");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user