fix: synchronized snapshot-tag checks and fixed ANSI width emoji handling

- Introduced a shared missingSnapshotTagMessage helper and reused it from both hashline patcher and preview validation.
- Required snapshot tags in preview hashline sections for all edits, including head/tail inserts, so preview failures now match apply-time rejections.
- Updated TUI width calculation to strip ANSI escapes before grapheme splitting, preserving styled ZWJ emoji width behavior.
This commit is contained in:
can1357
2026-05-30 15:59:58 +02:00
parent 5cd88b7abc
commit 55e146fa3c
7 changed files with 98 additions and 23 deletions
@@ -11,6 +11,7 @@
*/
import {
Patch as HashlinePatch,
missingSnapshotTagMessage,
normalizeToLF,
type Patch,
type PatchSection,
@@ -40,10 +41,6 @@ async function readSectionText(absolutePath: string, sectionPath: string): Promi
}
}
function hasAnchorScoped(section: PatchSection): boolean {
return section.hasAnchorScopedEdit;
}
function snapshotMatchesCurrent(snapshot: Snapshot, currentText: string): boolean {
return snapshot.text === currentText;
}
@@ -54,9 +51,10 @@ function validateSectionHash(
snapshots: SnapshotStore,
): string | null {
if (section.fileHash === undefined) {
return hasAnchorScoped(section)
? `Missing hashline snapshot tag for anchored edit to ${section.path}; use \`${section.path}#tag\` from your latest read.`
: null;
// The snapshot tag is mandatory on every section — head/tail inserts
// included — to keep this preview path in lockstep with the apply path
// (`Patcher.prepare`), which rejects tagless sections unconditionally.
return missingSnapshotTagMessage(section.path);
}
const snapshot = snapshots.byHash(absolutePath, section.fileHash);
if (snapshot && snapshotMatchesCurrent(snapshot, text)) return null;
+26 -4
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { formatHashlineHeader, InMemorySnapshotStore } from "@oh-my-pi/hashline";
import { formatHashlineHeader, InMemorySnapshotStore, missingSnapshotTagMessage } from "@oh-my-pi/hashline";
import {
adjustIndentation,
computeEditDiff,
@@ -251,18 +251,40 @@ describe("computeHashlineDiff", () => {
test("accepts hashline input edits", async () => {
const sourcePath = path.join(tempDir, "source.txt");
await Bun.write(sourcePath, "first\n");
const text = "first\n";
await Bun.write(sourcePath, text);
const snapshotStore = new InMemorySnapshotStore();
const tag = snapshotStore.record(sourcePath, text);
const result = await computeHashlineDiff(
{ input: `${sourcePath}\ninsert tail:\n+second` },
{ input: `${formatHashlineHeader(sourcePath, tag)}\ninsert tail:\n+second` },
tempDir,
new InMemorySnapshotStore(),
snapshotStore,
);
expect("diff" in result).toBe(true);
if ("diff" in result) {
expect(result.diff).toContain("second");
}
});
test("rejects a tagless head/tail insert in the preview path, matching apply", async () => {
const relativePath = "source.txt";
await Bun.write(path.join(tempDir, relativePath), "first\n");
// A tagless `insert tail:` carries no anchored edit, yet the apply path
// (Patcher.prepare) rejects it for the missing mandatory tag. The
// preview/diff path MUST emit the SAME rejection so a successful preview
// never precedes a failing apply.
const result = await computeHashlineDiff(
{ input: `${relativePath}\ninsert tail:\n+second` },
tempDir,
new InMemorySnapshotStore(),
);
expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.error).toBe(missingSnapshotTagMessage(relativePath));
}
});
test("returns a handled error when the source path is a local URL", async () => {
const result = await computeHashlineDiff(
{ input: "¶local://PLAN.md\ninsert tail:\n+x" },
+12
View File
@@ -5,6 +5,8 @@
* them.
*/
import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
/** Lines of context shown either side of a hash mismatch. */
export const MISMATCH_CONTEXT = 2;
@@ -75,3 +77,13 @@ export const RECOVERY_SESSION_REPLAY_WARNING =
*/
export const HEADTAIL_DRIFT_WARNING =
"Applied an `insert head:`/`insert tail:` edit onto the current file content even though the snapshot tag was stale (the file changed since your read). Head/tail position is content-independent, so the insert was not rejected — but re-read if the drift was unexpected.";
/**
* Error text emitted when a hashline section omits the mandatory snapshot tag.
* The tag is REQUIRED on every section, enforced identically by the apply path
* ({@link Patcher.prepare}) and the preview/diff path, so both surfaces reuse
* this single builder to stay in lockstep.
*/
export function missingSnapshotTagMessage(sectionPath: string): string {
return `Missing hashline snapshot tag for edit to ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}tag\` from your latest read/search output. To create a new file, use the write tool.`;
}
+3 -5
View File
@@ -23,11 +23,11 @@
* filesystem configuration.
*/
import { applyEdits } from "./apply";
import { computeFileHash, formatHashlineHeader, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
import { computeFileHash, formatHashlineHeader } from "./format";
import type { Filesystem, WriteResult } from "./fs";
import { isNotFound } from "./fs";
import type { Patch, PatchSection } from "./input";
import { HEADTAIL_DRIFT_WARNING } from "./messages";
import { HEADTAIL_DRIFT_WARNING, missingSnapshotTagMessage } from "./messages";
import { MismatchError } from "./mismatch";
import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
import { Recovery, type RecoveryResult } from "./recovery";
@@ -105,9 +105,7 @@ function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
function assertSectionHashPresent(sectionPath: string, fileHash: string | undefined): void {
if (fileHash !== undefined) return;
throw new Error(
`Missing hashline snapshot tag for edit to ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}tag\` from your latest read/search output. To create a new file, use the write tool.`,
);
throw new Error(missingSnapshotTagMessage(sectionPath));
}
function recoveryToApplyResult(result: RecoveryResult): ApplyResult {
+5 -5
View File
@@ -33,7 +33,7 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
<example>
Original (the exact shape `read` returns):
```
¶greet.py#A1
¶greet.py#A1B2
1:def greet(name):
2: msg = "Hello, " + name
3: print(msg)
@@ -42,14 +42,14 @@ Original (the exact shape `read` returns):
Insert a guard after line 1:
```
¶greet.py#A1
¶greet.py#A1B2
insert after 1:
+ if not name: name = "stranger"
```
Replace line 2 with two lines:
```
¶greet.py#A1
¶greet.py#A1B2
replace 2..2:
+ greeting = "Hi"
+ msg = f"{greeting}, {name}"
@@ -57,13 +57,13 @@ replace 2..2:
Delete line 3:
```
¶greet.py#A1
¶greet.py#A1B2
delete 3
```
Add a header and trailer:
```
¶greet.py#A1
¶greet.py#A1B2
insert head:
+# generated header
insert tail:
+30 -2
View File
@@ -87,10 +87,38 @@ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const EXTENDED_PICTOGRAPHIC_REGEX = /\p{Extended_Pictographic}/u;
// Matches CSI (`\x1b[…`) and OSC (`\x1b]…` terminated by BEL/ST) escape
// sequences. Mirrors the standard ansi-regex coverage so visible-span
// segmentation lines up with the native ANSI scanner.
const ANSI_ESCAPE_REGEX =
/[\u001b\u009b][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
function pictographicSpanWidth(span: string): number {
let width = 0;
for (const { segment } of segmenter.segment(span)) {
width += EXTENDED_PICTOGRAPHIC_REGEX.test(segment) ? 2 : nativeVisibleWidth(segment, getDefaultTabWidth());
}
return width;
}
// Width fallback for strings that mix ANSI styling with ZWJ pictographic
// emoji. `Intl.Segmenter` would split an escape sequence into individual
// graphemes, so the native scanner (which only skips ANSI when handed the
// complete sequence) double-counts the printable SGR bytes. Excise the ANSI
// spans first — they contribute zero cells — and apply the pictographic
// grapheme override only to the visible spans, then sum.
function visibleWidthByGrapheme(str: string): number {
let width = 0;
for (const { segment } of segmenter.segment(str)) {
width += EXTENDED_PICTOGRAPHIC_REGEX.test(segment) ? 2 : nativeVisibleWidth(segment, getDefaultTabWidth());
let lastIndex = 0;
ANSI_ESCAPE_REGEX.lastIndex = 0;
for (let match = ANSI_ESCAPE_REGEX.exec(str); match !== null; match = ANSI_ESCAPE_REGEX.exec(str)) {
if (match.index > lastIndex) {
width += pictographicSpanWidth(str.slice(lastIndex, match.index));
}
lastIndex = ANSI_ESCAPE_REGEX.lastIndex;
}
if (lastIndex < str.length) {
width += lastIndex === 0 ? pictographicSpanWidth(str) : pictographicSpanWidth(str.slice(lastIndex));
}
return width;
}
+17
View File
@@ -19,6 +19,23 @@ describe("text utils", () => {
expect(visibleWidth(text)).toBe(4);
});
it("counts a styled ZWJ emoji the same as the unstyled emoji (ANSI is zero-width)", () => {
// Family emoji built from ZWJ-joined code points renders as a single
// 2-cell grapheme. Wrapping it in SGR styling must not change its width:
// the grapheme fallback splits ANSI into separate segments, and the
// native scanner only skips ANSI when handed the complete escape — so
// the SGR bytes (`[`, `3`, `1`, `m`, …) must be excised before
// segmentation, not counted as visible cells.
const emoji = "\u{1F468}\u200d\u{1F469}\u200d\u{1F467}";
const styled = `\x1b[31m${emoji}\x1b[0m`;
expect(visibleWidth(emoji)).toBe(2);
expect(visibleWidth(styled)).toBe(visibleWidth(emoji));
// Styling around only part of a ZWJ-containing span is also zero-width.
expect(visibleWidth(`a\x1b[1m${emoji}\x1b[22mb`)).toBe(1 + 2 + 1);
// Plain styled ASCII is unaffected — ANSI strips to its visible text.
expect(visibleWidth("\x1b[31mhello\x1b[0m")).toBe(visibleWidth("hello"));
});
it("truncates ANSI text with ellipsis", () => {
const text = "\x1b[31mhello world\x1b[0m";
const result = truncateToWidth(text, 6);