fb3f1df13e
- 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---
228 lines
7.9 KiB
TypeScript
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;
|
|
}
|
|
} |