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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim 2026-02-24 17:32:44 +09:00
parent c6a69899d8
commit 55ad4297d4
4 changed files with 76 additions and 28 deletions

View File

@ -136,6 +136,10 @@ function extractFilePath(metadata: unknown): string | undefined {
} }
async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise<void> { async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise<void> {
if (output.output.startsWith("File written successfully.")) {
return
}
const filePath = extractFilePath(output.metadata) const filePath = extractFilePath(output.metadata)
if (!filePath) { if (!filePath) {
return return

View File

@ -189,6 +189,28 @@ describe("hashline-read-enhancer", () => {
fs.rmSync(tempDir, { recursive: true, force: true }) 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 () => { it("skips when feature is disabled", async () => {
//#given //#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: false } }) const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: false } })

View File

@ -38,6 +38,32 @@ describe("parseLineRef", () => {
expect(() => parseLineRef(ref)).toThrow(/not a line number/i) 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", () => { it("accepts refs copied with markers and trailing content", () => {
//#given //#given
const ref = ">>> 42#VK|const value = 1" const ref = ">>> 42#VK|const value = 1"

View File

@ -38,30 +38,24 @@ export function parseLineRef(ref: string): LineRef {
hash: match[2], hash: match[2],
} }
} }
const nonNumericMatch = ref.trim().match(/^([A-Za-z_]+)#([ZPMQVRWSNKTXJBYH]{2})$/) const hashIdx = normalized.indexOf('#')
if (nonNumericMatch) { 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( throw new Error(
`Invalid line reference: "${ref}". "${nonNumericMatch[1]}" is not a line number. ` + `Invalid line reference: "${ref}". "${prefix}" is not a line number. ` +
`Use the actual line number from the read output.` `Use the actual line number from the read output.`
) )
} }
}
throw new Error( throw new Error(
`Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"` `Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"`
) )
} }
export function validateLineRef(lines: string[], ref: string): void { export function validateLineRef(lines: string[], ref: string): void {
let parsed: LineRef const { line, hash } = parseLineRefWithHint(ref, lines)
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
if (line < 1 || line > lines.length) { if (line < 1 || line > lines.length) {
throw new Error( throw new Error(
@ -145,14 +139,9 @@ function suggestLineForHash(ref: string, lines: string[]): string | null {
} }
return null return null
} }
function parseLineRefWithHint(ref: string, lines: string[]): LineRef {
export function validateLineRefs(lines: string[], refs: string[]): void {
const mismatches: HashMismatch[] = []
for (const ref of refs) {
let parsed: LineRef
try { try {
parsed = parseLineRef(ref) return parseLineRef(ref)
} catch (parseError) { } catch (parseError) {
const hint = suggestLineForHash(ref, lines) const hint = suggestLineForHash(ref, lines)
if (hint && parseError instanceof Error) { if (hint && parseError instanceof Error) {
@ -160,7 +149,14 @@ export function validateLineRefs(lines: string[], refs: string[]): void {
} }
throw parseError throw parseError
} }
const { line, hash } = parsed }
export function validateLineRefs(lines: string[], refs: string[]): void {
const mismatches: HashMismatch[] = []
for (const ref of refs) {
const { line, hash } = parseLineRefWithHint(ref, lines)
if (line < 1 || line > lines.length) { if (line < 1 || line > lines.length) {
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`) throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)