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---
This commit is contained in:
CI
2026-05-29 16:18:30 +00:00
parent 7a20784c87
commit fb3f1df13e
32 changed files with 364 additions and 136 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
export class SolutionWriterAgent extends BaseAgent {
readonly name = "solution-writer";
readonly description = "Produces structured solution documents for .planning/solutions/.";
readonly description = "Produces structured solution documents.";
readonly workflow = "execute";
async execute(context: AgentContext): Promise<AgentResult> {
+129
View File
@@ -0,0 +1,129 @@
import { BackendUnavailableError, emptyTokenUsage, emptyBackendResult, DEFAULT_BACKEND_CONFIG } from "../backends/types.js";
import { OllamaLocalBackend } from "../backends/ollama-local.js";
import { OllamaCloudBackend } from "../backends/ollama-cloud.js";
import { OpencodeBackend } from "../backends/opencode.js";
describe("BackendUnavailableError", () => {
it("includes backend name in message", () => {
const err = new BackendUnavailableError("ollama-local");
expect(err.message).toContain("ollama-local");
expect(err.backendName).toBe("ollama-local");
});
it("includes agent name when provided", () => {
const err = new BackendUnavailableError("opencode", "executor");
expect(err.agentName).toBe("executor");
expect(err.message).toContain("executor");
});
});
describe("emptyTokenUsage", () => {
it("returns zeroed usage", () => {
const usage = emptyTokenUsage();
expect(usage.input_tokens).toBe(0);
expect(usage.output_tokens).toBe(0);
expect(usage.total_tokens).toBe(0);
expect(usage.estimated_cost_usd).toBe(0);
});
});
describe("emptyBackendResult", () => {
it("returns failed result with no artifacts", () => {
const result = emptyBackendResult("something failed");
expect(result.success).toBe(false);
expect(result.error).toBe("something failed");
expect(result.artifacts).toEqual([]);
expect(result.decisions).toEqual([]);
expect(result.escalations).toEqual([]);
});
it("returns result without error when no message provided", () => {
const result = emptyBackendResult();
expect(result.success).toBe(false);
expect(result.error).toBeUndefined();
});
});
describe("DEFAULT_BACKEND_CONFIG", () => {
it("has auto provider by default", () => {
expect(DEFAULT_BACKEND_CONFIG.provider).toBe("auto");
});
it("has opencode agent backend enabled", () => {
expect(DEFAULT_BACKEND_CONFIG.agent_backends.opencode?.enabled).toBe(true);
});
it("has ollama-local and ollama-cloud llm backends", () => {
expect(DEFAULT_BACKEND_CONFIG.llm_backends["ollama-local"]).toBeDefined();
expect(DEFAULT_BACKEND_CONFIG.llm_backends["ollama-cloud"]).toBeDefined();
});
});
describe("OllamaLocalBackend", () => {
it("has correct name and type", () => {
const backend = new OllamaLocalBackend();
expect(backend.name).toBe("ollama-local");
expect(backend.type).toBe("llm");
});
it("returns false when local Ollama is not available", async () => {
const backend = new OllamaLocalBackend({
base_url: "http://localhost:1",
model_profile: "balanced",
});
const available = await backend.isAvailable();
expect(available).toBe(false);
});
it("uses default config when none provided", () => {
const backend = new OllamaLocalBackend();
expect(backend).toBeDefined();
});
});
describe("OllamaCloudBackend", () => {
it("has correct name and type", () => {
const backend = new OllamaCloudBackend();
expect(backend.name).toBe("ollama-cloud");
expect(backend.type).toBe("llm");
});
it("returns false when no base_url configured", async () => {
const backend = new OllamaCloudBackend({
base_url: "",
api_key_env: "NONEXISTENT_KEY",
model_profile: "quality",
timeout_ms: 5000,
});
const available = await backend.isAvailable();
expect(available).toBe(false);
});
it("returns false when no API key available", async () => {
const backend = new OllamaCloudBackend({
base_url: "https://example.com",
api_key_env: "NONEXISTENT_CI_KEY_12345",
model_profile: "quality",
timeout_ms: 5000,
});
const available = await backend.isAvailable();
expect(available).toBe(false);
});
});
describe("OpencodeBackend", () => {
it("has correct name and type", () => {
const backend = new OpencodeBackend();
expect(backend.name).toBe("opencode");
expect(backend.type).toBe("agent");
});
it("returns false when opencode is not installed", async () => {
const backend = new OpencodeBackend({
enabled: true,
executable: "nonexistent-opencode-binary-xyz",
});
const available = await backend.isAvailable();
expect(available).toBe(false);
});
});
+1 -2
View File
@@ -153,7 +153,7 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
' "success": true,',
' "output": "Summary of what was accomplished",',
' "artifacts": [{"path": "file/path", "content": "...", "operation": "create"}],',
' "decisions": [{"id": "D-NNN", "decision": "what", "rationale": "why", "confidence": 0.85, "category": "general", "alternatives_considered": [], "learnship_equivalent": "", "human_override": null, "timestamp": ""}],',
' "decisions": [{"id": "D-NNN", "decision": "what", "rationale": "why", "confidence": 0.85, "category": "general", "alternatives_considered": [], "human_override": null, "timestamp": ""}],',
' "escalations": []',
'}',
"```"
@@ -241,7 +241,6 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
: (a as { option: string; rejected_reason: string })
)
: [],
learnship_equivalent: String(d.learnship_equivalent || ""),
human_override: d.human_override ? String(d.human_override) : null,
timestamp: String(d.timestamp || new Date().toISOString()),
}));
+7 -16
View File
@@ -25,8 +25,7 @@ export class OllamaLocalBackend extends OllamaBaseBackend {
protected resolveModel(): string {
if (this.localConfig.model) return this.localConfig.model;
const models = this.fetchAvailableModelsSync();
return this.modelProfileToModel(this.localConfig.model_profile, models);
return this.modelProfileToModel(this.localConfig.model_profile, []);
}
protected async callModel(
@@ -34,10 +33,15 @@ export class OllamaLocalBackend extends OllamaBaseBackend {
model: string,
toolRegistry: ToolRegistry
): Promise<OllamaChatResponse> {
let resolvedModel = model;
if (!this.localConfig.model) {
const models = await this.fetchAvailableModels();
resolvedModel = this.modelProfileToModel(this.localConfig.model_profile, models);
}
const url = `${this.localConfig.base_url}/v1/chat/completions`;
const body: Record<string, unknown> = {
model,
model: resolvedModel,
messages: messages.map((m) => {
const msg: Record<string, unknown> = { role: m.role, content: m.content };
if (m.name) msg.name = m.name;
@@ -65,17 +69,4 @@ export class OllamaLocalBackend extends OllamaBaseBackend {
return (await response.json()) as OllamaChatResponse;
}
private fetchAvailableModelsSync(): string[] {
try {
const { execSync } = require("node:child_process");
const result = execSync(`curl -s ${this.localConfig.base_url}/api/tags`, {
encoding: "utf-8",
timeout: 5000,
});
const data = JSON.parse(result) as { models?: Array<{ name: string }> };
return (data.models || []).map((m) => m.name);
} catch {
return [];
}
}
}
-1
View File
@@ -141,7 +141,6 @@ export class OpencodeBackend implements IntelligenceBackend {
: (a as { option: string; rejected_reason: string })
)
: [],
learnship_equivalent: String(d.learnship_equivalent || ""),
human_override: d.human_override ? String(d.human_override) : null,
timestamp: String(d.timestamp || new Date().toISOString()),
}))
+139
View File
@@ -0,0 +1,139 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { ToolRegistry, TOOL_DEFINITIONS } from "../backends/tool-registry.js";
describe("ToolRegistry", () => {
let tempDir: string;
let registry: ToolRegistry;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-tool-registry-test-"));
registry = new ToolRegistry(tempDir);
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe("definitions", () => {
it("provides 6 tool definitions", () => {
expect(TOOL_DEFINITIONS).toHaveLength(6);
const names = TOOL_DEFINITIONS.map((d) => d.name);
expect(names).toContain("readFile");
expect(names).toContain("writeFile");
expect(names).toContain("editFile");
expect(names).toContain("runBash");
expect(names).toContain("glob");
expect(names).toContain("grep");
});
it("getOpenAIToolSchema returns function-type schema", () => {
const schema = registry.getOpenAIToolSchema();
expect(schema.length).toBe(6);
expect(schema[0].type).toBe("function");
expect((schema[0].function as Record<string, unknown>).name).toBeDefined();
expect((schema[0].function as Record<string, unknown>).parameters).toBeDefined();
});
});
describe("readFile", () => {
it("reads an existing file", () => {
const filePath = path.join(tempDir, "test.txt");
fs.writeFileSync(filePath, "hello world");
const result = registry.execute({ name: "readFile", arguments: { path: filePath } });
expect(result.name).toBe("readFile");
expect(result.content).toBe("hello world");
expect(result.isError).toBeFalsy();
});
it("returns error for missing file", () => {
const result = registry.execute({ name: "readFile", arguments: { path: "/nonexistent/file.txt" } });
expect(result.isError).toBe(true);
expect(result.content).toContain("not found");
});
it("returns error for files exceeding max size", () => {
const bigRegistry = new ToolRegistry(tempDir, 10);
const filePath = path.join(tempDir, "big.txt");
fs.writeFileSync(filePath, "x".repeat(100));
const result = bigRegistry.execute({ name: "readFile", arguments: { path: filePath } });
expect(result.isError).toBe(true);
expect(result.content).toContain("too large");
});
});
describe("writeFile", () => {
it("writes a file creating parent directories", () => {
const filePath = path.join(tempDir, "sub", "dir", "test.txt");
const result = registry.execute({ name: "writeFile", arguments: { path: filePath, content: "written" } });
expect(result.isError).toBeFalsy();
expect(fs.readFileSync(filePath, "utf-8")).toBe("written");
});
});
describe("editFile", () => {
it("replaces an exact string in a file", () => {
const filePath = path.join(tempDir, "edit.txt");
fs.writeFileSync(filePath, "hello world");
const result = registry.execute({ name: "editFile", arguments: { path: filePath, old: "hello", new: "goodbye" } });
expect(result.isError).toBeFalsy();
expect(fs.readFileSync(filePath, "utf-8")).toBe("goodbye world");
});
it("returns error when old string not found", () => {
const filePath = path.join(tempDir, "edit.txt");
fs.writeFileSync(filePath, "hello world");
const result = registry.execute({ name: "editFile", arguments: { path: filePath, old: "missing", new: "replacement" } });
expect(result.isError).toBe(true);
});
it("returns error for missing file", () => {
const result = registry.execute({ name: "editFile", arguments: { path: "/nonexistent", old: "a", new: "b" } });
expect(result.isError).toBe(true);
});
});
describe("runBash", () => {
it("executes a command and returns stdout", () => {
const result = registry.execute({ name: "runBash", arguments: { command: "echo hello" } });
expect(result.content).toContain("hello");
expect(result.isError).toBeFalsy();
});
it("returns error with stderr for failing commands", () => {
const result = registry.execute({ name: "runBash", arguments: { command: "false" } });
expect(result.isError).toBe(true);
expect(result.content).toContain("Exit code");
});
});
describe("glob", () => {
it("finds files matching a pattern", () => {
fs.writeFileSync(path.join(tempDir, "app.ts"), "");
fs.writeFileSync(path.join(tempDir, "app.test.ts"), "");
fs.writeFileSync(path.join(tempDir, "README.md"), "");
const result = registry.execute({ name: "glob", arguments: { pattern: "*.ts" } });
const matches = JSON.parse(result.content);
expect(matches.length).toBeGreaterThanOrEqual(2);
});
});
describe("grep", () => {
it("finds matching lines", () => {
fs.writeFileSync(path.join(tempDir, "app.ts"), "export function main() {}\nconst x = 1;\n");
const result = registry.execute({ name: "grep", arguments: { pattern: "export", include: "*.ts" } });
const matches = JSON.parse(result.content);
expect(matches.length).toBe(1);
expect(matches[0].content).toContain("export");
});
});
describe("unknown tool", () => {
it("returns error for unknown tool name", () => {
const result = registry.execute({ name: "unknownTool", arguments: {} });
expect(result.isError).toBe(true);
expect(result.content).toContain("Unknown tool");
});
});
});
+5 -6
View File
@@ -17,17 +17,16 @@ describe("ArtifactManager", () => {
});
describe("ensureStructure", () => {
it("creates .planning directory structure", () => {
it("creates .ci directory structure", () => {
manager.ensureStructure();
expect(fs.existsSync(path.join(tempDir, ".planning"))).toBe(true);
expect(fs.existsSync(path.join(tempDir, ".planning", "phases"))).toBe(true);
expect(fs.existsSync(path.join(tempDir, ".ci"))).toBe(true);
expect(fs.existsSync(path.join(tempDir, ".ci", "audit"))).toBe(true);
});
it("is idempotent", () => {
manager.ensureStructure();
manager.ensureStructure();
expect(fs.existsSync(path.join(tempDir, ".planning"))).toBe(true);
expect(fs.existsSync(path.join(tempDir, ".ci"))).toBe(true);
});
});
@@ -68,7 +67,7 @@ describe("ArtifactManager", () => {
manager.writeProject(manifest);
const projectPath = path.join(tempDir, ".planning", "PROJECT.md");
const projectPath = path.join(tempDir, ".ci", "PROJECT.md");
expect(fs.existsSync(projectPath)).toBe(true);
const content = fs.readFileSync(projectPath, "utf-8");
expect(content).toContain("Test Project");
@@ -132,7 +131,7 @@ describe("ArtifactManager", () => {
],
});
const decisionsPath = path.join(tempDir, ".planning", "DECISIONS.md");
const decisionsPath = path.join(tempDir, ".ci", "DECISIONS.md");
expect(fs.existsSync(decisionsPath)).toBe(true);
const content = fs.readFileSync(decisionsPath, "utf-8");
expect(content).toContain("D-001");
+14 -14
View File
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
import * as path from "node:path";
import { writeFile, readFile, ensureDir } from "../utils/file.js";
const PLANNING_DIR = ".planning";
const CI_DIR = ".ci";
export interface ProjectManifest {
name: string;
@@ -48,18 +48,18 @@ export class ArtifactManager {
this.projectPath = projectPath;
}
private get planningDir(): string {
return path.join(this.projectPath, PLANNING_DIR);
private get ciDir(): string {
return path.join(this.projectPath, CI_DIR);
}
ensureStructure(): void {
ensureDir(this.planningDir);
ensureDir(path.join(this.planningDir, "phases"));
ensureDir(path.join(this.projectPath, ".ci", "audit"));
ensureDir(this.ciDir);
ensureDir(path.join(this.ciDir, "phases"));
ensureDir(path.join(this.ciDir, "audit"));
}
isInitialized(): boolean {
return fs.existsSync(path.join(this.planningDir, "PROJECT.md"));
return fs.existsSync(path.join(this.ciDir, "PROJECT.md"));
}
writeProject(manifest: ProjectManifest): void {
@@ -81,7 +81,7 @@ export class ArtifactManager {
}
lines.push("");
writeFile(path.join(this.planningDir, "PROJECT.md"), lines.join("\n"));
writeFile(path.join(this.ciDir, "PROJECT.md"), lines.join("\n"));
}
writeDecisions(decisions: DecisionsManifest): void {
@@ -99,11 +99,11 @@ export class ArtifactManager {
lines.push(`- **Timestamp**: ${d.timestamp}`);
lines.push("");
}
writeFile(path.join(this.planningDir, "DECISIONS.md"), lines.join("\n"));
writeFile(path.join(this.ciDir, "DECISIONS.md"), lines.join("\n"));
}
writeState(state: StateManifest): void {
writeJSON(path.join(this.planningDir, "STATE.md.json"), state);
writeJSON(path.join(this.ciDir, "STATE.md.json"), state);
const lines = [
"# Project State",
@@ -124,11 +124,11 @@ export class ArtifactManager {
}
lines.push("");
writeFile(path.join(this.planningDir, "STATE.md"), lines.join("\n"));
writeFile(path.join(this.ciDir, "STATE.md"), lines.join("\n"));
}
readState(): StateManifest | null {
const filePath = path.join(this.planningDir, "STATE.md.json");
const filePath = path.join(this.ciDir, "STATE.md.json");
if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
@@ -150,7 +150,7 @@ export class ArtifactManager {
artifactName: string,
content: string
): void {
const phaseDir = path.join(this.planningDir, "phases", `phase-${phase}`);
const phaseDir = path.join(this.ciDir, "phases", `phase-${phase}`);
ensureDir(phaseDir);
writeFile(path.join(phaseDir, artifactName), content);
}
@@ -160,7 +160,7 @@ export class ArtifactManager {
artifactName: string
): string | null {
const filePath = path.join(
this.planningDir,
this.ciDir,
"phases",
`phase-${phase}`,
artifactName
-1
View File
@@ -25,7 +25,6 @@ describe("Audit", () => {
confidence: 0.92,
category: "technology_choice",
alternatives_considered: [{ option: "MongoDB", rejected_reason: "No ACID" }],
learnship_equivalent: "discuss-phase would ask: What database?",
human_override: null,
};
-1
View File
@@ -26,7 +26,6 @@ describe("DecisionEngine", () => {
{ option: "MongoDB", rejected_reason: "No ACID transactions" },
{ option: "SQLite", rejected_reason: "No concurrent writes" },
],
learnship_equivalent: "discuss-phase would ask: What database? Options: A) PostgreSQL B) MongoDB",
};
describe("makeDecision", () => {
+2 -8
View File
@@ -10,7 +10,6 @@ export interface DecisionInput {
confidence: number;
category: DecisionCategory;
alternatives_considered: Alternative[];
learnship_equivalent: string;
phase?: string;
task?: string;
}
@@ -57,7 +56,6 @@ export class DecisionEngine {
confidence: input.confidence,
category: input.category,
alternatives_considered: input.alternatives_considered,
learnship_equivalent: input.learnship_equivalent,
human_override: null,
phase: input.phase,
task: input.task,
@@ -101,8 +99,7 @@ export class DecisionEngine {
decision: string,
rationale: string,
category: DecisionCategory,
alternatives: Alternative[] = [],
learnship_equivalent: string = ""
alternatives: Alternative[] = []
): DecisionResult {
return this.makeDecision({
decision,
@@ -110,7 +107,6 @@ export class DecisionEngine {
confidence: 0.95,
category,
alternatives_considered: alternatives,
learnship_equivalent,
});
}
@@ -118,8 +114,7 @@ export class DecisionEngine {
decision: string,
rationale: string,
category: DecisionCategory,
alternatives: Alternative[] = [],
learnship_equivalent: string = ""
alternatives: Alternative[] = []
): DecisionResult {
return this.makeDecision({
decision,
@@ -127,7 +122,6 @@ export class DecisionEngine {
confidence: 0.7,
category,
alternatives_considered: alternatives,
learnship_equivalent,
});
}
+1 -2
View File
@@ -10,8 +10,7 @@ describe("ErrorRecovery", () => {
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-recovery-test-"));
fs.mkdirSync(path.join(tempDir, ".planning", "phases"), { recursive: true });
fs.mkdirSync(path.join(tempDir, ".ci", "audit"), { recursive: true });
fs.mkdirSync(path.join(tempDir, ".ci"), { recursive: true });
recovery = new ErrorRecovery(DEFAULT_CI_CONFIG, tempDir);
});
-1
View File
@@ -18,7 +18,6 @@ export interface Decision {
confidence: number;
category: DecisionCategory;
alternatives_considered: Alternative[];
learnship_equivalent: string;
human_override: string | null;
phase?: string;
task?: string;
+10 -12
View File
@@ -141,38 +141,36 @@ export class BehavioralVerification extends VerificationLayer {
}
private checkPlanMustHaves(projectPath: string, phase: number): VerificationCheck {
const planPath = path.join(
const roadmapPath = path.join(
projectPath,
".planning",
"phases",
`phase-${phase}`,
"PLAN.md"
".ci",
"ROADMAP.md"
);
if (!fs.existsSync(planPath)) {
if (!fs.existsSync(roadmapPath)) {
return this.check(
"Plan must-haves covered",
"skipped",
`No PLAN.md found for phase ${phase}`
"No ROADMAP.md found — run 'ci init' first"
);
}
const content = fs.readFileSync(planPath, "utf-8");
const content = fs.readFileSync(roadmapPath, "utf-8");
const hasMustHaves = content.toLowerCase().includes("must");
const hasTasks = content.includes("- [") || content.includes("* [");
const hasPhases = content.includes("Phase") || content.includes("phase");
if (!hasTasks && !hasMustHaves) {
if (!hasPhases && !hasMustHaves) {
return this.check(
"Plan must-haves covered",
"warning",
"PLAN.md has no tasks or must-have items"
"ROADMAP.md has no phases or must-have items"
);
}
return this.check(
"Plan must-haves covered",
"pass",
"PLAN.md contains task definitions"
"ROADMAP.md contains phase definitions"
);
}
+1 -3
View File
@@ -8,13 +8,11 @@ describe("VerificationPipeline", () => {
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");
fs.writeFileSync(path.join(ciDir, "ROADMAP.md"), "# Roadmap\n\n## Phases\n\n### Phase 1: Init\n**Goal**: Set up project\n**Status**: not_started\n");
});
afterEach(() => {
+1 -1
View File
@@ -28,7 +28,7 @@ const CODE_QUALITY_PATTERNS: Array<{
message: "Direct console.log usage — consider structured logging",
},
{
pattern: /any\b/g,
pattern: /(?:as\s+any\b|:\s*any\b|<any>|any\[\s*\])/g,
severity: "P1",
category: "type_safety",
message: "Use of 'any' type — loses type safety",
+15 -17
View File
@@ -14,12 +14,12 @@ describe("StructuralVerification", () => {
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");
function setupProjectStructure(hasCIDir = true, hasRoadmap = true, hasCIConfig = true, hasSpec = true) {
if (hasCIDir) {
const ciDir = path.join(tempDir, ".ci");
fs.mkdirSync(ciDir, { recursive: true });
if (hasRoadmap) {
fs.writeFileSync(path.join(ciDir, "ROADMAP.md"), "# Roadmap\n\n## Phases\n\n### Phase 1: Init\n**Goal**: Set up project\n**Status**: not_started\n");
}
}
if (hasCIConfig) {
@@ -43,10 +43,10 @@ describe("StructuralVerification", () => {
expect(result.name).toBe("Structural");
expect(result.checks.length).toBeGreaterThan(0);
const phaseDirCheck = result.checks.find((c) => c.name === "Phase directory exists");
const phaseDirCheck = result.checks.find((c) => c.name === ".ci directory exists");
expect(phaseDirCheck?.status).toBe("pass");
const planCheck = result.checks.find((c) => c.name === "PLAN.md exists");
const planCheck = result.checks.find((c) => c.name === "ROADMAP.md exists");
expect(planCheck?.status).toBe("pass");
const configCheck = result.checks.find((c) => c.name === "CI config valid");
@@ -56,29 +56,29 @@ describe("StructuralVerification", () => {
expect(specCheck?.status).toBe("pass");
});
it("fails when phase directory is missing", async () => {
setupProjectStructure(false, false, true, true);
it("fails when .ci directory is missing", async () => {
setupProjectStructure(false, false, false, false);
const verifier = new StructuralVerification();
const result = await verifier.verify(tempDir, 1);
const phaseDirCheck = result.checks.find((c) => c.name === "Phase directory exists");
const phaseDirCheck = result.checks.find((c) => c.name === ".ci directory exists");
expect(phaseDirCheck?.status).toBe("fail");
});
it("fails when PLAN.md is missing", async () => {
it("warns when ROADMAP.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");
const planCheck = result.checks.find((c) => c.name === "ROADMAP.md exists");
expect(planCheck?.status).toBe("warning");
});
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 });
fs.mkdirSync(path.join(tempDir, ".ci"), { recursive: true });
const verifier = new StructuralVerification();
const result = await verifier.verify(tempDir, 1);
@@ -91,8 +91,6 @@ describe("StructuralVerification", () => {
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"), "{}");
+11 -20
View File
@@ -13,9 +13,6 @@ const STUB_PATTERNS = [
/not\s+implemented/i,
];
const TODO_PATTERN = /\bTODO\b/gi;
const FIXME_PATTERN = /\bFIXME\b/gi;
export class StructuralVerification extends VerificationLayer {
readonly layer = 1;
readonly name = "Structural";
@@ -44,30 +41,24 @@ export class StructuralVerification extends VerificationLayer {
}
private checkPhaseDir(projectPath: string, phase: number) {
const phaseDir = path.join(projectPath, ".planning", "phases", `phase-${phase}`);
const exists = fs.existsSync(phaseDir);
const ciDir = path.join(projectPath, ".ci");
const exists = fs.existsSync(ciDir);
return this.check(
"Phase directory exists",
".ci directory exists",
exists ? "pass" : "fail",
exists ? `Phase ${phase} directory found` : `Phase ${phase} directory not found`,
phaseDir
exists ? ".ci directory found" : ".ci directory not found",
ciDir
);
}
private checkPlanExists(projectPath: string, phase: number) {
const planPath = path.join(
projectPath,
".planning",
"phases",
`phase-${phase}`,
"PLAN.md"
);
const exists = fs.existsSync(planPath);
const roadmapPath = path.join(projectPath, ".ci", "ROADMAP.md");
const exists = fs.existsSync(roadmapPath);
return this.check(
"PLAN.md exists",
exists ? "pass" : "fail",
exists ? "PLAN.md found" : "PLAN.md not found",
planPath
"ROADMAP.md exists",
exists ? "pass" : "warning",
exists ? "ROADMAP.md found" : "ROADMAP.md not found (run 'ci init' first)",
roadmapPath
);
}
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "0.3.0";
export const VERSION = "0.4.0";