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]
|
## [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
|
## [17.3.3] - 2026-08-14
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -153,6 +153,26 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
|
|||||||
/** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
|
/** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
|
||||||
export const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
|
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 {
|
interface ReplacementGroup {
|
||||||
/** Positions in the edit array of the payload inserts, in payload order. */
|
/** Positions in the edit array of the payload inserts, in payload order. */
|
||||||
insertIndices: number[];
|
insertIndices: number[];
|
||||||
@@ -328,11 +348,13 @@ interface TextualBoundaryNormalization {
|
|||||||
* Normalize exact boundary echoes without interpreting language tokens.
|
* Normalize exact boundary echoes without interpreting language tokens.
|
||||||
*
|
*
|
||||||
* Two-sided echoes are removed when stripping both copies leaves one payload
|
* 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
|
* row per deleted range line. One-sided echoes are removed when the remaining
|
||||||
* removed when the remaining payload still covers the full range; an
|
* payload still covers the full range — on multi-line ranges from line
|
||||||
* under-filled one-sided echo is recorded as ambiguous so the syntax-probe
|
* equality alone, on single-line ranges only when every echoed row is an
|
||||||
* search gets first chance to resolve it, then rejected rather than silently
|
* annotation ({@link ANNOTATION_ROW_RE}), where an adjacent duplicate is never
|
||||||
* dropping unique range content.
|
* 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(
|
function normalizeTextualBoundaryEchoes(
|
||||||
edits: readonly AppliedEdit[],
|
edits: readonly AppliedEdit[],
|
||||||
@@ -361,7 +383,7 @@ function normalizeTextualBoundaryEchoes(
|
|||||||
dropLeading = leading;
|
dropLeading = leading;
|
||||||
dropTrailing = trailing;
|
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) {
|
if (group.payload.length - leading >= rangeLength) {
|
||||||
dropLeading = leading;
|
dropLeading = leading;
|
||||||
} else {
|
} else {
|
||||||
@@ -372,7 +394,10 @@ function normalizeTextualBoundaryEchoes(
|
|||||||
count: leading,
|
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) {
|
if (group.payload.length - trailing >= rangeLength) {
|
||||||
dropTrailing = trailing;
|
dropTrailing = trailing;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -111,7 +111,11 @@ export abstract class SnapshotStore {
|
|||||||
abstract clear(): void;
|
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;
|
const DEFAULT_MAX_VERSIONS_PER_PATH = 4;
|
||||||
/** Global ceiling on retained snapshot text across all paths (UTF-16 code units). */
|
/** Global ceiling on retained snapshot text across all paths (UTF-16 code units). */
|
||||||
const DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
const DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
||||||
@@ -124,7 +128,7 @@ function mergeSeenLines(snapshot: Snapshot, lines: Iterable<number> | undefined)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface InMemorySnapshotStoreOptions {
|
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;
|
maxPaths?: number;
|
||||||
/** Maximum full-file versions retained per path (default 4). Oldest dropped first. */
|
/** Maximum full-file versions retained per path (default 4). Oldest dropped first. */
|
||||||
maxVersionsPerPath?: number;
|
maxVersionsPerPath?: number;
|
||||||
|
|||||||
@@ -1065,3 +1065,98 @@ describe("post-apply parse advisory (the resolve_alias_path incident)", () => {
|
|||||||
expect(warnings).toHaveLength(0);
|
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();
|
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", () => {
|
it("rejects cross-path lookups", () => {
|
||||||
const store = new InMemorySnapshotStore();
|
const store = new InMemorySnapshotStore();
|
||||||
const tag = store.record(PATH, "shared\n");
|
const tag = store.record(PATH, "shared\n");
|
||||||
|
|||||||
Reference in New Issue
Block a user