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`;
},
});