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:
can1357
2026-06-15 04:23:36 +02:00
parent 9cfefdedd4
commit a655953e7a
18 changed files with 619 additions and 17 deletions
+2 -2
View File
@@ -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);
}
+5 -1
View File
@@ -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 };
};
+9
View File
@@ -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;
}
+5 -1
View File
@@ -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));
});
});
+11 -3
View File
@@ -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
+54
View File
@@ -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;
}
+10
View File
@@ -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,
+61
View File
@@ -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.`
);
}
+21 -1
View File
@@ -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);
}
+4 -4
View File
@@ -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>
+40 -4
View File
@@ -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);
}
+24
View File
@@ -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", () => {
+54
View File
@@ -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");
});
});