From 577c0d795fe5a8344d2ff1921ddff793cc98065d Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 20 Aug 2026 02:38:07 +0200 Subject: [PATCH] fix(hashline): corrected single-line annotation boundary handling and raised snapshot capacity - Added annotation row detection to reject under-filled one-sided echoes on single-line replacement ranges. - Raised default snapshot store path capacity from 30 to 256 to prevent early tags from aging out in wide sessions. - Added comprehensive test suites verifying boundary repair for single-line annotations and snapshot LRU retention. --- packages/hashline/CHANGELOG.md | 5 + packages/hashline/src/apply.ts | 39 ++++++-- packages/hashline/src/snapshots.ts | 8 +- .../hashline/test/boundary-repair.test.ts | 95 +++++++++++++++++++ packages/hashline/test/snapshots.test.ts | 10 ++ 5 files changed, 148 insertions(+), 9 deletions(-) diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index 64e84f677..43177003e 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Fixed + +- Dropped one-sided boundary echoes on single-line replacement ranges when every echoed row is an attribute/decorator (`#[napi]`, `@Injectable()`): the authored result parses, so the syntax probe never fired and the attribute was silently duplicated. Under-filled annotation echoes are now rejected instead of applied. +- Raised the default snapshot-store path capacity from 30 to 256 so tags minted early in a wide session no longer age out of the LRU and degrade a recoverable stale-tag mismatch into the misleading "hash is not from this session" rejection. + ## [17.3.3] - 2026-08-14 ### Fixed diff --git a/packages/hashline/src/apply.ts b/packages/hashline/src/apply.ts index 490382954..306f6020a 100644 --- a/packages/hashline/src/apply.ts +++ b/packages/hashline/src/apply.ts @@ -153,6 +153,26 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map 0 && rangeLength > 1) { + } else if (leading > 0 && (rangeLength > 1 || isAnnotationEchoRun(group.payload, 0, leading))) { if (group.payload.length - leading >= rangeLength) { dropLeading = leading; } else { @@ -372,7 +394,10 @@ function normalizeTextualBoundaryEchoes( count: leading, }); } - } else if (trailing > 0 && rangeLength > 1) { + } else if ( + trailing > 0 && + (rangeLength > 1 || isAnnotationEchoRun(group.payload, group.payload.length - trailing, group.payload.length)) + ) { if (group.payload.length - trailing >= rangeLength) { dropTrailing = trailing; } else { diff --git a/packages/hashline/src/snapshots.ts b/packages/hashline/src/snapshots.ts index 6cae609c4..999dde81c 100644 --- a/packages/hashline/src/snapshots.ts +++ b/packages/hashline/src/snapshots.ts @@ -111,7 +111,11 @@ export abstract class SnapshotStore { abstract clear(): void; } -const DEFAULT_MAX_PATHS = 30; +// Wide sessions routinely touch far more than a few dozen files; evicting a +// path downgrades a genuinely in-session tag to the misleading "hash is not +// from this session" rejection. Retention is still bounded by +// DEFAULT_MAX_TOTAL_BYTES, so a high path count costs little. +const DEFAULT_MAX_PATHS = 256; const DEFAULT_MAX_VERSIONS_PER_PATH = 4; /** Global ceiling on retained snapshot text across all paths (UTF-16 code units). */ const DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024; @@ -124,7 +128,7 @@ function mergeSeenLines(snapshot: Snapshot, lines: Iterable | undefined) } export interface InMemorySnapshotStoreOptions { - /** Maximum number of distinct paths tracked at once (default 30). LRU eviction. */ + /** Maximum number of distinct paths tracked at once (default 256). LRU eviction. */ maxPaths?: number; /** Maximum full-file versions retained per path (default 4). Oldest dropped first. */ maxVersionsPerPath?: number; diff --git a/packages/hashline/test/boundary-repair.test.ts b/packages/hashline/test/boundary-repair.test.ts index acdcec027..7b86d964c 100644 --- a/packages/hashline/test/boundary-repair.test.ts +++ b/packages/hashline/test/boundary-repair.test.ts @@ -1065,3 +1065,98 @@ describe("post-apply parse advisory (the resolve_alias_path incident)", () => { expect(warnings).toHaveLength(0); }); }); + +// The doc-restoration incident: `cargo fmt` shifted line numbers under the +// model, so a `PUT N.=N` meant to prepend doc lines above `#[napi]` landed one +// line high. Each body ended by restating the `#[napi]` that survives just +// below the range, duplicating the attribute. The result PARSES (attributes +// may repeat syntactically), so the probe-judged variant search never runs — +// only exact-text normalization with annotation-row evidence can catch it. +describe("single-line annotation echoes (the #[napi] doc-restoration incident)", () => { + it("drops a trailing attribute echo in a single-line replacement", () => { + const file = [ + "/// Unified-diff hunks with jsdiff", + "/// `structuredPatch(_, _, oldText, newText, _, _, { context }).hunks`", + "#[napi]", + "pub fn structured_patch_hunks() {}", + ].join("\n"); + const diff = ["PUT 2.=2:", "+/// semantics. `context` defaults to 4 like jsdiff.", "+#[napi]"].join("\n"); + const { text, warnings } = applyRust(file, diff); + expect(text).toBe( + [ + "/// Unified-diff hunks with jsdiff", + "/// semantics. `context` defaults to 4 like jsdiff.", + "#[napi]", + "pub fn structured_patch_hunks() {}", + ].join("\n"), + ); + expect(text.split("\n").filter(line => line === "#[napi]")).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); + }); + + it("drops the echo when the body carries several new doc rows", () => { + const file = [ + "/// Wrap text to a visible width, preserving ANSI escape codes across line", + "/// breaks.", + "#[napi]", + "pub fn wrap_text_with_ansi() {}", + ].join("\n"); + const diff = [ + "PUT 2.=2:", + "+///", + "+/// Returns UTF-16 lines with active SGR codes carried across line boundaries.", + "+#[napi]", + ].join("\n"); + const { text, warnings } = applyRust(file, diff); + expect(text).toBe( + [ + "/// Wrap text to a visible width, preserving ANSI escape codes across line", + "///", + "/// Returns UTF-16 lines with active SGR codes carried across line boundaries.", + "#[napi]", + "pub fn wrap_text_with_ansi() {}", + ].join("\n"), + ); + expect(text.split("\n").filter(line => line === "#[napi]")).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); + }); + + // Mirror direction: the range landed one line low and the body opens by + // restating the attribute that survives just above it. + it("drops a leading attribute echo in a single-line replacement", () => { + const file = ["#[napi]", "/// Old summary.", "pub fn f() {}"].join("\n"); + const diff = ["PUT 2.=2:", "+#[napi]", "+/// New summary."].join("\n"); + const { text, warnings } = applyRust(file, diff); + expect(text).toBe(["#[napi]", "/// New summary.", "pub fn f() {}"].join("\n")); + expect(text.split("\n").filter(line => line === "#[napi]")).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); + }); + + // Under-filled: the body is nothing but the echo. Dropping it would turn + // the replace into a bare delete of the range line; applying it would + // duplicate the attribute. Reject rather than guess. + it("rejects a single-line annotation echo whose body cannot fill the range", () => { + const file = ["/// Old doc.", "#[napi]", "pub fn f() {}"].join("\n"); + expect(() => applyRust(file, "PUT 1.=1:\n+#[napi]")).toThrow(/ends by restating/); + }); + + // Same shape in TypeScript: duplicate adjacent decorators parse, so the + // annotation-row evidence is what fires, not the syntax probe. + it("drops a trailing decorator echo in a single-line replacement", () => { + const file = ["/** Old summary. */", "@Injectable()", "class Service {}"].join("\n"); + const diff = ["PUT 1.=1:", "+/** Creates request-scoped services. */", "+@Injectable()"].join("\n"); + const { text, warnings } = apply(file, diff); + expect(text).toBe(["/** Creates request-scoped services. */", "@Injectable()", "class Service {}"].join("\n")); + expect(text.split("\n").filter(line => line === "@Injectable()")).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); + }); + + // Statement echoes on single-line ranges stay literal (pinned above by the + // balance-preserving tests): the annotation gate must not widen to them. + it("keeps a trailing statement echo literal on a single-line range", () => { + const file = ["foo();", "old();", "bar();"].join("\n"); + const { text, warnings } = apply(file, "PUT 2.=2:\n+fresh();\n+bar();"); + expect(text).toBe(["foo();", "fresh();", "bar();", "bar();"].join("\n")); + expect(warnings).toHaveLength(0); + }); +}); diff --git a/packages/hashline/test/snapshots.test.ts b/packages/hashline/test/snapshots.test.ts index 58ba324e8..fea2e2fc3 100644 --- a/packages/hashline/test/snapshots.test.ts +++ b/packages/hashline/test/snapshots.test.ts @@ -69,6 +69,16 @@ describe("InMemorySnapshotStore", () => { expect(store.head(PATH)).toBeNull(); }); + // The tokens.rs incident: a tag minted early in a session touching dozens + // of files aged out of the path LRU, downgrading a recoverable stale-tag + // mismatch to the misleading "hash is not from this session" rejection. + it("keeps an early tag resolvable across a wide session at default capacity", () => { + const store = new InMemorySnapshotStore(); + const tag = store.record(PATH, "first\n"); + for (let i = 0; i < 100; i++) store.record(`/w/other-${i}.ts`, `content ${i}\n`); + expect(store.byHash(PATH, tag)?.text).toBe("first\n"); + }); + it("rejects cross-path lookups", () => { const store = new InMemorySnapshotStore(); const tag = store.record(PATH, "shared\n");