Files
ci/src/verification/structural.ts
T
CI fb3f1df13e release(v0.4.0): purge learnship, migrate .planning→.ci, fix backends, add test coverage
- Remove all learnship references: Decision.learnship_equivalent field,
  agent persona prompts, opencode.json permissions, test fixtures
- Migrate verification layers from .planning/ to .ci/: structural
  checks .ci/ dir + ROADMAP.md, behavioral checks ROADMAP.md
- Fix ollama-local: remove sync require+curl blocking, use async
  fetchAvailableModels() in callModel
- Fix opencode.json: use __OPENCODE_DIR__ template tokens, remove
  legacy learnship permission entries
- Remove duplicate install script from package.json (keep postinstall)
- Fix quality any-regex false positives (target type annotations only)
- Add backends test coverage: backends.test.ts, tool-registry.test.ts
- Version bump 0.3.0 → 0.4.0
- Artifacts module: rename .planning→.ci internal paths
- Remove dead TODO_PATTERN/FIXME_PATTERN constants

---ci---
phase: 3
milestone: v0.4
status: complete
requirements:
  covered: [REQ-09, REQ-10, REQ-11, REQ-13, REQ-14, REQ-17]
  partial: []
decisions:
  - id: D-001
    decision: purge all learnship references from codebase
    rationale: project is CI-only, learnship is no longer a dependency
    confidence: 0.99
    category: scope
    alternatives: [keep for historical reference]
  - id: D-002
    decision: migrate verification from .planning/ to .ci/ paths
    rationale: .planning/ is removed schema, all current state lives in .ci/
    confidence: 0.95
    category: architecture
    alternatives: [keep dual-path support]
  - id: D-003
    decision: use __OPENCODE_DIR__ template tokens in opencode.json
    rationale: hardcoded ~ paths fail in containers and non-standard homes
    confidence: 0.90
    category: implementation_approach
    alternatives: [keep tilde expansion]
---/ci---
2026-05-29 16:18:30 +00:00

228 lines
7.9 KiB
TypeScript

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<VerificationResult> {
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;
}
}