Compare commits
17 Commits
fix/hashli
...
v3.8.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55b9ad60d8 | ||
|
|
e997e0071c | ||
|
|
b8257dc59c | ||
|
|
365d863e3a | ||
|
|
1785313f3b | ||
|
|
ac962d62ab | ||
|
|
d61c0f8cb5 | ||
|
|
a567cd0d68 | ||
|
|
55ad4297d4 | ||
|
|
c6a69899d8 | ||
|
|
2aeb96c3f6 | ||
|
|
5fd65f2935 | ||
|
|
b03aae57f3 | ||
|
|
8c3a0ca2fe | ||
|
|
9a2e0f1add | ||
|
|
d28ebd10c1 | ||
|
|
fb92babee7 |
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.5",
|
||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -74,13 +74,13 @@
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.8.4",
|
||||
"oh-my-opencode-darwin-x64": "3.8.4",
|
||||
"oh-my-opencode-linux-arm64": "3.8.4",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.8.4",
|
||||
"oh-my-opencode-linux-x64": "3.8.4",
|
||||
"oh-my-opencode-linux-x64-musl": "3.8.4",
|
||||
"oh-my-opencode-windows-x64": "3.8.4"
|
||||
"oh-my-opencode-darwin-arm64": "3.8.5",
|
||||
"oh-my-opencode-darwin-x64": "3.8.5",
|
||||
"oh-my-opencode-linux-arm64": "3.8.5",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.8.5",
|
||||
"oh-my-opencode-linux-x64": "3.8.5",
|
||||
"oh-my-opencode-linux-x64-musl": "3.8.5",
|
||||
"oh-my-opencode-windows-x64": "3.8.5"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64-musl",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.5",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { computeLineHash } from "../../tools/hashline-edit/hash-computation"
|
||||
import { toHashlineContent } from "../../tools/hashline-edit/diff-utils"
|
||||
|
||||
const WRITE_SUCCESS_MARKER = "File written successfully."
|
||||
|
||||
interface HashlineReadEnhancerConfig {
|
||||
hashline_edit?: { enabled: boolean }
|
||||
@@ -12,6 +13,7 @@ const CONTENT_OPEN_TAG = "<content>"
|
||||
const CONTENT_CLOSE_TAG = "</content>"
|
||||
const FILE_OPEN_TAG = "<file>"
|
||||
const FILE_CLOSE_TAG = "</file>"
|
||||
const OPENCODE_LINE_TRUNCATION_SUFFIX = "... (line truncated to 2000 chars)"
|
||||
|
||||
function isReadTool(toolName: string): boolean {
|
||||
return toolName.toLowerCase() === "read"
|
||||
@@ -55,6 +57,9 @@ function transformLine(line: string): string {
|
||||
if (!parsed) {
|
||||
return line
|
||||
}
|
||||
if (parsed.content.endsWith(OPENCODE_LINE_TRUNCATION_SUFFIX)) {
|
||||
return line
|
||||
}
|
||||
const hash = computeLineHash(parsed.lineNumber, parsed.content)
|
||||
return `${parsed.lineNumber}#${hash}|${parsed.content}`
|
||||
}
|
||||
@@ -137,7 +142,12 @@ function extractFilePath(metadata: unknown): string | undefined {
|
||||
}
|
||||
|
||||
async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise<void> {
|
||||
if (output.output.includes("Updated file (LINE#ID|content):")) {
|
||||
if (output.output.startsWith(WRITE_SUCCESS_MARKER)) {
|
||||
return
|
||||
}
|
||||
|
||||
const outputLower = output.output.toLowerCase()
|
||||
if (outputLower.startsWith("error") || outputLower.includes("failed")) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -152,8 +162,8 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk
|
||||
}
|
||||
|
||||
const content = await file.text()
|
||||
const hashlined = toHashlineContent(content)
|
||||
output.output = `${output.output}\n\nUpdated file (LINE#ID|content):\n${hashlined}`
|
||||
const lineCount = content === "" ? 0 : content.split("\n").length
|
||||
output.output = `${WRITE_SUCCESS_MARKER} ${lineCount} lines written.`
|
||||
}
|
||||
|
||||
export function createHashlineReadEnhancerHook(
|
||||
|
||||
@@ -84,6 +84,33 @@ describe("hashline-read-enhancer", () => {
|
||||
expect(lines[7]).toBe("</content>")
|
||||
})
|
||||
|
||||
it("keeps OpenCode-truncated lines unhashed while hashifying normal lines", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||
const input = { tool: "read", sessionID: "s", callID: "c" }
|
||||
const truncatedLine = `${"x".repeat(60)}... (line truncated to 2000 chars)`
|
||||
const output = {
|
||||
title: "demo.ts",
|
||||
output: [
|
||||
"<path>/tmp/demo.ts</path>",
|
||||
"<type>file</type>",
|
||||
"<content>",
|
||||
`1: ${truncatedLine}`,
|
||||
"2: normal line",
|
||||
"</content>",
|
||||
].join("\n"),
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
const lines = output.output.split("\n")
|
||||
expect(lines[3]).toBe(`1: ${truncatedLine}`)
|
||||
expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|normal line$/)
|
||||
})
|
||||
|
||||
it("hashifies plain read output without content tags", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||
@@ -164,7 +191,7 @@ describe("hashline-read-enhancer", () => {
|
||||
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
|
||||
})
|
||||
|
||||
it("appends LINE#ID output for write tool using metadata filepath", async () => {
|
||||
it("appends simple summary for write tool instead of full hashlined content", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-write-"))
|
||||
@@ -181,9 +208,55 @@ describe("hashline-read-enhancer", () => {
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toContain("Updated file (LINE#ID|content):")
|
||||
expect(output.output).toMatch(/1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1/)
|
||||
expect(output.output).toMatch(/2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2/)
|
||||
expect(output.output).toContain("File written successfully.")
|
||||
expect(output.output).toContain("2 lines written.")
|
||||
expect(output.output).not.toContain("Updated file (LINE#ID|content):")
|
||||
expect(output.output).not.toContain("const x = 1")
|
||||
|
||||
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("does not overwrite write tool error output with success message", async () => {
|
||||
//#given — write tool failed, but stale file exists from previous write
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-err-"))
|
||||
const filePath = path.join(tempDir, "demo.ts")
|
||||
fs.writeFileSync(filePath, "const x = 1")
|
||||
const input = { tool: "write", sessionID: "s", callID: "c" }
|
||||
const output = {
|
||||
title: "write",
|
||||
output: "Error: EACCES: permission denied, open '" + filePath + "'",
|
||||
metadata: { filepath: filePath },
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then — error output must be preserved, not overwritten with success message
|
||||
expect(output.output).toContain("Error: EACCES")
|
||||
expect(output.output).not.toContain("File written successfully.")
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -7,5 +7,4 @@ export const HASHLINE_DICT = Array.from({ length: 256 }, (_, i) => {
|
||||
})
|
||||
|
||||
export const HASHLINE_REF_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$/
|
||||
export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2}):(.*)$/
|
||||
export const HASHLINE_LEGACY_REF_PATTERN = /^([0-9]+):([0-9a-fA-F]{2,})$/
|
||||
export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})\|(.*)$/
|
||||
|
||||
@@ -80,7 +80,7 @@ export function applyInsertAfter(
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertAnchorEcho(lines[line - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`insert_after requires non-empty text for ${anchor}`)
|
||||
throw new Error(`append (anchored) requires non-empty text for ${anchor}`)
|
||||
}
|
||||
result.splice(line, 0, ...newLines)
|
||||
return result
|
||||
@@ -97,38 +97,12 @@ export function applyInsertBefore(
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertBeforeEcho(lines[line - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`insert_before requires non-empty text for ${anchor}`)
|
||||
throw new Error(`prepend (anchored) requires non-empty text for ${anchor}`)
|
||||
}
|
||||
result.splice(line - 1, 0, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyInsertBetween(
|
||||
lines: string[],
|
||||
afterAnchor: string,
|
||||
beforeAnchor: string,
|
||||
text: string | string[],
|
||||
options?: EditApplyOptions
|
||||
): string[] {
|
||||
if (shouldValidate(options)) {
|
||||
validateLineRef(lines, afterAnchor)
|
||||
validateLineRef(lines, beforeAnchor)
|
||||
}
|
||||
const { line: afterLine } = parseLineRef(afterAnchor)
|
||||
const { line: beforeLine } = parseLineRef(beforeAnchor)
|
||||
if (beforeLine <= afterLine) {
|
||||
throw new Error(`insert_between requires after_line (${afterLine}) < before_line (${beforeLine})`)
|
||||
}
|
||||
|
||||
const result = [...lines]
|
||||
const newLines = stripInsertBoundaryEcho(lines[afterLine - 1], lines[beforeLine - 1], toNewLines(text))
|
||||
if (newLines.length === 0) {
|
||||
throw new Error(`insert_between requires non-empty text for ${afterAnchor}..${beforeAnchor}`)
|
||||
}
|
||||
result.splice(beforeLine - 1, 0, ...newLines)
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyAppend(lines: string[], text: string | string[]): string[] {
|
||||
const normalized = toNewLines(text)
|
||||
if (normalized.length === 0) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { applyHashlineEdits, applyInsertAfter, applyReplaceLines, applySetLine } from "./edit-operations"
|
||||
import { applyAppend, applyInsertBetween, applyPrepend } from "./edit-operation-primitives"
|
||||
import { applyHashlineEdits } from "./edit-operations"
|
||||
import { applyAppend, applyInsertAfter, applyPrepend, applyReplaceLines, applySetLine } from "./edit-operation-primitives"
|
||||
import { computeLineHash } from "./hash-computation"
|
||||
import type { HashlineEdit } from "./types"
|
||||
|
||||
@@ -56,16 +56,6 @@ describe("hashline edit operations", () => {
|
||||
expect(result).toEqual("line 1\nbefore 2\nline 2\nline 3")
|
||||
})
|
||||
|
||||
it("applies insert_between with dual anchors", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
|
||||
//#when
|
||||
const result = applyInsertBetween(lines, anchorFor(lines, 1), anchorFor(lines, 2), ["between"]).join("\n")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("line 1\nbetween\nline 2\nline 3")
|
||||
})
|
||||
|
||||
it("throws when insert_after receives empty text array", () => {
|
||||
//#given
|
||||
@@ -85,13 +75,6 @@ describe("hashline edit operations", () => {
|
||||
).toThrow(/non-empty/i)
|
||||
})
|
||||
|
||||
it("throws when insert_between receives empty text array", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2"]
|
||||
|
||||
//#when / #then
|
||||
expect(() => applyInsertBetween(lines, anchorFor(lines, 1), anchorFor(lines, 2), [])).toThrow(/non-empty/i)
|
||||
})
|
||||
|
||||
it("applies mixed edits in one pass", () => {
|
||||
//#given
|
||||
@@ -109,6 +92,22 @@ describe("hashline edit operations", () => {
|
||||
expect(result).toEqual("line 1\ninserted\nline 2\nmodified")
|
||||
})
|
||||
|
||||
it("applies replace before prepend when both target same line", () => {
|
||||
//#given
|
||||
const content = "line 1\nline 2\nline 3"
|
||||
const lines = content.split("\n")
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "prepend", pos: anchorFor(lines, 2), lines: "before line 2" },
|
||||
{ op: "replace", pos: anchorFor(lines, 2), lines: "modified line 2" },
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = applyHashlineEdits(content, edits)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual("line 1\nbefore line 2\nmodified line 2\nline 3")
|
||||
})
|
||||
|
||||
it("deduplicates identical insert edits in one pass", () => {
|
||||
//#given
|
||||
const content = "line 1\nline 2"
|
||||
@@ -215,15 +214,6 @@ describe("hashline edit operations", () => {
|
||||
expect(result).toEqual(["before", "new 1", "new 2", "after"])
|
||||
})
|
||||
|
||||
it("throws when insert_between payload contains only boundary echoes", () => {
|
||||
//#given
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
|
||||
//#when / #then
|
||||
expect(() => applyInsertBetween(lines, anchorFor(lines, 1), anchorFor(lines, 2), ["line 1", "line 2"])).toThrow(
|
||||
/non-empty/i
|
||||
)
|
||||
})
|
||||
|
||||
it("restores indentation for first replace_lines entry", () => {
|
||||
//#given
|
||||
|
||||
@@ -27,7 +27,13 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
||||
}
|
||||
|
||||
const dedupeResult = dedupeEdits(edits)
|
||||
const sortedEdits = [...dedupeResult.edits].sort((a, b) => getEditLineNumber(b) - getEditLineNumber(a))
|
||||
const EDIT_PRECEDENCE: Record<string, number> = { replace: 0, append: 1, prepend: 2 }
|
||||
const sortedEdits = [...dedupeResult.edits].sort((a, b) => {
|
||||
const lineA = getEditLineNumber(a)
|
||||
const lineB = getEditLineNumber(b)
|
||||
if (lineB !== lineA) return lineB - lineA
|
||||
return (EDIT_PRECEDENCE[a.op] ?? 3) - (EDIT_PRECEDENCE[b.op] ?? 3)
|
||||
})
|
||||
|
||||
let noopEdits = 0
|
||||
|
||||
@@ -87,10 +93,3 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
|
||||
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
|
||||
return applyHashlineEditsWithReport(content, edits).content
|
||||
}
|
||||
|
||||
export {
|
||||
applySetLine,
|
||||
applyReplaceLines,
|
||||
applyInsertAfter,
|
||||
applyInsertBefore,
|
||||
} from "./edit-operation-primitives"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { countLineDiffs, generateUnifiedDiff } from "./diff-utils"
|
||||
import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization"
|
||||
import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits"
|
||||
import type { HashlineEdit } from "./types"
|
||||
import { HashlineMismatchError } from "./validation"
|
||||
|
||||
interface HashlineEditArgs {
|
||||
filePath: string
|
||||
@@ -158,7 +159,7 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
|
||||
return `Updated ${effectivePath}`
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message.toLowerCase().includes("hash")) {
|
||||
if (error instanceof HashlineMismatchError) {
|
||||
return `Error: hash mismatch - ${message}\nTip: reuse LINE#ID entries from the latest read/edit output, or batch related edits in one call.`
|
||||
}
|
||||
return `Error: ${message}`
|
||||
|
||||
@@ -16,9 +16,5 @@ export type {
|
||||
export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants"
|
||||
export {
|
||||
applyHashlineEdits,
|
||||
applyInsertAfter,
|
||||
applyInsertBefore,
|
||||
applyReplaceLines,
|
||||
applySetLine,
|
||||
} from "./edit-operations"
|
||||
export { createHashlineEditTool } from "./tools"
|
||||
|
||||
@@ -103,6 +103,25 @@ describe("createHashlineEditTool", () => {
|
||||
expect(result).toContain(">>>")
|
||||
})
|
||||
|
||||
it("does not classify invalid pos format as hash mismatch", async () => {
|
||||
//#given
|
||||
const filePath = path.join(tempDir, "invalid-format.txt")
|
||||
fs.writeFileSync(filePath, "line1\nline2")
|
||||
|
||||
//#when
|
||||
const result = await tool.execute(
|
||||
{
|
||||
filePath,
|
||||
edits: [{ op: "replace", pos: "42", lines: "updated" }],
|
||||
},
|
||||
createMockContext(),
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(result).toContain("Error")
|
||||
expect(result.toLowerCase()).not.toContain("hash mismatch")
|
||||
})
|
||||
|
||||
it("preserves literal backslash-n and supports string[] payload", async () => {
|
||||
//#given
|
||||
const filePath = path.join(tempDir, "test.txt")
|
||||
|
||||
@@ -19,7 +19,49 @@ describe("parseLineRef", () => {
|
||||
const ref = "42:VK"
|
||||
|
||||
//#when / #then
|
||||
expect(() => parseLineRef(ref)).toThrow("LINE#ID")
|
||||
expect(() => parseLineRef(ref)).toThrow("{line_number}#{hash_id}")
|
||||
})
|
||||
|
||||
it("gives specific hint when literal text is used instead of line number", () => {
|
||||
//#given — model sends "LINE#HK" instead of "1#HK"
|
||||
const ref = "LINE#HK"
|
||||
|
||||
//#when / #then — error should mention that LINE is not a valid number
|
||||
expect(() => parseLineRef(ref)).toThrow(/not a line number/i)
|
||||
})
|
||||
|
||||
it("gives specific hint for other non-numeric prefixes like POS#VK", () => {
|
||||
//#given
|
||||
const ref = "POS#VK"
|
||||
|
||||
//#when / #then
|
||||
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", () => {
|
||||
@@ -32,6 +74,28 @@ describe("parseLineRef", () => {
|
||||
//#then
|
||||
expect(result).toEqual({ line: 42, hash: "VK" })
|
||||
})
|
||||
|
||||
it("accepts refs copied with >>> marker only", () => {
|
||||
//#given
|
||||
const ref = ">>> 42#VK"
|
||||
|
||||
//#when
|
||||
const result = parseLineRef(ref)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({ line: 42, hash: "VK" })
|
||||
})
|
||||
|
||||
it("accepts refs with spaces around hash separator", () => {
|
||||
//#given
|
||||
const ref = "42 # VK"
|
||||
|
||||
//#when
|
||||
const result = parseLineRef(ref)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({ line: 42, hash: "VK" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateLineRef", () => {
|
||||
@@ -60,47 +124,13 @@ describe("validateLineRef", () => {
|
||||
expect(() => validateLineRefs(lines, ["2#ZZ"]))
|
||||
.toThrow(/>>>\s+2#[ZPMQVRWSNKTXJBYH]{2}\|two/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("legacy LINE:HEX backward compatibility", () => {
|
||||
it("parses legacy LINE:HEX ref", () => {
|
||||
//#given
|
||||
const ref = "42:ab"
|
||||
it("suggests correct line number when hash matches a file line", () => {
|
||||
//#given — model sends LINE#XX where XX is the actual hash for line 1
|
||||
const lines = ["function hello() {", " return 42", "}"]
|
||||
const hash = computeLineHash(1, lines[0])
|
||||
|
||||
//#when
|
||||
const result = parseLineRef(ref)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({ line: 42, hash: "ab" })
|
||||
})
|
||||
|
||||
it("parses legacy LINE:HEX ref with uppercase hex", () => {
|
||||
//#given
|
||||
const ref = "10:FF"
|
||||
|
||||
//#when
|
||||
const result = parseLineRef(ref)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({ line: 10, hash: "FF" })
|
||||
})
|
||||
|
||||
it("legacy ref fails validation with hash mismatch, not parse error", () => {
|
||||
//#given
|
||||
const lines = ["function hello() {"]
|
||||
|
||||
//#when / #then
|
||||
expect(() => validateLineRef(lines, "1:ab")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}\|/)
|
||||
})
|
||||
|
||||
it("extracts legacy ref from content with markers", () => {
|
||||
//#given
|
||||
const ref = ">>> 42:ab|const x = 1"
|
||||
|
||||
//#when
|
||||
const result = parseLineRef(ref)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({ line: 42, hash: "ab" })
|
||||
//#when / #then — error should suggest the correct reference
|
||||
expect(() => validateLineRefs(lines, [`LINE#${hash}`])).toThrow(new RegExp(`1#${hash}`))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computeLineHash } from "./hash-computation"
|
||||
import { HASHLINE_REF_PATTERN, HASHLINE_LEGACY_REF_PATTERN } from "./constants"
|
||||
import { HASHLINE_REF_PATTERN } from "./constants"
|
||||
|
||||
export interface LineRef {
|
||||
line: number
|
||||
@@ -13,23 +13,26 @@ interface HashMismatch {
|
||||
|
||||
const MISMATCH_CONTEXT = 2
|
||||
|
||||
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2}|[0-9]+:[0-9a-fA-F]{2,})/
|
||||
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/
|
||||
|
||||
function normalizeLineRef(ref: string): string {
|
||||
const trimmed = ref.trim()
|
||||
const originalTrimmed = ref.trim()
|
||||
let trimmed = originalTrimmed
|
||||
trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "")
|
||||
trimmed = trimmed.replace(/\s*#\s*/, "#")
|
||||
trimmed = trimmed.replace(/\|.*$/, "")
|
||||
trimmed = trimmed.trim()
|
||||
|
||||
if (HASHLINE_REF_PATTERN.test(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
if (HASHLINE_LEGACY_REF_PATTERN.test(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const extracted = trimmed.match(LINE_REF_EXTRACT_PATTERN)
|
||||
if (extracted) {
|
||||
return extracted[1]
|
||||
}
|
||||
|
||||
return trimmed
|
||||
return originalTrimmed
|
||||
}
|
||||
|
||||
export function parseLineRef(ref: string): LineRef {
|
||||
@@ -41,20 +44,25 @@ export function parseLineRef(ref: string): LineRef {
|
||||
hash: match[2],
|
||||
}
|
||||
}
|
||||
const legacyMatch = normalized.match(HASHLINE_LEGACY_REF_PATTERN)
|
||||
if (legacyMatch) {
|
||||
return {
|
||||
line: Number.parseInt(legacyMatch[1], 10),
|
||||
hash: legacyMatch[2],
|
||||
// normalized equals ref.trim() in all error paths — extraction only succeeds for valid refs
|
||||
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#ID" (e.g., "42#VK")`
|
||||
`Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"`
|
||||
)
|
||||
}
|
||||
|
||||
export function validateLineRef(lines: string[], ref: string): void {
|
||||
const { line, hash } = parseLineRef(ref)
|
||||
const { line, hash } = parseLineRefWithHint(ref, lines)
|
||||
|
||||
if (line < 1 || line > lines.length) {
|
||||
throw new Error(
|
||||
@@ -102,7 +110,7 @@ export class HashlineMismatchError extends Error {
|
||||
const output: string[] = []
|
||||
output.push(
|
||||
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. ` +
|
||||
"Use updated LINE#ID references below (>>> marks changed lines)."
|
||||
"Use updated {line_number}#{hash_id} references below (>>> marks changed lines)."
|
||||
)
|
||||
output.push("")
|
||||
|
||||
@@ -127,11 +135,34 @@ export class HashlineMismatchError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function suggestLineForHash(ref: string, lines: string[]): string | null {
|
||||
const hashMatch = ref.trim().match(/#([ZPMQVRWSNKTXJBYH]{2})$/)
|
||||
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}"?`
|
||||
}
|
||||
}
|
||||
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) {
|
||||
const { line, hash } = parseLineRef(ref)
|
||||
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)`)
|
||||
|
||||
Reference in New Issue
Block a user