Files
pikasTech-HWLAB/tools/src/hwlab-caserun-diff.ts
T
root 7d181c112e Merge remote-tracking branch 'origin/v0.3' into feat/caserun-static-audit
# Conflicts:
#	tools/src/hwlab-caserun-closeout.ts
#	tools/src/hwlab-caserun-runtime.ts
2026-07-16 17:12:30 +02:00

173 lines
10 KiB
TypeScript

import { sha256 } from "./hwlab-caserun-evidence.ts";
type CommandResult = { stdout?: unknown } & Record<string, unknown>;
export type AgentDiffFileRecord = {
path: string;
source: "tracked" | "untracked";
reason?: string;
rule?: string;
bytes?: number;
sha256?: string;
};
type DiffCollectionRule = { pattern: string; reason: string };
export type AgentDiffCollectionSummary = {
contractVersion: "hwpod-case-run-diff-collection-v1";
included: AgentDiffFileRecord[];
omitted: AgentDiffFileRecord[];
config: { include: DiffCollectionRule[]; ignore: DiffCollectionRule[] };
tracked: { paths: string[]; diffStat: string; diffPatchSha256: string };
submodules: Array<{ path: string; commit: string; state: "clean" | "modified" | "uninitialized" | "conflicted" }>;
untracked: { total: number; included: number; omitted: number };
};
export async function collectAgentWorkspaceDiff(input: {
definition: Record<string, unknown>;
subjectSubdir: string;
worktreePath: string;
statusShort: string;
trackedDiffStat: string;
trackedPatchText: string;
requestGit: (args: string[]) => Promise<CommandResult>;
requestProcess: (command: string, args: string[]) => Promise<CommandResult>;
}) {
const config = diffCollectionConfig(input.definition);
const roots = statusPaths(input.statusShort, "??");
const requests: Array<{ label: string; result: CommandResult }> = [];
const included: AgentDiffFileRecord[] = [];
const omitted: AgentDiffFileRecord[] = [];
const submoduleStatus = await input.requestGit(["submodule", "status", "--recursive"]);
requests.push({ label: "submodule-status", result: submoduleStatus });
const submodules = parseSubmodules(text(submoduleStatus.stdout));
if (roots.length > 0) {
const lsFiles = await input.requestGit(["ls-files", "--others", "--exclude-standard", "--", ...roots]);
requests.push({ label: "untracked-ls-files", result: lsFiles });
const paths = unique(text(lsFiles.stdout).split(/\r?\n/u).map(repoPath).filter(Boolean));
const metadata = await fileMetadata(input, paths);
requests.push(...metadata.requests);
for (const filePath of paths) {
const decision = classify(filePath, input.subjectSubdir, config);
const record = compact({ path: filePath, source: "untracked" as const, reason: decision.reason, rule: decision.rule, ...metadata.records.get(filePath) });
(decision.omit ? omitted : included).push(record);
}
}
const patches: string[] = [];
const stats: string[] = [];
for (const file of included) {
const stat = await input.requestGit(["diff", "--no-index", "--stat", "--", "/dev/null", file.path]);
const patch = await input.requestGit(["diff", "--no-index", "--binary", "--", "/dev/null", file.path]);
requests.push({ label: `untracked-diff-stat:${file.path}`, result: stat }, { label: `untracked-diff-patch:${file.path}`, result: patch });
stats.push(text(stat.stdout));
patches.push(normalizeNewFilePatch(text(patch.stdout), file.path));
}
const collection: AgentDiffCollectionSummary = {
contractVersion: "hwpod-case-run-diff-collection-v1",
included,
omitted,
config,
tracked: { paths: patchPaths(input.trackedPatchText), diffStat: input.trackedDiffStat, diffPatchSha256: sha256(input.trackedPatchText) },
submodules,
untracked: { total: included.length + omitted.length, included: included.length, omitted: omitted.length }
};
return {
patchText: joinPatch(input.trackedPatchText, patches.join("\n"), omitted),
diffStat: joinStat(input.trackedDiffStat, stats.join("\n"), omitted),
collection,
requests
};
}
function parseSubmodules(value: string) {
return value.split(/\r?\n/u).flatMap((line) => {
const match = line.match(/^([ +\-U])([0-9a-f]{7,64})\s+(.+?)(?:\s+\(.*\))?$/iu);
if (!match) return [];
const marker = match[1];
return [{
path: repoPath(match[3]),
commit: match[2],
state: marker === "-" ? "uninitialized" as const : marker === "+" ? "modified" as const : marker === "U" ? "conflicted" as const : "clean" as const
}];
});
}
async function fileMetadata(input: Parameters<typeof collectAgentWorkspaceDiff>[0], paths: string[]) {
const records = new Map<string, { bytes?: number; sha256?: string }>();
if (paths.length === 0) return { records, requests: [] as Array<{ label: string; result: CommandResult }> };
const script = [
"const fs=require('fs');const path=require('path');const crypto=require('crypto');",
"const root=String(process.argv[1]||'');const files=process.argv.slice(2);",
"const abs=p=>/^[A-Za-z]:[\\\\/]/.test(p)||p.startsWith('/')||p.startsWith('\\\\\\\\');",
"const full=p=>abs(p)?p:(root.includes('\\\\')?path.win32.join(root,p):path.join(root,p));",
"for(const rel of files){try{const buf=fs.readFileSync(full(rel));console.log(JSON.stringify({path:rel,bytes:buf.length,sha256:crypto.createHash('sha256').update(buf).digest('hex')}));}catch(error){console.log(JSON.stringify({path:rel,error:String(error&&error.message||error)}));}}"
].join("");
const result = await input.requestProcess("node", ["-e", script, input.worktreePath, ...paths]);
for (const line of text(result.stdout).split(/\r?\n/u)) {
try {
const value = JSON.parse(line);
const filePath = repoPath(value.path);
if (filePath) records.set(filePath, compact({ bytes: Number.isFinite(value.bytes) ? value.bytes : undefined, sha256: text(value.sha256) }));
} catch {}
}
return { records, requests: [{ label: "untracked-file-metadata", result }] };
}
function diffCollectionConfig(definition: Record<string, unknown>) {
const raw = record(definition.diffCollection ?? definition.agentDiffCollection);
return {
include: rules(raw.include ?? raw.includes, "included by CaseRun diff collection rule"),
ignore: rules(raw.ignore ?? raw.ignores ?? raw.omit ?? raw.omitted, "omitted by CaseRun diff collection rule")
};
}
function rules(value: unknown, defaultReason: string): DiffCollectionRule[] {
if (Array.isArray(value)) return value.flatMap((item) => rules(item, defaultReason));
if (typeof value === "string") return repoPath(value) ? [{ pattern: repoPath(value), reason: defaultReason }] : [];
const raw = record(value);
const pattern = text(raw.pattern ?? raw.path ?? raw.glob);
if (pattern) return [{ pattern: repoPath(pattern), reason: text(raw.reason) || defaultReason }];
return Object.entries(raw).flatMap(([key, item]) => repoPath(key) ? [{ pattern: repoPath(key), reason: typeof item === "string" ? item : text(record(item).reason) || defaultReason }] : []);
}
function classify(filePath: string, subdir: string, config: { include: DiffCollectionRule[]; ignore: DiffCollectionRule[] }) {
const candidates = unique([filePath, filePath.startsWith(`${repoPath(subdir)}/`) ? filePath.slice(repoPath(subdir).length + 1) : filePath]);
const matches = (rule: DiffCollectionRule) => candidates.some((candidate) => globMatch(rule.pattern, candidate));
const include = config.include.find(matches);
const ignore = config.ignore.find(matches);
if (ignore && !include) return { omit: true, reason: ignore.reason, rule: ignore.pattern };
if (include) return { omit: false, reason: include.reason, rule: include.pattern };
return { omit: false, reason: "included by default because untracked files are part of the agent diff", rule: "default-include-untracked" };
}
function globMatch(patternInput: string, pathInput: string) {
const pattern = repoPath(patternInput);
const filePath = repoPath(pathInput);
if (!pattern || !filePath) return false;
if (!/[?*]/u.test(pattern)) return filePath === pattern || filePath.startsWith(`${pattern}/`);
if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) return true;
let source = "";
for (let index = 0; index < pattern.length; index += 1) {
const char = pattern[index];
if (char === "*") { source += pattern[index + 1] === "*" ? ".*" : "[^/]*"; if (pattern[index + 1] === "*") index += 1; }
else if (char === "?") source += "[^/]";
else source += char?.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
return new RegExp(`^${source}${pattern.endsWith("/") ? ".*" : ""}$`, "u").test(filePath);
}
function statusPaths(status: string, marker: string) { return unique(status.split(/\r?\n/u).flatMap((line) => line.startsWith(`${marker} `) ? [repoPath(parseQuoted(line.slice(3)))] : []).filter(Boolean)); }
function parseQuoted(value: string) { const raw = value.trim(); if (!raw.startsWith('"')) return raw; try { return JSON.parse(raw); } catch { return raw.replace(/^"|"$/gu, ""); } }
function patchPaths(value: string) { return unique(text(value).split(/\r?\n/u).flatMap((line) => line.match(/^diff --git a\/(.*?) b\/(.*)$/u)?.[2] ?? []).map(repoPath)); }
function normalizeNewFilePatch(value: string, filePath: string) { const rel = repoPath(filePath); return text(value).replace(/^diff --git a\/dev\/null b\/(.*?)$/mu, `diff --git a/${rel} b/${rel}`).replace(/^--- a\/dev\/null$/mu, "--- /dev/null").replace(/^\+\+\+ b\/(.*?)$/mu, `+++ b/${rel}`); }
function joinPatch(tracked: string, untracked: string, omitted: AgentDiffFileRecord[]) { return [text(tracked), text(untracked), omitted.length ? ["# CaseRun diffCollection omitted files from patch body:", ...omitted.map((file) => `# - ${file.path} rule=${file.rule ?? ""} reason=${file.reason ?? ""} bytes=${file.bytes ?? ""} sha256=${file.sha256 ?? ""}`)].join("\n") : ""].filter(Boolean).join("\n"); }
function joinStat(tracked: string, untracked: string, omitted: AgentDiffFileRecord[]) { return [text(tracked), text(untracked), omitted.length ? ["[omitted by CaseRun diffCollection]", ...omitted.map((file) => ` ${file.path} | omitted rule=${file.rule ?? ""} reason=${file.reason ?? ""} bytes=${file.bytes ?? ""} sha256=${file.sha256 ?? ""}`)].join("\n") : ""].filter(Boolean).join("\n"); }
function repoPath(value: unknown) { return text(value).replace(/\\/gu, "/").replace(/^\.\//u, "").replace(/^\/+|\/+$/gu, ""); }
function text(value: unknown) { return typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim(); }
function record(value: unknown): Record<string, unknown> { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
function compact<T extends Record<string, unknown>>(value: T) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "")) as T; }
function unique(values: string[]) { return [...new Set(values.filter(Boolean))]; }