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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -153,6 +153,26 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
|
||||
/** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
|
||||
export const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
|
||||
|
||||
/**
|
||||
* A row that is nothing but an attribute or decorator: `#[napi]`,
|
||||
* `#![allow(dead_code)]`, `@Injectable()`, `@property`. Statements may repeat
|
||||
* verbatim on adjacent lines by intent, but annotations on one item never do —
|
||||
* an exact adjacent copy is a boundary echo. This is the extra evidence that
|
||||
* lets one-sided echo normalization act on single-line ranges (the
|
||||
* doc-restoration incident: a `PUT N.=N` landed one line high and its body
|
||||
* restated the `#[napi]` surviving just below the range, duplicating the
|
||||
* attribute — a result that parses, so no syntax probe could catch it).
|
||||
*/
|
||||
const ANNOTATION_ROW_RE = /^\s*(?:#!?\[.+\]|@[A-Za-z_$][\w$.]*(?:\(.*\))?)\s*$/;
|
||||
|
||||
/** Every payload row in `[start, end)` is an annotation row. */
|
||||
function isAnnotationEchoRun(payload: readonly string[], start: number, end: number): boolean {
|
||||
for (let i = start; i < end; i++) {
|
||||
if (!ANNOTATION_ROW_RE.test(payload[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface ReplacementGroup {
|
||||
/** Positions in the edit array of the payload inserts, in payload order. */
|
||||
insertIndices: number[];
|
||||
@@ -328,11 +348,13 @@ interface TextualBoundaryNormalization {
|
||||
* Normalize exact boundary echoes without interpreting language tokens.
|
||||
*
|
||||
* Two-sided echoes are removed when stripping both copies leaves one payload
|
||||
* row per deleted range line. One-sided echoes on multi-line ranges are
|
||||
* removed when the remaining payload still covers the full range; an
|
||||
* under-filled one-sided echo is recorded as ambiguous so the syntax-probe
|
||||
* search gets first chance to resolve it, then rejected rather than silently
|
||||
* dropping unique range content.
|
||||
* row per deleted range line. One-sided echoes are removed when the remaining
|
||||
* payload still covers the full range — on multi-line ranges from line
|
||||
* equality alone, on single-line ranges only when every echoed row is an
|
||||
* annotation ({@link ANNOTATION_ROW_RE}), where an adjacent duplicate is never
|
||||
* intentional. An under-filled one-sided echo is recorded as ambiguous so the
|
||||
* syntax-probe search gets first chance to resolve it, then rejected rather
|
||||
* than silently dropping unique range content.
|
||||
*/
|
||||
function normalizeTextualBoundaryEchoes(
|
||||
edits: readonly AppliedEdit[],
|
||||
@@ -361,7 +383,7 @@ function normalizeTextualBoundaryEchoes(
|
||||
dropLeading = leading;
|
||||
dropTrailing = trailing;
|
||||
}
|
||||
} else if (leading > 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 {
|
||||
|
||||
@@ -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<number> | 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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user