From 2f9471786e9625d14af9a1b735bad575e3984641 Mon Sep 17 00:00:00 2001 From: Hichem Date: Fri, 8 May 2026 11:32:31 +0200 Subject: [PATCH 1/7] feat(bmad): support custom output_folder via config.yaml Read `output_folder` from `_bmad/core/config.yaml` (with fallback to the `_bmad-output` default) so projects with a custom BMAD output path render correctly. Strip the `{project-root}/` placeholder when present. - New parse-config module with both a pure parser and a provider-aware loader, plus 15 tests covering valid/missing/malformed config. - LocalProvider's BMAD whitelist becomes instance-level and exposes `extendBmadDirs(name)` so the parser can grant access to a config-declared output folder at runtime. - Refresh flows (local + GitHub) now compute the file count against the resolved output dir instead of the hardcoded `_bmad-output/` prefix. --- src/actions/repo-actions.ts | 40 ++++++- src/lib/bmad/__tests__/parse-config.test.ts | 126 ++++++++++++++++++++ src/lib/bmad/parse-config.ts | 56 +++++++++ src/lib/bmad/parser.ts | 23 +++- src/lib/content-provider/local-provider.ts | 43 ++++++- src/lib/content-provider/types.ts | 6 + 6 files changed, 280 insertions(+), 14 deletions(-) create mode 100644 src/lib/bmad/__tests__/parse-config.test.ts create mode 100644 src/lib/bmad/parse-config.ts diff --git a/src/actions/repo-actions.ts b/src/actions/repo-actions.ts index f60b8df..a1fb693 100644 --- a/src/actions/repo-actions.ts +++ b/src/actions/repo-actions.ts @@ -11,6 +11,7 @@ import { import { LocalProvider } from "@/lib/content-provider/local-provider"; import { buildFileTree } from "@/lib/bmad/utils"; import { parseBmadFile } from "@/lib/bmad/parser"; +import { getBmadConfig, DEFAULT_OUTPUT_DIR } from "@/lib/bmad/parse-config"; import { prisma } from "@/lib/db/client"; import { getAuthenticatedSession } from "@/lib/db/helpers"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; @@ -363,8 +364,17 @@ async function refreshLocalRepo( const provider = new LocalProvider(repoConfig.localPath); await provider.validateRoot(); - const tree = await provider.getTree(); - const totalFiles = tree.paths.filter((p) => p.startsWith("_bmad-output/")).length; + let tree = await provider.getTree(); + const { outputDir } = await getBmadConfig(provider, tree.paths); + if (outputDir !== DEFAULT_OUTPUT_DIR) { + try { + provider.extendBmadDirs(outputDir); + tree = await provider.getTree(); + } catch (e) { + console.warn(`[Refresh] Cannot extend whitelist to "${outputDir}":`, e); + } + } + const totalFiles = tree.paths.filter((p) => p.startsWith(outputDir + "/")).length; const now = new Date(); await prisma.repo.update({ @@ -409,9 +419,29 @@ async function refreshGitHubRepo( recursive: "1", }); - const totalFiles = tree.tree.filter( - (item) => item.type === "blob" && item.path?.startsWith("_bmad-output/") - ).length; + const allPaths = tree.tree + .filter((item) => item.type === "blob" && typeof item.path === "string") + .map((item) => item.path as string); + + const ghProviderShim = { + async getTree() { + return { paths: allPaths, rootDirectories: [] }; + }, + async getFileContent(p: string) { + return getCachedUserRawContent( + octokit, + userId, + input.owner, + input.name, + syncBranch, + p, + ); + }, + async validateRoot() {}, + }; + const { outputDir } = await getBmadConfig(ghProviderShim, allPaths); + + const totalFiles = allPaths.filter((p) => p.startsWith(outputDir + "/")).length; const now = new Date(); await prisma.repo.update({ diff --git a/src/lib/bmad/__tests__/parse-config.test.ts b/src/lib/bmad/__tests__/parse-config.test.ts new file mode 100644 index 0000000..db75bc9 --- /dev/null +++ b/src/lib/bmad/__tests__/parse-config.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi } from "vitest"; +import { + parseConfigContent, + getBmadConfig, + DEFAULT_OUTPUT_DIR, + CORE_CONFIG_PATH, +} from "../parse-config"; +import type { ContentProvider } from "@/lib/content-provider"; + +function makeProvider(files: Record): ContentProvider { + return { + async getTree() { + return { paths: Object.keys(files), rootDirectories: [] }; + }, + async getFileContent(path: string) { + if (!(path in files)) throw new Error(`Not found: ${path}`); + return files[path]; + }, + async validateRoot() {}, + }; +} + +describe("parseConfigContent", () => { + it("extracts output_folder and strips {project-root} prefix", () => { + const yaml = ` +user_name: Hichem +output_folder: "{project-root}/_bmad-output" +`; + expect(parseConfigContent(yaml)).toEqual({ outputDir: "_bmad-output" }); + }); + + it("supports custom output paths", () => { + const yaml = `output_folder: "{project-root}/custom/out"`; + expect(parseConfigContent(yaml)).toEqual({ outputDir: "custom/out" }); + }); + + it("handles output_folder without {project-root} prefix", () => { + const yaml = `output_folder: my-output`; + expect(parseConfigContent(yaml)).toEqual({ outputDir: "my-output" }); + }); + + it("strips trailing and leading slashes", () => { + const yaml = `output_folder: "{project-root}//_bmad-output/"`; + expect(parseConfigContent(yaml)).toEqual({ outputDir: "_bmad-output" }); + }); + + it("returns null when output_folder is missing", () => { + const yaml = `user_name: Hichem`; + expect(parseConfigContent(yaml)).toBeNull(); + }); + + it("returns null when output_folder is empty", () => { + const yaml = `output_folder: ""`; + expect(parseConfigContent(yaml)).toBeNull(); + }); + + it("returns null when output_folder reduces to empty after stripping prefix", () => { + const yaml = `output_folder: "{project-root}/"`; + expect(parseConfigContent(yaml)).toBeNull(); + }); + + it("returns null for invalid YAML", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(parseConfigContent("key:\n\t- bad:\n\t\t\t- value")).toBeNull(); + consoleSpy.mockRestore(); + }); + + it("returns null when YAML is not an object", () => { + expect(parseConfigContent("just a string")).toBeNull(); + }); + + it("returns null when output_folder is not a string", () => { + const yaml = `output_folder: 42`; + expect(parseConfigContent(yaml)).toBeNull(); + }); +}); + +describe("getBmadConfig", () => { + it("returns parsed config when core/config.yaml exists", async () => { + const provider = makeProvider({ + [CORE_CONFIG_PATH]: `output_folder: "{project-root}/custom-out"`, + }); + const config = await getBmadConfig(provider, [CORE_CONFIG_PATH]); + expect(config.outputDir).toBe("custom-out"); + }); + + it("falls back to default when config file is missing", async () => { + const provider = makeProvider({}); + const config = await getBmadConfig(provider, ["some/other/file.md"]); + expect(config.outputDir).toBe(DEFAULT_OUTPUT_DIR); + }); + + it("falls back to default when config YAML is malformed", async () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const provider = makeProvider({ + [CORE_CONFIG_PATH]: `key:\n\t- bad`, + }); + const config = await getBmadConfig(provider, [CORE_CONFIG_PATH]); + expect(config.outputDir).toBe(DEFAULT_OUTPUT_DIR); + consoleSpy.mockRestore(); + }); + + it("falls back to default when config is missing output_folder", async () => { + const provider = makeProvider({ + [CORE_CONFIG_PATH]: `user_name: Hichem`, + }); + const config = await getBmadConfig(provider, [CORE_CONFIG_PATH]); + expect(config.outputDir).toBe(DEFAULT_OUTPUT_DIR); + }); + + it("falls back to default when fetching the config file throws", async () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const provider: ContentProvider = { + async getTree() { + return { paths: [CORE_CONFIG_PATH], rootDirectories: [] }; + }, + async getFileContent() { + throw new Error("network error"); + }, + async validateRoot() {}, + }; + const config = await getBmadConfig(provider, [CORE_CONFIG_PATH]); + expect(config.outputDir).toBe(DEFAULT_OUTPUT_DIR); + consoleSpy.mockRestore(); + }); +}); diff --git a/src/lib/bmad/parse-config.ts b/src/lib/bmad/parse-config.ts new file mode 100644 index 0000000..1750620 --- /dev/null +++ b/src/lib/bmad/parse-config.ts @@ -0,0 +1,56 @@ +import yaml from "js-yaml"; +import type { ContentProvider } from "@/lib/content-provider"; + +export interface BmadConfig { + outputDir: string; +} + +export const DEFAULT_OUTPUT_DIR = "_bmad-output"; +export const CORE_CONFIG_PATH = "_bmad/core/config.yaml"; + +const PROJECT_ROOT_PREFIX = /^\{project-root\}\/+/; + +function normalizeOutputFolder(raw: string): string | null { + const trimmed = raw.trim().replace(/^["']|["']$/g, ""); + if (!trimmed) return null; + const stripped = trimmed.replace(PROJECT_ROOT_PREFIX, ""); + const cleaned = stripped.replace(/^\/+/, "").replace(/\/+$/, ""); + return cleaned || null; +} + +export function parseConfigContent(content: string): BmadConfig | null { + try { + const data = yaml.load(content); + if (!data || typeof data !== "object") return null; + + const raw = (data as Record).output_folder; + if (typeof raw !== "string") return null; + + const outputDir = normalizeOutputFolder(raw); + if (!outputDir) return null; + + return { outputDir }; + } catch (e) { + console.warn("[BMAD Config] Failed to parse config YAML:", e); + return null; + } +} + +export async function getBmadConfig( + provider: ContentProvider, + paths: string[], +): Promise { + if (!paths.includes(CORE_CONFIG_PATH)) { + return { outputDir: DEFAULT_OUTPUT_DIR }; + } + + try { + const content = await provider.getFileContent(CORE_CONFIG_PATH); + const parsed = parseConfigContent(content); + if (parsed) return parsed; + } catch (e) { + console.warn(`[BMAD Config] Failed to read ${CORE_CONFIG_PATH}:`, e); + } + + return { outputDir: DEFAULT_OUTPUT_DIR }; +} diff --git a/src/lib/bmad/parser.ts b/src/lib/bmad/parser.ts index 55c76f9..9dc7e49 100644 --- a/src/lib/bmad/parser.ts +++ b/src/lib/bmad/parser.ts @@ -6,13 +6,13 @@ import { parseEpicFile } from "./parse-epic-file"; import { parseStory } from "./parse-story"; import { correlate, computeProjectStats } from "./correlate"; import { buildFileTree } from "./utils"; +import { getBmadConfig, DEFAULT_OUTPUT_DIR } from "./parse-config"; import type { RepoConfig } from "@/lib/types"; import type { ParsedBmadFile, BmadFileMetadata } from "./types"; import { normalizeStoryStatus } from "./utils"; import matter from "gray-matter"; import yaml from "js-yaml"; -const BMAD_OUTPUT = "_bmad-output"; const PLANNING = "planning-artifacts"; const IMPLEMENTATION = "implementation-artifacts"; @@ -26,10 +26,23 @@ export async function getBmadProject( ): Promise { const { owner, name: repo, branch, displayName } = config; - const providerTree = await provider.getTree(); - const allPaths = providerTree.paths; + let providerTree = await provider.getTree(); + const { outputDir } = await getBmadConfig(provider, providerTree.paths); - const bmadPaths = allPaths.filter((p) => p.startsWith(BMAD_OUTPUT + "/")); + if (outputDir !== DEFAULT_OUTPUT_DIR && provider.extendBmadDirs) { + try { + provider.extendBmadDirs(outputDir); + providerTree = await provider.getTree(); + } catch (e) { + console.warn( + `[BMAD Parse] Cannot extend whitelist to "${outputDir}":`, + e, + ); + } + } + + const allPaths = providerTree.paths; + const bmadPaths = allPaths.filter((p) => p.startsWith(outputDir + "/")); const sprintStatusPath = bmadPaths.find( (p) => @@ -161,7 +174,7 @@ export async function getBmadProject( const { epics, stories } = correlate(sprintStatus, rawEpics, rawStories, epicStatuses); const storyPathSet = new Set(storyPaths); const docPaths = bmadPaths.filter((p) => !storyPathSet.has(p)); - const fileTree = buildFileTree(docPaths, BMAD_OUTPUT); + const fileTree = buildFileTree(docPaths, outputDir); // Detect a "Docs" folder (case-insensitive) via rootDirectories (F20) const docsFolderName = providerTree.rootDirectories.find( diff --git a/src/lib/content-provider/local-provider.ts b/src/lib/content-provider/local-provider.ts index 50ad95a..20e4236 100644 --- a/src/lib/content-provider/local-provider.ts +++ b/src/lib/content-provider/local-provider.ts @@ -8,6 +8,12 @@ interface LocalProviderOptions { maxFileSizeBytes?: number; maxFileCount?: number; maxDepth?: number; + /** + * Top-level directories the provider is allowed to walk and read from. + * Defaults to ["_bmad", "_bmad-output"]. Pass a different list when the + * project's BMAD config points to a custom output folder. + */ + bmadDirs?: string[]; } export class LocalProvider implements ContentProvider { @@ -15,6 +21,7 @@ export class LocalProvider implements ContentProvider { private maxFileSizeBytes: number; private maxFileCount: number; private maxDepth: number; + private bmadDirs: Set; constructor(rootPath: string, options?: LocalProviderOptions) { // Guard 1 — Feature flag @@ -37,6 +44,31 @@ export class LocalProvider implements ContentProvider { this.maxFileCount = options?.maxFileCount ?? LOCAL_PROVIDER_DEFAULTS.maxFileCount; this.maxDepth = options?.maxDepth ?? LOCAL_PROVIDER_DEFAULTS.maxDepth; + this.bmadDirs = new Set( + options?.bmadDirs ?? Array.from(LocalProvider.DEFAULT_BMAD_DIRS), + ); + } + + /** + * Allow scanning an additional top-level directory (e.g. a custom output + * folder declared in `_bmad/core/config.yaml`). The name must be a single + * path segment — no slashes, no traversal, no leading dot. + */ + extendBmadDirs(name: string): void { + if ( + !name || + name.includes("/") || + name.includes("\\") || + name === "." || + name === ".." || + name.startsWith(".") + ) { + throw new Error(`Invalid BMAD dir name: ${name}`); + } + if (LocalProvider.IGNORED_DIRS.has(name)) { + throw new Error(`Cannot extend into ignored dir: ${name}`); + } + this.bmadDirs.add(name); } async validateRoot(): Promise { @@ -72,8 +104,11 @@ export class LocalProvider implements ContentProvider { ".now", ]); - /** Directories to scan for BMAD content. Only these (and their children) are walked. */ - private static BMAD_DIRS = new Set(["_bmad", "_bmad-output"]); + /** Default whitelist of top-level dirs containing BMAD content. */ + private static DEFAULT_BMAD_DIRS: ReadonlySet = new Set([ + "_bmad", + "_bmad-output", + ]); async getTree(): Promise { const paths: string[] = []; @@ -132,7 +167,7 @@ export class LocalProvider implements ContentProvider { }; for (const dirName of rootDirectories) { - if (LocalProvider.BMAD_DIRS.has(dirName)) { + if (this.bmadDirs.has(dirName)) { await walk(path.join(this.resolvedRoot, dirName), 1); } } @@ -184,7 +219,7 @@ export class LocalProvider implements ContentProvider { // Guard 7 — Restrict access to BMAD directories only const firstSegment = filePath.split(path.sep)[0]; - if (!LocalProvider.BMAD_DIRS.has(firstSegment)) { + if (!this.bmadDirs.has(firstSegment)) { throw new Error("Access denied: only BMAD directories are accessible"); } } diff --git a/src/lib/content-provider/types.ts b/src/lib/content-provider/types.ts index 793e75e..fbbcf6f 100644 --- a/src/lib/content-provider/types.ts +++ b/src/lib/content-provider/types.ts @@ -10,6 +10,12 @@ export interface ContentProvider { getFileContent(filePath: string): Promise; /** Verify that the root path exists and is accessible. Throws if not. */ validateRoot(): Promise; + /** + * Optionally allow the provider to scan an additional top-level directory + * (only meaningful for filesystem-based providers with a whitelist). + * No-op for providers without such a constraint. + */ + extendBmadDirs?(name: string): void; } export const LOCAL_PROVIDER_DEFAULTS = { From d4599731ea384837e03b5e92172016c7089f84e8 Mon Sep 17 00:00:00 2001 From: Hichem Date: Fri, 8 May 2026 11:32:38 +0200 Subject: [PATCH 2/7] fix(ui): suppress hydration warning on Input Password manager extensions (1Password, etc.) inject attributes like `data-com-onepassword-filled` on inputs before React hydrates, triggering a console error. `suppressHydrationWarning` is the canonical escape hatch for this exact case. --- src/components/ui/input.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index f1124ae..ff83ae4 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -7,6 +7,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { Date: Fri, 8 May 2026 11:59:22 +0200 Subject: [PATCH 3/7] feat(bmad): support epic-folder layout with auto-detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect `/planning-artifacts/epics/epic-N/` as a folder-based epic. When `epic.md` is present inside, use it as the metadata source; otherwise derive id and title from the folder name (e.g. `epic-1-platform-setup` → `{id: "1", title: "Platform Setup"}`). Inner `.md` files become stories with their epicId injected from the folder context, so a bare `story-1.md` inside `epic-3/` resolves as story `3.1`. Both layouts coexist during the BMAD V1 transition: - Single `epics.md` keeps absolute priority (back-compat). - Otherwise, flat `epic-N.md` files and `epic-N/` folders both contribute, with stories collected from `implementation-artifacts/` and from inside epic folders. Side fixes: - Sort epics by numeric id at parser level so order is consistent across the dashboard, the Epics page, the Stories page, and the Library. - Filter `.DS_Store`, `Thumbs.db`, and `desktop.ini` in LocalProvider so OS metadata never reaches the file tree. - parse-story now normalizes `-` separators in legacy filename ids (`story-2-1.md` → `2.1`, not `2-1`). --- .../bmad/__tests__/parse-epic-folder.test.ts | 65 +++++++++ .../bmad/__tests__/parser.epic-folder.test.ts | 136 ++++++++++++++++++ src/lib/bmad/parse-epic-folder.ts | 35 +++++ src/lib/bmad/parse-story.ts | 2 +- src/lib/bmad/parser.ts | 116 +++++++++++++-- .../__tests__/local-provider.test.ts | 13 ++ src/lib/content-provider/local-provider.ts | 12 ++ 7 files changed, 364 insertions(+), 15 deletions(-) create mode 100644 src/lib/bmad/__tests__/parse-epic-folder.test.ts create mode 100644 src/lib/bmad/__tests__/parser.epic-folder.test.ts create mode 100644 src/lib/bmad/parse-epic-folder.ts diff --git a/src/lib/bmad/__tests__/parse-epic-folder.test.ts b/src/lib/bmad/__tests__/parse-epic-folder.test.ts new file mode 100644 index 0000000..f7e4268 --- /dev/null +++ b/src/lib/bmad/__tests__/parse-epic-folder.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { parseEpicFolderName } from "../parse-epic-folder"; + +describe("parseEpicFolderName", () => { + it("parses 'epic-1' as id=1 with empty title", () => { + expect(parseEpicFolderName("epic-1")).toEqual({ id: "1", title: "" }); + }); + + it("parses 'epic_1' (underscore separator)", () => { + expect(parseEpicFolderName("epic_1")).toEqual({ id: "1", title: "" }); + }); + + it("parses bare numeric folder '1'", () => { + expect(parseEpicFolderName("1")).toEqual({ id: "1", title: "" }); + }); + + it("parses 'epic-1-project-foundation' with title-cased title", () => { + expect(parseEpicFolderName("epic-1-project-foundation")).toEqual({ + id: "1", + title: "Project Foundation", + }); + }); + + it("parses 'epic_1_project_foundation' (underscores)", () => { + expect(parseEpicFolderName("epic_1_project_foundation")).toEqual({ + id: "1", + title: "Project Foundation", + }); + }); + + it("parses '1-project-foundation' without epic prefix", () => { + expect(parseEpicFolderName("1-project-foundation")).toEqual({ + id: "1", + title: "Project Foundation", + }); + }); + + it("supports multi-digit ids", () => { + expect(parseEpicFolderName("epic-12-large-id")).toEqual({ + id: "12", + title: "Large Id", + }); + }); + + it("collapses multiple consecutive separators", () => { + expect(parseEpicFolderName("epic-1--double-sep")).toEqual({ + id: "1", + title: "Double Sep", + }); + }); + + it("is case-insensitive on the prefix", () => { + expect(parseEpicFolderName("EPIC-1-foo")).toEqual({ + id: "1", + title: "Foo", + }); + }); + + it("returns null for non-matching folder names", () => { + expect(parseEpicFolderName("not-an-epic")).toBeNull(); + expect(parseEpicFolderName("foo-1")).toBeNull(); + expect(parseEpicFolderName("")).toBeNull(); + expect(parseEpicFolderName(" ")).toBeNull(); + }); +}); diff --git a/src/lib/bmad/__tests__/parser.epic-folder.test.ts b/src/lib/bmad/__tests__/parser.epic-folder.test.ts new file mode 100644 index 0000000..c258819 --- /dev/null +++ b/src/lib/bmad/__tests__/parser.epic-folder.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from "vitest"; +import { getBmadProject } from "../parser"; +import type { ContentProvider } from "@/lib/content-provider"; +import type { RepoConfig } from "@/lib/types"; + +function makeProvider(files: Record): ContentProvider { + return { + async getTree() { + return { paths: Object.keys(files), rootDirectories: ["_bmad-output"] }; + }, + async getFileContent(p: string) { + if (!(p in files)) throw new Error(`Not found: ${p}`); + return files[p]; + }, + async validateRoot() {}, + }; +} + +const REPO: RepoConfig = { + owner: "local", + name: "test", + branch: "main", + displayName: "Test", + description: null, + sourceType: "local", + localPath: "/tmp/test", + lastSyncedAt: null, +}; + +describe("getBmadProject — epic-folder layout", () => { + it("derives an epic from folder name when no epic.md is present", async () => { + const provider = makeProvider({ + "_bmad-output/planning-artifacts/epics/epic-1-platform-setup/story-1-1.md": [ + "# Setup repo", + "Status: done", + ].join("\n"), + "_bmad-output/planning-artifacts/epics/epic-1-platform-setup/story-1-2.md": [ + "# Configure CI", + "Status: in-progress", + ].join("\n"), + }); + + const project = await getBmadProject(REPO, provider); + expect(project).not.toBeNull(); + expect(project!.epics).toHaveLength(1); + expect(project!.epics[0]).toMatchObject({ + id: "1", + title: "Platform Setup", + }); + expect(project!.stories).toHaveLength(2); + const ids = project!.stories.map((s) => s.id).sort(); + expect(ids).toEqual(["1.1", "1.2"]); + expect(project!.stories.every((s) => s.epicId === "1")).toBe(true); + }); + + it("uses epic.md as meta when present and treats siblings as stories", async () => { + const provider = makeProvider({ + "_bmad-output/planning-artifacts/epics/epic-2/epic.md": [ + "---", + "id: 2", + "title: Data Ingestion", + "---", + "", + "Pipeline goals.", + ].join("\n"), + "_bmad-output/planning-artifacts/epics/epic-2/story-2-1.md": [ + "# Stream input", + "Status: done", + ].join("\n"), + }); + + const project = await getBmadProject(REPO, provider); + expect(project!.epics).toHaveLength(1); + expect(project!.epics[0]).toMatchObject({ + id: "2", + title: "Data Ingestion", + }); + expect(project!.stories).toHaveLength(1); + expect(project!.stories[0].id).toBe("2.1"); + expect(project!.stories[0].epicId).toBe("2"); + }); + + it("injects epicId on stories whose filename does not encode it", async () => { + const provider = makeProvider({ + "_bmad-output/planning-artifacts/epics/epic-3/story-1.md": [ + "# Bare story", + "Status: done", + ].join("\n"), + }); + + const project = await getBmadProject(REPO, provider); + expect(project!.stories).toHaveLength(1); + // Filename is "story-1.md" → parseStory yields id="1"; folder context turns it into "3.1". + expect(project!.stories[0].id).toBe("3.1"); + expect(project!.stories[0].epicId).toBe("3"); + }); + + it("supports both layouts side-by-side (legacy + folder)", async () => { + const provider = makeProvider({ + // Legacy: flat impl story under epic 1 + "_bmad-output/implementation-artifacts/1-1-foo.md": [ + "# Foo", + "Status: done", + ].join("\n"), + // New: epic 2 as folder with derived meta + "_bmad-output/planning-artifacts/epics/epic-2-bar/story-2-1.md": [ + "# Bar", + "Status: in-progress", + ].join("\n"), + }); + + const project = await getBmadProject(REPO, provider); + expect(project!.epics).toHaveLength(1); // only epic 2 has a meta source + expect(project!.epics[0].id).toBe("2"); + expect(project!.stories.map((s) => s.id).sort()).toEqual(["1.1", "2.1"]); + }); + + it("ignores epic-folder when a single epics.md is present (single-file wins)", async () => { + const provider = makeProvider({ + "_bmad-output/planning-artifacts/epics.md": [ + "## Epic 1: From single file", + "- Story 1.1 - Foo", + ].join("\n"), + // This folder should be ignored because epics.md takes precedence + "_bmad-output/planning-artifacts/epics/epic-2-other/story-2-1.md": [ + "# Bar", + ].join("\n"), + }); + + const project = await getBmadProject(REPO, provider); + expect(project!.epics.map((e) => e.id)).toEqual(["1"]); + expect(project!.epics[0].title).toBe("From single file"); + // Story from the ignored folder shouldn't appear either + expect(project!.stories).toHaveLength(0); + }); +}); diff --git a/src/lib/bmad/parse-epic-folder.ts b/src/lib/bmad/parse-epic-folder.ts new file mode 100644 index 0000000..ce5616c --- /dev/null +++ b/src/lib/bmad/parse-epic-folder.ts @@ -0,0 +1,35 @@ +export interface EpicFolderName { + id: string; + title: string; +} + +/** + * Derive an epic id and (optional) title from a folder name when no + * `epic.md` file is present inside the folder. + * + * Recognized patterns: + * epic-1 → { id: "1", title: "" } + * epic_1 → { id: "1", title: "" } + * 1 → { id: "1", title: "" } + * epic-1-project-foundation → { id: "1", title: "Project Foundation" } + * epic_1_project_foundation → { id: "1", title: "Project Foundation" } + * 1-project-foundation → { id: "1", title: "Project Foundation" } + */ +export function parseEpicFolderName(folderName: string): EpicFolderName | null { + const trimmed = folderName.trim(); + if (!trimmed) return null; + + const match = trimmed.match(/^(?:epic[_-]?)?(\d+)(?:[_-]+(.+))?$/i); + if (!match) return null; + + const id = match[1]; + const slug = match[2] ?? ""; + + const title = slug + .split(/[-_]+/) + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(" "); + + return { id, title }; +} diff --git a/src/lib/bmad/parse-story.ts b/src/lib/bmad/parse-story.ts index 6315c99..ac3b1da 100644 --- a/src/lib/bmad/parse-story.ts +++ b/src/lib/bmad/parse-story.ts @@ -20,7 +20,7 @@ export function parseStory( id = `${numericMatch[1]}.${numericMatch[2]}`; epicId = numericMatch[1]; } else if (legacyMatch) { - id = legacyMatch[1].replace(/[._]/, "."); + id = legacyMatch[1].replace(/[._-]/, "."); epicId = id.includes(".") ? id.split(".")[0] : ""; } else { id = filename.replace(/\.md$/i, ""); diff --git a/src/lib/bmad/parser.ts b/src/lib/bmad/parser.ts index 9dc7e49..b12700f 100644 --- a/src/lib/bmad/parser.ts +++ b/src/lib/bmad/parser.ts @@ -7,6 +7,7 @@ import { parseStory } from "./parse-story"; import { correlate, computeProjectStats } from "./correlate"; import { buildFileTree } from "./utils"; import { getBmadConfig, DEFAULT_OUTPUT_DIR } from "./parse-config"; +import { parseEpicFolderName } from "./parse-epic-folder"; import type { RepoConfig } from "@/lib/types"; import type { ParsedBmadFile, BmadFileMetadata } from "./types"; import { normalizeStoryStatus } from "./utils"; @@ -50,30 +51,91 @@ export async function getBmadProject( p.endsWith("sprint-status.yaml") ); - // Auto-detect epics source: single file first, then directory fallback + // Auto-detect epics source: single file first, then directory fallback. + // The single file lives directly under planning-artifacts/ — NOT inside + // a subfolder (otherwise an epic-folder's epic.md would be captured here). const epicsPath = bmadPaths.find( (p) => - p.includes(PLANNING) && - (p.endsWith("epics.md") || p.endsWith("epic.md")) + p.endsWith("/" + PLANNING + "/epics.md") || + p.endsWith("/" + PLANNING + "/epic.md"), ); - const EPICS_DIR = PLANNING + "/epics"; + // All .md files under /...//epics/ + const allEpicsDirPaths = bmadPaths.filter( + (p) => p.includes("/" + PLANNING + "/epics/") && p.endsWith(".md"), + ); + + // Split by depth: flat (epic-1.md) vs nested (epic-1/.md) + const flatEpicPaths: string[] = []; + const folderEpicMap = new Map(); // folderName → contained .md paths + for (const p of allEpicsDirPaths) { + const idx = p.indexOf("/" + PLANNING + "/epics/"); + const rel = p.slice(idx + ("/" + PLANNING + "/epics/").length); + const parts = rel.split("/"); + if (parts.length === 1) { + if (/^(?:epic[_-]?)?\d+/i.test(parts[0])) flatEpicPaths.push(p); + } else { + const folder = parts[0]; + if (!folderEpicMap.has(folder)) folderEpicMap.set(folder, []); + folderEpicMap.get(folder)!.push(p); + } + } + + // Process epic folders → meta path or derived epic, plus inner stories + interface EpicFolderEntry { + folder: string; + id: string; + title: string; + metaPath: string | null; + storyPaths: string[]; + } + const epicFolders: EpicFolderEntry[] = []; + const epicIdByStoryPath = new Map(); + + for (const [folder, paths] of folderEpicMap) { + const derived = parseEpicFolderName(folder); + if (!derived) continue; + const metaPath = paths.find((p) => p.endsWith("/epic.md")) ?? null; + const innerStories = paths.filter((p) => !p.endsWith("/epic.md")); + epicFolders.push({ + folder, + id: derived.id, + title: derived.title, + metaPath, + storyPaths: innerStories, + }); + for (const sp of innerStories) { + epicIdByStoryPath.set(sp, derived.id); + } + } + + // Epic file paths = flat files + folder meta files (if no single epics.md) const epicFilePaths = epicsPath - ? [] // single file wins — skip directory - : bmadPaths.filter((p) => { - if (!p.includes(EPICS_DIR + "/") || !p.endsWith(".md")) return false; - const filename = p.split("/").pop() || ""; - // Match: epic-1.md, epic_1.md, 1-title.md, 1.md, epic-1-title.md - return /^(?:epic[_-]?)?\d+/i.test(filename); - }); - - const storyPaths = bmadPaths.filter((p) => { + ? [] // single epics.md wins — skip directory-based sources + : [ + ...flatEpicPaths, + ...epicFolders + .filter((e) => e.metaPath) + .map((e) => e.metaPath as string), + ]; + + // Folder-derived epics (no epic.md inside) — used only if no epics.md + const derivedFolderEpics = epicsPath + ? [] + : epicFolders.filter((e) => !e.metaPath); + + // Stories: implementation-artifacts (legacy) ∪ epic-folder-inner stories + const implStoryPaths = bmadPaths.filter((p) => { if (!p.includes(IMPLEMENTATION) || !p.endsWith(".md")) return false; const filename = p.split("/").pop() || ""; if (/^\d+-\d+-.+\.md$/.test(filename)) return true; if (/^story[_-]?\d/i.test(filename)) return true; return false; }); + const folderStoryPaths = epicsPath + ? [] // single-file mode: don't pull stories from epic folders + : epicFolders.flatMap((e) => e.storyPaths); + const storyPaths = Array.from(new Set([...implStoryPaths, ...folderStoryPaths])); const fetchContent = (path: string) => provider.getFileContent(path); @@ -125,6 +187,20 @@ export async function getBmadProject( let rawEpics: import("./types").Epic[] = []; const rawStories: NonNullable>[] = []; + // Synthesize epics for folders that have no epic.md inside. + for (const e of derivedFolderEpics) { + rawEpics.push({ + id: e.id, + title: e.title || `Epic ${e.id}`, + description: "", + status: "not-started", + stories: [], + totalStories: 0, + completedStories: 0, + progressPercent: 0, + }); + } + for (const { key, content } of results) { if (key === "sprint") { totalFiles++; @@ -158,6 +234,14 @@ export async function getBmadProject( const filename = storyPath.split("/").pop() || ""; const story = parseStory(content, filename); if (story) { + const folderEpicId = epicIdByStoryPath.get(storyPath); + if (folderEpicId) { + // Story lives inside epic-N/ — fix epicId and (re)build composite id + story.epicId = folderEpicId; + if (!story.id.includes(".")) { + story.id = `${folderEpicId}.${story.id}`; + } + } rawStories.push(story); } else { parseErrors.push({ file: storyPath, error: "Failed to parse story. Check the markdown format and section structure.", contentType: "story" }); @@ -171,7 +255,11 @@ export async function getBmadProject( console.warn(`[BMAD Parse] ${owner}/${repo}: ${parseErrors.length} parsing errors out of ${totalFiles} files`); } - const { epics, stories } = correlate(sprintStatus, rawEpics, rawStories, epicStatuses); + const correlated = correlate(sprintStatus, rawEpics, rawStories, epicStatuses); + const epics = [...correlated.epics].sort( + (a, b) => (parseInt(a.id, 10) || 0) - (parseInt(b.id, 10) || 0), + ); + const stories = correlated.stories; const storyPathSet = new Set(storyPaths); const docPaths = bmadPaths.filter((p) => !storyPathSet.has(p)); const fileTree = buildFileTree(docPaths, outputDir); diff --git a/src/lib/content-provider/__tests__/local-provider.test.ts b/src/lib/content-provider/__tests__/local-provider.test.ts index 3196de3..c7f15dc 100644 --- a/src/lib/content-provider/__tests__/local-provider.test.ts +++ b/src/lib/content-provider/__tests__/local-provider.test.ts @@ -86,6 +86,19 @@ describe("LocalProvider", () => { expect(tree.rootDirectories).not.toContain("root.txt"); }); + it("filters out OS metadata files (.DS_Store, Thumbs.db)", async () => { + await writeFile("_bmad-output/real.md", "# Real"); + await writeFile("_bmad-output/.DS_Store", "binary"); + await writeFile("_bmad-output/sub/.DS_Store", "binary"); + await writeFile("_bmad-output/sub/Thumbs.db", "binary"); + await writeFile("_bmad-output/sub/desktop.ini", "binary"); + + const provider = new LocalProvider(tmpDir); + const tree = await provider.getTree(); + + expect(tree.paths).toEqual([path.join("_bmad-output", "real.md")]); + }); + it("respects maxFileCount limit", async () => { for (let i = 0; i < 5; i++) { await writeFile(`_bmad/file${i}.txt`); diff --git a/src/lib/content-provider/local-provider.ts b/src/lib/content-provider/local-provider.ts index 20e4236..356e77f 100644 --- a/src/lib/content-provider/local-provider.ts +++ b/src/lib/content-provider/local-provider.ts @@ -110,6 +110,13 @@ export class LocalProvider implements ContentProvider { "_bmad-output", ]); + /** OS metadata files that should never appear in the project tree. */ + private static IGNORED_FILES: ReadonlySet = new Set([ + ".DS_Store", + "Thumbs.db", + "desktop.ini", + ]); + async getTree(): Promise { const paths: string[] = []; const rootDirectories: string[] = []; @@ -153,6 +160,11 @@ export class LocalProvider implements ContentProvider { continue; } + // Skip OS metadata files (.DS_Store, Thumbs.db, etc.) + if (LocalProvider.IGNORED_FILES.has(dirent.name)) { + continue; + } + // Guard 5 — File count limit fileCount++; if (fileCount > this.maxFileCount) { From ce0c4ef1cd5a5e80c146fa53ecd25a3074d029a0 Mon Sep 17 00:00:00 2001 From: Hichem Date: Fri, 8 May 2026 12:18:52 +0200 Subject: [PATCH 4/7] feat(bmad): make story frontmatter the source of truth for status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invert the priority between story markdown and sprint-status.yaml. Story status: - Markdown wins when the story declared a status explicitly (frontmatter `status:` or a `Status:` line in the body), tracked via a new `statusExplicit` flag on `StoryDetail`. - Otherwise, sprint-status.yaml fills in as a fallback (back-compat with projects whose stories carry no in-file status). - Stub stories that exist only in sprint-status are unchanged. Epic status: - Computed from the epic's stories (done / in-progress / not-started). - Sprint-status epic-level entries are now used only when the epic has no stories at all — otherwise the computed status wins. The `epicId` merge no longer overwrites a story's existing epic id with the sprint-status entry's epicId; the markdown is authoritative there too. Tests cover the new explicit/non-explicit/stub matrix and the epic computed-vs-fallback paths. --- src/lib/bmad/__tests__/correlate.test.ts | 33 +++++++++++++++++--- src/lib/bmad/__tests__/parse-story.test.ts | 35 ++++++++++++++++++++++ src/lib/bmad/correlate.ts | 29 ++++++++++++------ src/lib/bmad/parse-story.ts | 7 +++-- src/lib/bmad/types.ts | 6 ++++ 5 files changed, 94 insertions(+), 16 deletions(-) diff --git a/src/lib/bmad/__tests__/correlate.test.ts b/src/lib/bmad/__tests__/correlate.test.ts index 7c01ca2..1228081 100644 --- a/src/lib/bmad/__tests__/correlate.test.ts +++ b/src/lib/bmad/__tests__/correlate.test.ts @@ -55,8 +55,23 @@ describe("correlate", () => { expect(result.stories[0].status).toBe("backlog"); }); - it("sprint status overrides story markdown status", () => { - const stories = [makeStory({ id: "1.1", status: "backlog" })]; + it("markdown status wins over sprint-status when statusExplicit is true", () => { + const stories = [ + makeStory({ id: "1.1", status: "in-progress", statusExplicit: true }), + ]; + const epics = [makeEpic()]; + const sprint: SprintStatus = { + stories: [{ id: "1.1", title: "1-1-test", status: "done", epicId: "1" }], + }; + + const result = correlate(sprint, epics, stories); + expect(result.stories[0].status).toBe("in-progress"); + }); + + it("sprint-status fills in when story has no explicit status (back-compat)", () => { + const stories = [ + makeStory({ id: "1.1", status: "backlog" /* statusExplicit undefined */ }), + ]; const epics = [makeEpic()]; const sprint: SprintStatus = { stories: [{ id: "1.1", title: "1-1-test", status: "done", epicId: "1" }], @@ -129,11 +144,21 @@ describe("correlate", () => { expect(result.stories[0].epicTitle).toBe("My Epic"); }); - it("uses epicStatuses from sprint-status.yaml when provided", () => { - const stories = [makeStory({ id: "1.1", status: "backlog" })]; + it("computed epic status wins over sprint-status epicStatuses when stories exist", () => { + // Stories say "in-progress" → sprint-status says "done" → computed wins. + const stories = [makeStory({ id: "1.1", status: "in-progress" })]; const epics = [makeEpic()]; const epicStatuses = [{ id: "1", status: "done" as const }]; + const result = correlate(null, epics, stories, epicStatuses); + expect(result.epics[0].status).toBe("in-progress"); + }); + + it("falls back to sprint-status epicStatuses when epic has no stories", () => { + const stories: StoryDetail[] = []; + const epics = [makeEpic({ stories: [] })]; + const epicStatuses = [{ id: "1", status: "done" as const }]; + const result = correlate(null, epics, stories, epicStatuses); expect(result.epics[0].status).toBe("done"); }); diff --git a/src/lib/bmad/__tests__/parse-story.test.ts b/src/lib/bmad/__tests__/parse-story.test.ts index 7908ccb..2ec04a4 100644 --- a/src/lib/bmad/__tests__/parse-story.test.ts +++ b/src/lib/bmad/__tests__/parse-story.test.ts @@ -132,6 +132,41 @@ status: in-progress }); }); + describe("statusExplicit flag", () => { + it("is true when status comes from frontmatter", () => { + const content = `--- +status: done +--- +# Story`; + const result = parseStory(content, "1-1-test.md"); + expect(result!.statusExplicit).toBe(true); + }); + + it("is true when status comes from a 'Status:' body line", () => { + const result = parseStory( + "# Story\n\nStatus: in-progress\n", + "1-1-test.md", + ); + expect(result!.statusExplicit).toBe(true); + }); + + it("is false when no status is declared anywhere", () => { + const result = parseStory("# Just a title\n\nSome description", "1-1-test.md"); + expect(result!.statusExplicit).toBe(false); + expect(result!.status).toBe("backlog"); // default fallback + }); + + it("is false when frontmatter exists but has no status field", () => { + const content = `--- +title: Foo +epic_id: 1 +--- +# Story`; + const result = parseStory(content, "1-1-test.md"); + expect(result!.statusExplicit).toBe(false); + }); + }); + describe("error handling", () => { it("returns null on truly invalid content", () => { // parseStory is quite resilient, but we can test the fallback behavior diff --git a/src/lib/bmad/correlate.ts b/src/lib/bmad/correlate.ts index c09e33c..c0befe2 100644 --- a/src/lib/bmad/correlate.ts +++ b/src/lib/bmad/correlate.ts @@ -27,16 +27,18 @@ export function correlate( storyMap.set(s.id, s); } - // Apply statuses from sprint-status.yaml to stories, and create stubs for - // stories that only exist in sprint-status (no markdown file). + // Story status priority: markdown frontmatter / "Status:" line wins. + // sprint-status.yaml is only used as a fallback when the story markdown + // declared no explicit status (statusExplicit !== true), and to populate + // stubs for stories that exist only in sprint-status. if (sprintStatus) { for (const entry of sprintStatus.stories) { const story = storyMap.get(entry.id); if (story) { - if (entry.status !== "unknown") { + if (story.statusExplicit !== true && entry.status !== "unknown") { story.status = entry.status; } - if (entry.epicId) { + if (!story.epicId && entry.epicId) { story.epicId = entry.epicId; } } else { @@ -73,14 +75,23 @@ export function correlate( const completed = epicStories.filter((s) => s.status === "done").length; const total = epicStories.length; - // Use status from sprint-status.yaml if available, otherwise compute - let status: EpicStatus = epicStatusMap.get(epic.id) || "not-started"; - if (!epicStatusMap.has(epic.id)) { - if (completed === total && total > 0) { + // Epic status priority: derive from the epic's stories first. + // sprint-status.yaml epic-level is only used as fallback when the epic + // has no stories at all (so we have no signal otherwise). + let status: EpicStatus; + if (total > 0) { + if (completed === total) { status = "done"; - } else if (completed > 0 || epicStories.some((s) => s.status === "in-progress")) { + } else if ( + completed > 0 || + epicStories.some((s) => s.status === "in-progress") + ) { status = "in-progress"; + } else { + status = "not-started"; } + } else { + status = epicStatusMap.get(epic.id) || "not-started"; } return { diff --git a/src/lib/bmad/parse-story.ts b/src/lib/bmad/parse-story.ts index ac3b1da..612e7a7 100644 --- a/src/lib/bmad/parse-story.ts +++ b/src/lib/bmad/parse-story.ts @@ -56,9 +56,9 @@ export function parseStory( // Extract status from "Status: done" line (plain text, not frontmatter) const statusLineMatch = body.match(/^Status:\s*(.+)/im); - const status = normalizeStoryStatus( - frontmatterStatus || statusLineMatch?.[1]?.trim() - ); + const rawStatus = frontmatterStatus || statusLineMatch?.[1]?.trim(); + const statusExplicit = Boolean(rawStatus); + const status = normalizeStoryStatus(rawStatus); // Extract acceptance criteria const acceptanceCriteria: string[] = []; @@ -89,6 +89,7 @@ export function parseStory( id, title: String(title), status, + statusExplicit, epicId, description: body.trim().slice(0, 1000), acceptanceCriteria, diff --git a/src/lib/bmad/types.ts b/src/lib/bmad/types.ts index 5f3e156..0288ac0 100644 --- a/src/lib/bmad/types.ts +++ b/src/lib/bmad/types.ts @@ -39,6 +39,12 @@ export interface StoryDetail { id: string; title: string; status: StoryStatus; + /** + * True when the story markdown declared a status explicitly (frontmatter + * `status:` or a `Status:` line in the body). When false, `status` came + * from the default fallback and sprint-status.yaml may override it. + */ + statusExplicit?: boolean; epicId: string; epicTitle?: string; description: string; From be3c85a37253c84ea25c003b0a13aa89dc61a365 Mon Sep 17 00:00:00 2001 From: Hichem Date: Fri, 8 May 2026 16:04:20 +0200 Subject: [PATCH 5/7] fix(bmad): address PR #7 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the cloud review: 1. Library actions now use the resolved output dir. `fetchBmadFilesLocal`, `fetchBmadFilesGitHub`, and `fetchFileContent` read `_bmad/core/config.yaml` and filter / build the artifact tree against the configured output folder. Local file reads also extend the provider whitelist before opening a file in a custom output dir, so the security guard does not deny legitimate reads. 2. Nested output paths (e.g. `output_folder: "custom/out"`) work on local. The provider whitelist accepts only single-segment names, so we now pass the top-level segment (`custom`) to `extendBmadDirs` while the parser still filters paths by the full prefix. The walker descends recursively, so nested files become visible. 3. Epic-folder stories follow the canonical id from `epic.md`. When a folder named `epic-2-foo/` contains an `epic.md` whose frontmatter declares `id: 10`, stories inside that folder are now re-tagged with epicId `10` (instead of the folder-derived `2`), so correlation links them to the right epic. Refactored: shared `resolveBmadOutputDir(provider, paths)` helper in `parse-config.ts` consolidates the read-config-then-extend-whitelist dance used by the parser and by the Library actions. Tests: 2 new integration cases — nested `custom/out/` output dir and epic.md frontmatter id reconciliation. Total: 145/145 passing. --- src/actions/repo-actions.ts | 59 ++++++++++++--- .../bmad/__tests__/parser.epic-folder.test.ts | 73 +++++++++++++++++++ src/lib/bmad/parse-config.ts | 28 +++++++ src/lib/bmad/parser.ts | 54 ++++++++++---- 4 files changed, 187 insertions(+), 27 deletions(-) diff --git a/src/actions/repo-actions.ts b/src/actions/repo-actions.ts index a1fb693..3f5ff57 100644 --- a/src/actions/repo-actions.ts +++ b/src/actions/repo-actions.ts @@ -11,7 +11,11 @@ import { import { LocalProvider } from "@/lib/content-provider/local-provider"; import { buildFileTree } from "@/lib/bmad/utils"; import { parseBmadFile } from "@/lib/bmad/parser"; -import { getBmadConfig, DEFAULT_OUTPUT_DIR } from "@/lib/bmad/parse-config"; +import { + getBmadConfig, + resolveBmadOutputDir, + DEFAULT_OUTPUT_DIR, +} from "@/lib/bmad/parse-config"; import { prisma } from "@/lib/db/client"; import { getAuthenticatedSession } from "@/lib/db/helpers"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; @@ -526,17 +530,20 @@ export async function fetchBmadFiles(input: { async function fetchBmadFilesLocal(localPath: string) { const provider = new LocalProvider(localPath); await provider.validateRoot(); - const providerTree = await provider.getTree(); - const allPaths = providerTree.paths; + const initialTree = await provider.getTree(); + const { outputDir, paths: allPaths } = await resolveBmadOutputDir( + provider, + initialTree.paths, + ); - const bmadPaths = allPaths.filter((p) => p.startsWith(BMAD_OUTPUT + "/")); - const fileTree = buildFileTree(bmadPaths, BMAD_OUTPUT); + const bmadPaths = allPaths.filter((p) => p.startsWith(outputDir + "/")); + const fileTree = buildFileTree(bmadPaths, outputDir); const bmadCorePaths = allPaths.filter((p) => p.startsWith(BMAD_CORE + "/")); const bmadCoreTree = buildFileTree(bmadCorePaths, BMAD_CORE); // F20/F35: Detect docs/ via rootDirectories - const docsFolderName = providerTree.rootDirectories.find( + const docsFolderName = initialTree.rootDirectories.find( (d) => d.toLowerCase() === "docs" ) ?? null; const docsTree = docsFolderName @@ -568,12 +575,30 @@ async function fetchBmadFilesGitHub( branch, ); - const allPaths = tree.tree - .filter((item) => item.type === "blob") - .map((item) => item.path); + const allPaths: string[] = tree.tree + .filter((item) => item.type === "blob" && typeof item.path === "string") + .map((item) => item.path as string); - const bmadPaths = allPaths.filter((p) => p.startsWith(BMAD_OUTPUT + "/")); - const fileTree = buildFileTree(bmadPaths, BMAD_OUTPUT); + const ghProviderShim = { + async getTree() { + return { paths: allPaths, rootDirectories: [] }; + }, + async getFileContent(p: string) { + return getCachedUserRawContent( + octokit, + userId, + input.owner, + input.name, + branch, + p, + ); + }, + async validateRoot() {}, + }; + const { outputDir } = await getBmadConfig(ghProviderShim, allPaths); + + const bmadPaths = allPaths.filter((p) => p.startsWith(outputDir + "/")); + const fileTree = buildFileTree(bmadPaths, outputDir); const bmadCorePaths = allPaths.filter((p) => p.startsWith(BMAD_CORE + "/")); const bmadCoreTree = buildFileTree(bmadCorePaths, BMAD_CORE); @@ -657,6 +682,18 @@ export async function fetchFileContent(input: { return { success: false, error: sanitizeError(null, "FS_ERROR"), code: "FS_ERROR" }; } const provider = new LocalProvider(repoConfig.localPath); + // Extend whitelist when the requested file lives under a configured + // custom output dir (or its top segment) — otherwise the read is denied. + const tree = await provider.getTree(); + const { outputDir } = await getBmadConfig(provider, tree.paths); + if (outputDir !== DEFAULT_OUTPUT_DIR) { + const topSegment = outputDir.split("/")[0]; + try { + provider.extendBmadDirs(topSegment); + } catch { + // Validation failed — fall through; getFileContent will deny if needed. + } + } content = await provider.getFileContent(parsed.data.path); } else { const token = await getGitHubToken(userId); diff --git a/src/lib/bmad/__tests__/parser.epic-folder.test.ts b/src/lib/bmad/__tests__/parser.epic-folder.test.ts index c258819..0ae2106 100644 --- a/src/lib/bmad/__tests__/parser.epic-folder.test.ts +++ b/src/lib/bmad/__tests__/parser.epic-folder.test.ts @@ -27,6 +27,50 @@ const REPO: RepoConfig = { lastSyncedAt: null, }; +describe("getBmadProject — nested output_folder", () => { + it("scans a nested custom output dir like 'custom/out' when provider supports extension", async () => { + let extendedTo: string | undefined; + const allFiles = { + "_bmad/core/config.yaml": `output_folder: "{project-root}/custom/out"`, + "custom/out/planning-artifacts/epics.md": [ + "## Epic 1: Custom", + "- Story 1.1 - Foo", + ].join("\n"), + "custom/out/implementation-artifacts/1-1-foo.md": [ + "# Foo", + "Status: done", + ].join("\n"), + }; + let bmadDirs = new Set(["_bmad", "_bmad-output"]); + const provider: ContentProvider = { + async getTree() { + const visible = Object.keys(allFiles).filter((p) => { + const seg = p.split("/")[0]; + return bmadDirs.has(seg); + }); + return { paths: visible, rootDirectories: ["_bmad", "custom"] }; + }, + async getFileContent(p: string) { + if (!(p in allFiles)) throw new Error(`Not found: ${p}`); + return allFiles[p as keyof typeof allFiles]; + }, + async validateRoot() {}, + extendBmadDirs(name: string) { + extendedTo = name; + bmadDirs = new Set([...bmadDirs, name]); + }, + }; + + const project = await getBmadProject(REPO, provider); + // The whitelist should have been extended to the top-level segment. + expect(extendedTo).toBe("custom"); + expect(project!.epics).toHaveLength(1); + expect(project!.epics[0].title).toBe("Custom"); + expect(project!.stories).toHaveLength(1); + expect(project!.stories[0].id).toBe("1.1"); + }); +}); + describe("getBmadProject — epic-folder layout", () => { it("derives an epic from folder name when no epic.md is present", async () => { const provider = makeProvider({ @@ -115,6 +159,35 @@ describe("getBmadProject — epic-folder layout", () => { expect(project!.stories.map((s) => s.id).sort()).toEqual(["1.1", "2.1"]); }); + it("reconciles story epicId when epic.md declares an id different from the folder name", async () => { + const provider = makeProvider({ + // Folder name implies id "2", but epic.md frontmatter says id "10" + "_bmad-output/planning-artifacts/epics/epic-2-renamed/epic.md": [ + "---", + "id: 10", + "title: Renamed Epic", + "---", + "", + "Body.", + ].join("\n"), + // Story filename only carries "1" — should resolve to "10.1", not "2.1" + "_bmad-output/planning-artifacts/epics/epic-2-renamed/story-1.md": [ + "# Bare story", + "Status: done", + ].join("\n"), + }); + + const project = await getBmadProject(REPO, provider); + expect(project!.epics).toHaveLength(1); + expect(project!.epics[0].id).toBe("10"); + expect(project!.stories).toHaveLength(1); + expect(project!.stories[0].id).toBe("10.1"); + expect(project!.stories[0].epicId).toBe("10"); + // Correlation must hold: epic shows 1/1 done. + expect(project!.epics[0].completedStories).toBe(1); + expect(project!.epics[0].totalStories).toBe(1); + }); + it("ignores epic-folder when a single epics.md is present (single-file wins)", async () => { const provider = makeProvider({ "_bmad-output/planning-artifacts/epics.md": [ diff --git a/src/lib/bmad/parse-config.ts b/src/lib/bmad/parse-config.ts index 1750620..e1da6f4 100644 --- a/src/lib/bmad/parse-config.ts +++ b/src/lib/bmad/parse-config.ts @@ -54,3 +54,31 @@ export async function getBmadConfig( return { outputDir: DEFAULT_OUTPUT_DIR }; } + +/** + * Resolve the BMAD output directory and ensure the provider can scan it. + * For local providers, extends the whitelist to the top-level segment of + * the configured output dir. Returns the (possibly re-fetched) tree paths + * and the resolved `outputDir`. + */ +export async function resolveBmadOutputDir( + provider: ContentProvider, + initialPaths: string[], +): Promise<{ outputDir: string; paths: string[] }> { + const { outputDir } = await getBmadConfig(provider, initialPaths); + if (outputDir === DEFAULT_OUTPUT_DIR || !provider.extendBmadDirs) { + return { outputDir, paths: initialPaths }; + } + const topSegment = outputDir.split("/")[0]; + try { + provider.extendBmadDirs(topSegment); + const refreshed = await provider.getTree(); + return { outputDir, paths: refreshed.paths }; + } catch (e) { + console.warn( + `[BMAD Config] Cannot extend whitelist to "${topSegment}":`, + e, + ); + return { outputDir, paths: initialPaths }; + } +} diff --git a/src/lib/bmad/parser.ts b/src/lib/bmad/parser.ts index b12700f..f7268f8 100644 --- a/src/lib/bmad/parser.ts +++ b/src/lib/bmad/parser.ts @@ -6,7 +6,7 @@ import { parseEpicFile } from "./parse-epic-file"; import { parseStory } from "./parse-story"; import { correlate, computeProjectStats } from "./correlate"; import { buildFileTree } from "./utils"; -import { getBmadConfig, DEFAULT_OUTPUT_DIR } from "./parse-config"; +import { resolveBmadOutputDir } from "./parse-config"; import { parseEpicFolderName } from "./parse-epic-folder"; import type { RepoConfig } from "@/lib/types"; import type { ParsedBmadFile, BmadFileMetadata } from "./types"; @@ -27,22 +27,13 @@ export async function getBmadProject( ): Promise { const { owner, name: repo, branch, displayName } = config; - let providerTree = await provider.getTree(); - const { outputDir } = await getBmadConfig(provider, providerTree.paths); - - if (outputDir !== DEFAULT_OUTPUT_DIR && provider.extendBmadDirs) { - try { - provider.extendBmadDirs(outputDir); - providerTree = await provider.getTree(); - } catch (e) { - console.warn( - `[BMAD Parse] Cannot extend whitelist to "${outputDir}":`, - e, - ); - } - } + const initialTree = await provider.getTree(); + const { outputDir, paths: allPaths } = await resolveBmadOutputDir( + provider, + initialTree.paths, + ); + const providerTree = { ...initialTree, paths: allPaths }; - const allPaths = providerTree.paths; const bmadPaths = allPaths.filter((p) => p.startsWith(outputDir + "/")); const sprintStatusPath = bmadPaths.find( @@ -187,6 +178,15 @@ export async function getBmadProject( let rawEpics: import("./types").Epic[] = []; const rawStories: NonNullable>[] = []; + // Track the parsed epic that came from each folder's epic.md so we can + // reconcile story epic-ids in case the frontmatter id differs from the + // folder-derived id. + const epicByMetaPath = new Map(); + const storyByPath = new Map< + string, + NonNullable> + >(); + // Synthesize epics for folders that have no epic.md inside. for (const e of derivedFolderEpics) { rawEpics.push({ @@ -225,6 +225,7 @@ export async function getBmadProject( const epic = parseEpicFile(content, filename); if (epic) { rawEpics.push(epic); + epicByMetaPath.set(filePath, epic); } else { parseErrors.push({ file: filePath, error: "Failed to parse individual epic file. Check format (frontmatter or heading).", contentType: "epic" }); } @@ -243,12 +244,33 @@ export async function getBmadProject( } } rawStories.push(story); + storyByPath.set(storyPath, story); } else { parseErrors.push({ file: storyPath, error: "Failed to parse story. Check the markdown format and section structure.", contentType: "story" }); } } } + // Reconcile epic id when epic.md frontmatter declared a different id than + // the folder name implied. Stories were tagged with the folder-derived id; + // rewrite them to the canonical id from the parsed epic. + for (const ef of epicFolders) { + if (!ef.metaPath) continue; + const parsedEpic = epicByMetaPath.get(ef.metaPath); + if (!parsedEpic || parsedEpic.id === ef.id) continue; + + const oldPrefix = ef.id + "."; + const newPrefix = parsedEpic.id + "."; + for (const sp of ef.storyPaths) { + const story = storyByPath.get(sp); + if (!story) continue; + story.epicId = parsedEpic.id; + if (story.id.startsWith(oldPrefix)) { + story.id = newPrefix + story.id.slice(oldPrefix.length); + } + } + } + const successfulFiles = totalFiles - parseErrors.length; if (parseErrors.length > 0) { From 4be375d19b2c413ded0a3ccf22080272b9a82afe Mon Sep 17 00:00:00 2001 From: Hichem Date: Fri, 8 May 2026 20:52:44 +0200 Subject: [PATCH 6/7] fix(bmad): cover remaining repo-actions sites with dynamic output dir Three sites missed in the previous review-fix pass: - `refreshLocalRepo` was passing the full `outputDir` (which can be a nested path like `custom/out`) to `LocalProvider.extendBmadDirs`, which only accepts single-segment names. The whitelist extension silently failed for nested output folders, so the file count fell to 0. Now uses the shared `resolveBmadOutputDir` helper. - `importLocalFolder` was counting BMAD files against a hardcoded `_bmad-output/` prefix at import time, so a local repo with a custom `output_folder` was created with `totalFiles: 0`. Same helper. - The `BMAD_OUTPUT` constant became unused after threading the dynamic resolution through all sites. Removed. --- src/actions/repo-actions.ts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/actions/repo-actions.ts b/src/actions/repo-actions.ts index 3f5ff57..37ba767 100644 --- a/src/actions/repo-actions.ts +++ b/src/actions/repo-actions.ts @@ -31,7 +31,6 @@ import { checkRateLimit } from "@/lib/rate-limit"; // GraphQL can handle ~30 repos per query safely (GitHub complexity limits) const GRAPHQL_BATCH_SIZE = 30; -const BMAD_OUTPUT = "_bmad-output"; const BMAD_CORE = "_bmad"; // --------------------------------------------------------------------------- @@ -368,17 +367,12 @@ async function refreshLocalRepo( const provider = new LocalProvider(repoConfig.localPath); await provider.validateRoot(); - let tree = await provider.getTree(); - const { outputDir } = await getBmadConfig(provider, tree.paths); - if (outputDir !== DEFAULT_OUTPUT_DIR) { - try { - provider.extendBmadDirs(outputDir); - tree = await provider.getTree(); - } catch (e) { - console.warn(`[Refresh] Cannot extend whitelist to "${outputDir}":`, e); - } - } - const totalFiles = tree.paths.filter((p) => p.startsWith(outputDir + "/")).length; + const initialTree = await provider.getTree(); + const { outputDir, paths } = await resolveBmadOutputDir( + provider, + initialTree.paths, + ); + const totalFiles = paths.filter((p) => p.startsWith(outputDir + "/")).length; const now = new Date(); await prisma.repo.update({ @@ -836,8 +830,12 @@ export async function importLocalFolder(input: { // F11: displayName fallback to raw basename const displayName = parsed.data.displayName ?? rawBasename; - const bmadOutputCount = providerTree.paths.filter( - (p) => p.startsWith("_bmad-output/") + const { outputDir, paths: scannedPaths } = await resolveBmadOutputDir( + provider, + providerTree.paths, + ); + const bmadOutputCount = scannedPaths.filter((p) => + p.startsWith(outputDir + "/"), ).length; const repo = await prisma.repo.create({ From 20a86b0443e80afa59b2703efffe56038fc5fbe2 Mon Sep 17 00:00:00 2001 From: Hichem Date: Fri, 8 May 2026 21:01:21 +0200 Subject: [PATCH 7/7] fix(security): reject sibling reads when output_folder is nested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `output_folder: custom/out` is configured, the LocalProvider's whitelist is extended at the top-level segment (`custom`) because `extendBmadDirs` only accepts single-segment names. Without an additional check, this lets `fetchFileContent` read any file under `custom/` — e.g. `custom/secret.txt` — even though only `custom/out/...` should be reachable. `isPathOutsideNestedOutput(requestedPath, outputDir)` returns true when the path lives under the top segment of a nested output dir but outside the configured prefix; the local branch of `fetchFileContent` denies with `ACCESS_DENIED` in that case. The check is a no-op for the default `_bmad-output` and for any single-segment custom dir (no nesting to escape from). 6 unit tests cover the helper: single-segment passthrough, allowed reads under the prefix, sibling denial, unrelated top segments, the bare top segment, and prefix-vs-substring boundary cases. --- src/actions/repo-actions.ts | 22 +++++++++++-- src/lib/bmad/__tests__/parse-config.test.ts | 35 +++++++++++++++++++++ src/lib/bmad/parse-config.ts | 22 +++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/actions/repo-actions.ts b/src/actions/repo-actions.ts index 37ba767..ffa534e 100644 --- a/src/actions/repo-actions.ts +++ b/src/actions/repo-actions.ts @@ -14,6 +14,7 @@ import { parseBmadFile } from "@/lib/bmad/parser"; import { getBmadConfig, resolveBmadOutputDir, + isPathOutsideNestedOutput, DEFAULT_OUTPUT_DIR, } from "@/lib/bmad/parse-config"; import { prisma } from "@/lib/db/client"; @@ -676,10 +677,25 @@ export async function fetchFileContent(input: { return { success: false, error: sanitizeError(null, "FS_ERROR"), code: "FS_ERROR" }; } const provider = new LocalProvider(repoConfig.localPath); - // Extend whitelist when the requested file lives under a configured - // custom output dir (or its top segment) — otherwise the read is denied. + // The default LocalProvider whitelist covers `_bmad` and `_bmad-output`. + // When the project declares a custom (possibly nested) `output_folder`, + // we extend the whitelist to its top-level segment so the provider can + // read inside it — but the provider only validates by single segment. + // Re-check the requested path here so a nested config like + // `output_folder: custom/out` cannot be used to read `custom/secret.txt`. const tree = await provider.getTree(); const { outputDir } = await getBmadConfig(provider, tree.paths); + const requestedPath = parsed.data.path; + if ( + outputDir !== DEFAULT_OUTPUT_DIR && + isPathOutsideNestedOutput(requestedPath, outputDir) + ) { + return { + success: false, + error: sanitizeError(null, "ACCESS_DENIED"), + code: "ACCESS_DENIED", + }; + } if (outputDir !== DEFAULT_OUTPUT_DIR) { const topSegment = outputDir.split("/")[0]; try { @@ -688,7 +704,7 @@ export async function fetchFileContent(input: { // Validation failed — fall through; getFileContent will deny if needed. } } - content = await provider.getFileContent(parsed.data.path); + content = await provider.getFileContent(requestedPath); } else { const token = await getGitHubToken(userId); if (!token) { diff --git a/src/lib/bmad/__tests__/parse-config.test.ts b/src/lib/bmad/__tests__/parse-config.test.ts index db75bc9..f201322 100644 --- a/src/lib/bmad/__tests__/parse-config.test.ts +++ b/src/lib/bmad/__tests__/parse-config.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import { parseConfigContent, getBmadConfig, + isPathOutsideNestedOutput, DEFAULT_OUTPUT_DIR, CORE_CONFIG_PATH, } from "../parse-config"; @@ -124,3 +125,37 @@ describe("getBmadConfig", () => { consoleSpy.mockRestore(); }); }); + +describe("isPathOutsideNestedOutput", () => { + it("returns false for a single-segment outputDir (no nesting concern)", () => { + expect(isPathOutsideNestedOutput("_bmad-output/file.md", "_bmad-output")).toBe(false); + // Even a path with the same top but technically outside has nothing + // nested to escape from — the provider's own whitelist is the boundary. + expect(isPathOutsideNestedOutput("foo/x.md", "_bmad-output")).toBe(false); + }); + + it("allows reads under the configured nested outputDir", () => { + expect(isPathOutsideNestedOutput("custom/out/foo.md", "custom/out")).toBe(false); + expect(isPathOutsideNestedOutput("custom/out/sub/bar.md", "custom/out")).toBe(false); + }); + + it("denies sibling reads under the same top segment", () => { + expect(isPathOutsideNestedOutput("custom/secret.txt", "custom/out")).toBe(true); + expect(isPathOutsideNestedOutput("custom/other/foo.md", "custom/out")).toBe(true); + }); + + it("does not flag paths under unrelated top segments", () => { + expect(isPathOutsideNestedOutput("_bmad/core/config.yaml", "custom/out")).toBe(false); + expect(isPathOutsideNestedOutput("docs/readme.md", "custom/out")).toBe(false); + }); + + it("denies the bare top segment itself when nested output is configured", () => { + // "custom" alone is not under "custom/out/" → must be denied + expect(isPathOutsideNestedOutput("custom", "custom/out")).toBe(true); + }); + + it("treats outputDir with the prefix exactly as outputDir's name (not a substring)", () => { + // "custom/output-something" should NOT be considered under "custom/out" + expect(isPathOutsideNestedOutput("custom/output-something/file.md", "custom/out")).toBe(true); + }); +}); diff --git a/src/lib/bmad/parse-config.ts b/src/lib/bmad/parse-config.ts index e1da6f4..268ab42 100644 --- a/src/lib/bmad/parse-config.ts +++ b/src/lib/bmad/parse-config.ts @@ -55,6 +55,28 @@ export async function getBmadConfig( return { outputDir: DEFAULT_OUTPUT_DIR }; } +/** + * When `outputDir` is nested (e.g. "custom/out"), the LocalProvider whitelist + * is extended at the top-level segment ("custom") only — the walker can't + * filter on a multi-segment prefix. This means a manual file read could + * reach sibling files under the top segment (e.g. "custom/secret.txt") + * even though only "custom/out/..." should be accessible. + * + * Returns true when `requestedPath` falls inside the top segment but + * outside the configured outputDir prefix — caller must deny in that case. + */ +export function isPathOutsideNestedOutput( + requestedPath: string, + outputDir: string, +): boolean { + const topSegment = outputDir.split("/")[0]; + const isNested = topSegment !== outputDir; + if (!isNested) return false; + const requestedTop = requestedPath.split("/")[0]; + if (requestedTop !== topSegment) return false; + return !requestedPath.startsWith(outputDir + "/"); +} + /** * Resolve the BMAD output directory and ensure the provider can scan it. * For local providers, extends the whitelist to the top-level segment of