From bbd2e86499e3fc0e84ea22e833840c9996f0fd58 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 14 Mar 2026 13:42:17 +0900 Subject: [PATCH] fix(hashline): accept legacy hashes for indented anchors Keep persisted LINE#ID anchors working after strict whitespace hashing by falling back to the legacy hash for validation-only lookups. Co-authored-by: Sisyphus --- .../hashline-edit/hash-computation.test.ts | 14 ++++++++++++++ src/tools/hashline-edit/hash-computation.ts | 12 ++++++++++-- src/tools/hashline-edit/validation.test.ts | 11 ++++++++++- src/tools/hashline-edit/validation.ts | 17 +++++++++-------- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/tools/hashline-edit/hash-computation.test.ts b/src/tools/hashline-edit/hash-computation.test.ts index bea1bba0d..60350c736 100644 --- a/src/tools/hashline-edit/hash-computation.test.ts +++ b/src/tools/hashline-edit/hash-computation.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "bun:test" import { computeLineHash, + computeLegacyLineHash, formatHashLine, formatHashLines, streamHashLinesFromLines, @@ -58,6 +59,19 @@ describe("computeLineHash", () => { expect(hash1).not.toBe(hash2) }) + it("preserves legacy hashes for leading indentation variants", () => { + //#given + const content1 = "function hello() {" + const content2 = " function hello() {" + + //#when + const hash1 = computeLegacyLineHash(1, content1) + const hash2 = computeLegacyLineHash(1, content2) + + //#then + expect(hash1).toBe(hash2) + }) + it("ignores trailing whitespace differences", () => { //#given const content1 = "function hello() {" diff --git a/src/tools/hashline-edit/hash-computation.ts b/src/tools/hashline-edit/hash-computation.ts index 25b05cbf4..7434445cf 100644 --- a/src/tools/hashline-edit/hash-computation.ts +++ b/src/tools/hashline-edit/hash-computation.ts @@ -3,14 +3,22 @@ import { createHashlineChunkFormatter } from "./hashline-chunk-formatter" const RE_SIGNIFICANT = /[\p{L}\p{N}]/u -export function computeLineHash(lineNumber: number, content: string): string { - const stripped = content.replace(/\r/g, "").trimEnd() +function computeNormalizedLineHash(lineNumber: number, normalizedContent: string): string { + const stripped = normalizedContent const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber const hash = Bun.hash.xxHash32(stripped, seed) const index = hash % 256 return HASHLINE_DICT[index] } +export function computeLineHash(lineNumber: number, content: string): string { + return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").trimEnd()) +} + +export function computeLegacyLineHash(lineNumber: number, content: string): string { + return computeNormalizedLineHash(lineNumber, content.replace(/\r/g, "").trim()) +} + export function formatHashLine(lineNumber: number, content: string): string { const hash = computeLineHash(lineNumber, content) return `${lineNumber}#${hash}|${content}` diff --git a/src/tools/hashline-edit/validation.test.ts b/src/tools/hashline-edit/validation.test.ts index 751a90b9c..8839103db 100644 --- a/src/tools/hashline-edit/validation.test.ts +++ b/src/tools/hashline-edit/validation.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test" -import { computeLineHash } from "./hash-computation" +import { computeLineHash, computeLegacyLineHash } from "./hash-computation" import { parseLineRef, validateLineRef, validateLineRefs } from "./validation" describe("parseLineRef", () => { @@ -116,6 +116,15 @@ describe("validateLineRef", () => { expect(() => validateLineRef(lines, "1#ZZ")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}\|/) }) + it("accepts legacy hashes for indented lines", () => { + //#given + const lines = [" function hello() {", " return 42", " }"] + const legacyHash = computeLegacyLineHash(1, lines[0]) + + //#when / #then + expect(() => validateLineRef(lines, `1#${legacyHash}`)).not.toThrow() + }) + it("shows >>> mismatch context in batched validation", () => { //#given const lines = ["one", "two", "three", "four"] diff --git a/src/tools/hashline-edit/validation.ts b/src/tools/hashline-edit/validation.ts index ed6061557..aa9166c16 100644 --- a/src/tools/hashline-edit/validation.ts +++ b/src/tools/hashline-edit/validation.ts @@ -1,4 +1,4 @@ -import { computeLineHash } from "./hash-computation" +import { computeLegacyLineHash, computeLineHash } from "./hash-computation" import { HASHLINE_REF_PATTERN } from "./constants" export interface LineRef { @@ -15,6 +15,10 @@ const MISMATCH_CONTEXT = 2 const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/ +function isCompatibleLineHash(line: number, content: string, hash: string): boolean { + return computeLineHash(line, content) === hash || computeLegacyLineHash(line, content) === hash +} + export function normalizeLineRef(ref: string): string { const originalTrimmed = ref.trim() let trimmed = originalTrimmed @@ -71,9 +75,7 @@ export function validateLineRef(lines: string[], ref: string): void { } const content = lines[line - 1] - const currentHash = computeLineHash(line, content) - - if (currentHash !== hash) { + if (!isCompatibleLineHash(line, content, hash)) { throw new HashlineMismatchError([{ line, expected: hash }], lines) } } @@ -140,8 +142,8 @@ function suggestLineForHash(ref: string, lines: string[]): string | null { if (!hashMatch) return null const hash = hashMatch[1] for (let i = 0; i < lines.length; i++) { - if (computeLineHash(i + 1, lines[i]) === hash) { - return `Did you mean "${i + 1}#${hash}"?` + if (isCompatibleLineHash(i + 1, lines[i], hash)) { + return `Did you mean "${i + 1}#${computeLineHash(i + 1, lines[i])}"?` } } return null @@ -169,8 +171,7 @@ export function validateLineRefs(lines: string[], refs: string[]): void { } const content = lines[line - 1] - const currentHash = computeLineHash(line, content) - if (currentHash !== hash) { + if (!isCompatibleLineHash(line, content, hash)) { mismatches.push({ line, expected: hash }) } }