From 0f94b100d5fb15462cf1634b345f4f072bd2ed6a Mon Sep 17 00:00:00 2001 From: Ermin Muratovic Date: Tue, 7 Apr 2026 12:29:54 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20cforge-dev=20init=20=E2=80=94=20scaffol?= =?UTF-8?q?d=20and=20maintain=20project=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements `cforge-dev init`: an interactive wizard that discovers existing context files (README.md, IDEA.md, ARCHITECTURE.md, MEMORANDUM.md, MILESTONES.md, CONTRIBUTING.md, CFORGE_DEV.md, docs/**/*.md), generates missing documentation from user answers and git-remote coordinates, and optionally scaffolds docs/decisions/ (ADRs) and docs/scenarios/ (SCEs). New files: - src/domain/interfaces/DocumentStore.ts — filesystem abstraction (clean arch) - src/application/dtos/InitProjectDto.ts — DTO + IdeaAnswers - src/application/use-cases/InitProject.ts — pure use case; exports CONTEXT_FILE_ORDER - src/infrastructure/filesystem/FileSystemDocumentStore.ts — concrete fs impl - src/cli/commands/init.ts — interactive CLI command - tests/application/InitProject.test.ts — 30 use-case unit tests (TDD) - tests/infrastructure/FileSystemDocumentStore.test.ts — 10 infra tests - tests/cli/commands/init.test.ts — 8 CLI tests Co-Authored-By: Claude Sonnet 4.6 --- src/application/dtos/InitProjectDto.ts | 16 + src/application/use-cases/InitProject.ts | 179 ++++++++++ src/cli/commands/init.ts | 77 +++++ src/cli/index.ts | 5 + src/cli/validation.ts | 1 + src/domain/interfaces/DocumentStore.ts | 20 ++ .../filesystem/FileSystemDocumentStore.ts | 65 ++++ tests/application/InitProject.test.ts | 322 ++++++++++++++++++ tests/cli/commands/init.test.ts | 140 ++++++++ .../FileSystemDocumentStore.test.ts | 102 ++++++ 10 files changed, 927 insertions(+) create mode 100644 src/application/dtos/InitProjectDto.ts create mode 100644 src/application/use-cases/InitProject.ts create mode 100644 src/cli/commands/init.ts create mode 100644 src/domain/interfaces/DocumentStore.ts create mode 100644 src/infrastructure/filesystem/FileSystemDocumentStore.ts create mode 100644 tests/application/InitProject.test.ts create mode 100644 tests/cli/commands/init.test.ts create mode 100644 tests/infrastructure/FileSystemDocumentStore.test.ts diff --git a/src/application/dtos/InitProjectDto.ts b/src/application/dtos/InitProjectDto.ts new file mode 100644 index 0000000..ef59e84 --- /dev/null +++ b/src/application/dtos/InitProjectDto.ts @@ -0,0 +1,16 @@ +export interface IdeaAnswers { + problem: string; + audience: string; + differentiator: string; +} + +export interface InitProjectDto { + /** GitHub owner (org or user). Resolved from git remote when available. */ + repoOwner?: string; + /** GitHub repository name. Resolved from git remote when available. */ + repoName?: string; + /** Answers from interactive IDEA.md prompts; omit to skip IDEA.md generation. */ + ideaAnswers?: IdeaAnswers; + /** Whether to scaffold docs/decisions/ and docs/scenarios/. */ + scaffoldDocs: boolean; +} diff --git a/src/application/use-cases/InitProject.ts b/src/application/use-cases/InitProject.ts new file mode 100644 index 0000000..625915a --- /dev/null +++ b/src/application/use-cases/InitProject.ts @@ -0,0 +1,179 @@ +import { DocumentStore } from "../../domain/interfaces/DocumentStore"; +import { InitProjectDto, IdeaAnswers } from "../dtos/InitProjectDto"; + +export interface InitProjectResult { + /** Files created during this run. */ + created: string[]; + /** Files that were skipped because they already exist or data was absent. */ + skipped: string[]; + /** Existing context files discovered before any writes occurred. */ + discoveredContextFiles: string[]; +} + +/** + * Standard context files read by cforge-dev at runtime, in loading order. + * A well-structured repository means CFORGE_DEV.md needs minimal overrides. + */ +export const CONTEXT_FILE_ORDER = [ + "README.md", + "IDEA.md", + "ARCHITECTURE.md", + "MEMORANDUM.md", + "MILESTONES.md", + "CONTRIBUTING.md", + "CFORGE_DEV.md", +] as const; + +export class InitProject { + constructor(private readonly store: DocumentStore) {} + + execute(input: InitProjectDto): InitProjectResult { + const created: string[] = []; + const skipped: string[] = []; + + // Step 1 — Discover existing context before any writes + const discoveredContextFiles = this.discoverContextFiles(); + + // Step 2 — CFORGE_DEV.md + if (!this.store.exists("CFORGE_DEV.md")) { + this.store.write("CFORGE_DEV.md", this.cforgeDevContent(input.repoOwner, input.repoName)); + created.push("CFORGE_DEV.md"); + } else { + skipped.push("CFORGE_DEV.md"); + } + + // Step 2 — README.md + if (!this.store.exists("README.md")) { + this.store.write("README.md", this.readmeContent(input.repoName)); + created.push("README.md"); + } else { + skipped.push("README.md"); + } + + // Step 2 — IDEA.md (only when user provided answers) + if (!this.store.exists("IDEA.md")) { + if (input.ideaAnswers) { + this.store.write("IDEA.md", this.ideaContent(input.ideaAnswers)); + created.push("IDEA.md"); + } else { + skipped.push("IDEA.md"); + } + } else { + skipped.push("IDEA.md"); + } + + // Step 2 — ARCHITECTURE.md + if (!this.store.exists("ARCHITECTURE.md")) { + this.store.write("ARCHITECTURE.md", this.architectureContent(input.repoName)); + created.push("ARCHITECTURE.md"); + } else { + skipped.push("ARCHITECTURE.md"); + } + + // Step 3 — Scaffold docs/ if requested + if (input.scaffoldDocs) { + if (!this.store.exists("docs/decisions")) { + this.store.ensureDir("docs/decisions"); + this.store.write( + "docs/decisions/0001-record-architecture-decisions.md", + this.adrTemplate(), + ); + created.push("docs/decisions/"); + } + + if (!this.store.exists("docs/scenarios")) { + this.store.ensureDir("docs/scenarios"); + this.store.write("docs/scenarios/example-scenario.md", this.sceTemplate()); + created.push("docs/scenarios/"); + } + } + + return { created, skipped, discoveredContextFiles }; + } + + // --------------------------------------------------------------------------- + // Private helpers — discovery + // --------------------------------------------------------------------------- + + private discoverContextFiles(): string[] { + const discovered: string[] = []; + for (const file of CONTEXT_FILE_ORDER) { + if (this.store.exists(file)) { + discovered.push(file); + } + } + const docsFiles = this.store.glob("docs/**/*.md"); + discovered.push(...docsFiles); + return discovered; + } + + // --------------------------------------------------------------------------- + // Private helpers — content generators + // --------------------------------------------------------------------------- + + private cforgeDevContent(owner?: string, repo?: string): string { + const ownerLine = owner ?? ""; + const repoLine = repo ?? ""; + return ( + `# CFORGE_DEV.md\n` + + `## Repository\n` + + `owner: ${ownerLine}\n` + + `repo: ${repoLine}\n` + + `## Context files\n` + + `# List any non-standard files to include\n` + ); + } + + private readmeContent(repoName?: string): string { + const name = repoName ?? "My Project"; + return ( + `# ${name}\n\n` + + `> TODO: Add project description.\n\n` + + `## Getting Started\n\n` + + `TODO: Add setup instructions.\n` + ); + } + + private ideaContent(answers: IdeaAnswers): string { + return ( + `# IDEA.md\n\n` + + `## What problem does this solve?\n\n` + + `${answers.problem}\n\n` + + `## Who is it for?\n\n` + + `${answers.audience}\n\n` + + `## What makes it different?\n\n` + + `${answers.differentiator}\n` + ); + } + + private architectureContent(repoName?: string): string { + const name = repoName ?? "this project"; + return ( + `# Architecture\n\n` + + `## Overview\n\n` + + `TODO: Describe the architecture of ${name}.\n\n` + + `## Layers\n\n` + + `TODO: Document architectural layers and dependencies.\n` + ); + } + + private adrTemplate(): string { + return ( + `# ADR-0001: Record Architecture Decisions\n\n` + + `## Status\n\nAccepted\n\n` + + `## Context\n\nWe need to record the architectural decisions made on this project.\n\n` + + `## Decision\n\nWe will use Architecture Decision Records (ADRs) to capture significant decisions.\n\n` + + `## Consequences\n\nA log of architectural decisions will be maintained.\n` + ); + } + + private sceTemplate(): string { + return ( + `# Scenario: Example\n\n` + + `## Context\n\nDescribe the scenario context.\n\n` + + `## Given\n\n- Initial conditions\n\n` + + `## When\n\n- Triggering event\n\n` + + `## Then\n\n- Expected outcomes\n` + ); + } +} diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts new file mode 100644 index 0000000..1136ca3 --- /dev/null +++ b/src/cli/commands/init.ts @@ -0,0 +1,77 @@ +import * as readline from "readline"; +import { InitProject } from "../../application/use-cases/InitProject"; +import { FileSystemDocumentStore } from "../../infrastructure/filesystem/FileSystemDocumentStore"; +import { resolveRepoFromGit } from "../utils/resolveRepo"; +import { USAGE_INIT } from "../validation"; +import { c } from "../utils/ui"; + +function prompt(rl: readline.Interface, question: string): Promise { + return new Promise((resolve) => { + rl.question(question, (answer) => resolve(answer.trim())); + }); +} + +export async function initCommand(arg?: string): Promise { + if (arg === "--help") { + console.log(USAGE_INIT); + process.exit(0); + } + + // Resolve repo coordinates from git remote (best-effort) + const repoCoords = resolveRepoFromGit(); + + const store = new FileSystemDocumentStore(process.cwd()); + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + + try { + // Prompt for IDEA.md answers only when the file does not yet exist + let ideaAnswers: { problem: string; audience: string; differentiator: string } | undefined; + if (!store.exists("IDEA.md")) { + console.log(`\n${c.bold("Generating IDEA.md — answer three quick questions:")}\n`); + ideaAnswers = { + problem: await prompt(rl, " 1. What problem does this solve? "), + audience: await prompt(rl, " 2. Who is it for? "), + differentiator: await prompt(rl, " 3. What makes it different? "), + }; + } + + // Ask about docs scaffolding + const docsAnswer = await prompt( + rl, + `\nScaffold docs/decisions/ (ADRs) and docs/scenarios/ (SCEs)? [y/N] `, + ); + const scaffoldDocs = docsAnswer.toLowerCase() === "y"; + + rl.close(); + + const result = new InitProject(store).execute({ + repoOwner: repoCoords?.owner, + repoName: repoCoords?.repo, + ideaAnswers, + scaffoldDocs, + }); + + // Output summary + console.log(); + if (result.created.length > 0) { + console.log(c.success(`\u2713 Created:`)); + for (const f of result.created) { + console.log(` ${c.bold(f)}`); + } + } + + if (result.skipped.length > 0) { + console.log(c.dim(`\n Skipped (already exist): ${result.skipped.join(", ")}`)); + } + + if (result.discoveredContextFiles.length > 0) { + console.log(c.dim(`\n Discovered context: ${result.discoveredContextFiles.join(", ")}`)); + } + + console.log(`\n${c.success("\u2713")} ${c.bold("cforge-dev init complete.")}\n`); + } catch (err) { + rl.close(); + throw err; + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index e889abb..1a864e2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -7,12 +7,14 @@ import { verifyCommand } from "./commands/verify"; import { releaseCommand } from "./commands/release"; import { chatCommand } from "./commands/chat"; import { auditCommand } from "./commands/audit"; +import { initCommand } from "./commands/init"; import { getVersion } from "./utils/getVersion"; import { printWelcome } from "./utils/ui"; export const USAGE = `cforge-dev — AI-native SDLC orchestrator Usage: + cforge-dev init Scaffold and maintain project documentation cforge-dev plan Plan a sprint from a PRD file cforge-dev implement Generate Claude Code prompt for an issue cforge-dev implement --auto Autonomous: Claude Code implements + opens PR @@ -38,6 +40,9 @@ export async function main(): Promise { } switch (command) { + case "init": + await initCommand(args[0]); + break; case "plan": await planCommand(args[0]); break; diff --git a/src/cli/validation.ts b/src/cli/validation.ts index 111cec1..7c977d7 100644 --- a/src/cli/validation.ts +++ b/src/cli/validation.ts @@ -1,3 +1,4 @@ +export const USAGE_INIT = "Usage: cforge-dev init"; export const USAGE_IMPLEMENT = "Usage: cforge-dev implement [issue-number] [--auto] [--max-budget ]"; export const USAGE_VERIFY = "Usage: cforge-dev verify [pr-number]"; export const USAGE_RELEASE = "Usage: cforge-dev release [version]"; diff --git a/src/domain/interfaces/DocumentStore.ts b/src/domain/interfaces/DocumentStore.ts new file mode 100644 index 0000000..198b29c --- /dev/null +++ b/src/domain/interfaces/DocumentStore.ts @@ -0,0 +1,20 @@ +/** + * Abstraction over filesystem operations, scoped to a project root. + * Paths are relative to the project root passed to the concrete implementation. + */ +export interface DocumentStore { + /** Returns true if the path exists (file or directory). */ + exists(relativePath: string): boolean; + + /** Reads a file and returns its content as a UTF-8 string. */ + read(relativePath: string): string; + + /** Writes content to a file, creating parent directories as needed. */ + write(relativePath: string, content: string): void; + + /** Creates a directory (and any missing parents) if it does not exist. */ + ensureDir(relativePath: string): void; + + /** Returns matching relative paths for a glob pattern rooted at the project root. */ + glob(pattern: string): string[]; +} diff --git a/src/infrastructure/filesystem/FileSystemDocumentStore.ts b/src/infrastructure/filesystem/FileSystemDocumentStore.ts new file mode 100644 index 0000000..f507c22 --- /dev/null +++ b/src/infrastructure/filesystem/FileSystemDocumentStore.ts @@ -0,0 +1,65 @@ +import * as fs from "fs"; +import * as path from "path"; +import { DocumentStore } from "../../domain/interfaces/DocumentStore"; + +/** + * Filesystem-backed DocumentStore. + * All relative paths are resolved against `root` (defaults to `process.cwd()`). + */ +export class FileSystemDocumentStore implements DocumentStore { + private readonly root: string; + + constructor(root: string = process.cwd()) { + this.root = root; + } + + exists(relativePath: string): boolean { + return fs.existsSync(this.abs(relativePath)); + } + + read(relativePath: string): string { + return fs.readFileSync(this.abs(relativePath), "utf-8"); + } + + write(relativePath: string, content: string): void { + const absPath = this.abs(relativePath); + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, content, "utf-8"); + } + + ensureDir(relativePath: string): void { + fs.mkdirSync(this.abs(relativePath), { recursive: true }); + } + + glob(pattern: string): string[] { + // Simple recursive glob that avoids adding a new dependency. + // Supports patterns of the form "docs/**/*.md". + const [base, ...rest] = pattern.split("/**"); + const baseDir = this.abs(base); + + if (!fs.existsSync(baseDir)) return []; + + // Extract the file suffix after the last `*` in the glob tail (e.g. "/*.md" → ".md") + const ext = rest[0]?.replace(/^.*\*/, "") ?? ""; + const results: string[] = []; + this.walk(baseDir, ext, results); + + return results.map((abs) => path.relative(this.root, abs).replace(/\\/g, "/")); + } + + private walk(dir: string, ext: string, results: string[]): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + this.walk(full, ext, results); + } else if (!ext || entry.name.endsWith(ext)) { + results.push(full); + } + } + } + + private abs(relativePath: string): string { + return path.join(this.root, relativePath); + } +} diff --git a/tests/application/InitProject.test.ts b/tests/application/InitProject.test.ts new file mode 100644 index 0000000..c3581ea --- /dev/null +++ b/tests/application/InitProject.test.ts @@ -0,0 +1,322 @@ +import { InitProject, CONTEXT_FILE_ORDER } from "../../src/application/use-cases/InitProject"; +import { DocumentStore } from "../../src/domain/interfaces/DocumentStore"; + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +interface MockStore extends DocumentStore { + _written: Record; + _dirs: string[]; +} + +function mockStore(existingFiles: Record = {}): MockStore { + const _written: Record = {}; + const _dirs: string[] = []; + + return { + _written, + _dirs, + exists: jest.fn((p: string) => p in existingFiles || p in _written), + read: jest.fn((p: string) => existingFiles[p] ?? _written[p] ?? ""), + write: jest.fn((p: string, content: string) => { _written[p] = content; }), + ensureDir: jest.fn((p: string) => { _dirs.push(p); }), + glob: jest.fn(() => []), + }; +} + +// --------------------------------------------------------------------------- +// CONTEXT_FILE_ORDER +// --------------------------------------------------------------------------- + +describe("CONTEXT_FILE_ORDER", () => { + it("includes all required standard files in the specified order", () => { + expect(CONTEXT_FILE_ORDER).toEqual([ + "README.md", + "IDEA.md", + "ARCHITECTURE.md", + "MEMORANDUM.md", + "MILESTONES.md", + "CONTRIBUTING.md", + "CFORGE_DEV.md", + ]); + }); +}); + +// --------------------------------------------------------------------------- +// InitProject — empty project +// --------------------------------------------------------------------------- + +describe("InitProject — empty project", () => { + it("creates CFORGE_DEV.md when it does not exist", () => { + const store = mockStore(); + const uc = new InitProject(store); + + const result = uc.execute({ scaffoldDocs: false }); + + expect(store._written["CFORGE_DEV.md"]).toBeDefined(); + expect(result.created).toContain("CFORGE_DEV.md"); + }); + + it("creates README.md when it does not exist", () => { + const store = mockStore(); + const uc = new InitProject(store); + + const result = uc.execute({ scaffoldDocs: false }); + + expect(store._written["README.md"]).toBeDefined(); + expect(result.created).toContain("README.md"); + }); + + it("creates ARCHITECTURE.md when it does not exist", () => { + const store = mockStore(); + const uc = new InitProject(store); + + const result = uc.execute({ scaffoldDocs: false }); + + expect(store._written["ARCHITECTURE.md"]).toBeDefined(); + expect(result.created).toContain("ARCHITECTURE.md"); + }); + + it("skips IDEA.md when ideaAnswers are not provided", () => { + const store = mockStore(); + const uc = new InitProject(store); + + const result = uc.execute({ scaffoldDocs: false }); + + expect(store._written["IDEA.md"]).toBeUndefined(); + expect(result.created).not.toContain("IDEA.md"); + expect(result.skipped).toContain("IDEA.md"); + }); + + it("creates IDEA.md when ideaAnswers are provided", () => { + const store = mockStore(); + const uc = new InitProject(store); + + const result = uc.execute({ + scaffoldDocs: false, + ideaAnswers: { + problem: "Slow deployments", + audience: "DevOps engineers", + differentiator: "AI-native", + }, + }); + + expect(store._written["IDEA.md"]).toBeDefined(); + expect(result.created).toContain("IDEA.md"); + }); +}); + +// --------------------------------------------------------------------------- +// InitProject — existing files are skipped +// --------------------------------------------------------------------------- + +describe("InitProject — existing files are skipped", () => { + it("skips CFORGE_DEV.md when it already exists", () => { + const store = mockStore({ "CFORGE_DEV.md": "# existing" }); + const uc = new InitProject(store); + + const result = uc.execute({ scaffoldDocs: false }); + + expect(store.write).not.toHaveBeenCalledWith("CFORGE_DEV.md", expect.any(String)); + expect(result.skipped).toContain("CFORGE_DEV.md"); + expect(result.created).not.toContain("CFORGE_DEV.md"); + }); + + it("skips README.md when it already exists", () => { + const store = mockStore({ "README.md": "# existing" }); + const uc = new InitProject(store); + + const result = uc.execute({ scaffoldDocs: false }); + + expect(store.write).not.toHaveBeenCalledWith("README.md", expect.any(String)); + expect(result.skipped).toContain("README.md"); + }); + + it("skips ARCHITECTURE.md when it already exists", () => { + const store = mockStore({ "ARCHITECTURE.md": "# existing" }); + const uc = new InitProject(store); + + const result = uc.execute({ scaffoldDocs: false }); + + expect(store.write).not.toHaveBeenCalledWith("ARCHITECTURE.md", expect.any(String)); + expect(result.skipped).toContain("ARCHITECTURE.md"); + }); + + it("skips IDEA.md when it already exists (even with ideaAnswers)", () => { + const store = mockStore({ "IDEA.md": "# existing" }); + const uc = new InitProject(store); + + const result = uc.execute({ + scaffoldDocs: false, + ideaAnswers: { problem: "x", audience: "y", differentiator: "z" }, + }); + + expect(store.write).not.toHaveBeenCalledWith("IDEA.md", expect.any(String)); + expect(result.skipped).toContain("IDEA.md"); + }); +}); + +// --------------------------------------------------------------------------- +// InitProject — repo coordinates in CFORGE_DEV.md +// --------------------------------------------------------------------------- + +describe("InitProject — CFORGE_DEV.md content", () => { + it("includes owner and repo when coordinates are provided", () => { + const store = mockStore(); + const uc = new InitProject(store); + + uc.execute({ repoOwner: "acme", repoName: "my-app", scaffoldDocs: false }); + + const content = store._written["CFORGE_DEV.md"]; + expect(content).toContain("owner: acme"); + expect(content).toContain("repo: my-app"); + }); + + it("uses placeholder when coordinates are not provided", () => { + const store = mockStore(); + const uc = new InitProject(store); + + uc.execute({ scaffoldDocs: false }); + + const content = store._written["CFORGE_DEV.md"]; + expect(content).toContain(""); + expect(content).toContain(""); + }); + + it("includes ## Repository and ## Context files sections", () => { + const store = mockStore(); + const uc = new InitProject(store); + + uc.execute({ scaffoldDocs: false }); + + const content = store._written["CFORGE_DEV.md"]; + expect(content).toContain("## Repository"); + expect(content).toContain("## Context files"); + }); +}); + +// --------------------------------------------------------------------------- +// InitProject — IDEA.md content +// --------------------------------------------------------------------------- + +describe("InitProject — IDEA.md content", () => { + it("includes answers in the correct sections", () => { + const store = mockStore(); + const uc = new InitProject(store); + + uc.execute({ + scaffoldDocs: false, + ideaAnswers: { + problem: "Teams ship slow", + audience: "Startup CTOs", + differentiator: "Zero-config AI", + }, + }); + + const content = store._written["IDEA.md"]; + expect(content).toContain("Teams ship slow"); + expect(content).toContain("Startup CTOs"); + expect(content).toContain("Zero-config AI"); + }); +}); + +// --------------------------------------------------------------------------- +// InitProject — docs scaffolding +// --------------------------------------------------------------------------- + +describe("InitProject — docs scaffolding", () => { + it("does not scaffold docs/ when scaffoldDocs is false", () => { + const store = mockStore(); + const uc = new InitProject(store); + + uc.execute({ scaffoldDocs: false }); + + expect(store.ensureDir).not.toHaveBeenCalled(); + expect(store._written["docs/decisions/0001-record-architecture-decisions.md"]).toBeUndefined(); + }); + + it("creates docs/decisions/ and a template ADR when scaffoldDocs is true", () => { + const store = mockStore(); + const uc = new InitProject(store); + + const result = uc.execute({ scaffoldDocs: true }); + + expect(store.ensureDir).toHaveBeenCalledWith("docs/decisions"); + expect(store._written["docs/decisions/0001-record-architecture-decisions.md"]).toBeDefined(); + expect(result.created).toContain("docs/decisions/"); + }); + + it("creates docs/scenarios/ and a template SCE when scaffoldDocs is true", () => { + const store = mockStore(); + const uc = new InitProject(store); + + const result = uc.execute({ scaffoldDocs: true }); + + expect(store.ensureDir).toHaveBeenCalledWith("docs/scenarios"); + expect(store._written["docs/scenarios/example-scenario.md"]).toBeDefined(); + expect(result.created).toContain("docs/scenarios/"); + }); + + it("skips docs/decisions/ if it already exists", () => { + const store = mockStore({ "docs/decisions": "" }); + const uc = new InitProject(store); + + const result = uc.execute({ scaffoldDocs: true }); + + expect(store.ensureDir).not.toHaveBeenCalledWith("docs/decisions"); + expect(result.created).not.toContain("docs/decisions/"); + }); + + it("skips docs/scenarios/ if it already exists", () => { + const store = mockStore({ "docs/scenarios": "" }); + const uc = new InitProject(store); + + const result = uc.execute({ scaffoldDocs: true }); + + expect(store.ensureDir).not.toHaveBeenCalledWith("docs/scenarios"); + expect(result.created).not.toContain("docs/scenarios/"); + }); +}); + +// --------------------------------------------------------------------------- +// InitProject — context discovery +// --------------------------------------------------------------------------- + +describe("InitProject — context discovery", () => { + it("returns an empty discoveredContextFiles list when no context files exist", () => { + const store = mockStore(); + const uc = new InitProject(store); + + const result = uc.execute({ scaffoldDocs: false }); + + // Files created during execute() do not count as pre-existing context + expect(result.discoveredContextFiles).toHaveLength(0); + }); + + it("returns existing standard context files in discovery order", () => { + const store = mockStore({ + "README.md": "# r", + "ARCHITECTURE.md": "# a", + "CONTRIBUTING.md": "# c", + }); + const uc = new InitProject(store); + + const result = uc.execute({ scaffoldDocs: false }); + + expect(result.discoveredContextFiles).toEqual(["README.md", "ARCHITECTURE.md", "CONTRIBUTING.md"]); + }); + + it("includes docs/**/*.md files returned by glob in discoveredContextFiles", () => { + const store: MockStore = { + ...mockStore(), + glob: jest.fn(() => ["docs/decisions/0001-foo.md", "docs/scenarios/bar.md"]), + }; + const uc = new InitProject(store); + + const result = uc.execute({ scaffoldDocs: false }); + + expect(result.discoveredContextFiles).toContain("docs/decisions/0001-foo.md"); + expect(result.discoveredContextFiles).toContain("docs/scenarios/bar.md"); + }); +}); diff --git a/tests/cli/commands/init.test.ts b/tests/cli/commands/init.test.ts new file mode 100644 index 0000000..de14f1a --- /dev/null +++ b/tests/cli/commands/init.test.ts @@ -0,0 +1,140 @@ +jest.mock("readline"); +jest.mock("../../../src/infrastructure/filesystem/FileSystemDocumentStore"); +jest.mock("../../../src/cli/utils/resolveRepo"); + +import * as readline from "readline"; +import { initCommand } from "../../../src/cli/commands/init"; +import { FileSystemDocumentStore } from "../../../src/infrastructure/filesystem/FileSystemDocumentStore"; +import { resolveRepoFromGit } from "../../../src/cli/utils/resolveRepo"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const mockResolveRepoFromGit = resolveRepoFromGit as jest.MockedFunction; +const MockFileSystemDocumentStore = FileSystemDocumentStore as jest.MockedClass; +const mockCreateInterface = readline.createInterface as jest.MockedFunction; + +/** Simulate readline where every question is answered with `answer`. */ +function mockReadline(answer = "N") { + mockCreateInterface.mockReturnValue({ + question: (_q: string, cb: (a: string) => void) => cb(answer), + close: jest.fn(), + } as any); +} + +/** Build a mock store instance. */ +function setupMockStore(existingFiles: string[] = []) { + const instance = { + exists: jest.fn((p: string) => existingFiles.includes(p)), + read: jest.fn(() => ""), + write: jest.fn(), + ensureDir: jest.fn(), + glob: jest.fn(() => []), + }; + MockFileSystemDocumentStore.mockImplementation(() => instance as any); + return instance; +} + +// --------------------------------------------------------------------------- +// Shared setup / teardown +// --------------------------------------------------------------------------- + +let mockExit: jest.SpyInstance; +let mockLog: jest.SpyInstance; +let mockError: jest.SpyInstance; + +beforeEach(() => { + jest.clearAllMocks(); + mockExit = jest.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code}`); + }) as never); + mockLog = jest.spyOn(console, "log").mockImplementation(() => {}); + mockError = jest.spyOn(console, "error").mockImplementation(() => {}); + mockResolveRepoFromGit.mockReturnValue(undefined); +}); + +afterEach(() => { + mockExit.mockRestore(); + mockLog.mockRestore(); + mockError.mockRestore(); +}); + +// --------------------------------------------------------------------------- +// --help flag +// --------------------------------------------------------------------------- + +describe("initCommand --help", () => { + it("prints usage and exits 0", async () => { + setupMockStore(); + await expect(initCommand("--help")).rejects.toThrow("process.exit:0"); + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it("logs a usage message containing 'init'", async () => { + setupMockStore(); + await expect(initCommand("--help")).rejects.toThrow("process.exit:0"); + const output = mockLog.mock.calls.map((c: unknown[]) => String(c[0])).join(""); + expect(output).toContain("init"); + }); +}); + +// --------------------------------------------------------------------------- +// IDEA.md already exists — no idea prompts, answers "N" to docs scaffolding +// --------------------------------------------------------------------------- + +describe("initCommand — IDEA.md already exists", () => { + it("runs without error when all standard files already exist", async () => { + setupMockStore(["IDEA.md", "README.md", "ARCHITECTURE.md", "CFORGE_DEV.md"]); + mockReadline("N"); + + await expect(initCommand()).resolves.toBeUndefined(); + }); + + it("does not call store.write when all standard files already exist", async () => { + const store = setupMockStore(["IDEA.md", "README.md", "ARCHITECTURE.md", "CFORGE_DEV.md"]); + mockReadline("N"); + + await initCommand(); + + expect(store.write).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Repo coordinates are resolved from git and forwarded +// --------------------------------------------------------------------------- + +describe("initCommand — repo coordinate resolution", () => { + it("completes successfully when git coords are available", async () => { + setupMockStore(["IDEA.md", "README.md", "ARCHITECTURE.md", "CFORGE_DEV.md"]); + mockResolveRepoFromGit.mockReturnValue({ owner: "acme", repo: "my-app" }); + mockReadline("N"); + + await expect(initCommand()).resolves.toBeUndefined(); + }); + + it("completes successfully when git coords are unavailable", async () => { + setupMockStore(["IDEA.md", "README.md", "ARCHITECTURE.md", "CFORGE_DEV.md"]); + mockResolveRepoFromGit.mockReturnValue(undefined); + mockReadline("N"); + + await expect(initCommand()).resolves.toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// docs scaffolding triggered by "y" answer +// --------------------------------------------------------------------------- + +describe("initCommand — docs scaffolding", () => { + it("calls store.ensureDir for docs/decisions and docs/scenarios when user answers y", async () => { + const store = setupMockStore(["IDEA.md", "README.md", "ARCHITECTURE.md", "CFORGE_DEV.md"]); + mockReadline("y"); + + await initCommand(); + + expect(store.ensureDir).toHaveBeenCalledWith("docs/decisions"); + expect(store.ensureDir).toHaveBeenCalledWith("docs/scenarios"); + }); +}); diff --git a/tests/infrastructure/FileSystemDocumentStore.test.ts b/tests/infrastructure/FileSystemDocumentStore.test.ts new file mode 100644 index 0000000..3a0fd37 --- /dev/null +++ b/tests/infrastructure/FileSystemDocumentStore.test.ts @@ -0,0 +1,102 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { FileSystemDocumentStore } from "../../src/infrastructure/filesystem/FileSystemDocumentStore"; + +function tmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "cforge-test-")); +} + +describe("FileSystemDocumentStore", () => { + let root: string; + let store: FileSystemDocumentStore; + + beforeEach(() => { + root = tmpDir(); + store = new FileSystemDocumentStore(root); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + // ------------------------------------------------------------------------- + // exists + // ------------------------------------------------------------------------- + + it("returns false for a file that does not exist", () => { + expect(store.exists("missing.md")).toBe(false); + }); + + it("returns true for a file that was written", () => { + store.write("present.md", "hello"); + expect(store.exists("present.md")).toBe(true); + }); + + it("returns true for a directory that was created with ensureDir", () => { + store.ensureDir("docs/decisions"); + expect(store.exists("docs/decisions")).toBe(true); + }); + + // ------------------------------------------------------------------------- + // read / write + // ------------------------------------------------------------------------- + + it("reads back the content written to a file", () => { + store.write("README.md", "# Hello"); + expect(store.read("README.md")).toBe("# Hello"); + }); + + it("creates parent directories when writing a nested file", () => { + store.write("docs/decisions/0001.md", "# ADR"); + expect(store.exists("docs/decisions/0001.md")).toBe(true); + }); + + it("overwrites an existing file on second write", () => { + store.write("file.md", "first"); + store.write("file.md", "second"); + expect(store.read("file.md")).toBe("second"); + }); + + // ------------------------------------------------------------------------- + // ensureDir + // ------------------------------------------------------------------------- + + it("creates a nested directory structure", () => { + store.ensureDir("a/b/c"); + expect(fs.existsSync(path.join(root, "a/b/c"))).toBe(true); + }); + + it("does not throw when the directory already exists", () => { + store.ensureDir("existing"); + expect(() => store.ensureDir("existing")).not.toThrow(); + }); + + // ------------------------------------------------------------------------- + // glob + // ------------------------------------------------------------------------- + + it("returns an empty array when the base directory does not exist", () => { + expect(store.glob("docs/**/*.md")).toEqual([]); + }); + + it("returns relative paths for matching files", () => { + store.write("docs/decisions/0001.md", "adr1"); + store.write("docs/decisions/0002.md", "adr2"); + store.write("docs/scenarios/sc1.md", "sce1"); + + const results = store.glob("docs/**/*.md"); + expect(results).toHaveLength(3); + expect(results).toContain("docs/decisions/0001.md"); + expect(results).toContain("docs/decisions/0002.md"); + expect(results).toContain("docs/scenarios/sc1.md"); + }); + + it("does not return files that do not match the extension", () => { + store.write("docs/decisions/note.txt", "txt"); + store.write("docs/decisions/0001.md", "md"); + + const results = store.glob("docs/**/*.md"); + expect(results).toEqual(["docs/decisions/0001.md"]); + }); +});