From 55ad4297d432d47572172a165e809417176b3f22 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 24 Feb 2026 17:32:44 +0900 Subject: [PATCH] fix(hashline-edit): widen non-numeric prefix detection and remove duplicate try-catch - Replace regex /^([A-Za-z_]+)#.../ with indexOf-based prefix check to catch line-ref#VK and line.ref#VK style inputs that were previously giving generic errors - Extract parseLineRefWithHint helper to eliminate duplicated try-catch in validateLineRef and validateLineRefs - Restore idempotency guard in appendWriteHashlineOutput using new output format - Add tests for LINE42 extraction, line-ref hint, line.ref hint, and guard behavior Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/hooks/hashline-read-enhancer/hook.ts | 4 ++ .../hashline-read-enhancer/index.test.ts | 22 ++++++++ src/tools/hashline-edit/validation.test.ts | 26 ++++++++++ src/tools/hashline-edit/validation.ts | 52 +++++++++---------- 4 files changed, 76 insertions(+), 28 deletions(-) diff --git a/src/hooks/hashline-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts index 09a46c11..16484bd0 100644 --- a/src/hooks/hashline-read-enhancer/hook.ts +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -136,6 +136,10 @@ function extractFilePath(metadata: unknown): string | undefined { } async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise { + if (output.output.startsWith("File written successfully.")) { + return + } + const filePath = extractFilePath(output.metadata) if (!filePath) { return diff --git a/src/hooks/hashline-read-enhancer/index.test.ts b/src/hooks/hashline-read-enhancer/index.test.ts index 627380e0..60bbd630 100644 --- a/src/hooks/hashline-read-enhancer/index.test.ts +++ b/src/hooks/hashline-read-enhancer/index.test.ts @@ -189,6 +189,28 @@ describe("hashline-read-enhancer", () => { fs.rmSync(tempDir, { recursive: true, force: true }) }) + it("does not re-process write output that already contains the success marker", async () => { + //#given + const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-idem-")) + const filePath = path.join(tempDir, "demo.ts") + fs.writeFileSync(filePath, "a\nb\nc\nd\ne") + const input = { tool: "write", sessionID: "s", callID: "c" } + const output = { + title: "write", + output: "File written successfully. 99 lines written.", + metadata: { filepath: filePath }, + } + + //#when + await hook["tool.execute.after"](input, output) + + //#then — guard should prevent re-reading the file and updating the count + expect(output.output).toBe("File written successfully. 99 lines written.") + + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + it("skips when feature is disabled", async () => { //#given const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: false } }) diff --git a/src/tools/hashline-edit/validation.test.ts b/src/tools/hashline-edit/validation.test.ts index 4d4c1b00..b41d748d 100644 --- a/src/tools/hashline-edit/validation.test.ts +++ b/src/tools/hashline-edit/validation.test.ts @@ -38,6 +38,32 @@ describe("parseLineRef", () => { expect(() => parseLineRef(ref)).toThrow(/not a line number/i) }) + it("extracts valid line number from mixed prefix like LINE42 without throwing", () => { + //#given — normalizeLineRef extracts 42#VK from LINE42#VK + const ref = "LINE42#VK" + + //#when / #then — should parse successfully as line 42 + const result = parseLineRef(ref) + expect(result.line).toBe(42) + expect(result.hash).toBe("VK") + }) + + it("gives specific hint when hyphenated prefix like line-ref is used", () => { + //#given + const ref = "line-ref#VK" + + //#when / #then + expect(() => parseLineRef(ref)).toThrow(/not a line number/i) + }) + + it("gives specific hint when prefix contains a period like line.ref", () => { + //#given + const ref = "line.ref#VK" + + //#when / #then + expect(() => parseLineRef(ref)).toThrow(/not a line number/i) + }) + it("accepts refs copied with markers and trailing content", () => { //#given const ref = ">>> 42#VK|const value = 1" diff --git a/src/tools/hashline-edit/validation.ts b/src/tools/hashline-edit/validation.ts index 89c5d6eb..93e8e01e 100644 --- a/src/tools/hashline-edit/validation.ts +++ b/src/tools/hashline-edit/validation.ts @@ -38,12 +38,16 @@ export function parseLineRef(ref: string): LineRef { hash: match[2], } } - const nonNumericMatch = ref.trim().match(/^([A-Za-z_]+)#([ZPMQVRWSNKTXJBYH]{2})$/) - if (nonNumericMatch) { - throw new Error( - `Invalid line reference: "${ref}". "${nonNumericMatch[1]}" is not a line number. ` + - `Use the actual line number from the read output.` - ) + const hashIdx = normalized.indexOf('#') + if (hashIdx > 0) { + const prefix = normalized.slice(0, hashIdx) + const suffix = normalized.slice(hashIdx + 1) + if (!/^\d+$/.test(prefix) && /^[ZPMQVRWSNKTXJBYH]{2}$/.test(suffix)) { + throw new Error( + `Invalid line reference: "${ref}". "${prefix}" is not a line number. ` + + `Use the actual line number from the read output.` + ) + } } throw new Error( `Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"` @@ -51,17 +55,7 @@ export function parseLineRef(ref: string): LineRef { } export function validateLineRef(lines: string[], ref: string): void { - let parsed: LineRef - try { - parsed = parseLineRef(ref) - } catch (parseError) { - const hint = suggestLineForHash(ref, lines) - if (hint && parseError instanceof Error) { - throw new Error(`${parseError.message} ${hint}`) - } - throw parseError - } - const { line, hash } = parsed + const { line, hash } = parseLineRefWithHint(ref, lines) if (line < 1 || line > lines.length) { throw new Error( @@ -145,22 +139,24 @@ function suggestLineForHash(ref: string, lines: string[]): string | null { } return null } +function parseLineRefWithHint(ref: string, lines: string[]): LineRef { + try { + return parseLineRef(ref) + } catch (parseError) { + const hint = suggestLineForHash(ref, lines) + if (hint && parseError instanceof Error) { + throw new Error(`${parseError.message} ${hint}`) + } + throw parseError + } +} + export function validateLineRefs(lines: string[], refs: string[]): void { const mismatches: HashMismatch[] = [] for (const ref of refs) { - let parsed: LineRef - try { - parsed = parseLineRef(ref) - } catch (parseError) { - const hint = suggestLineForHash(ref, lines) - if (hint && parseError instanceof Error) { - throw new Error(`${parseError.message} ${hint}`) - } - throw parseError - } - const { line, hash } = parsed + const { line, hash } = parseLineRefWithHint(ref, lines) if (line < 1 || line > lines.length) { throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)