240 lines
8.8 KiB
TypeScript
240 lines
8.8 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
|
import { VerificationPipeline } from "../verification/index.js";
|
|
import { CommitBuilder, VerifyCommitInput } from "../core/commit-builder.js";
|
|
import { GitContext } from "../core/git-context.js";
|
|
import { CIAgentFiles } from "../core/ciagent-files.js";
|
|
import { fileExists } from "../utils/file.js";
|
|
import { execSync } from "node:child_process";
|
|
|
|
export interface VerifierResult {
|
|
success: boolean;
|
|
mustHaveScore: number;
|
|
requirementsCovered: string[];
|
|
requirementsPartial: string[];
|
|
integrationChecks: { import: string; resolved: boolean }[];
|
|
layers: { name: string; passed: boolean }[];
|
|
error?: string;
|
|
}
|
|
|
|
export class VerifierAgent extends BaseAgent {
|
|
readonly name = "verifier";
|
|
readonly description = "Verifies phase outputs. Generates automated tests instead of requesting human UAT.";
|
|
readonly workflow = "verify";
|
|
|
|
async execute(context: AgentContext): Promise<AgentResult> {
|
|
const start = Date.now();
|
|
this.log("Verifying phase output...");
|
|
|
|
if (context.backend) {
|
|
const result = await this.executeViaBackend(
|
|
context,
|
|
`Verify phase ${context.phase} output against must-haves, requirement coverage, and integration links. Specification: ${context.specification}. Check all .ciagent/ reference files. Run the 4-layer verification pipeline (structural, behavioral, security, quality). Verify imports resolve. Report structured VerifierResult.`
|
|
);
|
|
return { ...result, duration_ms: Date.now() - start };
|
|
}
|
|
|
|
const result = await this.runMechanicalVerification(context);
|
|
const output = JSON.stringify(result, null, 2);
|
|
|
|
return {
|
|
success: result.success,
|
|
output,
|
|
artifacts_created: [],
|
|
decisions: 0,
|
|
escalations: result.success ? 0 : 1,
|
|
duration_ms: Date.now() - start,
|
|
error: result.error,
|
|
};
|
|
}
|
|
|
|
private async runMechanicalVerification(context: AgentContext): Promise<VerifierResult> {
|
|
try {
|
|
const pipeline = new VerificationPipeline(context.project_path);
|
|
const pipelineResult = await pipeline.run(context.phase);
|
|
|
|
const layers: { name: string; passed: boolean }[] = [
|
|
{ name: pipelineResult.structural.name, passed: pipelineResult.structural.passed },
|
|
{ name: pipelineResult.behavioral.name, passed: pipelineResult.behavioral.passed },
|
|
{ name: pipelineResult.security.name, passed: pipelineResult.security.passed },
|
|
{ name: pipelineResult.quality.name, passed: pipelineResult.quality.passed },
|
|
];
|
|
|
|
const gitContext = new GitContext(context.project_path);
|
|
const ciFiles = new CIAgentFiles(context.project_path);
|
|
|
|
const mustHaveScore = this.checkMustHaves(context, gitContext, ciFiles);
|
|
const reqCoverage = this.checkRequirementCoverage(gitContext, ciFiles);
|
|
const integrationChecks = this.checkIntegrationLinks(context.project_path);
|
|
|
|
const allPassed = pipelineResult.all_passed &&
|
|
mustHaveScore >= 1.0 &&
|
|
reqCoverage.partial.length === 0;
|
|
|
|
const result: VerifierResult = {
|
|
success: allPassed,
|
|
mustHaveScore,
|
|
requirementsCovered: reqCoverage.covered,
|
|
requirementsPartial: reqCoverage.partial,
|
|
integrationChecks,
|
|
layers,
|
|
};
|
|
|
|
if (!allPassed) {
|
|
result.error = `Verification gaps: mustHaveScore=${mustHaveScore}, partialReqs=${reqCoverage.partial.join(",")}, layerFailures=${layers.filter(l => !l.passed).map(l => l.name).join(",")}`;
|
|
}
|
|
|
|
this.commitVerificationResult(context, result, ciFiles);
|
|
|
|
return result;
|
|
} catch (err) {
|
|
return {
|
|
success: false,
|
|
mustHaveScore: 0,
|
|
requirementsCovered: [],
|
|
requirementsPartial: [],
|
|
integrationChecks: [],
|
|
layers: [],
|
|
error: `Verification failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
private checkMustHaves(context: AgentContext, gitContext: GitContext, ciFiles: CIAgentFiles): number {
|
|
const roadmap = ciFiles.readRoadmapMd();
|
|
if (!roadmap) return 0;
|
|
|
|
const currentPhase = roadmap.phases.find(p => p.number === context.phase);
|
|
if (!currentPhase) return 0;
|
|
|
|
const successCriteria = currentPhase.successCriteria;
|
|
if (successCriteria.length === 0) return 1;
|
|
|
|
let passing = 0;
|
|
for (const criterion of successCriteria) {
|
|
const fileHint = criterion.match(/(?:file|exists|present|created|written)[:\s]+([^\s,;]+)/i);
|
|
if (fileHint) {
|
|
const candidate = path.join(context.project_path, fileHint[1]);
|
|
if (fileExists(candidate)) {
|
|
passing++;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (fileExists(path.join(context.project_path, ".ciagent"))) {
|
|
passing++;
|
|
}
|
|
}
|
|
|
|
return Math.min(passing / successCriteria.length, 1);
|
|
}
|
|
|
|
private checkRequirementCoverage(gitContext: GitContext, ciFiles: CIAgentFiles): { covered: string[]; partial: string[] } {
|
|
const gitCoverage = gitContext.getRequirementsCoverage();
|
|
const reqsMd = ciFiles.readRequirementsMd();
|
|
|
|
if (!reqsMd || reqsMd.traceability.length === 0) {
|
|
return { covered: gitCoverage.covered, partial: gitCoverage.partial };
|
|
}
|
|
|
|
const covered = new Set(gitCoverage.covered);
|
|
const partial = new Set(gitCoverage.partial);
|
|
|
|
for (const t of reqsMd.traceability) {
|
|
if (t.status === "complete") {
|
|
covered.add(t.requirement);
|
|
partial.delete(t.requirement);
|
|
} else if (t.status === "in_progress" || t.status === "blocked") {
|
|
partial.add(t.requirement);
|
|
}
|
|
}
|
|
|
|
return {
|
|
covered: [...covered].sort(),
|
|
partial: [...partial].sort(),
|
|
};
|
|
}
|
|
|
|
private checkIntegrationLinks(projectPath: string): { import: string; resolved: boolean }[] {
|
|
const checks: { import: string; resolved: boolean }[] = [];
|
|
const srcDir = path.join(projectPath, "src");
|
|
|
|
if (!fs.existsSync(srcDir)) return checks;
|
|
|
|
const tsFiles = this.collectTsFiles(srcDir);
|
|
const importPattern = /import\s+.*from\s+['"](\.\/[^'"]+)['"]/g;
|
|
|
|
for (const file of tsFiles) {
|
|
const content = fs.readFileSync(file, "utf-8");
|
|
let match: RegExpExecArray | null;
|
|
while ((match = importPattern.exec(content)) !== null) {
|
|
const importPath = match[1];
|
|
const resolved = this.resolveImport(file, importPath);
|
|
if (importPath.startsWith(".")) {
|
|
checks.push({ import: `${path.relative(projectPath, file)}:${importPath}`, resolved: resolved !== null });
|
|
}
|
|
}
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
|
|
private commitVerificationResult(context: AgentContext, result: VerifierResult, ciFiles: CIAgentFiles): void {
|
|
try {
|
|
const projectState = new GitContext(context.project_path).reconstructState();
|
|
const milestone = projectState.currentMilestone || "v1.0";
|
|
|
|
const verifyInput: VerifyCommitInput = {
|
|
phase: context.phase,
|
|
milestone,
|
|
subject: result.success ? "passed" : "gaps_found",
|
|
requirements: {
|
|
covered: result.requirementsCovered,
|
|
partial: result.requirementsPartial,
|
|
},
|
|
lessons: result.success ? ["All verification checks passed"] : [`Must-have score: ${result.mustHaveScore}`, `Layer failures: ${result.layers.filter(l => !l.passed).map(l => l.name).join(", ")}`],
|
|
};
|
|
|
|
const commitMsg = CommitBuilder.buildVerifyCommit(verifyInput);
|
|
if (fileExists(path.join(context.project_path, ".git"))) {
|
|
execSync(`git add -A && git commit -m "${commitMsg.replace(/"/g, '\\"')}" --allow-empty`, {
|
|
cwd: context.project_path,
|
|
stdio: "pipe",
|
|
});
|
|
}
|
|
} catch (err) {
|
|
this.warn(`Verification commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
private collectTsFiles(dir: string): string[] {
|
|
const files: string[] = [];
|
|
if (!fs.existsSync(dir)) return files;
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory() && entry.name !== "node_modules") {
|
|
files.push(...this.collectTsFiles(fullPath));
|
|
} else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts") && !entry.name.endsWith(".test.ts")) {
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
private resolveImport(fromFile: string, importPath: string): string | null {
|
|
if (!importPath.startsWith(".")) return null;
|
|
const dir = path.dirname(fromFile);
|
|
const candidates = [
|
|
path.resolve(dir, importPath + ".ts"),
|
|
path.resolve(dir, importPath + ".js"),
|
|
path.resolve(dir, importPath, "index.ts"),
|
|
path.resolve(dir, importPath, "index.js"),
|
|
];
|
|
for (const candidate of candidates) {
|
|
if (fs.existsSync(candidate)) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
} |