import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { isAbsolute, join, relative } from "node:path"; import type { KdSourceAnchorRef, KdSourceAnchorSnapshot } from "./types.ts"; export interface SourceAnchorSnapshotInput { path: string; content: string; summary?: string; } export interface SourceAnchorValidation { passed: boolean; reason?: string; checkedRefs: string[]; } export function createSourceAnchorSnapshot(input: SourceAnchorSnapshotInput, sequence: number, now = new Date().toISOString()): KdSourceAnchorSnapshot { const lines = splitLines(input.content); const refs = lines.map((content, index) => ({ line: index + 1, hash: computeLineAnchor(index + 1, content), })); return { id: `S-${String(sequence).padStart(3, "0")}`, path: input.path.trim(), fileHash: computeFileHash(input.content), refs, summary: input.summary?.trim() || undefined, createdAt: now, }; } export function canonicalSourceAnchorPath(cwd: string, path: string): string { const normalizedPath = path.replace(/\\/g, "/").trim(); if (!isAbsolute(path)) return normalizedPath.replace(/^\.\//, ""); const relativePath = relative(cwd, path); const normalizedRelative = relativePath.replace(/\\/g, "/"); if (!normalizedRelative.startsWith("../") && normalizedRelative !== ".." && !/^[a-z]:/i.test(normalizedRelative)) { return normalizedRelative.replace(/^\.\//, ""); } return normalizedPath; } export function formatAnchoredContent(content: string): string { return splitLines(content) .map((line, index) => `${index + 1}#${computeLineAnchor(index + 1, line)}|${line}`) .join("\n"); } export function formatSourceAnchors(snapshots: KdSourceAnchorSnapshot[] | undefined, limit = 8): string { const items = Array.isArray(snapshots) ? snapshots.slice(-Math.max(1, limit)) : []; if (items.length === 0) return "无。"; return items.map((item) => `- ${item.id} ${item.path}:${item.refs.length} anchors,fileHash=${item.fileHash.slice(0, 12)}${item.summary ? `,${item.summary}` : ""}`).join("\n"); } export function validateSourceAnchorSnapshot(cwd: string, snapshot: KdSourceAnchorSnapshot): SourceAnchorValidation { const path = resolvePath(cwd, snapshot.path); if (!existsSync(path)) return { passed: false, reason: `锚点校验失败:文件不存在:${snapshot.path}`, checkedRefs: [] }; const content = readFileSync(path, "utf8"); const currentFileHash = computeFileHash(content); if (currentFileHash === snapshot.fileHash) { return { passed: true, checkedRefs: snapshot.refs.map(formatAnchorRef) }; } const lines = splitLines(content); const stale = snapshot.refs.filter((ref) => lines[ref.line - 1] === undefined || computeLineAnchor(ref.line, lines[ref.line - 1]) !== ref.hash); const checkedRefs = snapshot.refs.map(formatAnchorRef); if (stale.length === 0) { return { passed: false, reason: `锚点校验失败:${snapshot.path} 文件整体哈希变化。必须重新读取锚点后再编辑。`, checkedRefs, }; } return { passed: false, reason: `锚点校验失败:${snapshot.path} 有 ${stale.length} 行已变化:${stale.slice(0, 6).map(formatAnchorRef).join(", ")}。必须重新读取锚点后再编辑。`, checkedRefs, }; } export function latestSourceAnchorForPath(cwd: string, snapshots: KdSourceAnchorSnapshot[] | undefined, path: string): KdSourceAnchorSnapshot | undefined { const normalized = anchorPathKey(cwd, path); return [...(Array.isArray(snapshots) ? snapshots : [])].reverse().find((snapshot) => anchorPathKey(cwd, snapshot.path) === normalized); } export function sanitizeSourceAnchorSnapshot(value: unknown): KdSourceAnchorSnapshot | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const record = value as Record; const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : undefined; const path = typeof record.path === "string" && record.path.trim() ? record.path.trim() : undefined; const fileHash = typeof record.fileHash === "string" && /^[a-f0-9]{64}$/i.test(record.fileHash) ? record.fileHash : undefined; const refs = Array.isArray(record.refs) ? record.refs.flatMap(sanitizeSourceAnchorRef) : []; if (!id || !path || !fileHash || refs.length === 0) return undefined; return { id, path, fileHash, refs, summary: typeof record.summary === "string" && record.summary.trim() ? record.summary.trim() : undefined, createdAt: typeof record.createdAt === "string" ? record.createdAt : new Date().toISOString(), }; } function sanitizeSourceAnchorRef(value: unknown): KdSourceAnchorRef[] { if (!value || typeof value !== "object" || Array.isArray(value)) return []; const record = value as Record; const line = typeof record.line === "number" && Number.isInteger(record.line) && record.line > 0 ? record.line : undefined; const hash = typeof record.hash === "string" && /^[a-f0-9]{8}$/i.test(record.hash) ? record.hash.toLowerCase() : undefined; return line && hash ? [{ line, hash }] : []; } function computeLineAnchor(line: number, content: string): string { const normalized = content.replace(/\r/g, "").trimEnd(); return createHash("sha256").update(`${line}\0${normalized}`, "utf8").digest("hex").slice(0, 8); } function computeFileHash(content: string): string { return createHash("sha256").update(content.replace(/\r\n/g, "\n"), "utf8").digest("hex"); } function splitLines(content: string): string[] { return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); } function formatAnchorRef(ref: KdSourceAnchorRef): string { return `${ref.line}#${ref.hash}`; } function resolvePath(cwd: string, path: string): string { return isAbsolute(path) ? path : join(cwd, path); } function normalizePath(path: string): string { return path.replace(/\\/g, "/").toLowerCase(); } function anchorPathKey(cwd: string, path: string): string { return normalizePath(canonicalSourceAnchorPath(cwd, path)); }