Skip to content

Commit c2f12ac

Browse files
clee231LarsCowe
andauthored
feat(implement): Updated Planning artifact detection to use _bmad/config.yaml first (#138)
* feat(implement): Updated Planning artifact detection to use _bmad/config.yaml configuration. If not found, continue with normal artifactDir search flow. * feat(implement): Generalizing BMAD config.yaml loading and defining constant variables * docs(ralph): add driver interface contract for ralph_loop.sh (#139) Document the sourceable driver contract: required hooks, optional capability hooks, metadata hooks, global variables, capability matrix, session ID recovery chain, and a minimal skeleton for new drivers. Closes #135 * fix(run): trust RALPH_STATUS block over heuristic analysis (#140) When a valid RALPH_STATUS block is parsed from text output, skip all heuristic analysis (completion keywords, test-only detection, stuck detection, no-work patterns, output length trends). The status block is authoritative — heuristics only run as fallback when no block is present. This brings the text path to parity with the JSON path which already returns early on structured data. Also fix a pre-existing division-by-zero bug in output length trend analysis when .last_output_length contains 0. Fixes: #123 * fix(implement): Addressing code concerns. Added test coverage, validating BMAD configuration, preventing path traversal risk, validating directory existence. * fix(installer): Updating bmad-assets.ts to use new constant. * fix(utils): Update error handling for isDirectory() to match exists() * fix(artifacts): Fixing blocking finding with path validation * fix(utils): Adding unit tests for isDirectory() * fix(utils): Updating validation for modules in BmadConfig * fix(artifacts): Handling if planning_artifacts is whitespace edge case * fix(tests): Adding additional test coverages for artifact and config validation functions * fix(artifacts): Fixing Windows path test failure --------- Co-authored-by: Lars <120098061+LarsCowe@users.noreply.github.com>
1 parent 7bdebf8 commit c2f12ac

12 files changed

Lines changed: 613 additions & 16 deletions

File tree

package-lock.json

Lines changed: 17 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@
6666
"@inquirer/input": "^4.1.9",
6767
"@inquirer/select": "^4.2.0",
6868
"chalk": "^5.6.2",
69-
"commander": "^14.0.2"
69+
"commander": "^14.0.2",
70+
"yaml": "^2.8.3"
7071
},
7172
"devDependencies": {
7273
"@eslint/js": "^10.0.1",

src/installer/bmad-assets.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { basename, join } from "node:path";
33
import { debug, warn } from "../utils/logger.js";
44
import { atomicWriteFile, exists } from "../utils/file-system.js";
55
import { formatError, isEnoent } from "../utils/errors.js";
6-
import { CONFIG_FILE } from "../utils/constants.js";
6+
import { CONFIG_FILE, BMAD_CONFIG_FILE } from "../utils/constants.js";
77
import type { Platform } from "../platform/types.js";
88
import { classifyCommands, generateCommandIndex } from "./commands.js";
99
import type { ClassifiedCommand } from "./types.js";
@@ -167,7 +167,7 @@ async function finalizeBmadInstall(
167167
const projectName = await deriveProjectName(projectDir);
168168
const escapedName = projectName.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
169169
await atomicWriteFile(
170-
join(projectDir, "_bmad/config.yaml"),
170+
join(projectDir, BMAD_CONFIG_FILE),
171171
`# BMAD Configuration - Generated by bmalph
172172
platform: ${platform.id}
173173
project_name: "${escapedName}"

src/transition/artifacts.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,24 @@
1-
import { join, relative } from "node:path";
2-
import { debug } from "../utils/logger.js";
3-
import { exists } from "../utils/file-system.js";
1+
import { join, relative, resolve, sep } from "node:path";
2+
import { debug, warn } from "../utils/logger.js";
3+
import { isDirectory } from "../utils/file-system.js";
4+
import { readBmadConfig } from "../utils/config.js";
45

56
export async function findArtifactsDir(projectDir: string): Promise<string | null> {
7+
const bmadConfig = await readBmadConfig(projectDir);
8+
const trimmed = bmadConfig?.planning_artifacts?.trim();
9+
if (trimmed) {
10+
const resolved = resolve(projectDir, trimmed);
11+
debug(`Checking config-specified artifacts dir: ${resolved}`);
12+
13+
const projectRoot = resolve(projectDir);
14+
if (!resolved.startsWith(projectRoot + sep) && resolved !== projectRoot) {
15+
warn(`planning_artifacts path escapes project directory, ignoring: ${trimmed}`);
16+
} else if (await isDirectory(resolved)) {
17+
debug(`Found artifacts at: ${resolved}`);
18+
return resolved;
19+
}
20+
}
21+
622
const candidates = [
723
"_bmad-output/planning-artifacts",
824
"_bmad-output/planning_artifacts",
@@ -12,7 +28,7 @@ export async function findArtifactsDir(projectDir: string): Promise<string | nul
1228
for (const candidate of candidates) {
1329
const fullPath = join(projectDir, candidate);
1430
debug(`Checking artifacts dir: ${fullPath}`);
15-
if (await exists(fullPath)) {
31+
if (await isDirectory(fullPath)) {
1632
debug(`Found artifacts at: ${fullPath}`);
1733
return fullPath;
1834
}

src/utils/config.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,32 @@
1-
import { mkdir } from "node:fs/promises";
1+
import { mkdir, readFile } from "node:fs/promises";
22
import { join } from "node:path";
3+
import { parse } from "yaml";
34
import { readJsonFile } from "./json.js";
4-
import { validateConfig } from "./validate.js";
5-
import { CONFIG_FILE } from "./constants.js";
5+
import { validateConfig, validateBmadConfig } from "./validate.js";
6+
import { CONFIG_FILE, BMAD_CONFIG_FILE } from "./constants.js";
67
import { atomicWriteFile } from "./file-system.js";
7-
import { warn } from "./logger.js";
8-
import { formatError } from "./errors.js";
8+
import { warn, debug } from "./logger.js";
9+
import { formatError, isEnoent } from "./errors.js";
910
import type { PlatformId } from "../platform/types.js";
1011

1112
export interface UpstreamVersions {
1213
bmadCommit: string;
1314
}
1415

16+
export interface BmadConfig {
17+
platform?: string;
18+
project_name?: string;
19+
output_folder?: string;
20+
user_name?: string;
21+
communication_language?: string;
22+
document_output_language?: string;
23+
user_skill_level?: string;
24+
planning_artifacts?: string;
25+
implementation_artifacts?: string;
26+
project_knowledge?: string;
27+
modules?: string[];
28+
}
29+
1530
export interface BmalphConfig {
1631
name: string;
1732
description: string;
@@ -41,3 +56,21 @@ export async function writeConfig(projectDir: string, config: BmalphConfig): Pro
4156
await mkdir(join(projectDir, "bmalph"), { recursive: true });
4257
await atomicWriteFile(join(projectDir, CONFIG_FILE), JSON.stringify(config, null, 2) + "\n");
4358
}
59+
60+
export async function readBmadConfig(projectDir: string): Promise<BmadConfig | null> {
61+
const configPath = join(projectDir, BMAD_CONFIG_FILE);
62+
try {
63+
const content = await readFile(configPath, "utf-8");
64+
const parsed: unknown = parse(content);
65+
const config = validateBmadConfig(parsed);
66+
debug(`Read BMAD config from: ${configPath}`);
67+
return config;
68+
} catch (err) {
69+
if (isEnoent(err)) {
70+
debug(`BMAD config not found at: ${configPath}`);
71+
return null;
72+
}
73+
warn(`Error reading BMAD config: ${formatError(err)}`);
74+
return null;
75+
}
76+
}

src/utils/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ export const STATE_DIR = "bmalph/state";
7171
/** bmalph config file path */
7272
export const CONFIG_FILE = "bmalph/config.json";
7373

74+
/** BMAD config file path */
75+
export const BMAD_CONFIG_FILE = "_bmad/config.yaml";
76+
7477
/** Ralph status file path */
7578
export const RALPH_STATUS_FILE = ".ralph/status.json";
7679

src/utils/file-system.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ export async function exists(path: string): Promise<boolean> {
1616
}
1717
}
1818

19+
/**
20+
* Checks whether a directory exists at the given path.
21+
* Returns false if the path doesn't exist or is a file.
22+
*/
23+
export async function isDirectory(path: string): Promise<boolean> {
24+
try {
25+
const stats = await stat(path);
26+
return stats.isDirectory();
27+
} catch (err) {
28+
if (isEnoent(err)) return false;
29+
throw err;
30+
}
31+
}
32+
1933
/**
2034
* Writes content to a file atomically using a temp file + rename.
2135
* Prevents partial writes from corrupting the target file.

src/utils/validate.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { BmalphConfig, UpstreamVersions } from "./config.js";
1+
import type { BmalphConfig, BmadConfig, UpstreamVersions } from "./config.js";
22
import type { BmalphState } from "./state.js";
33
import { PLATFORM_IDS, type PlatformId } from "../platform/types.js";
44
import {
@@ -95,6 +95,115 @@ export function validateConfig(data: unknown): BmalphConfig {
9595
};
9696
}
9797

98+
export function validateBmadConfig(data: unknown): BmadConfig {
99+
assertObject(data, "bmadConfig");
100+
101+
let platform: string | undefined;
102+
if (data.platform !== undefined) {
103+
if (typeof data.platform !== "string") {
104+
throw new Error("bmadConfig.platform must be a string");
105+
}
106+
platform = data.platform;
107+
}
108+
109+
let project_name: string | undefined;
110+
if (data.project_name !== undefined) {
111+
if (typeof data.project_name !== "string") {
112+
throw new Error("bmadConfig.project_name must be a string");
113+
}
114+
project_name = data.project_name;
115+
}
116+
117+
let output_folder: string | undefined;
118+
if (data.output_folder !== undefined) {
119+
if (typeof data.output_folder !== "string") {
120+
throw new Error("bmadConfig.output_folder must be a string");
121+
}
122+
output_folder = data.output_folder;
123+
}
124+
125+
let user_name: string | undefined;
126+
if (data.user_name !== undefined) {
127+
if (typeof data.user_name !== "string") {
128+
throw new Error("bmadConfig.user_name must be a string");
129+
}
130+
user_name = data.user_name;
131+
}
132+
133+
let communication_language: string | undefined;
134+
if (data.communication_language !== undefined) {
135+
if (typeof data.communication_language !== "string") {
136+
throw new Error("bmadConfig.communication_language must be a string");
137+
}
138+
communication_language = data.communication_language;
139+
}
140+
141+
let document_output_language: string | undefined;
142+
if (data.document_output_language !== undefined) {
143+
if (typeof data.document_output_language !== "string") {
144+
throw new Error("bmadConfig.document_output_language must be a string");
145+
}
146+
document_output_language = data.document_output_language;
147+
}
148+
149+
let user_skill_level: string | undefined;
150+
if (data.user_skill_level !== undefined) {
151+
if (typeof data.user_skill_level !== "string") {
152+
throw new Error("bmadConfig.user_skill_level must be a string");
153+
}
154+
user_skill_level = data.user_skill_level;
155+
}
156+
157+
let planning_artifacts: string | undefined;
158+
if (data.planning_artifacts !== undefined) {
159+
if (typeof data.planning_artifacts !== "string") {
160+
throw new Error("bmadConfig.planning_artifacts must be a string");
161+
}
162+
planning_artifacts = data.planning_artifacts;
163+
}
164+
165+
let implementation_artifacts: string | undefined;
166+
if (data.implementation_artifacts !== undefined) {
167+
if (typeof data.implementation_artifacts !== "string") {
168+
throw new Error("bmadConfig.implementation_artifacts must be a string");
169+
}
170+
implementation_artifacts = data.implementation_artifacts;
171+
}
172+
173+
let project_knowledge: string | undefined;
174+
if (data.project_knowledge !== undefined) {
175+
if (typeof data.project_knowledge !== "string") {
176+
throw new Error("bmadConfig.project_knowledge must be a string");
177+
}
178+
project_knowledge = data.project_knowledge;
179+
}
180+
181+
let modules: string[] | undefined;
182+
if (data.modules !== undefined) {
183+
if (
184+
!Array.isArray(data.modules) ||
185+
!data.modules.every((m: unknown) => typeof m === "string")
186+
) {
187+
throw new Error("bmadConfig.modules must be an array of strings");
188+
}
189+
modules = data.modules;
190+
}
191+
192+
return {
193+
platform,
194+
project_name,
195+
output_folder,
196+
user_name,
197+
communication_language,
198+
document_output_language,
199+
user_skill_level,
200+
planning_artifacts,
201+
implementation_artifacts,
202+
project_knowledge,
203+
modules,
204+
};
205+
}
206+
98207
export function validateState(data: unknown): BmalphState {
99208
assertObject(data, "state");
100209

0 commit comments

Comments
 (0)