feat: extend validate-file-refs.js to scan CSV workflow-file references
Add CSV file reference extraction to the Layer 1 validation pipeline, preventing broken _bmad/ workflow-file paths in module-help.csv files. Closes the gap identified after PR #1529 where CSV references were unvalidated despite being a source of repeat community issues. Refs: #1519
This commit is contained in:
parent
a8cda7c6fa
commit
1cc2fd1096
|
|
@ -147,6 +147,15 @@ Keep messages under 72 characters. Each commit = one logical change.
|
||||||
- Everything is natural language (markdown) — no code in core framework
|
- Everything is natural language (markdown) — no code in core framework
|
||||||
- Use BMad modules for domain-specific features
|
- Use BMad modules for domain-specific features
|
||||||
- Validate YAML schemas: `npm run validate:schemas`
|
- Validate YAML schemas: `npm run validate:schemas`
|
||||||
|
- Validate file references: `npm run validate:refs`
|
||||||
|
|
||||||
|
### File-Pattern-to-Validator Mapping
|
||||||
|
|
||||||
|
| File Pattern | Validator | Extraction Function |
|
||||||
|
| ------------ | --------- | ------------------- |
|
||||||
|
| `*.yaml`, `*.yml` | `validate-file-refs.js` | `extractYamlRefs` |
|
||||||
|
| `*.md`, `*.xml` | `validate-file-refs.js` | `extractMarkdownRefs` |
|
||||||
|
| `*.csv` | `validate-file-refs.js` | `extractCsvRefs` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
"test": "npm run test:schemas && npm run test:install && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check",
|
"test": "npm run test:schemas && npm run test:install && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check",
|
||||||
"test:coverage": "c8 --reporter=text --reporter=html npm run test:schemas",
|
"test:coverage": "c8 --reporter=text --reporter=html npm run test:schemas",
|
||||||
"test:install": "node test/test-installation-components.js",
|
"test:install": "node test/test-installation-components.js",
|
||||||
|
"test:refs": "node test/test-file-refs-csv.js",
|
||||||
"test:schemas": "node test/test-agent-schema.js",
|
"test:schemas": "node test/test-agent-schema.js",
|
||||||
"validate:refs": "node tools/validate-file-refs.js",
|
"validate:refs": "node tools/validate-file-refs.js",
|
||||||
"validate:schemas": "node tools/validate-agent-schema.js"
|
"validate:schemas": "node tools/validate-agent-schema.js"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
module,phase,name,workflow-file,description
|
||||||
|
bmm,anytime,Document,,Analyze project
|
||||||
|
bmm,1-analysis,Brainstorm,,Brainstorm ideas
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
module,phase,name,workflow-file,description
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
name,code,description,agent
|
||||||
|
brainstorm,BSP,"Generate ideas",analyst
|
||||||
|
party,PM,"Multi-agent",facilitator
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
module,phase,name,workflow-file,description
|
||||||
|
bmm,anytime,Template Var,{output_folder}/something.md,Has unresolvable template var
|
||||||
|
bmm,anytime,Normal Ref,_bmad/core/tasks/help.md,Normal resolvable ref
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs,
|
||||||
|
bmm,anytime,Document Project,DP,,_bmad/bmm/workflows/document-project/workflow.yaml,bmad-bmm-document-project,false,analyst,Create Mode,"Analyze project",project-knowledge,*,
|
||||||
|
bmm,1-analysis,Brainstorm Project,BP,10,_bmad/core/workflows/brainstorming/workflow.md,bmad-brainstorming,false,analyst,data=template.md,"Brainstorming",planning_artifacts,"session",
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs
|
||||||
|
core,anytime,Brainstorming,BSP,,_bmad/core/workflows/brainstorming/workflow.md,bmad-brainstorming,false,analyst,,"Generate ideas",{output_folder}/brainstorming.md,
|
||||||
|
core,anytime,Party Mode,PM,,_bmad/core/workflows/party-mode/workflow.md,bmad-party-mode,false,facilitator,,"Multi-agent discussion",,
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
name,workflow-file,description
|
||||||
|
test,_bmad/core/tasks/help.md,A test entry
|
||||||
|
|
|
@ -0,0 +1,133 @@
|
||||||
|
/**
|
||||||
|
* CSV File Reference Extraction Test Runner
|
||||||
|
*
|
||||||
|
* Tests extractCsvRefs() from validate-file-refs.js against fixtures.
|
||||||
|
* Verifies correct extraction of workflow-file references from CSV files.
|
||||||
|
*
|
||||||
|
* Usage: node test/test-file-refs-csv.js
|
||||||
|
* Exit codes: 0 = all tests pass, 1 = test failures
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { extractCsvRefs } = require('../tools/validate-file-refs.js');
|
||||||
|
|
||||||
|
// ANSI color codes
|
||||||
|
const colors = {
|
||||||
|
reset: '\u001B[0m',
|
||||||
|
green: '\u001B[32m',
|
||||||
|
red: '\u001B[31m',
|
||||||
|
cyan: '\u001B[36m',
|
||||||
|
dim: '\u001B[2m',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIXTURES = path.join(__dirname, 'fixtures/file-refs-csv');
|
||||||
|
|
||||||
|
let totalTests = 0;
|
||||||
|
let passedTests = 0;
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
totalTests++;
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
passedTests++;
|
||||||
|
console.log(` ${colors.green}\u2713${colors.reset} ${name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ${colors.red}\u2717${colors.reset} ${name} ${colors.red}${error.message}${colors.reset}`);
|
||||||
|
failures.push({ name, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFixture(relativePath) {
|
||||||
|
const fullPath = path.join(FIXTURES, relativePath);
|
||||||
|
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||||
|
return { fullPath, content };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Valid fixtures ---
|
||||||
|
|
||||||
|
console.log(`\n${colors.cyan}CSV File Reference Extraction Tests${colors.reset}\n`);
|
||||||
|
console.log(`${colors.cyan}Valid fixtures${colors.reset}`);
|
||||||
|
|
||||||
|
test('bmm-style.csv: extracts workflow-file refs with trailing commas', () => {
|
||||||
|
const { fullPath, content } = loadFixture('valid/bmm-style.csv');
|
||||||
|
const refs = extractCsvRefs(fullPath, content);
|
||||||
|
assert(refs.length === 2, `Expected 2 refs, got ${refs.length}`);
|
||||||
|
assert(refs[0].raw === '_bmad/bmm/workflows/document-project/workflow.yaml', `Wrong raw[0]: ${refs[0].raw}`);
|
||||||
|
assert(refs[1].raw === '_bmad/core/workflows/brainstorming/workflow.md', `Wrong raw[1]: ${refs[1].raw}`);
|
||||||
|
assert(refs[0].type === 'project-root', `Wrong type: ${refs[0].type}`);
|
||||||
|
assert(refs[0].line === 2, `Wrong line for row 0: ${refs[0].line}`);
|
||||||
|
assert(refs[1].line === 3, `Wrong line for row 1: ${refs[1].line}`);
|
||||||
|
assert(refs[0].file === fullPath, 'Wrong file path');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core-style.csv: extracts refs from core module-help format', () => {
|
||||||
|
const { fullPath, content } = loadFixture('valid/core-style.csv');
|
||||||
|
const refs = extractCsvRefs(fullPath, content);
|
||||||
|
assert(refs.length === 2, `Expected 2 refs, got ${refs.length}`);
|
||||||
|
assert(refs[0].raw === '_bmad/core/workflows/brainstorming/workflow.md', `Wrong raw[0]: ${refs[0].raw}`);
|
||||||
|
assert(refs[1].raw === '_bmad/core/workflows/party-mode/workflow.md', `Wrong raw[1]: ${refs[1].raw}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minimal.csv: extracts refs from minimal 3-column CSV', () => {
|
||||||
|
const { fullPath, content } = loadFixture('valid/minimal.csv');
|
||||||
|
const refs = extractCsvRefs(fullPath, content);
|
||||||
|
assert(refs.length === 1, `Expected 1 ref, got ${refs.length}`);
|
||||||
|
assert(refs[0].raw === '_bmad/core/tasks/help.md', `Wrong raw: ${refs[0].raw}`);
|
||||||
|
assert(refs[0].line === 2, `Wrong line: ${refs[0].line}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Invalid fixtures ---
|
||||||
|
|
||||||
|
console.log(`\n${colors.cyan}Invalid fixtures (expect 0 refs)${colors.reset}`);
|
||||||
|
|
||||||
|
test('no-workflow-column.csv: returns 0 refs when workflow-file column missing', () => {
|
||||||
|
const { fullPath, content } = loadFixture('invalid/no-workflow-column.csv');
|
||||||
|
const refs = extractCsvRefs(fullPath, content);
|
||||||
|
assert(refs.length === 0, `Expected 0 refs, got ${refs.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty-data.csv: returns 0 refs when CSV has header only', () => {
|
||||||
|
const { fullPath, content } = loadFixture('invalid/empty-data.csv');
|
||||||
|
const refs = extractCsvRefs(fullPath, content);
|
||||||
|
assert(refs.length === 0, `Expected 0 refs, got ${refs.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all-empty-workflow.csv: returns 0 refs when all workflow-file cells empty', () => {
|
||||||
|
const { fullPath, content } = loadFixture('invalid/all-empty-workflow.csv');
|
||||||
|
const refs = extractCsvRefs(fullPath, content);
|
||||||
|
assert(refs.length === 0, `Expected 0 refs, got ${refs.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unresolvable-vars.csv: filters out template variables, keeps normal refs', () => {
|
||||||
|
const { fullPath, content } = loadFixture('invalid/unresolvable-vars.csv');
|
||||||
|
const refs = extractCsvRefs(fullPath, content);
|
||||||
|
assert(refs.length === 1, `Expected 1 ref, got ${refs.length}`);
|
||||||
|
assert(refs[0].raw === '_bmad/core/tasks/help.md', `Wrong raw: ${refs[0].raw}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Summary ---
|
||||||
|
|
||||||
|
console.log(`\n${colors.cyan}${'═'.repeat(55)}${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}${'═'.repeat(55)}${colors.reset}\n`);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.log(`${colors.red}FAILED TESTS:${colors.reset}\n`);
|
||||||
|
for (const failure of failures) {
|
||||||
|
console.log(`${colors.red}\u2717${colors.reset} ${failure.name}`);
|
||||||
|
console.log(` ${failure.message}\n`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${colors.green}All tests passed!${colors.reset}\n`);
|
||||||
|
process.exit(0);
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
|
const { parse: parseCsv } = require('csv-parse/sync');
|
||||||
|
|
||||||
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
||||||
const SRC_DIR = path.join(PROJECT_ROOT, 'src');
|
const SRC_DIR = path.join(PROJECT_ROOT, 'src');
|
||||||
|
|
@ -38,7 +39,7 @@ const STRICT = process.argv.includes('--strict');
|
||||||
// --- Constants ---
|
// --- Constants ---
|
||||||
|
|
||||||
// File extensions to scan
|
// File extensions to scan
|
||||||
const SCAN_EXTENSIONS = new Set(['.yaml', '.yml', '.md', '.xml']);
|
const SCAN_EXTENSIONS = new Set(['.yaml', '.yml', '.md', '.xml', '.csv']);
|
||||||
|
|
||||||
// Skip directories
|
// Skip directories
|
||||||
const SKIP_DIRS = new Set(['node_modules', '_module-installer', '.git']);
|
const SKIP_DIRS = new Set(['node_modules', '_module-installer', '.git']);
|
||||||
|
|
@ -292,6 +293,39 @@ function extractMarkdownRefs(filePath, content) {
|
||||||
return refs;
|
return refs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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; // Skip unparseable CSV
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process if workflow-file column exists
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Line = header (1) + data row index (0-based) + 1
|
||||||
|
const line = i + 2;
|
||||||
|
refs.push({ file: filePath, raw, type: 'project-root', line });
|
||||||
|
}
|
||||||
|
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Reference Resolution ---
|
// --- Reference Resolution ---
|
||||||
|
|
||||||
function resolveRef(ref) {
|
function resolveRef(ref) {
|
||||||
|
|
@ -351,8 +385,12 @@ function checkAbsolutePathLeaks(filePath, content) {
|
||||||
return leaks;
|
return leaks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Exports (for testing) ---
|
||||||
|
module.exports = { extractCsvRefs };
|
||||||
|
|
||||||
// --- Main ---
|
// --- Main ---
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
console.log(`\nValidating file references in: ${SRC_DIR}`);
|
console.log(`\nValidating file references in: ${SRC_DIR}`);
|
||||||
console.log(`Mode: ${STRICT ? 'STRICT (exit 1 on issues)' : 'WARNING (exit 0)'}${VERBOSE ? ' + VERBOSE' : ''}\n`);
|
console.log(`Mode: ${STRICT ? 'STRICT (exit 1 on issues)' : 'WARNING (exit 0)'}${VERBOSE ? ' + VERBOSE' : ''}\n`);
|
||||||
|
|
||||||
|
|
@ -374,6 +412,8 @@ for (const filePath of files) {
|
||||||
let refs;
|
let refs;
|
||||||
if (ext === '.yaml' || ext === '.yml') {
|
if (ext === '.yaml' || ext === '.yml') {
|
||||||
refs = extractYamlRefs(filePath, content);
|
refs = extractYamlRefs(filePath, content);
|
||||||
|
} else if (ext === '.csv') {
|
||||||
|
refs = extractCsvRefs(filePath, content);
|
||||||
} else {
|
} else {
|
||||||
refs = extractMarkdownRefs(filePath, content);
|
refs = extractMarkdownRefs(filePath, content);
|
||||||
}
|
}
|
||||||
|
|
@ -478,3 +518,4 @@ if (process.env.GITHUB_STEP_SUMMARY) {
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(hasIssues && STRICT ? 1 : 0);
|
process.exit(hasIssues && STRICT ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue