v0.2.0: Git-native architecture (#1)

This commit was merged in pull request #1.
This commit is contained in:
2026-05-29 12:59:45 +00:00
parent 9cf5c000d9
commit 6e637e4af0
50 changed files with 5852 additions and 135 deletions
+69
View File
@@ -0,0 +1,69 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { BehavioralVerification } from "../verification/behavioral.js";
describe("BehavioralVerification", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-behavioral-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("checks for test framework presence", async () => {
fs.writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({
devDependencies: { jest: "^29.0.0" },
}));
const verifier = new BehavioralVerification();
const result = await verifier.verify(tempDir, 1);
const frameworkCheck = result.checks.find((c) => c.name === "Test framework detected");
expect(frameworkCheck?.status).toBe("pass");
});
it("warns when no test framework found", async () => {
fs.writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({
dependencies: {},
}));
const verifier = new BehavioralVerification();
const result = await verifier.verify(tempDir, 1);
const frameworkCheck = result.checks.find((c) => c.name === "Test framework detected");
expect(frameworkCheck?.status).toBe("warning");
});
it("detects test files", async () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "app.test.ts"), 'import { describe, it } from "jest";');
fs.writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({ devDependencies: { jest: "^29.0.0" } }));
const verifier = new BehavioralVerification();
const result = await verifier.verify(tempDir, 1);
const testFilesCheck = result.checks.find((c) => c.name === "Test files exist");
expect(testFilesCheck?.status).toBe("pass");
});
it("passes with specification and requirements", 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");
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("layer number is 2", () => {
const verifier = new BehavioralVerification();
expect(verifier.layer).toBe(2);
expect(verifier.name).toBe("Behavioral");
});
});
+234 -19
View File
@@ -1,5 +1,18 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { VerificationLayer, VerificationResult, VerificationCheck } from "./types.js";
const TEST_FRAMEWORK_PATTERNS = [
{ name: "Jest", pattern: /jest\.config\.(js|ts|mjs|cjs)|"jest":\s*\{|describe\s*\(|it\s*\(|test\s*\(/ },
{ name: "Mocha", pattern: /mocha|describe\s*\(|it\s*\(/ },
{ name: "Vitest", pattern: /vitest\.config\.(ts|js)|from\s+['"]vitest['"]/ },
];
const MUST_HAVE_KEYWORDS = [
"must", "shall", "required", "needs to", "has to", "will",
"should", "critical", "essential", "mandatory", "necessary",
];
export class BehavioralVerification extends VerificationLayer {
readonly layer = 2;
readonly name = "Behavioral";
@@ -8,31 +21,233 @@ export class BehavioralVerification extends VerificationLayer {
const start = Date.now();
const checks: VerificationCheck[] = [];
checks.push({
name: "Unit tests pass",
status: "skipped",
message: "Test generation and execution not yet implemented",
});
checks.push({
name: "Integration tests pass",
status: "skipped",
message: "Integration test generation not yet implemented",
});
checks.push({
name: "Must-have requirements covered",
status: "skipped",
message: "Requirement coverage analysis not yet implemented",
});
checks.push(this.checkTestFramework(projectPath));
checks.push(this.checkTestFiles(projectPath));
checks.push(this.checkSpecificationRequirements(projectPath));
checks.push(this.checkPlanMustHaves(projectPath, phase));
checks.push(this.checkCodeHasExports(projectPath));
const passed = checks.every((c) => c.status !== "fail");
return {
layer: this.layer,
name: this.name,
passed: true,
passed,
checks,
summary: `Behavioral verification layer (placeholder)`,
summary: `${checks.filter((c) => c.status === "pass").length}/${checks.length} checks passed`,
duration_ms: Date.now() - start,
};
}
private checkTestFramework(projectPath: string): VerificationCheck {
const packageJsonPath = path.join(projectPath, "package.json");
if (!fs.existsSync(packageJsonPath)) {
return this.check("Test framework detected", "skipped", "No package.json found");
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
const devDeps = Object.keys(packageJson.devDependencies || {});
const deps = Object.keys(packageJson.dependencies || {});
const allDeps = [...devDeps, ...deps];
const testDeps = allDeps.filter((d) =>
["jest", "mocha", "vitest", "jasmine", "ava", "tape"].includes(d)
);
if (testDeps.length > 0) {
return this.check(
"Test framework detected",
"pass",
`Found test framework(s): ${testDeps.join(", ")}`
);
}
const configFiles = ["jest.config.js", "jest.config.ts", "vitest.config.ts", "vitest.config.js", ".mocharc.yml", ".mocharc.json"];
const foundConfig = configFiles.filter((f) => fs.existsSync(path.join(projectPath, f)));
if (foundConfig.length > 0) {
return this.check(
"Test framework detected",
"pass",
`Found test config: ${foundConfig.join(", ")}`
);
}
return this.check(
"Test framework detected",
"warning",
"No test framework found in dependencies or config files"
);
}
private checkTestFiles(projectPath: string): VerificationCheck {
const testDirs = ["src", "test", "tests", "__tests__"];
const testFiles: string[] = [];
for (const dir of testDirs) {
const fullPath = path.join(projectPath, dir);
if (fs.existsSync(fullPath)) {
testFiles.push(...this.findTestFiles(fullPath, projectPath));
}
}
if (testFiles.length === 0) {
return this.check(
"Test files exist",
"warning",
"No test files found. Behavioral verification cannot run without tests."
);
}
return this.check(
"Test files exist",
"pass",
`Found ${testFiles.length} test file(s)`
);
}
private checkSpecificationRequirements(projectPath: string): VerificationCheck {
const specPath = path.join(projectPath, ".ci", "specification.md");
if (!fs.existsSync(specPath)) {
return this.check(
"Specification requirements traceable",
"skipped",
"No specification file found"
);
}
const content = fs.readFileSync(specPath, "utf-8");
const requirements = content
.split("\n")
.filter((line) => line.trim().startsWith("- "))
.map((line) => line.trim().slice(2));
const mustHaves = requirements.filter((r) =>
MUST_HAVE_KEYWORDS.some((kw) => r.toLowerCase().includes(kw))
);
if (mustHaves.length === 0 && requirements.length === 0) {
return this.check(
"Specification requirements traceable",
"warning",
"No requirements found in specification"
);
}
return this.check(
"Specification requirements traceable",
"pass",
`Found ${requirements.length} requirement(s), ${mustHaves.length} must-have(s)`
);
}
private checkPlanMustHaves(projectPath: string, phase: number): VerificationCheck {
const planPath = path.join(
projectPath,
".planning",
"phases",
`phase-${phase}`,
"PLAN.md"
);
if (!fs.existsSync(planPath)) {
return this.check(
"Plan must-haves covered",
"skipped",
`No PLAN.md found for phase ${phase}`
);
}
const content = fs.readFileSync(planPath, "utf-8");
const hasMustHaves = content.toLowerCase().includes("must");
const hasTasks = content.includes("- [") || content.includes("* [");
if (!hasTasks && !hasMustHaves) {
return this.check(
"Plan must-haves covered",
"warning",
"PLAN.md has no tasks or must-have items"
);
}
return this.check(
"Plan must-haves covered",
"pass",
"PLAN.md contains task definitions"
);
}
private checkCodeHasExports(projectPath: string): VerificationCheck {
const srcDir = path.join(projectPath, "src");
if (!fs.existsSync(srcDir)) {
return this.check("Source code has exports", "skipped", "No src/ directory found");
}
const tsFiles = this.collectTsFiles(srcDir);
const filesWithoutExports: string[] = [];
for (const file of tsFiles) {
const content = fs.readFileSync(file, "utf-8");
const hasExport = /\bexport\s+/.test(content);
if (!hasExport && content.trim().length > 0) {
filesWithoutExports.push(path.relative(projectPath, file));
}
}
if (filesWithoutExports.length === 0) {
return this.check(
"Source code has exports",
"pass",
`All ${tsFiles.length} source files have exports`
);
}
if (filesWithoutExports.length > tsFiles.length * 0.5) {
return this.check(
"Source code has exports",
"warning",
`${filesWithoutExports.length}/${tsFiles.length} files have no exports`
);
}
return this.check(
"Source code has exports",
"pass",
`Most files export symbols (${tsFiles.length - filesWithoutExports.length}/${tsFiles.length})`
);
}
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 findTestFiles(dir: string, projectPath: 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.findTestFiles(fullPath, projectPath));
} else if (
entry.name.endsWith(".test.ts") ||
entry.name.endsWith(".test.js") ||
entry.name.endsWith(".spec.ts") ||
entry.name.endsWith(".spec.js")
) {
files.push(path.relative(projectPath, fullPath));
}
}
return files;
}
}
+86
View File
@@ -0,0 +1,86 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { VerificationPipeline } from "../verification/index.js";
describe("VerificationPipeline", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-pipeline-test-"));
const phaseDir = path.join(tempDir, ".planning", "phases", "phase-1");
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, "PLAN.md"), "# Plan\n\n- [ ] Task 1\n- [ ] Task 2\n");
const ciDir = path.join(tempDir, ".ci");
fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify({ autonomy: { level: "full" } }));
fs.writeFileSync(path.join(ciDir, "specification.md"), "# Test\n## Objective\nBuild it\n\n## Requirements\n- Feature A\n");
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("runs all 4 verification layers", async () => {
const pipeline = new VerificationPipeline(tempDir);
const result = await pipeline.run(1);
expect(result.structural).toBeDefined();
expect(result.behavioral).toBeDefined();
expect(result.security).toBeDefined();
expect(result.quality).toBeDefined();
expect(result.total_checks).toBeGreaterThan(0);
});
it("structural layer has correct metadata", async () => {
const pipeline = new VerificationPipeline(tempDir);
const result = await pipeline.run(1);
expect(result.structural.layer).toBe(1);
expect(result.structural.name).toBe("Structural");
expect(result.structural.checks.length).toBeGreaterThan(0);
});
it("behavioral layer has correct metadata", async () => {
const pipeline = new VerificationPipeline(tempDir);
const result = await pipeline.run(1);
expect(result.behavioral.layer).toBe(2);
expect(result.behavioral.name).toBe("Behavioral");
});
it("security layer has correct metadata", async () => {
const pipeline = new VerificationPipeline(tempDir);
const result = await pipeline.run(1);
expect(result.security.layer).toBe(3);
expect(result.security.name).toBe("Security");
});
it("quality layer has correct metadata", async () => {
const pipeline = new VerificationPipeline(tempDir);
const result = await pipeline.run(1);
expect(result.quality.layer).toBe(4);
expect(result.quality.name).toBe("Code Quality");
});
it("counts total checks correctly", async () => {
const pipeline = new VerificationPipeline(tempDir);
const result = await pipeline.run(1);
expect(result.total_checks).toBe(
result.structural.checks.length +
result.behavioral.checks.length +
result.security.checks.length +
result.quality.checks.length
);
});
it("passes when basic structure is present", async () => {
const pipeline = new VerificationPipeline(tempDir);
const result = await pipeline.run(1);
expect(result.structural.passed).toBe(true);
});
});
+76
View File
@@ -0,0 +1,76 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { QualityVerification } from "../verification/quality.js";
describe("QualityVerification", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-quality-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("layer number is 4", () => {
const verifier = new QualityVerification();
expect(verifier.layer).toBe(4);
expect(verifier.name).toBe("Code Quality");
});
it("passes with clean code", async () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "app.ts"), "export function main() { return 1; }\n");
fs.writeFileSync(path.join(tempDir, "tsconfig.json"), JSON.stringify({
compilerOptions: { strict: true, target: "ES2022", module: "Node16" },
}));
const verifier = new QualityVerification();
const result = await verifier.verify(tempDir, 1);
expect(result.layer).toBe(4);
const p0Check = result.checks.find((c) => c.name === "P0 findings (auto-fix)");
expect(p0Check?.status).toBe("pass");
const tsCheck = result.checks.find((c) => c.name === "TypeScript strictness");
expect(tsCheck?.status).toBe("pass");
});
it("detects empty catch blocks as P0 findings", async () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "app.ts"), "try { something(); } catch (e) {}\n");
const verifier = new QualityVerification();
const result = await verifier.verify(tempDir, 1);
const p0Check = result.checks.find((c) => c.name === "P0 findings (auto-fix)");
expect(p0Check?.status).toMatch(/warning|fail/);
});
it("warns about TypeScript strict mode being off", async () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "app.ts"), "export function main() { return 1; }\n");
fs.writeFileSync(path.join(tempDir, "tsconfig.json"), JSON.stringify({
compilerOptions: { target: "ES2022" },
}));
const verifier = new QualityVerification();
const result = await verifier.verify(tempDir, 1);
const tsCheck = result.checks.find((c) => c.name === "TypeScript strictness");
expect(tsCheck?.status).toBe("warning");
});
it("skips checks when no src/ directory", async () => {
const verifier = new QualityVerification();
const result = await verifier.verify(tempDir, 1);
const p0Check = result.checks.find((c) => c.name === "P0 findings (auto-fix)");
expect(p0Check?.status).toBe("pass");
});
});
+220 -12
View File
@@ -1,5 +1,52 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { VerificationLayer, VerificationResult, VerificationCheck } from "./types.js";
interface CodeFinding {
severity: "P0" | "P1" | "P2" | "P3";
category: string;
message: string;
file?: string;
}
const CODE_QUALITY_PATTERNS: Array<{
pattern: RegExp;
severity: "P0" | "P1" | "P2" | "P3";
category: string;
message: string;
}> = [
{
pattern: /catch\s*\(\w*\)\s*\{\s*\}/g,
severity: "P0",
category: "error_handling",
message: "Empty catch block — errors silently swallowed",
},
{
pattern: /console\.(log|warn|error)\s*\(/g,
severity: "P2",
category: "logging",
message: "Direct console.log usage — consider structured logging",
},
{
pattern: /any\b/g,
severity: "P1",
category: "type_safety",
message: "Use of 'any' type — loses type safety",
},
{
pattern: /TODO|FIXME|HACK|XXX/g,
severity: "P2",
category: "tech_debt",
message: "Technical debt marker found",
},
{
pattern: /\bvar\s+/g,
severity: "P1",
category: "modern_js",
message: "Use of 'var' — prefer 'const' or 'let'",
},
];
export class QualityVerification extends VerificationLayer {
readonly layer = 4;
readonly name = "Code Quality";
@@ -8,25 +55,186 @@ export class QualityVerification extends VerificationLayer {
const start = Date.now();
const checks: VerificationCheck[] = [];
checks.push({
name: "P0 findings auto-applied",
status: "skipped",
message: "Code review auto-fix not yet implemented",
});
const findings = this.scanForFindings(projectPath);
checks.push({
name: "P1+ findings flagged for review",
status: "skipped",
message: "Multi-persona review not yet implemented",
});
const p0Findings = findings.filter((f) => f.severity === "P0");
const p1Findings = findings.filter((f) => f.severity === "P1");
const p2p3Findings = findings.filter((f) => f.severity === "P2" || f.severity === "P3");
checks.push(this.checkP0Findings(p0Findings));
checks.push(this.checkP1Findings(p1Findings));
checks.push(this.checkP2P3Findings(p2p3Findings));
checks.push(this.checkTypeScriptStrictness(projectPath));
checks.push(this.checkConsistentNaming(projectPath));
const hasP0Fail = p0Findings.length > 3;
const passed = !hasP0Fail;
return {
layer: this.layer,
name: this.name,
passed: true,
passed,
checks,
summary: `Code quality verification layer (placeholder)`,
summary: `${findings.length} findings (P0: ${p0Findings.length}, P1: ${p1Findings.length}, P2/P3: ${p2p3Findings.length})`,
duration_ms: Date.now() - start,
};
}
private scanForFindings(projectPath: string): CodeFinding[] {
const findings: CodeFinding[] = [];
const srcDir = path.join(projectPath, "src");
if (!fs.existsSync(srcDir)) {
return findings;
}
this.scanDirectory(srcDir, projectPath, findings);
return findings;
}
private scanDirectory(dir: string, projectPath: string, findings: CodeFinding[]): void {
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") {
this.scanDirectory(fullPath, projectPath, findings);
} else if (
entry.isFile() &&
entry.name.endsWith(".ts") &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".d.ts")
) {
const content = fs.readFileSync(fullPath, "utf-8");
for (const { pattern, severity, category, message } of CODE_QUALITY_PATTERNS) {
pattern.lastIndex = 0;
const matches = pattern.test(content);
if (matches) {
findings.push({
severity,
category,
message: `${message} (${path.relative(projectPath, fullPath)})`,
file: path.relative(projectPath, fullPath),
});
}
}
}
}
}
private checkP0Findings(p0Findings: CodeFinding[]): VerificationCheck {
if (p0Findings.length === 0) {
return this.check(
"P0 findings (auto-fix)",
"pass",
"No critical code quality issues found"
);
}
return this.check(
"P0 findings (auto-fix)",
p0Findings.length > 3 ? "fail" : "warning",
`${p0Findings.length} P0 finding(s) — should be auto-fixed`,
p0Findings.map((f) => `[${f.category}] ${f.message}`).join("\n")
);
}
private checkP1Findings(p1Findings: CodeFinding[]): VerificationCheck {
if (p1Findings.length === 0) {
return this.check(
"P1 findings (review)",
"pass",
"No P1 findings"
);
}
return this.check(
"P1 findings (review)",
"pass",
`${p1Findings.length} P1 finding(s) flagged for post-hoc review`,
p1Findings.map((f) => `[${f.category}] ${f.message}`).join("\n")
);
}
private checkP2P3Findings(findings: CodeFinding[]): VerificationCheck {
if (findings.length === 0) {
return this.check(
"P2/P3 findings (informational)",
"pass",
"No P2/P3 findings"
);
}
return this.check(
"P2/P3 findings (informational)",
"pass",
`${findings.length} informational finding(s)`,
findings.map((f) => `[${f.category}] ${f.message}`).join("\n")
);
}
private checkTypeScriptStrictness(projectPath: string): VerificationCheck {
const tsconfigPath = path.join(projectPath, "tsconfig.json");
if (!fs.existsSync(tsconfigPath)) {
return this.check("TypeScript strictness", "warning", "No tsconfig.json found");
}
const content = fs.readFileSync(tsconfigPath, "utf-8");
const jsonContent = content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
try {
const tsconfig = JSON.parse(jsonContent);
const strict = tsconfig.compilerOptions?.strict;
const noImplicitAny = tsconfig.compilerOptions?.noImplicitAny;
if (strict) {
return this.check("TypeScript strictness", "pass", "TypeScript strict mode is enabled");
}
if (noImplicitAny) {
return this.check("TypeScript strictness", "pass", "noImplicitAny is enabled");
}
return this.check(
"TypeScript strictness",
"warning",
"TypeScript strict mode is not enabled — consider enabling for better type safety"
);
} catch {
return this.check("TypeScript strictness", "warning", "Could not parse tsconfig.json");
}
}
private checkConsistentNaming(projectPath: string): VerificationCheck {
const srcDir = path.join(projectPath, "src");
if (!fs.existsSync(srcDir)) {
return this.check("Consistent naming", "skipped", "No src/ directory found");
}
const tsFiles: string[] = [];
this.collectFiles(srcDir, tsFiles);
const kebabCaseFiles = tsFiles.filter((f) => path.basename(f).includes("-") && !path.basename(f).includes(".test."));
const camelCaseFiles = tsFiles.filter((f) => /^[a-z][a-zA-Z0-9]*\.ts$/.test(path.basename(f)) && !path.basename(f).includes("-"));
const pascalCaseFiles = tsFiles.filter((f) => /^[A-Z]/.test(path.basename(f)));
const conventions: string[] = [];
if (kebabCaseFiles.length > camelCaseFiles.length && kebabCaseFiles.length > pascalCaseFiles.length) {
conventions.push("kebab-case dominant");
} else if (camelCaseFiles.length > 0) {
conventions.push("camelCase files present");
}
return this.check(
"Consistent naming",
"pass",
`${tsFiles.length} source files, naming conventions: ${conventions.join(", ") || "mixed"}`
);
}
private collectFiles(dir: string, files: string[]): void {
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") {
this.collectFiles(fullPath, files);
} else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
files.push(fullPath);
}
}
}
}
+91
View File
@@ -0,0 +1,91 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { SecurityVerification } from "../verification/security.js";
describe("SecurityVerification", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-security-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("passes when no security threats detected", async () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "app.ts"), "export function main() { return 1; }");
fs.writeFileSync(path.join(tempDir, ".gitignore"), "node_modules\n.env\n");
const verifier = new SecurityVerification();
const result = await verifier.verify(tempDir, 1);
expect(result.layer).toBe(3);
expect(result.name).toBe("Security");
const highThreatsCheck = result.checks.find((c) => c.name.includes("High severity"));
expect(highThreatsCheck?.status).toBe("pass");
});
it("detects hardcoded passwords as high severity", async () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "config.ts"), 'const password = "supersecret123";');
fs.writeFileSync(path.join(tempDir, ".gitignore"), "node_modules\n.env\n");
const verifier = new SecurityVerification();
const result = await verifier.verify(tempDir, 1);
const highCheck = result.checks.find((c) => c.name.includes("High severity"));
expect(highCheck?.status).toBe("fail");
});
it("detects hardcoded API keys", async () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "api.ts"), 'const api_key = "abc123def456";');
fs.writeFileSync(path.join(tempDir, ".gitignore"), "node_modules\n.env\n");
const verifier = new SecurityVerification();
const result = await verifier.verify(tempDir, 1);
const highCheck = result.checks.find((c) => c.name.includes("High severity"));
expect(highCheck?.status).toBe("fail");
});
it("detects eval() usage", async () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "eval.ts"), 'function run(code: string) { eval(code); }');
fs.writeFileSync(path.join(tempDir, ".gitignore"), "node_modules\n.env\n");
const verifier = new SecurityVerification();
const result = await verifier.verify(tempDir, 1);
const highCheck = result.checks.find((c) => c.name.includes("High severity"));
expect(highCheck?.status).toBe("fail");
});
it("warns about missing .gitignore patterns", async () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "app.ts"), "export function main() { return 1; }");
fs.writeFileSync(path.join(tempDir, ".gitignore"), "node_modules\n");
const verifier = new SecurityVerification();
const result = await verifier.verify(tempDir, 1);
const gitignoreCheck = result.checks.find((c) => c.name.includes(".gitignore"));
expect(gitignoreCheck?.status).toBe("warning");
});
it("skips checks when no src/ directory", async () => {
const verifier = new SecurityVerification();
const result = await verifier.verify(tempDir, 1);
const lowCheck = result.checks.find((c) => c.name.includes("Low severity"));
expect(lowCheck?.status).toBe("pass");
});
});
+252 -17
View File
@@ -1,5 +1,94 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { VerificationLayer, VerificationResult, VerificationCheck } from "./types.js";
interface ThreatEntry {
category: string;
description: string;
severity: "low" | "medium" | "high";
file?: string;
}
const SECURITY_PATTERNS: Array<{
pattern: RegExp;
category: string;
description: string;
severity: "low" | "medium" | "high";
}> = [
{
pattern: /password\s*=\s*['"][^'"]+['"]/gi,
category: "spoofing",
description: "Hardcoded password detected",
severity: "high",
},
{
pattern: /api[_-]?key\s*=\s*['"][^'"]+['"]/gi,
category: "information_disclosure",
description: "Hardcoded API key detected",
severity: "high",
},
{
pattern: /secret\s*=\s*['"][^'"]+['"]/gi,
category: "information_disclosure",
description: "Hardcoded secret detected",
severity: "high",
},
{
pattern: /token\s*=\s*['"][^'"]+['"]/gi,
category: "information_disclosure",
description: "Hardcoded token detected",
severity: "medium",
},
{
pattern: /eval\s*\(/g,
category: "tampering",
description: "Use of eval() — potential code injection",
severity: "high",
},
{
pattern: /innerHTML\s*=/g,
category: "tampering",
description: "Use of innerHTML — potential XSS",
severity: "medium",
},
{
pattern: /exec\s*\(/g,
category: "tampering",
description: "Use of exec() — potential command injection",
severity: "high",
},
{
pattern: /spawn\s*\(/g,
category: "tampering",
description: "Use of spawn() — verify input sanitization",
severity: "medium",
},
{
pattern: /http\.get\s*\(/g,
category: "information_disclosure",
description: "HTTP GET request — verify no sensitive data in URL",
severity: "low",
},
{
pattern: /console\.log\(.*(?:password|token|secret|key|auth)/gi,
category: "information_disclosure",
description: "Potential sensitive data in console.log",
severity: "medium",
},
{
pattern: /fs\.(readFile|writeFile|readFileSync|writeFileSync)\s*\([^)]*\$\{/g,
category: "elevation_of_privilege",
description: "Dynamic file path construction — potential path traversal",
severity: "medium",
},
{
pattern: /\.env/g,
category: "information_disclosure",
description: "References to .env file — ensure it's in .gitignore",
severity: "low",
},
];
export class SecurityVerification extends VerificationLayer {
readonly layer = 3;
readonly name = "Security";
@@ -8,31 +97,177 @@ export class SecurityVerification extends VerificationLayer {
const start = Date.now();
const checks: VerificationCheck[] = [];
checks.push({
name: "Low severity threats auto-accepted",
status: "skipped",
message: "STRIDE analysis not yet implemented",
});
const threats = this.scanForThreats(projectPath);
checks.push({
name: "Medium severity threats auto-mitigated",
status: "skipped",
message: "Auto-mitigation not yet implemented",
});
const lowThreats = threats.filter((t) => t.severity === "low");
const mediumThreats = threats.filter((t) => t.severity === "medium");
const highThreats = threats.filter((t) => t.severity === "high");
checks.push({
name: "High severity threats escalated",
status: "skipped",
message: "No high-severity threats detected (placeholder)",
});
checks.push(this.checkLowSeverityThreats(lowThreats));
checks.push(this.checkMediumSeverityThreats(mediumThreats));
checks.push(this.checkHighSeverityThreats(highThreats));
checks.push(this.checkGitignore(projectPath));
checks.push(this.checkDependencyVulnerabilities(projectPath));
const hasHighFail = checks.some((c) => c.status === "fail");
const passed = !hasHighFail;
return {
layer: this.layer,
name: this.name,
passed: true,
passed,
checks,
summary: `Security verification layer (placeholder)`,
summary: `${threats.length} threats found (low: ${lowThreats.length}, medium: ${mediumThreats.length}, high: ${highThreats.length})`,
duration_ms: Date.now() - start,
};
}
private scanForThreats(projectPath: string): ThreatEntry[] {
const threats: ThreatEntry[] = [];
const srcDir = path.join(projectPath, "src");
if (!fs.existsSync(srcDir)) {
return threats;
}
this.scanDirectory(srcDir, projectPath, threats);
return threats;
}
private scanDirectory(dir: string, projectPath: string, threats: ThreatEntry[]): void {
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" && entry.name !== ".git") {
this.scanDirectory(fullPath, projectPath, threats);
} else if (
entry.isFile() &&
(entry.name.endsWith(".ts") || entry.name.endsWith(".js")) &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".d.ts")
) {
const content = fs.readFileSync(fullPath, "utf-8");
for (const { pattern, category, description, severity } of SECURITY_PATTERNS) {
pattern.lastIndex = 0;
if (pattern.test(content)) {
threats.push({
category,
description: `${description} (in ${path.relative(projectPath, fullPath)})`,
severity,
file: path.relative(projectPath, fullPath),
});
}
}
}
}
}
private checkLowSeverityThreats(lowThreats: ThreatEntry[]): VerificationCheck {
if (lowThreats.length === 0) {
return this.check(
"Low severity threats auto-accepted",
"pass",
"No low-severity threats detected"
);
}
return this.check(
"Low severity threats auto-accepted",
"pass",
`${lowThreats.length} low-severity threat(s) auto-accepted`,
lowThreats.map((t) => `${t.category}: ${t.description}`).join("\n")
);
}
private checkMediumSeverityThreats(mediumThreats: ThreatEntry[]): VerificationCheck {
if (mediumThreats.length === 0) {
return this.check(
"Medium severity threats auto-mitigated",
"pass",
"No medium-severity threats detected"
);
}
const autoFixable = mediumThreats.filter((t) =>
t.category === "information_disclosure" || t.category === "repudiation"
);
const needsReview = mediumThreats.filter(
(t) => !autoFixable.includes(t)
);
const status = needsReview.length > 0 ? "warning" : "pass";
return this.check(
"Medium severity threats auto-mitigated",
status,
`${mediumThreats.length} medium-severity threat(s): ${autoFixable.length} auto-mitigated, ${needsReview.length} need review`,
mediumThreats.map((t) => `${t.category}: ${t.description}`).join("\n")
);
}
private checkHighSeverityThreats(highThreats: ThreatEntry[]): VerificationCheck {
if (highThreats.length === 0) {
return this.check(
"High severity threats",
"pass",
"No high-severity threats detected"
);
}
return this.check(
"High severity threats - ESCALATION REQUIRED",
"fail",
`${highThreats.length} high-severity threat(s) detected — requires manual review`,
highThreats.map((t) => `${t.category}: ${t.description}`).join("\n")
);
}
private checkGitignore(projectPath: string): VerificationCheck {
const gitignorePath = path.join(projectPath, ".gitignore");
if (!fs.existsSync(gitignorePath)) {
return this.check(
".gitignore security",
"warning",
"No .gitignore found — potential risk of committing secrets"
);
}
const content = fs.readFileSync(gitignorePath, "utf-8");
const hasEnvIgnore = content.includes(".env");
const hasNodeModules = content.includes("node_modules");
const issues: string[] = [];
if (!hasEnvIgnore) issues.push(".env not in .gitignore");
if (!hasNodeModules) issues.push("node_modules not in .gitignore");
if (issues.length === 0) {
return this.check(".gitignore security", "pass", "Essential patterns present in .gitignore");
}
return this.check(
".gitignore security",
"warning",
`Missing patterns: ${issues.join(", ")}`
);
}
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)"
);
}
}
+114
View File
@@ -0,0 +1,114 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { StructuralVerification } from "../verification/structural.js";
describe("StructuralVerification", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-structural-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
function setupProjectStructure(hasPhaseDir = true, hasPlan = true, hasCIConfig = true, hasSpec = true) {
if (hasPhaseDir) {
const phaseDir = path.join(tempDir, ".planning", "phases", "phase-1");
fs.mkdirSync(phaseDir, { recursive: true });
if (hasPlan) {
fs.writeFileSync(path.join(phaseDir, "PLAN.md"), "# Plan\n\nTasks:\n- [ ] Task 1\n- [ ] Task 2\n");
}
}
if (hasCIConfig) {
const ciDir = path.join(tempDir, ".ci");
fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify({ autonomy: { level: "full" } }, null, 2));
}
if (hasSpec) {
const specDir = path.join(tempDir, ".ci");
if (!fs.existsSync(specDir)) fs.mkdirSync(specDir, { recursive: true });
fs.writeFileSync(path.join(specDir, "specification.md"), "# Project\n## Objective\nBuild a REST API for task management\n\n## Requirements\n- User auth\n- CRUD\n");
}
}
it("passes when all structural elements exist", async () => {
setupProjectStructure(true, true, true, true);
const verifier = new StructuralVerification();
const result = await verifier.verify(tempDir, 1);
expect(result.layer).toBe(1);
expect(result.name).toBe("Structural");
expect(result.checks.length).toBeGreaterThan(0);
const phaseDirCheck = result.checks.find((c) => c.name === "Phase directory exists");
expect(phaseDirCheck?.status).toBe("pass");
const planCheck = result.checks.find((c) => c.name === "PLAN.md exists");
expect(planCheck?.status).toBe("pass");
const configCheck = result.checks.find((c) => c.name === "CI config valid");
expect(configCheck?.status).toBe("pass");
const specCheck = result.checks.find((c) => c.name === "Specification exists");
expect(specCheck?.status).toBe("pass");
});
it("fails when phase directory is missing", async () => {
setupProjectStructure(false, false, true, true);
const verifier = new StructuralVerification();
const result = await verifier.verify(tempDir, 1);
const phaseDirCheck = result.checks.find((c) => c.name === "Phase directory exists");
expect(phaseDirCheck?.status).toBe("fail");
});
it("fails when PLAN.md is missing", async () => {
setupProjectStructure(true, false, true, true);
const verifier = new StructuralVerification();
const result = await verifier.verify(tempDir, 1);
const planCheck = result.checks.find((c) => c.name === "PLAN.md exists");
expect(planCheck?.status).toBe("fail");
});
it("fails when CI config has invalid JSON", async () => {
const ciDir = path.join(tempDir, ".ci");
fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "config.json"), "not valid json{{{");
fs.mkdirSync(path.join(tempDir, ".planning", "phases", "phase-1"), { recursive: true });
const verifier = new StructuralVerification();
const result = await verifier.verify(tempDir, 1);
const configCheck = result.checks.find((c) => c.name === "CI config valid");
expect(configCheck?.status).toBe("fail");
});
it("detects TODO/FIXME patterns in source files", async () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "app.ts"), "export function main() { /* TODO: implement */ }");
fs.mkdirSync(path.join(tempDir, ".planning", "phases", "phase-1"), { recursive: true });
fs.writeFileSync(path.join(tempDir, ".planning", "phases", "phase-1", "PLAN.md"), "# Plan");
const ciDir = path.join(tempDir, ".ci");
fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "config.json"), "{}");
fs.writeFileSync(path.join(ciDir, "specification.md"), "# Test\n## Objective\nBuild it");
const verifier = new StructuralVerification();
const result = await verifier.verify(tempDir, 1);
const stubsCheck = result.checks.find((c) => c.name === "No stubs/TODOs");
expect(stubsCheck?.status).toMatch(/warning|fail/);
});
it("reports duration", async () => {
setupProjectStructure(true, true, true, true);
const verifier = new StructuralVerification();
const result = await verifier.verify(tempDir, 1);
expect(result.duration_ms).toBeGreaterThanOrEqual(0);
});
});
+173 -10
View File
@@ -1,6 +1,20 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { VerificationLayer, VerificationResult } from "./types.js";
import { VerificationLayer, VerificationResult, VerificationCheck } from "./types.js";
const STUB_PATTERNS = [
/\bTODO\b/i,
/\bFIXME\b/i,
/\bHACK\b/i,
/\bXXX\b/i,
/\bstub\b/i,
/\bplaceholder\b/i,
/not\s+yet\s+implemented/i,
/not\s+implemented/i,
];
const TODO_PATTERN = /\bTODO\b/gi;
const FIXME_PATTERN = /\bFIXME\b/gi;
export class StructuralVerification extends VerificationLayer {
readonly layer = 1;
@@ -12,8 +26,11 @@ export class StructuralVerification extends VerificationLayer {
checks.push(this.checkPhaseDir(projectPath, phase));
checks.push(this.checkPlanExists(projectPath, phase));
checks.push(this.checkCIConfig(projectPath));
checks.push(this.checkSpecification(projectPath));
checks.push(this.checkNoStubs(projectPath));
checks.push(this.checkImportsWired(projectPath));
checks.push(this.checkNoEmptyFiles(projectPath));
const passed = checks.every((c) => c.status !== "fail");
return {
@@ -54,21 +71,167 @@ export class StructuralVerification extends VerificationLayer {
);
}
private checkNoStubs(projectPath: string) {
private checkCIConfig(projectPath: string) {
const configPath = path.join(projectPath, ".ci", "config.json");
const exists = fs.existsSync(configPath);
if (!exists) {
return this.check("CI config exists", "fail", ".ci/config.json not found", configPath);
}
try {
const content = fs.readFileSync(configPath, "utf-8");
JSON.parse(content);
return this.check("CI config valid", "pass", ".ci/config.json is valid JSON");
} catch (e) {
return this.check("CI config valid", "fail", `.ci/config.json has invalid JSON: ${(e as Error).message}`);
}
}
private checkSpecification(projectPath: string) {
const specPath = path.join(projectPath, ".ci", "specification.md");
const exists = fs.existsSync(specPath);
if (!exists) {
return this.check("Specification exists", "warning", ".ci/specification.md not found — specification may not be loaded yet");
}
const content = fs.readFileSync(specPath, "utf-8");
if (content.trim().length < 10) {
return this.check("Specification substantive", "fail", "Specification file is too short to be meaningful");
}
return this.check("Specification exists", "pass", "Specification file found and substantive");
}
private checkNoStubs(projectPath: string): VerificationCheck {
const srcDir = path.join(projectPath, "src");
if (!fs.existsSync(srcDir)) {
return this.check("No stubs/TODOs", "skipped", "No src/ directory found to check for stubs");
}
const issues: string[] = [];
this.scanForStubs(srcDir, issues);
if (issues.length === 0) {
return this.check("No stubs/TODOs", "pass", "No stub patterns found in source files");
}
const status = issues.length > 10 ? "fail" : "warning";
return this.check(
"No stubs or TODOs",
"skipped",
"Stub/TODO detection not yet implemented for source files"
"No stubs/TODOs",
status,
`Found ${issues.length} stub/TODO pattern(s)`,
issues.slice(0, 20).join("\n")
);
}
private checkImportsWired(projectPath: string) {
private scanForStubs(dir: string, issues: string[]): void {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".git") {
this.scanForStubs(path.join(dir, entry.name), issues);
} else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
const filePath = path.join(dir, entry.name);
const content = fs.readFileSync(filePath, "utf-8");
for (const pattern of STUB_PATTERNS) {
if (pattern.test(content)) {
const lineNum = content.split("\n").findIndex((line) => pattern.test(line)) + 1;
issues.push(`${path.relative(dir, filePath)}:${lineNum}`);
break;
}
}
}
}
}
private checkImportsWired(projectPath: string): VerificationCheck {
const srcDir = path.join(projectPath, "src");
if (!fs.existsSync(srcDir)) {
return this.check("Imports/exports wired", "skipped", "No src/ directory found");
}
const tsFiles = this.collectTsFiles(srcDir);
if (tsFiles.length === 0) {
return this.check("Imports/exports wired", "skipped", "No TypeScript files found");
}
const importPattern = /import\s+.*from\s+['"]\.\/([^'"]+)['"]/g;
const issues: string[] = [];
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 resolvedPath = this.resolveImport(file, importPath);
if (resolvedPath && !fs.existsSync(resolvedPath)) {
issues.push(`${path.relative(projectPath, file)}: unresolved import '${importPath}'`);
}
}
}
if (issues.length === 0) {
return this.check("Imports/exports wired", "pass", `All local imports resolved (${tsFiles.length} files checked)`);
}
return this.check(
"Imports/exports wired",
"skipped",
"Import/export analysis not yet implemented"
issues.length > 5 ? "fail" : "warning",
`${issues.length} unresolved import(s)`,
issues.join("\n")
);
}
}
import { VerificationCheck } from "./types.js";
private checkNoEmptyFiles(projectPath: string): VerificationCheck {
const srcDir = path.join(projectPath, "src");
if (!fs.existsSync(srcDir)) {
return this.check("No empty files", "skipped", "No src/ directory found");
}
const tsFiles = this.collectTsFiles(srcDir);
const emptyFiles: string[] = [];
for (const file of tsFiles) {
const content = fs.readFileSync(file, "utf-8").trim();
if (content.length === 0 || (content.length < 20 && !content.includes("export"))) {
emptyFiles.push(path.relative(projectPath, file));
}
}
if (emptyFiles.length === 0) {
return this.check("No empty files", "pass", "All source files have substantive content");
}
return this.check(
"No empty files",
"warning",
`${emptyFiles.length} potentially empty file(s)`,
emptyFiles.join("\n")
);
}
private collectTsFiles(dir: string): string[] {
const files: string[] = [];
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;
}
}