diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml index aa281b4..6519455 100644 --- a/.github/workflows/quality.yaml +++ b/.github/workflows/quality.yaml @@ -113,3 +113,6 @@ jobs: - name: Test agent compilation components run: npm run test:install + + - name: Validate file references + run: npm run validate:refs diff --git a/package.json b/package.json index 0c89bf3..23166ed 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,10 @@ "release:minor": "npm version minor && git push --follow-tags", "release:patch": "npm version patch && git push --follow-tags", "release:prerelease": "npm version prerelease && git push --follow-tags", - "test": "npm run test:schemas && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check", + "test": "npm run test:schemas && npm run test:refs && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check", + "test:refs": "node test/test-validate-file-refs.cjs", "test:schemas": "node test/test-agent-schema.js", + "validate:refs": "node tools/validate-file-refs.mjs", "validate:schemas": "node test/validate-agent-schema.js" }, "lint-staged": { diff --git a/src/workflows/agent/data/agent-validation.md b/src/workflows/agent/data/agent-validation.md index e0e36ed..9c3f6a9 100644 --- a/src/workflows/agent/data/agent-validation.md +++ b/src/workflows/agent/data/agent-validation.md @@ -77,7 +77,7 @@ critical_actions: - [ ] ALL sidecar paths: `{project-root}/_bmad/_memory/{sidecar-folder}/...` - [ ] `{project-root}` is literal (not replaced) - [ ] `{sidecar-folder}` = actual folder name -- [ ] No `./` or `/Users/` paths +- [ ] No `./` or `/Users/` paths ### Persona Addition - [ ] `communication_style` includes memory reference patterns diff --git a/src/workflows/workflow/data/frontmatter-standards.md b/src/workflows/workflow/data/frontmatter-standards.md index 315bbfd..4864fd0 100644 --- a/src/workflows/workflow/data/frontmatter-standards.md +++ b/src/workflows/workflow/data/frontmatter-standards.md @@ -16,9 +16,9 @@ | Variable | Example | |----------|---------| -| `{project-root}` | `/Users/user/dev/BMAD-METHOD` | +| `{project-root}` | `/Users/user/dev/BMAD-METHOD` | | `{project_name}` | `my-project` | -| `{output_folder}` | `/Users/user/dev/BMAD-METHOD/output` | +| `{output_folder}` | `/Users/user/dev/BMAD-METHOD/output` | | `{user_name}` | `Brian` | | `{communication_language}` | `english` | | `{document_output_language}` | `english` | @@ -110,8 +110,8 @@ nextStepFile: './step-02-foo.md' | Pattern | Why | |---------|-----| | `workflow_path: '{project-root}/...'` | Use relative paths | -| `thisStepFile: './step-XX.md'` | Remove unless referenced | -| `workflowFile: './workflow.md'` | Remove unless referenced | +| `thisStepFile: './step-XX.md'` | Remove unless referenced | +| `workflowFile: './workflow.md'` | Remove unless referenced | | `{workflow_path}/templates/...` | Use `../template.md` | | `{workflow_path}/data/...` | Use `./data/file.md` | diff --git a/src/workflows/workflow/data/step-type-patterns.md b/src/workflows/workflow/data/step-type-patterns.md index c857227..7b0531e 100644 --- a/src/workflows/workflow/data/step-type-patterns.md +++ b/src/workflows/workflow/data/step-type-patterns.md @@ -79,7 +79,7 @@ templateFile: '../templates/[template].md' ### 2. Init (Continuable) **Use:** Multi-session workflow -**Frontmatter:** Add `continueFile: './step-01b-continue.md'` +**Frontmatter:** Add `continueFile: './step-01b-continue.md'` **Logic:** ```markdown diff --git a/test/fixtures/file-refs/invalid/absolute-path-leak.md b/test/fixtures/file-refs/invalid/absolute-path-leak.md new file mode 100644 index 0000000..218eefb --- /dev/null +++ b/test/fixtures/file-refs/invalid/absolute-path-leak.md @@ -0,0 +1,5 @@ +# Absolute Path Leak + +This file contains a leaked absolute path: + +Load config from /Users/developer/project/_bmad/config.yaml diff --git a/test/fixtures/file-refs/invalid/broken-internal-ref.agent.yaml b/test/fixtures/file-refs/invalid/broken-internal-ref.agent.yaml new file mode 100644 index 0000000..efaa6b2 --- /dev/null +++ b/test/fixtures/file-refs/invalid/broken-internal-ref.agent.yaml @@ -0,0 +1,11 @@ +# Fixture: broken internal {project-root}/_bmad/bmb/ ref +# Expected: BROKEN (target does not exist in src/) +agent: + metadata: + id: test-agent + name: Test + module: bmb + menu: + - trigger: XX or fuzzy match on nonexistent + exec: "{project-root}/_bmad/bmb/workflows/nonexistent/workflow.md" + description: "Broken ref" diff --git a/test/fixtures/file-refs/invalid/broken-relative-ref.md b/test/fixtures/file-refs/invalid/broken-relative-ref.md new file mode 100644 index 0000000..d750471 --- /dev/null +++ b/test/fixtures/file-refs/invalid/broken-relative-ref.md @@ -0,0 +1,9 @@ +--- +name: 'step-broken' +description: 'Test step with broken relative ref' +nextStepFile: './does-not-exist.md' +--- + +# Broken Step + +This step has a broken relative ref. diff --git a/test/fixtures/file-refs/invalid/wrong-depth.md b/test/fixtures/file-refs/invalid/wrong-depth.md new file mode 100644 index 0000000..c68aad8 --- /dev/null +++ b/test/fixtures/file-refs/invalid/wrong-depth.md @@ -0,0 +1,9 @@ +--- +name: 'step-wrong-depth' +description: 'Step with wrong relative depth (../../ instead of ../)' +nextStepFile: '../../data/frontmatter-standards.md' +--- + +# Wrong Depth + +This step uses ../../ when it should use ../ — a common BMB broken ref pattern. diff --git a/test/fixtures/file-refs/skip/external-core-ref.md b/test/fixtures/file-refs/skip/external-core-ref.md new file mode 100644 index 0000000..900b516 --- /dev/null +++ b/test/fixtures/file-refs/skip/external-core-ref.md @@ -0,0 +1,10 @@ +--- +name: 'step-with-external' +description: 'Step with external core module ref (should be skipped)' +advancedElicitationTask: '{project-root}/_bmad/core/workflows/advanced-elicitation/workflow.xml' +partyModeWorkflow: '{project-root}/_bmad/core/workflows/party-mode/workflow.md' +--- + +# External Refs + +These refs point to the core module and should be skipped by default. diff --git a/test/fixtures/file-refs/skip/external-relative-ref.md b/test/fixtures/file-refs/skip/external-relative-ref.md new file mode 100644 index 0000000..3aa77fe --- /dev/null +++ b/test/fixtures/file-refs/skip/external-relative-ref.md @@ -0,0 +1,10 @@ +--- +name: 'step-with-relative-external' +description: 'Step with ../../../../core/ relative ref (should be skipped)' +advancedElicitationTask: '../../../../core/workflows/advanced-elicitation/workflow.xml' +partyModeWorkflow: '../../../../core/workflows/party-mode/workflow.md' +--- + +# Relative External Refs + +These climb out of the src/ directory and should be skipped. diff --git a/test/fixtures/file-refs/skip/install-generated-ref.md b/test/fixtures/file-refs/skip/install-generated-ref.md new file mode 100644 index 0000000..2751d09 --- /dev/null +++ b/test/fixtures/file-refs/skip/install-generated-ref.md @@ -0,0 +1,10 @@ +--- +name: 'step-install-generated' +description: 'Step referencing install-generated files (should be skipped)' +--- + +# Install Generated + +Load and read full config from {project-root}/_bmad/bmb/config.yaml and resolve variables. + +Check KB at {project-root}/_bmad/bmb/docs/workflows/kb.csv for context. diff --git a/test/fixtures/file-refs/skip/template-placeholder.md b/test/fixtures/file-refs/skip/template-placeholder.md new file mode 100644 index 0000000..989edfd --- /dev/null +++ b/test/fixtures/file-refs/skip/template-placeholder.md @@ -0,0 +1,11 @@ +--- +name: 'step-[N]-[name]' +description: 'Template pattern with bracketed placeholders' +nextStepFile: './step-02-[name].md' +outputFile: '{output_folder}/[output].md' +templateFile: '../templates/[template].md' +--- + +# Template Placeholders + +These bracketed refs are templates, not real file references. They should be skipped. diff --git a/test/fixtures/file-refs/skip/unresolvable-vars.md b/test/fixtures/file-refs/skip/unresolvable-vars.md new file mode 100644 index 0000000..46cc2ff --- /dev/null +++ b/test/fixtures/file-refs/skip/unresolvable-vars.md @@ -0,0 +1,10 @@ +--- +name: 'step-with-runtime-vars' +description: 'Step with runtime variables that cannot be resolved statically' +workflowPlanFile: '{bmb_creations_output_folder}/workflows/{new_workflow_name}/workflow-plan.md' +outputFile: '{output_folder}/report-{datetime}.md' +--- + +# Runtime Variables + +These contain {curly_brace} variables that are resolved at runtime, not statically. diff --git a/test/fixtures/file-refs/valid/csv-workflow-ref.csv b/test/fixtures/file-refs/valid/csv-workflow-ref.csv new file mode 100644 index 0000000..ab75b48 --- /dev/null +++ b/test/fixtures/file-refs/valid/csv-workflow-ref.csv @@ -0,0 +1,2 @@ +module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs, +bmb,anytime,Create Agent,CA,10,_bmad/bmb/workflows/agent/workflow-create-agent.md,bmad_bmb_create_agent,false,agent-builder,Create Mode,"Create agent",bmb_creations_output_folder,"agent", diff --git a/test/fixtures/file-refs/valid/internal-bmb-ref.agent.yaml b/test/fixtures/file-refs/valid/internal-bmb-ref.agent.yaml new file mode 100644 index 0000000..4b2579d --- /dev/null +++ b/test/fixtures/file-refs/valid/internal-bmb-ref.agent.yaml @@ -0,0 +1,11 @@ +# Fixture: valid internal {project-root}/_bmad/bmb/ ref +# Expected: VALID (maps to src/workflows/workflow/workflow-create-workflow.md) +agent: + metadata: + id: test-agent + name: Test + module: bmb + menu: + - trigger: CW or fuzzy match on create-workflow + exec: "{project-root}/_bmad/bmb/workflows/workflow/workflow-create-workflow.md" + description: "Test menu item" diff --git a/test/fixtures/file-refs/valid/relative-ref-target.md b/test/fixtures/file-refs/valid/relative-ref-target.md new file mode 100644 index 0000000..1bc8b6b --- /dev/null +++ b/test/fixtures/file-refs/valid/relative-ref-target.md @@ -0,0 +1,3 @@ +# Target File + +This file exists so the relative ref in relative-ref.md resolves. diff --git a/test/fixtures/file-refs/valid/relative-ref.md b/test/fixtures/file-refs/valid/relative-ref.md new file mode 100644 index 0000000..bd9d2b6 --- /dev/null +++ b/test/fixtures/file-refs/valid/relative-ref.md @@ -0,0 +1,9 @@ +--- +name: 'step-03-requirements' +description: 'Test step with valid relative ref' +nextStepFile: './relative-ref-target.md' +--- + +# Test Step + +This step has a valid relative ref in frontmatter. diff --git a/test/test-validate-file-refs.cjs b/test/test-validate-file-refs.cjs new file mode 100644 index 0000000..508df64 --- /dev/null +++ b/test/test-validate-file-refs.cjs @@ -0,0 +1,680 @@ +/** + * File Reference Validation Test Runner + * + * Tests the validate-file-refs.mjs tool against fixtures and the live source tree. + * Covers all 10 acceptance criteria for MSSCI-14579. + * + * Test categories: + * 1. Module import — _testing exports exist and are functions + * 2. Path mapping — {project-root}/_bmad/bmb/ → src/ resolution + * 3. Module auto-detect — reads module.yaml code field + * 4. Ref extraction — YAML, markdown, CSV patterns + * 5. Skip logic — external refs, install-generated, template placeholders, runtime vars + * 6. Broken ref detection — wrong depth, missing files, stale refs + * 7. Absolute path leak detection + * 8. CLI exit codes — default (0) vs --strict (1) + * 9. Live source tree baseline — known broken ref ratchet + * + * Usage: node test/test-validate-file-refs.cjs + * Exit codes: 0 = all tests pass, 1 = test failures + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const { execFile } = require('node:child_process'); + +const TOOL_PATH = path.join(__dirname, '..', 'tools', 'validate-file-refs.mjs'); +const SRC_DIR = path.join(__dirname, '..', 'src'); + +// Known broken ref baseline — ratchet down as refs are fixed upstream +const KNOWN_BASELINE = 26; + +// ANSI color codes (matching test-agent-schema.js pattern) +const colors = { + reset: '\u001B[0m', + green: '\u001B[32m', + red: '\u001B[31m', + yellow: '\u001B[33m', + blue: '\u001B[34m', + cyan: '\u001B[36m', + dim: '\u001B[2m', +}; + +// --- Test Infrastructure --- + +let totalTests = 0; +let passedTests = 0; +const failures = []; + +function pass(name, detail) { + totalTests++; + passedTests++; + console.log(` ${colors.green}✓${colors.reset} ${name} ${colors.dim}${detail || ''}${colors.reset}`); +} + +function fail(name, reason) { + totalTests++; + console.log(` ${colors.red}✗${colors.reset} ${name} ${colors.red}${reason}${colors.reset}`); + failures.push({ name, reason }); +} + +function section(title) { + console.log(`\n${colors.blue}${title}${colors.reset}`); +} + +/** + * Run the validator CLI as a child process + * @param {string[]} args - CLI arguments + * @returns {Promise<{stdout: string, stderr: string, exitCode: number}>} + */ +function runCLI(args = []) { + return new Promise((resolve) => { + execFile('node', [TOOL_PATH, ...args], { cwd: path.join(__dirname, '..') }, (error, stdout, stderr) => { + resolve({ + stdout: stdout || '', + stderr: stderr || '', + exitCode: error ? error.code : 0, + }); + }); + }); +} + +// --- Test Suites --- + +/** + * AC10: _testing exports enable unit-level test coverage + */ +async function testExports() { + section('AC10: _testing exports'); + + let mod; + try { + // Dynamic import for ESM module from CJS test + mod = await import(TOOL_PATH); + } catch (error) { + fail('Module import', `Cannot import validate-file-refs.mjs: ${error.message}`); + return; + } + + const _testing = mod._testing || (mod.default && mod.default._testing); + if (!_testing) { + fail('_testing namespace', 'Module does not export _testing'); + return; + } + pass('_testing namespace', 'exported'); + + // Check required exports + const requiredExports = [ + 'mapInstalledToSource', + 'isResolvable', + 'extractYamlRefs', + 'extractMarkdownRefs', + 'extractCsvRefs', + 'checkAbsolutePathLeaks', + 'detectModuleCode', + 'isExternalRef', + 'isBracketedPlaceholder', + ]; + + for (const name of requiredExports) { + if (typeof _testing[name] === 'function') { + pass(`export: ${name}`, 'is a function'); + } else { + fail(`export: ${name}`, `missing or not a function (got ${typeof _testing[name]})`); + } + } +} + +/** + * AC2: Path mapping — {project-root}/_bmad/bmb/ → src/ + */ +async function testPathMapping() { + section('AC2: Path mapping'); + + let _testing; + try { + const mod = await import(TOOL_PATH); + _testing = mod._testing || (mod.default && mod.default._testing); + } catch { + fail('Path mapping (import)', 'Cannot import module'); + return; + } + + if (!_testing || !_testing.mapInstalledToSource) { + fail('Path mapping', '_testing.mapInstalledToSource not available'); + return; + } + + const { mapInstalledToSource } = _testing; + + // Internal bmb ref should map to src/ + const internal = mapInstalledToSource('{project-root}/_bmad/bmb/workflows/workflow/workflow-create-workflow.md'); + if (internal && internal.includes(path.join('src', 'workflows', 'workflow', 'workflow-create-workflow.md'))) { + pass('Internal bmb ref', `maps to ${path.relative(SRC_DIR, internal)}`); + } else { + fail('Internal bmb ref', `expected src/workflows/... got ${internal}`); + } + + // Install-generated config.yaml should return null (skip) + const config = mapInstalledToSource('{project-root}/_bmad/bmb/config.yaml'); + if (config === null) { + pass('Install-generated config.yaml', 'returns null (skipped)'); + } else { + fail('Install-generated config.yaml', `expected null, got ${config}`); + } + + // Install-generated docs/ KB should return null (skip) + const docsKb = mapInstalledToSource('{project-root}/_bmad/bmb/docs/workflows/kb.csv'); + if (docsKb === null) { + pass('Install-generated docs/ KB', 'returns null (skipped)'); + } else { + fail('Install-generated docs/ KB', `expected null, got ${docsKb}`); + } +} + +/** + * AC3: Auto-detect module code from module.yaml + */ +async function testModuleAutoDetect() { + section('AC3: Module auto-detect'); + + let _testing; + try { + const mod = await import(TOOL_PATH); + _testing = mod._testing || (mod.default && mod.default._testing); + } catch { + fail('Module auto-detect (import)', 'Cannot import module'); + return; + } + + if (!_testing || !_testing.detectModuleCode) { + fail('detectModuleCode', 'not exported'); + return; + } + + const code = _testing.detectModuleCode(SRC_DIR); + if (code === 'bmb') { + pass('detectModuleCode', 'returns "bmb" from src/module.yaml'); + } else { + fail('detectModuleCode', `expected "bmb", got "${code}"`); + } +} + +/** + * AC3: External ref detection + */ +async function testExternalRefDetection() { + section('AC3: External ref detection'); + + let _testing; + try { + const mod = await import(TOOL_PATH); + _testing = mod._testing || (mod.default && mod.default._testing); + } catch { + fail('External ref detection (import)', 'Cannot import module'); + return; + } + + if (!_testing || !_testing.isExternalRef) { + fail('isExternalRef', 'not exported'); + return; + } + + const { isExternalRef } = _testing; + + // Core module ref is external + if (isExternalRef('{project-root}/_bmad/core/workflows/advanced-elicitation/workflow.xml', 'bmb')) { + pass('core/ ref', 'detected as external'); + } else { + fail('core/ ref', 'not detected as external'); + } + + // Own module ref is NOT external + if (isExternalRef('{project-root}/_bmad/bmb/workflows/workflow/workflow-create-workflow.md', 'bmb')) { + fail('bmb/ ref', 'incorrectly detected as external'); + } else { + pass('bmb/ ref', 'detected as internal'); + } + + // bmm module ref is external + if (isExternalRef('{project-root}/_bmad/bmm/workflows/workflow-status/workflow.yaml', 'bmb')) { + pass('bmm/ ref', 'detected as external'); + } else { + fail('bmm/ ref', 'not detected as external'); + } +} + +/** + * AC5: Template placeholder skip logic + */ +async function testBracketedPlaceholders() { + section('AC5: Bracketed placeholder detection'); + + let _testing; + try { + const mod = await import(TOOL_PATH); + _testing = mod._testing || (mod.default && mod.default._testing); + } catch { + fail('Placeholder detection (import)', 'Cannot import module'); + return; + } + + if (!_testing || !_testing.isBracketedPlaceholder) { + fail('isBracketedPlaceholder', 'not exported'); + return; + } + + const { isBracketedPlaceholder } = _testing; + + // Template patterns should be detected + const templates = ['step-[N]-[name].md', '../templates/[template].md', './step-02-[name].md', '{output_folder}/[output].md']; + + for (const ref of templates) { + if (isBracketedPlaceholder(ref)) { + pass(`Template: ${ref}`, 'detected as placeholder'); + } else { + fail(`Template: ${ref}`, 'not detected as placeholder'); + } + } + + // Non-template patterns should NOT be detected + const nonTemplates = [ + './step-02-discovery.md', + '../data/frontmatter-standards.md', + '{project-root}/_bmad/bmb/workflows/agent/workflow-create-agent.md', + ]; + + for (const ref of nonTemplates) { + if (isBracketedPlaceholder(ref)) { + fail(`Non-template: ${ref}`, 'incorrectly detected as placeholder'); + } else { + pass(`Non-template: ${ref}`, 'not detected as placeholder'); + } + } +} + +/** + * AC7: Absolute path leak detection + */ +async function testAbsolutePathLeaks() { + section('AC7: Absolute path leak detection'); + + let _testing; + try { + const mod = await import(TOOL_PATH); + _testing = mod._testing || (mod.default && mod.default._testing); + } catch { + fail('Abs path detection (import)', 'Cannot import module'); + return; + } + + if (!_testing || !_testing.checkAbsolutePathLeaks) { + fail('checkAbsolutePathLeaks', 'not exported'); + return; + } + + const { checkAbsolutePathLeaks } = _testing; + + const leakyContent = 'Load config from /Users/developer/project/config.yaml\nAnother line.'; + const { leaks } = checkAbsolutePathLeaks('test.md', leakyContent); + if (leaks.length > 0) { + pass('Detects /Users/ leak', `found ${leaks.length} leak(s)`); + } else { + fail('Detects /Users/ leak', 'no leaks detected'); + } + + const cleanContent = 'Load config from {project-root}/_bmad/bmb/config.yaml\nAnother line.'; + const { leaks: noLeaks } = checkAbsolutePathLeaks('test.md', cleanContent); + if (noLeaks.length === 0) { + pass('Clean content', 'no false positives'); + } else { + fail('Clean content', `false positive: ${noLeaks.length} leak(s) detected`); + } +} + +/** + * AC1, AC2: CLI exit codes + */ +async function testCLIExitCodes() { + section('AC1/AC2: CLI exit codes'); + + // Default mode should exit 0 (warning only) even with broken refs + const defaultResult = await runCLI([]); + if (defaultResult.exitCode === 0) { + pass('Default mode', 'exits 0 (warning only)'); + } else { + fail('Default mode', `expected exit 0, got ${defaultResult.exitCode}`); + } + + // Strict mode should exit 1 when broken refs exist + const strictResult = await runCLI(['--strict']); + if (strictResult.exitCode === 1) { + pass('--strict mode', 'exits 1 (broken refs exist)'); + } else { + fail('--strict mode', `expected exit 1, got ${strictResult.exitCode}`); + } + + // Verify summary output mentions file count + if (defaultResult.stdout.includes('Files scanned')) { + pass('Summary output', 'includes "Files scanned"'); + } else { + fail('Summary output', 'missing "Files scanned" in output'); + } +} + +/** + * AC6: validate:refs npm script + */ +async function testNpmScript() { + section('AC6: npm script'); + + const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')); + + if (packageJson.scripts && packageJson.scripts['validate:refs']) { + pass('validate:refs script', `"${packageJson.scripts['validate:refs']}"`); + } else { + fail('validate:refs script', 'not found in package.json'); + } + + if (packageJson.scripts && packageJson.scripts['test:refs']) { + pass('test:refs script', `"${packageJson.scripts['test:refs']}"`); + } else { + fail('test:refs script', 'not found in package.json'); + } + + // AC9: test:refs should be wired into the main test chain + if (packageJson.scripts && packageJson.scripts.test && packageJson.scripts.test.includes('test:refs')) { + pass('test chain', 'test:refs is in npm test chain'); + } else { + fail('test chain', 'test:refs not found in npm test chain'); + } +} + +/** + * AC8: Live source tree baseline ratchet + */ +async function testLiveBaseline() { + section('AC8: Live source tree baseline'); + + const result = await runCLI(['--json']); + + let data; + try { + // Try to parse JSON output, or fall back to counting from stdout + data = JSON.parse(result.stdout); + } catch { + // If --json isn't supported yet, count from text output + const brokenMatch = result.stdout.match(/Broken references:\s*(\d+)/); + const issueMatch = result.stdout.match(/Issues found:\s*(\d+)/); + const count = brokenMatch ? Number.parseInt(brokenMatch[1], 10) : issueMatch ? Number.parseInt(issueMatch[1], 10) : -1; + + if (count === -1) { + fail('Baseline count', 'Cannot parse broken ref count from output'); + return; + } + + data = { brokenCount: count }; + } + + const brokenCount = data.brokenCount ?? data.broken_refs ?? 0; + + if (brokenCount <= KNOWN_BASELINE) { + pass('Baseline ratchet', `${brokenCount} broken refs <= ${KNOWN_BASELINE} baseline`); + } else { + fail('Baseline ratchet', `${brokenCount} broken refs > ${KNOWN_BASELINE} baseline — NEW broken refs introduced`); + } + + if (brokenCount < KNOWN_BASELINE) { + console.log(` ${colors.yellow}! Baseline can be lowered: ${brokenCount} < ${KNOWN_BASELINE}${colors.reset}`); + } +} + +/** + * AC4: Skip logic — isResolvable + */ +async function testResolvableSkipLogic() { + section('AC4/AC5: Skip logic (isResolvable)'); + + let _testing; + try { + const mod = await import(TOOL_PATH); + _testing = mod._testing || (mod.default && mod.default._testing); + } catch { + fail('Skip logic (import)', 'Cannot import module'); + return; + } + + if (!_testing || !_testing.isResolvable) { + fail('isResolvable', 'not exported'); + return; + } + + const { isResolvable } = _testing; + + // Resolvable paths + const resolvable = [ + './step-02-discovery.md', + '{project-root}/_bmad/bmb/workflows/agent/workflow-create-agent.md', + '../data/frontmatter-standards.md', + ]; + + for (const ref of resolvable) { + if (isResolvable(ref)) { + pass(`Resolvable: ${ref}`, 'correctly identified'); + } else { + fail(`Resolvable: ${ref}`, 'incorrectly skipped'); + } + } + + // Unresolvable runtime variables + const unresolvable = ['{bmb_creations_output_folder}/workflows/plan.md', '{output_folder}/report.md', '{{template_var}}/file.md']; + + for (const ref of unresolvable) { + if (isResolvable(ref)) { + fail(`Unresolvable: ${ref}`, 'should be skipped'); + } else { + pass(`Unresolvable: ${ref}`, 'correctly skipped'); + } + } +} + +/** + * Inline suppression comments + */ +async function testInlineIgnore() { + section('Inline suppression comments'); + + let _testing; + try { + const mod = await import(TOOL_PATH); + _testing = mod._testing || (mod.default && mod.default._testing); + } catch { + fail('Inline ignore (import)', 'Cannot import module'); + return; + } + + const { isLineIgnored, checkAbsolutePathLeaks } = _testing; + + if (!isLineIgnored) { + fail('isLineIgnored', 'not exported'); + return; + } + + // Same-line ignore + const lines1 = ['normal line', 'path /Users/dev/foo ', 'another line']; + if (isLineIgnored(lines1, 2)) { + pass('Same-line ignore', 'detected'); + } else { + fail('Same-line ignore', 'not detected'); + } + + // Next-line ignore + const lines2 = ['', 'path /Users/dev/foo', 'another line']; + if (isLineIgnored(lines2, 2)) { + pass('Next-line ignore', 'detected'); + } else { + fail('Next-line ignore', 'not detected'); + } + + // No ignore — normal line should not be ignored + if (isLineIgnored(lines1, 1)) { + fail('No ignore', 'normal line incorrectly ignored'); + } else { + pass('No ignore', 'normal line not ignored'); + } + + // Abs-path leak suppressed by ignore-next-line + const ignoredContent = '\n| `{project-root}` | `/Users/user/dev/BMAD-METHOD` |'; + const { leaks, ignoredCount } = checkAbsolutePathLeaks('test.md', ignoredContent); + if (leaks.length === 0 && ignoredCount === 1) { + pass('Abs-path with ignore-next-line', 'suppressed (ignoredCount=1)'); + } else { + fail('Abs-path with ignore-next-line', `expected 0 leaks + 1 ignored, got ${leaks.length} leaks + ${ignoredCount} ignored`); + } + + // Abs-path leak suppressed by same-line ignore + const sameLineContent = '| `/Users/user/dev/project` | example |'; + const { leaks: leaks2 } = checkAbsolutePathLeaks('test.md', sameLineContent); + if (leaks2.length === 0) { + pass('Abs-path with same-line ignore', 'suppressed'); + } else { + fail('Abs-path with same-line ignore', `expected 0 leaks, got ${leaks2.length}`); + } + + // Abs-path leak without ignore still detected + const unleakedContent = '| `{project-root}` | `/Users/user/dev/BMAD-METHOD` |'; + const { leaks: leaks3 } = checkAbsolutePathLeaks('test.md', unleakedContent); + if (leaks3.length > 0) { + pass('Abs-path without ignore', 'still detected'); + } else { + fail('Abs-path without ignore', 'not detected'); + } +} + +/** + * Data directory example convention + */ +async function testDataDirExamples() { + section('Data directory example convention'); + + let _testing; + try { + const mod = await import(TOOL_PATH); + _testing = mod._testing || (mod.default && mod.default._testing); + } catch { + fail('Data dir examples (import)', 'Cannot import module'); + return; + } + + const { isDataDirExample } = _testing; + + if (!isDataDirExample) { + fail('isDataDirExample', 'not exported'); + return; + } + + // EXAMPLE- prefix in data dir → skip + if (isDataDirExample('/src/workflows/agent/data/agent-validation.md', './EXAMPLE-step.md')) { + pass('EXAMPLE- in data dir', 'skipped'); + } else { + fail('EXAMPLE- in data dir', 'not skipped'); + } + + // invalid- prefix in data dir → skip + if (isDataDirExample('/src/workflows/workflow/data/standards.md', './invalid-ref.md')) { + pass('invalid- in data dir', 'skipped'); + } else { + fail('invalid- in data dir', 'not skipped'); + } + + // EXAMPLE- prefix NOT in data dir → not skipped + if (isDataDirExample('/src/workflows/workflow/steps-v/step-01.md', './EXAMPLE-step.md')) { + fail('EXAMPLE- outside data dir', 'incorrectly skipped'); + } else { + pass('EXAMPLE- outside data dir', 'not skipped'); + } + + // Normal filename in data dir → not skipped + if (isDataDirExample('/src/workflows/agent/data/agent-validation.md', './step-01.md')) { + fail('Normal ref in data dir', 'incorrectly skipped'); + } else { + pass('Normal ref in data dir', 'not skipped'); + } +} + +/** + * AC7: CI step exists in quality.yaml + */ +async function testCIIntegration() { + section('AC7: CI integration'); + + const qualityPath = path.join(__dirname, '..', '.github', 'workflows', 'quality.yaml'); + if (!fs.existsSync(qualityPath)) { + fail('quality.yaml', 'file not found'); + return; + } + + const content = fs.readFileSync(qualityPath, 'utf8'); + + if (content.includes('validate:refs') || content.includes('validate-file-refs')) { + pass('CI step', 'validate:refs found in quality.yaml'); + } else { + fail('CI step', 'validate:refs not found in quality.yaml'); + } +} + +// --- Main --- + +async function main() { + console.log(`${colors.cyan}╔═══════════════════════════════════════════════════════════╗${colors.reset}`); + console.log(`${colors.cyan}║ File Reference Validation Test Suite ║${colors.reset}`); + console.log(`${colors.cyan}╚═══════════════════════════════════════════════════════════╝${colors.reset}`); + + // Check that the tool exists (RED state: it shouldn't yet) + if (!fs.existsSync(TOOL_PATH)) { + console.log(`\n${colors.red}✗ Tool not found: ${TOOL_PATH}${colors.reset}`); + console.log(`${colors.yellow} This is expected in RED state — Dev needs to create the tool.${colors.reset}\n`); + totalTests++; + failures.push({ name: 'Tool exists', reason: `${TOOL_PATH} not found` }); + } + + // Run all test suites + await testExports(); + await testPathMapping(); + await testModuleAutoDetect(); + await testExternalRefDetection(); + await testBracketedPlaceholders(); + await testAbsolutePathLeaks(); + await testResolvableSkipLogic(); + await testInlineIgnore(); + await testDataDirExamples(); + await testCLIExitCodes(); + await testNpmScript(); + await testCIIntegration(); + await testLiveBaseline(); + + // Summary + console.log(`\n${colors.cyan}═══════════════════════════════════════════════════════════${colors.reset}`); + console.log(`${colors.cyan}Test Results:${colors.reset}`); + console.log(` Total: ${totalTests}`); + console.log(` Passed: ${colors.green}${passedTests}${colors.reset}`); + console.log(` Failed: ${passedTests === totalTests ? colors.green : colors.red}${totalTests - passedTests}${colors.reset}`); + console.log(`${colors.cyan}═══════════════════════════════════════════════════════════${colors.reset}\n`); + + if (failures.length > 0) { + console.log(`${colors.red}FAILED TESTS:${colors.reset}\n`); + for (const f of failures) { + console.log(`${colors.red}✗${colors.reset} ${f.name}`); + console.log(` ${f.reason}\n`); + } + process.exitCode = 1; + return; + } + + console.log(`${colors.green}All tests passed!${colors.reset}\n`); +} + +main().catch((error) => { + console.error(`${colors.red}Fatal error:${colors.reset}`, error); + process.exitCode = 1; +}); diff --git a/tools/validate-file-refs.mjs b/tools/validate-file-refs.mjs new file mode 100644 index 0000000..dd5ac53 --- /dev/null +++ b/tools/validate-file-refs.mjs @@ -0,0 +1,657 @@ +/** + * File Reference Validator for bmad-builder + * + * Validates cross-file references in BMB source files (agents, workflows, steps). + * Catches broken file paths, missing referenced files, and absolute path leaks. + * Auto-detects own module code from module.yaml and skips external module refs. + * + * What it checks: + * - {project-root}/_bmad/bmb/ references → src/ files + * - Relative path references (./file.md, ../data/file.csv) + * - Frontmatter path variables (nextStepFile, stepTemplate, etc.) + * - CSV workflow-file column → source files + * - Absolute path leak detection (/Users/, /home/, C:\) + * + * What it skips: + * - External module refs ({project-root}/_bmad/core/, bmm/, etc.) + * - Relative refs resolving outside src/ (../../../../core/) + * - Install-generated files (config.yaml, docs/ KBs) + * - Template placeholders ([N], [name], [template]) + * - Runtime variables ({output_folder}, {bmb_creations_output_folder}, etc.) + * - {{mustache}} template variables + * - Lines with comment + * - Lines after comment + * - Refs with EXAMPLE- or invalid- filename prefix in data/ directories + * + * Usage: + * node tools/validate-file-refs.mjs # Warning mode (exit 0) + * node tools/validate-file-refs.mjs --strict # Fail on issues (exit 1) + * node tools/validate-file-refs.mjs --verbose # Show all checked refs + * node tools/validate-file-refs.mjs --include-external # Show external refs as INFO + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'yaml'; +import { parse as parseCsv } from 'csv-parse/sync'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// --- CLI Parsing --- + +const flags = new Set(process.argv.slice(2).filter((a) => a.startsWith('--'))); +const VERBOSE = flags.has('--verbose'); +const STRICT = flags.has('--strict'); +const INCLUDE_EXTERNAL = flags.has('--include-external'); + +const PROJECT_ROOT = path.resolve(__dirname, '..'); +const SRC_DIR = path.join(PROJECT_ROOT, 'src'); + +// --- Constants --- + +const SCAN_EXTENSIONS = new Set(['.yaml', '.yml', '.md', '.csv']); +const SKIP_DIRS = new Set(['node_modules', '_module-installer', '.git']); + +// Install-generated paths that don't exist in the source tree +const INSTALL_ONLY_PATHS = ['docs/']; +const INSTALL_GENERATED_FILES = ['config.yaml']; + +// Runtime variables that cannot be resolved statically +const UNRESOLVABLE_VARS = [ + '{output_folder}', + '{value}', + '{timestamp}', + '{config_source}:', + '{installed_path}', + '{shared_path}', + '{planning_artifacts}', + '{research_topic}', + '{user_name}', + '{communication_language}', + '{bmb_creations_output_folder}', + '{new_workflow_name}', + '{module_code}', + '{workflow_name}', + '{project_name}', + '{datetime}', + '{date}', + '{count}', + '{epic_number}', + '{sidecar-folder}', + '{targetWorkflowPath}', + '{workflow_folder_path}', + '{module_output_folder}', +]; + +// Frontmatter keys that contain file paths +const FRONTMATTER_PATH_KEYS = new Set([ + 'nextStepFile', + 'thisStepFile', + 'nextStep', + 'continueStepFile', + 'skipToStepFile', + 'altStepFile', + 'workflowFile', + 'stepTemplate', + 'agentTemplate', + 'agentArch', + 'menuHandlingStandards', + 'outputFormatStandards', + 'frontmatterStandards', + 'brainstormContext', + 'workflowExamples', + 'templateFile', + 'outputFile', + 'workflowPlanFile', + 'validationReportFile', + 'advancedElicitationTask', + 'partyModeWorkflow', +]); + +// Regex patterns +const PROJECT_ROOT_REF = /\{project-root\}\/_bmad\/([^\s'"<>})\]`]+)/g; +const RELATIVE_PATH_QUOTED = /['"](\.\.\/?[^'"]+\.(?:md|yaml|yml|xml|json|csv|txt))['"]/g; +const RELATIVE_PATH_DOT = /['"](\.\/[^'"]+\.(?:md|yaml|yml|xml|json|csv|txt))['"]/g; +const ABS_PATH_LEAK = /(?:\/Users\/|\/home\/|[A-Z]:\\)/; + +// Inline suppression comments +const IGNORE_COMMENT = //; +const IGNORE_NEXT_LINE_COMMENT = //; + +// Data directory example filename prefixes (auto-skipped) +const DATA_DIR_EXAMPLE_PREFIXES = ['EXAMPLE-', 'invalid-']; + +// --- Helpers --- + +function escapeAnnotation(str) { + return str.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A'); +} + +function stripCodeBlocks(content) { + return content.replaceAll(/```[\s\S]*?```/g, (m) => m.replaceAll(/[^\n]/g, '')); +} + +function offsetToLine(content, offset) { + let line = 1; + for (let i = 0; i < offset && i < content.length; i++) { + if (content[i] === '\n') line++; + } + return line; +} + +/** + * Detect whether a reference contains [bracketed] template placeholders. + * Matches [non-numeric] content like [N], [name], [template], [output]. + * Does NOT match [1], [2] (numbered list items). + */ +function isBracketedPlaceholder(refStr) { + return /\[[^\]]*[A-Za-z][^\]]*\]/.test(refStr); +} + +/** + * Check if a line should be ignored via inline suppression comments. + * Supports on the same line + * and on the previous line. + */ +function isLineIgnored(contentLines, lineNumber) { + const idx = lineNumber - 1; + if (idx < 0 || idx >= contentLines.length) return false; + if (IGNORE_COMMENT.test(contentLines[idx])) return true; + if (idx > 0 && IGNORE_NEXT_LINE_COMMENT.test(contentLines[idx - 1])) return true; + return false; +} + +/** + * Check if a reference uses a documentation-example filename convention + * inside a data/ directory. Files prefixed with EXAMPLE- or invalid- + * are treated as illustrative and skipped. + */ +function isDataDirExample(filePath, refStr) { + if (!filePath.includes('/data/')) return false; + const basename = path.basename(refStr); + return DATA_DIR_EXAMPLE_PREFIXES.some((prefix) => basename.startsWith(prefix)); +} + +/** + * Check if a reference string is statically resolvable. + */ +function isResolvable(refStr) { + if (refStr.includes('{{')) return false; + if (isBracketedPlaceholder(refStr)) return false; + for (const v of UNRESOLVABLE_VARS) { + if (refStr.includes(v)) return false; + } + return true; +} + +/** + * Check if a cleaned path is install-generated (not in source tree). + */ +function isInstallGenerated(cleanedPath) { + for (const prefix of INSTALL_ONLY_PATHS) { + if (cleanedPath.startsWith(prefix)) return true; + } + const basename = path.basename(cleanedPath); + for (const generated of INSTALL_GENERATED_FILES) { + if (basename === generated) return true; + } + return false; +} + +/** + * Read module.yaml to detect the module's own code (e.g., "bmb"). + */ +function detectModuleCode(srcDir) { + const moduleYamlPath = path.join(srcDir, 'module.yaml'); + if (!fs.existsSync(moduleYamlPath)) return null; + try { + const content = fs.readFileSync(moduleYamlPath, 'utf-8'); + const data = yaml.parse(content); + return data?.code || null; + } catch { + return null; + } +} + +/** + * Check if a {project-root}/_bmad/ ref is external (different module). + */ +function isExternalRef(refStr, moduleCode) { + if (!moduleCode) return false; + const match = refStr.match(/\{project-root\}\/_bmad\/([^/]+)\//); + if (!match) return false; + const refModule = match[1]; + // _memory, _config are special framework paths, not external modules + if (refModule.startsWith('_')) return false; + return refModule !== moduleCode; +} + +/** + * Map {project-root}/_bmad/bmb/ paths to src/ paths. + * Returns null for install-generated or external refs. + */ +function mapInstalledToSource(refPath, moduleCode) { + const match = refPath.match(/\{project-root\}\/_bmad\/([^/]+)\/(.*)/); + if (!match) return null; + + const refModule = match[1]; + const subPath = match[2]; + + // External module — skip + if (moduleCode && refModule !== moduleCode) return null; + + // Install-generated — skip + if (isInstallGenerated(subPath)) return null; + + return path.join(SRC_DIR, subPath); +} + +// --- File Discovery --- + +function getSourceFiles(dir) { + const files = []; + function walk(currentDir) { + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) continue; + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.isFile() && SCAN_EXTENSIONS.has(path.extname(entry.name))) { + files.push(fullPath); + } + } + } + walk(dir); + return files; +} + +// --- Reference Extraction --- + +/** + * Extract references from YAML files. + */ +function extractYamlRefs(filePath, content) { + const refs = []; + let doc; + try { + doc = yaml.parseDocument(content); + } catch { + return refs; + } + + function checkValue(value, range, keyPath) { + if (typeof value !== 'string') return; + if (!isResolvable(value)) return; + + const line = range ? offsetToLine(content, range[0]) : undefined; + + // {project-root}/_bmad/ refs + const prMatch = value.match(/\{project-root\}\/_bmad\/[^\s'"<>})\]`]+/); + if (prMatch) { + refs.push({ file: filePath, raw: prMatch[0], type: 'project-root', line, key: keyPath }); + } + + // Relative paths + const relMatch = value.match(/^\.\.?\/[^\s'"<>})\]`]+\.(?:md|yaml|yml|xml|json|csv|txt)$/); + if (relMatch) { + refs.push({ file: filePath, raw: relMatch[0], type: 'relative', line, key: keyPath }); + } + } + + function walkNode(node, keyPath) { + if (!node) return; + if (yaml.isMap(node)) { + for (const item of node.items) { + const key = item.key && item.key.value !== undefined ? item.key.value : '?'; + const childPath = keyPath ? `${keyPath}.${key}` : String(key); + walkNode(item.value, childPath); + } + } else if (yaml.isSeq(node)) { + for (const [i, item] of node.items.entries()) { + walkNode(item, `${keyPath}[${i}]`); + } + } else if (yaml.isScalar(node)) { + checkValue(node.value, node.range, keyPath); + } + } + + walkNode(doc.contents, ''); + return refs; +} + +/** + * Extract references from markdown files (frontmatter + body). + */ +function extractMarkdownRefs(filePath, content) { + const refs = []; + + // Parse frontmatter + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (fmMatch) { + try { + const fmData = yaml.parse(fmMatch[1]); + if (fmData && typeof fmData === 'object') { + for (const [key, value] of Object.entries(fmData)) { + if (typeof value !== 'string') continue; + if (!isResolvable(value)) continue; + + if (FRONTMATTER_PATH_KEYS.has(key)) { + const line = offsetToLine(content, content.indexOf(`${key}:`)); + if (value.includes('{project-root}/_bmad/')) { + refs.push({ file: filePath, raw: value, type: 'project-root', line, key }); + } else if (value.startsWith('./') || value.startsWith('../')) { + refs.push({ file: filePath, raw: value, type: 'relative', line, key }); + } + } + } + } + } catch { + // Unparseable frontmatter + } + } + + // Body references (after stripping code blocks) + const stripped = stripCodeBlocks(content); + + // {project-root}/_bmad/ refs in body text + PROJECT_ROOT_REF.lastIndex = 0; + let match; + while ((match = PROJECT_ROOT_REF.exec(stripped)) !== null) { + const raw = `{project-root}/_bmad/${match[1]}`; + if (!isResolvable(raw)) continue; + // Skip if already captured from frontmatter + if (refs.some((r) => r.raw === raw && r.file === filePath)) continue; + refs.push({ + file: filePath, + raw, + type: 'project-root', + line: offsetToLine(stripped, match.index), + }); + } + + // Quoted relative paths in body + for (const regex of [RELATIVE_PATH_QUOTED, RELATIVE_PATH_DOT]) { + regex.lastIndex = 0; + while ((match = regex.exec(stripped)) !== null) { + const raw = match[1]; + if (!isResolvable(raw)) continue; + if (refs.some((r) => r.raw === raw && r.file === filePath)) continue; + refs.push({ + file: filePath, + raw, + type: 'relative', + line: offsetToLine(stripped, match.index), + }); + } + } + + return refs; +} + +/** + * Extract workflow-file references from CSV files. + */ +function extractCsvRefs(filePath, content) { + const refs = []; + let records; + try { + records = parseCsv(content, { + columns: true, + skip_empty_lines: true, + relax_column_count: true, + }); + } catch { + return refs; + } + + const firstRecord = records[0]; + if (!firstRecord || !('workflow-file' in firstRecord)) return refs; + + for (const [i, record] of records.entries()) { + const raw = record['workflow-file']; + if (!raw || raw.trim() === '') continue; + if (!isResolvable(raw)) continue; + + const line = i + 2; // header + 0-based index + 1 + // CSV uses bare _bmad/ prefix + refs.push({ file: filePath, raw, type: 'project-root', line }); + } + + return refs; +} + +/** + * Detect absolute path leaks in file content. + */ +function checkAbsolutePathLeaks(filePath, content) { + const leaks = []; + let ignoredCount = 0; + const stripped = stripCodeBlocks(content); + const lines = stripped.split('\n'); + const originalLines = content.split('\n'); + + for (const [i, line] of lines.entries()) { + if (ABS_PATH_LEAK.test(line)) { + if (isLineIgnored(originalLines, i + 1)) { + ignoredCount++; + } else { + leaks.push({ file: filePath, line: i + 1, content: line.trim().slice(0, 100) }); + } + } + } + return { leaks, ignoredCount }; +} + +// --- Reference Resolution --- + +function resolveRef(ref, moduleCode) { + if (ref.type === 'project-root') { + // Handle bare _bmad/ prefix (from CSV) + let refPath = ref.raw; + if (!refPath.includes('{project-root}') && refPath.startsWith('_bmad/')) { + refPath = `{project-root}/${refPath}`; + } + return mapInstalledToSource(refPath, moduleCode); + } + + if (ref.type === 'relative') { + const resolved = path.resolve(path.dirname(ref.file), ref.raw); + // Skip if resolves outside src/ + if (!resolved.startsWith(SRC_DIR)) return null; + return resolved; + } + + return null; +} + +// --- Exports for testing --- + +export const _testing = { + mapInstalledToSource, + isResolvable, + extractYamlRefs, + extractMarkdownRefs, + extractCsvRefs, + checkAbsolutePathLeaks, + detectModuleCode, + isExternalRef, + isBracketedPlaceholder, + isInstallGenerated, + isLineIgnored, + isDataDirExample, + resolveRef, + getSourceFiles, + stripCodeBlocks, + offsetToLine, + SRC_DIR, + PROJECT_ROOT, +}; + +// --- Main --- + +const _isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename); +if (_isMain) { + const moduleCode = detectModuleCode(SRC_DIR); + console.log(`\nValidating file references in: ${path.relative(process.cwd(), SRC_DIR)}/`); + console.log(`Mode: ${STRICT ? 'STRICT (exit 1 on issues)' : 'WARNING (exit 0)'}${VERBOSE ? ' + VERBOSE' : ''}`); + console.log(`Module: ${moduleCode || '(unknown)'}\n`); + + const files = getSourceFiles(SRC_DIR); + console.log(`Files to scan: ${files.length}\n`); + + let totalRefs = 0; + let brokenRefs = 0; + let totalLeaks = 0; + let externalRefs = 0; + let ignoredRefs = 0; + let filesWithIssues = 0; + const refsByType = { 'project-root': 0, relative: 0 }; + const allIssues = []; + + for (const filePath of files) { + const relativePath = path.relative(PROJECT_ROOT, filePath); + const content = fs.readFileSync(filePath, 'utf-8'); + const ext = path.extname(filePath); + const contentLines = content.split('\n'); + + // Extract references by file type + let refs; + if (ext === '.yaml' || ext === '.yml') { + refs = extractYamlRefs(filePath, content); + } else if (ext === '.csv') { + refs = extractCsvRefs(filePath, content); + } else { + refs = extractMarkdownRefs(filePath, content); + } + + // Filter inline-ignored and data-dir example refs + refs = refs.filter((ref) => { + if (ref.line && isLineIgnored(contentLines, ref.line)) { + ignoredRefs++; + return false; + } + if (isDataDirExample(filePath, ref.raw)) { + ignoredRefs++; + return false; + } + return true; + }); + + const broken = []; + const external = []; + + for (const ref of refs) { + totalRefs++; + if (ref.type in refsByType) refsByType[ref.type]++; + + // Check for external module refs + if (ref.type === 'project-root' && isExternalRef(ref.raw, moduleCode)) { + externalRefs++; + if (INCLUDE_EXTERNAL) { + external.push({ ref, module: ref.raw.match(/\{project-root\}\/_bmad\/([^/]+)\//)?.[1] }); + } + continue; + } + + const resolved = resolveRef(ref, moduleCode); + if (resolved === null) continue; // Skipped (install-generated, external, outside src) + + if (!fs.existsSync(resolved)) { + broken.push({ ref, resolved: path.relative(PROJECT_ROOT, resolved) }); + brokenRefs++; + } + } + + // Absolute path leaks + const { leaks, ignoredCount: leakIgnored } = checkAbsolutePathLeaks(filePath, content); + totalLeaks += leaks.length; + ignoredRefs += leakIgnored; + + const hasIssues = broken.length > 0 || leaks.length > 0; + const hasInfo = external.length > 0; + + if (hasIssues) { + filesWithIssues++; + console.log(`\n${relativePath}`); + + for (const { ref, resolved } of broken) { + const location = ref.line ? `line ${ref.line}` : ref.key ? `key: ${ref.key}` : ''; + console.log(` [BROKEN] ${ref.raw}${location ? ` (${location})` : ''}`); + console.log(` Target not found: ${resolved}`); + allIssues.push({ file: relativePath, line: ref.line || 1, ref: ref.raw, issue: 'broken ref' }); + if (process.env.GITHUB_ACTIONS) { + const line = ref.line || 1; + console.log(`::warning file=${relativePath},line=${line}::${escapeAnnotation(`Broken reference: ${ref.raw} → ${resolved}`)}`); + } + } + + for (const leak of leaks) { + console.log(` [ABS-PATH] Line ${leak.line}: ${leak.content}`); + allIssues.push({ file: relativePath, line: leak.line, ref: leak.content, issue: 'abs-path' }); + if (process.env.GITHUB_ACTIONS) { + console.log(`::warning file=${relativePath},line=${leak.line}::${escapeAnnotation(`Absolute path leak: ${leak.content}`)}`); + } + } + } else if (VERBOSE && refs.length > 0) { + console.log(` [OK] ${relativePath} (${refs.length} refs)`); + } + + if (hasInfo) { + for (const { ref, module: mod } of external) { + console.log(` [INFO] External ref to ${mod}: ${ref.raw}`); + } + } + } + + // Summary + const totalIssues = brokenRefs + totalLeaks; + console.log(`\n${'─'.repeat(60)}`); + console.log(`\nSummary:`); + console.log(` Files scanned: ${files.length}`); + console.log(` References checked: ${totalRefs}`); + console.log(` project-root refs: ${refsByType['project-root']}`); + console.log(` relative refs: ${refsByType.relative}`); + console.log(` External (skipped): ${externalRefs}`); + if (ignoredRefs > 0) { + console.log(` Suppressed (ignored): ${ignoredRefs}`); + } + console.log(` Broken references: ${brokenRefs}`); + console.log(` Absolute path leaks: ${totalLeaks}`); + console.log( + ` Total issues: ${totalIssues} / ${totalRefs} refs (${totalRefs > 0 ? ((totalIssues / totalRefs) * 100).toFixed(1) : 0}%)`, + ); + + const hasIssues = totalIssues > 0; + + if (hasIssues) { + console.log(` Files affected: ${filesWithIssues}`); + if (STRICT) { + console.log(`\n [STRICT MODE] Exiting with failure.`); + } else { + console.log(`\n Run with --strict to treat warnings as errors.`); + } + } else { + console.log(`\n All file references valid!`); + } + console.log(''); + + // GHA step summary + if (process.env.GITHUB_STEP_SUMMARY) { + let summary = '## BMB File Reference Validation\n\n'; + if (allIssues.length > 0) { + summary += '| File | Line | Reference | Issue |\n'; + summary += '|------|------|-----------|-------|\n'; + for (const iss of allIssues) { + summary += `| ${iss.file} | ${iss.line} | ${iss.ref} | ${iss.issue} |\n`; + } + summary += '\n'; + } + summary += `**${files.length} files scanned, ${totalRefs} references checked, ${brokenRefs + totalLeaks} issues found**\n`; + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); + } + + process.exit(hasIssues && STRICT ? 1 : 0); +}