fix: rabitai PR fixes

This commit is contained in:
sno 2026-03-15 19:40:20 +01:00
parent fad8a7923e
commit f197be10da
7 changed files with 182 additions and 54 deletions

View File

@ -11,7 +11,7 @@ BMAD supports running multiple independent projects within a single installation
## How It Works
A `.current_project` file in your `_bmad/` directory stores the active project name. When set, all artifact output paths are redirected to project-specific subdirectories.
A `.current_project` file in your `_bmad/` directory stores the active project name. This file is mutable local state: it should never be committed, because committing it can silently flip the active project context for other contributors and CI. Add `_bmad/.current_project` to your VCS ignore file, for example `.gitignore`. When set, all artifact output paths are redirected to project-specific subdirectories.
## Quick Start
@ -45,4 +45,4 @@ Temporarily target a different project without changing the global context:
- Path traversal (`..`) is rejected
- Absolute paths are rejected
- Only alphanumeric characters, dots, dashes, underscores, and forward slashes are allowed
- Only alphanumeric characters, dots, dashes, and underscores are allowed

View File

@ -58,8 +58,9 @@ directories:
# 2. FILE FALLBACK: If no inline override, check if {project-root}/_bmad/.current_project exists.
# If so, read its single-line content as project_suffix (trim whitespace/newlines).
# 3. VALIDATE (if project_suffix is set):
# - REJECT if empty, contains '..', starts with '/' or drive letter (e.g. C:)
# - REJECT if fails whitelist regex: ^[a-zA-Z0-9._\-/]+$
# - REJECT if empty, equals '.', contains '..', starts with '/' or drive letter (e.g. C:)
# - REJECT if it contains '/' or '\' because project_suffix must be a single directory name
# - REJECT if fails whitelist regex: ^[a-zA-Z0-9._-]+$
# - On rejection: output "Security Error: Invalid project context" and HALT
# 4. OVERRIDE PATHS (if valid project_suffix):
# - output_folder = {project-root}/_bmad-output/{project_suffix}
@ -77,6 +78,6 @@ monorepo_context:
- "#project:"
- "#p:"
validation:
whitelist_regex: "^[a-zA-Z0-9._\\-/]+$"
whitelist_regex: "^[a-zA-Z0-9._-]+$"
reject_traversal: true
reject_absolute: true

View File

@ -8,15 +8,17 @@
### 1. Identify Projects
- Use your file listing or system capabilities to examine the `{project-root}/_bmad-output/` directory.
- Identify all subdirectories within this path. Each subdirectory represents an available project context, except:
- First check whether `{project-root}/_bmad-output/` exists and is readable.
- If it does not exist, or listing it returns an `ENOENT`-style not-found error, treat the available project list as empty and continue with only `default (root)`.
- If it exists, use your file listing or system capabilities to examine the `{project-root}/_bmad-output/` directory.
- Identify all direct child subdirectories within this path. Each subdirectory represents an available project context, except:
- Ignore hidden directories starting with `.`
- Ignore standard BMAD artifact directories: `planning-artifacts`, `implementation-artifacts`, `test-artifacts`, `knowledge`, `docs`, `assets`
- The root `_bmad-output/` directory itself represents the `default (root)` unset project context.
### 2. Output Results
1. Display a formatted numbered list of the existing projects you found.
1. Display a formatted numbered list of the existing projects you found. If none exist yet, show only `default (root)`.
2. Clearly indicate the `default (root)` project context, usually as `[0]`.
3. Check whether an active project is currently set by reading the active project selector file in `{project-root}/_bmad/` (or as configured in `monorepo_context.context_file`). If it exists, indicate which project from the list is active, for example by adding `(ACTIVE)` next to the entry.
4. Inform the user that they can switch projects with PS (Project Switch) or create a new one with PN (Project New).

View File

@ -20,13 +20,15 @@ The context file is the default hidden project selector file in `{project-root}/
- Ask: `What should the new project be called?`
- Wait for input.
3. **Validate Project Name**
- **Cleanup**: Remove leading and trailing slashes and any occurrences of `_bmad-output/`.
- **Validate - No Absolute**: Check the raw user input before cleanup. Reject if it starts with `/`, starts with `\\`, or starts with a drive letter such as `C:`.
- **Cleanup**: If the raw value starts with `_bmad-output/`, remove that single leading prefix once. Then trim leading and trailing slashes.
- **Validate - No Traversal**: Reject if path contains `..`.
- **Validate - No Absolute**: Reject if path starts with `/` or a drive letter such as `C:`.
- **Validate - Empty/Whitespace**: Reject if empty or only whitespace.
- **Validate - Whitelist**: Match against regex `^[-a-zA-Z0-9._/]+$`.
- **Validate - No Separators**: Reject if the sanitized value still contains `/` or `\`. Project names must be single directory names, not nested paths.
- **Validate - Empty/Whitespace**: Reject if empty, only whitespace, or exactly `.`.
- **Validate - Whitelist**: Match against regex `^[-a-zA-Z0-9._]+$`.
- **Validate - Reserved Names**: Reject if the sanitized value is any reserved BMAD artifact directory name: `planning-artifacts`, `implementation-artifacts`, `test-artifacts`, `knowledge`, `docs`, `assets`.
- If invalid:
- Output: `Error: Invalid project name — must be a relative path and contain only alphanumeric characters, dots, dashes, underscores, or slashes. Traversal (..) is strictly forbidden.`
- Output: `Error: Invalid project name — use a single directory name containing only alphanumeric characters, dots, dashes, or underscores. Nested paths, reserved artifact names, absolute paths, and traversal (..) are forbidden.`
- Halt.
4. **Check for Existing Project**
- Check if `{project-root}/_bmad-output/<sanitized_path>` already exists.

View File

@ -17,9 +17,11 @@ The context file is the default hidden project selector file in `{project-root}/
1. **Analyze Request**: Determine the requested project name from the user's initial invocation, such as "switch project my-app".
2. **Wait for Input (If Missing)**: If the user did not provide a project name:
- Use your file listing capabilities to examine the `{project-root}/_bmad-output/` directory.
- First check whether `{project-root}/_bmad-output/` exists and is readable.
- If it exists, use your file listing capabilities to examine that directory.
- Output a formatted list of the existing projects you found, explicitly noting the `default (root)` project context.
Exclude hidden directories and standard BMAD artifact folders: `planning-artifacts`, `implementation-artifacts`, `test-artifacts`, `knowledge`, `docs`, `assets`.
- If `{project-root}/_bmad-output/` does not exist or is unreadable because it is missing, show only the `default (root)` project context.
- Present options in a numbered list format:
```text
@ -44,17 +46,19 @@ Enter a number to select, type an existing project name, or enter 'CLEAR' to res
- If the number is out of range, show an error and ask again.
- If valid, use that project name as the path.
- **Case: Path Provided** (text input)
- **Cleanup**: Remove leading and trailing slashes and any occurrences of `_bmad-output/`.
- **Validate - No Absolute**: Check the raw user input before cleanup. Reject if it starts with `/`, starts with `\\`, or starts with a drive letter such as `C:`.
- **Cleanup**: If the raw value starts with `_bmad-output/`, remove that single leading prefix once. Then trim leading and trailing slashes.
- **Validate - No Traversal**: Reject if path contains `..`.
- **Validate - No Absolute**: Reject if path starts with `/` or a drive letter such as `C:`.
- **Validate - Empty/Whitespace**: Reject if empty or only whitespace.
- **Validate - Whitelist**: Match against regex `^[-a-zA-Z0-9._/]+$`.
- **Validate - No Separators**: Reject if the sanitized value still contains `/` or `\`. Project names must be single directory names, not nested paths.
- **Validate - Empty/Whitespace**: Reject if empty, only whitespace, or exactly `.`.
- **Validate - Whitelist**: Match against regex `^[-a-zA-Z0-9._]+$`.
- **Validate - Reserved Names**: Reject if the sanitized value is any reserved BMAD artifact directory name: `planning-artifacts`, `implementation-artifacts`, `test-artifacts`, `knowledge`, `docs`, `assets`.
- **Check Results**
- If invalid:
- Output: `Error: Invalid project name — must be a relative path and contain only alphanumeric characters, dots, dashes, underscores, or slashes. Traversal (..) is strictly forbidden.`
- Output: `Error: Invalid project name — use a single directory name containing only alphanumeric characters, dots, dashes, or underscores. Nested paths, reserved artifact names, absolute paths, and traversal (..) are forbidden.`
- Halt.
- **Validate Existence**: Check if `{project-root}/_bmad-output/<sanitized_path>` exists on disk.
- If it does not exist:
- **Validate Existence**: Check if `{project-root}/_bmad-output/<sanitized_path>` exists and is a directory.
- If it does not exist, or exists but is not a directory:
- Output: `Error: Project <sanitized_path> does not exist. Use PN (Project New) to create it first, or PL (Project List) to see available projects.`
- Halt.
- Write the active project selector file in `{project-root}/_bmad/` with content `<sanitized_path>`

View File

@ -1915,6 +1915,11 @@ async function runTests() {
generatedConfig32.includes('inline_override_patterns:') && generatedConfig32.includes('- "#p:"'),
'Generated bmm config preserves monorepo override patterns',
);
assert(generatedConfig32.includes('context_file:'), 'Generated bmm config preserves monorepo context_file');
assert(generatedConfig32.includes('reject_traversal: true'), 'Generated bmm config preserves traversal protection');
assert(generatedConfig32.includes('reject_absolute: true'), 'Generated bmm config preserves absolute-path protection');
assert(generatedConfig32.includes('whitelist_regex: "^[a-zA-Z0-9._-]+$"'), 'Generated bmm config preserves monorepo whitelist regex');
assert(!generatedConfig32.includes('\ncurrent_project:'), 'Generated bmm config does not materialize mutable current project state');
} catch (error) {
assert(false, 'Monorepo config generation test succeeds', error.message);
} finally {
@ -1924,59 +1929,132 @@ async function runTests() {
console.log('');
// ============================================================
// Test 33: Context skills are discoverable as skills
// Test 33: Installer manages monorepo helper files safely
// ============================================================
console.log(`${colors.yellow}Test Suite 33: Context Skill Discovery${colors.reset}\n`);
console.log(`${colors.yellow}Test Suite 33: Installer Monorepo Helper Files${colors.reset}\n`);
let tempFixture33;
try {
tempFixture33 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-context-skills-'));
await fs.ensureDir(path.join(tempFixture33, '_config'));
await fs.ensureDir(path.join(tempFixture33, 'bmm', 'agents'));
await fs.writeFile(path.join(tempFixture33, 'bmm', 'agents', 'test.md'), '<agent name="Test" title="T"><persona>p</persona></agent>');
tempFixture33 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-monorepo-helpers-'));
const installer33 = new Installer();
const projectDir33 = path.join(tempFixture33, 'project');
await fs.ensureDir(projectDir33);
const sourceContextDir33 = path.join(projectRoot, 'src', 'bmm', 'workflows', '0-context');
const targetContextDir33 = path.join(tempFixture33, 'bmm', 'workflows', '0-context');
await fs.copy(sourceContextDir33, targetContextDir33);
await installer33.ensureProjectGitignore(projectDir33);
const gitignore33 = await fs.readFile(path.join(projectDir33, '.gitignore'), 'utf8');
assert(gitignore33.includes('_bmad/.current_project'), 'Installer creates a .gitignore entry for local monorepo state');
const generator33 = new ManifestGenerator();
await generator33.generateManifests(tempFixture33, ['bmm'], [], { ides: [] });
const newProjectSkill33 = generator33.skills.find((skill) => skill.canonicalId === 'bmad-project-new');
const setProjectSkill33 = generator33.skills.find((skill) => skill.canonicalId === 'bmad-project-switch');
const listProjectsSkill33 = generator33.skills.find((skill) => skill.canonicalId === 'bmad-project-list');
assert(newProjectSkill33 !== undefined, 'New project skill appears in skills[]');
assert(setProjectSkill33 !== undefined, 'Set project context skill appears in skills[]');
assert(listProjectsSkill33 !== undefined, 'List projects skill appears in skills[]');
await installer33.ensureProjectGitignore(projectDir33);
const gitignore33Repeat = await fs.readFile(path.join(projectDir33, '.gitignore'), 'utf8');
assert(
newProjectSkill33 && newProjectSkill33.path.includes('workflows/0-context/bmad-project-new/SKILL.md'),
gitignore33Repeat.match(/^_bmad\/\.current_project$/gm)?.length === 1,
'Installer does not duplicate the .gitignore entry on repeat runs',
);
const syntheticModule33 = `header: test
# --- Monorepo / Multi-Project Context Support ---
monorepo_context:
enabled: true
context_file: ".current_project"
# --- Future Section ---
future_key: true
`;
const extractedBlock33 = installer33.extractMonorepoContextBlock(syntheticModule33);
assert(extractedBlock33 && extractedBlock33.includes('monorepo_context:'), 'Monorepo block extraction keeps the monorepo section');
assert(extractedBlock33 && !extractedBlock33.includes('future_key: true'), 'Monorepo block extraction stops before later sections');
assert(
installer33.extractMonorepoContextBlock('header: test\n# --- Monorepo / Multi-Project Context Support ---\n# comment only\n') ===
null,
'Monorepo block extraction ignores comment-only sections without a top-level monorepo_context key',
);
const customModuleDir33 = path.join(tempFixture33, 'custom-bmm');
await fs.ensureDir(customModuleDir33);
await fs.writeFile(path.join(customModuleDir33, 'module.yaml'), syntheticModule33, 'utf8');
installer33.moduleManager.setCustomModulePaths(new Map([['custom-bmm', customModuleDir33]]));
const resolvedSchemaPath33 = await installer33.resolveModuleSchemaPath('custom-bmm');
assert(
resolvedSchemaPath33 === path.join(customModuleDir33, 'module.yaml'),
'resolveModuleSchemaPath prefers live moduleManager custom module paths',
);
await fs.ensureDir(path.join(tempFixture33, 'custom-bmm'));
await installer33.generateModuleConfigs(tempFixture33, { core: {} });
const generatedConfig33 = await fs.readFile(path.join(tempFixture33, 'custom-bmm', 'config.yaml'), 'utf8');
assert(generatedConfig33.includes('monorepo_context:'), 'Static config blocks are still emitted for custom modules');
assert(
!generatedConfig33.includes('\n{}\n'),
'Static config blocks replace serialized empty-object YAML instead of appending after {}',
);
} catch (error) {
assert(false, 'Installer monorepo helper-file test succeeds', error.message);
} finally {
if (tempFixture33) await fs.remove(tempFixture33).catch(() => {});
}
console.log('');
// ============================================================
// Test 34: Context skills are discoverable as skills
// ============================================================
console.log(`${colors.yellow}Test Suite 34: Context Skill Discovery${colors.reset}\n`);
let tempFixture34;
try {
tempFixture34 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-context-skills-'));
await fs.ensureDir(path.join(tempFixture34, '_config'));
await fs.ensureDir(path.join(tempFixture34, 'bmm', 'agents'));
await fs.writeFile(path.join(tempFixture34, 'bmm', 'agents', 'test.md'), '<agent name="Test" title="T"><persona>p</persona></agent>');
const sourceContextDir34 = path.join(projectRoot, 'src', 'bmm', 'workflows', '0-context');
const targetContextDir34 = path.join(tempFixture34, 'bmm', 'workflows', '0-context');
await fs.copy(sourceContextDir34, targetContextDir34);
const generator34 = new ManifestGenerator();
await generator34.generateManifests(tempFixture34, ['bmm'], [], { ides: [] });
const newProjectSkill34 = generator34.skills.find((skill) => skill.canonicalId === 'bmad-project-new');
const setProjectSkill34 = generator34.skills.find((skill) => skill.canonicalId === 'bmad-project-switch');
const listProjectsSkill34 = generator34.skills.find((skill) => skill.canonicalId === 'bmad-project-list');
assert(newProjectSkill34 !== undefined, 'New project skill appears in skills[]');
assert(setProjectSkill34 !== undefined, 'Set project context skill appears in skills[]');
assert(listProjectsSkill34 !== undefined, 'List projects skill appears in skills[]');
assert(
newProjectSkill34 && newProjectSkill34.path.includes('workflows/0-context/bmad-project-new/SKILL.md'),
'New project skill keeps its workflow directory path',
);
assert(
setProjectSkill33 && setProjectSkill33.path.includes('workflows/0-context/bmad-project-switch/SKILL.md'),
setProjectSkill34 && setProjectSkill34.path.includes('workflows/0-context/bmad-project-switch/SKILL.md'),
'Set project skill keeps its workflow directory path',
);
assert(
listProjectsSkill33 && listProjectsSkill33.path.includes('workflows/0-context/bmad-project-list/SKILL.md'),
listProjectsSkill34 && listProjectsSkill34.path.includes('workflows/0-context/bmad-project-list/SKILL.md'),
'List projects skill keeps its workflow directory path',
);
assert(
!generator33.workflows.some((workflow) => workflow.path.includes('0-context/bmad-project-new')),
!generator34.workflows.some((workflow) => workflow.path.includes('0-context/bmad-project-new')),
'New project skill does not appear in workflows[]',
);
assert(
!generator33.workflows.some((workflow) => workflow.path.includes('0-context/bmad-project-switch')),
!generator34.workflows.some((workflow) => workflow.path.includes('0-context/bmad-project-switch')),
'Set project context skill does not appear in workflows[]',
);
assert(
!generator33.workflows.some((workflow) => workflow.path.includes('0-context/bmad-project-list')),
!generator34.workflows.some((workflow) => workflow.path.includes('0-context/bmad-project-list')),
'List projects skill does not appear in workflows[]',
);
assert(
!generator34.skills.some((skill) => skill.canonicalId === '.current_project'),
'Manifest generation is unchanged when no mutable current-project file exists in the fixture',
);
} catch (error) {
assert(false, 'Context skill discovery test succeeds', error.message);
} finally {
if (tempFixture33) await fs.remove(tempFixture33).catch(() => {});
if (tempFixture34) await fs.remove(tempFixture34).catch(() => {});
}
console.log('');

View File

@ -816,6 +816,7 @@ class Installer {
// Create bmad directory structure
spinner.message('Creating directory structure...');
await this.createDirectoryStructure(bmadDir);
await this.ensureProjectGitignore(projectDir);
// Cache custom modules if any
if (customModulePaths && customModulePaths.size > 0) {
@ -1455,6 +1456,8 @@ class Installer {
throw new Error(`No BMAD installation found at ${bmadDir}`);
}
await this.ensureProjectGitignore(projectDir);
spinner.message('Analyzing update requirements...');
// Compare versions and determine what needs updating
@ -1981,6 +1984,35 @@ class Installer {
await fs.ensureDir(path.join(bmadDir, '_config', 'custom'));
}
/**
* Ensure local-only BMAD state is ignored by version control.
* @param {string} projectDir - Project root directory
*/
async ensureProjectGitignore(projectDir) {
const gitignorePath = path.join(projectDir, '.gitignore');
const ignoreEntry = `${BMAD_FOLDER_NAME}/.current_project`;
let existingContent = '';
if (await fs.pathExists(gitignorePath)) {
existingContent = await fs.readFile(gitignorePath, 'utf8');
}
const existingLines = new Set(
existingContent
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean),
);
if (existingLines.has(ignoreEntry) || existingLines.has(`/${ignoreEntry}`)) {
return;
}
const separator = existingContent === '' || existingContent.endsWith('\n') ? '' : '\n';
const nextContent = `${existingContent}${separator}${ignoreEntry}\n`;
await fs.writeFile(gitignorePath, nextContent, 'utf8');
}
/**
* Generate clean config.yaml files for each installed module
* @param {string} bmadDir - BMAD installation directory
@ -2069,9 +2101,14 @@ class Installer {
const staticConfigBlocks = await this.getStaticConfigBlocks(moduleName);
if (staticConfigBlocks.length > 0) {
const trimmedYamlContent = yamlContent.trim();
if (trimmedYamlContent === '' || trimmedYamlContent === '{}') {
yamlContent = `${staticConfigBlocks.join('\n\n')}\n`;
} else {
yamlContent = yamlContent.trimEnd();
yamlContent += `\n\n${staticConfigBlocks.join('\n\n')}\n`;
}
}
// Write the clean config file with POSIX-compliant final newline
const content = header + yamlContent;
@ -2106,6 +2143,10 @@ class Installer {
* @returns {Promise<string|null>} Path to module.yaml or null if not found
*/
async resolveModuleSchemaPath(moduleName) {
if (this.moduleManager.customModulePaths?.has(moduleName)) {
return path.join(this.moduleManager.customModulePaths.get(moduleName), 'module.yaml');
}
if (this.configCollector.customModulePaths?.has(moduleName)) {
return path.join(this.configCollector.customModulePaths.get(moduleName), 'module.yaml');
}
@ -2125,17 +2166,17 @@ class Installer {
* @returns {string|null} Preserved block or null when absent
*/
extractMonorepoContextBlock(content) {
// Capture from the header comment through to the end of the monorepo_context YAML block.
// The block is expected at the end of the file, after any other config sections.
const headerIndex = content.indexOf('# --- Monorepo / Multi-Project Context Support ---');
if (headerIndex === -1) {
return null;
}
const block = content.slice(headerIndex).trimEnd();
const afterHeader = content.slice(headerIndex);
const nextSectionMatch = afterHeader.slice(1).match(/\n# --- .+ ---/);
const endIndex = nextSectionMatch ? headerIndex + 1 + nextSectionMatch.index : content.length;
const block = content.slice(headerIndex, endIndex).trimEnd();
// Sanity check: the block must contain the structured YAML key
if (!block.includes('monorepo_context:')) {
if (!/^\s*monorepo_context\s*:/m.test(block)) {
return null;
}