feat(coding-agent): introduced hashline format v2 with colon separators and tag-based API

- Changed hashline format separator from pipe (|) to colon (:) for improved readability across all tools and output formats.
- Refactored hashline edit API with operation-based structure: renamed delete->rm, rename->mv, set->target/new_content, and added explicit op field for operation types.
- Updated hashline hash encoding from 4-character base36 to 2-character hexadecimal for more compact representation.
- Replaced anchor terminology with tags throughout hashline documentation and API for clearer semantics.
This commit is contained in:
can1357
2026-02-19 22:04:18 +01:00
parent e7b01abf0c
commit 7ce3bb6906
17 changed files with 5616 additions and 837 deletions
+1 -1
View File
@@ -123,7 +123,7 @@ function normalizeMessagesForProvider(
return changed ? normalized : messages;
}
export const INTENT_FIELD = "_intent";
export const INTENT_FIELD = "agent__intent";
function injectIntentIntoSchema(schema: unknown): unknown {
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
@@ -116,7 +116,10 @@ describe("Tool argument coercion", () => {
id: "call-7",
name: "t7",
arguments:
'{"path":"somefile.js","edits":"[{\\"target\\":\\"13#cf\\",\\"new_content\\":\\"...\\"}]"}' as unknown as Record<string, unknown>,
'{"path":"somefile.js","edits":"[{\\"target\\":\\"13#cf\\",\\"new_content\\":\\"...\\"}]"}' as unknown as Record<
string,
unknown
>,
};
const result = validateToolArguments(tool, toolCall);
+7 -2
View File
@@ -1,7 +1,6 @@
# Changelog
## [Unreleased]
### Added
- Display streamed tool intent in working message during agent execution
@@ -13,6 +12,12 @@
### Changed
- Hashline format separator changed from pipe (`|`) to colon (`:`) for improved readability (e.g., `LINE#ID:content` instead of `LINE#ID|content`)
- Hashline hash representation changed from 4-character base36 to 2-character hexadecimal for more compact line references
- Hashline edit API: renamed `delete` parameter to `rm` for consistency with standard file operations
- Hashline edit API: renamed `rename` parameter to `mv` for consistency with standard file operations
- Hashline edit API: content-replace operations now require explicit `op: "replaceText"` field to distinguish from other edit types
- Hashline documentation terminology updated: references to 'anchors' replaced with 'tags' for clearer semantics
- Intent tracing now uses `_intent` field name in tool schemas
- Hashline edit API: renamed `set` operation to `target`/`new_content` for clearer semantics
- Hashline edit API: renamed `set_range` operation to `first`/`last`/`new_content`
@@ -4834,4 +4839,4 @@ Initial public release.
- Git branch display in footer
- Message queueing during streaming responses
- OAuth integration for Gmail and Google Calendar access
- HTML export with syntax highlighting and collapsible sections
- HTML export with syntax highlighting and collapsible sections
@@ -247,11 +247,11 @@ handlebars.registerHelper("hlineref", (lineNum: unknown, content: unknown): stri
/**
* {{hlinefull lineNum "content"}} — format a full read-style line with prefix.
* Returns `"lineNum#hash|content"`.
* Returns `"lineNum#hash:content"`.
*/
handlebars.registerHelper("hlinefull", (lineNum: unknown, content: unknown): string => {
const { ref, text } = formatHashlineRef(lineNum, content);
return `${ref}|${text}`;
return `${ref}:${text}`;
});
export function renderPromptTemplate(template: string, context: TemplateContext = {}): string {
@@ -287,7 +287,7 @@ export const SETTINGS_SCHEMA = {
ui: {
tab: "config",
label: "Read hash lines",
description: "Include line hashes in read output for hashline edit mode (LINE#ID|content)",
description: "Include line hashes in read output for hashline edit mode (LINE#ID:content)",
},
},
showHardwareCursor: {
+1 -1
View File
@@ -8,8 +8,8 @@ import * as Diff from "diff";
import { resolveToCwd } from "../tools/path-utils";
import { previewPatch } from "./applicator";
import { DEFAULT_FUZZY_THRESHOLD, findMatch } from "./fuzzy";
import type { HashlineEdit } from "./hashline";
import { applyHashlineEdits } from "./hashline";
import type { HashlineEdit } from "./index";
import { adjustIndentation, normalizeToLF, stripBom } from "./normalize";
import type { DiffError, DiffResult, PatchInput } from "./types";
import { EditMatchError } from "./types";
+198 -329
View File
@@ -2,103 +2,27 @@
* Hashline edit mode — a line-addressable edit format using content hashes.
*
* Each line in a file is identified by its 1-indexed line number and a short
* base36 hash derived from the normalized line content (xxHash32, truncated to 4
* base36 chars).
* hexadecimal hash derived from the normalized line content (xxHash32, truncated to 2
* hex chars).
* The combined `LINE#ID` reference acts as both an address and a staleness check:
* if the file has changed since the caller last read it, hash mismatches are caught
* before any mutation occurs.
*
* Displayed format: `LINENUM#HASH|CONTENT`
* Reference format: `"LINENUM#HASH"` (e.g. `"5#a3f2"`)
* Displayed format: `LINENUM#HASH:CONTENT`
* Reference format: `"LINENUM#HASH"` (e.g. `"5#aa"`)
*/
import type { HashlineEdit } from "./index";
import type { HashMismatch } from "./types";
type ParsedRefs =
| { kind: "single"; ref: { line: number; hash: string } }
| { kind: "range"; start: { line: number; hash: string }; end: { line: number; hash: string } }
| { kind: "insertAfter"; after: { line: number; hash: string } }
| { kind: "insertBefore"; before: { line: number; hash: string } }
| { kind: "insertBetween"; after: { line: number; hash: string }; before: { line: number; hash: string } }
| { kind: "insertAtEof" };
function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dstLines: string[] } {
if ("set" in edit) {
return {
spec: { kind: "single", ref: parseLineRef(edit.set.ref) },
dstLines: edit.set.body,
};
}
if ("set_range" in edit) {
const r = edit.set_range as Record<string, unknown>;
const start = parseLineRef(r.beg as string);
if (!r.end) {
return {
spec: { kind: "single", ref: start },
dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")),
};
}
const end = parseLineRef(r.end as string);
return {
spec: start.line === end.line ? { kind: "single", ref: start } : { kind: "range", start, end },
dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")),
};
}
if ("insert" in edit) {
const r = edit.insert as Record<string, unknown>;
const dstLines = Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.text ?? r.content ?? ""));
const hasAfterField = "after" in r;
const hasBeforeField = "before" in r;
const afterRef = r.after;
const beforeRef = r.before;
if (hasAfterField && (typeof afterRef !== "string" || afterRef.trim().length === 0)) {
throw new Error('insert.after must be a non-empty "LINE#ID" string when provided');
}
if (hasBeforeField && (typeof beforeRef !== "string" || beforeRef.trim().length === 0)) {
throw new Error('insert.before must be a non-empty "LINE#ID" string when provided');
}
const hasAfter = hasAfterField && typeof afterRef === "string";
const hasBefore = hasBeforeField && typeof beforeRef === "string";
if (hasAfter && hasBefore) {
return {
spec: {
kind: "insertBetween",
after: parseLineRef(afterRef),
before: parseLineRef(beforeRef),
},
dstLines,
};
}
if (hasAfter) {
return {
spec: { kind: "insertAfter", after: parseLineRef(afterRef) },
dstLines,
};
}
if (hasBefore) {
return {
spec: { kind: "insertBefore", before: parseLineRef(beforeRef) },
dstLines,
};
}
return { spec: { kind: "insertAtEof" }, dstLines };
}
if ("replace" in edit) {
throw new Error("replace edits are applied separately; do not pass them to applyHashlineEdits");
}
throw new Error("Unknown hashline edit operation");
}
/** Split dst into lines; empty string means delete (no lines). */
function splitDstLines(dst: string): string[] {
return dst === "" ? [] : dst.split("\n");
}
/** Pattern matching hashline display format: `LINE#ID|CONTENT` */
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[0-9a-zA-Z]{1,16}\|/;
/** Pattern matching a unified-diff `+` prefix (but not `++`) */
const DIFF_PLUS_RE = /^\+(?!\+)/;
export type LineTag = { line: number; hash: string };
export type HashlineEdit =
| { op: "set"; tag: LineTag; content: string[] }
| { op: "replace"; first: LineTag; last: LineTag; content: string[] }
| { op: "append"; after?: LineTag; content: string[] }
| { op: "prepend"; before?: LineTag; content: string[] }
| { op: "insert"; after: LineTag; before: LineTag; content: string[] };
export type ReplaceTextEdit = { op: "replaceText"; old_text: string; new_text: string; all?: boolean };
export type EditSpec = HashlineEdit | ReplaceTextEdit;
/**
* Compare two strings ignoring all whitespace differences.
@@ -144,16 +68,6 @@ function restoreLeadingIndent(templateLine: string, line: string): string {
return templateIndent + line;
}
const CONFUSABLE_HYPHENS_RE = /[\u2010\u2011\u2012\u2013\u2014\u2212\uFE63\uFF0D]/g;
function normalizeConfusableHyphens(s: string): string {
return s.replace(CONFUSABLE_HYPHENS_RE, "-");
}
function normalizeConfusableHyphensInLines(lines: string[]): string[] {
return lines.map(l => normalizeConfusableHyphens(l));
}
function restoreIndentForPairedReplacement(oldLines: string[], newLines: string[]): string[] {
if (oldLines.length !== newLines.length) return newLines;
let changed = false;
@@ -261,50 +175,19 @@ function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine:
return out;
}
/**
* Strip hashline display prefixes and diff `+` markers from replacement lines.
*
* Models frequently copy the `LINE#ID ` prefix from read output into their
* replacement content, or include unified-diff `+` prefixes. Both corrupt the
* output file. This strips them heuristically before application.
*/
function stripNewLinePrefixes(lines: string[]): string[] {
// Detect whether the *majority* of non-empty lines carry a prefix —
// if only one line out of many has a match it's likely real content.
let hashPrefixCount = 0;
let diffPlusCount = 0;
let nonEmpty = 0;
for (const l of lines) {
if (l.length === 0) continue;
nonEmpty++;
if (HASHLINE_PREFIX_RE.test(l)) hashPrefixCount++;
if (DIFF_PLUS_RE.test(l)) diffPlusCount++;
}
if (nonEmpty === 0) return lines;
const NIBBLE_STR = "ZPMQVRWSNKTXJBYH";
const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5;
const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
if (!stripHash && !stripPlus) return lines;
return lines.map(l => {
if (stripHash) return l.replace(HASHLINE_PREFIX_RE, "");
if (stripPlus) return l.replace(DIFF_PLUS_RE, "");
return l;
});
}
const HASH_LEN = 2;
const RADIX = 16;
const HASH_MOD = RADIX ** HASH_LEN;
const DICT = Array.from({ length: HASH_MOD }, (_, i) => i.toString(RADIX).padStart(HASH_LEN, "0"));
const DICT = Array.from({ length: 256 }, (_, i) => {
const h = i >>> 4;
const l = i & 0x0f;
return `${NIBBLE_STR[h]}${NIBBLE_STR[l]}`;
});
/**
* Compute a short base36 hash of a single line.
* Compute a short hexadecimal hash of a single line.
*
* Uses xxHash64 on a whitespace-normalized line, truncated to {@link HASH_LEN}
* base36 characters. The `idx` parameter is accepted for compatibility with older
* Uses xxHash32 on a whitespace-normalized line, truncated to {@link HASH_LEN}
* hex characters. The `idx` parameter is accepted for compatibility with older
* call sites, but is not currently mixed into the hash.
* The line input should not include a trailing newline.
*/
@@ -314,13 +197,20 @@ export function computeLineHash(idx: number, line: string): string {
}
line = line.replace(/\s+/g, "");
void idx; // Might use line, but for now, let's not.
return DICT[Bun.hash.xxHash32(line) % HASH_MOD];
return DICT[Bun.hash.xxHash32(line) & 0xff];
}
/**
* Formats a tag given the line number and content.
*/
export function formatLineTag(line: number, content: string): string {
return `${line}#${computeLineHash(line, content)}`;
}
/**
* Format file content with hashline prefixes for display.
*
* Each line becomes `LINENUM#HASH|CONTENT` where LINENUM is 1-indexed.
* Each line becomes `LINENUM#HASH:CONTENT` where LINENUM is 1-indexed.
*
* @param content - Raw file content string
* @param startLine - First line number (1-indexed, defaults to 1)
@@ -329,7 +219,7 @@ export function computeLineHash(idx: number, line: string): string {
* @example
* ```
* formatHashLines("function hi() {\n return;\n}")
* // "1#HH|function hi() {\n2#HH| return;\n3#HH|}"
* // "1#HH:function hi() {\n2#HH: return;\n3#HH:}"
* ```
*/
export function formatHashLines(content: string, startLine = 1): string {
@@ -337,8 +227,7 @@ export function formatHashLines(content: string, startLine = 1): string {
return lines
.map((line, i) => {
const num = startLine + i;
const hash = computeLineHash(num, line);
return `${num}#${hash}|${line}`;
return `${formatLineTag(num, line)}:${line}`;
})
.join("\n");
}
@@ -410,7 +299,7 @@ export async function* streamHashLinesFromUtf8(
};
const pushLine = (line: string): string[] => {
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}|${line}`;
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}:${line}`;
lineNum++;
const chunksToYield: string[] = [];
@@ -504,7 +393,7 @@ export async function* streamHashLinesFromLines(
const pushLine = (line: string): string[] => {
sawAnyLine = true;
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}|${line}`;
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}:${line}`;
lineNum++;
const chunksToYield: string[] = [];
@@ -560,18 +449,14 @@ export async function* streamHashLinesFromLines(
*
* @throws Error if the format is invalid (not `NUMBER#HEXHASH`)
*/
export function parseLineRef(ref: string): { line: number; hash: string } {
// Strip display-format suffix: "5#ab|some content" → "5#ab", or legacy "5#ab some content" → "5#ab"
// Models often copy the full display format from read output.
const cleaned = ref
.replace(/\|.*$/, "")
.replace(/ {2}.*$/, "")
.replace(/^>+\s*/, "")
.trim();
const normalized = cleaned.replace(/\s*#\s*/, "#");
const strictMatch = normalized.match(/^(\d+)#([0-9a-zA-Z]{1,16})$/);
const prefixMatch = strictMatch ? null : normalized.match(new RegExp(`^(\\d+)#([0-9a-zA-Z]{${HASH_LEN}})`));
const match = strictMatch ?? prefixMatch;
export function parseTag(ref: string): { line: number; hash: string } {
// This regex captures:
// 1. optional leading ">+" and whitespace
// 2. line number (1+ digits)
// 3. "#" with optional surrounding spaces
// 4. hash (2 hex chars)
// 5. optional trailing display suffix (":..." or " ...")
const match = ref.match(/^\s*[>+-]*\s*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})/);
if (!match) {
throw new Error(`Invalid line reference "${ref}". Expected format "LINE#ID" (e.g. "5#aa").`);
}
@@ -648,9 +533,9 @@ export class HashlineMismatchError extends Error {
const prefix = `${lineNum}#${hash}`;
if (mismatchSet.has(lineNum)) {
lines.push(`>>> ${prefix}|${content}`);
lines.push(`>>> ${prefix}:${content}`);
} else {
lines.push(` ${prefix}|${content}`);
lines.push(` ${prefix}:${content}`);
}
}
return lines.join("\n");
@@ -670,7 +555,7 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
}
const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
if (actualHash !== ref.hash.toLowerCase()) {
if (actualHash !== ref.hash) {
throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines);
}
}
@@ -683,7 +568,7 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
* Apply an array of hashline edits to file content.
*
* Each edit operation identifies target lines directly (`set`, `set_range`,
* `insert`). Line references are resolved via {@link parseLineRef}
* `insert`). Line references are resolved via {@link parseTag}
* and hashes validated before any mutation.
*
* Edits are sorted bottom-up (highest effective line first) so earlier
@@ -711,36 +596,29 @@ export function applyHashlineEdits(
const autocorrect = Bun.env.PI_HL_AUTOCORRECT === "1";
// Parse src specs and dst lines up front
const parsed = edits.map(edit => {
const parsedEdit = parseHashlineEdit(edit);
return {
spec: parsedEdit.spec,
dstLines: stripNewLinePrefixes(parsedEdit.dstLines),
};
});
function collectExplicitlyTouchedLines(): Set<number> {
const touched = new Set<number>();
for (const { spec } of parsed) {
switch (spec.kind) {
case "single":
touched.add(spec.ref.line);
for (const edit of edits) {
switch (edit.op) {
case "set":
touched.add(edit.tag.line);
break;
case "range":
for (let ln = spec.start.line; ln <= spec.end.line; ln++) touched.add(ln);
case "replace":
for (let ln = edit.first.line; ln <= edit.last.line; ln++) touched.add(ln);
break;
case "insertAfter":
touched.add(spec.after.line);
case "append":
if (edit.after) {
touched.add(edit.after.line);
}
break;
case "insertBefore":
touched.add(spec.before.line);
case "prepend":
if (edit.before) {
touched.add(edit.before.line);
}
break;
case "insertBetween":
touched.add(spec.after.line);
touched.add(spec.before.line);
break;
case "insertAtEof":
case "insert":
touched.add(edit.after.line);
touched.add(edit.before.line);
break;
}
}
@@ -755,59 +633,53 @@ export function applyHashlineEdits(
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
}
const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
if (actualHash === ref.hash.toLowerCase()) {
if (actualHash === ref.hash) {
return true;
}
mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
return false;
}
for (const { spec, dstLines } of parsed) {
switch (spec.kind) {
case "single": {
if (!validateRef(spec.ref)) continue;
for (const edit of edits) {
switch (edit.op) {
case "set": {
if (!validateRef(edit.tag)) continue;
break;
}
case "insertAfter": {
if (dstLines.length === 0) {
case "append": {
if (edit.content.length === 0) {
throw new Error('Insert-after edit (src "N#HH..") requires non-empty dst');
}
if (!validateRef(spec.after)) continue;
if (edit.after && !validateRef(edit.after)) continue;
break;
}
case "insertBefore": {
if (dstLines.length === 0) {
case "prepend": {
if (edit.content.length === 0) {
throw new Error('Insert-before edit (src "N#HH..") requires non-empty dst');
}
if (!validateRef(spec.before)) continue;
if (edit.before && !validateRef(edit.before)) continue;
break;
}
case "insertBetween": {
if (dstLines.length === 0) {
case "insert": {
if (edit.content.length === 0) {
throw new Error('Insert-between edit (src "A#HH.. B#HH..") requires non-empty dst');
}
if (spec.before.line !== spec.after.line + 1) {
if (edit.before.line !== edit.after.line + 1) {
throw new Error(
`insert requires adjacent anchors (after ${spec.after.line}, before ${spec.before.line})`,
`insert requires adjacent anchors (after ${edit.after.line}, before ${edit.before.line})`,
);
}
const afterValid = validateRef(spec.after);
const beforeValid = validateRef(spec.before);
const afterValid = validateRef(edit.after);
const beforeValid = validateRef(edit.before);
if (!afterValid || !beforeValid) continue;
break;
}
case "insertAtEof": {
if (dstLines.length === 0) {
throw new Error("Insert-at-EOF edit requires non-empty dst");
}
break;
}
case "range": {
if (spec.start.line > spec.end.line) {
throw new Error(`Range start line ${spec.start.line} must be <= end line ${spec.end.line}`);
case "replace": {
if (edit.first.line > edit.last.line) {
throw new Error(`Range start line ${edit.first.line} must be <= end line ${edit.last.line}`);
}
const startValid = validateRef(spec.start);
const endValid = validateRef(spec.end);
const startValid = validateRef(edit.first);
const endValid = validateRef(edit.last);
if (!startValid || !endValid) continue;
break;
}
@@ -819,30 +691,35 @@ export function applyHashlineEdits(
// Deduplicate identical edits targeting the same line(s)
const seenEditKeys = new Map<string, number>();
const dedupIndices = new Set<number>();
for (let i = 0; i < parsed.length; i++) {
const p = parsed[i];
for (let i = 0; i < edits.length; i++) {
const edit = edits[i];
let lineKey: string;
switch (p.spec.kind) {
case "single":
lineKey = `s:${p.spec.ref.line}`;
switch (edit.op) {
case "set":
lineKey = `s:${edit.tag.line}`;
break;
case "range":
lineKey = `r:${p.spec.start.line}:${p.spec.end.line}`;
case "replace":
lineKey = `r:${edit.first.line}:${edit.last.line}`;
break;
case "insertAfter":
lineKey = `i:${p.spec.after.line}`;
break;
case "insertBefore":
lineKey = `ib:${p.spec.before.line}`;
break;
case "insertBetween":
lineKey = `ix:${p.spec.after.line}:${p.spec.before.line}`;
break;
case "insertAtEof":
case "append":
if (edit.after) {
lineKey = `i:${edit.after.line}`;
break;
}
lineKey = "ieof";
break;
case "prepend":
if (edit.before) {
lineKey = `ib:${edit.before.line}`;
break;
}
lineKey = "ibef";
break;
case "insert":
lineKey = `ix:${edit.after.line}:${edit.before.line}`;
break;
}
const dstKey = `${lineKey}|${p.dstLines.join("\n")}`;
const dstKey = `${lineKey}:${edit.content.join("\n")}`;
if (seenEditKeys.has(dstKey)) {
dedupIndices.add(i);
} else {
@@ -850,51 +727,47 @@ export function applyHashlineEdits(
}
}
if (dedupIndices.size > 0) {
for (let i = parsed.length - 1; i >= 0; i--) {
if (dedupIndices.has(i)) parsed.splice(i, 1);
for (let i = edits.length - 1; i >= 0; i--) {
if (dedupIndices.has(i)) edits.splice(i, 1);
}
}
// Compute sort key (descending) — bottom-up application
const annotated = parsed.map((p, idx) => {
const annotated = edits.map((edit, idx) => {
let sortLine: number;
let precedence: number;
switch (p.spec.kind) {
case "single":
sortLine = p.spec.ref.line;
switch (edit.op) {
case "set":
sortLine = edit.tag.line;
precedence = 0;
break;
case "range":
sortLine = p.spec.end.line;
case "replace":
sortLine = edit.last.line;
precedence = 0;
break;
case "insertAfter":
sortLine = p.spec.after.line;
case "append":
sortLine = edit.after ? edit.after.line : fileLines.length + 1;
precedence = 1;
break;
case "insertBefore":
sortLine = p.spec.before.line;
case "prepend":
sortLine = edit.before ? edit.before.line : 0;
precedence = 2;
break;
case "insertBetween":
sortLine = p.spec.before.line;
case "insert":
sortLine = edit.before.line;
precedence = 3;
break;
case "insertAtEof":
sortLine = fileLines.length + 1;
precedence = 4;
break;
}
return { ...p, idx, sortLine, precedence };
return { edit, idx, sortLine, precedence };
});
annotated.sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx);
// Apply edits bottom-up
for (const { spec, dstLines, idx } of annotated) {
switch (spec.kind) {
case "single": {
const merged = autocorrect ? maybeExpandSingleLineMerge(spec.ref.line, dstLines) : null;
for (const { edit, idx } of annotated) {
switch (edit.op) {
case "set": {
const merged = autocorrect ? maybeExpandSingleLineMerge(edit.tag.line, edit.content) : null;
if (merged) {
const origLines = originalFileLines.slice(
merged.startLine - 1,
@@ -902,16 +775,11 @@ export function applyHashlineEdits(
);
let nextLines = merged.newLines;
nextLines = restoreIndentForPairedReplacement([origLines[0] ?? ""], nextLines);
if (
origLines.join("\n") === nextLines.join("\n") &&
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
) {
nextLines = normalizeConfusableHyphensInLines(nextLines);
}
if (origLines.join("\n") === nextLines.join("\n")) {
if (origLines.every((line, i) => line === nextLines[i])) {
noopEdits.push({
editIndex: idx,
loc: `${spec.ref.line}#${spec.ref.hash}`,
loc: `${edit.tag.line}#${edit.tag.hash}`,
currentContent: origLines.join("\n"),
});
break;
@@ -922,112 +790,113 @@ export function applyHashlineEdits(
}
const count = 1;
const origLines = originalFileLines.slice(spec.ref.line - 1, spec.ref.line);
const origLines = originalFileLines.slice(edit.tag.line - 1, edit.tag.line);
let stripped = autocorrect
? stripRangeBoundaryEcho(originalFileLines, spec.ref.line, spec.ref.line, dstLines)
: dstLines;
? stripRangeBoundaryEcho(originalFileLines, edit.tag.line, edit.tag.line, edit.content)
: edit.content;
stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
let newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
if (
autocorrect &&
origLines.join("\n") === newLines.join("\n") &&
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
) {
newLines = normalizeConfusableHyphensInLines(newLines);
}
if (origLines.join("\n") === newLines.join("\n")) {
const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
if (origLines.every((line, i) => line === newLines[i])) {
noopEdits.push({
editIndex: idx,
loc: `${spec.ref.line}#${spec.ref.hash}`,
loc: `${edit.tag.line}#${edit.tag.hash}`,
currentContent: origLines.join("\n"),
});
break;
}
fileLines.splice(spec.ref.line - 1, count, ...newLines);
trackFirstChanged(spec.ref.line);
fileLines.splice(edit.tag.line - 1, count, ...newLines);
trackFirstChanged(edit.tag.line);
break;
}
case "range": {
const count = spec.end.line - spec.start.line + 1;
const origLines = originalFileLines.slice(spec.start.line - 1, spec.start.line - 1 + count);
case "replace": {
const count = edit.last.line - edit.first.line + 1;
const origLines = originalFileLines.slice(edit.first.line - 1, edit.first.line - 1 + count);
let stripped = autocorrect
? stripRangeBoundaryEcho(originalFileLines, spec.start.line, spec.end.line, dstLines)
: dstLines;
? stripRangeBoundaryEcho(originalFileLines, edit.first.line, edit.last.line, edit.content)
: edit.content;
stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
let newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
if (
autocorrect &&
origLines.join("\n") === newLines.join("\n") &&
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
) {
newLines = normalizeConfusableHyphensInLines(newLines);
}
if (origLines.join("\n") === newLines.join("\n")) {
const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
if (autocorrect && origLines.every((line, i) => line === newLines[i])) {
noopEdits.push({
editIndex: idx,
loc: `${spec.start.line}#${spec.start.hash}`,
loc: `${edit.first.line}#${edit.first.hash}`,
currentContent: origLines.join("\n"),
});
break;
}
fileLines.splice(spec.start.line - 1, count, ...newLines);
trackFirstChanged(spec.start.line);
fileLines.splice(edit.first.line - 1, count, ...newLines);
trackFirstChanged(edit.first.line);
break;
}
case "insertAfter": {
const anchorLine = originalFileLines[spec.after.line - 1];
const inserted = autocorrect ? stripInsertAnchorEchoAfter(anchorLine, dstLines) : dstLines;
case "append": {
const inserted = edit.after
? autocorrect
? stripInsertAnchorEchoAfter(originalFileLines[edit.after.line - 1], edit.content)
: edit.content
: edit.content;
if (inserted.length === 0) {
noopEdits.push({
editIndex: idx,
loc: `${spec.after.line}#${spec.after.hash}`,
currentContent: originalFileLines[spec.after.line - 1],
loc: edit.after ? `${edit.after.line}#${edit.after.hash}` : "EOF",
currentContent: edit.after ? originalFileLines[edit.after.line - 1] : "",
});
break;
}
fileLines.splice(spec.after.line, 0, ...inserted);
trackFirstChanged(spec.after.line + 1);
if (edit.after) {
fileLines.splice(edit.after.line, 0, ...inserted);
trackFirstChanged(edit.after.line + 1);
} else {
if (fileLines.length === 1 && fileLines[0] === "") {
fileLines.splice(0, 1, ...inserted);
trackFirstChanged(1);
} else {
fileLines.splice(fileLines.length, 0, ...inserted);
trackFirstChanged(fileLines.length - inserted.length + 1);
}
}
break;
}
case "insertBefore": {
const anchorLine = originalFileLines[spec.before.line - 1];
const inserted = autocorrect ? stripInsertAnchorEchoBefore(anchorLine, dstLines) : dstLines;
case "prepend": {
const inserted = edit.before
? autocorrect
? stripInsertAnchorEchoBefore(originalFileLines[edit.before.line - 1], edit.content)
: edit.content
: edit.content;
if (inserted.length === 0) {
noopEdits.push({
editIndex: idx,
loc: `${spec.before.line}#${spec.before.hash}`,
currentContent: originalFileLines[spec.before.line - 1],
loc: edit.before ? `${edit.before.line}#${edit.before.hash}` : "BOF",
currentContent: edit.before ? originalFileLines[edit.before.line - 1] : "",
});
break;
}
fileLines.splice(spec.before.line - 1, 0, ...inserted);
trackFirstChanged(spec.before.line);
if (edit.before) {
fileLines.splice(edit.before.line - 1, 0, ...inserted);
trackFirstChanged(edit.before.line);
} else {
if (fileLines.length === 1 && fileLines[0] === "") {
fileLines.splice(0, 1, ...inserted);
} else {
fileLines.splice(0, 0, ...inserted);
}
trackFirstChanged(1);
}
break;
}
case "insertBetween": {
const afterLine = originalFileLines[spec.after.line - 1];
const beforeLine = originalFileLines[spec.before.line - 1];
const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, dstLines) : dstLines;
case "insert": {
const afterLine = originalFileLines[edit.after.line - 1];
const beforeLine = originalFileLines[edit.before.line - 1];
const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, edit.content) : edit.content;
if (inserted.length === 0) {
noopEdits.push({
editIndex: idx,
loc: `${spec.after.line}#${spec.after.hash}..${spec.before.line}#${spec.before.hash}`,
loc: `${edit.after.line}#${edit.after.hash}..${edit.before.line}#${edit.before.hash}`,
currentContent: `${afterLine}\n${beforeLine}`,
});
break;
}
fileLines.splice(spec.before.line - 1, 0, ...inserted);
trackFirstChanged(spec.before.line);
break;
}
case "insertAtEof": {
if (fileLines.length === 1 && fileLines[0] === "") {
fileLines.splice(0, 1, ...dstLines);
trackFirstChanged(1);
break;
}
fileLines.splice(fileLines.length, 0, ...dstLines);
trackFirstChanged(fileLines.length - dstLines.length + 1);
fileLines.splice(edit.before.line - 1, 0, ...inserted);
trackFirstChanged(edit.before.line);
break;
}
}
@@ -1047,12 +916,12 @@ export function applyHashlineEdits(
function maybeExpandSingleLineMerge(
line: number,
dst: string[],
content: string[],
): { startLine: number; deleteCount: number; newLines: string[] } | null {
if (dst.length !== 1) return null;
if (content.length !== 1) return null;
if (line < 1 || line > fileLines.length) return null;
const newLine = dst[0];
const newLine = content[0];
const newCanon = stripAllWhitespace(newLine);
const newCanonForMergeOps = stripMergeOperatorChars(newCanon);
if (newCanon.length === 0) return null;
+271 -160
View File
@@ -34,7 +34,14 @@ import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard"
import { applyPatch } from "./applicator";
import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff";
import { findMatch } from "./fuzzy";
import { applyHashlineEdits, computeLineHash, parseLineRef } from "./hashline";
import {
applyHashlineEdits,
computeLineHash,
type HashlineEdit,
type LineTag,
parseTag,
type ReplaceTextEdit,
} from "./hashline";
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
import { buildNormativeUpdateInput } from "./normative";
import { type EditToolDetails, getLspBatchRequest } from "./shared";
@@ -66,7 +73,7 @@ export {
computeLineHash,
formatHashLines,
HashlineMismatchError,
parseLineRef,
parseTag,
streamHashLinesFromLines,
streamHashLinesFromUtf8,
validateLineRef,
@@ -128,26 +135,68 @@ const patchEditSchema = Type.Object({
export type ReplaceParams = Static<typeof replaceEditSchema>;
export type PatchParams = Static<typeof patchEditSchema>;
type HashlineEditUnion = Static<typeof hashlineEditItemSchemaWithReplace>;
type HashlineReplaceEdit = { replace: { old_text: string; new_text: string; all?: boolean } };
export type HashlineEdit =
| { set: { ref: string; body: string[] } }
| { set_range: { beg: string; end: string; body: string[] } }
| { insert: { before?: string; after?: string; body: string[] } }
| HashlineReplaceEdit;
/** Pattern matching hashline display format: `LINE#ID:CONTENT` */
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[0-9a-zA-Z]{1,16}:/;
const hashlineContentFormat = (kind: string) =>
/** Pattern matching a unified-diff `+` prefix (but not `++`) */
const DIFF_PLUS_RE = /^[+-](?![+-])/;
/**
* Strip hashline display prefixes and diff `+` markers from replacement lines.
*
* Models frequently copy the `LINE#ID ` prefix from read output into their
* replacement content, or include unified-diff `+` prefixes. Both corrupt the
* output file. This strips them heuristically before application.
*/
function stripNewLinePrefixes(lines: string[]): string[] {
// Detect whether the *majority* of non-empty lines carry a prefix —
// if only one line out of many has a match it's likely real content.
let hashPrefixCount = 0;
let diffPlusCount = 0;
let nonEmpty = 0;
for (const l of lines) {
if (l.length === 0) continue;
nonEmpty++;
if (HASHLINE_PREFIX_RE.test(l)) hashPrefixCount++;
if (DIFF_PLUS_RE.test(l)) diffPlusCount++;
}
if (nonEmpty === 0) return lines;
const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5;
const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
if (!stripHash && !stripPlus) return lines;
return lines.map(l => {
if (stripHash) return l.replace(HASHLINE_PREFIX_RE, "");
if (stripPlus) return l.replace(DIFF_PLUS_RE, "");
return l;
});
}
const hashlineReplaceContentFormat = (kind: string) =>
Type.Union([
Type.Null(),
Type.Array(Type.String(), { description: `${kind} lines` }),
Type.String({ description: `${kind} line` }),
]);
const hashlineInsertContentFormat = (kind: string) =>
Type.Union([
Type.Array(Type.String(), { description: `${kind} lines`, minItems: 1 }),
Type.String({ description: `${kind} line`, minLength: 1 }),
]);
const hashlineTagFormat = (what: string) =>
Type.String({
description: `Tag identifying the ${what} in "LINE#ID" format`,
});
function hashlineParseContent(edit: string | string[] | null): string[] {
if (edit === null) return [];
if (Array.isArray(edit)) return edit;
const lines = edit.split("\n");
const lines = stripNewLinePrefixes(edit.split("\n"));
if (lines.length === 0) return [];
if (lines[lines.length - 1].trim() === "") return lines.slice(0, -1);
return lines;
@@ -159,113 +208,89 @@ function hashlineParseContentString(edit: string | string[] | null): string {
return edit;
}
const hashlineTargetEditSchema = Type.Object({
target: Type.String({ description: 'Line reference "LINE#ID"' }),
new_content: hashlineContentFormat("Replacement"),
});
const hashlineTargetEditSchema = Type.Object(
{
op: Type.Literal("set"),
tag: hashlineTagFormat("line being replaced"),
content: hashlineReplaceContentFormat("Replacement"),
},
{ additionalProperties: false },
);
function hashlineParseTargetEdit(edit: HashlineEditUnion): HashlineEdit | null {
if ("target" in edit) {
return {
set: {
ref: edit.target,
body: hashlineParseContent(edit.new_content),
},
};
}
return null;
}
const hashlineAppendEditSchema = Type.Object(
{
op: Type.Literal("append"),
after: Type.Optional(hashlineTagFormat("line after which to append")),
content: hashlineInsertContentFormat("Appended"),
},
{ additionalProperties: false },
);
const hashlineRangeEditSchema = Type.Object({
first: Type.String({ description: 'Start line ref "LINE#ID"' }),
last: Type.String({ description: 'End line ref "LINE#ID"' }),
new_content: hashlineContentFormat("Replacement"),
});
const hashlinePrependEditSchema = Type.Object(
{
op: Type.Literal("prepend"),
before: Type.Optional(hashlineTagFormat("line before which to prepend")),
content: hashlineInsertContentFormat("Prepended"),
},
{ additionalProperties: false },
);
function hashlineParseRangeEdit(edit: HashlineEditUnion): HashlineEdit | null {
if ("first" in edit && "last" in edit) {
return {
set_range: {
beg: edit.first,
end: edit.last,
body: hashlineParseContent(edit.new_content),
},
};
}
return null;
}
const hashlineRangeEditSchema = Type.Object(
{
op: Type.Literal("replace"),
first: hashlineTagFormat("first line"),
last: hashlineTagFormat("last line"),
content: hashlineReplaceContentFormat("Replacement"),
},
{ additionalProperties: false },
);
const hashlineInsertEditSchema = Type.Object({
before: Type.Optional(Type.String({ description: 'Insert before this line "LINE#ID"' })),
after: Type.Optional(Type.String({ description: 'Insert after this line "LINE#ID"' })),
inserted_lines: hashlineContentFormat("Inserted"),
});
const hashlineInsertEditSchema = Type.Object(
{
op: Type.Literal("insert"),
before: Type.Optional(hashlineTagFormat("line before which to insert")),
after: Type.Optional(hashlineTagFormat("line after which to insert")),
content: hashlineInsertContentFormat("Inserted"),
},
{ additionalProperties: false },
);
function hashlineParseInsertEdit(edit: HashlineEditUnion): HashlineEdit | null {
if ("inserted_lines" in edit) {
const { before = "", after = "", inserted_lines } = edit;
return {
insert: {
before: before.length > 0 ? before : undefined,
after: after.length > 0 ? after : undefined,
body: hashlineParseContent(inserted_lines),
},
};
}
return null;
}
const hashlineReplaceTextEditSchema = Type.Object(
{
op: Type.Literal("replaceText"),
old_text: Type.String({ description: "Text to find", minLength: 1 }),
new_text: hashlineReplaceContentFormat("Replacement"),
all: Type.Optional(Type.Boolean({ description: "Replace all occurrences" })),
},
{ additionalProperties: false },
);
const hashlineReplaceTextEditSchema = Type.Object({
old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }),
new_text: hashlineContentFormat("Replacement"),
all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })),
});
const HL_REPLACE_ENABLED = Bun.env.PI_HL_REPLACETXT === "1";
function hashlineParseReplaceTextEdit(edit: HashlineEditUnion): HashlineReplaceEdit | null {
if ("old_text" in edit && "new_text" in edit) {
const { old_text, new_text, all } = edit;
return {
replace: {
old_text: old_text,
new_text: hashlineParseContentString(new_text),
all: all,
},
};
}
return null;
}
const hashlineEditItemSchema = Type.Union([
const hashlineEditSpecSchema = Type.Union([
hashlineTargetEditSchema,
hashlineRangeEditSchema,
hashlineAppendEditSchema,
hashlinePrependEditSchema,
hashlineInsertEditSchema,
]);
const hashlineEditItemSchemaWithReplace = Type.Union([
hashlineTargetEditSchema,
hashlineRangeEditSchema,
hashlineInsertEditSchema,
hashlineReplaceTextEditSchema,
...(HL_REPLACE_ENABLED ? [hashlineReplaceTextEditSchema] : []),
]);
const hashlineEditSchema = Type.Object({
path: Type.String({ description: "File path (relative or absolute)" }),
edits: Type.Array(hashlineEditItemSchema, { description: "Array of edit operations" }),
delete: Type.Optional(Type.Literal(true, { description: "Delete the file" })),
rename: Type.Optional(Type.String({ description: "New path for move" })),
});
const hashlineEditSchemaWithReplace = Type.Object({
path: Type.String({ description: "File path (relative or absolute)" }),
edits: Type.Array(hashlineEditItemSchemaWithReplace, { description: "Array of edit operations" }),
delete: Type.Optional(Type.Literal(true, { description: "Delete the file" })),
rename: Type.Optional(Type.String({ description: "New path for move" })),
});
const hashlineEditSchema = Type.Object(
{
path: Type.String({ description: "File path (relative or absolute)" }),
edits: Type.Array(hashlineEditSpecSchema, {
description: "Changes to apply to the file at `path`",
minItems: 0,
}),
delete: Type.Optional(Type.Boolean({ description: "Delete the file when true" })),
rename: Type.Optional(Type.String({ description: "New path if moving" })),
},
{ additionalProperties: false },
);
export type HashlineToolEdit = Static<typeof hashlineEditItemSchemaWithReplace>;
export type HashlineParams = Static<typeof hashlineEditSchemaWithReplace>;
function isHashlineReplaceTextEnabled(): boolean {
return Bun.env.PI_HL_REPLACETXT === "1";
}
export type HashlineToolEdit = Static<typeof hashlineEditSpecSchema>;
export type HashlineParams = Static<typeof hashlineEditSchema>;
// ═══════════════════════════════════════════════════════════════════════════
// LSP FileSystem for patch mode
@@ -355,11 +380,7 @@ function mergeDiagnosticsWithWarnings(
// Tool Class
// ═══════════════════════════════════════════════════════════════════════════
type TInput =
| typeof replaceEditSchema
| typeof patchEditSchema
| typeof hashlineEditSchema
| typeof hashlineEditSchemaWithReplace;
type TInput = typeof replaceEditSchema | typeof patchEditSchema | typeof hashlineEditSchema;
export type EditMode = "replace" | "patch" | "hashline";
@@ -465,7 +486,7 @@ export class EditTool implements AgentTool<TInput> {
case "patch":
return renderPromptTemplate(patchDescription);
case "hashline":
return renderPromptTemplate(hashlineDescription, { allowReplaceText: isHashlineReplaceTextEnabled() });
return renderPromptTemplate(hashlineDescription, { allowReplaceText: HL_REPLACE_ENABLED });
default:
return renderPromptTemplate(replaceDescription);
}
@@ -479,7 +500,7 @@ export class EditTool implements AgentTool<TInput> {
case "patch":
return patchEditSchema;
case "hashline":
return isHashlineReplaceTextEnabled() ? hashlineEditSchemaWithReplace : hashlineEditSchema;
return hashlineEditSchema;
default:
return replaceEditSchema;
}
@@ -499,9 +520,8 @@ export class EditTool implements AgentTool<TInput> {
// ─────────────────────────────────────────────────────────────────
if (this.mode === "hashline") {
const { path, edits, delete: deleteFile, rename } = params as HashlineParams;
const op: Operation = deleteFile === true ? "delete" : "update";
enforcePlanModeWrite(this.session, path, { op, rename });
enforcePlanModeWrite(this.session, path, { op: deleteFile ? "delete" : "update", rename });
if (path.endsWith(".ipynb") && edits?.length > 0) {
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
@@ -511,7 +531,7 @@ export class EditTool implements AgentTool<TInput> {
const resolvedRename = rename ? resolvePlanPath(this.session, rename) : undefined;
const file = Bun.file(absolutePath);
if (deleteFile === true) {
if (deleteFile) {
if (await file.exists()) {
await file.unlink();
}
@@ -527,30 +547,115 @@ export class EditTool implements AgentTool<TInput> {
}
if (!(await file.exists())) {
throw new Error(`File not found: ${path}`);
}
const allowReplaceText = isHashlineReplaceTextEnabled();
const anchorEdits: HashlineEdit[] = [];
const replaceEdits: HashlineReplaceEdit[] = [];
for (let i = 0; i < edits.length; i++) {
const edit = edits[i] as HashlineEditUnion;
const anchorEdit =
hashlineParseTargetEdit(edit) ?? hashlineParseRangeEdit(edit) ?? hashlineParseInsertEdit(edit);
if (!anchorEdit) {
if (allowReplaceText) {
const replaceEdit = hashlineParseReplaceTextEdit(edit);
if (replaceEdit) {
replaceEdits.push(replaceEdit);
continue;
const content: string[] = [];
for (const edit of edits) {
switch (edit.op) {
case "append": {
if (edit.after) {
throw new Error(`File not found: ${path}`);
}
content.push(...hashlineParseContent(edit.content));
break;
}
case "prepend": {
if (edit.before) {
throw new Error(`File not found: ${path}`);
}
content.unshift(...hashlineParseContent(edit.content));
break;
}
default: {
throw new Error(`File not found: ${path}`);
}
}
throw new Error(
`edits[${i}] must match exactly one variant: {target,new_content} | {first,last,new_content} | {before/after,inserted_lines}${allowReplaceText ? " | {old_text,new_text}" : ""}. Got keys: [${Object.keys(edit).join(", ")}].`,
);
}
anchorEdits.push(anchorEdit);
await file.write(content.join("\n"));
return {
content: [{ type: "text", text: `Created ${path}` }],
details: {
diff: "",
op: "create",
meta: outputMeta().get(),
},
};
}
const anchorEdits: HashlineEdit[] = [];
const replaceEdits: ReplaceTextEdit[] = [];
for (const edit of edits) {
switch (edit.op) {
case "set": {
const { tag, content } = edit;
anchorEdits.push({ op: "set", tag: parseTag(tag), content: hashlineParseContent(content) });
break;
}
case "replace": {
const { first, last, content } = edit;
anchorEdits.push({
op: "replace",
first: parseTag(first),
last: parseTag(last),
content: hashlineParseContent(content),
});
break;
}
case "append": {
const { after, content } = edit;
anchorEdits.push({
op: "append",
...(after ? { after: parseTag(after) } : {}),
content: hashlineParseContent(content),
});
break;
}
case "prepend": {
const { before, content } = edit;
anchorEdits.push({
op: "prepend",
...(before ? { before: parseTag(before) } : {}),
content: hashlineParseContent(content),
});
break;
}
case "insert": {
const { before, after, content } = edit;
if (before && !after) {
anchorEdits.push({
op: "prepend",
before: parseTag(before),
content: hashlineParseContent(content),
});
} else if (after && !before) {
anchorEdits.push({
op: "append",
after: parseTag(after),
content: hashlineParseContent(content),
});
} else if (before && after) {
anchorEdits.push({
op: "insert",
before: parseTag(before),
after: parseTag(after),
content: hashlineParseContent(content),
});
} else {
throw new Error(`Insert must have both before and after tags.`);
}
break;
}
case "replaceText": {
const { old_text, new_text, all } = edit;
replaceEdits.push({
op: "replaceText",
old_text: old_text,
new_text: hashlineParseContentString(new_text),
all: all ?? false,
});
break;
}
default:
throw new Error(`Invalid edit operation: ${JSON.stringify(edit)}`);
}
}
const rawContent = await file.text();
@@ -565,12 +670,12 @@ export class EditTool implements AgentTool<TInput> {
// Apply content-replace edits (substr-style fuzzy replace)
for (const r of replaceEdits) {
if (r.replace.old_text.length === 0) {
throw new Error("replace.old_text must not be empty.");
if (r.old_text.length === 0) {
throw new Error("old_text must not be empty.");
}
const rep = replaceText(normalizedContent, r.replace.old_text, r.replace.new_text, {
const rep = replaceText(normalizedContent, r.old_text, r.new_text, {
fuzzy: this.#allowFuzzy,
all: r.replace.all ?? false,
all: r.all ?? false,
threshold: this.#fuzzyThreshold,
});
normalizedContent = rep.content;
@@ -598,21 +703,35 @@ export class EditTool implements AgentTool<TInput> {
// Edits were not literally identical but heuristics normalized them back
const lines = result.content.split("\n");
const targetLines: string[] = [];
const refs: LineTag[] = [];
for (const edit of anchorEdits) {
const refs: string[] = [];
if ("set" in edit) refs.push(edit.set.ref);
else if ("set_range" in edit) refs.push(edit.set_range.beg, edit.set_range.end);
else if ("insert" in edit) {
if (edit.insert.after) refs.push(edit.insert.after);
if (edit.insert.before) refs.push(edit.insert.before);
refs.length = 0;
switch (edit.op) {
case "set":
refs.push(edit.tag);
break;
case "replace":
refs.push(edit.first, edit.last);
break;
case "append":
if (edit.after) refs.push(edit.after);
break;
case "prepend":
if (edit.before) refs.push(edit.before);
break;
case "insert":
refs.push(edit.after, edit.before);
break;
default:
break;
}
for (const ref of refs) {
try {
const parsed = parseLineRef(ref);
if (parsed.line >= 1 && parsed.line <= lines.length) {
const lineContent = lines[parsed.line - 1];
const hash = computeLineHash(parsed.line, lineContent);
targetLines.push(`${parsed.line}#${hash}|${lineContent}`);
if (ref.line >= 1 && ref.line <= lines.length) {
const lineContent = lines[ref.line - 1];
const hash = computeLineHash(ref.line, lineContent);
targetLines.push(`${ref.line}#${hash}:${lineContent}`);
}
} catch {
/* skip malformed refs */
@@ -667,7 +786,7 @@ export class EditTool implements AgentTool<TInput> {
diff: diffResult.diff,
firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
diagnostics,
op,
op: "update",
rename,
meta,
},
@@ -714,17 +833,10 @@ export class EditTool implements AgentTool<TInput> {
// Generate diff for display
let diffResult = { diff: "", firstChangedLine: undefined as number | undefined };
let normative: PatchInput | undefined;
if (result.change.type === "update" && result.change.oldContent && result.change.newContent) {
const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text);
const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text);
diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew);
normative = buildNormativeUpdateInput({
path,
rename: effRename,
oldContent: result.change.oldContent,
newContent: result.change.newContent,
});
}
let resultText: string;
@@ -762,7 +874,6 @@ export class EditTool implements AgentTool<TInput> {
rename: effRename,
meta,
},
$normative: normative,
};
}
@@ -13,7 +13,7 @@ Powerful search tool built on ripgrep.
<output>
- Results are always content mode.
{{#if IS_HASHLINE_MODE}}
- Text output is CID prefixed: `LINE#ID|content`
- Text output is CID prefixed: `LINE#ID:content`
{{else}}
{{#if IS_LINE_NUMBER_MODE}}
- Text output is line-number-prefixed
@@ -1,12 +1,11 @@
# Edit
Apply precise file edits using `LINE#ID` anchors from `read` output.
**CRITICAL:** anchors are `LINE#ID` only. Copy verbatim from the prefix (example: `{{hlineref 42 "const x = 1"}}`). Never include `|content`.
Apply precise file edits using `LINE#ID` tags, anchoring to the file content.
<workflow>
1. `read` the target range to capture current `LINE#ID` anchors.
1. `read` the target range to capture current `LINE#ID` tags.
2. Pick the smallest operation per change site (line/range/insert/content-replace).
3. Direction-lock every edit: exact current text -> intended text.
3. Direction-lock every edit: exact current text → intended text.
4. Submit one `edit` call per file containing all operations.
5. If another edit is needed in that file, re-read first (hashes changed).
6. Output tool calls only; no prose.
@@ -14,23 +13,22 @@ Apply precise file edits using `LINE#ID` anchors from `read` output.
<operations>
- **Single line replace/delete**
- `{ target: "LINE#ID", new_content: ["..."] }`
- `new_content: null` deletes the line; `new_content: [""]` keeps a blank line.
- `{ op: "set", tag: "N#ID", content: […] }`
- `content: null` deletes the line; `content: [""]` keeps a blank line.
- **Range replace/delete**
- `{ first: "LINE#ID", last: "LINE#ID", new_content: ["..."] }`
- Use for swaps, block rewrites, or deleting a full span (`new_content: null`).
- `{ op: "replace", first: "N#ID", last: "N#ID", content: […] }`
- Use for swaps, block rewrites, or deleting a full span (`content: null`).
- **Insert** (new content)
- `{ before: "LINE#ID", inserted_lines: ["..."] }`
- `{ after: "LINE#ID", inserted_lines: ["..."] }`
- `{ after: "LINE#ID", before: "LINE#ID", inserted_lines: ["..."] }` (between adjacent anchors; safest for blocks)
- `inserted_lines` must be non-empty.
- `{ op: "prepend", before: "N#ID", content: […] }` or `{ op: "prepend", content: […] }` (no `before` = insert at beginning of file)
- `{ op: "append", after: "N#ID", content: […] }` or `{ op: "append", content: […] }` (no `after` = insert at end of file)
- `{ op: "insert", after: "N#ID", before: "N#ID", content: […] }` (between adjacent anchors; safest for blocks)
{{#if allowReplaceText}}
- **Content replace** (fallback when anchors unavailable)
- `{ old_text: "...", new_text: "...", all?: boolean }`
- **Content replace**
- `{ op: "replaceText", old_text: "…", new_text: "…", all?: boolean }`
{{/if}}
- **File-level controls**
- `{ delete: true, edits: [] }` deletes the file (cannot be combined with `rename`).
- `{ rename: "new/path.ts", edits: [...] }` writes result to new path and removes old path.
- `{ rename: "new/path.ts", edits: […] }` writes result to new path and removes old path.
**Atomicity:** all ops validate against the same pre-edit file snapshot; refs are interpreted against last `read`; applicator applies bottom-up.
</operations>
@@ -44,61 +42,61 @@ Apply precise file edits using `LINE#ID` anchors from `read` output.
7. **For swaps/moves:** prefer one range operation over multiple single-line operations.
</rules>
<selection_heuristics>
- One wrong line -> `{ target, new_content }`
- Adjacent block changed -> `{ first, last, new_content }`
- Missing line/block -> insert with `before`/`after` + `inserted_lines`
</selection_heuristics>
<op_choice>
- One wrong line → `set`
- Adjacent block changed → `insert`
- Missing line/block → insert with `append`/`prepend`
</op_choice>
<anchor_hygiene>
- Copy anchor IDs exactly from `read` or error output.
- Never handcraft hashes.
- For inserts, prefer `after+before` dual anchors when both boundaries are known.
<tag_choice>
- Copy tags exactly from the prefix of the `read` or error output.
- Never guess tags.
- For inserts, prefer `insert` > `append`/`prepend` when both boundaries are known.
- Re-read after each successful edit call before issuing another on same file.
</anchor_hygiene>
</tag_choice>
<recovery>
**Hash mismatch (`>>>`)**
- Retry with the updated anchors shown in error output.
- Re-read only if required anchors are missing from error snippet.
**Tag mismatch (`>>>`)**
- Retry with the updated tags shown in error output.
- Re-read only if required tags are missing from error snippet.
- If mismatch repeats, stop and re-read the exact block.
**No-op / identical content**
- Re-read immediately; target is stale or replacement equals current text.
- After two no-ops on same area, re-read the full function/block before retry.
</recovery>
<example name="single line replace — fix a value or type">
<example name="fix a value or type">
```ts
{{hlinefull 23 " const timeout: number = 5000;"}}
```
```
target: "{{hlineref 23 " const timeout: number = 5000;"}}"
new_content: [" const timeout: number = 30_000;"]
op: "set"
tag: "{{hlineref 23 " const timeout: number = 5000;"}}"
content: [" const timeout: number = 30_000;"]
```
</example>
<example name="single line delete — remove a line entirely">
<example name="remove a line entirely">
```ts
{{hlinefull 7 "// @ts-ignore"}}
{{hlinefull 8 "const data = fetchSync(url);"}}
```
```
target: "{{hlineref 7 "// @ts-ignore"}}"
new_content: null
op: "set"
tag: "{{hlineref 7 "// @ts-ignore"}}"
content: null
```
</example>
<example name="single line blank — clear content but keep the line break">
<example name="clear content but keep the line break">
```ts
{{hlinefull 14 " placeholder: \"DO NOT SHIP\","}}
```
```
target: "{{hlineref 14 " placeholder: \"DO NOT SHIP\","}}"
new_content: [""]
op: "set"
tag: "{{hlineref 14 " placeholder: \"DO NOT SHIP\","}}"
content: [""]
```
</example>
<example name="range replace — rewrite a block of logic">
<example name="rewrite a block of logic">
```ts
{{hlinefull 60 " } catch (err) {"}}
{{hlinefull 61 " console.error(err);"}}
@@ -106,13 +104,14 @@ new_content: [""]
{{hlinefull 63 " }"}}
```
```
op: "replace"
first: "{{hlineref 60 " } catch (err) {"}}"
last: "{{hlineref 63 " }"}}"
new_content: [" } catch (err) {", " if (isEnoent(err)) return null;", " throw err;", " }"]
content: [" } catch (err) {", " if (isEnoent(err)) return null;", " throw err;", " }"]
```
</example>
<example name="range delete — remove a full block">
<example name="remove a full block">
```ts
{{hlinefull 80 " // TODO: remove after migration"}}
{{hlinefull 81 " if (legacy) {"}}
@@ -120,49 +119,53 @@ new_content: [" } catch (err) {", " if (isEnoent(err)) return null;", "
{{hlinefull 83 " }"}}
```
```
op: "replace"
first: "{{hlineref 80 " // TODO: remove after migration"}}"
last: "{{hlineref 83 " }"}}"
new_content: null
content: null
```
</example>
<example name="insert with before — add an import above the first import">
<example name="add an import above the first import">
```ts
{{hlinefull 1 "import * as fs from \"node:fs/promises\";"}}
{{hlinefull 2 "import * as path from \"node:path\";"}}
```
```
op: "prepend"
before: "{{hlineref 1 "import * as fs from \"node:fs/promises\";"}}"
inserted_lines: ["import * as os from \"node:os\";"]
content: ["import * as os from \"node:os\";"]
```
Use `before` when prepending at the top of a block or file — there is no meaningful anchor above.
Use `before` for anchored insertion before a specific line. Omit `before` to prepend at BOF.
</example>
<example name="insert with after — append at end of file">
<example name="append at end of file">
```ts
{{hlinefull 260 "export { serialize, deserialize };"}}
```
```
op: "append"
after: "{{hlineref 260 "export { serialize, deserialize };"}}"
inserted_lines: ["export { validate };"]
content: ["export { validate };"]
```
Use `after` when appending at the bottom — there is no anchor below.
Use `after` for anchored insertion after a specific line. Omit `after` to append at EOF.
</example>
<example name="insert with after + before (dual anchor) — add an entry between known siblings">
<example name="add an entry between known siblings">
```ts
{{hlinefull 44 " \"build\": \"bun run compile\","}}
{{hlinefull 45 " \"test\": \"bun test\""}}
```
```
op: "insert"
after: "{{hlineref 44 " \"build\": \"bun run compile\","}}"
before: "{{hlineref 45 " \"test\": \"bun test\""}}"
inserted_lines: [" \"lint\": \"biome check\","]
content: [" \"lint\": \"biome check\","]
```
Dual anchors pin the insert to exactly one gap, preventing drift from edits elsewhere in the file. **Always prefer dual anchors when both boundaries are content lines.**
</example>
<example name="insert a function before another function — anchor to the target, not whitespace">
<example name="insert a function before another function">
```ts
{{hlinefull 100 " return buf.toString(\"hex\");"}}
{{hlinefull 101 "}"}}
@@ -170,18 +173,21 @@ Dual anchors pin the insert to exactly one gap, preventing drift from edits else
{{hlinefull 103 "export function serialize(data: unknown): string {"}}
```
```
op: "insert"
before: "{{hlineref 103 "export function serialize(data: unknown): string {"}}"
inserted_lines: ["function validate(data: unknown): boolean {", " return data != null && typeof data === \"object\";", "}", ""]
content: ["function validate(data: unknown): boolean {", " return data != null && typeof data === \"object\";", "}", ""]
```
The trailing `""` in `inserted_lines` preserves the blank-line separator. **Anchor to the structural line (`export function ...`), not the blank line above it** — blank lines are ambiguous and may be added or removed by other edits.
The trailing `""` in `content` preserves the blank-line separator. **Anchor to the structural line (`export function ...`), not the blank line above it** — blank lines are ambiguous and may be added or removed by other edits.
</example>
{{#if allowReplaceText}}
<example name="content replace (fallback) — when anchors are unavailable">
<example name="content replace (rare)">
```
op: "replaceText"
old_text: "x = 42"
new_text: "x = 99"
```
Use only when line anchors aren't available. `old_text` must match exactly one location in the file (or set `"all": true` for all occurrences).
</example>
{{/if}}
@@ -193,34 +199,34 @@ delete: true
```
</example>
<example name="file rename with edits — move and modify in one atomic call">
<example name="file rename with edits">
```
path: "src/utils.ts"
rename: "src/helpers/utils.ts"
edits: [..]
edits: […]
```
</example>
<example name="anti-pattern: anchoring to whitespace">
Bad — anchors to a blank line; fragile if blank lines shift:
Bad — tags to a blank line; fragile if blank lines shift:
```
after: "{{hlineref 102 ""}}"
inserted_lines: ["function validate() { ... }"]
content: ["function validate() {", …, "}"]
```
Good — anchors to the structural target:
```
before: "{{hlineref 103 "export function serialize(data: unknown): string {"}}"
inserted_lines: ["function validate() { ... }", ""]
content: ["function validate() {", …, "}"]
```
</example>
<validation>
- [ ] Payload shape is `{ "path": string, "edits": [operation, ...], "delete"?: true, "rename"?: string }`
- [ ] Every operation matches exactly one variant
- [ ] Every anchor is copied exactly as `LINE#ID` (no spaces, no `|content`)
- [ ] `new_content` / `inserted_lines` lines are raw content only (no diff markers, no anchor prefixes)
- [ ] Every replacement is meaningfully different from current content
- [ ] Scope is minimal and formatting is preserved except targeted token changes
</validation>
**Final reminder:** anchors are immutable references to the last read snapshot. Re-read when state changes, then edit.
<critical>
Ensure:
- Payload shape is `{ "path": string, "edits": [operation, …], "delete"?: boolean, "rename"?: string }`
- Every edit matches exactly one variant
- Every tag has been copied EXACTLY from a tool result as `N#ID`
- Scope is minimal and formatting is preserved except targeted token changes
</critical>
**Final reminder:** tags are immutable references to the last read snapshot. Re-read when state changes, then edit.
@@ -6,7 +6,7 @@ Reads files from local filesystem or internal URLs.
- Reads up to {{DEFAULT_MAX_LINES}} lines default
- Use `offset` and `limit` for large files
{{#if IS_HASHLINE_MODE}}
- Text output is CID prefixed: `LINE#ID|content`
- Text output is CID prefixed: `LINE#ID:content`
{{else}}
{{#if IS_LINE_NUMBER_MODE}}
- Text output is line-number-prefixed
+2 -2
View File
@@ -220,10 +220,10 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
const formatLine = (lineNumber: number, line: string, isMatch: boolean): string => {
if (useHashLines) {
const ref = `${lineNumber}#${computeLineHash(lineNumber, line)}`;
return isMatch ? `>>${ref}|${line}` : ` ${ref}|${line}`;
return isMatch ? `>>${ref}:${line}` : ` ${ref}:${line}`;
}
const padded = lineNumber.toString().padStart(lineWidth, " ");
return isMatch ? `>>${padded}|${line}` : ` ${padded}|${line}`;
return isMatch ? `>>${padded}:${line}` : ` ${padded}:${line}`;
};
// Add context before
+2 -2
View File
@@ -772,7 +772,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
const prependHashLines = (text: string, startNum: number): string => {
const textLines = text.split("\n");
return textLines
.map((line, i) => `${startNum + i}#${computeLineHash(startNum + i, line)}|${line}`)
.map((line, i) => `${startNum + i}#${computeLineHash(startNum + i, line)}:${line}`)
.join("\n");
};
const formatText = (text: string, startNum: number): string => {
@@ -929,7 +929,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
};
const prependHashLines = (text: string, startNum: number): string => {
const textLines = text.split("\n");
return textLines.map((line, i) => `${startNum + i}#${computeLineHash(startNum + i, line)}|${line}`).join("\n");
return textLines.map((line, i) => `${startNum + i}#${computeLineHash(startNum + i, line)}:${line}`).join("\n");
};
const formatText = (text: string, startNum: number): string => {
if (shouldAddHashLines) return prependHashLines(text, startNum);
+215 -259
View File
@@ -1,41 +1,45 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import {
applyHashlineEdits,
computeLineHash,
formatHashLines,
type HashlineEdit,
HashlineMismatchError,
parseLineRef,
parseTag,
streamHashLinesFromLines,
streamHashLinesFromUtf8,
validateLineRef,
} from "@oh-my-pi/pi-coding-agent/patch";
import { formatLineTag, type HashlineEdit, type LineTag } from "@oh-my-pi/pi-coding-agent/patch/hashline";
function makeTag(line: number, content: string): LineTag {
return parseTag(formatLineTag(line, content));
}
// ═══════════════════════════════════════════════════════════════════════════
// computeLineHash
// ═══════════════════════════════════════════════════════════════════════════
describe("computeLineHash", () => {
test("returns 2-4 character alphanumeric hash string", () => {
it("returns 2-4 character alphanumeric hash string", () => {
const hash = computeLineHash(1, "hello");
expect(hash).toMatch(/^[0-9a-z]{2,4}$/);
expect(hash).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/);
});
test("same content at same line produces same hash", () => {
it("same content at same line produces same hash", () => {
const a = computeLineHash(1, "hello");
const b = computeLineHash(1, "hello");
expect(a).toBe(b);
});
test("different content produces different hash", () => {
it("different content produces different hash", () => {
const a = computeLineHash(1, "hello");
const b = computeLineHash(1, "world");
expect(a).not.toBe(b);
});
test("empty line produces valid hash", () => {
it("empty line produces valid hash", () => {
const hash = computeLineHash(1, "");
expect(hash).toMatch(/^[0-9a-z]{2,4}$/);
expect(hash).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/);
});
});
@@ -44,13 +48,13 @@ describe("computeLineHash", () => {
// ═══════════════════════════════════════════════════════════════════════════
describe("formatHashLines", () => {
test("formats single line", () => {
it("formats single line", () => {
const result = formatHashLines("hello");
const hash = computeLineHash(1, "hello");
expect(result).toBe(`1#${hash}|hello`);
expect(result).toBe(`1#${hash}:hello`);
});
test("formats multiple lines with 1-indexed numbers", () => {
it("formats multiple lines with 1-indexed numbers", () => {
const result = formatHashLines("foo\nbar\nbaz");
const lines = result.split("\n");
expect(lines).toHaveLength(3);
@@ -59,27 +63,27 @@ describe("formatHashLines", () => {
expect(lines[2]).toStartWith("3#");
});
test("respects custom startLine", () => {
it("respects custom startLine", () => {
const result = formatHashLines("foo\nbar", 10);
const lines = result.split("\n");
expect(lines[0]).toStartWith("10#");
expect(lines[1]).toStartWith("11#");
});
test("handles empty lines in content", () => {
it("handles empty lines in content", () => {
const result = formatHashLines("foo\n\nbar");
const lines = result.split("\n");
expect(lines).toHaveLength(3);
expect(lines[1]).toMatch(/^2#[0-9a-z]{2,4}\|$/);
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:$/);
});
test("round-trips with computeLineHash", () => {
it("round-trips with computeLineHash", () => {
const content = "function hello() {\n return 42;\n}";
const formatted = formatHashLines(content);
const lines = formatted.split("\n");
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/^(\d+)#([0-9a-z]+)\|(.*)$/);
const match = lines[i].match(/^(\d+)#([ZPMQVRWSNKTXJBYH]{2}):(.*)$/);
expect(match).not.toBeNull();
const lineNum = Number.parseInt(match![1], 10);
const hash = match![2];
@@ -109,26 +113,26 @@ describe("streamHashLinesFrom*", () => {
}
}
test("streamHashLinesFromUtf8 matches formatHashLines", async () => {
it("streamHashLinesFromUtf8 matches formatHashLines", async () => {
const content = "foo\nbar\nbaz";
const streamed = await collectText(streamHashLinesFromUtf8(utf8Chunks(content, 2), { maxChunkLines: 1 }));
expect(streamed).toBe(formatHashLines(content));
});
test("streamHashLinesFromUtf8 handles empty content", async () => {
it("streamHashLinesFromUtf8 handles empty content", async () => {
const content = "";
const streamed = await collectText(streamHashLinesFromUtf8(utf8Chunks(content, 2), { maxChunkLines: 1 }));
expect(streamed).toBe(formatHashLines(content));
});
test("streamHashLinesFromLines matches formatHashLines (including trailing newline)", async () => {
it("streamHashLinesFromLines matches formatHashLines (including trailing newline)", async () => {
const content = "foo\nbar\n";
const lines = ["foo", "bar", ""]; // match `content.split("\\n")`
const streamed = await collectText(streamHashLinesFromLines(lines, { maxChunkLines: 2 }));
expect(streamed).toBe(formatHashLines(content));
});
test("chunking respects maxChunkLines", async () => {
it("chunking respects maxChunkLines", async () => {
const content = "a\nb\nc";
const parts: string[] = [];
for await (const part of streamHashLinesFromUtf8(utf8Chunks(content, 1), {
@@ -143,47 +147,46 @@ describe("streamHashLinesFrom*", () => {
});
// ═══════════════════════════════════════════════════════════════════════════
// parseLineRef
// parseTag
// ═══════════════════════════════════════════════════════════════════════════
describe("parseLineRef", () => {
test("parses valid reference", () => {
const ref = parseLineRef("5#abcd");
expect(ref).toEqual({ line: 5, hash: "abcd" });
describe("parseTag", () => {
it("parses valid reference", () => {
const ref = parseTag("5#QQ");
expect(ref).toEqual({ line: 5, hash: "QQ" });
});
test("parses single-digit hash", () => {
const ref = parseLineRef("1#a");
expect(ref).toEqual({ line: 1, hash: "a" });
it("rejects single-character hash", () => {
expect(() => parseTag("1#Q")).toThrow(/Invalid line reference/);
});
test("parses long hash", () => {
const ref = parseLineRef("100#abcdef0123456789");
expect(ref).toEqual({ line: 100, hash: "abcdef0123456789" });
it("parses long hash by taking strict 2-char prefix", () => {
const ref = parseTag("100#QQQQ");
expect(ref).toEqual({ line: 100, hash: "QQ" });
});
test("rejects missing separator", () => {
expect(() => parseLineRef("5abcd")).toThrow(/Invalid line reference/);
it("rejects missing separator", () => {
expect(() => parseTag("5QQ")).toThrow(/Invalid line reference/);
});
test("rejects non-numeric line", () => {
expect(() => parseLineRef("abc#1234")).toThrow(/Invalid line reference/);
it("rejects non-numeric line", () => {
expect(() => parseTag("abc#Q")).toThrow(/Invalid line reference/);
});
test("rejects non-alphanumeric hash", () => {
expect(() => parseLineRef("5#$$$$")).toThrow(/Invalid line reference/);
it("rejects non-alphanumeric hash", () => {
expect(() => parseTag("5#$$$$")).toThrow(/Invalid line reference/);
});
test("rejects line number 0", () => {
expect(() => parseLineRef("0#abcd")).toThrow(/Line number must be >= 1/);
it("rejects line number 0", () => {
expect(() => parseTag("0#QQ")).toThrow(/Line number must be >= 1/);
});
test("rejects empty string", () => {
expect(() => parseLineRef("")).toThrow(/Invalid line reference/);
it("rejects empty string", () => {
expect(() => parseTag("")).toThrow(/Invalid line reference/);
});
test("rejects empty hash", () => {
expect(() => parseLineRef("5#")).toThrow(/Invalid line reference/);
it("rejects empty hash", () => {
expect(() => parseTag("5#")).toThrow(/Invalid line reference/);
});
});
@@ -192,29 +195,29 @@ describe("parseLineRef", () => {
// ═══════════════════════════════════════════════════════════════════════════
describe("validateLineRef", () => {
test("accepts valid ref with matching hash", () => {
it("accepts valid ref with matching hash", () => {
const lines = ["hello", "world"];
const hash = computeLineHash(1, "hello");
expect(() => validateLineRef({ line: 1, hash }, lines)).not.toThrow();
});
test("rejects line out of range (too high)", () => {
it("rejects line out of range (too high)", () => {
const lines = ["hello"];
const hash = computeLineHash(1, "hello");
expect(() => validateLineRef({ line: 2, hash }, lines)).toThrow(/does not exist/);
});
test("rejects line out of range (zero)", () => {
it("rejects line out of range (zero)", () => {
const lines = ["hello"];
expect(() => validateLineRef({ line: 0, hash: "aaaa" }, lines)).toThrow(/does not exist/);
});
test("rejects mismatched hash", () => {
it("rejects mismatched hash", () => {
const lines = ["hello", "world"];
expect(() => validateLineRef({ line: 1, hash: "0000" }, lines)).toThrow(/has changed since last read/);
});
test("validates last line correctly", () => {
it("validates last line correctly", () => {
const lines = ["a", "b", "c"];
const hash = computeLineHash(3, "c");
expect(() => validateLineRef({ line: 3, hash }, lines)).not.toThrow();
@@ -226,31 +229,29 @@ describe("validateLineRef", () => {
// ═══════════════════════════════════════════════════════════════════════════
describe("applyHashlineEdits — replace", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}#${computeLineHash(lineNum, content)}`;
}
test("replaces single line", () => {
it("replaces single line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ set: { ref: makeRef(2, "bbb"), body: ["BBB"] } }];
const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(2, "bbb"), content: ["BBB"] }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nBBB\nccc");
expect(result.firstChangedLine).toBe(2);
});
test("range replace (shrink)", () => {
it("range replace (shrink)", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [{ set_range: { beg: makeRef(2, "bbb"), end: makeRef(3, "ccc"), body: ["ONE"] } }];
const edits: HashlineEdit[] = [
{ op: "replace", first: makeTag(2, "bbb"), last: makeTag(3, "ccc"), content: ["ONE"] },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nONE\nddd");
});
test("range replace (same count)", () => {
it("range replace (same count)", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [
{ set_range: { beg: makeRef(2, "bbb"), end: makeRef(3, "ccc"), body: ["XXX", "YYY"] } },
{ op: "replace", first: makeTag(2, "bbb"), last: makeTag(3, "ccc"), content: ["XXX", "YYY"] },
];
const result = applyHashlineEdits(content, edits);
@@ -258,18 +259,18 @@ describe("applyHashlineEdits — replace", () => {
expect(result.firstChangedLine).toBe(2);
});
test("replaces first line", () => {
it("replaces first line", () => {
const content = "first\nsecond\nthird";
const edits: HashlineEdit[] = [{ set: { ref: makeRef(1, "first"), body: ["FIRST"] } }];
const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(1, "first"), content: ["FIRST"] }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("FIRST\nsecond\nthird");
expect(result.firstChangedLine).toBe(1);
});
test("replaces last line", () => {
it("replaces last line", () => {
const content = "first\nsecond\nthird";
const edits: HashlineEdit[] = [{ set: { ref: makeRef(3, "third"), body: ["THIRD"] } }];
const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(3, "third"), content: ["THIRD"] }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("first\nsecond\nTHIRD");
@@ -282,38 +283,34 @@ describe("applyHashlineEdits — replace", () => {
// ═══════════════════════════════════════════════════════════════════════════
describe("applyHashlineEdits — delete", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}#${computeLineHash(lineNum, content)}`;
}
test("deletes single line", () => {
it("deletes single line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ set: { ref: makeRef(2, "bbb"), body: [] } }];
const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(2, "bbb"), content: [] }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nccc");
expect(result.firstChangedLine).toBe(2);
});
test("deletes range of lines", () => {
it("deletes range of lines", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [{ set_range: { beg: makeRef(2, "bbb"), end: makeRef(3, "ccc"), body: [] } }];
const edits: HashlineEdit[] = [{ op: "replace", first: makeTag(2, "bbb"), last: makeTag(3, "ccc"), content: [] }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nddd");
});
test("deletes first line", () => {
it("deletes first line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ set: { ref: makeRef(1, "aaa"), body: [] } }];
const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(1, "aaa"), content: [] }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("bbb\nccc");
});
test("deletes last line", () => {
it("deletes last line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ set: { ref: makeRef(3, "ccc"), body: [] } }];
const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(3, "ccc"), content: [] }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nbbb");
@@ -325,63 +322,59 @@ describe("applyHashlineEdits — delete", () => {
// ═══════════════════════════════════════════════════════════════════════════
describe("applyHashlineEdits — insert", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}#${computeLineHash(lineNum, content)}`;
}
test("inserts after a line", () => {
it("inserts after a line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: ["NEW"] } }];
const edits: HashlineEdit[] = [{ op: "append", after: makeTag(1, "aaa"), content: ["NEW"] }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nNEW\nbbb\nccc");
expect(result.firstChangedLine).toBe(2);
});
test("inserts multiple lines", () => {
it("inserts multiple lines", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: ["x", "y", "z"] } }];
const edits: HashlineEdit[] = [{ op: "append", after: makeTag(1, "aaa"), content: ["x", "y", "z"] }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nx\ny\nz\nbbb");
});
test("inserts after last line", () => {
it("inserts after last line", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { after: makeRef(2, "bbb"), body: ["NEW"] } }];
const edits: HashlineEdit[] = [{ op: "append", after: makeTag(2, "bbb"), content: ["NEW"] }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nbbb\nNEW");
});
test("insert with empty dst throws", () => {
it("insert with empty dst throws", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: [] } }];
const edits: HashlineEdit[] = [{ op: "append", after: makeTag(1, "aaa"), content: [] }];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
test("inserts at EOF without anchors", () => {
it("inserts at EOF without anchors", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { body: ["NEW"] } }];
const edits = [{ op: "append", content: ["NEW"] }] as unknown as HashlineEdit[];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nbbb\nNEW");
expect(result.firstChangedLine).toBe(3);
});
test("inserts at EOF into empty file without anchors", () => {
it("inserts at EOF into empty file without anchors", () => {
const content = "";
const edits: HashlineEdit[] = [{ insert: { body: ["NEW"] } }];
const edits = [{ op: "append", content: ["NEW"] }] as unknown as HashlineEdit[];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("NEW");
expect(result.firstChangedLine).toBe(1);
});
test("insert at EOF with empty dst throws", () => {
it("insert at EOF with empty dst throws", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { body: [] } }];
const edits = [{ op: "append", content: [] }] as unknown as HashlineEdit[];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
@@ -392,62 +385,66 @@ describe("applyHashlineEdits — insert", () => {
// ═══════════════════════════════════════════════════════════════════════════
describe("applyHashlineEdits — insert (before)", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}#${computeLineHash(lineNum, content)}`;
}
test("inserts before a line", () => {
it("inserts before a line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ insert: { before: makeRef(2, "bbb"), body: ["NEW"] } }];
const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(2, "bbb"), content: ["NEW"] }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nNEW\nbbb\nccc");
expect(result.firstChangedLine).toBe(2);
});
test("inserts multiple lines before", () => {
it("inserts multiple lines before", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { before: makeRef(2, "bbb"), body: ["x", "y", "z"] } }];
const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(2, "bbb"), content: ["x", "y", "z"] }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nx\ny\nz\nbbb");
});
test("inserts before first line", () => {
it("inserts before first line", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { before: makeRef(1, "aaa"), body: ["NEW"] } }];
const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(1, "aaa"), content: ["NEW"] }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("NEW\naaa\nbbb");
});
test("insert with before and empty text throws", () => {
it("prepends at BOF without anchor", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { before: makeRef(1, "aaa"), body: [] } }];
const edits = [{ op: "prepend", content: ["NEW"] }] as unknown as HashlineEdit[];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("NEW\naaa\nbbb");
expect(result.firstChangedLine).toBe(1);
});
it("insert with before and empty text throws", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(1, "aaa"), content: [] }];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
test("strips anchor echo from end of inserted text (autocorrect)", () => {
it("strips anchor echo from end of inserted text (autocorrect)", () => {
Bun.env.PI_HL_AUTOCORRECT = "1";
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ insert: { before: makeRef(2, "bbb"), body: ["NEW", "bbb"] } }];
const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(2, "bbb"), content: ["NEW", "bbb"] }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nNEW\nbbb\nccc");
});
Bun.env.PI_HL_AUTOCORRECT = undefined;
test("insert before and insert after at same line produce correct order", () => {
it("insert before and insert after at same line produce correct order", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{ insert: { before: makeRef(2, "bbb"), body: ["BEFORE"] } },
{ insert: { after: makeRef(2, "bbb"), body: ["AFTER"] } },
{ op: "prepend", before: makeTag(2, "bbb"), content: ["BEFORE"] },
{ op: "append", after: makeTag(2, "bbb"), content: ["AFTER"] },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nBEFORE\nbbb\nAFTER\nccc");
});
test("insert before with set at same line", () => {
it("insert before with set at same line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{ insert: { before: makeRef(2, "bbb"), body: ["BEFORE"] } },
{ set: { ref: makeRef(2, "bbb"), body: ["BBB"] } },
{ op: "prepend", before: makeTag(2, "bbb"), content: ["BEFORE"] },
{ op: "set", tag: makeTag(2, "bbb"), content: ["BBB"] },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nBEFORE\nBBB\nccc");
@@ -459,45 +456,39 @@ describe("applyHashlineEdits — insert (before)", () => {
// ═══════════════════════════════════════════════════════════════════════════
describe("applyHashlineEdits — insert (between)", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}#${computeLineHash(lineNum, content)}`;
}
test("inserts between adjacent anchors", () => {
it("inserts between adjacent anchors", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{ insert: { after: makeRef(1, "aaa"), before: makeRef(2, "bbb"), body: ["NEW"] } },
{ op: "insert", after: makeTag(1, "aaa"), before: makeTag(2, "bbb"), content: ["NEW"] },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nNEW\nbbb\nccc");
expect(result.firstChangedLine).toBe(2);
});
test("inserts multiple lines between anchors", () => {
it("inserts multiple lines between anchors", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{
insert: {
after: makeRef(1, "aaa"),
before: makeRef(2, "bbb"),
body: ["x", "y", "z"],
},
op: "insert",
after: makeTag(1, "aaa"),
before: makeTag(2, "bbb"),
content: ["x", "y", "z"],
},
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nx\ny\nz\nbbb\nccc");
});
test("strips boundary echo from both sides (autocorrect)", () => {
it("strips boundary echo from both sides (autocorrect)", () => {
Bun.env.PI_HL_AUTOCORRECT = "1";
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{
insert: {
after: makeRef(1, "aaa"),
before: makeRef(2, "bbb"),
body: ["aaa", "NEW", "bbb"],
},
op: "insert",
after: makeTag(1, "aaa"),
before: makeTag(2, "bbb"),
content: ["aaa", "NEW", "bbb"],
},
];
const result = applyHashlineEdits(content, edits);
@@ -520,19 +511,15 @@ describe("applyHashlineEdits — heuristics", () => {
Bun.env.PI_HL_AUTOCORRECT = origEnv;
});
function makeRef(lineNum: number, content: string): string {
return `${lineNum}#${computeLineHash(lineNum, content)}`;
}
test("strips insert-after anchor echo", () => {
it("strips insert-after anchor echo", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ insert: { after: makeRef(2, "bbb"), body: ["bbb", "NEW"] } }];
const edits: HashlineEdit[] = [{ op: "append", after: makeTag(2, "bbb"), content: ["bbb", "NEW"] }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nbbb\nNEW\nccc");
});
test("strips range boundary echo and preserves whitespace on unchanged lines", () => {
it("strips range boundary echo and preserves whitespace on unchanged lines", () => {
const content = [
"import { foo } from 'x';",
"if (cond) {",
@@ -547,12 +534,11 @@ describe("applyHashlineEdits — heuristics", () => {
const end = 6;
const edits: HashlineEdit[] = [
{
set_range: {
beg: makeRef(start, "if (cond) {"),
end: makeRef(end, "}"),
// Echoes line after the range ("after();") and also reformats the import line.
body: ["if (cond) {", " doA();", "} else {", " doB();", "}", "after();"],
},
op: "replace",
first: makeTag(start, "if (cond) {"),
last: makeTag(end, "}"),
// Echoes line after the range ("after();") and also reformats the import line.
content: ["if (cond) {", " doA();", "} else {", " doB();", "}", "after();"],
},
];
@@ -562,15 +548,14 @@ describe("applyHashlineEdits — heuristics", () => {
expect(result.content).toBe(content);
});
test("does not override model whitespace choices in replacement content", () => {
it("does not override model whitespace choices in replacement content", () => {
const content = ["import { foo } from 'x';", "import { bar } from 'y';", "const x = 1;"].join("\n");
const edits: HashlineEdit[] = [
{
set_range: {
beg: makeRef(1, "import { foo } from 'x';"),
end: makeRef(2, "import { bar } from 'y';"),
body: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"],
},
op: "replace",
first: makeTag(1, "import { foo } from 'x';"),
last: makeTag(2, "import { bar } from 'y';"),
content: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"],
},
];
const result = applyHashlineEdits(content, edits);
@@ -582,27 +567,26 @@ describe("applyHashlineEdits — heuristics", () => {
expect(outLines[3]).toBe("const x = 1;");
});
test("restores a long wrapped line when model reflows it across many lines", () => {
it("restores a long wrapped line when model reflows it across many lines", () => {
const longLine =
"const options = veryLongIdentifier + anotherLongIdentifier + thirdLongIdentifier + fourthLongIdentifier;";
const content = ["before();", longLine, "after();"].join("\n");
const edits: HashlineEdit[] = [
{
set: {
ref: makeRef(2, longLine),
body: [
"const",
"options",
"=",
"veryLongIdentifier",
"+",
"anotherLongIdentifier",
"+",
"thirdLongIdentifier",
"+",
"fourthLongIdentifier;",
],
},
op: "set",
tag: makeTag(2, longLine),
content: [
"const",
"options",
"=",
"veryLongIdentifier",
"+",
"anotherLongIdentifier",
"+",
"thirdLongIdentifier",
"+",
"fourthLongIdentifier;",
],
},
];
@@ -610,18 +594,17 @@ describe("applyHashlineEdits — heuristics", () => {
expect(result.content).toBe(content);
});
test("repairs single-line replacement that absorbed the next line (prevents duplication)", () => {
it("repairs single-line replacement that absorbed the next line (prevents duplication)", () => {
const content = [" typeof HOOK === 'undefined' &&", " typeof HOOK.checkDCE !== 'function'", "tail();"].join(
"\n",
);
const edits: HashlineEdit[] = [
{
set: {
ref: makeRef(1, " typeof HOOK === 'undefined' &&"),
// Model merged both lines into one and dropped indentation.
body: ["typeof HOOK === 'undefined' || typeof HOOK.checkDCE !== 'function'"],
},
op: "set",
tag: makeTag(1, " typeof HOOK === 'undefined' &&"),
// Model merged both lines into one and dropped indentation.
content: ["typeof HOOK === 'undefined' || typeof HOOK.checkDCE !== 'function'"],
},
];
@@ -631,7 +614,7 @@ describe("applyHashlineEdits — heuristics", () => {
);
});
test("repairs single-line replacement that absorbed the previous line (prevents duplication)", () => {
it("repairs single-line replacement that absorbed the previous line (prevents duplication)", () => {
const content = [
" const nativeStyleResolver: ResolveNativeStyle | void =",
" resolveRNStyle || hook.resolveRNStyle;",
@@ -640,11 +623,10 @@ describe("applyHashlineEdits — heuristics", () => {
const edits: HashlineEdit[] = [
{
set: {
ref: makeRef(2, " resolveRNStyle || hook.resolveRNStyle;"),
// Model absorbed the declaration line and dropped indentation.
body: ["const nativeStyleResolver: ResolveNativeStyle | void = resolveRNStyle ?? hook.resolveRNStyle;"],
},
op: "set",
tag: makeTag(2, " resolveRNStyle || hook.resolveRNStyle;"),
// Model absorbed the declaration line and dropped indentation.
content: ["const nativeStyleResolver: ResolveNativeStyle | void = resolveRNStyle ?? hook.resolveRNStyle;"],
},
];
@@ -657,15 +639,14 @@ describe("applyHashlineEdits — heuristics", () => {
);
});
test("accepts polluted src that starts with LINE#ID but includes trailing content", () => {
it("accepts polluted src that starts with LINE#ID but includes trailing content", () => {
const content = "aaa\nbbb\nccc";
const srcHash = computeLineHash(2, "bbb");
const edits: HashlineEdit[] = [
{
set: {
ref: `2#${srcHash}export function foo(a, b) {}`, // comma in trailing content
body: ["BBB"],
},
op: "set",
tag: parseTag(`2#${srcHash}export function foo(a, b) {}`), // comma in trailing content
content: ["BBB"],
},
];
@@ -673,29 +654,13 @@ describe("applyHashlineEdits — heuristics", () => {
expect(result.content).toBe("aaa\nBBB\nccc");
});
test("treats same-line ranges as single-line replacements", () => {
it("treats same-line ranges as single-line replacements", () => {
const content = "aaa\nbbb\nccc";
const good = makeRef(2, "bbb");
const edits: HashlineEdit[] = [{ set_range: { beg: good, end: good, body: ["BBB"] } }];
const good = makeTag(2, "bbb");
const edits: HashlineEdit[] = [{ op: "replace", first: good, last: good, content: ["BBB"] }];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nBBB\nccc");
});
test("normalizes unicode-confusable hyphens when an edit would otherwise be a no-op", () => {
const content = "aaa\ndevtools–unsupported-bridge-protocol\nccc";
// dst is byte-identical to original (en-dash), so this would normally be a no-op.
const edits: HashlineEdit[] = [
{
set: {
ref: makeRef(2, "devtools–unsupported-bridge-protocol"),
body: ["devtools–unsupported-bridge-protocol"],
},
},
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\ndevtools-unsupported-bridge-protocol\nccc");
});
});
// ═══════════════════════════════════════════════════════════════════════════
@@ -703,15 +668,11 @@ describe("applyHashlineEdits — heuristics", () => {
// ═══════════════════════════════════════════════════════════════════════════
describe("applyHashlineEdits — multiple edits", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}#${computeLineHash(lineNum, content)}`;
}
test("applies two non-overlapping replaces (bottom-up safe)", () => {
it("applies two non-overlapping replaces (bottom-up safe)", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
const edits: HashlineEdit[] = [
{ set: { ref: makeRef(2, "bbb"), body: ["BBB"] } },
{ set: { ref: makeRef(4, "ddd"), body: ["DDD"] } },
{ op: "set", tag: makeTag(2, "bbb"), content: ["BBB"] },
{ op: "set", tag: makeTag(4, "ddd"), content: ["DDD"] },
];
const result = applyHashlineEdits(content, edits);
@@ -719,46 +680,45 @@ describe("applyHashlineEdits — multiple edits", () => {
expect(result.firstChangedLine).toBe(2);
});
test("applies replace + delete in one call", () => {
it("applies replace + delete in one call", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [
{ set: { ref: makeRef(2, "bbb"), body: ["BBB"] } },
{ set: { ref: makeRef(4, "ddd"), body: [] } },
{ op: "set", tag: makeTag(2, "bbb"), content: ["BBB"] },
{ op: "set", tag: makeTag(4, "ddd"), content: [] },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nBBB\nccc");
});
test("applies replace + insert in one call", () => {
it("applies replace + insert in one call", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{ set: { ref: makeRef(3, "ccc"), body: ["CCC"] } },
{ insert: { after: makeRef(1, "aaa"), body: ["INSERTED"] } },
{ op: "set", tag: makeTag(3, "ccc"), content: ["CCC"] },
{ op: "append", after: makeTag(1, "aaa"), content: ["INSERTED"] },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("aaa\nINSERTED\nbbb\nCCC");
});
test("applies non-overlapping edits against original anchors when line counts change", () => {
it("applies non-overlapping edits against original anchors when line counts change", () => {
const content = "one\ntwo\nthree\nfour\nfive\nsix";
const edits: HashlineEdit[] = [
{
set_range: {
beg: makeRef(2, "two"),
end: makeRef(3, "three"),
body: ["TWO_THREE"],
},
op: "replace",
first: makeTag(2, "two"),
last: makeTag(3, "three"),
content: ["TWO_THREE"],
},
{ set: { ref: makeRef(6, "six"), body: ["SIX"] } },
{ op: "set", tag: makeTag(6, "six"), content: ["SIX"] },
];
const result = applyHashlineEdits(content, edits);
expect(result.content).toBe("one\nTWO_THREE\nfour\nfive\nSIX");
});
test("empty edits array is a no-op", () => {
it("empty edits array is a no-op", () => {
const content = "aaa\nbbb";
const result = applyHashlineEdits(content, []);
expect(result.content).toBe(content);
@@ -771,20 +731,16 @@ describe("applyHashlineEdits — multiple edits", () => {
// ═══════════════════════════════════════════════════════════════════════════
describe("applyHashlineEdits — errors", () => {
function makeRef(lineNum: number, content: string): string {
return `${lineNum}#${computeLineHash(lineNum, content)}`;
}
test("rejects stale hash", () => {
it("rejects stale hash", () => {
const content = "aaa\nbbb\nccc";
// Use a hash that doesn't match any line (avoid 00 — ccc hashes to 00)
const edits: HashlineEdit[] = [{ set: { ref: "2#zz", body: ["BBB"] } }];
const edits: HashlineEdit[] = [{ op: "set", tag: parseTag("2#QQ"), content: ["BBB"] }];
expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError);
});
test("stale hash error shows >>> markers with correct hashes", () => {
it("stale hash error shows >>> markers with correct hashes", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
const edits: HashlineEdit[] = [{ set: { ref: "2#zz", body: ["BBB"] } }];
const edits: HashlineEdit[] = [{ op: "set", tag: parseTag("2#QQ"), content: ["BBB"] }];
try {
applyHashlineEdits(content, edits);
@@ -796,7 +752,7 @@ describe("applyHashlineEdits — errors", () => {
expect(msg).toContain(">>>");
// Should show the correct hash for line 2
const correctHash = computeLineHash(2, "bbb");
expect(msg).toContain(`2#${correctHash}|bbb`);
expect(msg).toContain(`2#${correctHash}:bbb`);
// Context lines should NOT have >>> markers
const lines = msg.split("\n");
const contextLines = lines.filter(l => l.startsWith(" ") && !l.startsWith(" ...") && l.includes("#"));
@@ -804,10 +760,13 @@ describe("applyHashlineEdits — errors", () => {
}
});
test("stale hash error collects all mismatches", () => {
it("stale hash error collects all mismatches", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
// Use hashes that don't match any line (avoid 00 — ccc hashes to 00)
const edits: HashlineEdit[] = [{ set: { ref: "2#zz", body: ["BBB"] } }, { set: { ref: "4#zz", body: ["DDD"] } }];
const edits: HashlineEdit[] = [
{ op: "set", tag: parseTag("2#ZZ"), content: ["BBB"] },
{ op: "set", tag: parseTag("4#ZZ"), content: ["DDD"] },
];
try {
applyHashlineEdits(content, edits);
@@ -824,10 +783,10 @@ describe("applyHashlineEdits — errors", () => {
}
});
test("does not relocate stale line refs even when hash uniquely matches another line", () => {
it("does not relocate stale line refs even when hash uniquely matches another line", () => {
const content = "aaa\nbbb\nccc";
const staleButUnique = `2#${computeLineHash(1, "ccc")}`;
const edits: HashlineEdit[] = [{ set: { ref: staleButUnique, body: ["CCC"] } }];
const staleButUnique = parseTag(`2#${computeLineHash(1, "ccc")}`);
const edits: HashlineEdit[] = [{ op: "set", tag: staleButUnique, content: ["CCC"] }];
try {
applyHashlineEdits(content, edits);
expect.unreachable("should have thrown");
@@ -838,59 +797,56 @@ describe("applyHashlineEdits — errors", () => {
}
});
test("does not relocate when expected hash is non-unique", () => {
it("does not relocate when expected hash is non-unique", () => {
const content = "dup\nmid\ndup";
const staleDuplicate = `2#${computeLineHash(1, "dup")}`;
const edits: HashlineEdit[] = [{ set: { ref: staleDuplicate, body: ["DUP"] } }];
const staleDuplicate = parseTag(`2#${computeLineHash(1, "dup")}`);
const edits: HashlineEdit[] = [{ op: "set", tag: staleDuplicate, content: ["DUP"] }];
expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError);
});
test("rejects out-of-range line", () => {
it("rejects out-of-range line", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ set: { ref: "10#aa", body: ["X"] } }];
const edits: HashlineEdit[] = [{ op: "set", tag: parseTag("10#ZZ"), content: ["X"] }];
expect(() => applyHashlineEdits(content, edits)).toThrow(/does not exist/);
});
test("rejects range with start > end", () => {
it("rejects range with start > end", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
const edits: HashlineEdit[] = [{ set_range: { beg: makeRef(5, "eee"), end: makeRef(2, "bbb"), body: ["X"] } }];
const edits: HashlineEdit[] = [
{ op: "replace", first: makeTag(5, "eee"), last: makeTag(2, "bbb"), content: ["X"] },
];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
test("rejects insert with after and empty text", () => {
it("rejects insert with after and empty text", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: [] } }];
const edits: HashlineEdit[] = [{ op: "append", after: makeTag(1, "aaa"), content: [] }];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
test("rejects insert with before and empty text", () => {
it("rejects insert with before and empty text", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { before: makeRef(1, "aaa"), body: [] } }];
const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(1, "aaa"), content: [] }];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
test("rejects insert with both anchors and empty text", () => {
it("rejects insert with both anchors and empty text", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), before: makeRef(2, "bbb"), body: [] } }];
const edits: HashlineEdit[] = [
{ op: "insert", after: makeTag(1, "aaa"), before: makeTag(2, "bbb"), content: [] },
];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
test("rejects insert with non-adjacent anchors", () => {
it("rejects insert with non-adjacent anchors", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{ insert: { after: makeRef(1, "aaa"), before: makeRef(3, "ccc"), body: ["NEW"] } },
{ op: "insert", after: makeTag(1, "aaa"), before: makeTag(3, "ccc"), content: ["NEW"] },
];
expect(() => applyHashlineEdits(content, edits)).toThrow(/adjacent anchors/);
});
test("rejects replace edit (handled separately in EditTool)", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ replace: { old_text: "aaa", new_text: "AAA" } }];
expect(() => applyHashlineEdits(content, edits)).toThrow(/replace edits are applied separately/);
});
});
+5 -5
View File
@@ -490,7 +490,7 @@ function b() {
const output = getTextOutput(result);
expect(output).toContain("1. example.txt:2");
expect(output).toMatch(/>>\s*2.*\|match line/);
expect(output).toMatch(/>>\s*2#[ZPMQVRWSNKTXJBYH]{2}:match line/);
});
it("should respect global limit and include context lines", async () => {
@@ -508,9 +508,9 @@ function b() {
const output = getTextOutput(result);
expect(output).toContain("1. context.txt:2");
expect(output).toMatch(/\b1.*\|before/);
expect(output).toMatch(/>>\s*2.*\|match one/);
expect(output).toMatch(/\b3.*\|after/);
expect(output).toMatch(/\b1#[ZPMQVRWSNKTXJBYH]{2}:before/);
expect(output).toMatch(/>>\s*2#[ZPMQVRWSNKTXJBYH]{2}:match one/);
expect(output).toMatch(/\b3#[ZPMQVRWSNKTXJBYH]{2}:after/);
expect(output).toContain("[1 matches limit reached. Use limit=2 for more]");
// Ensure second match is not present
expect(output).not.toContain("match two");
@@ -669,7 +669,7 @@ describe("edit tool CRLF handling", () => {
const hashlineEditTool = new EditTool(session);
const result = await hashlineEditTool.execute("hashline-replace-1", {
path: testFile,
edits: [{ old_text: "x = 42", new_text: "x = 99" }],
edits: [{ op: "replaceText", old_text: "x = 42", new_text: "x = 99" }],
});
expect(getTextOutput(result)).toContain("Updated");