refactor(patch): removed experimental hashline utilities

- Removed replaceText edit operation and content-replace logic from hashline module.
- Removed autocorrect heuristics including line merge detection and whitespace normalization.
- Simplified edit operations to line-anchor-based methods (delete, rename, prepend, append, insert).
- Removed HL_REPLACE_ENABLED feature flag and related conditional logic.
This commit is contained in:
can1357
2026-02-22 16:03:07 +01:00
parent d0520ec210
commit d746edcd4b
5 changed files with 12 additions and 386 deletions
+4
View File
@@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
### Removed
- Removed `replaceText` edit operation from hashline mode (substring-based text replacement)
- Removed autocorrect heuristics that attempted to detect and fix line merges and formatting rewrites in hashline edits
## [12.19.0] - 2026-02-22
### Added
+6 -286
View File
@@ -21,159 +21,6 @@ export type HashlineEdit =
| { 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.
*
* Returns true when the non-whitespace characters are identical — meaning
* the only differences are in spaces, tabs, or other whitespace.
*/
function equalsIgnoringWhitespace(a: string, b: string): boolean {
// Fast path: identical strings
if (a === b) return true;
// Compare with all whitespace removed
return a.replace(/\s+/g, "") === b.replace(/\s+/g, "");
}
function stripAllWhitespace(s: string): string {
return s.replace(/\s+/g, "");
}
function stripTrailingContinuationTokens(s: string): string {
// Heuristic: models often merge a continuation line into the prior line
// while also changing the trailing operator (e.g. `&&` → `||`).
// Strip common trailing continuation tokens so we can still detect merges.
return s.replace(/(?:&&|\|\||\?\?|\?|:|=|,|\+|-|\*|\/|\.|\()\s*$/u, "");
}
function stripMergeOperatorChars(s: string): string {
// Used for merge detection when the model changes a logical operator like
// `||` → `??` while also merging adjacent lines.
return s.replace(/[|&?]/g, "");
}
function leadingWhitespace(s: string): string {
const match = s.match(/^\s*/);
return match ? match[0] : "";
}
function restoreLeadingIndent(templateLine: string, line: string): string {
if (line.length === 0) return line;
const templateIndent = leadingWhitespace(templateLine);
if (templateIndent.length === 0) return line;
const indent = leadingWhitespace(line);
if (indent.length > 0) return line;
return templateIndent + line;
}
function restoreIndentForPairedReplacement(oldLines: string[], newLines: string[]): string[] {
if (oldLines.length !== newLines.length) return newLines;
let changed = false;
const out = new Array<string>(newLines.length);
for (let i = 0; i < newLines.length; i++) {
const restored = restoreLeadingIndent(oldLines[i], newLines[i]);
out[i] = restored;
if (restored !== newLines[i]) changed = true;
}
return changed ? out : newLines;
}
/**
* Undo pure formatting rewrites where the model reflows a single logical line
* into multiple lines (or similar), but the token stream is identical.
*/
function restoreOldWrappedLines(oldLines: string[], newLines: string[]): string[] {
if (oldLines.length === 0 || newLines.length < 2) return newLines;
const canonToOld = new Map<string, { line: string; count: number }>();
for (const line of oldLines) {
const canon = stripAllWhitespace(line);
const bucket = canonToOld.get(canon);
if (bucket) bucket.count++;
else canonToOld.set(canon, { line, count: 1 });
}
const candidates: { start: number; len: number; replacement: string; canon: string }[] = [];
for (let start = 0; start < newLines.length; start++) {
for (let len = 2; len <= 10 && start + len <= newLines.length; len++) {
const canonSpan = stripAllWhitespace(newLines.slice(start, start + len).join(""));
const old = canonToOld.get(canonSpan);
if (old && old.count === 1 && canonSpan.length >= 6) {
candidates.push({ start, len, replacement: old.line, canon: canonSpan });
}
}
}
if (candidates.length === 0) return newLines;
// Keep only spans whose canonical match is unique in the new output.
const canonCounts = new Map<string, number>();
for (const c of candidates) {
canonCounts.set(c.canon, (canonCounts.get(c.canon) ?? 0) + 1);
}
const uniqueCandidates = candidates.filter(c => (canonCounts.get(c.canon) ?? 0) === 1);
if (uniqueCandidates.length === 0) return newLines;
// Apply replacements back-to-front so indices remain stable.
uniqueCandidates.sort((a, b) => b.start - a.start);
const out = [...newLines];
for (const c of uniqueCandidates) {
out.splice(c.start, c.len, c.replacement);
}
return out;
}
function stripInsertAnchorEchoAfter(anchorLine: string, dstLines: string[]): string[] {
if (dstLines.length <= 1) return dstLines;
if (equalsIgnoringWhitespace(dstLines[0], anchorLine)) {
return dstLines.slice(1);
}
return dstLines;
}
function stripInsertAnchorEchoBefore(anchorLine: string, dstLines: string[]): string[] {
if (dstLines.length <= 1) return dstLines;
if (equalsIgnoringWhitespace(dstLines[dstLines.length - 1], anchorLine)) {
return dstLines.slice(0, -1);
}
return dstLines;
}
function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, dstLines: string[]): string[] {
let out = dstLines;
if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) {
out = out.slice(1);
}
if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) {
out = out.slice(0, -1);
}
return out;
}
function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, dstLines: string[]): string[] {
// Only strip when the model replaced with multiple lines and grew the edit.
// This avoids turning a single-line replacement into a deletion.
const count = endLine - startLine + 1;
if (dstLines.length <= 1 || dstLines.length <= count) return dstLines;
let out = dstLines;
const beforeIdx = startLine - 2;
if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], fileLines[beforeIdx])) {
out = out.slice(1);
}
const afterIdx = endLine;
if (
afterIdx < fileLines.length &&
out.length > 0 &&
equalsIgnoringWhitespace(out[out.length - 1], fileLines[afterIdx])
) {
out = out.slice(0, -1);
}
return out;
}
const NIBBLE_STR = "ZPMQVRWSNKTXJBYH";
@@ -594,38 +441,6 @@ export function applyHashlineEdits(
let firstChangedLine: number | undefined;
const noopEdits: Array<{ editIndex: number; loc: string; currentContent: string }> = [];
const autocorrect = Bun.env.PI_HL_AUTOCORRECT === "1";
function collectExplicitlyTouchedLines(): Set<number> {
const touched = new Set<number>();
for (const edit of edits) {
switch (edit.op) {
case "set":
touched.add(edit.tag.line);
break;
case "replace":
for (let ln = edit.first.line; ln <= edit.last.line; ln++) touched.add(ln);
break;
case "append":
if (edit.after) {
touched.add(edit.after.line);
}
break;
case "prepend":
if (edit.before) {
touched.add(edit.before.line);
}
break;
case "insert":
touched.add(edit.after.line);
touched.add(edit.before.line);
break;
}
}
return touched;
}
const explicitlyTouchedLines = collectExplicitlyTouchedLines();
// Pre-validate: collect all hash mismatches before mutating
const mismatches: HashMismatch[] = [];
function validateRef(ref: { line: number; hash: string }): boolean {
@@ -765,35 +580,8 @@ export function applyHashlineEdits(
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,
merged.startLine - 1 + merged.deleteCount,
);
let nextLines = merged.newLines;
nextLines = restoreIndentForPairedReplacement([origLines[0] ?? ""], nextLines);
if (origLines.every((line, i) => line === nextLines[i])) {
noopEdits.push({
editIndex: idx,
loc: `${edit.tag.line}#${edit.tag.hash}`,
currentContent: origLines.join("\n"),
});
break;
}
fileLines.splice(merged.startLine - 1, merged.deleteCount, ...nextLines);
trackFirstChanged(merged.startLine);
break;
}
const count = 1;
const origLines = originalFileLines.slice(edit.tag.line - 1, edit.tag.line);
let stripped = autocorrect
? stripRangeBoundaryEcho(originalFileLines, edit.tag.line, edit.tag.line, edit.content)
: edit.content;
stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
const newLines = edit.content;
if (origLines.every((line, i) => line === newLines[i])) {
noopEdits.push({
editIndex: idx,
@@ -802,36 +590,19 @@ export function applyHashlineEdits(
});
break;
}
fileLines.splice(edit.tag.line - 1, count, ...newLines);
fileLines.splice(edit.tag.line - 1, 1, ...newLines);
trackFirstChanged(edit.tag.line);
break;
}
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, edit.first.line, edit.last.line, edit.content)
: edit.content;
stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
if (autocorrect && origLines.every((line, i) => line === newLines[i])) {
noopEdits.push({
editIndex: idx,
loc: `${edit.first.line}#${edit.first.hash}`,
currentContent: origLines.join("\n"),
});
break;
}
const newLines = edit.content;
fileLines.splice(edit.first.line - 1, count, ...newLines);
trackFirstChanged(edit.first.line);
break;
}
case "append": {
const inserted = edit.after
? autocorrect
? stripInsertAnchorEchoAfter(originalFileLines[edit.after.line - 1], edit.content)
: edit.content
: edit.content;
const inserted = edit.content;
if (inserted.length === 0) {
noopEdits.push({
editIndex: idx,
@@ -855,11 +626,7 @@ export function applyHashlineEdits(
break;
}
case "prepend": {
const inserted = edit.before
? autocorrect
? stripInsertAnchorEchoBefore(originalFileLines[edit.before.line - 1], edit.content)
: edit.content
: edit.content;
const inserted = edit.content;
if (inserted.length === 0) {
noopEdits.push({
editIndex: idx,
@@ -884,7 +651,7 @@ export function applyHashlineEdits(
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;
const inserted = edit.content;
if (inserted.length === 0) {
noopEdits.push({
editIndex: idx,
@@ -911,51 +678,4 @@ export function applyHashlineEdits(
firstChangedLine = line;
}
}
function maybeExpandSingleLineMerge(
line: number,
content: string[],
): { startLine: number; deleteCount: number; newLines: string[] } | null {
if (content.length !== 1) return null;
if (line < 1 || line > fileLines.length) return null;
const newLine = content[0];
const newCanon = stripAllWhitespace(newLine);
const newCanonForMergeOps = stripMergeOperatorChars(newCanon);
if (newCanon.length === 0) return null;
const orig = fileLines[line - 1];
const origCanon = stripAllWhitespace(orig);
const origCanonForMatch = stripTrailingContinuationTokens(origCanon);
const origCanonForMergeOps = stripMergeOperatorChars(origCanon);
const origLooksLikeContinuation = origCanonForMatch.length < origCanon.length;
if (origCanon.length === 0) return null;
const nextIdx = line;
const prevIdx = line - 2;
// Case A: dst absorbed the next continuation line.
if (origLooksLikeContinuation && nextIdx < fileLines.length && !explicitlyTouchedLines.has(line + 1)) {
const next = fileLines[nextIdx];
const nextCanon = stripAllWhitespace(next);
const a = newCanon.indexOf(origCanonForMatch);
const b = newCanon.indexOf(nextCanon);
if (a !== -1 && b !== -1 && a < b && newCanon.length <= origCanon.length + nextCanon.length + 32) {
return { startLine: line, deleteCount: 2, newLines: [newLine] };
}
}
// Case B: dst absorbed the previous declaration/continuation line.
if (prevIdx >= 0 && !explicitlyTouchedLines.has(line - 1)) {
const prev = fileLines[prevIdx];
const prevCanon = stripAllWhitespace(prev);
const prevCanonForMatch = stripTrailingContinuationTokens(prevCanon);
const prevLooksLikeContinuation = prevCanonForMatch.length < prevCanon.length;
if (!prevLooksLikeContinuation) return null;
const a = newCanonForMergeOps.indexOf(stripMergeOperatorChars(prevCanonForMatch));
const b = newCanonForMergeOps.indexOf(origCanonForMergeOps);
if (a !== -1 && b !== -1 && a < b && newCanon.length <= prevCanon.length + origCanon.length + 32) {
return { startLine: line - 1, deleteCount: 2, newLines: [newLine] };
}
}
return null;
}
}
+2 -53
View File
@@ -34,14 +34,7 @@ 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,
type HashlineEdit,
type LineTag,
parseTag,
type ReplaceTextEdit,
} from "./hashline";
import { applyHashlineEdits, computeLineHash, type HashlineEdit, type LineTag, parseTag } from "./hashline";
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
import { buildNormativeUpdateInput } from "./normative";
import { type EditToolDetails, getLspBatchRequest } from "./shared";
@@ -201,13 +194,6 @@ export function hashlineParseContent(edit: string | string[] | null): string[] {
if (lines[lines.length - 1].trim() === "") return lines.slice(0, -1);
return lines;
}
function hashlineParseContentString(edit: string | string[] | null): string {
if (edit === null) return "";
if (Array.isArray(edit)) return edit.join("\n");
return edit;
}
const hashlineTargetEditSchema = Type.Object(
{
op: Type.Literal("set"),
@@ -255,25 +241,12 @@ const hashlineInsertEditSchema = Type.Object(
{ additionalProperties: false },
);
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 HL_REPLACE_ENABLED = Bun.env.PI_HL_REPLACETXT === "1";
const hashlineEditSpecSchema = Type.Union([
hashlineTargetEditSchema,
hashlineRangeEditSchema,
hashlineAppendEditSchema,
hashlinePrependEditSchema,
hashlineInsertEditSchema,
...(HL_REPLACE_ENABLED ? [hashlineReplaceTextEditSchema] : []),
]);
const hashlineEditSchema = Type.Object(
@@ -486,7 +459,7 @@ export class EditTool implements AgentTool<TInput> {
case "patch":
return renderPromptTemplate(patchDescription);
case "hashline":
return renderPromptTemplate(hashlineDescription, { allowReplaceText: HL_REPLACE_ENABLED });
return renderPromptTemplate(hashlineDescription);
default:
return renderPromptTemplate(replaceDescription);
}
@@ -581,7 +554,6 @@ export class EditTool implements AgentTool<TInput> {
}
const anchorEdits: HashlineEdit[] = [];
const replaceEdits: ReplaceTextEdit[] = [];
for (const edit of edits) {
switch (edit.op) {
case "set": {
@@ -643,16 +615,6 @@ export class EditTool implements AgentTool<TInput> {
}
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)}`);
}
@@ -668,19 +630,6 @@ export class EditTool implements AgentTool<TInput> {
const anchorResult = applyHashlineEdits(normalizedContent, anchorEdits);
normalizedContent = anchorResult.content;
// Apply content-replace edits (substr-style fuzzy replace)
for (const r of replaceEdits) {
if (r.old_text.length === 0) {
throw new Error("old_text must not be empty.");
}
const rep = replaceText(normalizedContent, r.old_text, r.new_text, {
fuzzy: this.#allowFuzzy,
all: r.all ?? false,
threshold: this.#fuzzyThreshold,
});
normalizedContent = rep.content;
}
const result = {
content: normalizedContent,
firstChangedLine: anchorResult.firstChangedLine,
@@ -22,10 +22,6 @@ Apply precise file edits using `LINE#ID` tags, anchoring to the file content.
- `{ 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**
- `{ 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.
@@ -180,18 +176,6 @@ content: ["function validate(data: unknown): boolean {", " return data != null
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 (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}}
<example name="file delete">
```
path: "src/deprecated/legacy.ts"
-31
View File
@@ -661,37 +661,6 @@ describe("edit tool CRLF handling", () => {
).rejects.toThrow(/Found 2 occurrences/);
});
it("should apply hashline replace (substr-style) when edit variant is hashline", async () => {
const originalEditVariant = Bun.env.PI_EDIT_VARIANT;
const originalHashlineReplace = Bun.env.PI_HL_REPLACETXT;
Bun.env.PI_EDIT_VARIANT = "hashline";
Bun.env.PI_HL_REPLACETXT = "1";
const hashDir = path.join(os.tmpdir(), `coding-agent-hashline-replace-${Snowflake.next()}`);
fs.mkdirSync(hashDir, { recursive: true });
const testFile = path.join(hashDir, "app.txt");
fs.writeFileSync(testFile, "x = 42\ny = 10\n");
try {
const session = createTestToolSession(hashDir);
const hashlineEditTool = new EditTool(session);
const result = await hashlineEditTool.execute("hashline-replace-1", {
path: testFile,
edits: [{ op: "replaceText", old_text: "x = 42", new_text: "x = 99" }],
});
expect(getTextOutput(result)).toContain("Updated");
const content = await Bun.file(testFile).text();
expect(content).toBe("x = 99\ny = 10\n");
} finally {
fs.rmSync(hashDir, { recursive: true, force: true });
if (originalEditVariant === undefined) delete Bun.env.PI_EDIT_VARIANT;
else Bun.env.PI_EDIT_VARIANT = originalEditVariant;
if (originalHashlineReplace === undefined) delete Bun.env.PI_HL_REPLACETXT;
else Bun.env.PI_HL_REPLACETXT = originalHashlineReplace;
}
});
it("should delete file in hashline mode with delete:true", async () => {
const originalEditVariant = Bun.env.PI_EDIT_VARIANT;
Bun.env.PI_EDIT_VARIANT = "hashline";