feat(config): add experimental.hashline_edit flag and provider state module

This commit is contained in:
YeonGyu-Kim
2026-02-16 15:59:12 +09:00
parent fcf26d9898
commit 149de9da66
12 changed files with 531 additions and 0 deletions

View File

@@ -2830,6 +2830,9 @@
},
"safe_hook_creation": {
"type": "boolean"
},
"hashline_edit": {
"type": "boolean"
}
},
"additionalProperties": false

View File

@@ -663,6 +663,59 @@ describe("ExperimentalConfigSchema feature flags", () => {
expect(result.data.safe_hook_creation).toBeUndefined()
}
})
test("accepts hashline_edit as true", () => {
//#given
const config = { hashline_edit: true }
//#when
const result = ExperimentalConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.hashline_edit).toBe(true)
}
})
test("accepts hashline_edit as false", () => {
//#given
const config = { hashline_edit: false }
//#when
const result = ExperimentalConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.hashline_edit).toBe(false)
}
})
test("hashline_edit is optional", () => {
//#given
const config = { safe_hook_creation: true }
//#when
const result = ExperimentalConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.hashline_edit).toBeUndefined()
}
})
test("rejects non-boolean hashline_edit", () => {
//#given
const config = { hashline_edit: "true" }
//#when
const result = ExperimentalConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
})
describe("GitMasterConfigSchema", () => {

View File

@@ -15,6 +15,8 @@ export const ExperimentalConfigSchema = z.object({
plugin_load_timeout_ms: z.number().min(1000).optional(),
/** Wrap hook creation in try/catch to prevent one failing hook from crashing the plugin (default: true at call site) */
safe_hook_creation: z.boolean().optional(),
/** Enable hashline_edit tool for improved file editing with hash-based line anchors */
hashline_edit: z.boolean().optional(),
})
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>

View File

@@ -0,0 +1,113 @@
import { describe, expect, test, beforeEach } from "bun:test"
import { setProvider, getProvider, clearProvider } from "./hashline-provider-state"
describe("hashline-provider-state", () => {
beforeEach(() => {
// Clear state before each test
clearProvider("test-session-1")
clearProvider("test-session-2")
})
describe("setProvider", () => {
test("should store provider ID for a session", () => {
// given
const sessionID = "test-session-1"
const providerID = "openai"
// when
setProvider(sessionID, providerID)
// then
expect(getProvider(sessionID)).toBe("openai")
})
test("should overwrite existing provider for same session", () => {
// given
const sessionID = "test-session-1"
setProvider(sessionID, "openai")
// when
setProvider(sessionID, "anthropic")
// then
expect(getProvider(sessionID)).toBe("anthropic")
})
})
describe("getProvider", () => {
test("should return undefined for non-existent session", () => {
// given
const sessionID = "non-existent-session"
// when
const result = getProvider(sessionID)
// then
expect(result).toBeUndefined()
})
test("should return stored provider ID", () => {
// given
const sessionID = "test-session-1"
setProvider(sessionID, "anthropic")
// when
const result = getProvider(sessionID)
// then
expect(result).toBe("anthropic")
})
test("should handle multiple sessions independently", () => {
// given
setProvider("session-1", "openai")
setProvider("session-2", "anthropic")
// when
const result1 = getProvider("session-1")
const result2 = getProvider("session-2")
// then
expect(result1).toBe("openai")
expect(result2).toBe("anthropic")
})
})
describe("clearProvider", () => {
test("should remove provider for a session", () => {
// given
const sessionID = "test-session-1"
setProvider(sessionID, "openai")
// when
clearProvider(sessionID)
// then
expect(getProvider(sessionID)).toBeUndefined()
})
test("should not affect other sessions", () => {
// given
setProvider("session-1", "openai")
setProvider("session-2", "anthropic")
// when
clearProvider("session-1")
// then
expect(getProvider("session-1")).toBeUndefined()
expect(getProvider("session-2")).toBe("anthropic")
})
test("should handle clearing non-existent session gracefully", () => {
// given
const sessionID = "non-existent"
// when
clearProvider(sessionID)
// then
expect(getProvider(sessionID)).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,13 @@
const providerStateMap = new Map<string, string>()
export function setProvider(sessionID: string, providerID: string): void {
providerStateMap.set(sessionID, providerID)
}
export function getProvider(sessionID: string): string | undefined {
return providerStateMap.get(sessionID)
}
export function clearProvider(sessionID: string): void {
providerStateMap.delete(sessionID)
}

View File

@@ -0,0 +1,30 @@
export const HASH_DICT = [
"00", "01", "02", "03", "04", "05", "06", "07", "08", "09",
"0a", "0b", "0c", "0d", "0e", "0f", "10", "11", "12", "13",
"14", "15", "16", "17", "18", "19", "1a", "1b", "1c", "1d",
"1e", "1f", "20", "21", "22", "23", "24", "25", "26", "27",
"28", "29", "2a", "2b", "2c", "2d", "2e", "2f", "30", "31",
"32", "33", "34", "35", "36", "37", "38", "39", "3a", "3b",
"3c", "3d", "3e", "3f", "40", "41", "42", "43", "44", "45",
"46", "47", "48", "49", "4a", "4b", "4c", "4d", "4e", "4f",
"50", "51", "52", "53", "54", "55", "56", "57", "58", "59",
"5a", "5b", "5c", "5d", "5e", "5f", "60", "61", "62", "63",
"64", "65", "66", "67", "68", "69", "6a", "6b", "6c", "6d",
"6e", "6f", "70", "71", "72", "73", "74", "75", "76", "77",
"78", "79", "7a", "7b", "7c", "7d", "7e", "7f", "80", "81",
"82", "83", "84", "85", "86", "87", "88", "89", "8a", "8b",
"8c", "8d", "8e", "8f", "90", "91", "92", "93", "94", "95",
"96", "97", "98", "99", "9a", "9b", "9c", "9d", "9e", "9f",
"a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9",
"aa", "ab", "ac", "ad", "ae", "af", "b0", "b1", "b2", "b3",
"b4", "b5", "b6", "b7", "b8", "b9", "ba", "bb", "bc", "bd",
"be", "bf", "c0", "c1", "c2", "c3", "c4", "c5", "c6", "c7",
"c8", "c9", "ca", "cb", "cc", "cd", "ce", "cf", "d0", "d1",
"d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9", "da", "db",
"dc", "dd", "de", "df", "e0", "e1", "e2", "e3", "e4", "e5",
"e6", "e7", "e8", "e9", "ea", "eb", "ec", "ed", "ee", "ef",
"f0", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9",
"fa", "fb", "fc", "fd", "fe", "ff",
] as const
export const HASHLINE_PATTERN = /^(\d+):([0-9a-f]{2})\|(.*)$/

View File

@@ -0,0 +1,123 @@
import { describe, it, expect } from "bun:test"
import { computeLineHash, formatHashLine, formatHashLines } from "./hash-computation"
describe("computeLineHash", () => {
it("returns consistent 2-char hex for same input", () => {
//#given
const lineNumber = 1
const content = "function hello() {"
//#when
const hash1 = computeLineHash(lineNumber, content)
const hash2 = computeLineHash(lineNumber, content)
//#then
expect(hash1).toBe(hash2)
expect(hash1).toMatch(/^[0-9a-f]{2}$/)
})
it("strips whitespace before hashing", () => {
//#given
const lineNumber = 1
const content1 = "function hello() {"
const content2 = " function hello() { "
//#when
const hash1 = computeLineHash(lineNumber, content1)
const hash2 = computeLineHash(lineNumber, content2)
//#then
expect(hash1).toBe(hash2)
})
it("handles empty lines", () => {
//#given
const lineNumber = 1
const content = ""
//#when
const hash = computeLineHash(lineNumber, content)
//#then
expect(hash).toMatch(/^[0-9a-f]{2}$/)
})
it("returns different hashes for different content", () => {
//#given
const lineNumber = 1
const content1 = "function hello() {"
const content2 = "function world() {"
//#when
const hash1 = computeLineHash(lineNumber, content1)
const hash2 = computeLineHash(lineNumber, content2)
//#then
expect(hash1).not.toBe(hash2)
})
})
describe("formatHashLine", () => {
it("formats line with hash prefix", () => {
//#given
const lineNumber = 42
const content = "function hello() {"
//#when
const result = formatHashLine(lineNumber, content)
//#then
expect(result).toMatch(/^42:[0-9a-f]{2}\|function hello\(\) \{$/)
})
it("preserves content after hash prefix", () => {
//#given
const lineNumber = 1
const content = "const x = 42"
//#when
const result = formatHashLine(lineNumber, content)
//#then
expect(result).toContain("|const x = 42")
})
})
describe("formatHashLines", () => {
it("formats all lines with hash prefixes", () => {
//#given
const content = "function hello() {\n return 42\n}"
//#when
const result = formatHashLines(content)
//#then
const lines = result.split("\n")
expect(lines).toHaveLength(3)
expect(lines[0]).toMatch(/^1:[0-9a-f]{2}\|/)
expect(lines[1]).toMatch(/^2:[0-9a-f]{2}\|/)
expect(lines[2]).toMatch(/^3:[0-9a-f]{2}\|/)
})
it("handles empty file", () => {
//#given
const content = ""
//#when
const result = formatHashLines(content)
//#then
expect(result).toBe("")
})
it("handles single line", () => {
//#given
const content = "const x = 42"
//#when
const result = formatHashLines(content)
//#then
expect(result).toMatch(/^1:[0-9a-f]{2}\|const x = 42$/)
})
})

View File

@@ -0,0 +1,19 @@
import { HASH_DICT } from "./constants"
export function computeLineHash(lineNumber: number, content: string): string {
const stripped = content.replace(/\s+/g, "")
const hash = Bun.hash.xxHash32(stripped)
const index = hash % 256
return HASH_DICT[index]
}
export function formatHashLine(lineNumber: number, content: string): string {
const hash = computeLineHash(lineNumber, content)
return `${lineNumber}:${hash}|${content}`
}
export function formatHashLines(content: string): string {
if (!content) return ""
const lines = content.split("\n")
return lines.map((line, index) => formatHashLine(index + 1, line)).join("\n")
}

View File

@@ -0,0 +1,5 @@
export { computeLineHash, formatHashLine, formatHashLines } from "./hash-computation"
export { parseLineRef, validateLineRef } from "./validation"
export type { LineRef } from "./validation"
export type { SetLine, ReplaceLines, InsertAfter, Replace, HashlineEdit } from "./types"
export { HASH_DICT, HASHLINE_PATTERN } from "./constants"

View File

@@ -0,0 +1,26 @@
export interface SetLine {
type: "set_line"
line: string
text: string
}
export interface ReplaceLines {
type: "replace_lines"
start_line: string
end_line: string
text: string
}
export interface InsertAfter {
type: "insert_after"
line: string
text: string
}
export interface Replace {
type: "replace"
old_text: string
new_text: string
}
export type HashlineEdit = SetLine | ReplaceLines | InsertAfter | Replace

View File

@@ -0,0 +1,105 @@
import { describe, it, expect } from "bun:test"
import { parseLineRef, validateLineRef } from "./validation"
describe("parseLineRef", () => {
it("parses valid line reference", () => {
//#given
const ref = "42:a3"
//#when
const result = parseLineRef(ref)
//#then
expect(result).toEqual({ line: 42, hash: "a3" })
})
it("parses line reference with different hash", () => {
//#given
const ref = "1:ff"
//#when
const result = parseLineRef(ref)
//#then
expect(result).toEqual({ line: 1, hash: "ff" })
})
it("throws on invalid format - no colon", () => {
//#given
const ref = "42a3"
//#when & #then
expect(() => parseLineRef(ref)).toThrow()
})
it("throws on invalid format - non-numeric line", () => {
//#given
const ref = "abc:a3"
//#when & #then
expect(() => parseLineRef(ref)).toThrow()
})
it("throws on invalid format - invalid hash", () => {
//#given
const ref = "42:xyz"
//#when & #then
expect(() => parseLineRef(ref)).toThrow()
})
it("throws on empty string", () => {
//#given
const ref = ""
//#when & #then
expect(() => parseLineRef(ref)).toThrow()
})
})
describe("validateLineRef", () => {
it("validates matching hash", () => {
//#given
const lines = ["function hello() {", " return 42", "}"]
const ref = "1:42"
//#when & #then
expect(() => validateLineRef(lines, ref)).not.toThrow()
})
it("throws on hash mismatch", () => {
//#given
const lines = ["function hello() {", " return 42", "}"]
const ref = "1:00" // Wrong hash
//#when & #then
expect(() => validateLineRef(lines, ref)).toThrow()
})
it("throws on line out of bounds", () => {
//#given
const lines = ["function hello() {", " return 42", "}"]
const ref = "99:a3"
//#when & #then
expect(() => validateLineRef(lines, ref)).toThrow()
})
it("throws on invalid line number", () => {
//#given
const lines = ["function hello() {"]
const ref = "0:a3" // Line numbers start at 1
//#when & #then
expect(() => validateLineRef(lines, ref)).toThrow()
})
it("error message includes current hash", () => {
//#given
const lines = ["function hello() {"]
const ref = "1:00"
//#when & #then
expect(() => validateLineRef(lines, ref)).toThrow(/current hash/)
})
})

View File

@@ -0,0 +1,39 @@
import { computeLineHash } from "./hash-computation"
export interface LineRef {
line: number
hash: string
}
export function parseLineRef(ref: string): LineRef {
const match = ref.match(/^(\d+):([0-9a-f]{2})$/)
if (!match) {
throw new Error(
`Invalid line reference format: "${ref}". Expected format: "LINE:HASH" (e.g., "42:a3")`
)
}
return {
line: Number.parseInt(match[1], 10),
hash: match[2],
}
}
export function validateLineRef(lines: string[], ref: string): void {
const { line, hash } = parseLineRef(ref)
if (line < 1 || line > lines.length) {
throw new Error(
`Line number ${line} out of bounds. File has ${lines.length} lines.`
)
}
const content = lines[line - 1]
const currentHash = computeLineHash(line, content)
if (currentHash !== hash) {
throw new Error(
`Hash mismatch at line ${line}. Expected hash: ${hash}, current hash: ${currentHash}. ` +
`Line content may have changed. Current content: "${content}"`
)
}
}