7d181c112e
# Conflicts: # tools/src/hwlab-caserun-closeout.ts # tools/src/hwlab-caserun-runtime.ts
173 lines
10 KiB
TypeScript
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))]; }
|