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:
can1357
2026-03-01 10:59:15 +01:00
parent 625be8618a
commit 8663794c56
5 changed files with 119 additions and 12 deletions
+8
View File
@@ -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");
});
});
// ═══════════════════════════════════════════════════════════════════════════
-1
View File
@@ -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";