diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 67a42ac70..76fcda18e 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -76,6 +76,11 @@ const { WAVE1_VALIDATION_ARTIFACT_REGISTRY, Wave1ValidationHarness, } = require('../tools/cli/installers/lib/core/wave-1-validation-harness'); +const { + WAVE2_VALIDATION_ERROR_CODES, + WAVE2_VALIDATION_ARTIFACT_REGISTRY, + Wave2ValidationHarness, +} = require('../tools/cli/installers/lib/core/wave-2-validation-harness'); // ANSI colors const colors = { @@ -4161,6 +4166,351 @@ async function runTests() { console.log(''); + // ============================================================ + // Test 15: Wave-2 shard-doc Validation Artifact Suite + // ============================================================ + console.log(`${colors.yellow}Test Suite 15: Wave-2 shard-doc Validation Artifact Suite${colors.reset}\n`); + + const tempWave2ValidationHarnessRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-wave2-validation-suite-')); + try { + const tempProjectRoot = tempWave2ValidationHarnessRoot; + const tempBmadDir = path.join(tempProjectRoot, '_bmad'); + const tempConfigDir = path.join(tempBmadDir, '_config'); + const tempSourceTasksDir = path.join(tempProjectRoot, 'bmad-fork', 'src', 'core', 'tasks'); + const commandLabelReportPath = path.join(tempConfigDir, 'bmad-help-command-label-report.csv'); + + await fs.ensureDir(tempConfigDir); + await fs.ensureDir(tempSourceTasksDir); + + const writeCsv = async (filePath, columns, rows) => { + const buildCsvLine = (values) => + values + .map((value) => { + const text = String(value ?? ''); + return text.includes(',') || text.includes('"') ? `"${text.replaceAll('"', '""')}"` : text; + }) + .join(','); + const lines = [columns.join(','), ...rows.map((row) => buildCsvLine(columns.map((column) => row[column] ?? '')))]; + await fs.writeFile(filePath, `${lines.join('\n')}\n`, 'utf8'); + }; + + const commandLabelReportColumns = [ + 'surface', + 'canonicalId', + 'rawCommandValue', + 'displayedCommandLabel', + 'normalizedDisplayedLabel', + 'rowCountForCanonicalId', + 'authoritySourceType', + 'authoritySourcePath', + 'status', + 'failureReason', + ]; + const commandLabelReportRows = [ + { + surface: '_bmad/_config/bmad-help.csv', + canonicalId: 'bmad-shard-doc', + rawCommandValue: 'bmad-shard-doc', + displayedCommandLabel: '/bmad-shard-doc', + normalizedDisplayedLabel: '/bmad-shard-doc', + rowCountForCanonicalId: '1', + authoritySourceType: 'sidecar', + authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + status: 'PASS', + failureReason: '', + }, + ]; + + await fs.writeFile( + path.join(tempSourceTasksDir, 'shard-doc.artifact.yaml'), + yaml.stringify({ + schemaVersion: 1, + canonicalId: 'bmad-shard-doc', + artifactType: 'task', + module: 'core', + sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml', + displayName: 'Shard Document', + description: 'Split large markdown documents into smaller files by section with an index.', + dependencies: { + requires: [], + }, + }), + 'utf8', + ); + await fs.writeFile( + path.join(tempSourceTasksDir, 'shard-doc.xml'), + 'Split markdown docs\n', + 'utf8', + ); + + await writeCsv( + path.join(tempConfigDir, 'task-manifest.csv'), + [...TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS, ...TASK_MANIFEST_WAVE1_ADDITIVE_COLUMNS], + [ + { + name: 'shard-doc', + displayName: 'Shard Document', + description: 'Split large markdown documents into smaller files by section with an index.', + module: 'core', + path: '_bmad/core/tasks/shard-doc.xml', + standalone: 'true', + legacyName: 'shard-doc', + canonicalId: 'bmad-shard-doc', + authoritySourceType: 'sidecar', + authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + }, + ], + ); + await writeCsv( + path.join(tempConfigDir, 'bmad-help.csv'), + [...HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS, ...HELP_CATALOG_WAVE1_ADDITIVE_COLUMNS], + [ + { + module: 'core', + phase: 'anytime', + name: 'Shard Document', + code: 'SD', + sequence: '', + 'workflow-file': '_bmad/core/tasks/shard-doc.xml', + command: 'bmad-shard-doc', + required: 'false', + 'agent-name': '', + 'agent-command': '', + 'agent-display-name': '', + 'agent-title': '', + options: '', + description: 'Split large markdown documents into smaller files by section with an index.', + 'output-location': '', + outputs: '', + }, + ], + ); + await writeCsv( + path.join(tempConfigDir, 'canonical-aliases.csv'), + [ + 'canonicalId', + 'alias', + 'aliasType', + 'authoritySourceType', + 'authoritySourcePath', + 'rowIdentity', + 'normalizedAliasValue', + 'rawIdentityHasLeadingSlash', + 'resolutionEligibility', + ], + [ + { + canonicalId: 'bmad-shard-doc', + alias: 'bmad-shard-doc', + aliasType: 'canonical-id', + authoritySourceType: 'sidecar', + authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + rowIdentity: 'alias-row:bmad-shard-doc:canonical-id', + normalizedAliasValue: 'bmad-shard-doc', + rawIdentityHasLeadingSlash: 'false', + resolutionEligibility: 'canonical-id-only', + }, + { + canonicalId: 'bmad-shard-doc', + alias: 'shard-doc', + aliasType: 'legacy-name', + authoritySourceType: 'sidecar', + authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + rowIdentity: 'alias-row:bmad-shard-doc:legacy-name', + normalizedAliasValue: 'shard-doc', + rawIdentityHasLeadingSlash: 'false', + resolutionEligibility: 'legacy-name-only', + }, + { + canonicalId: 'bmad-shard-doc', + alias: '/bmad-shard-doc', + aliasType: 'slash-command', + authoritySourceType: 'sidecar', + authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + rowIdentity: 'alias-row:bmad-shard-doc:slash-command', + normalizedAliasValue: 'bmad-shard-doc', + rawIdentityHasLeadingSlash: 'true', + resolutionEligibility: 'slash-command-only', + }, + ], + ); + await writeCsv(commandLabelReportPath, commandLabelReportColumns, commandLabelReportRows); + + const authorityRecords = [ + { + recordType: 'metadata-authority', + canonicalId: 'bmad-shard-doc', + authoritativePresenceKey: 'capability:bmad-shard-doc', + authoritySourceType: 'sidecar', + authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + }, + { + recordType: 'source-body-authority', + canonicalId: 'bmad-shard-doc', + authoritativePresenceKey: 'capability:bmad-shard-doc', + authoritySourceType: 'source-xml', + authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml', + }, + ]; + + const harness = new Wave2ValidationHarness(); + const firstRun = await harness.generateAndValidate({ + projectDir: tempProjectRoot, + bmadDir: tempBmadDir, + bmadFolderName: '_bmad', + shardDocAuthorityRecords: authorityRecords, + }); + assert( + firstRun.terminalStatus === 'PASS' && firstRun.generatedArtifactCount === WAVE2_VALIDATION_ARTIFACT_REGISTRY.length, + 'Wave-2 validation harness generates and validates all required artifacts', + ); + + const artifactPathsById = new Map( + WAVE2_VALIDATION_ARTIFACT_REGISTRY.map((artifact) => [ + artifact.artifactId, + path.join(tempProjectRoot, '_bmad-output', 'planning-artifacts', artifact.relativePath), + ]), + ); + for (const [artifactId, artifactPath] of artifactPathsById.entries()) { + assert(await fs.pathExists(artifactPath), `Wave-2 validation harness outputs artifact ${artifactId}`); + } + + const firstArtifactContents = new Map(); + for (const [artifactId, artifactPath] of artifactPathsById.entries()) { + firstArtifactContents.set(artifactId, await fs.readFile(artifactPath, 'utf8')); + } + + await harness.generateAndValidate({ + projectDir: tempProjectRoot, + bmadDir: tempBmadDir, + bmadFolderName: '_bmad', + shardDocAuthorityRecords: authorityRecords, + }); + let deterministicOutputs = true; + for (const [artifactId, artifactPath] of artifactPathsById.entries()) { + const rerunContent = await fs.readFile(artifactPath, 'utf8'); + if (rerunContent !== firstArtifactContents.get(artifactId)) { + deterministicOutputs = false; + break; + } + } + assert(deterministicOutputs, 'Wave-2 validation harness outputs are byte-stable across unchanged repeated runs'); + + await fs.remove(artifactPathsById.get(8)); + try { + await harness.validateGeneratedArtifacts({ projectDir: tempProjectRoot }); + assert(false, 'Wave-2 validation harness fails when a required artifact is missing'); + } catch (error) { + assert( + error.code === WAVE2_VALIDATION_ERROR_CODES.REQUIRED_ARTIFACT_MISSING, + 'Wave-2 validation harness emits deterministic missing-artifact error code', + ); + } + + await harness.generateAndValidate({ + projectDir: tempProjectRoot, + bmadDir: tempBmadDir, + bmadFolderName: '_bmad', + shardDocAuthorityRecords: authorityRecords, + }); + + await fs.remove(commandLabelReportPath); + try { + await harness.generateValidationArtifacts({ + projectDir: tempProjectRoot, + bmadDir: tempBmadDir, + bmadFolderName: '_bmad', + shardDocAuthorityRecords: authorityRecords, + }); + assert(false, 'Wave-2 validation harness rejects missing command-label report input surface'); + } catch (error) { + assert( + error.code === WAVE2_VALIDATION_ERROR_CODES.REQUIRED_ARTIFACT_MISSING, + 'Wave-2 validation harness emits deterministic missing-input-surface error code', + ); + } + await writeCsv(commandLabelReportPath, commandLabelReportColumns, commandLabelReportRows); + + const artifactSixPath = artifactPathsById.get(6); + const artifactSixLines = (await fs.readFile(artifactSixPath, 'utf8')).split('\n'); + artifactSixLines[0] = artifactSixLines[0].replace('canonicalId', 'brokenCanonicalId'); + await fs.writeFile(artifactSixPath, artifactSixLines.join('\n'), 'utf8'); + try { + await harness.validateGeneratedArtifacts({ projectDir: tempProjectRoot }); + assert(false, 'Wave-2 validation harness rejects schema/header drift'); + } catch (error) { + assert( + error.code === WAVE2_VALIDATION_ERROR_CODES.CSV_SCHEMA_MISMATCH, + 'Wave-2 validation harness emits deterministic schema-mismatch error code', + ); + } + + await harness.generateAndValidate({ + projectDir: tempProjectRoot, + bmadDir: tempBmadDir, + bmadFolderName: '_bmad', + shardDocAuthorityRecords: authorityRecords, + }); + + const artifactEightPath = artifactPathsById.get(8); + const artifactEightRows = csv.parse(await fs.readFile(artifactEightPath, 'utf8'), { + columns: true, + skip_empty_lines: true, + }); + const artifactSixInventoryRow = artifactEightRows.find((row) => row.artifactId === '6'); + if (artifactSixInventoryRow) { + artifactSixInventoryRow.artifactPath = 'validation/wave-2/drifted-command-label-report.csv'; + } + await writeCsv( + artifactEightPath, + ['rowIdentity', 'artifactId', 'artifactPath', 'artifactType', 'required', 'rowCount', 'exists', 'schemaVersion', 'status'], + artifactEightRows, + ); + try { + await harness.validateGeneratedArtifacts({ projectDir: tempProjectRoot }); + assert(false, 'Wave-2 validation harness rejects inventory deterministic-identifier drift'); + } catch (error) { + assert( + error.code === WAVE2_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + 'Wave-2 validation harness emits deterministic inventory-row validation error code', + ); + } + + await harness.generateAndValidate({ + projectDir: tempProjectRoot, + bmadDir: tempBmadDir, + bmadFolderName: '_bmad', + shardDocAuthorityRecords: authorityRecords, + }); + + const artifactTwoPath = artifactPathsById.get(2); + const artifactTwoRows = csv.parse(await fs.readFile(artifactTwoPath, 'utf8'), { + columns: true, + skip_empty_lines: true, + }); + const filteredAuthorityRows = artifactTwoRows.filter((row) => row.recordType !== 'source-body-authority'); + await writeCsv( + artifactTwoPath, + ['rowIdentity', 'recordType', 'canonicalId', 'authoritativePresenceKey', 'authoritySourceType', 'authoritySourcePath', 'status'], + filteredAuthorityRows, + ); + try { + await harness.validateGeneratedArtifacts({ projectDir: tempProjectRoot }); + assert(false, 'Wave-2 validation harness rejects missing source-body authority records'); + } catch (error) { + assert( + error.code === WAVE2_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + 'Wave-2 validation harness emits deterministic missing-row error code', + ); + } + } catch (error) { + assert(false, 'Wave-2 shard-doc validation artifact suite setup', error.message); + } finally { + await fs.remove(tempWave2ValidationHarnessRoot); + } + + console.log(''); + // ============================================================ // Summary // ============================================================ diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index cb05c37ff..f6dde4454 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -21,6 +21,7 @@ const { } = require('./help-catalog-generator'); const { validateHelpCatalogCompatibilitySurface } = require('./projection-compatibility-validator'); const { Wave1ValidationHarness } = require('./wave-1-validation-harness'); +const { Wave2ValidationHarness } = require('./wave-2-validation-harness'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); const { CLIUtils } = require('../../../lib/cli-utils'); const { ManifestGenerator } = require('./manifest-generator'); @@ -59,7 +60,9 @@ class Installer { this.helpCatalogCommandLabelReportRows = []; this.codexExportDerivationRecords = []; this.latestWave1ValidationRun = null; + this.latestWave2ValidationRun = null; this.wave1ValidationHarness = new Wave1ValidationHarness(); + this.wave2ValidationHarness = new Wave2ValidationHarness(); } async runConfigurationGenerationTask({ message, bmadDir, moduleConfigs, config, allModules, addResult }) { @@ -166,6 +169,16 @@ class Installer { }; } + async buildWave2ValidationOptions({ projectDir, bmadDir }) { + return { + projectDir, + bmadDir, + bmadFolderName: this.bmadFolderName || BMAD_FOLDER_NAME, + shardDocAuthorityRecords: this.shardDocAuthorityRecords || [], + helpCatalogCommandLabelReportRows: this.helpCatalogCommandLabelReportRows || [], + }; + } + /** * Find the bmad installation directory in a project * Always uses the standard _bmad folder name @@ -1350,8 +1363,18 @@ class Installer { }); const validationRun = await this.wave1ValidationHarness.generateAndValidate(validationOptions); this.latestWave1ValidationRun = validationRun; - addResult('Validation artifacts', 'ok', `${validationRun.generatedArtifactCount} artifacts`); - return `${validationRun.generatedArtifactCount} validation artifacts generated`; + addResult('Wave-1 validation artifacts', 'ok', `${validationRun.generatedArtifactCount} artifacts`); + + message('Generating deterministic wave-2 shard-doc validation artifact suite...'); + const wave2ValidationOptions = await this.buildWave2ValidationOptions({ + projectDir, + bmadDir, + }); + const wave2ValidationRun = await this.wave2ValidationHarness.generateAndValidate(wave2ValidationOptions); + this.latestWave2ValidationRun = wave2ValidationRun; + addResult('Wave-2 validation artifacts', 'ok', `${wave2ValidationRun.generatedArtifactCount} artifacts`); + + return `${validationRun.generatedArtifactCount + wave2ValidationRun.generatedArtifactCount} validation artifacts generated`; }, }); diff --git a/tools/cli/installers/lib/core/wave-2-validation-harness.js b/tools/cli/installers/lib/core/wave-2-validation-harness.js new file mode 100644 index 000000000..1403fdd72 --- /dev/null +++ b/tools/cli/installers/lib/core/wave-2-validation-harness.js @@ -0,0 +1,736 @@ +const path = require('node:path'); +const fs = require('fs-extra'); +const yaml = require('yaml'); +const csv = require('csv-parse/sync'); +const { getSourcePath } = require('../../../lib/project-root'); +const { normalizeDisplayedCommandLabel } = require('./help-catalog-generator'); + +const SHARD_DOC_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml'; +const SHARD_DOC_SOURCE_XML_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.xml'; + +const WAVE2_VALIDATION_ERROR_CODES = Object.freeze({ + REQUIRED_ARTIFACT_MISSING: 'ERR_WAVE2_VALIDATION_REQUIRED_ARTIFACT_MISSING', + CSV_SCHEMA_MISMATCH: 'ERR_WAVE2_VALIDATION_CSV_SCHEMA_MISMATCH', + REQUIRED_ROW_MISSING: 'ERR_WAVE2_VALIDATION_REQUIRED_ROW_MISSING', + YAML_SCHEMA_MISMATCH: 'ERR_WAVE2_VALIDATION_YAML_SCHEMA_MISMATCH', +}); + +const WAVE2_VALIDATION_ARTIFACT_REGISTRY = Object.freeze([ + Object.freeze({ + artifactId: 1, + relativePath: path.join('validation', 'wave-2', 'shard-doc-sidecar-snapshot.yaml'), + type: 'yaml', + requiredTopLevelKeys: ['schemaVersion', 'canonicalId', 'artifactType', 'module', 'sourcePath', 'displayName', 'description', 'status'], + }), + Object.freeze({ + artifactId: 2, + relativePath: path.join('validation', 'wave-2', 'shard-doc-authority-records.csv'), + type: 'csv', + columns: [ + 'rowIdentity', + 'recordType', + 'canonicalId', + 'authoritativePresenceKey', + 'authoritySourceType', + 'authoritySourcePath', + 'status', + ], + requiredRowIdentityFields: ['rowIdentity'], + }), + Object.freeze({ + artifactId: 3, + relativePath: path.join('validation', 'wave-2', 'shard-doc-task-manifest-comparison.csv'), + type: 'csv', + columns: [ + 'surface', + 'sourcePath', + 'name', + 'module', + 'path', + 'legacyName', + 'canonicalId', + 'authoritySourceType', + 'authoritySourcePath', + 'status', + ], + }), + Object.freeze({ + artifactId: 4, + relativePath: path.join('validation', 'wave-2', 'shard-doc-help-catalog-comparison.csv'), + type: 'csv', + columns: ['surface', 'sourcePath', 'name', 'workflowFile', 'command', 'rowCountForCanonicalCommand', 'status'], + }), + Object.freeze({ + artifactId: 5, + relativePath: path.join('validation', 'wave-2', 'shard-doc-alias-table.csv'), + type: 'csv', + columns: [ + 'rowIdentity', + 'canonicalId', + 'alias', + 'aliasType', + 'normalizedAliasValue', + 'rawIdentityHasLeadingSlash', + 'resolutionEligibility', + 'authoritySourceType', + 'authoritySourcePath', + 'status', + ], + requiredRowIdentityFields: ['rowIdentity'], + }), + Object.freeze({ + artifactId: 6, + relativePath: path.join('validation', 'wave-2', 'shard-doc-command-label-report.csv'), + type: 'csv', + columns: [ + 'surface', + 'canonicalId', + 'rawCommandValue', + 'displayedCommandLabel', + 'normalizedDisplayedLabel', + 'rowCountForCanonicalId', + 'authoritySourceType', + 'authoritySourcePath', + 'status', + ], + }), + Object.freeze({ + artifactId: 7, + relativePath: path.join('validation', 'wave-2', 'shard-doc-duplicate-report.csv'), + type: 'csv', + columns: ['surface', 'canonicalId', 'normalizedVisibleKey', 'matchingRowCount', 'status'], + }), + Object.freeze({ + artifactId: 8, + relativePath: path.join('validation', 'wave-2', 'shard-doc-artifact-inventory.csv'), + type: 'csv', + columns: ['rowIdentity', 'artifactId', 'artifactPath', 'artifactType', 'required', 'rowCount', 'exists', 'schemaVersion', 'status'], + requiredRowIdentityFields: ['rowIdentity'], + }), +]); + +class Wave2ValidationHarnessError extends Error { + constructor({ code, detail, artifactId, fieldPath, sourcePath, observedValue, expectedValue }) { + const message = `${code}: ${detail} (artifact=${artifactId}, fieldPath=${fieldPath}, sourcePath=${sourcePath})`; + super(message); + this.name = 'Wave2ValidationHarnessError'; + this.code = code; + this.detail = detail; + this.artifactId = artifactId; + this.fieldPath = fieldPath; + this.sourcePath = sourcePath; + this.observedValue = observedValue; + this.expectedValue = expectedValue; + } +} + +function normalizePath(value) { + return String(value || '').replaceAll('\\', '/'); +} + +function normalizeValue(value) { + return String(value ?? '').trim(); +} + +function parseCsvRows(csvContent) { + return csv.parse(String(csvContent || ''), { + columns: true, + skip_empty_lines: true, + trim: true, + }); +} + +function parseCsvHeader(csvContent) { + const parsed = csv.parse(String(csvContent || ''), { + to_line: 1, + skip_empty_lines: true, + trim: true, + }); + return Array.isArray(parsed) && parsed.length > 0 ? parsed[0].map(String) : []; +} + +function escapeCsv(value) { + return `"${String(value ?? '').replaceAll('"', '""')}"`; +} + +function serializeCsv(columns, rows) { + const lines = [columns.join(',')]; + for (const row of rows) { + const serialized = columns.map((column) => escapeCsv(Object.prototype.hasOwnProperty.call(row, column) ? row[column] : '')); + lines.push(serialized.join(',')); + } + return `${lines.join('\n')}\n`; +} + +function sortRowsDeterministically(rows, columns) { + return [...rows].sort((left, right) => { + const leftKey = columns.map((column) => normalizeValue(left[column])).join('|'); + const rightKey = columns.map((column) => normalizeValue(right[column])).join('|'); + return leftKey.localeCompare(rightKey); + }); +} + +class Wave2ValidationHarness { + constructor() { + this.registry = WAVE2_VALIDATION_ARTIFACT_REGISTRY; + } + + getArtifactRegistry() { + return this.registry; + } + + resolveOutputPaths(options = {}) { + const projectDir = path.resolve(options.projectDir || process.cwd()); + const planningArtifactsRoot = path.join(projectDir, '_bmad-output', 'planning-artifacts'); + const validationRoot = path.join(planningArtifactsRoot, 'validation', 'wave-2'); + return { + projectDir, + planningArtifactsRoot, + validationRoot, + }; + } + + buildArtifactPathsMap(outputPaths) { + const artifactPaths = new Map(); + for (const artifact of this.registry) { + artifactPaths.set(artifact.artifactId, path.join(outputPaths.planningArtifactsRoot, artifact.relativePath)); + } + return artifactPaths; + } + + async writeCsvArtifact(filePath, columns, rows) { + const sortedRows = sortRowsDeterministically(rows, columns); + await fs.writeFile(filePath, serializeCsv(columns, sortedRows), 'utf8'); + } + + async assertRequiredInputSurfaceExists({ artifactId, absolutePath, sourcePath, description }) { + if (await fs.pathExists(absolutePath)) { + return; + } + throw new Wave2ValidationHarnessError({ + code: WAVE2_VALIDATION_ERROR_CODES.REQUIRED_ARTIFACT_MISSING, + detail: `Required input surface is missing (${description})`, + artifactId, + fieldPath: '', + sourcePath: normalizePath(sourcePath), + observedValue: '', + expectedValue: normalizePath(sourcePath), + }); + } + + requireRow({ rows, predicate, artifactId, fieldPath, sourcePath, detail }) { + const match = (rows || []).find(predicate); + if (match) { + return match; + } + throw new Wave2ValidationHarnessError({ + code: WAVE2_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail, + artifactId, + fieldPath, + sourcePath: normalizePath(sourcePath), + observedValue: '', + expectedValue: 'required row', + }); + } + + async generateValidationArtifacts(options = {}) { + const outputPaths = this.resolveOutputPaths(options); + const runtimeFolder = normalizeValue(options.bmadFolderName || '_bmad'); + const bmadDir = path.resolve(options.bmadDir || path.join(outputPaths.projectDir, runtimeFolder)); + const artifactPaths = this.buildArtifactPathsMap(outputPaths); + const sidecarPath = + options.sidecarPath || + ((await fs.pathExists(path.join(outputPaths.projectDir, SHARD_DOC_SIDECAR_SOURCE_PATH))) + ? path.join(outputPaths.projectDir, SHARD_DOC_SIDECAR_SOURCE_PATH) + : getSourcePath('core', 'tasks', 'shard-doc.artifact.yaml')); + const sourceXmlPath = + options.sourceXmlPath || + ((await fs.pathExists(path.join(outputPaths.projectDir, SHARD_DOC_SOURCE_XML_SOURCE_PATH))) + ? path.join(outputPaths.projectDir, SHARD_DOC_SOURCE_XML_SOURCE_PATH) + : getSourcePath('core', 'tasks', 'shard-doc.xml')); + + await fs.ensureDir(outputPaths.validationRoot); + + await this.assertRequiredInputSurfaceExists({ + artifactId: 1, + absolutePath: sidecarPath, + sourcePath: SHARD_DOC_SIDECAR_SOURCE_PATH, + description: 'shard-doc sidecar metadata authority', + }); + await this.assertRequiredInputSurfaceExists({ + artifactId: 2, + absolutePath: sourceXmlPath, + sourcePath: SHARD_DOC_SOURCE_XML_SOURCE_PATH, + description: 'shard-doc XML source authority', + }); + await this.assertRequiredInputSurfaceExists({ + artifactId: 3, + absolutePath: path.join(bmadDir, '_config', 'task-manifest.csv'), + sourcePath: `${runtimeFolder}/_config/task-manifest.csv`, + description: 'task-manifest projection surface', + }); + await this.assertRequiredInputSurfaceExists({ + artifactId: 4, + absolutePath: path.join(bmadDir, '_config', 'bmad-help.csv'), + sourcePath: `${runtimeFolder}/_config/bmad-help.csv`, + description: 'help-catalog projection surface', + }); + await this.assertRequiredInputSurfaceExists({ + artifactId: 5, + absolutePath: path.join(bmadDir, '_config', 'canonical-aliases.csv'), + sourcePath: `${runtimeFolder}/_config/canonical-aliases.csv`, + description: 'canonical-aliases projection surface', + }); + + const sidecarMetadata = yaml.parse(await fs.readFile(sidecarPath, 'utf8')); + const taskManifestRows = parseCsvRows(await fs.readFile(path.join(bmadDir, '_config', 'task-manifest.csv'), 'utf8')); + const helpCatalogRows = parseCsvRows(await fs.readFile(path.join(bmadDir, '_config', 'bmad-help.csv'), 'utf8')); + const aliasRows = parseCsvRows(await fs.readFile(path.join(bmadDir, '_config', 'canonical-aliases.csv'), 'utf8')); + const commandLabelReportPath = path.join(bmadDir, '_config', 'bmad-help-command-label-report.csv'); + let commandLabelRows = []; + if (Array.isArray(options.helpCatalogCommandLabelReportRows) && options.helpCatalogCommandLabelReportRows.length > 0) { + commandLabelRows = options.helpCatalogCommandLabelReportRows; + } else { + await this.assertRequiredInputSurfaceExists({ + artifactId: 6, + absolutePath: commandLabelReportPath, + sourcePath: `${runtimeFolder}/_config/bmad-help-command-label-report.csv`, + description: 'help-catalog command-label report projection surface', + }); + commandLabelRows = parseCsvRows(await fs.readFile(commandLabelReportPath, 'utf8')); + } + + const shardDocTaskRow = this.requireRow({ + rows: taskManifestRows, + predicate: (row) => + normalizeValue(row.module).toLowerCase() === 'core' && + normalizeValue(row.name).toLowerCase() === 'shard-doc' && + normalizeValue(row.canonicalId) === 'bmad-shard-doc', + artifactId: 3, + fieldPath: 'rows[module=core,name=shard-doc,canonicalId=bmad-shard-doc]', + sourcePath: `${runtimeFolder}/_config/task-manifest.csv`, + detail: 'Required shard-doc task-manifest canonical row is missing', + }); + const shardDocHelpRows = helpCatalogRows.filter( + (row) => + normalizeValue(row.command).replace(/^\/+/, '') === 'bmad-shard-doc' && + normalizePath(normalizeValue(row['workflow-file'])).toLowerCase().endsWith('/core/tasks/shard-doc.xml'), + ); + if (shardDocHelpRows.length !== 1) { + throw new Wave2ValidationHarnessError({ + code: WAVE2_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Expected exactly one shard-doc help-catalog command row', + artifactId: 4, + fieldPath: 'rows[*].command', + sourcePath: `${runtimeFolder}/_config/bmad-help.csv`, + observedValue: String(shardDocHelpRows.length), + expectedValue: '1', + }); + } + + const shardDocAliasRows = aliasRows.filter((row) => normalizeValue(row.canonicalId) === 'bmad-shard-doc'); + const requiredAliasTypes = new Set(['canonical-id', 'legacy-name', 'slash-command']); + const observedAliasTypes = new Set(shardDocAliasRows.map((row) => normalizeValue(row.aliasType))); + for (const aliasType of requiredAliasTypes) { + if (!observedAliasTypes.has(aliasType)) { + throw new Wave2ValidationHarnessError({ + code: WAVE2_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Required shard-doc alias type row is missing', + artifactId: 5, + fieldPath: 'rows[*].aliasType', + sourcePath: `${runtimeFolder}/_config/canonical-aliases.csv`, + observedValue: [...observedAliasTypes].join('|') || '', + expectedValue: aliasType, + }); + } + } + + const shardDocCommandLabelRows = commandLabelRows.filter((row) => normalizeValue(row.canonicalId) === 'bmad-shard-doc'); + if (shardDocCommandLabelRows.length !== 1) { + throw new Wave2ValidationHarnessError({ + code: WAVE2_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Expected exactly one shard-doc command-label row', + artifactId: 6, + fieldPath: 'rows[*].canonicalId', + sourcePath: `${runtimeFolder}/_config/bmad-help-command-label-report.csv`, + observedValue: String(shardDocCommandLabelRows.length), + expectedValue: '1', + }); + } + const shardDocCommandLabelRow = shardDocCommandLabelRows[0]; + + const authorityRecordsInput = Array.isArray(options.shardDocAuthorityRecords) ? options.shardDocAuthorityRecords : []; + const authorityRecords = + authorityRecordsInput.length > 0 + ? authorityRecordsInput.map((record) => ({ + rowIdentity: `authority-record:${normalizeValue(record.recordType || 'unknown')}`, + recordType: normalizeValue(record.recordType), + canonicalId: normalizeValue(record.canonicalId), + authoritativePresenceKey: normalizeValue(record.authoritativePresenceKey), + authoritySourceType: normalizeValue(record.authoritySourceType), + authoritySourcePath: normalizeValue(record.authoritySourcePath), + status: 'PASS', + })) + : [ + { + rowIdentity: 'authority-record:metadata-authority', + recordType: 'metadata-authority', + canonicalId: 'bmad-shard-doc', + authoritativePresenceKey: 'capability:bmad-shard-doc', + authoritySourceType: 'sidecar', + authoritySourcePath: SHARD_DOC_SIDECAR_SOURCE_PATH, + status: 'PASS', + }, + { + rowIdentity: 'authority-record:source-body-authority', + recordType: 'source-body-authority', + canonicalId: 'bmad-shard-doc', + authoritativePresenceKey: 'capability:bmad-shard-doc', + authoritySourceType: 'source-xml', + authoritySourcePath: SHARD_DOC_SOURCE_XML_SOURCE_PATH, + status: 'PASS', + }, + ]; + + // Artifact 1 + const sidecarSnapshot = { + schemaVersion: sidecarMetadata?.schemaVersion ?? 1, + canonicalId: normalizeValue(sidecarMetadata?.canonicalId || 'bmad-shard-doc'), + artifactType: normalizeValue(sidecarMetadata?.artifactType || 'task'), + module: normalizeValue(sidecarMetadata?.module || 'core'), + sourcePath: SHARD_DOC_SIDECAR_SOURCE_PATH, + displayName: normalizeValue(sidecarMetadata?.displayName || 'Shard Document'), + description: normalizeValue( + sidecarMetadata?.description || 'Split large markdown documents into smaller files by section with an index.', + ), + status: 'PASS', + }; + await fs.writeFile(artifactPaths.get(1), yaml.stringify(sidecarSnapshot), 'utf8'); + + // Artifact 2 + await this.writeCsvArtifact(artifactPaths.get(2), this.registry[1].columns, authorityRecords); + + // Artifact 3 + const taskManifestComparisonRows = [ + { + surface: `${runtimeFolder}/_config/task-manifest.csv`, + sourcePath: SHARD_DOC_SOURCE_XML_SOURCE_PATH, + name: normalizeValue(shardDocTaskRow.name || 'shard-doc'), + module: normalizeValue(shardDocTaskRow.module || 'core'), + path: normalizeValue(shardDocTaskRow.path || `${runtimeFolder}/core/tasks/shard-doc.xml`), + legacyName: normalizeValue(shardDocTaskRow.legacyName || 'shard-doc'), + canonicalId: normalizeValue(shardDocTaskRow.canonicalId || 'bmad-shard-doc'), + authoritySourceType: normalizeValue(shardDocTaskRow.authoritySourceType || 'sidecar'), + authoritySourcePath: normalizeValue(shardDocTaskRow.authoritySourcePath || SHARD_DOC_SIDECAR_SOURCE_PATH), + status: 'PASS', + }, + ]; + await this.writeCsvArtifact(artifactPaths.get(3), this.registry[2].columns, taskManifestComparisonRows); + + // Artifact 4 + const shardDocHelpRow = shardDocHelpRows[0]; + const helpCatalogComparisonRows = [ + { + surface: `${runtimeFolder}/_config/bmad-help.csv`, + sourcePath: SHARD_DOC_SOURCE_XML_SOURCE_PATH, + name: normalizeValue(shardDocHelpRow.name || 'Shard Document'), + workflowFile: normalizeValue(shardDocHelpRow['workflow-file'] || '_bmad/core/tasks/shard-doc.xml'), + command: normalizeValue(shardDocHelpRow.command || 'bmad-shard-doc').replace(/^\/+/, ''), + rowCountForCanonicalCommand: String(shardDocHelpRows.length), + status: shardDocHelpRows.length === 1 ? 'PASS' : 'FAIL', + }, + ]; + await this.writeCsvArtifact(artifactPaths.get(4), this.registry[3].columns, helpCatalogComparisonRows); + + // Artifact 5 + const aliasTableRows = shardDocAliasRows.map((row) => ({ + rowIdentity: normalizeValue(row.rowIdentity), + canonicalId: normalizeValue(row.canonicalId), + alias: normalizeValue(row.alias), + aliasType: normalizeValue(row.aliasType), + normalizedAliasValue: normalizeValue(row.normalizedAliasValue), + rawIdentityHasLeadingSlash: normalizeValue(row.rawIdentityHasLeadingSlash), + resolutionEligibility: normalizeValue(row.resolutionEligibility), + authoritySourceType: normalizeValue(row.authoritySourceType || 'sidecar'), + authoritySourcePath: normalizeValue(row.authoritySourcePath || SHARD_DOC_SIDECAR_SOURCE_PATH), + status: 'PASS', + })); + await this.writeCsvArtifact(artifactPaths.get(5), this.registry[4].columns, aliasTableRows); + + // Artifact 6 + const commandLabelReportRows = [ + { + surface: normalizeValue(shardDocCommandLabelRow.surface || `${runtimeFolder}/_config/bmad-help.csv`), + canonicalId: 'bmad-shard-doc', + rawCommandValue: normalizeValue(shardDocCommandLabelRow.rawCommandValue || 'bmad-shard-doc').replace(/^\/+/, ''), + displayedCommandLabel: normalizeValue(shardDocCommandLabelRow.displayedCommandLabel || '/bmad-shard-doc'), + normalizedDisplayedLabel: normalizeDisplayedCommandLabel( + normalizeValue( + shardDocCommandLabelRow.normalizedDisplayedLabel || shardDocCommandLabelRow.displayedCommandLabel || '/bmad-shard-doc', + ), + ), + rowCountForCanonicalId: normalizeValue(shardDocCommandLabelRow.rowCountForCanonicalId || '1'), + authoritySourceType: normalizeValue(shardDocCommandLabelRow.authoritySourceType || 'sidecar'), + authoritySourcePath: normalizeValue(shardDocCommandLabelRow.authoritySourcePath || SHARD_DOC_SIDECAR_SOURCE_PATH), + status: normalizeValue(shardDocCommandLabelRow.status || 'PASS') || 'PASS', + }, + ]; + await this.writeCsvArtifact(artifactPaths.get(6), this.registry[5].columns, commandLabelReportRows); + + // Artifact 7 + const duplicateRows = [ + { + surface: `${runtimeFolder}/_config/bmad-help.csv`, + canonicalId: 'bmad-shard-doc', + normalizedVisibleKey: 'help-catalog-command:/bmad-shard-doc', + matchingRowCount: String(shardDocHelpRows.length), + status: shardDocHelpRows.length === 1 ? 'PASS' : 'FAIL', + }, + ]; + await this.writeCsvArtifact(artifactPaths.get(7), this.registry[6].columns, duplicateRows); + + // Artifact 8 + const inventoryRows = []; + for (const artifact of this.registry) { + const artifactPath = normalizePath(artifact.relativePath); + const absolutePath = artifactPaths.get(artifact.artifactId); + const isInventoryArtifact = artifact.artifactId === 8; + const exists = isInventoryArtifact ? true : await fs.pathExists(absolutePath); + let rowCount = 0; + if (isInventoryArtifact) { + rowCount = this.registry.length; + } else if (exists && artifact.type === 'csv') { + rowCount = parseCsvRows(await fs.readFile(absolutePath, 'utf8')).length; + } else if (exists && artifact.type === 'yaml') { + rowCount = 1; + } + + inventoryRows.push({ + rowIdentity: `artifact-inventory-row:${artifact.artifactId}`, + artifactId: String(artifact.artifactId), + artifactPath, + artifactType: artifact.type, + required: 'true', + rowCount: String(rowCount), + exists: exists ? 'true' : 'false', + schemaVersion: artifact.type === 'yaml' ? '1' : String((artifact.columns || []).length), + status: exists ? 'PASS' : 'FAIL', + }); + } + await this.writeCsvArtifact(artifactPaths.get(8), this.registry[7].columns, inventoryRows); + + return { + projectDir: outputPaths.projectDir, + planningArtifactsRoot: outputPaths.planningArtifactsRoot, + validationRoot: outputPaths.validationRoot, + generatedArtifactCount: this.registry.length, + artifactPaths: Object.fromEntries([...artifactPaths.entries()].map(([artifactId, artifactPath]) => [artifactId, artifactPath])), + }; + } + + async validateGeneratedArtifacts(options = {}) { + const outputPaths = this.resolveOutputPaths(options); + const artifactDataById = new Map(); + + for (const artifact of this.registry) { + const artifactPath = path.join(outputPaths.planningArtifactsRoot, artifact.relativePath); + if (!(await fs.pathExists(artifactPath))) { + throw new Wave2ValidationHarnessError({ + code: WAVE2_VALIDATION_ERROR_CODES.REQUIRED_ARTIFACT_MISSING, + detail: 'Required wave-2 validation artifact is missing', + artifactId: artifact.artifactId, + fieldPath: '', + sourcePath: normalizePath(artifact.relativePath), + observedValue: '', + expectedValue: normalizePath(artifact.relativePath), + }); + } + + if (artifact.type === 'csv') { + const content = await fs.readFile(artifactPath, 'utf8'); + const observedHeader = parseCsvHeader(content); + const expectedHeader = artifact.columns || []; + if (observedHeader.length !== expectedHeader.length) { + throw new Wave2ValidationHarnessError({ + code: WAVE2_VALIDATION_ERROR_CODES.CSV_SCHEMA_MISMATCH, + detail: 'CSV header length does not match required schema', + artifactId: artifact.artifactId, + fieldPath: '
', + sourcePath: normalizePath(artifact.relativePath), + observedValue: observedHeader.join(','), + expectedValue: expectedHeader.join(','), + }); + } + + for (const [index, expectedValue] of expectedHeader.entries()) { + const observed = normalizeValue(observedHeader[index]); + const expected = normalizeValue(expectedValue); + if (observed !== expected) { + throw new Wave2ValidationHarnessError({ + code: WAVE2_VALIDATION_ERROR_CODES.CSV_SCHEMA_MISMATCH, + detail: 'CSV header ordering does not match required schema', + artifactId: artifact.artifactId, + fieldPath: `header[${index}]`, + sourcePath: normalizePath(artifact.relativePath), + observedValue: observed, + expectedValue: expected, + }); + } + } + + const rows = parseCsvRows(content); + if (rows.length === 0) { + throw new Wave2ValidationHarnessError({ + code: WAVE2_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Required CSV artifact rows are missing', + artifactId: artifact.artifactId, + fieldPath: 'rows', + sourcePath: normalizePath(artifact.relativePath), + observedValue: '', + expectedValue: 'at least one row', + }); + } + for (const requiredField of artifact.requiredRowIdentityFields || []) { + for (const [rowIndex, row] of rows.entries()) { + if (!normalizeValue(row[requiredField])) { + throw new Wave2ValidationHarnessError({ + code: WAVE2_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Required row identity field is empty', + artifactId: artifact.artifactId, + fieldPath: `rows[${rowIndex}].${requiredField}`, + sourcePath: normalizePath(artifact.relativePath), + observedValue: '', + expectedValue: 'non-empty string', + }); + } + } + } + + artifactDataById.set(artifact.artifactId, { type: 'csv', rows, header: observedHeader }); + } else if (artifact.type === 'yaml') { + const parsed = yaml.parse(await fs.readFile(artifactPath, 'utf8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Wave2ValidationHarnessError({ + code: WAVE2_VALIDATION_ERROR_CODES.YAML_SCHEMA_MISMATCH, + detail: 'YAML artifact root must be a mapping object', + artifactId: artifact.artifactId, + fieldPath: '', + sourcePath: normalizePath(artifact.relativePath), + observedValue: typeof parsed, + expectedValue: 'object', + }); + } + for (const key of artifact.requiredTopLevelKeys || []) { + if (!Object.prototype.hasOwnProperty.call(parsed, key)) { + throw new Wave2ValidationHarnessError({ + code: WAVE2_VALIDATION_ERROR_CODES.YAML_SCHEMA_MISMATCH, + detail: 'Required YAML key is missing', + artifactId: artifact.artifactId, + fieldPath: key, + sourcePath: normalizePath(artifact.relativePath), + observedValue: '', + expectedValue: key, + }); + } + } + artifactDataById.set(artifact.artifactId, { type: 'yaml', parsed }); + } + } + + const authorityRows = artifactDataById.get(2)?.rows || []; + this.requireRow({ + rows: authorityRows, + predicate: (row) => + normalizeValue(row.recordType) === 'metadata-authority' && + normalizeValue(row.canonicalId) === 'bmad-shard-doc' && + normalizeValue(row.authoritativePresenceKey) === 'capability:bmad-shard-doc', + artifactId: 2, + fieldPath: 'rows[*].recordType', + sourcePath: normalizePath(this.registry[1].relativePath), + detail: 'Metadata authority record for shard-doc is missing', + }); + this.requireRow({ + rows: authorityRows, + predicate: (row) => + normalizeValue(row.recordType) === 'source-body-authority' && + normalizeValue(row.canonicalId) === 'bmad-shard-doc' && + normalizeValue(row.authoritativePresenceKey) === 'capability:bmad-shard-doc', + artifactId: 2, + fieldPath: 'rows[*].recordType', + sourcePath: normalizePath(this.registry[1].relativePath), + detail: 'Source-body authority record for shard-doc is missing', + }); + + const inventoryRows = artifactDataById.get(8)?.rows || []; + if (inventoryRows.length !== this.registry.length) { + throw new Wave2ValidationHarnessError({ + code: WAVE2_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Artifact inventory must include one row per required artifact', + artifactId: 8, + fieldPath: 'rows', + sourcePath: normalizePath(this.registry[7].relativePath), + observedValue: String(inventoryRows.length), + expectedValue: String(this.registry.length), + }); + } + for (const artifact of this.registry) { + const expectedArtifactPath = normalizePath(artifact.relativePath); + const expectedSchemaVersion = artifact.type === 'yaml' ? '1' : String((artifact.columns || []).length); + const inventoryRow = this.requireRow({ + rows: inventoryRows, + predicate: (row) => + normalizeValue(row.artifactId) === String(artifact.artifactId) && + normalizePath(normalizeValue(row.artifactPath)) === expectedArtifactPath && + normalizeValue(row.artifactType) === artifact.type && + normalizeValue(row.required).toLowerCase() === 'true' && + normalizeValue(row.exists).toLowerCase() === 'true' && + normalizeValue(row.status) === 'PASS' && + normalizeValue(row.schemaVersion) === expectedSchemaVersion, + artifactId: 8, + fieldPath: 'rows[*].artifactId', + sourcePath: normalizePath(this.registry[7].relativePath), + detail: `Artifact inventory is missing deterministic PASS row for artifact ${artifact.artifactId}`, + }); + + const observedRowCount = Number.parseInt(normalizeValue(inventoryRow.rowCount), 10); + const expectedInventoryRowCount = artifact.artifactId === 8 ? this.registry.length : null; + const rowCountIsValid = + Number.isFinite(observedRowCount) && + (expectedInventoryRowCount === null ? observedRowCount >= 1 : observedRowCount === expectedInventoryRowCount); + if (!rowCountIsValid) { + throw new Wave2ValidationHarnessError({ + code: WAVE2_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Artifact inventory rowCount does not satisfy deterministic contract', + artifactId: 8, + fieldPath: `rows[artifactId=${artifact.artifactId}].rowCount`, + sourcePath: normalizePath(this.registry[7].relativePath), + observedValue: normalizeValue(inventoryRow.rowCount) || '', + expectedValue: expectedInventoryRowCount === null ? '>= 1' : String(expectedInventoryRowCount), + }); + } + } + + return { + status: 'PASS', + validatedArtifactCount: this.registry.length, + }; + } + + async generateAndValidate(options = {}) { + const generated = await this.generateValidationArtifacts(options); + const validation = await this.validateGeneratedArtifacts(options); + return { + ...generated, + terminalStatus: validation.status, + validatedArtifactCount: validation.validatedArtifactCount, + }; + } +} + +module.exports = { + WAVE2_VALIDATION_ERROR_CODES, + WAVE2_VALIDATION_ARTIFACT_REGISTRY, + Wave2ValidationHarnessError, + Wave2ValidationHarness, +};