Merge pull request #2578 from pikasTech/feat/caserun-static-audit
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
CaseRun 模块单命令静态审计入口
This commit is contained in:
@@ -5,4 +5,7 @@ const argv = process.argv.slice(2);
|
||||
if (argv[0] === "kafka") {
|
||||
const { mainKafkaCli } = await import("../../src/hwlab-cli/kafka-regenerate.ts");
|
||||
await mainKafkaCli(argv.slice(1));
|
||||
} else if (argv[0] === "case" && argv[1] === "audit") {
|
||||
const { mainCaseRunAuditCli } = await import("../../src/hwlab-caserun-audit.ts");
|
||||
await mainCaseRunAuditCli(argv.slice(2));
|
||||
} else await main(argv);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { auditCaseRunSources, renderCaseRunAuditText } from "../src/hwlab-caserun-audit.ts";
|
||||
|
||||
test("CaseRun static audit passes clean source modules", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "caserun-audit-clean-"));
|
||||
const sourceDir = path.join(root, "tools", "src");
|
||||
await mkdir(sourceDir, { recursive: true });
|
||||
await writeFile(path.join(sourceDir, "hwlab-caserun-clean.ts"), "export const value: string = 'ok';\n", "utf8");
|
||||
|
||||
const report = await auditCaseRunSources({ root });
|
||||
assert.equal(report.ok, true, renderCaseRunAuditText(report));
|
||||
assert.equal(report.status, "passed");
|
||||
assert.equal(report.findingCount, 0);
|
||||
});
|
||||
|
||||
test("CaseRun static audit returns typed findings for representative source defects", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "caserun-audit-"));
|
||||
const sourceDir = path.join(root, "tools", "src");
|
||||
await mkdir(sourceDir, { recursive: true });
|
||||
await writeFile(path.join(sourceDir, "hwlab-caserun-runtime.ts"), [
|
||||
"import { helper } from './hwlab-caserun-helper.ts';",
|
||||
"const first = 1; const second = 2;",
|
||||
"export type MissingAlias = MissingCaseRunType;",
|
||||
"export const incompatibleValue: string = first;",
|
||||
"export const missingProperty = ({ value: first }).missing;",
|
||||
"export const runtimeValue = helper(first + second);",
|
||||
].join("\n"), "utf8");
|
||||
await writeFile(path.join(sourceDir, "hwlab-caserun-helper.ts"), [
|
||||
"import { runtimeValue } from './hwlab-caserun-runtime.ts';",
|
||||
"function duplicate(value: string) { return String(value).trim(); }",
|
||||
"export function helper(value: number) { return duplicate(runtimeValue + value); }",
|
||||
].join("\n"), "utf8");
|
||||
await writeFile(path.join(sourceDir, "hwlab-caserun-other.ts"), [
|
||||
"function duplicate(value: string) { return String(value).trim(); }",
|
||||
...Array.from({ length: 20 }, (_, index) => `export const line${index} = ${index};`),
|
||||
].join("\n"), "utf8");
|
||||
|
||||
const report = await auditCaseRunSources({ root, maxLines: 10 });
|
||||
const codes = new Set(report.findings.map((finding) => finding.code));
|
||||
assert.equal(report.ok, false);
|
||||
assert.equal(report.status, "failed");
|
||||
assert.ok(codes.has("caserun_type_ts2304"));
|
||||
assert.ok(codes.has("caserun_type_ts2322"));
|
||||
assert.ok(codes.has("caserun_type_ts2339"));
|
||||
assert.ok(codes.has("caserun_runtime_reverse_dependency"));
|
||||
assert.ok(codes.has("caserun_dependency_cycle"));
|
||||
assert.ok(codes.has("caserun_duplicate_pure_helper"));
|
||||
assert.ok(codes.has("caserun_compressed_top_level_declaration"));
|
||||
assert.ok(codes.has("caserun_source_line_limit_exceeded"));
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
// Responsibility: bounded, read-only static audit for CaseRun TypeScript source modules.
|
||||
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
|
||||
const DEFAULT_SOURCE_DIR = "tools/src";
|
||||
const DEFAULT_MAX_LINES = 2000;
|
||||
const MAX_TEXT_FINDINGS = 20;
|
||||
const MAX_JSON_FINDINGS = 100;
|
||||
|
||||
type AuditCategory = "type_check" | "dependency" | "duplicate_helper" | "compressed_declaration" | "line_count";
|
||||
|
||||
export type CaseRunAuditFinding = {
|
||||
code: string;
|
||||
category: AuditCategory;
|
||||
file: string;
|
||||
line: number;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CaseRunAuditReport = {
|
||||
ok: boolean;
|
||||
action: "case.audit";
|
||||
status: "passed" | "failed";
|
||||
scope: {
|
||||
root: string;
|
||||
sourceDir: string;
|
||||
fileCount: number;
|
||||
maxLines: number;
|
||||
};
|
||||
checks: Record<AuditCategory, { status: "passed" | "failed"; findingCount: number }>;
|
||||
findings: CaseRunAuditFinding[];
|
||||
findingCount: number;
|
||||
findingsTruncated: boolean;
|
||||
};
|
||||
|
||||
type SourceModule = {
|
||||
absolutePath: string;
|
||||
relativePath: string;
|
||||
sourceFile: ts.SourceFile;
|
||||
text: string;
|
||||
lineCount: number;
|
||||
};
|
||||
|
||||
type AuditOptions = {
|
||||
root?: string;
|
||||
sourceDir?: string;
|
||||
maxLines?: number;
|
||||
maxFindings?: number;
|
||||
};
|
||||
|
||||
export async function mainCaseRunAuditCli(argv = process.argv.slice(2)) {
|
||||
const json = argv.includes("--json");
|
||||
try {
|
||||
const parsed = parseAuditArgs(argv);
|
||||
if (parsed.help) {
|
||||
process.stdout.write(`${renderCaseRunAuditHelp()}\n`);
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
const report = await auditCaseRunSources({
|
||||
root: parsed.root,
|
||||
sourceDir: parsed.sourceDir,
|
||||
maxLines: parsed.maxLines,
|
||||
maxFindings: json ? MAX_JSON_FINDINGS : MAX_TEXT_FINDINGS,
|
||||
});
|
||||
process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : `${renderCaseRunAuditText(report)}\n`);
|
||||
process.exitCode = report.ok ? 0 : 2;
|
||||
} catch (error) {
|
||||
const failure = auditFailure(error);
|
||||
process.stdout.write(json ? `${JSON.stringify(failure, null, 2)}\n` : `${renderAuditFailureText(failure)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function auditCaseRunSources(options: AuditOptions = {}): Promise<CaseRunAuditReport> {
|
||||
const root = path.resolve(options.root ?? process.cwd());
|
||||
const sourceDir = normalizeSourceDir(options.sourceDir ?? DEFAULT_SOURCE_DIR);
|
||||
const maxLines = positiveInteger(options.maxLines ?? DEFAULT_MAX_LINES, "maxLines");
|
||||
const maxFindings = positiveInteger(options.maxFindings ?? MAX_JSON_FINDINGS, "maxFindings");
|
||||
const modules = await loadSourceModules(root, sourceDir);
|
||||
if (modules.length === 0) throw cliError("caserun_audit_scope_empty", "no CaseRun TypeScript source modules were found", { root, sourceDir });
|
||||
|
||||
const findings = [
|
||||
...typeCheckFindings(modules),
|
||||
...dependencyFindings(modules),
|
||||
...duplicateHelperFindings(modules),
|
||||
...compressedDeclarationFindings(modules),
|
||||
...lineCountFindings(modules, maxLines),
|
||||
].sort(compareFindings);
|
||||
const boundedFindings = findings.slice(0, maxFindings);
|
||||
const checks = checkSummary(findings);
|
||||
return {
|
||||
ok: findings.length === 0,
|
||||
action: "case.audit",
|
||||
status: findings.length === 0 ? "passed" : "failed",
|
||||
scope: { root, sourceDir, fileCount: modules.length, maxLines },
|
||||
checks,
|
||||
findings: boundedFindings,
|
||||
findingCount: findings.length,
|
||||
findingsTruncated: boundedFindings.length !== findings.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderCaseRunAuditText(report: CaseRunAuditReport) {
|
||||
const checkOrder: AuditCategory[] = ["type_check", "dependency", "duplicate_helper", "compressed_declaration", "line_count"];
|
||||
const lines = [
|
||||
`CaseRun static audit: ${report.status}`,
|
||||
`scope: ${report.scope.sourceDir} files=${report.scope.fileCount} maxLines=${report.scope.maxLines}`,
|
||||
...checkOrder.map((category) => `${category}: ${report.checks[category].status} findings=${report.checks[category].findingCount}`),
|
||||
];
|
||||
for (const finding of report.findings) lines.push(`${finding.code} ${finding.file}:${finding.line} ${finding.message}`);
|
||||
if (report.findingsTruncated) lines.push(`findings: showing=${report.findings.length} total=${report.findingCount}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderCaseRunAuditHelp() {
|
||||
return [
|
||||
"CaseRun static audit",
|
||||
"usage: hwlab-cli case audit [--json] [--root PATH] [--source-dir PATH] [--max-lines N]",
|
||||
"exit codes: 0 passed, 1 command failure, 2 audit findings",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function loadSourceModules(root: string, sourceDir: string) {
|
||||
const absoluteSourceDir = path.resolve(root, sourceDir);
|
||||
const entries = await readdir(absoluteSourceDir, { withFileTypes: true });
|
||||
const fileNames = entries
|
||||
.filter((entry) => entry.isFile() && /^hwlab-caserun-.*\.ts$/u.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
return Promise.all(fileNames.map(async (fileName): Promise<SourceModule> => {
|
||||
const absolutePath = path.join(absoluteSourceDir, fileName);
|
||||
const text = await readFile(absolutePath, "utf8");
|
||||
return {
|
||||
absolutePath,
|
||||
relativePath: repoRelative(root, absolutePath),
|
||||
sourceFile: ts.createSourceFile(absolutePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
|
||||
text,
|
||||
lineCount: text.length === 0 ? 0 : text.split(/\r?\n/u).length,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
function typeCheckFindings(modules: SourceModule[]) {
|
||||
const moduleByPath = new Map(modules.map((module) => [path.resolve(module.absolutePath), module]));
|
||||
const program = ts.createProgram([...moduleByPath.keys()], {
|
||||
allowImportingTsExtensions: true,
|
||||
allowJs: false,
|
||||
module: ts.ModuleKind.ESNext,
|
||||
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
||||
noEmit: true,
|
||||
skipLibCheck: true,
|
||||
strict: false,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
});
|
||||
return ts.getPreEmitDiagnostics(program)
|
||||
.filter((diagnostic) => diagnostic.file && moduleByPath.has(path.resolve(diagnostic.file.fileName)))
|
||||
.filter(isActionableDiagnostic)
|
||||
.map((diagnostic): CaseRunAuditFinding => {
|
||||
const module = moduleByPath.get(path.resolve(diagnostic.file!.fileName))!;
|
||||
const line = diagnostic.start === undefined ? 1 : diagnostic.file!.getLineAndCharacterOfPosition(diagnostic.start).line + 1;
|
||||
return {
|
||||
code: `caserun_type_ts${diagnostic.code}`,
|
||||
category: "type_check",
|
||||
file: module.relativePath,
|
||||
line,
|
||||
message: ts.flattenDiagnosticMessageText(diagnostic.messageText, " "),
|
||||
details: { diagnosticCode: diagnostic.code },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function isActionableDiagnostic(diagnostic: ts.Diagnostic) {
|
||||
if (diagnostic.category !== ts.DiagnosticCategory.Error) return false;
|
||||
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, " ");
|
||||
if (diagnostic.code === 2307 && /Cannot find module '(node:|typescript|kafkajs|yaml|pg|playwright)/u.test(message)) return false;
|
||||
if ([2580, 2591].includes(diagnostic.code) && /Cannot find name '(Buffer|process)'/u.test(message)) return false;
|
||||
if (diagnostic.code === 2552 && /Cannot find name 'Buffer'/u.test(message)) return false;
|
||||
if ([2867, 2868].includes(diagnostic.code) && /Cannot find name 'Bun'/u.test(message)) return false;
|
||||
if (diagnostic.code === 2503 && /Cannot find namespace 'NodeJS'/u.test(message)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function dependencyFindings(modules: SourceModule[]) {
|
||||
const byAbsolutePath = new Map(modules.map((module) => [path.resolve(module.absolutePath), module]));
|
||||
const edges = new Map(modules.map((module) => [module.absolutePath, runtimeDependencies(module, byAbsolutePath)]));
|
||||
const findings: CaseRunAuditFinding[] = [];
|
||||
for (const [from, targets] of edges) {
|
||||
for (const target of targets) {
|
||||
if (path.basename(target) !== "hwlab-caserun-runtime.ts" || path.basename(from) === "hwlab-caserun-lib.ts") continue;
|
||||
const module = byAbsolutePath.get(path.resolve(from))!;
|
||||
findings.push({
|
||||
code: "caserun_runtime_reverse_dependency",
|
||||
category: "dependency",
|
||||
file: module.relativePath,
|
||||
line: importLineFor(module, target),
|
||||
message: "CaseRun source module must not depend on the runtime orchestrator",
|
||||
details: { target: byAbsolutePath.get(path.resolve(target))?.relativePath ?? target },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const cycle of dependencyCycles(edges)) {
|
||||
const first = byAbsolutePath.get(path.resolve(cycle[0]))!;
|
||||
findings.push({
|
||||
code: "caserun_dependency_cycle",
|
||||
category: "dependency",
|
||||
file: first.relativePath,
|
||||
line: 1,
|
||||
message: `runtime dependency cycle: ${cycle.map((item) => path.basename(item)).join(" -> ")}`,
|
||||
details: { cycle: cycle.map((item) => byAbsolutePath.get(path.resolve(item))?.relativePath ?? item) },
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function runtimeDependencies(module: SourceModule, modules: Map<string, SourceModule>) {
|
||||
const dependencies = new Set<string>();
|
||||
for (const statement of module.sourceFile.statements) {
|
||||
if (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) continue;
|
||||
if (!statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
||||
if (isTypeOnlyDependency(statement)) continue;
|
||||
const specifier = statement.moduleSpecifier.text;
|
||||
if (!specifier.startsWith(".")) continue;
|
||||
const resolved = resolveModulePath(module.absolutePath, specifier);
|
||||
if (resolved && modules.has(path.resolve(resolved))) dependencies.add(path.resolve(resolved));
|
||||
}
|
||||
return [...dependencies].sort();
|
||||
}
|
||||
|
||||
function isTypeOnlyDependency(statement: ts.ImportDeclaration | ts.ExportDeclaration) {
|
||||
if (ts.isExportDeclaration(statement)) return statement.isTypeOnly;
|
||||
const clause = statement.importClause;
|
||||
if (!clause) return false;
|
||||
if (clause.isTypeOnly) return true;
|
||||
if (clause.name) return false;
|
||||
const bindings = clause.namedBindings;
|
||||
return Boolean(bindings && ts.isNamedImports(bindings) && bindings.elements.length > 0 && bindings.elements.every((element) => element.isTypeOnly));
|
||||
}
|
||||
|
||||
function resolveModulePath(sourcePath: string, specifier: string) {
|
||||
const candidate = path.resolve(path.dirname(sourcePath), specifier);
|
||||
if (path.extname(candidate)) return candidate;
|
||||
return `${candidate}.ts`;
|
||||
}
|
||||
|
||||
function importLineFor(module: SourceModule, target: string) {
|
||||
for (const statement of module.sourceFile.statements) {
|
||||
if ((!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || !statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
||||
if (path.resolve(resolveModulePath(module.absolutePath, statement.moduleSpecifier.text) ?? "") === path.resolve(target)) return lineOf(module.sourceFile, statement);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function dependencyCycles(edges: Map<string, string[]>) {
|
||||
const cycles = new Map<string, string[]>();
|
||||
const visiting = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
const stack: string[] = [];
|
||||
const visit = (node: string) => {
|
||||
if (visiting.has(node)) {
|
||||
const start = stack.indexOf(node);
|
||||
const cycle = [...stack.slice(start), node];
|
||||
const key = canonicalCycle(cycle);
|
||||
cycles.set(key, cycle);
|
||||
return;
|
||||
}
|
||||
if (visited.has(node)) return;
|
||||
visiting.add(node);
|
||||
stack.push(node);
|
||||
for (const target of edges.get(node) ?? []) visit(target);
|
||||
stack.pop();
|
||||
visiting.delete(node);
|
||||
visited.add(node);
|
||||
};
|
||||
for (const node of edges.keys()) visit(node);
|
||||
return [...cycles.values()];
|
||||
}
|
||||
|
||||
function canonicalCycle(cycle: string[]) {
|
||||
const nodes = cycle.slice(0, -1).map((item) => path.basename(item));
|
||||
const rotations = nodes.map((_, index) => [...nodes.slice(index), ...nodes.slice(0, index)].join("|"));
|
||||
return rotations.sort()[0] ?? "";
|
||||
}
|
||||
|
||||
function duplicateHelperFindings(modules: SourceModule[]) {
|
||||
const helpers = new Map<string, { module: SourceModule; node: ts.FunctionDeclaration; fingerprint: string }[]>();
|
||||
for (const module of modules) {
|
||||
for (const statement of module.sourceFile.statements) {
|
||||
if (!ts.isFunctionDeclaration(statement) || !statement.name || !statement.body || !isPureHelper(statement)) continue;
|
||||
const name = statement.name.text;
|
||||
const entries = helpers.get(name) ?? [];
|
||||
entries.push({ module, node: statement, fingerprint: helperFingerprint(statement, module.sourceFile) });
|
||||
helpers.set(name, entries);
|
||||
}
|
||||
}
|
||||
const findings: CaseRunAuditFinding[] = [];
|
||||
for (const [name, entries] of helpers) {
|
||||
const byFingerprint = new Map<string, typeof entries>();
|
||||
for (const entry of entries) {
|
||||
const matches = byFingerprint.get(entry.fingerprint) ?? [];
|
||||
matches.push(entry);
|
||||
byFingerprint.set(entry.fingerprint, matches);
|
||||
}
|
||||
for (const matches of byFingerprint.values()) {
|
||||
if (matches.length < 2) continue;
|
||||
for (const entry of matches) findings.push({
|
||||
code: "caserun_duplicate_pure_helper",
|
||||
category: "duplicate_helper",
|
||||
file: entry.module.relativePath,
|
||||
line: lineOf(entry.module.sourceFile, entry.node),
|
||||
message: `duplicate top-level pure helper: ${name}`,
|
||||
details: { declarations: matches.map((match) => `${match.module.relativePath}:${lineOf(match.module.sourceFile, match.node)}`) },
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function isPureHelper(node: ts.FunctionDeclaration) {
|
||||
if (node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) || node.asteriskToken) return false;
|
||||
let impure = false;
|
||||
const visit = (child: ts.Node) => {
|
||||
if (ts.isAwaitExpression(child) || ts.isYieldExpression(child) || ts.isThrowStatement(child) || ts.isNewExpression(child) || ts.isDeleteExpression(child)) impure = true;
|
||||
if (ts.isBinaryExpression(child) && isAssignmentOperatorKind(child.operatorToken.kind)) impure = true;
|
||||
if (ts.isPrefixUnaryExpression(child) || ts.isPostfixUnaryExpression(child)) {
|
||||
if (child.operator === ts.SyntaxKind.PlusPlusToken || child.operator === ts.SyntaxKind.MinusMinusToken) impure = true;
|
||||
}
|
||||
if (!impure) ts.forEachChild(child, visit);
|
||||
};
|
||||
visit(node.body!);
|
||||
return !impure;
|
||||
}
|
||||
|
||||
function isAssignmentOperatorKind(kind: ts.SyntaxKind) {
|
||||
return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment;
|
||||
}
|
||||
|
||||
function helperFingerprint(node: ts.FunctionDeclaration, sourceFile: ts.SourceFile) {
|
||||
return `${node.typeParameters?.length ?? 0}:${node.parameters.length}:${node.body!.getText(sourceFile).replace(/\s+/gu, " ").trim()}`;
|
||||
}
|
||||
|
||||
function compressedDeclarationFindings(modules: SourceModule[]) {
|
||||
const findings: CaseRunAuditFinding[] = [];
|
||||
for (const module of modules) {
|
||||
const statementsByLine = new Map<number, ts.Statement[]>();
|
||||
for (const statement of module.sourceFile.statements) {
|
||||
const line = lineOf(module.sourceFile, statement);
|
||||
const statements = statementsByLine.get(line) ?? [];
|
||||
statements.push(statement);
|
||||
statementsByLine.set(line, statements);
|
||||
}
|
||||
for (const [line, statements] of statementsByLine) {
|
||||
if (statements.length < 2 || !statements.some(isDeclarationStatement)) continue;
|
||||
findings.push({
|
||||
code: "caserun_compressed_top_level_declaration",
|
||||
category: "compressed_declaration",
|
||||
file: module.relativePath,
|
||||
line,
|
||||
message: "multiple top-level declarations share one physical line",
|
||||
details: { statementCount: statements.length },
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function isDeclarationStatement(statement: ts.Statement) {
|
||||
return ts.isVariableStatement(statement) || ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement);
|
||||
}
|
||||
|
||||
function lineCountFindings(modules: SourceModule[], maxLines: number) {
|
||||
return modules.flatMap((module): CaseRunAuditFinding[] => module.lineCount < maxLines ? [] : [{
|
||||
code: "caserun_source_line_limit_exceeded",
|
||||
category: "line_count",
|
||||
file: module.relativePath,
|
||||
line: maxLines,
|
||||
message: `CaseRun source file must stay below ${maxLines} lines; found ${module.lineCount}`,
|
||||
details: { lineCount: module.lineCount, maxLines, comparison: "<" },
|
||||
}]);
|
||||
}
|
||||
|
||||
function checkSummary(findings: CaseRunAuditFinding[]) {
|
||||
const categories: AuditCategory[] = ["type_check", "dependency", "duplicate_helper", "compressed_declaration", "line_count"];
|
||||
return Object.fromEntries(categories.map((category) => {
|
||||
const findingCount = findings.filter((finding) => finding.category === category).length;
|
||||
return [category, { status: findingCount === 0 ? "passed" : "failed", findingCount }];
|
||||
})) as CaseRunAuditReport["checks"];
|
||||
}
|
||||
|
||||
function parseAuditArgs(argv: string[]) {
|
||||
const result: { help: boolean; root?: string; sourceDir?: string; maxLines?: number } = { help: false };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === "--json") continue;
|
||||
if (["--help", "-h", "help"].includes(argument)) {
|
||||
result.help = true;
|
||||
continue;
|
||||
}
|
||||
const [name, inlineValue] = argument.split("=", 2);
|
||||
if (["--root", "--source-dir", "--max-lines"].includes(name)) {
|
||||
const value = inlineValue ?? argv[index + 1];
|
||||
if (!inlineValue) index += 1;
|
||||
if (!value) throw cliError("missing_required_value", `${name} requires a value`, { option: name });
|
||||
if (name === "--root") result.root = value;
|
||||
if (name === "--source-dir") result.sourceDir = value;
|
||||
if (name === "--max-lines") result.maxLines = positiveInteger(Number(value), "maxLines");
|
||||
continue;
|
||||
}
|
||||
throw cliError("unsupported_case_audit_option", `unsupported case audit option: ${argument}`, { option: argument });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function auditFailure(error: unknown) {
|
||||
const value = error as { code?: string; message?: string; details?: Record<string, unknown> };
|
||||
return {
|
||||
ok: false,
|
||||
action: "case.audit",
|
||||
status: "error",
|
||||
error: {
|
||||
code: value?.code ?? "caserun_audit_failed",
|
||||
message: value?.message ?? String(error),
|
||||
details: value?.details ?? {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderAuditFailureText(failure: ReturnType<typeof auditFailure>) {
|
||||
return `CaseRun static audit: error\n${failure.error.code}: ${failure.error.message}`;
|
||||
}
|
||||
|
||||
function normalizeSourceDir(value: string) {
|
||||
const normalized = value.replace(/\\/gu, "/").replace(/^\.\//u, "").replace(/\/$/u, "");
|
||||
if (!normalized || normalized === ".." || normalized.startsWith("../") || path.isAbsolute(value)) throw cliError("invalid_case_audit_source_dir", "sourceDir must be a relative path inside the audit root", { sourceDir: value });
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function positiveInteger(value: number, field: string) {
|
||||
if (!Number.isInteger(value) || value <= 0) throw cliError("invalid_positive_integer", `${field} must be a positive integer`, { field, value });
|
||||
return value;
|
||||
}
|
||||
|
||||
function repoRelative(root: string, absolutePath: string) {
|
||||
return path.relative(root, absolutePath).replace(/\\/gu, "/");
|
||||
}
|
||||
|
||||
function lineOf(sourceFile: ts.SourceFile, node: ts.Node) {
|
||||
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
||||
}
|
||||
|
||||
function compareFindings(left: CaseRunAuditFinding, right: CaseRunAuditFinding) {
|
||||
return left.category.localeCompare(right.category) || left.file.localeCompare(right.file) || left.line - right.line || left.code.localeCompare(right.code);
|
||||
}
|
||||
|
||||
function cliError(code: string, message: string, details: Record<string, unknown> = {}) {
|
||||
return Object.assign(new Error(message), { code, details });
|
||||
}
|
||||
@@ -1,32 +1,92 @@
|
||||
// SPEC: PJ2026-0103 HarnessRL draft-2026-06-25-p0-web-caserun-e2e.
|
||||
// Responsibility: CaseRun evidence rendering, archive, aggregate, and terminal closeout.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { renderTraceRowsMarkdown, traceDisplayRows, traceNoiseEventCount, type TraceEventRow } from "./hwlab-cli/trace-renderer.ts";
|
||||
import {
|
||||
agentTerminalEvidence,
|
||||
agentToolCallSummaryFromResult,
|
||||
arrayOfObjects,
|
||||
clean,
|
||||
cliError,
|
||||
clipText,
|
||||
commandVisibility,
|
||||
commandsFromToolCallSummary,
|
||||
compactObject,
|
||||
hwpodCommandKind,
|
||||
mentionsHwpod,
|
||||
numberOption,
|
||||
parseJsonMaybe,
|
||||
sha256,
|
||||
summarizeAgentTrace,
|
||||
text,
|
||||
uniqueStrings,
|
||||
} from "./hwlab-caserun-evidence.ts";
|
||||
import type { ArchivedAgentTraceSnapshot, AgentTraceCommand, AgentTraceStage, CaseContext, CaseReadableAgentArchive, CaseRegistryArchive, CaseRegistryArchiveFile, PreparedCaseRun } from "./hwlab-caserun-shared.ts";
|
||||
import { webUrlFrom } from "./hwlab-caserun-runtime-config.ts";
|
||||
import type { AgentRunStage, ArchivedAgentTraceSnapshot, AgentTraceCommand, AgentTraceStage, CaseContext, CaseReadableAgentArchive, CaseRegistryArchive, CaseRegistryArchiveFile, PreparedCaseRun } from "./hwlab-caserun-shared.ts";
|
||||
const CASE_TRACE_RENDERER = "tools/src/hwlab-cli/trace-renderer:traceDisplayRows";
|
||||
const MAX_AGENT_TRACE_COMMANDS = 30;
|
||||
const HWLAB_API_KEY_PREFIX = "hwl_live_";
|
||||
|
||||
function sha256Buffer(value: Buffer) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
export function agentTraceLookup(input: { baseUrl: string; traceId: string; sessionId?: string; conversationId?: string; threadId?: string }) {
|
||||
const traceId = input.traceId;
|
||||
const commands = clean({
|
||||
result: `hwlab-cli client agent result ${traceId}`,
|
||||
trace: `hwlab-cli client agent trace ${traceId} --render web`,
|
||||
traceFull: `hwlab-cli client agent trace ${traceId} --render web --full`,
|
||||
inspect: `hwlab-cli client agent inspect --trace-id ${traceId}`,
|
||||
sessionStatus: input.sessionId ? `hwlab-cli client agent session status ${input.sessionId}` : undefined,
|
||||
});
|
||||
return clean({
|
||||
source: "hwlab-cli.client.agent",
|
||||
strategy: "id_plus_existing_cli",
|
||||
note: "CaseRun records trace/session identity and reuses existing HWLAB Web-equivalent agent CLI for trace/result/inspect; it does not implement a separate trace query path.",
|
||||
baseUrl: input.baseUrl,
|
||||
traceId,
|
||||
sessionId: input.sessionId,
|
||||
conversationId: input.conversationId,
|
||||
threadId: input.threadId,
|
||||
commands,
|
||||
});
|
||||
}
|
||||
|
||||
export function agentTraceIdentityArtifact(run: PreparedCaseRun, agent: AgentRunStage) {
|
||||
const traceId = text(agent.traceId);
|
||||
const lookup = agent.traceLookup ?? agentTraceLookup({ baseUrl: agent.baseUrl, traceId, sessionId: agent.sessionId, conversationId: agent.conversationId, threadId: agent.threadId });
|
||||
const resultBody = agent.result?.body && typeof agent.result.body === "object" ? agent.result.body : agent.result;
|
||||
return clean({
|
||||
contractVersion: "hwpod-case-run-agent-trace-identity-v1",
|
||||
source: "caserun-identity",
|
||||
lookupOnly: true,
|
||||
status: text(resultBody?.status ?? agent.stageStatus) || "recorded",
|
||||
traceId,
|
||||
sessionId: agent.sessionId,
|
||||
conversationId: agent.conversationId,
|
||||
threadId: agent.threadId,
|
||||
providerProfile: agent.providerProfile,
|
||||
requestedProviderProfile: agent.requestedProviderProfile,
|
||||
resolvedBackendProfile: agent.resolvedBackendProfile,
|
||||
provider: agent.provider,
|
||||
model: agent.model,
|
||||
backend: agent.backend,
|
||||
infrastructureBackend: agent.infrastructureBackend,
|
||||
providerTrace: agent.providerTrace,
|
||||
projectId: agent.projectId,
|
||||
resultStatus: text(resultBody?.status),
|
||||
terminalStatus: agent.terminalStatus,
|
||||
commandStatus: agent.commandStatus,
|
||||
agentRunStatus: agent.agentRunStatus,
|
||||
toolCallSummary: agent.toolCallSummary ?? agentToolCallSummaryFromResult(resultBody),
|
||||
finalResponse: resultBody?.finalResponse ?? resultBody?.reply ?? null,
|
||||
traceSummary: resultBody?.traceSummary ?? null,
|
||||
terminalEvidence: resultBody?.terminalEvidence ?? null,
|
||||
agentRun: compactObject(resultBody?.agentRun ?? resultBody?.traceSummary?.agentRun ?? null),
|
||||
lookup,
|
||||
hint: "Use lookup.commands.trace or lookup.commands.result to query the full Web-equivalent AgentRun trace with the existing HWLAB CLI.",
|
||||
runId: run.runId,
|
||||
caseId: run.caseId,
|
||||
});
|
||||
}
|
||||
|
||||
function requiredText(value: unknown, field: string) {
|
||||
@@ -305,7 +365,7 @@ export function aggregateTraceBody(messages: any, traceMarkdown: string) {
|
||||
return renderTraceRowsMarkdown(rows.map((row) => traceEventRowFromAggregate(row)));
|
||||
}
|
||||
if (traceMarkdown) {
|
||||
return renderTraceRowsMarkdown([{ rowId: "trace-markdown", seq: null, tone: "source", header: "Rendered trace markdown", body: traceMarkdown, bodyFormat: "markdown" }]);
|
||||
return renderTraceRowsMarkdown([{ rowId: "trace-markdown", seq: null, sequenceAuthority: "source", tone: "source", header: "Rendered trace markdown", body: traceMarkdown, bodyFormat: "markdown" }]);
|
||||
}
|
||||
return `_No readable trace artifact was found._`;
|
||||
}
|
||||
@@ -353,6 +413,7 @@ export function traceEventRowFromAggregate(row: Record<string, unknown>): TraceE
|
||||
return {
|
||||
rowId: text(row.rowId) || text(row.header) || "trace-row",
|
||||
seq: numberOption(row.seq) ?? null,
|
||||
sequenceAuthority: row.sequenceAuthority === "projected" ? "projected" : "source",
|
||||
tone: traceRowTone(row.tone),
|
||||
header: text(row.header) || text(row.rowId) || "trace row",
|
||||
body: row.body === undefined || row.body === null ? null : String(row.body),
|
||||
@@ -398,7 +459,7 @@ export function aggregateArtifactIndex(manifest: any) {
|
||||
|
||||
export async function registryAggregateFileRecord(file: string) {
|
||||
const content = await readFile(file);
|
||||
return { bytes: content.length, sha256: sha256Buffer(content) };
|
||||
return { bytes: content.length, sha256: sha256(content) };
|
||||
}
|
||||
|
||||
export async function archiveCaseRunArtifacts(context: CaseContext, run: PreparedCaseRun, evidence: any, options: { sync?: boolean } = {}): Promise<CaseRegistryArchive> {
|
||||
@@ -568,7 +629,7 @@ export async function materializeFullAgentTraceWithExistingCli(context: CaseCont
|
||||
if (forceRuntimeRefresh) throw cliError("case_refresh_runtime_unavailable", "explicit runtime refresh requires trace identity, runtime base URL, and HWLAB_API_KEY", { traceId, hasLookup: Boolean(lookup), hasApiKey: apiKey.startsWith(HWLAB_API_KEY_PREFIX) });
|
||||
return initialTrace;
|
||||
}
|
||||
const baseUrl = text((lookup as any).baseUrl) || webUrlFrom(context, run.definition);
|
||||
const baseUrl = text((lookup as any).baseUrl) || webUrlFrom(context);
|
||||
const command = [process.execPath, path.join(context.cwd, "tools/hwlab-cli/bin/hwlab-cli.mjs"), "client", "agent", "trace", traceId, "--render", "web", "--full"];
|
||||
const result = await (context.runProcess ?? runProcess)(command, context.cwd, { ...process.env, ...context.env, HWLAB_RUNTIME_WEB_URL: baseUrl });
|
||||
if (result.exitCode !== 0) {
|
||||
@@ -608,6 +669,7 @@ export function traceEventRowFromObject(row: Record<string, unknown>): TraceEven
|
||||
return {
|
||||
rowId: text(row.rowId ?? row.id),
|
||||
seq: numberOption(row.seq) ?? null,
|
||||
sequenceAuthority: row.sequenceAuthority === "projected" ? "projected" : "source",
|
||||
tone: traceRowTone(row.tone),
|
||||
header: text(row.header ?? row.title ?? row.label),
|
||||
body: row.body === null || row.body === undefined ? null : text(row.body ?? row.content ?? row.text ?? row.message),
|
||||
@@ -783,7 +845,7 @@ export async function registryArtifactFileRecord(run: PreparedCaseRun, caseRepoR
|
||||
return clean({
|
||||
path: artifactRel(rel),
|
||||
bytes: content.length,
|
||||
sha256: sha256Buffer(content),
|
||||
sha256: sha256(content),
|
||||
source: artifactSourceLabel(run, source)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { sha256 } from "./hwlab-caserun-evidence.ts";
|
||||
|
||||
type CommandResult = { stdout?: unknown } & Record<string, unknown>;
|
||||
|
||||
@@ -165,7 +165,6 @@ function patchPaths(value: string) { return unique(text(value).split(/\r?\n/u).f
|
||||
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 sha256(value: string) { return createHash("sha256").update(value).digest("hex"); }
|
||||
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> : {}; }
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// SPEC: PJ2026-0103 HarnessRL draft-2026-06-25-p0-web-caserun-e2e.
|
||||
// Responsibility: normalize AgentRun provider, terminal, and tool-call evidence for CaseRun.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
export type AgentToolCallSummaryItem = {
|
||||
source: string;
|
||||
index: number;
|
||||
@@ -145,6 +148,18 @@ export function text(value: unknown) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
export function sha256(value: string | Uint8Array) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function commandVisibility(command: string[]) {
|
||||
return command.map((item) => item.includes("/") || item.includes("\\") ? path.basename(item) || item : item);
|
||||
}
|
||||
|
||||
export function cliError(code: string, message: string, details: Record<string, unknown> = {}) {
|
||||
return Object.assign(new Error(message), { code, details });
|
||||
}
|
||||
|
||||
export function clean<T extends Record<string, unknown>>(value: T): T {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Responsibility: explicit v0.3 CaseRun runtime endpoint resolution.
|
||||
|
||||
import { cliError, text } from "./hwlab-caserun-evidence.ts";
|
||||
import type { CaseContext } from "./hwlab-caserun-shared.ts";
|
||||
|
||||
const REMOVED_V02_API_URL = "http://74.48.78.17:19667";
|
||||
const REMOVED_V02_WEB_URL = "http://74.48.78.17:19666";
|
||||
|
||||
export function webUrlFrom(context: CaseContext) {
|
||||
const value = text(context.parsed.baseUrl ?? context.parsed.webUrl ?? context.env.HWLAB_RUNTIME_WEB_URL ?? context.env.HWLAB_CLOUD_WEB_URL);
|
||||
if (!value) {
|
||||
throw cliError("caserun_runtime_web_url_required", "CaseRun v0.3 requires an explicit runtime Web URL from --base-url, --web-url, HWLAB_RUNTIME_WEB_URL, or HWLAB_CLOUD_WEB_URL; case runtime.webUrl fallback is removed.", {
|
||||
allowed: ["--base-url", "--web-url", "HWLAB_RUNTIME_WEB_URL", "HWLAB_CLOUD_WEB_URL"],
|
||||
removedFallbacks: ["case.runtime.webUrl", REMOVED_V02_WEB_URL],
|
||||
});
|
||||
}
|
||||
return rejectRemovedV02RuntimeUrl(value, "web");
|
||||
}
|
||||
|
||||
export function apiUrlFrom(context: CaseContext) {
|
||||
const value = text(context.parsed.apiUrl ?? context.parsed.apiBaseUrl ?? context.parsed.runtimeApiUrl ?? context.env.HWLAB_RUNTIME_API_URL ?? context.env.HWLAB_CASERUN_RUNTIME_API_URL);
|
||||
if (!value) {
|
||||
throw cliError("caserun_runtime_api_url_required", "CaseRun v0.3 requires an explicit runtime API URL from --api-url, --api-base-url, --runtime-api-url, HWLAB_RUNTIME_API_URL, or HWLAB_CASERUN_RUNTIME_API_URL; case runtime.apiUrl fallback is removed.", {
|
||||
allowed: ["--api-url", "--api-base-url", "--runtime-api-url", "HWLAB_RUNTIME_API_URL", "HWLAB_CASERUN_RUNTIME_API_URL"],
|
||||
removedFallbacks: ["case.runtime.apiUrl", REMOVED_V02_API_URL],
|
||||
});
|
||||
}
|
||||
return rejectRemovedV02RuntimeUrl(value, "api");
|
||||
}
|
||||
|
||||
function rejectRemovedV02RuntimeUrl(value: string, kind: "api" | "web") {
|
||||
const normalized = value.replace(/\/+$/u, "");
|
||||
const removed = kind === "web" ? REMOVED_V02_WEB_URL : REMOVED_V02_API_URL;
|
||||
if (normalized === removed) throw cliError("caserun_removed_v02_runtime_url", `CaseRun v0.3 does not support the removed v0.2 ${kind} runtime URL`, { kind, url: normalized });
|
||||
return normalized;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// Responsibility: v0.3 CaseRun runtime-origin resolution, evidence normalization, and compile-only HWPOD smoke orchestration; SPEC: PJ2026-0103 HarnessRL draft-2026-06-25-p0-web-caserun-e2e.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { closeSync, openSync } from "node:fs";
|
||||
import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -10,13 +10,12 @@ import path from "node:path";
|
||||
import { renderTraceRowsMarkdown, traceDisplayRows, traceNoiseEventCount, type TraceEventRow } from "./hwlab-cli/trace-renderer.ts";
|
||||
import { collectAgentWorkspaceDiff } from "./hwlab-caserun-diff.ts";
|
||||
import { inspectCaseRegistryRun, refreshCaseRegistryArtifacts, skillUsageCaseRegistryRun } from "./hwlab-caserun-diagnostics.ts";
|
||||
import { agentTerminalEvidence, agentToolCallSummaryFromResult, agentWithResultEvidence, applyCaseRunEvidenceRelationships, commandsFromToolCallSummary, summarizeAgentTrace, firstNumberOption, firstTextOption, type AgentToolCallSummary } from "./hwlab-caserun-evidence.ts";
|
||||
import { agentTerminalEvidence, agentToolCallSummaryFromResult, agentWithResultEvidence, applyCaseRunEvidenceRelationships, clean, cliError, clipText, commandVisibility, commandsFromToolCallSummary, compactObject, firstNumberOption, firstTextOption, numberOption, parseJsonMaybe, sha256, summarizeAgentTrace, text, type AgentToolCallSummary } from "./hwlab-caserun-evidence.ts";
|
||||
import { apiUrlFrom, webUrlFrom } from "./hwlab-caserun-runtime-config.ts";
|
||||
import { readHwpodSpec } from "./hwpod-harness-lib.ts";
|
||||
import { prepareSubjectWorktree, caseRunForSubjectSnapshot, subjectFromDefinition, requestSubjectWorktree, subjectWorktreeStatus, keilSidecarCopyOp, subjectRelativeKeilProject, stripUvprojx, windowsJoin, keilSidecarCopyNodeScript, keilSidecarSummary, objectRecord, isAbsolutePathLike, subjectRelativeWorktreePath, normalizeLocalPath, subjectWorktreePath, looksLikeWindowsPath, rewriteHwpodSpecWorkspacePath, pollKeilJobStatus, requestKeilJobStatus, keilJobStatus, hwpodResultTexts, artifactsFrom } from "./hwlab-caserun-subject.ts";
|
||||
import { agentTraceLookupForRun, renderEvidenceSummary, agentStageSummary, agentStageCommand, agentCommandDetail, markdownTableText, writeCaseAggregateFromRegistry, resolveAggregateRunId, aggregateOutputRel, renderCaseAggregateMarkdown, aggregateTraceBody, aggregateMessagesWithFreshFullTrace, freshRenderedTraceBody, traceEventRowFromAggregate, aggregateBullets, aggregateInlineValue, markdownInline, aggregateDetails, escapeHtml, aggregateFence, aggregateArtifactIndex, registryAggregateFileRecord, archiveCaseRunArtifacts, syncCaseRegistryRun, syncCaseRegistryRel, gitProcess, collectSummaryFromArchive, writeReadableAgentArtifacts, archivedAgentTraceSnapshot, existingAgentTraceSummary, archivedAgentTraceBody, materializeFullAgentTraceWithExistingCli, traceRowsFromBody, traceEventRowFromObject, traceRowTone, applyArchivedAgentTraceSummary, agentTraceBody, agentFinalResponse, finalResponseMissingReason, agentMessageRowForArchive, renderAgentTranscript, renderTranscriptRow, renderFinalResponseArtifact, refreshCompletedRunArchive, caseRunArtifactEntries, copyCaseRunArtifact, registryArtifactFileRecord, caseRunArtifactManifest, aggregateManifestEntry, caseRunArtifactTrace, caseRegistryRunRel, artifactRel, artifactSourceLabel } from "./hwlab-caserun-closeout.ts";
|
||||
import { prepareSubjectWorktree, caseRunForSubjectSnapshot, subjectFromDefinition, requestSubjectWorktree, subjectWorktreeStatus, keilSidecarCopyOp, subjectRelativeKeilProject, stripUvprojx, windowsJoin, keilSidecarCopyNodeScript, keilSidecarSummary, objectRecord, isAbsolutePathLike, subjectRelativeWorktreePath, normalizeLocalPath, subjectWorktreePath, looksLikeWindowsPath, rewriteHwpodSpecWorkspacePath, pollKeilJobStatus, requestKeilJobStatus, keilJobStatus, hwpodResultTexts, artifactsFrom, slug } from "./hwlab-caserun-subject.ts";
|
||||
import { agentTraceIdentityArtifact, agentTraceLookup, agentTraceLookupForRun, renderEvidenceSummary, agentStageSummary, agentStageCommand, agentCommandDetail, markdownTableText, writeCaseAggregateFromRegistry, resolveAggregateRunId, aggregateOutputRel, renderCaseAggregateMarkdown, aggregateTraceBody, aggregateMessagesWithFreshFullTrace, freshRenderedTraceBody, traceEventRowFromAggregate, aggregateBullets, aggregateInlineValue, markdownInline, aggregateDetails, escapeHtml, aggregateFence, aggregateArtifactIndex, registryAggregateFileRecord, archiveCaseRunArtifacts, syncCaseRegistryRun, syncCaseRegistryRel, gitProcess, collectSummaryFromArchive, writeReadableAgentArtifacts, archivedAgentTraceSnapshot, existingAgentTraceSummary, archivedAgentTraceBody, materializeFullAgentTraceWithExistingCli, traceRowsFromBody, traceEventRowFromObject, traceRowTone, applyArchivedAgentTraceSummary, agentTraceBody, agentFinalResponse, finalResponseMissingReason, agentMessageRowForArchive, renderAgentTranscript, renderTranscriptRow, renderFinalResponseArtifact, refreshCompletedRunArchive, caseRunArtifactEntries, copyCaseRunArtifact, registryArtifactFileRecord, caseRunArtifactManifest, aggregateManifestEntry, caseRunArtifactTrace, caseRegistryRunRel, artifactRel, artifactSourceLabel } from "./hwlab-caserun-closeout.ts";
|
||||
|
||||
const REMOVED_V02_API_URL = "http://74.48.78.17:19667";
|
||||
const REMOVED_V02_WEB_URL = "http://74.48.78.17:19666";
|
||||
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
||||
const DEFAULT_JOB_TIMEOUT_MS = 50000;
|
||||
const MAX_JOB_TIMEOUT_MS = 300000;
|
||||
@@ -74,6 +73,7 @@ export function caseHelp() {
|
||||
`case build CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-dir DIR]`,
|
||||
`case collect CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --run-dir DIR`,
|
||||
`case aggregate CASE_ID --run-id RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--output aggregate.md]`,
|
||||
"case audit [--json] [--root PATH] [--source-dir PATH] [--max-lines N]",
|
||||
`case refresh CASE_ID --run-id RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--source local|runtime]`,
|
||||
`case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--provider-profile PROFILE]`,
|
||||
`case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`,
|
||||
@@ -116,7 +116,7 @@ export async function promptCaseRun(context: CaseContext) {
|
||||
caseFile: loaded.caseFile,
|
||||
sourceSpecPath: loaded.specPath,
|
||||
specPath: loaded.specPath,
|
||||
apiUrl: apiUrlFrom(context, loaded.definition),
|
||||
apiUrl: apiUrlFrom(context),
|
||||
compileOnly: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -215,7 +215,7 @@ export async function startCaseRun(context: CaseContext) {
|
||||
caseFile: "",
|
||||
sourceSpecPath: "",
|
||||
specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"),
|
||||
apiUrl: apiUrlFrom(context, {}),
|
||||
apiUrl: apiUrlFrom(context),
|
||||
compileOnly: true,
|
||||
createdAt: context.now(),
|
||||
updatedAt: context.now(),
|
||||
@@ -397,7 +397,7 @@ export async function prepareCaseRun(context: CaseContext, action = "prepare") {
|
||||
const runDir = path.resolve(context.cwd, text(context.parsed.runDir) || path.join(stateRoot(context), runId));
|
||||
const specPath = path.join(runDir, ".hwlab", "hwpod-spec.yaml");
|
||||
const now = context.now();
|
||||
const apiUrl = apiUrlFrom(context, loaded.definition);
|
||||
const apiUrl = apiUrlFrom(context);
|
||||
const subject = await prepareSubjectWorktree(context, { caseId, runId, runDir, apiUrl, definition: loaded.definition, sourceSpecPath: loaded.specPath }, collectSourceRootSnapshot);
|
||||
const agentTask = agentTaskFromDefinition(loaded.definition, caseId);
|
||||
const run: PreparedCaseRun = {
|
||||
@@ -683,7 +683,7 @@ async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) {
|
||||
const prompt = await renderAgentTaskPrompt(run, run.agentTask);
|
||||
await writeFile(promptPath, prompt, "utf8");
|
||||
const promptSha256 = sha256(prompt);
|
||||
const baseUrl = webUrlFrom(context, run.definition);
|
||||
const baseUrl = webUrlFrom(context);
|
||||
const providerProfile = text(context.parsed.providerProfile) || run.agentTask.providerProfile;
|
||||
const projectId = text(context.parsed.projectId) || run.agentTask.projectId;
|
||||
const conversationId = text(context.parsed.conversationId) || `cnv_case_${slug(run.caseId)}_${slug(run.runId)}`;
|
||||
@@ -846,67 +846,6 @@ async function submitAgentTask(context: CaseContext, input: { baseUrl: string; p
|
||||
}, "agent_task_submit");
|
||||
}
|
||||
|
||||
function agentTraceLookup(input: { baseUrl: string; traceId: string; sessionId?: string; conversationId?: string; threadId?: string }) {
|
||||
const traceId = input.traceId;
|
||||
const base = input.baseUrl;
|
||||
const commands = clean({
|
||||
result: `hwlab-cli client agent result ${traceId}`,
|
||||
trace: `hwlab-cli client agent trace ${traceId} --render web`,
|
||||
traceFull: `hwlab-cli client agent trace ${traceId} --render web --full`,
|
||||
inspect: `hwlab-cli client agent inspect --trace-id ${traceId}`,
|
||||
sessionStatus: input.sessionId ? `hwlab-cli client agent session status ${input.sessionId}` : undefined
|
||||
});
|
||||
return clean({
|
||||
source: "hwlab-cli.client.agent",
|
||||
strategy: "id_plus_existing_cli",
|
||||
note: "CaseRun records trace/session identity and reuses existing HWLAB Web-equivalent agent CLI for trace/result/inspect; it does not implement a separate trace query path.",
|
||||
baseUrl: base,
|
||||
traceId,
|
||||
sessionId: input.sessionId,
|
||||
conversationId: input.conversationId,
|
||||
threadId: input.threadId,
|
||||
commands
|
||||
});
|
||||
}
|
||||
|
||||
function agentTraceIdentityArtifact(run: PreparedCaseRun, agent: AgentRunStage) {
|
||||
const traceId = text(agent.traceId);
|
||||
const lookup = agent.traceLookup ?? agentTraceLookup({ baseUrl: agent.baseUrl, traceId, sessionId: agent.sessionId, conversationId: agent.conversationId, threadId: agent.threadId });
|
||||
const resultBody = agent.result?.body && typeof agent.result.body === "object" ? agent.result.body : agent.result;
|
||||
return clean({
|
||||
contractVersion: "hwpod-case-run-agent-trace-identity-v1",
|
||||
source: "caserun-identity",
|
||||
lookupOnly: true,
|
||||
status: text(resultBody?.status ?? agent.stageStatus) || "recorded",
|
||||
traceId,
|
||||
sessionId: agent.sessionId,
|
||||
conversationId: agent.conversationId,
|
||||
threadId: agent.threadId,
|
||||
providerProfile: agent.providerProfile,
|
||||
requestedProviderProfile: agent.requestedProviderProfile,
|
||||
resolvedBackendProfile: agent.resolvedBackendProfile,
|
||||
provider: agent.provider,
|
||||
model: agent.model,
|
||||
backend: agent.backend,
|
||||
infrastructureBackend: agent.infrastructureBackend,
|
||||
providerTrace: agent.providerTrace,
|
||||
projectId: agent.projectId,
|
||||
resultStatus: text(resultBody?.status),
|
||||
terminalStatus: agent.terminalStatus,
|
||||
commandStatus: agent.commandStatus,
|
||||
agentRunStatus: agent.agentRunStatus,
|
||||
toolCallSummary: agent.toolCallSummary ?? agentToolCallSummaryFromResult(resultBody),
|
||||
finalResponse: resultBody?.finalResponse ?? resultBody?.reply ?? null,
|
||||
traceSummary: resultBody?.traceSummary ?? null,
|
||||
terminalEvidence: resultBody?.terminalEvidence ?? null,
|
||||
agentRun: compactObject(resultBody?.agentRun ?? resultBody?.traceSummary?.agentRun ?? null),
|
||||
lookup,
|
||||
hint: "Use lookup.commands.trace or lookup.commands.result to query the full Web-equivalent AgentRun trace with the existing HWLAB CLI.",
|
||||
runId: run.runId,
|
||||
caseId: run.caseId
|
||||
});
|
||||
}
|
||||
|
||||
async function pollAgentResult(context: CaseContext, input: { baseUrl: string; traceId: string; acceptedBody: any; resultUrl?: string; run?: PreparedCaseRun; agent?: AgentRunStage }) {
|
||||
const requestedTimeoutMs = effectiveAgentTimeoutMs(context, input.run?.agentTask);
|
||||
const timeoutMs = Math.max(Math.min(requestedTimeoutMs, MAX_AGENT_TIMEOUT_MS), 1000);
|
||||
@@ -1144,17 +1083,6 @@ function caseAgentHeaders(context: CaseContext) {
|
||||
return { "content-type": "application/json", authorization: `Bearer ${apiKey}` };
|
||||
}
|
||||
|
||||
function webUrlFrom(context: CaseContext, definition: any) {
|
||||
const value = text(context.parsed.baseUrl ?? context.parsed.webUrl ?? context.env.HWLAB_RUNTIME_WEB_URL ?? context.env.HWLAB_CLOUD_WEB_URL);
|
||||
if (!value) {
|
||||
throw cliError("caserun_runtime_web_url_required", "CaseRun v0.3 requires an explicit runtime Web URL from --base-url, --web-url, HWLAB_RUNTIME_WEB_URL, or HWLAB_CLOUD_WEB_URL; case runtime.webUrl fallback is removed.", {
|
||||
allowed: ["--base-url", "--web-url", "HWLAB_RUNTIME_WEB_URL", "HWLAB_CLOUD_WEB_URL"],
|
||||
removedFallbacks: ["case.runtime.webUrl", REMOVED_V02_WEB_URL]
|
||||
});
|
||||
}
|
||||
return rejectRemovedV02RuntimeUrl(value, "web");
|
||||
}
|
||||
|
||||
function firstHwpodOutput(body: any) {
|
||||
const results = Array.isArray(body?.body?.results) ? body.body.results : Array.isArray(body?.results) ? body.results : [];
|
||||
const first = results[0] ?? null;
|
||||
@@ -1216,39 +1144,10 @@ function stringArray(value: unknown) {
|
||||
return Array.isArray(value) ? value.map((item) => text(item)).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function sha256(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function promptShellArg(value: string) {
|
||||
return `'${String(value).replace(/'/gu, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function sha256Buffer(value: Buffer) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
|
||||
function apiUrlFrom(context: CaseContext, definition: any) {
|
||||
const value = text(context.parsed.apiUrl ?? context.parsed.apiBaseUrl ?? context.parsed.runtimeApiUrl ?? context.env.HWLAB_RUNTIME_API_URL ?? context.env.HWLAB_CASERUN_RUNTIME_API_URL);
|
||||
if (!value) {
|
||||
throw cliError("caserun_runtime_api_url_required", "CaseRun v0.3 requires an explicit runtime API URL from --api-url, --api-base-url, --runtime-api-url, HWLAB_RUNTIME_API_URL, or HWLAB_CASERUN_RUNTIME_API_URL; case runtime.apiUrl fallback is removed.", {
|
||||
allowed: ["--api-url", "--api-base-url", "--runtime-api-url", "HWLAB_RUNTIME_API_URL", "HWLAB_CASERUN_RUNTIME_API_URL"],
|
||||
removedFallbacks: ["case.runtime.apiUrl", REMOVED_V02_API_URL]
|
||||
});
|
||||
}
|
||||
return rejectRemovedV02RuntimeUrl(value, "api");
|
||||
}
|
||||
|
||||
function rejectRemovedV02RuntimeUrl(value: string, kind: "api" | "web") {
|
||||
const normalized = value.replace(/\/+$/u, "");
|
||||
const removed = kind === "web" ? REMOVED_V02_WEB_URL : REMOVED_V02_API_URL;
|
||||
if (normalized === removed) {
|
||||
throw cliError("caserun_removed_v02_runtime_url", `CaseRun v0.3 does not support the removed v0.2 ${kind} runtime URL`, { kind, url: normalized });
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function stateRoot(context: CaseContext) {
|
||||
return text(context.parsed.stateDir) || path.join(".state", "hwlab-cli", "caserun");
|
||||
}
|
||||
@@ -1347,7 +1246,7 @@ async function loadControlRunSkeleton(context: CaseContext, caseId: string, runI
|
||||
caseFile: "",
|
||||
sourceSpecPath: "",
|
||||
specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"),
|
||||
apiUrl: apiUrlFrom(context, {}),
|
||||
apiUrl: apiUrlFrom(context),
|
||||
compileOnly: true,
|
||||
createdAt: context.now(),
|
||||
updatedAt: context.now(),
|
||||
@@ -1489,34 +1388,5 @@ function extractPrepareBlocker(summary: ReturnType<typeof errorSummary>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function commandVisibility(command: string[]) {
|
||||
return command.map((item) => item.includes("/") || item.includes("\\") ? path.basename(item) || item : item);
|
||||
}
|
||||
|
||||
function compactObject(value: any) {
|
||||
const json = JSON.stringify(value ?? null);
|
||||
if (json.length <= 4000) return value;
|
||||
return { clipped: true, bytes: Buffer.byteLength(json), preview: json.slice(0, 4000) };
|
||||
}
|
||||
|
||||
function parseJsonMaybe(value: unknown) {
|
||||
if (value && typeof value === "object") return value as any;
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw); } catch { return null; }
|
||||
}
|
||||
|
||||
function clipText(value: unknown, maxBytes = 2000) {
|
||||
const raw = String(value ?? "");
|
||||
const buffer = Buffer.from(raw, "utf8");
|
||||
if (buffer.length <= maxBytes) return raw;
|
||||
return `${buffer.subarray(0, maxBytes).toString("utf8")}\n... clipped ...`;
|
||||
}
|
||||
|
||||
function ok(action: string, extra: Record<string, unknown> = {}, status = "succeeded") { return { ok: status !== "failed", action, status, ...extra }; }
|
||||
function cliError(code: string, message: string, details: Record<string, unknown> = {}) { return Object.assign(new Error(message), { code, details }); }
|
||||
function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw cliError("missing_required_value", `${field} is required`, { field }); return result; }
|
||||
function text(value: unknown) { return String(value ?? "").trim(); }
|
||||
function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; }
|
||||
function slug(value: string) { return value.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "") || "case"; }
|
||||
function clean<T extends Record<string, unknown>>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)) as T; }
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from "node:path";
|
||||
import { readHwpodSpec } from "./hwpod-harness-lib.ts";
|
||||
import {
|
||||
clean,
|
||||
cliError,
|
||||
clipText,
|
||||
compactObject,
|
||||
firstNumberOption,
|
||||
@@ -19,11 +20,7 @@ const DEFAULT_POLL_INTERVAL_MS = 1000;
|
||||
const DEFAULT_JOB_TIMEOUT_MS = 50000;
|
||||
const MAX_JOB_TIMEOUT_MS = 300000;
|
||||
|
||||
function cliError(code: string, message: string, details: any = {}) {
|
||||
return Object.assign(new Error(message), { code, details });
|
||||
}
|
||||
|
||||
function slug(value: string) {
|
||||
export function slug(value: string) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "") || "case";
|
||||
}
|
||||
export async function prepareSubjectWorktree(context: CaseContext, input: { caseId: string; runId: string; runDir: string; apiUrl: string; definition: Record<string, unknown>; sourceSpecPath: string }, collectSnapshot: (context: CaseContext, run: PreparedCaseRun, label: string) => Promise<SourceRootSnapshot>) {
|
||||
|
||||
Reference in New Issue
Block a user