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---
251 lines
7.9 KiB
TypeScript
251 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 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";
|
|
|
|
async verify(projectPath: string, phase: number): Promise<VerificationResult> {
|
|
const start = Date.now();
|
|
const checks: VerificationCheck[] = [];
|
|
|
|
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,
|
|
checks,
|
|
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 roadmapPath = path.join(
|
|
projectPath,
|
|
".ci",
|
|
"ROADMAP.md"
|
|
);
|
|
|
|
if (!fs.existsSync(roadmapPath)) {
|
|
return this.check(
|
|
"Plan must-haves covered",
|
|
"skipped",
|
|
"No ROADMAP.md found — run 'ci init' first"
|
|
);
|
|
}
|
|
|
|
const content = fs.readFileSync(roadmapPath, "utf-8");
|
|
const hasMustHaves = content.toLowerCase().includes("must");
|
|
const hasPhases = content.includes("Phase") || content.includes("phase");
|
|
|
|
if (!hasPhases && !hasMustHaves) {
|
|
return this.check(
|
|
"Plan must-haves covered",
|
|
"warning",
|
|
"ROADMAP.md has no phases or must-have items"
|
|
);
|
|
}
|
|
|
|
return this.check(
|
|
"Plan must-haves covered",
|
|
"pass",
|
|
"ROADMAP.md contains phase 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;
|
|
}
|
|
} |