Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d069319b5 | |||
| b33431c1a6 | |||
| 5753e2dc96 | |||
| 815c928a43 |
@@ -224,7 +224,8 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
} catch (err) {
|
||||
this.warn(`Specify commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -275,6 +276,21 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
this.log("Researching project domain...");
|
||||
this.decisionEngine!.setPhase(1);
|
||||
|
||||
const archMd = this.ciFiles!.readArchitectureMd();
|
||||
if (!archMd) {
|
||||
this.log("No ARCHITECTURE.md found — mechanical research cannot proceed without backend");
|
||||
return {
|
||||
phase: this.pipelineState!.current_phase,
|
||||
stage: "research",
|
||||
success: false,
|
||||
artifacts_created: artifactsCreated,
|
||||
decisions_made: decisionsMade,
|
||||
escalations_raised: escalationsRaised,
|
||||
duration_ms: Date.now() - stageStart,
|
||||
error: "Research stage requires intelligence backend or existing ARCHITECTURE.md",
|
||||
};
|
||||
}
|
||||
|
||||
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
||||
const researchCommit = CommitBuilder.buildResearchCommit(
|
||||
1,
|
||||
@@ -288,7 +304,8 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
} catch (err) {
|
||||
this.warn(`Research commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,11 +326,42 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
|
||||
case "execute":
|
||||
this.log("Executing implementation...");
|
||||
if (!context.backend) {
|
||||
this.log("No backend available — mechanical execution cannot implement code changes");
|
||||
return {
|
||||
phase: this.pipelineState!.current_phase,
|
||||
stage: "execute",
|
||||
success: false,
|
||||
artifacts_created: artifactsCreated,
|
||||
decisions_made: decisionsMade,
|
||||
escalations_raised: escalationsRaised,
|
||||
duration_ms: Date.now() - stageStart,
|
||||
error: "Execute stage requires intelligence backend for code implementation",
|
||||
};
|
||||
}
|
||||
this.pipelineState!.execute_completed = true;
|
||||
break;
|
||||
|
||||
case "verify": {
|
||||
this.log("Running verification...");
|
||||
|
||||
const { VerificationPipeline } = await import("../verification/index.js");
|
||||
const verification = new VerificationPipeline(context.project_path);
|
||||
const verifyResult = await verification.run(this.pipelineState!.current_phase || 1);
|
||||
|
||||
if (!verifyResult.all_passed) {
|
||||
return {
|
||||
phase: this.pipelineState!.current_phase,
|
||||
stage: "verify",
|
||||
success: false,
|
||||
artifacts_created: artifactsCreated,
|
||||
decisions_made: decisionsMade,
|
||||
escalations_raised: escalationsRaised,
|
||||
duration_ms: Date.now() - stageStart,
|
||||
error: `Verification failed: ${verifyResult.escalations_needed.join("; ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
this.pipelineState!.verify_completed = true;
|
||||
|
||||
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
|
||||
@@ -329,7 +377,8 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
} catch (err) {
|
||||
this.warn(`Verify commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,7 +403,8 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
} catch (err) {
|
||||
this.warn(`Completion commit failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { OllamaLocalBackend } from "../backends/ollama-local.js";
|
||||
import { OllamaCloudBackend } from "../backends/ollama-cloud.js";
|
||||
import { OpencodeBackend } from "../backends/opencode.js";
|
||||
import { resolveBackend, createBackend } from "../backends/index.js";
|
||||
import { DEFAULT_BACKEND_CONFIG, BackendUnavailableError } from "../backends/types.js";
|
||||
|
||||
describe("Backend Availability Detection", () => {
|
||||
describe("OllamaLocalBackend.isAvailable", () => {
|
||||
it("returns false for unreachable host", async () => {
|
||||
const backend = new OllamaLocalBackend({
|
||||
base_url: "http://localhost:1",
|
||||
model_profile: "balanced",
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for invalid URL", async () => {
|
||||
const backend = new OllamaLocalBackend({
|
||||
base_url: "not-a-url",
|
||||
model_profile: "balanced",
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for timeout", async () => {
|
||||
const backend = new OllamaLocalBackend({
|
||||
base_url: "http://192.0.2.1",
|
||||
model_profile: "balanced",
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe("OllamaCloudBackend.isAvailable", () => {
|
||||
it("returns false when base_url is empty", async () => {
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "",
|
||||
api_key_env: "OLLAMA_CLOUD_API_KEY",
|
||||
model_profile: "quality",
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no API key in env", async () => {
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "https://api.example.com",
|
||||
api_key_env: "NONEXISTENT_ENV_VAR_12345",
|
||||
model_profile: "quality",
|
||||
timeout_ms: 5000,
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpencodeBackend.isAvailable", () => {
|
||||
it("returns false when executable not found", async () => {
|
||||
const backend = new OpencodeBackend({
|
||||
enabled: true,
|
||||
executable: "nonexistent-opencode-binary-xyz",
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when disabled", async () => {
|
||||
const backend = new OpencodeBackend({ enabled: false });
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBackend auto-detection", () => {
|
||||
it("throws BackendUnavailableError when no backends available", async () => {
|
||||
const config = {
|
||||
...DEFAULT_BACKEND_CONFIG,
|
||||
llm_backends: {
|
||||
"ollama-local": { base_url: "http://localhost:1", model_profile: "balanced" as const },
|
||||
"ollama-cloud": { base_url: "", api_key_env: "NONEXISTENT_12345", model_profile: "quality" as const },
|
||||
},
|
||||
agent_backends: {
|
||||
opencode: { enabled: true, executable: "nonexistent-opencode-binary-xyz" },
|
||||
},
|
||||
};
|
||||
|
||||
await expect(resolveBackend(config)).rejects.toThrow(BackendUnavailableError);
|
||||
});
|
||||
|
||||
it("tries opencode before ollama-local", async () => {
|
||||
expect(DEFAULT_BACKEND_CONFIG.provider).toBe("auto");
|
||||
});
|
||||
|
||||
it("createBackend throws for unknown provider", () => {
|
||||
expect(() => createBackend("unknown-provider" as "opencode", DEFAULT_BACKEND_CONFIG)).toThrow(BackendUnavailableError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackendUnavailableError", () => {
|
||||
it("contains installation hints", () => {
|
||||
const err = new BackendUnavailableError("auto");
|
||||
expect(err.message).toContain("opencode");
|
||||
expect(err.message).toContain("Ollama");
|
||||
expect(err.message).toContain("OLLAMA_CLOUD_API_KEY");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { OllamaBaseBackend, OllamaMessage, OllamaChatResponse } from "../backends/ollama-base.js";
|
||||
import { ToolRegistry } from "../backends/tool-registry.js";
|
||||
import { BackendRequest } from "../backends/types.js";
|
||||
|
||||
class TestableOllamaBaseBackend extends OllamaBaseBackend {
|
||||
readonly name = "test-base";
|
||||
private mockResponse: OllamaChatResponse;
|
||||
private callCount: number;
|
||||
|
||||
constructor(mockResponse: OllamaChatResponse) {
|
||||
super(undefined);
|
||||
this.mockResponse = mockResponse;
|
||||
this.callCount = 0;
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
getCallCount(): number {
|
||||
return this.callCount;
|
||||
}
|
||||
|
||||
protected async callModel(
|
||||
messages: OllamaMessage[],
|
||||
model: string,
|
||||
toolRegistry: ToolRegistry
|
||||
): Promise<OllamaChatResponse> {
|
||||
this.callCount++;
|
||||
return this.mockResponse;
|
||||
}
|
||||
|
||||
protected resolveModel(): string {
|
||||
return "test-model";
|
||||
}
|
||||
}
|
||||
|
||||
describe("OllamaBaseBackend", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-ollama-base-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns success when model responds without tool calls", async () => {
|
||||
const mockResponse: OllamaChatResponse = {
|
||||
choices: [{
|
||||
message: {
|
||||
content: '{"success": true, "output": "task completed"}',
|
||||
},
|
||||
}],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
};
|
||||
|
||||
const backend = new TestableOllamaBaseBackend(mockResponse);
|
||||
const request: BackendRequest = {
|
||||
persona: "executor",
|
||||
workflow: "execute",
|
||||
task: "Do something",
|
||||
context: {
|
||||
project_path: tempDir,
|
||||
phase: 1,
|
||||
stage: "execute",
|
||||
specification: "",
|
||||
config_path: "",
|
||||
},
|
||||
autonomy: "full",
|
||||
};
|
||||
|
||||
const result = await backend.execute(request);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain("task completed");
|
||||
});
|
||||
|
||||
it("handles tool calls in response", async () => {
|
||||
const writePath = path.join(tempDir, "output.txt");
|
||||
const responses: OllamaChatResponse[] = [
|
||||
{
|
||||
choices: [{
|
||||
message: {
|
||||
content: "",
|
||||
tool_calls: [{
|
||||
function: { name: "writeFile", arguments: JSON.stringify({ path: writePath, content: "hello" }) },
|
||||
}],
|
||||
},
|
||||
}],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
},
|
||||
{
|
||||
choices: [{
|
||||
message: {
|
||||
content: '{"success": true, "output": "file written"}',
|
||||
},
|
||||
}],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 },
|
||||
},
|
||||
];
|
||||
|
||||
let callIndex = 0;
|
||||
class ToolCallBackend extends OllamaBaseBackend {
|
||||
readonly name = "tool-call-test";
|
||||
constructor() {
|
||||
super(undefined);
|
||||
}
|
||||
async isAvailable(): Promise<boolean> { return true; }
|
||||
protected async callModel(): Promise<OllamaChatResponse> {
|
||||
return responses[callIndex++];
|
||||
}
|
||||
protected resolveModel(): string { return "test-model"; }
|
||||
}
|
||||
|
||||
const backend = new ToolCallBackend();
|
||||
const request: BackendRequest = {
|
||||
persona: "executor",
|
||||
workflow: "execute",
|
||||
task: "Write a file",
|
||||
context: {
|
||||
project_path: tempDir,
|
||||
phase: 1,
|
||||
stage: "execute",
|
||||
specification: "",
|
||||
config_path: "",
|
||||
},
|
||||
autonomy: "full",
|
||||
};
|
||||
|
||||
const result = await backend.execute(request);
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.existsSync(writePath)).toBe(true);
|
||||
expect(fs.readFileSync(writePath, "utf-8")).toBe("hello");
|
||||
expect(result.artifacts.length).toBe(1);
|
||||
expect(result.artifacts[0].path).toBe(writePath);
|
||||
});
|
||||
|
||||
it("stops after max tool rounds", async () => {
|
||||
const alwaysToolCall: OllamaChatResponse = {
|
||||
choices: [{
|
||||
message: {
|
||||
content: "",
|
||||
tool_calls: [{
|
||||
function: { name: "readFile", arguments: JSON.stringify({ path: "/etc/hostname" }) },
|
||||
}],
|
||||
},
|
||||
}],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
};
|
||||
|
||||
class InfiniteLoopBackend extends OllamaBaseBackend {
|
||||
readonly name = "infinite-loop";
|
||||
private callCount = 0;
|
||||
constructor() {
|
||||
super(undefined);
|
||||
}
|
||||
async isAvailable(): Promise<boolean> { return true; }
|
||||
protected async callModel(): Promise<OllamaChatResponse> {
|
||||
this.callCount++;
|
||||
return alwaysToolCall;
|
||||
}
|
||||
protected resolveModel(): string { return "test-model"; }
|
||||
getCallCount() { return this.callCount; }
|
||||
}
|
||||
|
||||
const backend = new InfiniteLoopBackend();
|
||||
const request: BackendRequest = {
|
||||
persona: "executor",
|
||||
workflow: "execute",
|
||||
task: "Infinite loop test",
|
||||
context: {
|
||||
project_path: tempDir,
|
||||
phase: 1,
|
||||
stage: "execute",
|
||||
specification: "",
|
||||
config_path: "",
|
||||
},
|
||||
autonomy: "full",
|
||||
};
|
||||
|
||||
const result = await backend.execute(request);
|
||||
expect(result.output).toContain("maximum rounds");
|
||||
expect(backend.getCallCount()).toBe(50);
|
||||
});
|
||||
|
||||
it("handles error from callModel gracefully", async () => {
|
||||
class ErrorBackend extends OllamaBaseBackend {
|
||||
readonly name = "error-backend";
|
||||
constructor() {
|
||||
super(undefined);
|
||||
}
|
||||
async isAvailable(): Promise<boolean> { return true; }
|
||||
protected async callModel(): Promise<OllamaChatResponse> {
|
||||
throw new Error("Model connection failed");
|
||||
}
|
||||
protected resolveModel(): string { return "test-model"; }
|
||||
}
|
||||
|
||||
const backend = new ErrorBackend();
|
||||
const request: BackendRequest = {
|
||||
persona: "executor",
|
||||
workflow: "execute",
|
||||
task: "Fail test",
|
||||
context: {
|
||||
project_path: tempDir,
|
||||
phase: 1,
|
||||
stage: "execute",
|
||||
specification: "",
|
||||
config_path: "",
|
||||
},
|
||||
autonomy: "full",
|
||||
};
|
||||
|
||||
const result = await backend.execute(request);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Backend execution failed");
|
||||
});
|
||||
|
||||
it("modelProfileToModel selects smallest for speed", () => {
|
||||
const backend = new TestableOllamaBaseBackend({} as OllamaChatResponse);
|
||||
const models = ["llama3.1:70b", "llama3.1:8b", "llama3.1"];
|
||||
const selected = (backend as unknown as { modelProfileToModel: (p: string, m: string[]) => string }).modelProfileToModel("speed", models);
|
||||
expect(selected).toBe("llama3.1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as os from "node:os";
|
||||
import { OllamaCloudBackend } from "../backends/ollama-cloud.js";
|
||||
|
||||
describe("OllamaCloudBackend Retry/Rate-Limit", () => {
|
||||
describe("configuration", () => {
|
||||
it("uses default config when none provided", () => {
|
||||
const backend = new OllamaCloudBackend();
|
||||
expect(backend.name).toBe("ollama-cloud");
|
||||
expect(backend.type).toBe("llm");
|
||||
});
|
||||
|
||||
it("accepts custom config", () => {
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "https://custom.api.com",
|
||||
api_key_env: "MY_API_KEY",
|
||||
model_profile: "quality",
|
||||
timeout_ms: 30000,
|
||||
});
|
||||
expect(backend).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAvailable", () => {
|
||||
it("returns false when base_url is empty", async () => {
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "",
|
||||
api_key_env: "KEY",
|
||||
model_profile: "quality",
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no API key in environment", async () => {
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "https://api.example.com",
|
||||
api_key_env: "NONEXISTENT_API_KEY_VAR_98765",
|
||||
model_profile: "quality",
|
||||
timeout_ms: 5000,
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unreachable endpoint", async () => {
|
||||
process.env.TEST_OLLAMA_CLOUD_KEY = "test-key";
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "http://localhost:1",
|
||||
api_key_env: "TEST_OLLAMA_CLOUD_KEY",
|
||||
model_profile: "quality",
|
||||
timeout_ms: 5000,
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
delete process.env.TEST_OLLAMA_CLOUD_KEY;
|
||||
});
|
||||
});
|
||||
|
||||
describe("retry behavior", () => {
|
||||
it("MAX_RETRIES is 3", () => {
|
||||
const source = OllamaCloudBackend.toString();
|
||||
expect(source).toBeDefined();
|
||||
});
|
||||
|
||||
it("BASE_BACKOFF_MS is 1000", () => {
|
||||
const source = OllamaCloudBackend.toString();
|
||||
expect(source).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("authentication", () => {
|
||||
it("uses API key from environment variable", () => {
|
||||
process.env.TEST_CI_CLOUD_KEY = "sk-test-key-123";
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "https://api.example.com",
|
||||
api_key_env: "TEST_CI_CLOUD_KEY",
|
||||
model_profile: "quality",
|
||||
});
|
||||
expect(backend).toBeDefined();
|
||||
delete process.env.TEST_CI_CLOUD_KEY;
|
||||
});
|
||||
|
||||
it("returns false when API key env var is not set", async () => {
|
||||
const backend = new OllamaCloudBackend({
|
||||
base_url: "https://api.example.com",
|
||||
api_key_env: "DEFINITELY_NOT_SET_99999",
|
||||
model_profile: "quality",
|
||||
timeout_ms: 5000,
|
||||
});
|
||||
expect(await backend.isAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { ToolRegistry, TOOL_DEFINITIONS } from "../backends/tool-registry.js";
|
||||
|
||||
describe("ToolRegistry Extended", () => {
|
||||
let tempDir: string;
|
||||
let registry: ToolRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-tool-registry-ext-"));
|
||||
registry = new ToolRegistry(tempDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("readFile edge cases", () => {
|
||||
it("reads empty file", () => {
|
||||
const filePath = path.join(tempDir, "empty.txt");
|
||||
fs.writeFileSync(filePath, "");
|
||||
const result = registry.execute({ name: "readFile", arguments: { path: filePath } });
|
||||
expect(result.content).toBe("");
|
||||
expect(result.isError).toBeFalsy();
|
||||
});
|
||||
|
||||
it("reads file with unicode content", () => {
|
||||
const filePath = path.join(tempDir, "unicode.txt");
|
||||
fs.writeFileSync(filePath, "héllo wörld 🌍");
|
||||
const result = registry.execute({ name: "readFile", arguments: { path: filePath } });
|
||||
expect(result.content).toBe("héllo wörld 🌍");
|
||||
});
|
||||
|
||||
it("handles unreadable file gracefully", () => {
|
||||
if (process.getuid?.() === 0) return;
|
||||
const filePath = path.join(tempDir, "unreadable.txt");
|
||||
fs.writeFileSync(filePath, "data");
|
||||
fs.chmodSync(filePath, 0o000);
|
||||
const result = registry.execute({ name: "readFile", arguments: { path: filePath } });
|
||||
expect(result.isError).toBe(true);
|
||||
fs.chmodSync(filePath, 0o644);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeFile edge cases", () => {
|
||||
it("overwrites existing file", () => {
|
||||
const filePath = path.join(tempDir, "overwrite.txt");
|
||||
fs.writeFileSync(filePath, "old");
|
||||
const result = registry.execute({ name: "writeFile", arguments: { path: filePath, content: "new" } });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("new");
|
||||
});
|
||||
|
||||
it("creates nested directories", () => {
|
||||
const filePath = path.join(tempDir, "a", "b", "c", "deep.txt");
|
||||
const result = registry.execute({ name: "writeFile", arguments: { path: filePath, content: "deep" } });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("deep");
|
||||
});
|
||||
});
|
||||
|
||||
describe("editFile edge cases", () => {
|
||||
it("replaces only first occurrence", () => {
|
||||
const filePath = path.join(tempDir, "multi.txt");
|
||||
fs.writeFileSync(filePath, "aaa bbb aaa");
|
||||
const result = registry.execute({ name: "editFile", arguments: { path: filePath, old: "aaa", new: "zzz" } });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("zzz bbb aaa");
|
||||
});
|
||||
|
||||
it("handles empty old string", () => {
|
||||
const filePath = path.join(tempDir, "empty-old.txt");
|
||||
fs.writeFileSync(filePath, "hello");
|
||||
const result = registry.execute({ name: "editFile", arguments: { path: filePath, old: "", new: "X" } });
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toContain("X");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runBash edge cases", () => {
|
||||
it("respects cwd argument", () => {
|
||||
const subDir = path.join(tempDir, "subdir");
|
||||
fs.mkdirSync(subDir);
|
||||
const result = registry.execute({ name: "runBash", arguments: { command: "pwd", cwd: subDir } });
|
||||
expect(result.content).toContain("subdir");
|
||||
expect(result.isError).toBeFalsy();
|
||||
});
|
||||
|
||||
it("respects timeout argument", () => {
|
||||
const result = registry.execute({ name: "runBash", arguments: { command: "sleep 100", timeout: 500 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("captures stderr in error output", () => {
|
||||
const result = registry.execute({ name: "runBash", arguments: { command: "echo error >&2 && exit 1" } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content).toContain("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("glob edge cases", () => {
|
||||
it("finds files in subdirectories", () => {
|
||||
const subDir = path.join(tempDir, "src");
|
||||
fs.mkdirSync(subDir);
|
||||
fs.writeFileSync(path.join(subDir, "app.ts"), "");
|
||||
fs.writeFileSync(path.join(subDir, "util.ts"), "");
|
||||
const result = registry.execute({ name: "glob", arguments: { pattern: "**/*.ts" } });
|
||||
const matches = JSON.parse(result.content);
|
||||
expect(matches.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("returns empty array for no matches", () => {
|
||||
const result = registry.execute({ name: "glob", arguments: { pattern: "*.xyz" } });
|
||||
const matches = JSON.parse(result.content);
|
||||
expect(matches).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("grep edge cases", () => {
|
||||
it("supports include pattern filter", () => {
|
||||
fs.writeFileSync(path.join(tempDir, "app.ts"), "const x = 1;\n");
|
||||
fs.writeFileSync(path.join(tempDir, "app.js"), "const x = 1;\n");
|
||||
const result = registry.execute({ name: "grep", arguments: { pattern: "const", include: "*.ts" } });
|
||||
const matches = JSON.parse(result.content);
|
||||
expect(matches.every((m: { file: string }) => m.file.endsWith(".ts"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty for no matches", () => {
|
||||
fs.writeFileSync(path.join(tempDir, "app.ts"), "nothing interesting\n");
|
||||
const result = registry.execute({ name: "grep", arguments: { pattern: "NONEXISTENT_PATTERN_XYZ", include: "*.ts" } });
|
||||
const matches = JSON.parse(result.content);
|
||||
expect(matches).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+163
-12
@@ -545,21 +545,172 @@ export class CiFiles {
|
||||
}
|
||||
|
||||
private parseRequirementsMd(content: string): RequirementsMd {
|
||||
return {
|
||||
v1: [],
|
||||
v2: [],
|
||||
outOfScope: [],
|
||||
traceability: [],
|
||||
};
|
||||
const v1: RequirementsMd["v1"] = [];
|
||||
const v2: RequirementsMd["v2"] = [];
|
||||
|
||||
const v1Section = this.extractSection(content, "## v1 Requirements");
|
||||
if (v1Section) {
|
||||
const categoryBlocks = v1Section.split(/\n### /).filter(Boolean);
|
||||
for (const block of categoryBlocks) {
|
||||
const lines = block.split("\n");
|
||||
const category = lines[0].trim();
|
||||
const items: Array<{ id: string; description: string }> = [];
|
||||
|
||||
for (const line of lines.slice(1)) {
|
||||
const tableMatch = line.match(/^\|\s*([A-Z]+-\d+)\s*\|\s*(.+?)\s*\|/);
|
||||
if (tableMatch) {
|
||||
items.push({ id: tableMatch[1], description: tableMatch[2] });
|
||||
continue;
|
||||
}
|
||||
const listMatch = line.match(/^\s*-?\s*\*?\s*\[?\s*\*?\s*([A-Z]+-\d+)[\]:\s*]*(.+)/);
|
||||
if (listMatch) {
|
||||
items.push({ id: listMatch[1], description: listMatch[2].trim() });
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length > 0) {
|
||||
v1.push({ category, items });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const v2Section = this.extractSection(content, "## v2 Requirements");
|
||||
if (v2Section) {
|
||||
const categoryBlocks = v2Section.split(/\n### /).filter(Boolean);
|
||||
for (const block of categoryBlocks) {
|
||||
const lines = block.split("\n");
|
||||
const category = lines[0].trim();
|
||||
const items: Array<{ id: string; description: string }> = [];
|
||||
|
||||
for (const line of lines.slice(1)) {
|
||||
const tableMatch = line.match(/^\|\s*([A-Z]+-\d+)\s*\|\s*(.+?)\s*\|/);
|
||||
if (tableMatch) {
|
||||
items.push({ id: tableMatch[1], description: tableMatch[2] });
|
||||
continue;
|
||||
}
|
||||
const listMatch = line.match(/^\s*-?\s*\*?\s*\[?\s*\*?\s*([A-Z]+-\d+)[\]:\s*]*(.+)/);
|
||||
if (listMatch) {
|
||||
items.push({ id: listMatch[1], description: listMatch[2].trim() });
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length > 0) {
|
||||
v2.push({ category, items });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const outOfScope: RequirementsMd["outOfScope"] = [];
|
||||
const outSection = this.extractSection(content, "## Out of Scope");
|
||||
if (outSection) {
|
||||
const tableRows = outSection.split("\n").filter((line) => /^\|/.test(line) && !line.includes("---") && !line.includes("Feature"));
|
||||
for (const row of tableRows) {
|
||||
const cols = row.split("|").map((c) => c.trim()).filter(Boolean);
|
||||
if (cols.length >= 2) {
|
||||
outOfScope.push({ feature: cols[0], reason: cols[1] });
|
||||
}
|
||||
}
|
||||
if (outOfScope.length === 0) {
|
||||
const listItems = this.extractListItems(content, "## Out of Scope");
|
||||
for (const item of listItems) {
|
||||
outOfScope.push({ feature: item, reason: "" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const traceability: RequirementsMd["traceability"] = [];
|
||||
const traceSection = this.extractSection(content, "## Traceability");
|
||||
if (traceSection) {
|
||||
const activeHeader = traceSection.includes("Active Milestone")
|
||||
? "## v0.5 Requirements (Active Milestone)"
|
||||
: content.includes("## v1 Requirements")
|
||||
? "## v1 Requirements"
|
||||
: undefined;
|
||||
|
||||
const tableRows = traceSection.split("\n").filter((line) => /^\|/.test(line) && !line.includes("---") && !line.includes("Requirement") && !line.includes("REQ-ID"));
|
||||
for (const row of tableRows) {
|
||||
const cols = row.split("|").map((c) => c.trim()).filter(Boolean);
|
||||
if (cols.length >= 3) {
|
||||
const req = cols[0];
|
||||
const phaseStr = cols[1];
|
||||
const phaseMatch = phaseStr.match(/(\d+)/);
|
||||
const phase = phaseMatch ? parseInt(phaseMatch[1], 10) : 0;
|
||||
const statusStr = cols[2].toLowerCase();
|
||||
const status = ["pending", "in_progress", "complete", "blocked", "covered"].includes(statusStr)
|
||||
? (statusStr === "covered" ? "complete" : statusStr as "pending" | "in_progress" | "complete" | "blocked")
|
||||
: "pending";
|
||||
traceability.push({ requirement: req, phase, status });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allReqIds = new Set<string>();
|
||||
for (const cat of [...v1, ...v2]) {
|
||||
for (const item of cat.items) {
|
||||
allReqIds.add(item.id);
|
||||
}
|
||||
}
|
||||
for (const t of traceability) {
|
||||
allReqIds.add(t.requirement);
|
||||
}
|
||||
const coveredInTrace = new Set(traceability.filter((t) => t.status === "complete").map((t) => t.requirement));
|
||||
for (const reqId of allReqIds) {
|
||||
if (!coveredInTrace.has(reqId)) {
|
||||
traceability.push({ requirement: reqId, phase: 0, status: "pending" });
|
||||
}
|
||||
}
|
||||
|
||||
return { v1, v2, outOfScope, traceability };
|
||||
}
|
||||
|
||||
private parseArchitectureMd(content: string): ArchitectureMd {
|
||||
return {
|
||||
overview: this.extractSection(content, "## Overview") || "",
|
||||
components: [],
|
||||
dataFlow: this.extractSection(content, "## Data Flow") || "",
|
||||
buildOrder: [],
|
||||
};
|
||||
const overview = this.extractSection(content, "## Overview") || "";
|
||||
|
||||
const components: ArchitectureMd["components"] = [];
|
||||
const section = content;
|
||||
const componentRegex = /###\s+(.+)/g;
|
||||
let compMatch;
|
||||
|
||||
const h3Positions: Array<{ name: string; start: number }> = [];
|
||||
while ((compMatch = componentRegex.exec(section)) !== null) {
|
||||
h3Positions.push({ name: compMatch[1].trim(), start: compMatch.index + compMatch[0].length });
|
||||
}
|
||||
|
||||
for (let i = 0; i < h3Positions.length; i++) {
|
||||
const name = h3Positions[i].name;
|
||||
const start = h3Positions[i].start;
|
||||
const end = i + 1 < h3Positions.length ? h3Positions[i + 1].start - (content.substring(h3Positions[i + 1].start - 4, h3Positions[i + 1].start) === "### " ? 4 : 0) : content.length;
|
||||
const block = content.slice(start, end);
|
||||
|
||||
const descMatch = block.match(/[-*]\s*\*?\*?(?:Description|description)\*?\*?\s*[::]\s*(.+)/);
|
||||
const boundaryMatch = block.match(/[-*]\s*\*?\*?(?:Boundaries|boundaries)\*?\*?\s*[::]\s*(.+)/);
|
||||
const depsMatch = block.match(/[-*]\s*\*?\*?(?:Depends on|depends on|Dependencies)\*?\*?\s*[::]\s*(.+)/);
|
||||
|
||||
components.push({
|
||||
name,
|
||||
description: descMatch ? descMatch[1].trim() : "",
|
||||
boundaries: boundaryMatch ? boundaryMatch[1].trim() : "",
|
||||
dependsOn: depsMatch
|
||||
? depsMatch[1].split(",").map((d: string) => d.trim().replace(/\*\*/g, "")).filter(Boolean)
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
const dataFlow = this.extractSection(content, "## Data Flow")
|
||||
|| this.extractSection(content, "## Data flow")
|
||||
|| "";
|
||||
|
||||
const buildOrder: string[] = [];
|
||||
const buildSection = this.extractSection(content, "## Build Order");
|
||||
if (buildSection) {
|
||||
const listItems = buildSection
|
||||
.split("\n")
|
||||
.filter((line) => /^\d+\./.test(line.trim()))
|
||||
.map((line) => line.trim().replace(/^\d+\.\s*/, ""));
|
||||
buildOrder.push(...listItems);
|
||||
}
|
||||
|
||||
return { overview, components, dataFlow, buildOrder };
|
||||
}
|
||||
|
||||
private extractSection(content: string, header: string): string | null {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { CIConfig } from "../types/config.js";
|
||||
|
||||
export interface RetryConfig {
|
||||
@@ -67,12 +68,39 @@ export class ErrorRecovery {
|
||||
}
|
||||
|
||||
async rollback(phase: number, reason: string): Promise<RecoveryResult> {
|
||||
return {
|
||||
recovered: true,
|
||||
strategy: "rollback",
|
||||
attempts: 1,
|
||||
message: `Rolled back phase ${phase}: ${reason}`,
|
||||
};
|
||||
try {
|
||||
const phaseBranch = `phase/${String(phase).padStart(2, "0")}`;
|
||||
const branches = this.git("branch --list");
|
||||
const branchExists = branches.split("\n").some((b) => b.trim().replace(/^\*?\s+/, "") === phaseBranch);
|
||||
|
||||
if (branchExists) {
|
||||
const currentBranch = this.git("rev-parse --abbrev-ref HEAD");
|
||||
if (currentBranch === phaseBranch) {
|
||||
this.git("checkout main");
|
||||
}
|
||||
this.git(`branch -D ${phaseBranch}`);
|
||||
}
|
||||
|
||||
const tag = `v0.5.${phase}`;
|
||||
const tags = this.git("tag -l").split("\n").map((t) => t.trim());
|
||||
if (tags.includes(tag)) {
|
||||
this.git(`tag -d ${tag}`);
|
||||
}
|
||||
|
||||
return {
|
||||
recovered: true,
|
||||
strategy: "rollback",
|
||||
attempts: 1,
|
||||
message: `Rolled back phase ${phase}: ${reason}. Branch ${branchExists ? `${phaseBranch} deleted` : "not found"}. Tag ${tags.includes(tag) ? `${tag} deleted` : "not found"}.`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
recovered: false,
|
||||
strategy: "rollback",
|
||||
attempts: 1,
|
||||
message: `Rollback failed for phase ${phase}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
canAutoDebug(error: string, confidence: number): boolean {
|
||||
@@ -86,4 +114,16 @@ export class ErrorRecovery {
|
||||
getMaxRevisions(): number {
|
||||
return this.config.autonomy.max_revision_iterations;
|
||||
}
|
||||
|
||||
private git(args: string): string {
|
||||
try {
|
||||
return execSync(`git ${args}`, {
|
||||
cwd: this.projectPath,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,33 @@ describe("BehavioralVerification", () => {
|
||||
expect(testFilesCheck?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("passes with specification and requirements", async () => {
|
||||
it("passes with REQUIREMENTS.md", async () => {
|
||||
const ciDir = path.join(tempDir, ".ci");
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ciDir, "specification.md"), "# Test\n## Objective\nBuild it\n\n## Requirements\n- Must have auth\n- Shall support CRUD\n");
|
||||
fs.writeFileSync(path.join(ciDir, "REQUIREMENTS.md"), "# Requirements\n\n| REQ-ID | Requirement | Priority | Phase | Status |\n|--------|-------------|----------|-------|--------|\n| REQ-01 | Must have auth | P0 | 1 | pending |\n");
|
||||
|
||||
const verifier = new BehavioralVerification();
|
||||
const result = await verifier.verify(tempDir, 1);
|
||||
|
||||
const specCheck = result.checks.find((c) => c.name === "Specification requirements traceable");
|
||||
expect(specCheck?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("skips when no REQUIREMENTS.md or PROJECT.md", async () => {
|
||||
const ciDir = path.join(tempDir, ".ci");
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
|
||||
const verifier = new BehavioralVerification();
|
||||
const result = await verifier.verify(tempDir, 1);
|
||||
|
||||
const specCheck = result.checks.find((c) => c.name === "Specification requirements traceable");
|
||||
expect(specCheck?.status).toBe("skipped");
|
||||
});
|
||||
|
||||
it("passes with PROJECT.md when no REQUIREMENTS.md", async () => {
|
||||
const ciDir = path.join(tempDir, ".ci");
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ciDir, "PROJECT.md"), "# Test\n\n## What This Is\nBuild it\n\n## Requirements\n\n### Active\n\n- [ ] Must have auth\n- [ ] Shall support CRUD\n");
|
||||
|
||||
const verifier = new BehavioralVerification();
|
||||
const result = await verifier.verify(tempDir, 1);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { VerificationLayer, VerificationResult, VerificationCheck } from "./types.js";
|
||||
|
||||
const TEST_FRAMEWORK_PATTERNS = [
|
||||
@@ -26,6 +27,7 @@ export class BehavioralVerification extends VerificationLayer {
|
||||
checks.push(this.checkSpecificationRequirements(projectPath));
|
||||
checks.push(this.checkPlanMustHaves(projectPath, phase));
|
||||
checks.push(this.checkCodeHasExports(projectPath));
|
||||
checks.push(this.checkRequirementTestCoverage(projectPath));
|
||||
|
||||
const passed = checks.every((c) => c.status !== "fail");
|
||||
return {
|
||||
@@ -106,15 +108,59 @@ export class BehavioralVerification extends VerificationLayer {
|
||||
}
|
||||
|
||||
private checkSpecificationRequirements(projectPath: string): VerificationCheck {
|
||||
const specPath = path.join(projectPath, ".ci", "specification.md");
|
||||
const reqPath = path.join(projectPath, ".ci", "REQUIREMENTS.md");
|
||||
const projectPath_md = path.join(projectPath, ".ci", "PROJECT.md");
|
||||
|
||||
const specPath = reqPath;
|
||||
if (!fs.existsSync(specPath)) {
|
||||
const altPath = projectPath_md;
|
||||
if (!fs.existsSync(altPath)) {
|
||||
return this.check(
|
||||
"Specification requirements traceable",
|
||||
"skipped",
|
||||
"No REQUIREMENTS.md or PROJECT.md found"
|
||||
);
|
||||
}
|
||||
return this.checkFromProjectMd(altPath);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(specPath, "utf-8");
|
||||
const requirements = content
|
||||
.split("\n")
|
||||
.filter((line) => /^\|.*\|.*\|.*\|/.test(line) && !line.includes("REQ-ID") && !line.includes("---"))
|
||||
.map((line) => {
|
||||
const cols = line.split("|").map((c) => c.trim()).filter(Boolean);
|
||||
return cols.length >= 2 ? cols[1] : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (requirements.length === 0) {
|
||||
const listRequirements = content
|
||||
.split("\n")
|
||||
.filter((line) => line.trim().startsWith("- "))
|
||||
.map((line) => line.trim().slice(2));
|
||||
if (listRequirements.length === 0) {
|
||||
return this.check(
|
||||
"Specification requirements traceable",
|
||||
"warning",
|
||||
"No requirements found in REQUIREMENTS.md"
|
||||
);
|
||||
}
|
||||
return this.check(
|
||||
"Specification requirements traceable",
|
||||
"skipped",
|
||||
"No specification file found"
|
||||
"pass",
|
||||
`Found ${listRequirements.length} requirement(s)`
|
||||
);
|
||||
}
|
||||
|
||||
return this.check(
|
||||
"Specification requirements traceable",
|
||||
"pass",
|
||||
`Found ${requirements.length} requirement(s) in REQUIREMENTS.md`
|
||||
);
|
||||
}
|
||||
|
||||
private checkFromProjectMd(specPath: string): VerificationCheck {
|
||||
const content = fs.readFileSync(specPath, "utf-8");
|
||||
const requirements = content
|
||||
.split("\n")
|
||||
@@ -129,7 +175,7 @@ export class BehavioralVerification extends VerificationLayer {
|
||||
return this.check(
|
||||
"Specification requirements traceable",
|
||||
"warning",
|
||||
"No requirements found in specification"
|
||||
"No requirements found in PROJECT.md"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,6 +220,98 @@ export class BehavioralVerification extends VerificationLayer {
|
||||
);
|
||||
}
|
||||
|
||||
private checkRequirementTestCoverage(projectPath: string): VerificationCheck {
|
||||
const isGitRepo = fs.existsSync(path.join(projectPath, ".git"));
|
||||
if (!isGitRepo) {
|
||||
return this.check(
|
||||
"Requirement test coverage via git log",
|
||||
"skipped",
|
||||
"Not a git repository — cannot check requirement coverage from commit history"
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = execSync(
|
||||
`git log --all --max-count=100 --format="%B%x01"`,
|
||||
{ cwd: projectPath, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 }
|
||||
);
|
||||
|
||||
const coveredReqs = new Set<string>();
|
||||
const ciBlockRegex = /---ci---[\s\S]*?---\/ci---/g;
|
||||
const entries = raw.split("\x01").filter(Boolean);
|
||||
|
||||
for (const entry of entries) {
|
||||
let match;
|
||||
while ((match = ciBlockRegex.exec(entry)) !== null) {
|
||||
const reqMatch = match[0].match(/covered:\s*\[([^\]]*)\]/);
|
||||
if (reqMatch) {
|
||||
const reqs = reqMatch[1].split(",").map((r: string) => r.trim().replace(/['"]/g, "")).filter(Boolean);
|
||||
for (const req of reqs) coveredReqs.add(req);
|
||||
}
|
||||
}
|
||||
ciBlockRegex.lastIndex = 0;
|
||||
}
|
||||
|
||||
const reqPath = path.join(projectPath, ".ci", "REQUIREMENTS.md");
|
||||
if (!fs.existsSync(reqPath)) {
|
||||
return this.check(
|
||||
"Requirement test coverage via git log",
|
||||
"skipped",
|
||||
"No REQUIREMENTS.md found to check coverage against"
|
||||
);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(reqPath, "utf-8");
|
||||
const allReqs = content
|
||||
.split("\n")
|
||||
.filter((line) => /^\|.*\|.*\|.*\|/.test(line) && !line.includes("REQ-ID") && !line.includes("---"))
|
||||
.map((line) => {
|
||||
const cols = line.split("|").map((c) => c.trim()).filter(Boolean);
|
||||
return cols.length >= 1 ? cols[0] : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (allReqs.length === 0) {
|
||||
return this.check(
|
||||
"Requirement test coverage via git log",
|
||||
"skipped",
|
||||
"No requirements with REQ-IDs found in REQUIREMENTS.md"
|
||||
);
|
||||
}
|
||||
|
||||
const covered = allReqs.filter((r) => coveredReqs.has(r));
|
||||
const coveragePct = Math.round((covered.length / allReqs.length) * 100);
|
||||
|
||||
if (coveragePct >= 80) {
|
||||
return this.check(
|
||||
"Requirement test coverage via git log",
|
||||
"pass",
|
||||
`${covered.length}/${allReqs.length} requirements covered (${coveragePct}%)`
|
||||
);
|
||||
}
|
||||
|
||||
if (coveragePct >= 50) {
|
||||
return this.check(
|
||||
"Requirement test coverage via git log",
|
||||
"warning",
|
||||
`${covered.length}/${allReqs.length} requirements covered (${coveragePct}%) — target ≥80%`
|
||||
);
|
||||
}
|
||||
|
||||
return this.check(
|
||||
"Requirement test coverage via git log",
|
||||
"warning",
|
||||
`${covered.length}/${allReqs.length} requirements covered (${coveragePct}%) — significant gaps`
|
||||
);
|
||||
} catch {
|
||||
return this.check(
|
||||
"Requirement test coverage via git log",
|
||||
"skipped",
|
||||
"Could not read git log for requirement coverage"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private checkCodeHasExports(projectPath: string): VerificationCheck {
|
||||
const srcDir = path.join(projectPath, "src");
|
||||
if (!fs.existsSync(srcDir)) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { VerificationLayer, VerificationResult, VerificationCheck } from "./types.js";
|
||||
|
||||
interface CodeFinding {
|
||||
@@ -66,6 +67,7 @@ export class QualityVerification extends VerificationLayer {
|
||||
checks.push(this.checkP2P3Findings(p2p3Findings));
|
||||
checks.push(this.checkTypeScriptStrictness(projectPath));
|
||||
checks.push(this.checkConsistentNaming(projectPath));
|
||||
checks.push(this.checkTypeScriptCompilation(projectPath));
|
||||
|
||||
const hasP0Fail = p0Findings.length > 3;
|
||||
const passed = !hasP0Fail;
|
||||
@@ -226,6 +228,32 @@ export class QualityVerification extends VerificationLayer {
|
||||
);
|
||||
}
|
||||
|
||||
private checkTypeScriptCompilation(projectPath: string): VerificationCheck {
|
||||
const tsconfigPath = path.join(projectPath, "tsconfig.json");
|
||||
if (!fs.existsSync(tsconfigPath)) {
|
||||
return this.check("TypeScript compilation", "skipped", "No tsconfig.json found");
|
||||
}
|
||||
|
||||
try {
|
||||
execSync("npx tsc --noEmit 2>&1", {
|
||||
cwd: projectPath,
|
||||
encoding: "utf-8",
|
||||
timeout: 60000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
return this.check("TypeScript compilation", "pass", "TypeScript compiles without errors");
|
||||
} catch (err) {
|
||||
const execErr = err as { stdout?: string };
|
||||
const output = execErr.stdout || "";
|
||||
const errorCount = (output.match(/error TS/g) || []).length;
|
||||
return this.check(
|
||||
"TypeScript compilation",
|
||||
errorCount > 5 ? "fail" : "warning",
|
||||
`${errorCount} TypeScript compilation error(s)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private collectFiles(dir: string, files: string[]): void {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { VerificationLayer, VerificationResult, VerificationCheck } from "./types.js";
|
||||
|
||||
interface ThreatEntry {
|
||||
@@ -250,24 +251,80 @@ export class SecurityVerification extends VerificationLayer {
|
||||
}
|
||||
|
||||
private checkDependencyVulnerabilities(projectPath: string): VerificationCheck {
|
||||
const packageLockPath = path.join(projectPath, "package-lock.json");
|
||||
if (!fs.existsSync(packageLockPath)) {
|
||||
return this.check(
|
||||
"Dependency audit",
|
||||
"skipped",
|
||||
"No package-lock.json found — cannot audit dependencies"
|
||||
);
|
||||
}
|
||||
|
||||
const packageJsonPath = path.join(projectPath, "package.json");
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return this.check("Dependency audit", "skipped", "No package.json found");
|
||||
}
|
||||
|
||||
return this.check(
|
||||
"Dependency audit",
|
||||
"pass",
|
||||
"Dependency structure available for audit (run `npm audit` for full check)"
|
||||
);
|
||||
try {
|
||||
const result = execSync("npm audit --json 2>/dev/null", {
|
||||
cwd: projectPath,
|
||||
encoding: "utf-8",
|
||||
timeout: 30000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const audit = JSON.parse(result);
|
||||
const vulnerabilities = audit.metadata?.vulnerabilities || {};
|
||||
const high = vulnerabilities.high || 0;
|
||||
const critical = vulnerabilities.critical || 0;
|
||||
const medium = vulnerabilities.moderate || 0;
|
||||
const low = vulnerabilities.low || 0;
|
||||
const total = high + critical + medium + low;
|
||||
|
||||
if (total === 0) {
|
||||
return this.check("Dependency audit", "pass", "No known vulnerabilities in dependencies");
|
||||
}
|
||||
|
||||
if (critical > 0 || high > 0) {
|
||||
return this.check(
|
||||
"Dependency audit",
|
||||
"fail",
|
||||
`${total} vulnerabilities (critical: ${critical}, high: ${high}, medium: ${medium}, low: ${low})`
|
||||
);
|
||||
}
|
||||
|
||||
return this.check(
|
||||
"Dependency audit",
|
||||
"warning",
|
||||
`${total} vulnerabilities (medium: ${medium}, low: ${low}) — no critical/high`
|
||||
);
|
||||
} catch (err) {
|
||||
const output = (err as { stdout?: string }).stdout;
|
||||
if (output) {
|
||||
try {
|
||||
const audit = JSON.parse(output);
|
||||
const vulnerabilities = audit.metadata?.vulnerabilities || {};
|
||||
const high = vulnerabilities.high || 0;
|
||||
const critical = vulnerabilities.critical || 0;
|
||||
const medium = vulnerabilities.moderate || 0;
|
||||
const low = vulnerabilities.low || 0;
|
||||
const total = high + critical + medium + low;
|
||||
|
||||
if (total === 0) {
|
||||
return this.check("Dependency audit", "pass", "No known vulnerabilities in dependencies");
|
||||
}
|
||||
|
||||
if (critical > 0 || high > 0) {
|
||||
return this.check(
|
||||
"Dependency audit",
|
||||
"fail",
|
||||
`${total} vulnerabilities (critical: ${critical}, high: ${high}, medium: ${medium}, low: ${low})`
|
||||
);
|
||||
}
|
||||
|
||||
return this.check(
|
||||
"Dependency audit",
|
||||
"warning",
|
||||
`${total} vulnerabilities (medium: ${medium}, low: ${low}) — no critical/high`
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return this.check(
|
||||
"Dependency audit",
|
||||
"skipped",
|
||||
"npm audit not available — run manually for full check"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user