import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import type { ActiveRun } from "./types.js"; import { runArtifactPath, runRoot } from "./paths.js"; export type EvidenceSource = "local-command" | "project-metadata" | "user-provided" | "external-system" | "manual-note"; export interface EvidenceEntry { path: string; kind: string; source?: EvidenceSource; command?: string; exitCode?: number; createdAt: string; updatedAt: string; size: number; } export interface EvidenceIndex { version: 1; entries: EvidenceEntry[]; } export const EVIDENCE_INDEX = "evidence/index.json"; export function evidenceIndexPath(cwd: string, run: ActiveRun): string { return runArtifactPath(cwd, run, EVIDENCE_INDEX); } export function readEvidenceIndex(cwd: string, run: ActiveRun): EvidenceIndex { const path = evidenceIndexPath(cwd, run); if (!existsSync(path)) return { version: 1, entries: [] }; try { const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; if (parsed.version !== 1 || !Array.isArray(parsed.entries)) return { version: 1, entries: [] }; return { version: 1, entries: parsed.entries.filter(isEvidenceEntry) }; } catch { return { version: 1, entries: [] }; } } export function hasEvidenceEntry(cwd: string, run: ActiveRun, path: string): boolean { const normalized = normalizeEvidencePath(path); if (!normalized) return false; return readEvidenceIndex(cwd, run).entries.some((entry) => normalizeEvidencePath(entry.path) === normalized); } export function writeEvidenceFile( cwd: string, run: ActiveRun, path: string, content: string, options: { kind?: string; source?: EvidenceSource; command?: string; exitCode?: number } = {}, ): string { const normalized = normalizeEvidencePath(path); if (!normalized || !normalized.startsWith("evidence/") || normalized === EVIDENCE_INDEX) { throw new Error(`非法 evidence 路径:${path}`); } const absolutePath = runArtifactPath(cwd, run, normalized); mkdirSync(dirname(absolutePath), { recursive: true }); writeFileSync(absolutePath, content.endsWith("\n") ? content : `${content}\n`, "utf8"); recordEvidence(cwd, run, normalized, options); return absolutePath; } export function recordEvidence( cwd: string, run: ActiveRun, path: string, options: { kind?: string; source?: EvidenceSource; command?: string; exitCode?: number } = {}, ): void { const normalized = normalizeEvidencePath(path); if (!normalized) return; const absolutePath = join(runRoot(cwd, run), normalized); if (!existsSync(absolutePath)) return; const now = new Date().toISOString(); const size = statSync(absolutePath).size; const index = readEvidenceIndex(cwd, run); const existing = index.entries.find((entry) => normalizeEvidencePath(entry.path) === normalized); if (existing) { existing.kind = options.kind ?? existing.kind; existing.source = options.source ?? existing.source ?? inferEvidenceSource(options.kind ?? existing.kind, options.command ?? existing.command); existing.command = options.command ?? existing.command; existing.exitCode = options.exitCode ?? existing.exitCode; existing.updatedAt = now; existing.size = size; } else { index.entries.push({ path: normalized, kind: options.kind ?? inferKind(normalized), source: options.source ?? inferEvidenceSource(options.kind ?? inferKind(normalized), options.command), command: options.command, exitCode: options.exitCode, createdAt: now, updatedAt: now, size, }); } const indexPath = evidenceIndexPath(cwd, run); mkdirSync(dirname(indexPath), { recursive: true }); writeFileSync(indexPath, `${JSON.stringify(index, null, 2)}\n`, "utf8"); } function isEvidenceEntry(value: unknown): value is EvidenceEntry { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const entry = value as Partial; return typeof entry.path === "string" && entry.path.trim().length > 0; } export function inferEvidenceSource(kind: string | undefined, command: string | undefined): EvidenceSource { const normalizedKind = (kind ?? "").toLowerCase(); const normalizedCommand = (command ?? "").toLowerCase(); if (/user|manual-business-confirmation|manual-user|用户/.test(normalizedCommand)) return "user-provided"; if (/metadata|data-source|cosmic-metadata|form-metadata/.test(normalizedKind)) return "project-metadata"; if (/external|bos|uat|manual-test|生产|外部/.test(normalizedKind) || /external|bos|uat|生产|外部/.test(normalizedCommand)) return "external-system"; if (normalizedCommand.trim()) return "local-command"; return "manual-note"; } function inferKind(path: string): string { const name = path.split("/").at(-1) ?? path; return name.replace(/\.(md|txt|json)$/i, ""); } function normalizeEvidencePath(path: unknown): string | undefined { if (typeof path !== "string") return undefined; const normalized = path.replace(/\\/g, "/").replace(/^\/+/, "").replace(/^\.\//, ""); return normalized.trim() || undefined; }