Compare commits

...

1 Commits

Author SHA1 Message Date
a7a40c5b46 feat(coder): add hashline editing core + wire audit hooks into dispatch pipeline
Hashline editing: content-hash anchors for edit_file stale-patch detection.
Pure-JS xxHash32, line hash computation, validation with HashlineMismatchError,
256-entry hash dictionary. 6 files in apps/coder/src/services/hashline/.

Audit hooks: emitHook('tool.execute.after') wired in frame-emitter.ts for
completed/failed tool results. emitHook('turn.end') wired at terminal points
in dispatcher.ts (all 5 run functions: native, external, opencode, warm ACP,
claude SDK). Fire-and-forget, non-blocking.
2026-06-07 23:17:47 +00:00
8 changed files with 411 additions and 2 deletions

View File

@@ -30,6 +30,7 @@ import {
type TerminalMessageStatus, type TerminalMessageStatus,
} from './finalize-message.js'; } from './finalize-message.js';
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js'; import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
import { emitHook } from '../plugins/host.js';
interface InferenceRunner { interface InferenceRunner {
enqueue: ( enqueue: (
@@ -123,6 +124,22 @@ export function createDispatcher(deps: Deps): {
publishAgentStatus(broker.publishFrame, sessionId, chatId, agent, status, reason); publishAgentStatus(broker.publishFrame, sessionId, chatId, agent, status, reason);
} }
// EmitHook: fire-and-forget turn.end notification. Best-effort — a hook throwing
// is silently swallowed so it never blocks the dispatch flow.
function emitTurnEnd(
sessionId: string,
taskId: string,
state: string,
agent?: string | null,
model?: string | null,
outputSummary?: string,
): void {
void emitHook('turn.end', {
sessionId,
turnSummary: { taskId, state, agent, model: model ?? undefined, outputSummary },
});
}
// F1 (OCE-001/OCE-002): finalize a streaming assistant message into a terminal // F1 (OCE-001/OCE-002): finalize a streaming assistant message into a terminal
// state and publish the matching message_complete frame. Best-effort + idempotent // state and publish the matching message_complete frame. Best-effort + idempotent
// (the helper's `WHERE status='streaming'` guard) — a failure here must never mask // (the helper's `WHERE status='streaming'` guard) — a failure here must never mask
@@ -318,6 +335,7 @@ export function createDispatcher(deps: Deps): {
// Declared before try so the catch block can write it back on the task row. // Declared before try so the catch block can write it back on the task row.
let chatId: string | null = null; let chatId: string | null = null;
let sessionId: string | undefined;
try { try {
// Mark running // Mark running
@@ -330,7 +348,6 @@ export function createDispatcher(deps: Deps): {
// Session setup: reuse a pre-created session (e.g. Q&A arena contestants // Session setup: reuse a pre-created session (e.g. Q&A arena contestants
// whose persona is stamped on the session via agent_id) or create a fresh one. // whose persona is stamped on the session via agent_id) or create a fresh one.
const model = task.model ?? config.DEFAULT_MODEL; const model = task.model ?? config.DEFAULT_MODEL;
let sessionId: string;
if (task.session_id) { if (task.session_id) {
sessionId = task.session_id; sessionId = task.session_id;
} else { } else {
@@ -377,6 +394,7 @@ export function createDispatcher(deps: Deps): {
SET state = 'cancelled', ended_at = clock_timestamp() SET state = 'cancelled', ended_at = clock_timestamp()
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
if (sessionId) emitTurnEnd(sessionId, taskId, 'cancelled', null, task.model);
return; return;
} }
@@ -399,6 +417,7 @@ export function createDispatcher(deps: Deps): {
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)'); log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
emitTurnEnd(sessionId, taskId, 'completed', null, task.model, summary);
} else { } else {
const [msg] = await sql<{ content: string | null }[]>` const [msg] = await sql<{ content: string | null }[]>`
SELECT content FROM messages WHERE id = ${assistantId} SELECT content FROM messages WHERE id = ${assistantId}
@@ -410,6 +429,7 @@ export function createDispatcher(deps: Deps): {
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)'); log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
emitTurnEnd(sessionId, taskId, 'failed', null, task.model, summary);
} }
} catch (err) { } catch (err) {
const errMsg = err instanceof Error ? err.message : String(err); const errMsg = err instanceof Error ? err.message : String(err);
@@ -419,6 +439,7 @@ export function createDispatcher(deps: Deps): {
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}, chat_id = ${chatId} SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}, chat_id = ${chatId}
WHERE id = ${taskId} WHERE id = ${taskId}
`.catch(() => {}); `.catch(() => {});
if (sessionId) emitTurnEnd(sessionId, taskId, 'failed', null, task.model, errMsg);
} }
} }
@@ -684,6 +705,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent); await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`; await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled'); emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
await cleanupWorktree(projectPath, taskId); await cleanupWorktree(projectPath, taskId);
clearTaskCommands(taskId); clearTaskCommands(taskId);
return; return;
@@ -738,6 +760,7 @@ export function createDispatcher(deps: Deps): {
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)'); log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
// #10: external-agent turn completed cleanly. // #10: external-agent turn completed cleanly.
emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete'); emitAgentStatus(sessionId, chatId, agent, 'idle', 'turn_complete');
emitTurnEnd(sessionId, taskId, 'completed', agent, task.model, outputSummary);
clearTaskCommands(taskId); clearTaskCommands(taskId);
} catch (err) { } catch (err) {
@@ -762,6 +785,7 @@ export function createDispatcher(deps: Deps): {
// preceded its assignment — guard so the status publish never masks the real // preceded its assignment — guard so the status publish never masks the real
// error. // error.
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'failed'); if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'failed');
if (sessionId) emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
// Best-effort cleanup // Best-effort cleanup
await cleanupWorktree(projectPath, taskId); await cleanupWorktree(projectPath, taskId);
@@ -1030,6 +1054,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent); await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`; await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled'); emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
clearTaskCommands(taskId); clearTaskCommands(taskId);
return; // worktree persists (no cleanup); backend stays warm return; // worktree persists (no cleanup); backend stays warm
} }
@@ -1090,6 +1115,7 @@ export function createDispatcher(deps: Deps): {
result.ok ? 'idle' : 'error', result.ok ? 'idle' : 'error',
result.ok ? 'turn_complete' : 'failed', result.ok ? 'turn_complete' : 'failed',
); );
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
clearTaskCommands(taskId); clearTaskCommands(taskId);
} catch (err) { } catch (err) {
const errMsg = err instanceof Error ? err.message : String(err); const errMsg = err instanceof Error ? err.message : String(err);
@@ -1104,6 +1130,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, status, task.model); await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
// #10: turn crashed. // #10: turn crashed.
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed'); if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
if (sessionId) emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
clearTaskCommands(taskId); clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn. // No worktree cleanup (persistent); backend stays warm for the next turn.
} }
@@ -1308,6 +1335,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent); await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`; await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled'); emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
clearTaskCommands(taskId); clearTaskCommands(taskId);
return; // worktree persists (no cleanup); backend stays warm return; // worktree persists (no cleanup); backend stays warm
} }
@@ -1367,6 +1395,7 @@ export function createDispatcher(deps: Deps): {
result.ok ? 'idle' : 'error', result.ok ? 'idle' : 'error',
result.ok ? 'turn_complete' : 'failed', result.ok ? 'turn_complete' : 'failed',
); );
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
clearTaskCommands(taskId); clearTaskCommands(taskId);
} catch (err) { } catch (err) {
const errMsg = err instanceof Error ? err.message : String(err); const errMsg = err instanceof Error ? err.message : String(err);
@@ -1381,6 +1410,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, status, task.model); await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
// #10: turn crashed. // #10: turn crashed.
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed'); emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
clearTaskCommands(taskId); clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn. // No worktree cleanup (persistent); backend stays warm for the next turn.
} }
@@ -1576,6 +1606,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent); await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`; await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled'); emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
emitTurnEnd(sessionId, taskId, 'cancelled', agent, task.model);
clearTaskCommands(taskId); clearTaskCommands(taskId);
return; // worktree persists (no cleanup); backend stays warm return; // worktree persists (no cleanup); backend stays warm
} }
@@ -1638,6 +1669,7 @@ export function createDispatcher(deps: Deps): {
result.ok ? 'idle' : 'error', result.ok ? 'idle' : 'error',
result.ok ? 'turn_complete' : 'failed', result.ok ? 'turn_complete' : 'failed',
); );
emitTurnEnd(sessionId, taskId, finalState, agent, task.model, outputSummary);
clearTaskCommands(taskId); clearTaskCommands(taskId);
} catch (err) { } catch (err) {
const errMsg = err instanceof Error ? err.message : String(err); const errMsg = err instanceof Error ? err.message : String(err);
@@ -1652,6 +1684,7 @@ export function createDispatcher(deps: Deps): {
await finalizeMessage(sessionId, chatId, assistantId, status, task.model); await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
// #10: turn crashed. // #10: turn crashed.
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed'); emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
emitTurnEnd(sessionId, taskId, status, agent, task.model, errMsg);
clearTaskCommands(taskId); clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn. // No worktree cleanup (persistent); backend stays warm for the next turn.
} }

View File

@@ -19,9 +19,10 @@
import type { Broker } from '@boocode/server/broker'; import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames'; import type { WsFrame } from '@boocode/contracts/ws-frames';
import type { AgentEvent } from './agent-backend.js'; import type { AgentEvent } from './agent-backend.js';
import { type AcpToolSnapshot, snapshotToWireToolCall } from './acp-tool-snapshot.js'; import { type AcpToolSnapshot, snapshotToWireToolCall, mapToolLifecycleStatus } from './acp-tool-snapshot.js';
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js'; import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
import type { DcpStreamStripper } from './dcp-strip.js'; import type { DcpStreamStripper } from './dcp-strip.js';
import { emitHook } from '../plugins/host.js';
export interface FrameEmitterOpts { export interface FrameEmitterOpts {
broker?: Broker; broker?: Broker;
@@ -91,8 +92,29 @@ export function makeFrameEmitter(opts: FrameEmitterOpts): FrameEmitter {
} }
break; break;
case 'tool_call': case 'tool_call':
toolSnapshots.set(e.toolCall.toolCallId, e.toolCall);
if (canStream()) {
broker!.publishFrame(sessionId!, {
type: 'tool_call',
message_id: assistantId!,
chat_id: chatId!,
tool_call: snapshotToWireToolCall(e.toolCall),
} as WsFrame);
}
break;
case 'tool_update': case 'tool_update':
toolSnapshots.set(e.toolCall.toolCallId, e.toolCall); toolSnapshots.set(e.toolCall.toolCallId, e.toolCall);
{
const lifecycle = mapToolLifecycleStatus(e.toolCall.status, e.toolCall.rawOutput);
if (lifecycle === 'completed' || lifecycle === 'failed') {
void emitHook('tool.execute.after', {
toolName: e.toolCall.title,
args: e.toolCall.rawInput,
result: e.toolCall.rawOutput,
duration: undefined,
});
}
}
if (canStream()) { if (canStream()) {
broker!.publishFrame(sessionId!, { broker!.publishFrame(sessionId!, {
type: 'tool_call', type: 'tool_call',

View File

@@ -0,0 +1,10 @@
export const NIBBLE_STR = "ZPMQVRWSNKTXJBYH"
export const HASHLINE_DICT = Array.from({ length: 256 }, (_, i) => {
const high = i >>> 4
const low = i & 0x0f
return `${NIBBLE_STR[high]}${NIBBLE_STR[low]}`
})
export const HASHLINE_REF_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$/
export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})\|(.*)$/

View File

@@ -0,0 +1,31 @@
import { HASHLINE_DICT } from "./constants.js"
import { hashXxh32 } from "./xxhash32.js"
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u
function computeNormalizedLineHash(lineNumber: number, normalizedContent: string): string {
const stripped = normalizedContent
const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber
const hash = hashXxh32(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, "").replace(/\s+/g, ""))
}
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,11 @@
/**
* Hashline editing core — content-hash anchors for edit_file stale-patch detection.
*
* Ported from oh-my-openagent/packages/hashline-core/.
* Bundles a runtime-aware xxHash32 (Bun fast-path, pure-JS fallback).
*/
export { computeLineHash, formatHashLines, formatHashLine, computeLegacyLineHash } from "./hash-computation.js"
export { parseLineRef, validateLineRef, validateLineRefs, HashlineMismatchError, normalizeLineRef } from "./validation.js"
export type { LineRef } from "./validation.js"
export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants.js"
export type { ReplaceEdit, AppendEdit, PrependEdit, HashlineEdit } from "./types.js"

View File

@@ -0,0 +1,20 @@
export interface ReplaceEdit {
op: "replace"
pos: string
end?: string
lines: string | string[]
}
export interface AppendEdit {
op: "append"
pos?: string
lines: string | string[]
}
export interface PrependEdit {
op: "prepend"
pos?: string
lines: string | string[]
}
export type HashlineEdit = ReplaceEdit | AppendEdit | PrependEdit

View File

@@ -0,0 +1,192 @@
import { computeLegacyLineHash, computeLineHash } from "./hash-computation.js"
import { HASHLINE_REF_PATTERN } from "./constants.js"
export interface LineRef {
line: number
hash: string
}
interface HashMismatch {
line: number
expected: string
}
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
trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "")
trimmed = trimmed.replace(/\s*#\s*/, "#")
trimmed = trimmed.replace(/\|.*$/, "")
trimmed = trimmed.trim()
if (HASHLINE_REF_PATTERN.test(trimmed)) {
return trimmed
}
const extracted = trimmed.match(LINE_REF_EXTRACT_PATTERN)
if (extracted) {
return extracted[1]!
}
return originalTrimmed
}
export function parseLineRef(ref: string): LineRef {
const normalized = normalizeLineRef(ref)
const match = normalized.match(HASHLINE_REF_PATTERN)
if (match) {
return {
line: Number.parseInt(match[1]!, 10),
hash: match[2]!,
}
}
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}"`
)
}
export function validateLineRef(lines: string[], ref: string): void {
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.`
)
}
const content = lines[line - 1]
if (content === undefined) {
throw new Error(
`Line number ${line} out of bounds. File has ${lines.length} lines.`
)
}
if (!isCompatibleLineHash(line, content, hash)) {
throw new HashlineMismatchError([{ line, expected: hash }], lines)
}
}
export class HashlineMismatchError extends Error {
readonly remaps: ReadonlyMap<string, string>
constructor(
private readonly mismatches: HashMismatch[],
private readonly fileLines: string[]
) {
super(HashlineMismatchError.formatMessage(mismatches, fileLines))
this.name = "HashlineMismatchError"
const remaps = new Map<string, string>()
for (const mismatch of mismatches) {
const content = fileLines[mismatch.line - 1]
const actualLine = content ?? ""
const actual = computeLineHash(mismatch.line, actualLine)
remaps.set(`${mismatch.line}#${mismatch.expected}`, `${mismatch.line}#${actual}`)
}
this.remaps = remaps
}
static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
const mismatchByLine = new Map<number, HashMismatch>()
for (const mismatch of mismatches) mismatchByLine.set(mismatch.line, mismatch)
const displayLines = new Set<number>()
for (const mismatch of mismatches) {
const low = Math.max(1, mismatch.line - MISMATCH_CONTEXT)
const high = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT)
for (let line = low; line <= high; line++) displayLines.add(line)
}
const sortedLines = [...displayLines].sort((a, b) => a - b)
const output: string[] = []
output.push(
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. ` +
"Use updated {line_number}#{hash_id} references below (>>> marks changed lines)."
)
output.push("")
let previousLine = -1
for (const line of sortedLines) {
if (previousLine !== -1 && line > previousLine + 1) {
output.push(" ...")
}
previousLine = line
const content = fileLines[line - 1] ?? ""
const hash = computeLineHash(line, content)
const prefix = `${line}#${hash}|${content}`
if (mismatchByLine.has(line)) {
output.push(`>>> ${prefix}`)
} else {
output.push(` ${prefix}`)
}
}
return output.join("\n")
}
}
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 (isCompatibleLineHash(i + 1, lines[i] ?? "", hash)) {
return `Did you mean "${i + 1}#${computeLineHash(i + 1, lines[i] ?? "")}"?`
}
}
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 } = parseLineRefWithHint(ref, lines)
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]
if (content === undefined) {
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)
}
if (!isCompatibleLineHash(line, content, hash)) {
mismatches.push({ line, expected: hash })
}
}
if (mismatches.length > 0) {
throw new HashlineMismatchError(mismatches, lines)
}
}

View File

@@ -0,0 +1,90 @@
type BunHashRuntime = { hash: { xxHash32(data: string | Uint8Array, seed: number): number } }
const runtime = globalThis as typeof globalThis & { Bun?: BunHashRuntime }
const encoder = new TextEncoder()
const PRIME32_1 = 0x9e3779b1
const PRIME32_2 = 0x85ebca77
const PRIME32_3 = 0xc2b2ae3d
const PRIME32_4 = 0x27d4eb2f
const PRIME32_5 = 0x165667b1
function rotateLeft32(value: number, bits: number): number {
return ((value << bits) | (value >>> (32 - bits))) >>> 0
}
function readUint32LittleEndian(input: Uint8Array, offset: number): number {
return (
((input[offset] ?? 0) |
((input[offset + 1] ?? 0) << 8) |
((input[offset + 2] ?? 0) << 16) |
((input[offset + 3] ?? 0) << 24)) >>>
0
)
}
function round32(accumulator: number, value: number): number {
const added = (accumulator + Math.imul(value, PRIME32_2)) >>> 0
return Math.imul(rotateLeft32(added, 13), PRIME32_1) >>> 0
}
function xxHash32Js(input: Uint8Array, seed: number): number {
let offset = 0
const length = input.length
let hash: number
if (length >= 16) {
const limit = length - 16
let value1 = (seed + PRIME32_1 + PRIME32_2) >>> 0
let value2 = (seed + PRIME32_2) >>> 0
let value3 = seed >>> 0
let value4 = (seed - PRIME32_1) >>> 0
while (offset <= limit) {
value1 = round32(value1, readUint32LittleEndian(input, offset))
offset += 4
value2 = round32(value2, readUint32LittleEndian(input, offset))
offset += 4
value3 = round32(value3, readUint32LittleEndian(input, offset))
offset += 4
value4 = round32(value4, readUint32LittleEndian(input, offset))
offset += 4
}
hash = (rotateLeft32(value1, 1) + rotateLeft32(value2, 7)) >>> 0
hash = (hash + rotateLeft32(value3, 12)) >>> 0
hash = (hash + rotateLeft32(value4, 18)) >>> 0
} else {
hash = (seed + PRIME32_5) >>> 0
}
hash = (hash + length) >>> 0
while (offset + 4 <= length) {
hash = (hash + Math.imul(readUint32LittleEndian(input, offset), PRIME32_3)) >>> 0
hash = Math.imul(rotateLeft32(hash, 17), PRIME32_4) >>> 0
offset += 4
}
while (offset < length) {
hash = (hash + Math.imul(input[offset] ?? 0, PRIME32_5)) >>> 0
hash = Math.imul(rotateLeft32(hash, 11), PRIME32_1) >>> 0
offset += 1
}
hash = (hash ^ (hash >>> 15)) >>> 0
hash = Math.imul(hash, PRIME32_2) >>> 0
hash = (hash ^ (hash >>> 13)) >>> 0
hash = Math.imul(hash, PRIME32_3) >>> 0
return (hash ^ (hash >>> 16)) >>> 0
}
export function hashXxh32(input: string, seed: number): number {
const bun = runtime.Bun
if (bun !== undefined) {
return bun.hash.xxHash32(input, seed)
}
return xxHash32Js(encoder.encode(input), seed >>> 0)
}