From ee2869a6c420e300946904c4f58e3fed930e5ef3 Mon Sep 17 00:00:00 2001 From: Ermin Muratovic Date: Tue, 7 Apr 2026 16:02:25 +0200 Subject: [PATCH] fix: update cforge-dev context file loading logic CFORGE_DEV.md is now optional. When absent, loadContext falls back to README.md, IDEA.md, ARCHITECTURE.md, MEMORANDUM.md, MILESTONES.md, CONTRIBUTING.md, and docs/**/*.md. An error is thrown only when none of these files are present. Co-Authored-By: Claude Sonnet 4.6 --- src/cli/utils/loadContext.ts | 85 ++++++++++++- tests/cli/utils/loadContext.test.ts | 188 ++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 tests/cli/utils/loadContext.test.ts diff --git a/src/cli/utils/loadContext.ts b/src/cli/utils/loadContext.ts index 5c0cbb6..2b38714 100644 --- a/src/cli/utils/loadContext.ts +++ b/src/cli/utils/loadContext.ts @@ -3,15 +3,62 @@ import * as path from "path"; import { ProjectContext } from "../../domain/models/ProjectContext"; import { resolveRepoFromGit } from "./resolveRepo"; +const STANDARD_CONTEXT_FILES = [ + "README.md", + "IDEA.md", + "ARCHITECTURE.md", + "MEMORANDUM.md", + "MILESTONES.md", + "CONTRIBUTING.md", +]; + export function loadContext(): ProjectContext { - const filePath = path.join(process.cwd(), "CFORGE_DEV.md"); + const root = process.cwd(); + const cforgeDevPath = path.join(root, "CFORGE_DEV.md"); + + if (fs.existsSync(cforgeDevPath)) { + return parseContextFromCforgeDev(fs.readFileSync(cforgeDevPath, "utf-8")); + } + + const standardPaths = STANDARD_CONTEXT_FILES + .map((f) => path.join(root, f)) + .filter((p) => fs.existsSync(p)); + + const docsPaths = findDocsMarkdownFiles(root); + const existingPaths = [...standardPaths, ...docsPaths]; - if (!fs.existsSync(filePath)) { + if (existingPaths.length === 0) { throw new Error("CFORGE_DEV.md not found — run cforge-dev from project root"); } - const content = fs.readFileSync(filePath, "utf-8"); + const content = existingPaths.map((p) => fs.readFileSync(p, "utf-8")).join("\n\n"); + return parseContextFromStandardFiles(content); +} + +function findDocsMarkdownFiles(root: string): string[] { + const docsDir = path.join(root, "docs"); + if (!fs.existsSync(docsDir)) return []; + return walkMarkdownFiles(docsDir); +} + +function walkMarkdownFiles(dir: string): string[] { + const result: string[] = []; + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true }) as fs.Dirent[]) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + result.push(...walkMarkdownFiles(full)); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + result.push(full); + } + } + } catch { + // ignore unreadable directories + } + return result; +} +function parseContextFromCforgeDev(content: string): ProjectContext { const repoField = extractField(content, "Repo"); const fromGit = (!repoField || !repoField.includes("/")) ? resolveRepoFromGit() : undefined; @@ -45,6 +92,38 @@ export function loadContext(): ProjectContext { return { repoOwner, repoName, stack, architecture, rules, existingModules, openQuestions }; } +function parseContextFromStandardFiles(content: string): ProjectContext { + const fromGit = resolveRepoFromGit(); + const repoOwner = fromGit?.owner ?? "unknown"; + const repoName = fromGit?.repo ?? "unknown"; + const stack = extractField(content, "Stack") ?? "TypeScript"; + const architecture = extractLine(content, "Architecture") ?? "Clean Architecture"; + + const rulesSection = extractSection(content, "Architecture Rules"); + const rules = rulesSection + .split("\n") + .map((l) => l.replace(/^-\s*/, "").trim()) + .filter((l) => l.length > 0); + + const modulesSection = extractSection(content, "Existing Modules"); + const existingModules = modulesSection + .split("\n") + .map((l) => l.replace(/^-\s*/, "").trim()) + .filter((l) => l.length > 0 && !l.startsWith("_")) + .map((l) => { + const [name, ...rest] = l.split(":"); + return { name: name.trim(), description: rest.join(":").trim() }; + }); + + const questionsSection = extractSection(content, "Open Questions"); + const openQuestions = questionsSection + .split("\n") + .map((l) => l.replace(/^-\s*/, "").trim()) + .filter((l) => l.length > 0 && !l.startsWith("_")); + + return { repoOwner, repoName, stack, architecture, rules, existingModules, openQuestions }; +} + function extractField(content: string, label: string): string | undefined { const regex = new RegExp(`\\*\\*${label}:\\*\\*\\s*(.+)`, "i"); const match = content.match(regex); diff --git a/tests/cli/utils/loadContext.test.ts b/tests/cli/utils/loadContext.test.ts new file mode 100644 index 0000000..b4fcfb7 --- /dev/null +++ b/tests/cli/utils/loadContext.test.ts @@ -0,0 +1,188 @@ +import { loadContext } from "../../../src/cli/utils/loadContext"; + +jest.mock("fs"); +import * as fs from "fs"; +const mockExistsSync = fs.existsSync as jest.MockedFunction; +const mockReadFileSync = fs.readFileSync as jest.MockedFunction; +const mockReaddirSync = fs.readdirSync as jest.MockedFunction; + +jest.mock("../../../src/cli/utils/resolveRepo"); +import { resolveRepoFromGit } from "../../../src/cli/utils/resolveRepo"; +const mockResolveRepoFromGit = resolveRepoFromGit as jest.MockedFunction; + +const CFORGE_DEV_CONTENT = `# CFORGE_DEV.md + +## Identity +- **Repo:** test-owner/test-repo +- **Stack:** Node.js + +## Architecture Rules +- TDD-first +- Domain never imports from Infrastructure + +## Existing Modules +- MyModule: does stuff +- OtherModule: does other stuff + +## Open Questions +- Why does this exist? +`; + +describe("loadContext", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockResolveRepoFromGit.mockReturnValue({ owner: "git-owner", repo: "git-repo" }); + mockReaddirSync.mockReturnValue([]); + }); + + describe("with CFORGE_DEV.md present", () => { + beforeEach(() => { + mockExistsSync.mockImplementation((p) => String(p).endsWith("CFORGE_DEV.md")); + mockReadFileSync.mockReturnValue(CFORGE_DEV_CONTENT as any); + }); + + it("loads repo owner and name from CFORGE_DEV.md", () => { + const ctx = loadContext(); + expect(ctx.repoOwner).toBe("test-owner"); + expect(ctx.repoName).toBe("test-repo"); + }); + + it("loads stack from CFORGE_DEV.md", () => { + const ctx = loadContext(); + expect(ctx.stack).toBe("Node.js"); + }); + + it("loads architecture rules from CFORGE_DEV.md", () => { + const ctx = loadContext(); + expect(ctx.rules).toContain("TDD-first"); + expect(ctx.rules).toContain("Domain never imports from Infrastructure"); + }); + + it("loads existing modules from CFORGE_DEV.md", () => { + const ctx = loadContext(); + expect(ctx.existingModules).toContainEqual({ name: "MyModule", description: "does stuff" }); + }); + + it("loads open questions from CFORGE_DEV.md", () => { + const ctx = loadContext(); + expect(ctx.openQuestions).toContain("Why does this exist?"); + }); + }); + + describe("without CFORGE_DEV.md — standard context files", () => { + beforeEach(() => { + mockReadFileSync.mockReturnValue("# Project\n" as any); + }); + + it("does not throw when README.md exists", () => { + mockExistsSync.mockImplementation((p) => String(p).endsWith("README.md")); + expect(() => loadContext()).not.toThrow(); + }); + + it("does not throw when IDEA.md exists", () => { + mockExistsSync.mockImplementation((p) => String(p).endsWith("IDEA.md")); + expect(() => loadContext()).not.toThrow(); + }); + + it("does not throw when ARCHITECTURE.md exists", () => { + mockExistsSync.mockImplementation((p) => String(p).endsWith("ARCHITECTURE.md")); + expect(() => loadContext()).not.toThrow(); + }); + + it("does not throw when MEMORANDUM.md exists", () => { + mockExistsSync.mockImplementation((p) => String(p).endsWith("MEMORANDUM.md")); + expect(() => loadContext()).not.toThrow(); + }); + + it("does not throw when MILESTONES.md exists", () => { + mockExistsSync.mockImplementation((p) => String(p).endsWith("MILESTONES.md")); + expect(() => loadContext()).not.toThrow(); + }); + + it("does not throw when CONTRIBUTING.md exists", () => { + mockExistsSync.mockImplementation((p) => String(p).endsWith("CONTRIBUTING.md")); + expect(() => loadContext()).not.toThrow(); + }); + + it("resolves repo owner and name from git when using standard files", () => { + mockExistsSync.mockImplementation((p) => String(p).endsWith("README.md")); + const ctx = loadContext(); + expect(ctx.repoOwner).toBe("git-owner"); + expect(ctx.repoName).toBe("git-repo"); + }); + + it("falls back to 'unknown' for repo when git also fails", () => { + mockResolveRepoFromGit.mockReturnValue(undefined); + mockExistsSync.mockImplementation((p) => String(p).endsWith("README.md")); + const ctx = loadContext(); + expect(ctx.repoOwner).toBe("unknown"); + expect(ctx.repoName).toBe("unknown"); + }); + + it("defaults stack to TypeScript when not in standard files", () => { + mockExistsSync.mockImplementation((p) => String(p).endsWith("README.md")); + const ctx = loadContext(); + expect(ctx.stack).toBe("TypeScript"); + }); + + it("defaults architecture to Clean Architecture when not in standard files", () => { + mockExistsSync.mockImplementation((p) => String(p).endsWith("README.md")); + const ctx = loadContext(); + expect(ctx.architecture).toBe("Clean Architecture"); + }); + + it("returns empty rules and modules when standard files have no structured sections", () => { + mockExistsSync.mockImplementation((p) => String(p).endsWith("README.md")); + const ctx = loadContext(); + expect(ctx.rules).toEqual([]); + expect(ctx.existingModules).toEqual([]); + expect(ctx.openQuestions).toEqual([]); + }); + }); + + describe("without CFORGE_DEV.md — docs/**/*.md files", () => { + it("does not throw when a docs markdown file exists", () => { + mockExistsSync.mockImplementation((p) => { + const s = String(p); + return s.endsWith("/docs") || s.endsWith("\\docs"); + }); + mockReaddirSync.mockReturnValue([ + { name: "api.md", isDirectory: () => false, isFile: () => true }, + ] as any); + mockReadFileSync.mockReturnValue("# API Docs\n" as any); + expect(() => loadContext()).not.toThrow(); + }); + + it("recursively finds markdown files in nested docs subdirectories", () => { + mockExistsSync.mockImplementation((p) => { + const s = String(p); + return s.endsWith("/docs") || s.endsWith("\\docs") || s.includes("/docs/"); + }); + mockReaddirSync.mockImplementation((dir) => { + const s = String(dir); + if (s.endsWith("/docs") || s.endsWith("\\docs")) { + return [{ name: "sub", isDirectory: () => true, isFile: () => false }] as any; + } + return [{ name: "guide.md", isDirectory: () => false, isFile: () => true }] as any; + }); + mockReadFileSync.mockReturnValue("# Guide\n" as any); + expect(() => loadContext()).not.toThrow(); + }); + }); + + describe("when no context files exist", () => { + it("throws when CFORGE_DEV.md and all standard files are absent", () => { + mockExistsSync.mockReturnValue(false); + expect(() => loadContext()).toThrow( + "CFORGE_DEV.md not found — run cforge-dev from project root" + ); + }); + + it("throws the exact legacy error message", () => { + mockExistsSync.mockReturnValue(false); + expect(() => loadContext()).toThrowError( + "CFORGE_DEV.md not found — run cforge-dev from project root" + ); + }); + }); +});