import * as fs from "node:fs"; import * as path from "node:path"; 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, ]; export class StructuralVerification extends VerificationLayer { readonly layer = 1; readonly name = "Structural"; async verify(projectPath: string, phase: number): Promise { const start = Date.now(); const checks: VerificationCheck[] = []; 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 { layer: this.layer, name: this.name, passed, checks, summary: `${checks.filter((c) => c.status === "pass").length}/${checks.length} checks passed`, duration_ms: Date.now() - start, }; } private checkPhaseDir(projectPath: string, phase: number) { const ciDir = path.join(projectPath, ".ci"); const exists = fs.existsSync(ciDir); return this.check( ".ci directory exists", exists ? "pass" : "fail", exists ? ".ci directory found" : ".ci directory not found", ciDir ); } private checkPlanExists(projectPath: string, phase: number) { const roadmapPath = path.join(projectPath, ".ci", "ROADMAP.md"); const exists = fs.existsSync(roadmapPath); return this.check( "ROADMAP.md exists", exists ? "pass" : "warning", exists ? "ROADMAP.md found" : "ROADMAP.md not found (run 'ci init' first)", roadmapPath ); } 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/TODOs", status, `Found ${issues.length} stub/TODO pattern(s)`, issues.slice(0, 20).join("\n") ); } 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", issues.length > 5 ? "fail" : "warning", `${issues.length} unresolved import(s)`, issues.join("\n") ); } 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; } }