feat(coding-agent): added escaped tab auto-correction and Unicode escape detection
- Added auto-correction for escaped tab indentation in edits via PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS environment variable. - Added warning detection for suspicious Unicode escape placeholder \uDDDD in edit content. - Clarified hashline documentation that \t in JSON represents real tab characters, not literal backslash-t strings. - Added comprehensive test coverage for escaped tab auto-correction and Unicode escape detection.
This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Auto-correction of escaped tab indentation in edits (enabled by default, controllable via `PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS` environment variable)
|
||||
- Warning when suspicious Unicode escape placeholder `\uDDDD` is detected in edit content
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated hashline documentation to clarify that `\t` in JSON represents a real tab character, not a literal backslash-t sequence
|
||||
|
||||
## [13.5.2] - 2026-03-01
|
||||
|
||||
|
||||
@@ -411,6 +411,45 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
|
||||
}
|
||||
}
|
||||
|
||||
function isEscapedTabAutocorrectEnabled(): boolean {
|
||||
const value = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
|
||||
if (value === "0") return false;
|
||||
if (value === "1") return true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function maybeAutocorrectEscapedTabIndentation(edits: HashlineEdit[], warnings: string[]): void {
|
||||
if (!isEscapedTabAutocorrectEnabled()) return;
|
||||
for (const edit of edits) {
|
||||
if (edit.lines.length === 0) continue;
|
||||
const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
|
||||
if (!hasEscapedTabs) continue;
|
||||
const hasRealTabs = edit.lines.some(line => line.includes("\t"));
|
||||
if (hasRealTabs) continue;
|
||||
let correctedCount = 0;
|
||||
const corrected = edit.lines.map(line =>
|
||||
line.replace(/^((?:\\t)+)/, escaped => {
|
||||
correctedCount += escaped.length / 2;
|
||||
return "\t".repeat(escaped.length / 2);
|
||||
}),
|
||||
);
|
||||
if (correctedCount === 0) continue;
|
||||
edit.lines = corrected;
|
||||
warnings.push(
|
||||
`Auto-corrected escaped tab indentation in edit: converted leading \\t sequence(s) to real tab characters`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeWarnSuspiciousUnicodeEscapePlaceholder(edits: HashlineEdit[], warnings: string[]): void {
|
||||
for (const edit of edits) {
|
||||
if (edit.lines.length === 0) continue;
|
||||
if (!edit.lines.some(line => /\\uDDDD/i.test(line))) continue;
|
||||
warnings.push(
|
||||
`Detected literal \\uDDDD in edit content; no autocorrection applied. Verify whether this should be a real Unicode escape or plain text.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Edit Application
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -493,6 +532,8 @@ export function applyHashlineEdits(
|
||||
if (mismatches.length > 0) {
|
||||
throw new HashlineMismatchError(mismatches, fileLines);
|
||||
}
|
||||
maybeAutocorrectEscapedTabIndentation(edits, warnings);
|
||||
maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
|
||||
// Deduplicate identical edits targeting the same line(s)
|
||||
const seenEditKeys = new Map<string, number>();
|
||||
const dedupIndices = new Set<number>();
|
||||
|
||||
@@ -249,15 +249,15 @@ Good — anchors to structural line:
|
||||
</example>
|
||||
|
||||
<example name="indentation must match context">
|
||||
Leading whitespace in `lines` **MUST** be copied from the `read` output, not reconstructed from memory. Check the actual indent of neighboring lines.
|
||||
Leading whitespace in `lines` **MUST** be copied from the `read` output, not reconstructed from memory. If the file uses tabs, use `\t` in JSON — you **MUST NOT** use `\\t`, which produces a literal backslash-t in the file.
|
||||
```ts
|
||||
{{hlinefull 10 "class Foo {"}}
|
||||
{{hlinefull 11 " bar() {"}}
|
||||
{{hlinefull 12 " return 1;"}}
|
||||
{{hlinefull 13 " }"}}
|
||||
{{hlinefull 14 "}"}}
|
||||
{{hlinefull 11 "\tbar() {"}}
|
||||
{{hlinefull 12 "\t\treturn 1;"}}
|
||||
{{hlinefull 13 "\t}"}}
|
||||
{{hlinefull 14 "}}"}}
|
||||
```
|
||||
Bad — indent guessed as 4 spaces instead of 2 (as seen on lines 11–13):
|
||||
Bad — indent guessed as spaces; `\\t` emits literal backslash-t:
|
||||
```
|
||||
{
|
||||
path: "…",
|
||||
@@ -272,7 +272,7 @@ Bad — indent guessed as 4 spaces instead of 2 (as seen on lines 11–13):
|
||||
}]
|
||||
}
|
||||
```
|
||||
Good — indent matches the 2-space style visible on adjacent lines:
|
||||
Good — `\t` in JSON is a real tab, matching the file's indentation:
|
||||
```
|
||||
{
|
||||
path: "…",
|
||||
@@ -280,9 +280,9 @@ Good — indent matches the 2-space style visible on adjacent lines:
|
||||
op: "prepend",
|
||||
pos: {{hlinejsonref 14 "}"}},
|
||||
lines: [
|
||||
" baz() {",
|
||||
" return 2;",
|
||||
" }"
|
||||
"\tbaz() {",
|
||||
"\t\treturn 2;",
|
||||
"\t}"
|
||||
]
|
||||
}]
|
||||
}
|
||||
@@ -294,5 +294,5 @@ Good — indent matches the 2-space style visible on adjacent lines:
|
||||
- Every tag **MUST** be copied exactly from fresh tool result as `N#ID`.
|
||||
- You **MUST** re-read after each edit call before issuing another on same file.
|
||||
- Formatting is a batch operation. You **MUST NOT** use this tool to reformat, reindent, or adjust whitespace — run the project's formatter instead. If the only change is whitespace, it is formatting; do not touch it.
|
||||
- `lines` entries **MUST** be literal file content with real space indentation. (`\\t` in JSON inserts a literal backslash-t into the file, not a tab.)
|
||||
- `lines` entries **MUST** be literal file content with indentation copied exactly from the `read` output. If the file uses tabs, use `\t` in JSON (a real tab character) — you **MUST NOT** use `\\t` (two characters: backslash + t), which produces the literal string `\t` in the file.
|
||||
</critical>
|
||||
@@ -570,6 +570,65 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
expect(result.lines).toBe("ALPHA\n\n\ngamma");
|
||||
expect(result.warnings).toBeUndefined();
|
||||
});
|
||||
it("auto-corrects leading escaped tab indentation by default", () => {
|
||||
const previous = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
|
||||
delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
|
||||
try {
|
||||
const content = "root\n\tchild\n\t\tvalue\nend";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] }];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("root\n\tchild\n\t\treplaced\nend");
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings?.[0]).toContain("Auto-corrected escaped tab indentation");
|
||||
} finally {
|
||||
if (previous === undefined) delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
|
||||
else Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not auto-correct escaped tab indentation when disabled by env", () => {
|
||||
const previous = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
|
||||
Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = "0";
|
||||
try {
|
||||
const content = "root\n\tchild\n\t\tvalue\nend";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] }];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("root\n\tchild\n\\t\\treplaced\nend");
|
||||
expect(result.warnings).toBeUndefined();
|
||||
} finally {
|
||||
if (previous === undefined) delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
|
||||
else Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not auto-correct when edit already includes real tab characters", () => {
|
||||
const previous = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
|
||||
delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
|
||||
try {
|
||||
const content = "root\n\tchild\n\t\tvalue\nend";
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
op: "replace",
|
||||
pos: makeTag(3, "\t\tvalue"),
|
||||
lines: ["\t\talready-tab", "\\t\\tescaped-still-literal"],
|
||||
},
|
||||
];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("root\n\tchild\n\t\talready-tab\n\\t\\tescaped-still-literal\nend");
|
||||
expect(result.warnings).toBeUndefined();
|
||||
} finally {
|
||||
if (previous === undefined) delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
|
||||
else Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = previous;
|
||||
}
|
||||
});
|
||||
it("warns on literal \\uDDDD without changing content", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: ["\\uDDDD"] }];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.lines).toBe("aaa\n\\uDDDD\nccc");
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings?.[0]).toContain("Detected literal \\uDDDD");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -99,7 +99,6 @@ describe("wrapTextWithAnsi", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("strikethrough styling", () => {
|
||||
it("disables strikethrough at wrapped line ends while preserving fg/bg colors", () => {
|
||||
const strikeOn = "\x1b[9m";
|
||||
|
||||
Reference in New Issue
Block a user