diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 8e3b872f4..beb6c2551 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -88,6 +88,11 @@ const { SHARD_DOC_VALIDATION_ARTIFACT_REGISTRY, ShardDocValidationHarness, } = require('../tools/cli/installers/lib/core/shard-doc-validation-harness'); +const { + INDEX_DOCS_VALIDATION_ERROR_CODES, + INDEX_DOCS_VALIDATION_ARTIFACT_REGISTRY, + IndexDocsValidationHarness, +} = require('../tools/cli/installers/lib/core/index-docs-validation-harness'); // ANSI colors const colors = { @@ -5415,6 +5420,443 @@ async function runTests() { console.log(''); + // Test 16: Index-docs Validation Artifact Suite + // ============================================================ + console.log(`${colors.yellow}Test Suite 16: Index-docs Validation Artifact Suite${colors.reset}\n`); + + const tempIndexDocsValidationHarnessRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-index-docs-validation-suite-')); + try { + const tempProjectRoot = tempIndexDocsValidationHarnessRoot; + 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-index-docs', + rawCommandValue: 'bmad-index-docs', + displayedCommandLabel: '/bmad-index-docs', + normalizedDisplayedLabel: '/bmad-index-docs', + rowCountForCanonicalId: '1', + authoritySourceType: 'sidecar', + authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs.artifact.yaml', + status: 'PASS', + failureReason: '', + }, + ]; + + await fs.writeFile( + path.join(tempSourceTasksDir, 'index-docs.artifact.yaml'), + yaml.stringify({ + schemaVersion: 1, + canonicalId: 'bmad-index-docs', + artifactType: 'task', + module: 'core', + sourcePath: 'bmad-fork/src/core/tasks/index-docs.xml', + displayName: 'Index Docs', + description: + 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.', + dependencies: { + requires: [], + }, + }), + 'utf8', + ); + await fs.writeFile( + path.join(tempSourceTasksDir, 'index-docs.xml'), + 'Create lightweight index for quick LLM scanning\n', + 'utf8', + ); + + await writeCsv( + path.join(tempConfigDir, 'task-manifest.csv'), + [...TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS, ...TASK_MANIFEST_CANONICAL_ADDITIVE_COLUMNS], + [ + { + name: 'index-docs', + displayName: 'Index Docs', + description: + 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.', + module: 'core', + path: '_bmad/core/tasks/index-docs.xml', + standalone: 'true', + legacyName: 'index-docs', + canonicalId: 'bmad-index-docs', + authoritySourceType: 'sidecar', + authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs.artifact.yaml', + }, + ], + ); + await writeCsv( + path.join(tempConfigDir, 'bmad-help.csv'), + [...HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS, ...HELP_CATALOG_CANONICAL_ADDITIVE_COLUMNS], + [ + { + module: 'core', + phase: 'anytime', + name: 'Help', + code: 'BH', + sequence: '', + 'workflow-file': '_bmad/core/tasks/help.md', + command: 'bmad-help', + required: 'false', + 'agent-name': '', + 'agent-command': '', + 'agent-display-name': '', + 'agent-title': '', + options: '', + description: 'Show BMAD help and available resources.', + 'output-location': '', + outputs: '', + }, + { + 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: '', + }, + { + module: 'core', + phase: 'anytime', + name: 'Index Docs', + code: 'ID', + sequence: '', + 'workflow-file': '_bmad/core/tasks/index-docs.xml', + command: 'bmad-index-docs', + required: 'false', + 'agent-name': '', + 'agent-command': '', + 'agent-display-name': '', + 'agent-title': '', + options: '', + description: + 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.', + 'output-location': '', + outputs: '', + }, + ], + ); + await writeCsv( + path.join(tempConfigDir, 'canonical-aliases.csv'), + [ + 'canonicalId', + 'alias', + 'aliasType', + 'authoritySourceType', + 'authoritySourcePath', + 'rowIdentity', + 'normalizedAliasValue', + 'rawIdentityHasLeadingSlash', + 'resolutionEligibility', + ], + [ + { + canonicalId: 'bmad-index-docs', + alias: 'bmad-index-docs', + aliasType: 'canonical-id', + authoritySourceType: 'sidecar', + authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs.artifact.yaml', + rowIdentity: 'alias-row:bmad-index-docs:canonical-id', + normalizedAliasValue: 'bmad-index-docs', + rawIdentityHasLeadingSlash: 'false', + resolutionEligibility: 'canonical-id-only', + }, + { + canonicalId: 'bmad-index-docs', + alias: 'index-docs', + aliasType: 'legacy-name', + authoritySourceType: 'sidecar', + authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs.artifact.yaml', + rowIdentity: 'alias-row:bmad-index-docs:legacy-name', + normalizedAliasValue: 'index-docs', + rawIdentityHasLeadingSlash: 'false', + resolutionEligibility: 'legacy-name-only', + }, + { + canonicalId: 'bmad-index-docs', + alias: '/bmad-index-docs', + aliasType: 'slash-command', + authoritySourceType: 'sidecar', + authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs.artifact.yaml', + rowIdentity: 'alias-row:bmad-index-docs:slash-command', + normalizedAliasValue: 'bmad-index-docs', + rawIdentityHasLeadingSlash: 'true', + resolutionEligibility: 'slash-command-only', + }, + ], + ); + await writeCsv(commandLabelReportPath, commandLabelReportColumns, commandLabelReportRows); + + const authorityRecords = [ + { + recordType: 'metadata-authority', + canonicalId: 'bmad-index-docs', + authoritativePresenceKey: 'capability:bmad-index-docs', + authoritySourceType: 'sidecar', + authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs.artifact.yaml', + }, + { + recordType: 'source-body-authority', + canonicalId: 'bmad-index-docs', + authoritativePresenceKey: 'capability:bmad-index-docs', + authoritySourceType: 'source-xml', + authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs.xml', + }, + ]; + + const harness = new IndexDocsValidationHarness(); + const firstRun = await harness.generateAndValidate({ + projectDir: tempProjectRoot, + bmadDir: tempBmadDir, + bmadFolderName: '_bmad', + indexDocsAuthorityRecords: authorityRecords, + }); + assert( + firstRun.terminalStatus === 'PASS' && firstRun.generatedArtifactCount === INDEX_DOCS_VALIDATION_ARTIFACT_REGISTRY.length, + 'Index-docs validation harness generates and validates all required artifacts', + ); + + const artifactPathsById = new Map( + INDEX_DOCS_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), `Index-docs 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', + indexDocsAuthorityRecords: 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, 'Index-docs validation harness outputs are byte-stable across unchanged repeated runs'); + + try { + await harness.executeIsolatedReplay({ + artifactPath: '_bmad/_config/task-manifest.csv', + componentPath: 'bmad-fork/tools/cli/installers/lib/core/manifest-generator.js', + rowIdentity: '', + runtimeFolder: '_bmad', + }); + assert(false, 'Index-docs replay evidence generation rejects missing claimed rowIdentity'); + } catch (error) { + assert( + error.code === INDEX_DOCS_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + 'Index-docs replay evidence generation emits deterministic missing-claimed-rowIdentity error code', + ); + } + + try { + await harness.executeIsolatedReplay({ + artifactPath: '_bmad/_config/task-manifest.csv', + componentPath: 'bmad-fork/tools/cli/installers/lib/core/installer.js::mergeModuleHelpCatalogs()', + rowIdentity: 'issued-artifact:_bmad-_config-task-manifest.csv', + runtimeFolder: '_bmad', + }); + assert(false, 'Index-docs replay evidence generation rejects issuing-component contract mismatch'); + } catch (error) { + assert( + error.code === INDEX_DOCS_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID, + 'Index-docs replay evidence generation emits deterministic issuing-component contract mismatch code', + ); + } + + const artifactElevenPath = artifactPathsById.get(11); + const artifactElevenRows = csv.parse(await fs.readFile(artifactElevenPath, 'utf8'), { + columns: true, + skip_empty_lines: true, + }); + artifactElevenRows[0].baselineArtifactSha256 = 'not-a-sha'; + await writeCsv(artifactElevenPath, INDEX_DOCS_VALIDATION_ARTIFACT_REGISTRY[10].columns, artifactElevenRows); + try { + await harness.validateGeneratedArtifacts({ projectDir: tempProjectRoot }); + assert(false, 'Index-docs validation harness rejects malformed replay-evidence payloads'); + } catch (error) { + assert( + error.code === INDEX_DOCS_VALIDATION_ERROR_CODES.REPLAY_EVIDENCE_INVALID, + 'Index-docs validation harness emits deterministic replay-evidence validation error code', + ); + } + + await harness.generateAndValidate({ + projectDir: tempProjectRoot, + bmadDir: tempBmadDir, + bmadFolderName: '_bmad', + indexDocsAuthorityRecords: authorityRecords, + }); + + await fs.remove(artifactPathsById.get(8)); + try { + await harness.validateGeneratedArtifacts({ projectDir: tempProjectRoot }); + assert(false, 'Index-docs validation harness fails when a required artifact is missing'); + } catch (error) { + assert( + error.code === INDEX_DOCS_VALIDATION_ERROR_CODES.REQUIRED_ARTIFACT_MISSING, + 'Index-docs validation harness emits deterministic missing-artifact error code', + ); + } + + await harness.generateAndValidate({ + projectDir: tempProjectRoot, + bmadDir: tempBmadDir, + bmadFolderName: '_bmad', + indexDocsAuthorityRecords: authorityRecords, + }); + + await fs.remove(commandLabelReportPath); + try { + await harness.generateValidationArtifacts({ + projectDir: tempProjectRoot, + bmadDir: tempBmadDir, + bmadFolderName: '_bmad', + indexDocsAuthorityRecords: authorityRecords, + }); + assert(false, 'Index-docs validation harness rejects missing command-label report input surface'); + } catch (error) { + assert( + error.code === INDEX_DOCS_VALIDATION_ERROR_CODES.REQUIRED_ARTIFACT_MISSING, + 'Index-docs 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, 'Index-docs validation harness rejects schema/header drift'); + } catch (error) { + assert( + error.code === INDEX_DOCS_VALIDATION_ERROR_CODES.CSV_SCHEMA_MISMATCH, + 'Index-docs validation harness emits deterministic schema-mismatch error code', + ); + } + + await harness.generateAndValidate({ + projectDir: tempProjectRoot, + bmadDir: tempBmadDir, + bmadFolderName: '_bmad', + indexDocsAuthorityRecords: 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/index-docs/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, 'Index-docs validation harness rejects inventory deterministic-identifier drift'); + } catch (error) { + assert( + error.code === INDEX_DOCS_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + 'Index-docs validation harness emits deterministic inventory-row validation error code', + ); + } + + await harness.generateAndValidate({ + projectDir: tempProjectRoot, + bmadDir: tempBmadDir, + bmadFolderName: '_bmad', + indexDocsAuthorityRecords: 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, 'Index-docs validation harness rejects missing source-body authority records'); + } catch (error) { + assert( + error.code === INDEX_DOCS_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + 'Index-docs validation harness emits deterministic missing-row error code', + ); + } + } catch (error) { + assert(false, 'Index-docs validation artifact suite setup', error.message); + } finally { + await fs.remove(tempIndexDocsValidationHarnessRoot); + } + + console.log(''); + // ============================================================ // Summary // ============================================================ diff --git a/tools/cli/installers/lib/core/index-docs-validation-harness.js b/tools/cli/installers/lib/core/index-docs-validation-harness.js new file mode 100644 index 000000000..42c4c7738 --- /dev/null +++ b/tools/cli/installers/lib/core/index-docs-validation-harness.js @@ -0,0 +1,1594 @@ +const path = require('node:path'); +const crypto = require('node:crypto'); +const os = require('node:os'); +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 { ManifestGenerator } = require('./manifest-generator'); +const { + ProjectionCompatibilityError, + validateTaskManifestCompatibilitySurface, + validateHelpCatalogLoaderEntries, + validateGithubCopilotHelpLoaderEntries, +} = require('./projection-compatibility-validator'); + +const INDEX_DOCS_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs.artifact.yaml'; +const INDEX_DOCS_SOURCE_XML_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs.xml'; +const INDEX_DOCS_EVIDENCE_ISSUER_COMPONENT = 'bmad-fork/tools/cli/installers/lib/core/index-docs-validation-harness.js'; + +const INDEX_DOCS_VALIDATION_ERROR_CODES = Object.freeze({ + REQUIRED_ARTIFACT_MISSING: 'ERR_INDEX_DOCS_VALIDATION_REQUIRED_ARTIFACT_MISSING', + CSV_SCHEMA_MISMATCH: 'ERR_INDEX_DOCS_VALIDATION_CSV_SCHEMA_MISMATCH', + REQUIRED_ROW_MISSING: 'ERR_INDEX_DOCS_VALIDATION_REQUIRED_ROW_MISSING', + YAML_SCHEMA_MISMATCH: 'ERR_INDEX_DOCS_VALIDATION_YAML_SCHEMA_MISMATCH', + BINDING_EVIDENCE_INVALID: 'ERR_INDEX_DOCS_VALIDATION_BINDING_EVIDENCE_INVALID', + COMPATIBILITY_GATE_FAILED: 'ERR_INDEX_DOCS_VALIDATION_COMPATIBILITY_GATE_FAILED', + REPLAY_EVIDENCE_INVALID: 'ERR_INDEX_DOCS_VALIDATION_REPLAY_EVIDENCE_INVALID', +}); + +const INDEX_DOCS_VALIDATION_ARTIFACT_REGISTRY = Object.freeze([ + Object.freeze({ + artifactId: 1, + relativePath: path.join('validation', 'index-docs', 'index-docs-sidecar-snapshot.yaml'), + type: 'yaml', + requiredTopLevelKeys: ['schemaVersion', 'canonicalId', 'artifactType', 'module', 'sourcePath', 'displayName', 'description', 'status'], + }), + Object.freeze({ + artifactId: 2, + relativePath: path.join('validation', 'index-docs', 'index-docs-authority-records.csv'), + type: 'csv', + columns: [ + 'rowIdentity', + 'recordType', + 'canonicalId', + 'authoritativePresenceKey', + 'authoritySourceType', + 'authoritySourcePath', + 'status', + ], + requiredRowIdentityFields: ['rowIdentity'], + }), + Object.freeze({ + artifactId: 3, + relativePath: path.join('validation', 'index-docs', 'index-docs-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', 'index-docs', 'index-docs-help-catalog-comparison.csv'), + type: 'csv', + columns: ['surface', 'sourcePath', 'name', 'workflowFile', 'command', 'rowCountForCanonicalCommand', 'status'], + }), + Object.freeze({ + artifactId: 5, + relativePath: path.join('validation', 'index-docs', 'index-docs-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', 'index-docs', 'index-docs-command-label-report.csv'), + type: 'csv', + columns: [ + 'surface', + 'canonicalId', + 'rawCommandValue', + 'displayedCommandLabel', + 'normalizedDisplayedLabel', + 'rowCountForCanonicalId', + 'authoritySourceType', + 'authoritySourcePath', + 'status', + ], + }), + Object.freeze({ + artifactId: 7, + relativePath: path.join('validation', 'index-docs', 'index-docs-duplicate-report.csv'), + type: 'csv', + columns: ['surface', 'canonicalId', 'normalizedVisibleKey', 'matchingRowCount', 'status'], + }), + Object.freeze({ + artifactId: 8, + relativePath: path.join('validation', 'index-docs', 'index-docs-artifact-inventory.csv'), + type: 'csv', + columns: ['rowIdentity', 'artifactId', 'artifactPath', 'artifactType', 'required', 'rowCount', 'exists', 'schemaVersion', 'status'], + requiredRowIdentityFields: ['rowIdentity'], + }), + Object.freeze({ + artifactId: 9, + relativePath: path.join('validation', 'index-docs', 'index-docs-compatibility-gates.csv'), + type: 'csv', + columns: ['gateId', 'surface', 'sourcePath', 'status', 'failureCode', 'failureDetail'], + requiredRowIdentityFields: ['gateId'], + }), + Object.freeze({ + artifactId: 10, + relativePath: path.join('validation', 'index-docs', 'index-docs-issued-artifact-provenance.csv'), + type: 'csv', + columns: [ + 'rowIdentity', + 'artifactPath', + 'canonicalId', + 'issuerOwnerClass', + 'evidenceIssuerComponent', + 'evidenceMethod', + 'issuingComponent', + 'issuingComponentBindingBasis', + 'issuingComponentBindingEvidence', + 'claimScope', + 'status', + ], + requiredRowIdentityFields: ['rowIdentity'], + }), + Object.freeze({ + artifactId: 11, + relativePath: path.join('validation', 'index-docs', 'index-docs-replay-evidence.csv'), + type: 'csv', + columns: [ + 'rowIdentity', + 'provenanceRowIdentity', + 'artifactPath', + 'issuingComponent', + 'targetedRowLocator', + 'baselineArtifactSha256', + 'mutatedArtifactSha256', + 'rowLevelDiffSha256', + 'perturbationApplied', + 'baselineTargetRowCount', + 'mutatedTargetRowCount', + 'mutationKind', + 'evidenceIssuerClass', + 'status', + ], + requiredRowIdentityFields: ['rowIdentity', 'provenanceRowIdentity'], + }), + Object.freeze({ + artifactId: 12, + relativePath: path.join('validation', 'index-docs', 'index-docs-gate-summary.csv'), + type: 'csv', + columns: ['gateId', 'status', 'detail', 'sourcePath'], + requiredRowIdentityFields: ['gateId'], + }), +]); + +class IndexDocsValidationHarnessError extends Error { + constructor({ code, detail, artifactId, fieldPath, sourcePath, observedValue, expectedValue }) { + const message = `${code}: ${detail} (artifact=${artifactId}, fieldPath=${fieldPath}, sourcePath=${sourcePath})`; + super(message); + this.name = 'IndexDocsValidationHarnessError'; + 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); + }); +} + +function computeSha256(value) { + return crypto + .createHash('sha256') + .update(String(value || ''), 'utf8') + .digest('hex'); +} + +function sortObjectKeysDeep(value) { + if (Array.isArray(value)) return value.map((item) => sortObjectKeysDeep(item)); + if (!value || typeof value !== 'object') return value; + const sorted = {}; + for (const key of Object.keys(value).sort()) { + sorted[key] = sortObjectKeysDeep(value[key]); + } + return sorted; +} + +function canonicalJsonStringify(value) { + return JSON.stringify(sortObjectKeysDeep(value)); +} + +function isSha256(value) { + return /^[a-f0-9]{64}$/.test(String(value || '')); +} + +function buildIssuedArtifactRowIdentity(artifactPath) { + return `issued-artifact:${String(artifactPath || '').replaceAll('/', '-')}`; +} + +function countIndexDocsManifestClaimRows(csvContent, runtimeFolder) { + const expectedPath = normalizePath(`${runtimeFolder}/core/tasks/index-docs.xml`).toLowerCase(); + return parseCsvRows(csvContent).filter((row) => { + return ( + normalizeValue(row.canonicalId) === 'bmad-index-docs' && + normalizeValue(row.name).toLowerCase() === 'index-docs' && + normalizeValue(row.module).toLowerCase() === 'core' && + normalizePath(normalizeValue(row.path)).toLowerCase() === expectedPath + ); + }).length; +} + +function countIndexDocsHelpCatalogClaimRows(csvContent) { + return parseCsvRows(csvContent).filter((row) => { + const command = normalizeValue(row.command).replace(/^\/+/, '').toLowerCase(); + const workflowFile = normalizePath(normalizeValue(row['workflow-file'])).toLowerCase(); + return command === 'bmad-index-docs' && workflowFile.endsWith('/core/tasks/index-docs.xml'); + }).length; +} + +class IndexDocsValidationHarness { + constructor() { + this.registry = INDEX_DOCS_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', 'index-docs'); + 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 IndexDocsValidationHarnessError({ + code: INDEX_DOCS_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 IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail, + artifactId, + fieldPath, + sourcePath: normalizePath(sourcePath), + observedValue: '', + expectedValue: 'required row', + }); + } + + resolveReplayContract({ artifactPath, componentPath, rowIdentity, runtimeFolder }) { + const claimedRowIdentity = normalizeValue(rowIdentity); + if (!claimedRowIdentity) { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Claimed replay rowIdentity is required', + artifactId: 11, + fieldPath: 'rowIdentity', + sourcePath: normalizePath(artifactPath), + observedValue: '', + expectedValue: 'non-empty rowIdentity', + }); + } + + const expectedRowIdentity = buildIssuedArtifactRowIdentity(artifactPath); + if (claimedRowIdentity !== expectedRowIdentity) { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Claimed replay rowIdentity does not match issued-artifact contract', + artifactId: 11, + fieldPath: 'rowIdentity', + sourcePath: normalizePath(artifactPath), + observedValue: claimedRowIdentity, + expectedValue: expectedRowIdentity, + }); + } + + const contractsByRowIdentity = new Map([ + [ + buildIssuedArtifactRowIdentity(`${runtimeFolder}/_config/task-manifest.csv`), + { + artifactPath: `${runtimeFolder}/_config/task-manifest.csv`, + componentPathIncludes: 'manifest-generator.js', + mutationKind: 'component-input-perturbation:manifest-generator/tasks', + run: ({ workspaceRoot, perturbed }) => this.runManifestGeneratorReplay({ workspaceRoot, runtimeFolder, perturbed }), + }, + ], + [ + buildIssuedArtifactRowIdentity(`${runtimeFolder}/_config/bmad-help.csv`), + { + artifactPath: `${runtimeFolder}/_config/bmad-help.csv`, + componentPathIncludes: 'installer.js::mergemodulehelpcatalogs', + mutationKind: 'component-input-perturbation:installer/module-help-command', + run: ({ workspaceRoot, perturbed }) => this.runInstallerMergeReplay({ workspaceRoot, runtimeFolder, perturbed }), + }, + ], + ]); + + const contract = contractsByRowIdentity.get(claimedRowIdentity); + if (!contract) { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Claimed replay rowIdentity is not mapped to a replay contract', + artifactId: 11, + fieldPath: 'rowIdentity', + sourcePath: normalizePath(artifactPath), + observedValue: claimedRowIdentity, + expectedValue: 'known issued-artifact rowIdentity', + }); + } + + const normalizedComponentPath = normalizeValue(componentPath).toLowerCase(); + if ( + normalizeValue(artifactPath) !== normalizeValue(contract.artifactPath) || + !normalizedComponentPath.includes(String(contract.componentPathIncludes || '').toLowerCase()) + ) { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID, + detail: 'Claimed issuingComponent does not match replay contract mapping', + artifactId: 11, + fieldPath: 'issuingComponent', + sourcePath: normalizePath(artifactPath), + observedValue: canonicalJsonStringify({ + artifactPath, + componentPath, + rowIdentity: claimedRowIdentity, + }), + expectedValue: canonicalJsonStringify({ + artifactPath: contract.artifactPath, + componentPathIncludes: contract.componentPathIncludes, + rowIdentity: claimedRowIdentity, + }), + }); + } + + return contract; + } + + async runManifestGeneratorReplay({ workspaceRoot, runtimeFolder, perturbed }) { + const bmadDir = path.join(workspaceRoot, runtimeFolder); + const cfgDir = path.join(bmadDir, '_config'); + await fs.ensureDir(cfgDir); + + const generator = new ManifestGenerator(); + generator.bmadFolderName = runtimeFolder; + generator.helpAuthorityRecords = []; + generator.taskAuthorityRecords = [ + { + recordType: 'metadata-authority', + canonicalId: 'bmad-index-docs', + authoritySourceType: 'sidecar', + authoritySourcePath: INDEX_DOCS_SIDECAR_SOURCE_PATH, + sourcePath: INDEX_DOCS_SOURCE_XML_SOURCE_PATH, + }, + ]; + generator.tasks = perturbed + ? [] + : [ + { + name: 'index-docs', + displayName: 'Index Docs', + description: + 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.', + module: 'core', + path: `${runtimeFolder}/core/tasks/index-docs.xml`, + standalone: 'true', + }, + ]; + + await generator.writeTaskManifest(cfgDir); + const outputPath = path.join(cfgDir, 'task-manifest.csv'); + const content = await fs.readFile(outputPath, 'utf8'); + return { + content, + targetRowCount: countIndexDocsManifestClaimRows(content, runtimeFolder), + }; + } + + async runInstallerMergeReplay({ workspaceRoot, runtimeFolder, perturbed }) { + const { Installer } = require('./installer'); + + const bmadDir = path.join(workspaceRoot, runtimeFolder); + const coreDir = path.join(bmadDir, 'core'); + const cfgDir = path.join(bmadDir, '_config'); + await fs.ensureDir(coreDir); + await fs.ensureDir(cfgDir); + + const buildCsvLine = (values) => + values + .map((value) => { + const text = String(value ?? ''); + return text.includes(',') || text.includes('"') ? `"${text.replaceAll('"', '""')}"` : text; + }) + .join(','); + const writeCsv = async (filePath, columns, rows) => { + const lines = [columns.join(','), ...rows.map((row) => buildCsvLine(columns.map((column) => row[column] ?? '')))]; + await fs.writeFile(filePath, `${lines.join('\n')}\n`, 'utf8'); + }; + + await writeCsv( + path.join(coreDir, 'module-help.csv'), + [ + 'module', + 'phase', + 'name', + 'code', + 'sequence', + 'workflow-file', + 'command', + 'required', + 'agent', + 'options', + 'description', + 'output-location', + 'outputs', + ], + [ + { + module: 'core', + phase: 'anytime', + name: 'help', + code: 'BH', + sequence: '', + 'workflow-file': `${runtimeFolder}/core/tasks/help.md`, + command: 'bmad-help', + required: 'false', + agent: '', + options: '', + description: 'Show BMAD help', + 'output-location': '', + outputs: '', + }, + { + module: 'core', + phase: 'anytime', + name: 'Shard Document', + code: 'SD', + sequence: '', + 'workflow-file': `${runtimeFolder}/core/tasks/shard-doc.xml`, + command: 'bmad-shard-doc', + required: 'false', + agent: '', + options: '', + description: 'Split large markdown documents into smaller files by section with an index.', + 'output-location': '', + outputs: '', + }, + { + module: 'core', + phase: 'anytime', + name: 'Index Docs', + code: 'ID', + sequence: '', + 'workflow-file': `${runtimeFolder}/core/tasks/index-docs.xml`, + command: perturbed ? 'index-docs' : 'bmad-index-docs', + required: 'false', + agent: '', + options: '', + description: + 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.', + 'output-location': '', + outputs: '', + }, + ], + ); + + await fs.writeFile( + path.join(cfgDir, 'agent-manifest.csv'), + 'name,displayName,title,icon,capabilities,role,identity,communicationStyle,principles,module,path\n', + 'utf8', + ); + + const installer = new Installer(); + installer.bmadFolderName = runtimeFolder; + installer.installedFiles = new Set(); + installer.helpAuthorityRecords = []; + installer.indexDocsAuthorityRecords = [ + { + recordType: 'metadata-authority', + canonicalId: 'bmad-index-docs', + authoritySourceType: 'sidecar', + authoritySourcePath: INDEX_DOCS_SIDECAR_SOURCE_PATH, + sourcePath: INDEX_DOCS_SOURCE_XML_SOURCE_PATH, + }, + ]; + + try { + await installer.mergeModuleHelpCatalogs(bmadDir); + const outputPath = path.join(cfgDir, 'bmad-help.csv'); + const content = await fs.readFile(outputPath, 'utf8'); + return { + content, + targetRowCount: countIndexDocsHelpCatalogClaimRows(content), + }; + } catch (error) { + if (perturbed && normalizeValue(error?.code) === 'ERR_HELP_COMMAND_LABEL_CONTRACT_FAILED') { + return { + content: `PERTURBED_COMPONENT_FAILURE:${normalizeValue(error.code)}:${normalizeValue(error.detail || error.message)}`, + targetRowCount: 0, + }; + } + throw error; + } + } + + async executeIsolatedReplay({ artifactPath, componentPath, rowIdentity, runtimeFolder }) { + const contract = this.resolveReplayContract({ + artifactPath, + componentPath, + rowIdentity, + runtimeFolder, + }); + const baselineWorkspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'index-docs-replay-baseline-')); + const perturbedWorkspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'index-docs-replay-perturbed-')); + + try { + const baseline = await contract.run({ workspaceRoot: baselineWorkspaceRoot, perturbed: false }); + if (Number(baseline.targetRowCount) <= 0) { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Claimed replay rowIdentity target is absent in baseline component output', + artifactId: 11, + fieldPath: 'rowIdentity', + sourcePath: normalizePath(artifactPath), + observedValue: String(baseline.targetRowCount), + expectedValue: `at least one row for ${normalizeValue(rowIdentity)}`, + }); + } + + const mutated = await contract.run({ workspaceRoot: perturbedWorkspaceRoot, perturbed: true }); + return { + baselineContent: baseline.content, + mutatedContent: mutated.content, + baselineTargetRowCount: Number(baseline.targetRowCount), + mutatedTargetRowCount: Number(mutated.targetRowCount), + perturbationApplied: true, + mutationKind: contract.mutationKind, + targetedRowLocator: normalizeValue(rowIdentity), + }; + } finally { + await fs.remove(baselineWorkspaceRoot); + await fs.remove(perturbedWorkspaceRoot); + } + } + + async buildObservedBindingEvidence({ artifactPath, absolutePath, componentPath, rowIdentity, runtimeFolder }) { + await this.assertRequiredInputSurfaceExists({ + artifactId: 10, + absolutePath, + sourcePath: artifactPath, + description: 'issued-artifact replay target surface', + }); + + const mutationResult = await this.executeIsolatedReplay({ + artifactPath, + componentPath, + rowIdentity, + runtimeFolder: normalizeValue(runtimeFolder || '_bmad'), + }); + + const baselineArtifactSha256 = computeSha256(mutationResult.baselineContent); + const mutatedArtifactSha256 = computeSha256(mutationResult.mutatedContent); + const diffPayload = { + artifactPath, + componentPath, + rowIdentity, + mutationKind: mutationResult.mutationKind, + targetedRowLocator: mutationResult.targetedRowLocator, + baselineTargetRowCount: mutationResult.baselineTargetRowCount, + mutatedTargetRowCount: mutationResult.mutatedTargetRowCount, + baselineArtifactSha256, + mutatedArtifactSha256, + }; + const rowLevelDiffSha256 = computeSha256(canonicalJsonStringify(diffPayload)); + const evidencePayload = canonicalJsonStringify({ + evidenceVersion: 1, + observationMethod: 'validator-observed-baseline-plus-isolated-single-component-perturbation', + observationOutcome: + mutationResult.baselineTargetRowCount > mutationResult.mutatedTargetRowCount ? 'observed-impact' : 'no-impact-observed', + artifactPath, + componentPath, + targetedRowLocator: mutationResult.targetedRowLocator, + mutationKind: mutationResult.mutationKind, + baselineTargetRowCount: mutationResult.baselineTargetRowCount, + mutatedTargetRowCount: mutationResult.mutatedTargetRowCount, + baselineArtifactSha256, + mutatedArtifactSha256, + rowLevelDiffSha256, + perturbationApplied: true, + serializationFormat: 'json-canonical-v1', + encoding: 'utf-8', + lineEndings: 'lf', + worktreePath: 'isolated-replay-temp-workspaces', + commitSha: 'not-applicable', + timestampUtc: '1970-01-01T00:00:00Z', + }); + + return { + evidenceMethod: 'validator-observed-baseline-plus-isolated-single-component-perturbation', + issuingComponentBindingBasis: 'validator-observed-baseline-plus-isolated-single-component-perturbation', + issuingComponentBindingEvidence: evidencePayload, + targetedRowLocator: mutationResult.targetedRowLocator, + baselineArtifactSha256, + mutatedArtifactSha256, + rowLevelDiffSha256, + perturbationApplied: true, + baselineTargetRowCount: mutationResult.baselineTargetRowCount, + mutatedTargetRowCount: mutationResult.mutatedTargetRowCount, + mutationKind: mutationResult.mutationKind, + status: mutationResult.baselineTargetRowCount > mutationResult.mutatedTargetRowCount ? 'PASS' : 'FAIL', + }; + } + + async createIssuedArtifactEvidenceRows({ runtimeFolder, bmadDir }) { + const bindings = [ + { + artifactPath: `${runtimeFolder}/_config/task-manifest.csv`, + absolutePath: path.join(bmadDir, '_config', 'task-manifest.csv'), + issuingComponent: 'bmad-fork/tools/cli/installers/lib/core/manifest-generator.js', + }, + { + artifactPath: `${runtimeFolder}/_config/bmad-help.csv`, + absolutePath: path.join(bmadDir, '_config', 'bmad-help.csv'), + issuingComponent: 'bmad-fork/tools/cli/installers/lib/core/installer.js::mergeModuleHelpCatalogs()', + }, + ]; + + const provenanceRows = []; + const replayEvidenceRows = []; + + for (const binding of bindings) { + const rowIdentity = buildIssuedArtifactRowIdentity(binding.artifactPath); + const evidence = await this.buildObservedBindingEvidence({ + artifactPath: binding.artifactPath, + absolutePath: binding.absolutePath, + componentPath: binding.issuingComponent, + rowIdentity, + runtimeFolder, + }); + + provenanceRows.push({ + rowIdentity, + artifactPath: binding.artifactPath, + canonicalId: 'bmad-index-docs', + issuerOwnerClass: 'independent-validator', + evidenceIssuerComponent: INDEX_DOCS_EVIDENCE_ISSUER_COMPONENT, + evidenceMethod: evidence.evidenceMethod, + issuingComponent: binding.issuingComponent, + issuingComponentBindingBasis: evidence.issuingComponentBindingBasis, + issuingComponentBindingEvidence: evidence.issuingComponentBindingEvidence, + claimScope: binding.artifactPath, + status: evidence.status, + }); + + replayEvidenceRows.push({ + rowIdentity: `replay-evidence:${rowIdentity}`, + provenanceRowIdentity: rowIdentity, + artifactPath: binding.artifactPath, + issuingComponent: binding.issuingComponent, + targetedRowLocator: evidence.targetedRowLocator, + baselineArtifactSha256: evidence.baselineArtifactSha256, + mutatedArtifactSha256: evidence.mutatedArtifactSha256, + rowLevelDiffSha256: evidence.rowLevelDiffSha256, + perturbationApplied: evidence.perturbationApplied ? 'true' : 'false', + baselineTargetRowCount: String(evidence.baselineTargetRowCount), + mutatedTargetRowCount: String(evidence.mutatedTargetRowCount), + mutationKind: evidence.mutationKind, + evidenceIssuerClass: 'independent-validator', + status: evidence.status, + }); + } + + return { + provenanceRows, + replayEvidenceRows, + }; + } + + runCompatibilityGate({ gateId, surface, sourcePath, runner }) { + try { + runner(); + return { + gateId, + surface, + sourcePath, + status: 'PASS', + failureCode: '', + failureDetail: '', + }; + } catch (error) { + if (error instanceof ProjectionCompatibilityError) { + return { + gateId, + surface, + sourcePath, + status: 'FAIL', + failureCode: normalizeValue(error.code || 'ERR_COMPATIBILITY_GATE_FAILED'), + failureDetail: normalizeValue(error.detail || error.message || 'compatibility gate failure'), + }; + } + throw error; + } + } + + generateCompatibilityGateRows({ taskManifestCsvContent, helpCatalogCsvContent, runtimeFolder }) { + const helpRows = parseCsvRows(helpCatalogCsvContent); + const helpHeaderColumns = parseCsvHeader(helpCatalogCsvContent); + + return [ + this.runCompatibilityGate({ + gateId: 'task-manifest-loader', + surface: 'task-manifest-loader', + sourcePath: `${runtimeFolder}/_config/task-manifest.csv`, + runner: () => { + validateTaskManifestCompatibilitySurface(taskManifestCsvContent, { + surface: 'task-manifest-loader', + sourcePath: `${runtimeFolder}/_config/task-manifest.csv`, + }); + }, + }), + this.runCompatibilityGate({ + gateId: 'bmad-help-catalog-loader', + surface: 'bmad-help-catalog-loader', + sourcePath: `${runtimeFolder}/_config/bmad-help.csv`, + runner: () => { + validateHelpCatalogLoaderEntries(helpRows, { + surface: 'bmad-help-catalog-loader', + sourcePath: `${runtimeFolder}/_config/bmad-help.csv`, + headerColumns: helpHeaderColumns, + }); + }, + }), + this.runCompatibilityGate({ + gateId: 'github-copilot-help-loader', + surface: 'github-copilot-help-loader', + sourcePath: `${runtimeFolder}/_config/bmad-help.csv`, + runner: () => { + validateGithubCopilotHelpLoaderEntries(helpRows, { + surface: 'github-copilot-help-loader', + sourcePath: `${runtimeFolder}/_config/bmad-help.csv`, + headerColumns: helpHeaderColumns, + }); + }, + }), + ]; + } + + buildGateSummaryRows({ compatibilityRows, provenanceRows, replayRows, runtimeFolder }) { + const compatibilityPass = compatibilityRows.every((row) => normalizeValue(row.status) === 'PASS'); + const provenancePass = provenanceRows.every((row) => normalizeValue(row.status) === 'PASS'); + const replayPass = replayRows.every((row) => normalizeValue(row.status) === 'PASS'); + + return [ + { + gateId: 'compatibility-gates', + status: compatibilityPass ? 'PASS' : 'FAIL', + detail: compatibilityPass ? 'task/help/copilot compatibility gates passed' : 'one or more compatibility gates failed', + sourcePath: `${runtimeFolder}/_config/task-manifest.csv|${runtimeFolder}/_config/bmad-help.csv`, + }, + { + gateId: 'issued-artifact-provenance', + status: provenancePass ? 'PASS' : 'FAIL', + detail: provenancePass ? 'all issued-artifact provenance claims validated' : 'one or more provenance claims failed replay binding', + sourcePath: 'validation/index-docs/index-docs-issued-artifact-provenance.csv', + }, + { + gateId: 'replay-evidence', + status: replayPass ? 'PASS' : 'FAIL', + detail: replayPass ? 'row-targeted isolated replay evidence validated' : 'replay evidence is missing or invalid', + sourcePath: 'validation/index-docs/index-docs-replay-evidence.csv', + }, + { + gateId: 'required-test-commands', + status: compatibilityPass && provenancePass && replayPass ? 'PASS' : 'FAIL', + detail: + compatibilityPass && provenancePass && replayPass + ? 'harness prerequisites satisfied; CI/local test commands must also pass' + : 'harness prerequisites failed; required test command gate is blocked', + sourcePath: 'npm run test:install|npm test', + }, + ]; + } + + 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, INDEX_DOCS_SIDECAR_SOURCE_PATH))) + ? path.join(outputPaths.projectDir, INDEX_DOCS_SIDECAR_SOURCE_PATH) + : getSourcePath('core', 'tasks', 'index-docs.artifact.yaml')); + const sourceXmlPath = + options.sourceXmlPath || + ((await fs.pathExists(path.join(outputPaths.projectDir, INDEX_DOCS_SOURCE_XML_SOURCE_PATH))) + ? path.join(outputPaths.projectDir, INDEX_DOCS_SOURCE_XML_SOURCE_PATH) + : getSourcePath('core', 'tasks', 'index-docs.xml')); + + await fs.ensureDir(outputPaths.validationRoot); + + await this.assertRequiredInputSurfaceExists({ + artifactId: 1, + absolutePath: sidecarPath, + sourcePath: INDEX_DOCS_SIDECAR_SOURCE_PATH, + description: 'index-docs sidecar metadata authority', + }); + await this.assertRequiredInputSurfaceExists({ + artifactId: 2, + absolutePath: sourceXmlPath, + sourcePath: INDEX_DOCS_SOURCE_XML_SOURCE_PATH, + description: 'index-docs 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 taskManifestCsvContent = await fs.readFile(path.join(bmadDir, '_config', 'task-manifest.csv'), 'utf8'); + const helpCatalogCsvContent = await fs.readFile(path.join(bmadDir, '_config', 'bmad-help.csv'), 'utf8'); + const aliasCsvContent = await fs.readFile(path.join(bmadDir, '_config', 'canonical-aliases.csv'), 'utf8'); + const taskManifestRows = parseCsvRows(taskManifestCsvContent); + const helpCatalogRows = parseCsvRows(helpCatalogCsvContent); + const aliasRows = parseCsvRows(aliasCsvContent); + 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 indexDocsTaskRow = this.requireRow({ + rows: taskManifestRows, + predicate: (row) => + normalizeValue(row.module).toLowerCase() === 'core' && + normalizeValue(row.name).toLowerCase() === 'index-docs' && + normalizeValue(row.canonicalId) === 'bmad-index-docs', + artifactId: 3, + fieldPath: 'rows[module=core,name=index-docs,canonicalId=bmad-index-docs]', + sourcePath: `${runtimeFolder}/_config/task-manifest.csv`, + detail: 'Required index-docs task-manifest canonical row is missing', + }); + const indexDocsHelpRows = helpCatalogRows.filter( + (row) => + normalizeValue(row.command).replace(/^\/+/, '') === 'bmad-index-docs' && + normalizePath(normalizeValue(row['workflow-file'])).toLowerCase().endsWith('/core/tasks/index-docs.xml'), + ); + if (indexDocsHelpRows.length !== 1) { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Expected exactly one index-docs help-catalog command row', + artifactId: 4, + fieldPath: 'rows[*].command', + sourcePath: `${runtimeFolder}/_config/bmad-help.csv`, + observedValue: String(indexDocsHelpRows.length), + expectedValue: '1', + }); + } + + const indexDocsAliasRows = aliasRows.filter((row) => normalizeValue(row.canonicalId) === 'bmad-index-docs'); + const requiredAliasTypes = new Set(['canonical-id', 'legacy-name', 'slash-command']); + const observedAliasTypes = new Set(indexDocsAliasRows.map((row) => normalizeValue(row.aliasType))); + for (const aliasType of requiredAliasTypes) { + if (!observedAliasTypes.has(aliasType)) { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Required index-docs alias type row is missing', + artifactId: 5, + fieldPath: 'rows[*].aliasType', + sourcePath: `${runtimeFolder}/_config/canonical-aliases.csv`, + observedValue: [...observedAliasTypes].join('|') || '', + expectedValue: aliasType, + }); + } + } + + const indexDocsCommandLabelRows = commandLabelRows.filter((row) => normalizeValue(row.canonicalId) === 'bmad-index-docs'); + if (indexDocsCommandLabelRows.length !== 1) { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Expected exactly one index-docs command-label row', + artifactId: 6, + fieldPath: 'rows[*].canonicalId', + sourcePath: `${runtimeFolder}/_config/bmad-help-command-label-report.csv`, + observedValue: String(indexDocsCommandLabelRows.length), + expectedValue: '1', + }); + } + const indexDocsCommandLabelRow = indexDocsCommandLabelRows[0]; + + const authorityRecordsInput = Array.isArray(options.indexDocsAuthorityRecords) ? options.indexDocsAuthorityRecords : []; + 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-index-docs', + authoritativePresenceKey: 'capability:bmad-index-docs', + authoritySourceType: 'sidecar', + authoritySourcePath: INDEX_DOCS_SIDECAR_SOURCE_PATH, + status: 'PASS', + }, + { + rowIdentity: 'authority-record:source-body-authority', + recordType: 'source-body-authority', + canonicalId: 'bmad-index-docs', + authoritativePresenceKey: 'capability:bmad-index-docs', + authoritySourceType: 'source-xml', + authoritySourcePath: INDEX_DOCS_SOURCE_XML_SOURCE_PATH, + status: 'PASS', + }, + ]; + + // Artifact 1 + const sidecarSnapshot = { + schemaVersion: sidecarMetadata?.schemaVersion ?? 1, + canonicalId: normalizeValue(sidecarMetadata?.canonicalId || 'bmad-index-docs'), + artifactType: normalizeValue(sidecarMetadata?.artifactType || 'task'), + module: normalizeValue(sidecarMetadata?.module || 'core'), + sourcePath: INDEX_DOCS_SIDECAR_SOURCE_PATH, + displayName: normalizeValue(sidecarMetadata?.displayName || 'Index Docs'), + description: normalizeValue( + sidecarMetadata?.description || + 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.', + ), + 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: INDEX_DOCS_SOURCE_XML_SOURCE_PATH, + name: normalizeValue(indexDocsTaskRow.name || 'index-docs'), + module: normalizeValue(indexDocsTaskRow.module || 'core'), + path: normalizeValue(indexDocsTaskRow.path || `${runtimeFolder}/core/tasks/index-docs.xml`), + legacyName: normalizeValue(indexDocsTaskRow.legacyName || 'index-docs'), + canonicalId: normalizeValue(indexDocsTaskRow.canonicalId || 'bmad-index-docs'), + authoritySourceType: normalizeValue(indexDocsTaskRow.authoritySourceType || 'sidecar'), + authoritySourcePath: normalizeValue(indexDocsTaskRow.authoritySourcePath || INDEX_DOCS_SIDECAR_SOURCE_PATH), + status: 'PASS', + }, + ]; + await this.writeCsvArtifact(artifactPaths.get(3), this.registry[2].columns, taskManifestComparisonRows); + + // Artifact 4 + const indexDocsHelpRow = indexDocsHelpRows[0]; + const helpCatalogComparisonRows = [ + { + surface: `${runtimeFolder}/_config/bmad-help.csv`, + sourcePath: INDEX_DOCS_SOURCE_XML_SOURCE_PATH, + name: normalizeValue(indexDocsHelpRow.name || 'Index Docs'), + workflowFile: normalizeValue(indexDocsHelpRow['workflow-file'] || '_bmad/core/tasks/index-docs.xml'), + command: normalizeValue(indexDocsHelpRow.command || 'bmad-index-docs').replace(/^\/+/, ''), + rowCountForCanonicalCommand: String(indexDocsHelpRows.length), + status: indexDocsHelpRows.length === 1 ? 'PASS' : 'FAIL', + }, + ]; + await this.writeCsvArtifact(artifactPaths.get(4), this.registry[3].columns, helpCatalogComparisonRows); + + // Artifact 5 + const aliasTableRows = indexDocsAliasRows.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 || INDEX_DOCS_SIDECAR_SOURCE_PATH), + status: 'PASS', + })); + await this.writeCsvArtifact(artifactPaths.get(5), this.registry[4].columns, aliasTableRows); + + // Artifact 6 + const commandLabelReportRows = [ + { + surface: normalizeValue(indexDocsCommandLabelRow.surface || `${runtimeFolder}/_config/bmad-help.csv`), + canonicalId: 'bmad-index-docs', + rawCommandValue: normalizeValue(indexDocsCommandLabelRow.rawCommandValue || 'bmad-index-docs').replace(/^\/+/, ''), + displayedCommandLabel: normalizeValue(indexDocsCommandLabelRow.displayedCommandLabel || '/bmad-index-docs'), + normalizedDisplayedLabel: normalizeDisplayedCommandLabel( + normalizeValue( + indexDocsCommandLabelRow.normalizedDisplayedLabel || indexDocsCommandLabelRow.displayedCommandLabel || '/bmad-index-docs', + ), + ), + rowCountForCanonicalId: normalizeValue(indexDocsCommandLabelRow.rowCountForCanonicalId || '1'), + authoritySourceType: normalizeValue(indexDocsCommandLabelRow.authoritySourceType || 'sidecar'), + authoritySourcePath: normalizeValue(indexDocsCommandLabelRow.authoritySourcePath || INDEX_DOCS_SIDECAR_SOURCE_PATH), + status: normalizeValue(indexDocsCommandLabelRow.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-index-docs', + normalizedVisibleKey: 'help-catalog-command:/bmad-index-docs', + matchingRowCount: String(indexDocsHelpRows.length), + status: indexDocsHelpRows.length === 1 ? 'PASS' : 'FAIL', + }, + ]; + await this.writeCsvArtifact(artifactPaths.get(7), this.registry[6].columns, duplicateRows); + + // Artifact 9 + const compatibilityRows = this.generateCompatibilityGateRows({ + taskManifestCsvContent, + helpCatalogCsvContent, + runtimeFolder, + }); + await this.writeCsvArtifact(artifactPaths.get(9), this.registry[8].columns, compatibilityRows); + + // Artifact 10 + 11 + const { provenanceRows, replayEvidenceRows } = await this.createIssuedArtifactEvidenceRows({ + runtimeFolder, + bmadDir, + }); + await this.writeCsvArtifact(artifactPaths.get(10), this.registry[9].columns, provenanceRows); + await this.writeCsvArtifact(artifactPaths.get(11), this.registry[10].columns, replayEvidenceRows); + + // Artifact 12 + const gateSummaryRows = this.buildGateSummaryRows({ + compatibilityRows, + provenanceRows, + replayRows: replayEvidenceRows, + runtimeFolder, + }); + await this.writeCsvArtifact(artifactPaths.get(12), this.registry[11].columns, gateSummaryRows); + + // Artifact 8 (after all other artifacts exist) + 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])), + }; + } + + validateReplayEvidenceRow(row, sourcePath) { + if (!isSha256(row.baselineArtifactSha256)) { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.REPLAY_EVIDENCE_INVALID, + detail: 'Replay evidence baselineArtifactSha256 must be a valid sha256 hex digest', + artifactId: 11, + fieldPath: 'rows[*].baselineArtifactSha256', + sourcePath, + observedValue: normalizeValue(row.baselineArtifactSha256), + expectedValue: '64-char lowercase sha256 hex', + }); + } + if (!isSha256(row.mutatedArtifactSha256)) { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.REPLAY_EVIDENCE_INVALID, + detail: 'Replay evidence mutatedArtifactSha256 must be a valid sha256 hex digest', + artifactId: 11, + fieldPath: 'rows[*].mutatedArtifactSha256', + sourcePath, + observedValue: normalizeValue(row.mutatedArtifactSha256), + expectedValue: '64-char lowercase sha256 hex', + }); + } + if (!isSha256(row.rowLevelDiffSha256)) { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.REPLAY_EVIDENCE_INVALID, + detail: 'Replay evidence rowLevelDiffSha256 must be a valid sha256 hex digest', + artifactId: 11, + fieldPath: 'rows[*].rowLevelDiffSha256', + sourcePath, + observedValue: normalizeValue(row.rowLevelDiffSha256), + expectedValue: '64-char lowercase sha256 hex', + }); + } + + const perturbationApplied = normalizeValue(row.perturbationApplied).toLowerCase(); + if (perturbationApplied !== 'true') { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.REPLAY_EVIDENCE_INVALID, + detail: 'Replay evidence must prove perturbationApplied=true from isolated component replay', + artifactId: 11, + fieldPath: 'rows[*].perturbationApplied', + sourcePath, + observedValue: normalizeValue(row.perturbationApplied), + expectedValue: 'true', + }); + } + } + + 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 IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.REQUIRED_ARTIFACT_MISSING, + detail: 'Required index-docs 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 IndexDocsValidationHarnessError({ + code: INDEX_DOCS_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 IndexDocsValidationHarnessError({ + code: INDEX_DOCS_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 IndexDocsValidationHarnessError({ + code: INDEX_DOCS_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 IndexDocsValidationHarnessError({ + code: INDEX_DOCS_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 IndexDocsValidationHarnessError({ + code: INDEX_DOCS_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 IndexDocsValidationHarnessError({ + code: INDEX_DOCS_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-index-docs' && + normalizeValue(row.authoritativePresenceKey) === 'capability:bmad-index-docs', + artifactId: 2, + fieldPath: 'rows[*].recordType', + sourcePath: normalizePath(this.registry[1].relativePath), + detail: 'Metadata authority record for index-docs is missing', + }); + this.requireRow({ + rows: authorityRows, + predicate: (row) => + normalizeValue(row.recordType) === 'source-body-authority' && + normalizeValue(row.canonicalId) === 'bmad-index-docs' && + normalizeValue(row.authoritativePresenceKey) === 'capability:bmad-index-docs', + artifactId: 2, + fieldPath: 'rows[*].recordType', + sourcePath: normalizePath(this.registry[1].relativePath), + detail: 'Source-body authority record for index-docs is missing', + }); + + const compatibilityRows = artifactDataById.get(9)?.rows || []; + for (const gateId of ['task-manifest-loader', 'bmad-help-catalog-loader', 'github-copilot-help-loader']) { + const gateRow = this.requireRow({ + rows: compatibilityRows, + predicate: (row) => normalizeValue(row.gateId) === gateId, + artifactId: 9, + fieldPath: 'rows[*].gateId', + sourcePath: normalizePath(this.registry[8].relativePath), + detail: `Required compatibility gate row is missing (${gateId})`, + }); + if (normalizeValue(gateRow.status) !== 'PASS') { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.COMPATIBILITY_GATE_FAILED, + detail: `Compatibility gate failed (${gateId})`, + artifactId: 9, + fieldPath: `rows[gateId=${gateId}].status`, + sourcePath: normalizePath(this.registry[8].relativePath), + observedValue: normalizeValue(gateRow.status), + expectedValue: 'PASS', + }); + } + } + + const provenanceRows = artifactDataById.get(10)?.rows || []; + for (const artifactPath of ['_bmad/_config/task-manifest.csv', '_bmad/_config/bmad-help.csv']) { + const rowIdentity = buildIssuedArtifactRowIdentity(artifactPath); + const provenanceRow = this.requireRow({ + rows: provenanceRows, + predicate: (row) => normalizeValue(row.rowIdentity) === rowIdentity, + artifactId: 10, + fieldPath: 'rows[*].rowIdentity', + sourcePath: normalizePath(this.registry[9].relativePath), + detail: `Required issued-artifact provenance row is missing (${rowIdentity})`, + }); + if ( + normalizeValue(provenanceRow.status) !== 'PASS' || + normalizeValue(provenanceRow.issuerOwnerClass) !== 'independent-validator' || + normalizeValue(provenanceRow.evidenceIssuerComponent) !== INDEX_DOCS_EVIDENCE_ISSUER_COMPONENT + ) { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID, + detail: 'Issued-artifact provenance row failed deterministic issuer binding contract', + artifactId: 10, + fieldPath: `rows[rowIdentity=${rowIdentity}]`, + sourcePath: normalizePath(this.registry[9].relativePath), + observedValue: canonicalJsonStringify({ + status: normalizeValue(provenanceRow.status), + issuerOwnerClass: normalizeValue(provenanceRow.issuerOwnerClass), + evidenceIssuerComponent: normalizeValue(provenanceRow.evidenceIssuerComponent), + }), + expectedValue: canonicalJsonStringify({ + status: 'PASS', + issuerOwnerClass: 'independent-validator', + evidenceIssuerComponent: INDEX_DOCS_EVIDENCE_ISSUER_COMPONENT, + }), + }); + } + if (!normalizeValue(provenanceRow.issuingComponentBindingEvidence)) { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID, + detail: 'Issued-artifact provenance row is missing binding evidence payload', + artifactId: 10, + fieldPath: `rows[rowIdentity=${rowIdentity}].issuingComponentBindingEvidence`, + sourcePath: normalizePath(this.registry[9].relativePath), + observedValue: '', + expectedValue: 'non-empty canonical JSON payload', + }); + } + } + + const replayRows = artifactDataById.get(11)?.rows || []; + for (const replayRow of replayRows) { + this.validateReplayEvidenceRow(replayRow, normalizePath(this.registry[10].relativePath)); + const provenanceRow = this.requireRow({ + rows: provenanceRows, + predicate: (row) => normalizeValue(row.rowIdentity) === normalizeValue(replayRow.provenanceRowIdentity), + artifactId: 11, + fieldPath: 'rows[*].provenanceRowIdentity', + sourcePath: normalizePath(this.registry[10].relativePath), + detail: 'Replay evidence row references missing issued-artifact provenance rowIdentity', + }); + if (normalizeValue(replayRow.targetedRowLocator) !== normalizeValue(provenanceRow.rowIdentity)) { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.REPLAY_EVIDENCE_INVALID, + detail: 'Replay evidence targetedRowLocator must equal provenance rowIdentity', + artifactId: 11, + fieldPath: 'rows[*].targetedRowLocator', + sourcePath: normalizePath(this.registry[10].relativePath), + observedValue: normalizeValue(replayRow.targetedRowLocator), + expectedValue: normalizeValue(provenanceRow.rowIdentity), + }); + } + if ( + Number.parseInt(normalizeValue(replayRow.baselineTargetRowCount), 10) <= + Number.parseInt(normalizeValue(replayRow.mutatedTargetRowCount), 10) + ) { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.REPLAY_EVIDENCE_INVALID, + detail: 'Replay evidence must show baseline target count greater than mutated target count', + artifactId: 11, + fieldPath: 'rows[*].baselineTargetRowCount', + sourcePath: normalizePath(this.registry[10].relativePath), + observedValue: `${normalizeValue(replayRow.baselineTargetRowCount)}<=${normalizeValue(replayRow.mutatedTargetRowCount)}`, + expectedValue: 'baselineTargetRowCount > mutatedTargetRowCount', + }); + } + } + + const gateSummaryRows = artifactDataById.get(12)?.rows || []; + for (const gateId of ['compatibility-gates', 'issued-artifact-provenance', 'replay-evidence']) { + const summaryRow = this.requireRow({ + rows: gateSummaryRows, + predicate: (row) => normalizeValue(row.gateId) === gateId, + artifactId: 12, + fieldPath: 'rows[*].gateId', + sourcePath: normalizePath(this.registry[11].relativePath), + detail: `Required gate summary row is missing (${gateId})`, + }); + if (normalizeValue(summaryRow.status) !== 'PASS') { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_VALIDATION_ERROR_CODES.COMPATIBILITY_GATE_FAILED, + detail: `Gate summary failed (${gateId})`, + artifactId: 12, + fieldPath: `rows[gateId=${gateId}].status`, + sourcePath: normalizePath(this.registry[11].relativePath), + observedValue: normalizeValue(summaryRow.status), + expectedValue: 'PASS', + }); + } + } + + const inventoryRows = artifactDataById.get(8)?.rows || []; + if (inventoryRows.length !== this.registry.length) { + throw new IndexDocsValidationHarnessError({ + code: INDEX_DOCS_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 IndexDocsValidationHarnessError({ + code: INDEX_DOCS_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 = { + INDEX_DOCS_VALIDATION_ERROR_CODES, + INDEX_DOCS_VALIDATION_ARTIFACT_REGISTRY, + IndexDocsValidationHarnessError, + IndexDocsValidationHarness, +}; diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 74776ec26..e0fd7e328 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -27,6 +27,7 @@ const { const { validateHelpCatalogCompatibilitySurface } = require('./projection-compatibility-validator'); const { HelpValidationHarness } = require('./help-validation-harness'); const { ShardDocValidationHarness } = require('./shard-doc-validation-harness'); +const { IndexDocsValidationHarness } = require('./index-docs-validation-harness'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); const { CLIUtils } = require('../../../lib/cli-utils'); const { ManifestGenerator } = require('./manifest-generator'); @@ -75,8 +76,10 @@ class Installer { this.indexDocsAuthorityRecords = []; this.latestHelpValidationRun = null; this.latestShardDocValidationRun = null; + this.latestIndexDocsValidationRun = null; this.helpValidationHarness = new HelpValidationHarness(); this.shardDocValidationHarness = new ShardDocValidationHarness(); + this.indexDocsValidationHarness = new IndexDocsValidationHarness(); } async runConfigurationGenerationTask({ message, bmadDir, moduleConfigs, config, allModules, addResult }) { @@ -211,6 +214,16 @@ class Installer { }; } + async buildIndexDocsValidationOptions({ projectDir, bmadDir }) { + return { + projectDir, + bmadDir, + bmadFolderName: this.bmadFolderName || BMAD_FOLDER_NAME, + indexDocsAuthorityRecords: this.indexDocsAuthorityRecords || [], + helpCatalogCommandLabelReportRows: this.helpCatalogCommandLabelReportRows || [], + }; + } + /** * Find the bmad installation directory in a project * Always uses the standard _bmad folder name @@ -1406,7 +1419,20 @@ class Installer { this.latestShardDocValidationRun = shardDocValidationRun; addResult('Shard-doc validation artifacts', 'ok', `${shardDocValidationRun.generatedArtifactCount} artifacts`); - return `${validationRun.generatedArtifactCount + shardDocValidationRun.generatedArtifactCount} validation artifacts generated`; + message('Generating deterministic index-docs validation artifact suite...'); + const indexDocsValidationOptions = await this.buildIndexDocsValidationOptions({ + projectDir, + bmadDir, + }); + const indexDocsValidationRun = await this.indexDocsValidationHarness.generateAndValidate(indexDocsValidationOptions); + this.latestIndexDocsValidationRun = indexDocsValidationRun; + addResult('Index-docs validation artifacts', 'ok', `${indexDocsValidationRun.generatedArtifactCount} artifacts`); + + return `${ + validationRun.generatedArtifactCount + + shardDocValidationRun.generatedArtifactCount + + indexDocsValidationRun.generatedArtifactCount + } validation artifacts generated`; }, });