diff --git a/src/core/tasks/help.artifact.yaml b/src/core/tasks/help.artifact.yaml
new file mode 100644
index 000000000..ca0774417
--- /dev/null
+++ b/src/core/tasks/help.artifact.yaml
@@ -0,0 +1,9 @@
+schemaVersion: 1
+canonicalId: bmad-help
+artifactType: task
+module: core
+sourcePath: bmad-fork/src/core/tasks/help.md
+displayName: help
+description: "Analyzes what is done and the users query and offers advice on what to do next. Use if user says what should I do next or what do I do now"
+dependencies:
+ requires: []
diff --git a/src/core/tasks/index-docs.artifact.yaml b/src/core/tasks/index-docs.artifact.yaml
new file mode 100644
index 000000000..3ba9f8ab3
--- /dev/null
+++ b/src/core/tasks/index-docs.artifact.yaml
@@ -0,0 +1,9 @@
+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: []
diff --git a/src/core/tasks/shard-doc.artifact.yaml b/src/core/tasks/shard-doc.artifact.yaml
new file mode 100644
index 000000000..7444a1b30
--- /dev/null
+++ b/src/core/tasks/shard-doc.artifact.yaml
@@ -0,0 +1,9 @@
+schemaVersion: 1
+canonicalId: bmad-shard-doc
+artifactType: task
+module: core
+sourcePath: bmad-fork/src/core/tasks/shard-doc.xml
+displayName: Shard Document
+description: "Split large markdown documents into smaller files by section with an index."
+dependencies:
+ requires: []
diff --git a/test/fixtures/index-docs/sidecar-negative/basename-path-mismatch/index-docs.artifact.yaml b/test/fixtures/index-docs/sidecar-negative/basename-path-mismatch/index-docs.artifact.yaml
new file mode 100644
index 000000000..88d48b041
--- /dev/null
+++ b/test/fixtures/index-docs/sidecar-negative/basename-path-mismatch/index-docs.artifact.yaml
@@ -0,0 +1,9 @@
+schemaVersion: 1
+canonicalId: bmad-index-docs
+artifactType: task
+module: core
+sourcePath: bmad-fork/src/core/tasks/not-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: []
diff --git a/test/fixtures/index-docs/sidecar-negative/unknown-major-version/index-docs.artifact.yaml b/test/fixtures/index-docs/sidecar-negative/unknown-major-version/index-docs.artifact.yaml
new file mode 100644
index 000000000..2e3c07140
--- /dev/null
+++ b/test/fixtures/index-docs/sidecar-negative/unknown-major-version/index-docs.artifact.yaml
@@ -0,0 +1,9 @@
+schemaVersion: 2
+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: []
diff --git a/test/fixtures/shard-doc/sidecar-negative/basename-path-mismatch/shard-doc.artifact.yaml b/test/fixtures/shard-doc/sidecar-negative/basename-path-mismatch/shard-doc.artifact.yaml
new file mode 100644
index 000000000..d0ef1f1ab
--- /dev/null
+++ b/test/fixtures/shard-doc/sidecar-negative/basename-path-mismatch/shard-doc.artifact.yaml
@@ -0,0 +1,9 @@
+schemaVersion: 1
+canonicalId: bmad-shard-doc
+artifactType: task
+module: core
+sourcePath: bmad-fork/src/core/tasks/not-shard-doc.xml
+displayName: Shard Document
+description: "Split large markdown documents into smaller files by section with an index."
+dependencies:
+ requires: []
diff --git a/test/fixtures/shard-doc/sidecar-negative/unknown-major-version/shard-doc.artifact.yaml b/test/fixtures/shard-doc/sidecar-negative/unknown-major-version/shard-doc.artifact.yaml
new file mode 100644
index 000000000..70efdad3c
--- /dev/null
+++ b/test/fixtures/shard-doc/sidecar-negative/unknown-major-version/shard-doc.artifact.yaml
@@ -0,0 +1,9 @@
+schemaVersion: 2
+canonicalId: bmad-shard-doc
+artifactType: task
+module: core
+sourcePath: bmad-fork/src/core/tasks/shard-doc.xml
+displayName: Shard Document
+description: "Split large markdown documents into smaller files by section with an index."
+dependencies:
+ requires: []
diff --git a/test/test-installation-components.js b/test/test-installation-components.js
index 646bd9ef7..e7433d571 100644
--- a/test/test-installation-components.js
+++ b/test/test-installation-components.js
@@ -12,9 +12,89 @@
*/
const path = require('node:path');
+const os = require('node:os');
const fs = require('fs-extra');
+const yaml = require('yaml');
+const csv = require('csv-parse/sync');
const { YamlXmlBuilder } = require('../tools/cli/lib/yaml-xml-builder');
+const { Installer } = require('../tools/cli/installers/lib/core/installer');
const { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator');
+const { TaskToolCommandGenerator } = require('../tools/cli/installers/lib/ide/shared/task-tool-command-generator');
+const { GitHubCopilotSetup } = require('../tools/cli/installers/lib/ide/github-copilot');
+const {
+ HELP_ALIAS_NORMALIZATION_ERROR_CODES,
+ LOCKED_EXEMPLAR_ALIAS_ROWS,
+ normalizeRawIdentityToTuple,
+ resolveAliasTupleFromRows,
+ resolveAliasTupleUsingCanonicalAliasCsv,
+ normalizeAndResolveExemplarAlias,
+} = require('../tools/cli/installers/lib/core/help-alias-normalizer');
+const {
+ HELP_SIDECAR_REQUIRED_FIELDS,
+ HELP_SIDECAR_ERROR_CODES,
+ SHARD_DOC_SIDECAR_REQUIRED_FIELDS,
+ SHARD_DOC_SIDECAR_ERROR_CODES,
+ INDEX_DOCS_SIDECAR_REQUIRED_FIELDS,
+ INDEX_DOCS_SIDECAR_ERROR_CODES,
+ SKILL_METADATA_RESOLUTION_ERROR_CODES,
+ resolveSkillMetadataAuthority,
+ validateHelpSidecarContractFile,
+ validateShardDocSidecarContractFile,
+ validateIndexDocsSidecarContractFile,
+} = require('../tools/cli/installers/lib/core/sidecar-contract-validator');
+const {
+ HELP_FRONTMATTER_MISMATCH_ERROR_CODES,
+ validateHelpAuthoritySplitAndPrecedence,
+} = require('../tools/cli/installers/lib/core/help-authority-validator');
+const {
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES,
+ validateShardDocAuthoritySplitAndPrecedence,
+} = require('../tools/cli/installers/lib/core/shard-doc-authority-validator');
+const {
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES,
+ validateIndexDocsAuthoritySplitAndPrecedence,
+} = require('../tools/cli/installers/lib/core/index-docs-authority-validator');
+const {
+ HELP_CATALOG_GENERATION_ERROR_CODES,
+ EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH,
+ EXEMPLAR_HELP_CATALOG_ISSUING_COMPONENT,
+ INSTALLER_HELP_CATALOG_MERGE_COMPONENT,
+ buildSidecarAwareExemplarHelpRow,
+ evaluateExemplarCommandLabelReportRows,
+} = require('../tools/cli/installers/lib/core/help-catalog-generator');
+const {
+ CodexSetup,
+ CODEX_EXPORT_DERIVATION_ERROR_CODES,
+ EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE,
+} = require('../tools/cli/installers/lib/ide/codex');
+const {
+ PROJECTION_COMPATIBILITY_ERROR_CODES,
+ TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS,
+ TASK_MANIFEST_CANONICAL_ADDITIVE_COLUMNS,
+ HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS,
+ HELP_CATALOG_CANONICAL_ADDITIVE_COLUMNS,
+ validateTaskManifestCompatibilitySurface,
+ validateTaskManifestLoaderEntries,
+ validateHelpCatalogCompatibilitySurface,
+ validateHelpCatalogLoaderEntries,
+ validateGithubCopilotHelpLoaderEntries,
+ validateCommandDocSurfaceConsistency,
+} = require('../tools/cli/installers/lib/core/projection-compatibility-validator');
+const {
+ HELP_VALIDATION_ERROR_CODES,
+ HELP_VALIDATION_ARTIFACT_REGISTRY,
+ HelpValidationHarness,
+} = require('../tools/cli/installers/lib/core/help-validation-harness');
+const {
+ SHARD_DOC_VALIDATION_ERROR_CODES,
+ 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 = {
@@ -158,9 +238,3752 @@ async function runTests() {
console.log('');
// ============================================================
- // Test 5: QA Agent Compilation
+ // Test 4: Exemplar Sidecar Contract Validation
// ============================================================
- console.log(`${colors.yellow}Test Suite 5: QA Agent Compilation${colors.reset}\n`);
+ console.log(`${colors.yellow}Test Suite 4: Sidecar Contract Validation${colors.reset}\n`);
+
+ const validHelpSidecar = {
+ schemaVersion: 1,
+ canonicalId: 'bmad-help',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: 'bmad-fork/src/core/tasks/help.md',
+ displayName: 'help',
+ description: 'Help command',
+ dependencies: {
+ requires: [],
+ },
+ };
+
+ const tempSidecarRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-help-sidecar-'));
+ const tempSidecarPath = path.join(tempSidecarRoot, 'help.artifact.yaml');
+ const deterministicSourcePath = 'bmad-fork/src/core/tasks/help/skill-manifest.yaml';
+ const expectedUnsupportedMajorDetail = 'sidecar schema major version is unsupported';
+ const expectedBasenameMismatchDetail = 'sidecar basename does not match sourcePath basename';
+
+ const writeTempSidecar = async (data) => {
+ await fs.writeFile(tempSidecarPath, yaml.stringify(data), 'utf8');
+ };
+
+ const expectValidationError = async (data, expectedCode, expectedFieldPath, testLabel, expectedDetail = null) => {
+ await writeTempSidecar(data);
+
+ try {
+ await validateHelpSidecarContractFile(tempSidecarPath, { errorSourcePath: deterministicSourcePath });
+ assert(false, testLabel, 'Expected validation error but validation passed');
+ } catch (error) {
+ assert(error.code === expectedCode, `${testLabel} returns expected error code`, `Expected ${expectedCode}, got ${error.code}`);
+ assert(
+ error.fieldPath === expectedFieldPath,
+ `${testLabel} returns expected field path`,
+ `Expected ${expectedFieldPath}, got ${error.fieldPath}`,
+ );
+ assert(
+ typeof error.message === 'string' &&
+ error.message.includes(expectedCode) &&
+ error.message.includes(expectedFieldPath) &&
+ error.message.includes(deterministicSourcePath),
+ `${testLabel} includes deterministic message context`,
+ );
+ if (expectedDetail !== null) {
+ assert(
+ error.detail === expectedDetail,
+ `${testLabel} returns locked detail string`,
+ `Expected "${expectedDetail}", got "${error.detail}"`,
+ );
+ }
+ }
+ };
+
+ try {
+ await writeTempSidecar(validHelpSidecar);
+ await validateHelpSidecarContractFile(tempSidecarPath, { errorSourcePath: deterministicSourcePath });
+ assert(true, 'Valid sidecar contract passes');
+
+ for (const requiredField of HELP_SIDECAR_REQUIRED_FIELDS.filter((field) => field !== 'dependencies')) {
+ const invalidSidecar = structuredClone(validHelpSidecar);
+ delete invalidSidecar[requiredField];
+ await expectValidationError(
+ invalidSidecar,
+ HELP_SIDECAR_ERROR_CODES.REQUIRED_FIELD_MISSING,
+ requiredField,
+ `Missing required field "${requiredField}"`,
+ );
+ }
+
+ await expectValidationError(
+ { ...validHelpSidecar, artifactType: 'workflow' },
+ HELP_SIDECAR_ERROR_CODES.ARTIFACT_TYPE_INVALID,
+ 'artifactType',
+ 'Invalid artifactType',
+ );
+
+ await expectValidationError(
+ { ...validHelpSidecar, module: 'bmm' },
+ HELP_SIDECAR_ERROR_CODES.MODULE_INVALID,
+ 'module',
+ 'Invalid module',
+ );
+
+ await expectValidationError(
+ { ...validHelpSidecar, schemaVersion: 2 },
+ HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED,
+ 'schemaVersion',
+ 'Unsupported sidecar major schema version',
+ expectedUnsupportedMajorDetail,
+ );
+
+ await expectValidationError(
+ { ...validHelpSidecar, canonicalId: ' ' },
+ HELP_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'canonicalId',
+ 'Empty canonicalId',
+ );
+
+ await expectValidationError(
+ { ...validHelpSidecar, sourcePath: '' },
+ HELP_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'sourcePath',
+ 'Empty sourcePath',
+ );
+
+ await expectValidationError(
+ { ...validHelpSidecar, sourcePath: 'bmad-fork/src/core/tasks/not-help.md' },
+ HELP_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH,
+ 'sourcePath',
+ 'Source path mismatch with exemplar contract',
+ expectedBasenameMismatchDetail,
+ );
+
+ const mismatchedBasenamePath = path.join(tempSidecarRoot, 'not-help.artifact.yaml');
+ await fs.writeFile(mismatchedBasenamePath, yaml.stringify(validHelpSidecar), 'utf8');
+ try {
+ await validateHelpSidecarContractFile(mismatchedBasenamePath, {
+ errorSourcePath: 'bmad-fork/src/core/tasks/not-help.artifact.yaml',
+ });
+ assert(false, 'Sidecar basename mismatch returns validation error', 'Expected validation error but validation passed');
+ } catch (error) {
+ assert(error.code === HELP_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH, 'Sidecar basename mismatch returns expected error code');
+ assert(
+ error.fieldPath === 'sourcePath',
+ 'Sidecar basename mismatch returns expected field path',
+ `Expected sourcePath, got ${error.fieldPath}`,
+ );
+ assert(
+ typeof error.message === 'string' &&
+ error.message.includes(HELP_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH) &&
+ error.message.includes('bmad-fork/src/core/tasks/not-help.artifact.yaml'),
+ 'Sidecar basename mismatch includes deterministic message context',
+ );
+ assert(
+ error.detail === expectedBasenameMismatchDetail,
+ 'Sidecar basename mismatch returns locked detail string',
+ `Expected "${expectedBasenameMismatchDetail}", got "${error.detail}"`,
+ );
+ }
+
+ const missingDependencies = structuredClone(validHelpSidecar);
+ delete missingDependencies.dependencies;
+ await expectValidationError(
+ missingDependencies,
+ HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING,
+ 'dependencies',
+ 'Missing dependencies block',
+ );
+
+ await expectValidationError(
+ { ...validHelpSidecar, dependencies: { requires: 'skill:bmad-help' } },
+ HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_INVALID,
+ 'dependencies.requires',
+ 'Non-array dependencies.requires',
+ );
+
+ await expectValidationError(
+ { ...validHelpSidecar, dependencies: { requires: ['skill:bmad-help'] } },
+ HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_NOT_EMPTY,
+ 'dependencies.requires',
+ 'Non-empty dependencies.requires',
+ );
+ } catch (error) {
+ assert(false, 'Sidecar validation suite setup', error.message);
+ } finally {
+ await fs.remove(tempSidecarRoot);
+ }
+
+ console.log('');
+
+ // ============================================================
+ // Test 4b: Shard-doc Sidecar Contract Validation
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 4b: Shard-doc Sidecar Contract Validation${colors.reset}\n`);
+
+ const validShardDocSidecar = {
+ schemaVersion: 1,
+ canonicalId: 'bmad-shard-doc',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml',
+ displayName: 'Shard Document',
+ description: 'Split large markdown documents into smaller files by section with an index.',
+ dependencies: {
+ requires: [],
+ },
+ };
+
+ const shardDocFixtureRoot = path.join(projectRoot, 'test', 'fixtures', 'shard-doc', 'sidecar-negative');
+ const unknownMajorFixturePath = path.join(shardDocFixtureRoot, 'unknown-major-version', 'shard-doc.artifact.yaml');
+ const basenameMismatchFixturePath = path.join(shardDocFixtureRoot, 'basename-path-mismatch', 'shard-doc.artifact.yaml');
+
+ const tempShardDocRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-shard-doc-sidecar-'));
+ const tempShardDocSidecarPath = path.join(tempShardDocRoot, 'shard-doc.artifact.yaml');
+ const deterministicShardDocSourcePath = 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml';
+
+ const writeTempShardDocSidecar = async (data) => {
+ await fs.writeFile(tempShardDocSidecarPath, yaml.stringify(data), 'utf8');
+ };
+
+ const expectShardDocValidationError = async (data, expectedCode, expectedFieldPath, testLabel, expectedDetail = null) => {
+ await writeTempShardDocSidecar(data);
+
+ try {
+ await validateShardDocSidecarContractFile(tempShardDocSidecarPath, { errorSourcePath: deterministicShardDocSourcePath });
+ assert(false, testLabel, 'Expected validation error but validation passed');
+ } catch (error) {
+ assert(error.code === expectedCode, `${testLabel} returns expected error code`, `Expected ${expectedCode}, got ${error.code}`);
+ assert(
+ error.fieldPath === expectedFieldPath,
+ `${testLabel} returns expected field path`,
+ `Expected ${expectedFieldPath}, got ${error.fieldPath}`,
+ );
+ assert(
+ error.sourcePath === deterministicShardDocSourcePath,
+ `${testLabel} returns expected source path`,
+ `Expected ${deterministicShardDocSourcePath}, got ${error.sourcePath}`,
+ );
+ assert(
+ typeof error.message === 'string' &&
+ error.message.includes(expectedCode) &&
+ error.message.includes(expectedFieldPath) &&
+ error.message.includes(deterministicShardDocSourcePath),
+ `${testLabel} includes deterministic message context`,
+ );
+ if (expectedDetail !== null) {
+ assert(
+ error.detail === expectedDetail,
+ `${testLabel} returns locked detail string`,
+ `Expected "${expectedDetail}", got "${error.detail}"`,
+ );
+ }
+ }
+ };
+
+ try {
+ await writeTempShardDocSidecar(validShardDocSidecar);
+ await validateShardDocSidecarContractFile(tempShardDocSidecarPath, { errorSourcePath: deterministicShardDocSourcePath });
+ assert(true, 'Valid shard-doc sidecar contract passes');
+
+ for (const requiredField of SHARD_DOC_SIDECAR_REQUIRED_FIELDS.filter((field) => field !== 'dependencies')) {
+ const invalidSidecar = structuredClone(validShardDocSidecar);
+ delete invalidSidecar[requiredField];
+ await expectShardDocValidationError(
+ invalidSidecar,
+ SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_MISSING,
+ requiredField,
+ `Shard-doc missing required field "${requiredField}"`,
+ );
+ }
+
+ const unknownMajorFixture = yaml.parse(await fs.readFile(unknownMajorFixturePath, 'utf8'));
+ await expectShardDocValidationError(
+ unknownMajorFixture,
+ SHARD_DOC_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED,
+ 'schemaVersion',
+ 'Shard-doc unsupported sidecar major schema version',
+ 'sidecar schema major version is unsupported',
+ );
+
+ const basenameMismatchFixture = yaml.parse(await fs.readFile(basenameMismatchFixturePath, 'utf8'));
+ await expectShardDocValidationError(
+ basenameMismatchFixture,
+ SHARD_DOC_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH,
+ 'sourcePath',
+ 'Shard-doc sourcePath mismatch',
+ 'sidecar basename does not match sourcePath basename',
+ );
+
+ const mismatchedShardDocBasenamePath = path.join(tempShardDocRoot, 'not-shard-doc.artifact.yaml');
+ await fs.writeFile(mismatchedShardDocBasenamePath, yaml.stringify(validShardDocSidecar), 'utf8');
+ try {
+ await validateShardDocSidecarContractFile(mismatchedShardDocBasenamePath, {
+ errorSourcePath: 'bmad-fork/src/core/tasks/not-shard-doc.artifact.yaml',
+ });
+ assert(false, 'Shard-doc basename mismatch returns validation error', 'Expected validation error but validation passed');
+ } catch (error) {
+ assert(
+ error.code === SHARD_DOC_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH,
+ 'Shard-doc basename mismatch returns expected error code',
+ );
+ assert(
+ error.fieldPath === 'sourcePath',
+ 'Shard-doc basename mismatch returns expected field path',
+ `Expected sourcePath, got ${error.fieldPath}`,
+ );
+ assert(
+ typeof error.message === 'string' &&
+ error.message.includes(SHARD_DOC_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH) &&
+ error.message.includes('bmad-fork/src/core/tasks/not-shard-doc.artifact.yaml'),
+ 'Shard-doc basename mismatch includes deterministic message context',
+ );
+ }
+
+ await expectShardDocValidationError(
+ { ...validShardDocSidecar, artifactType: 'workflow' },
+ SHARD_DOC_SIDECAR_ERROR_CODES.ARTIFACT_TYPE_INVALID,
+ 'artifactType',
+ 'Shard-doc invalid artifactType',
+ );
+
+ await expectShardDocValidationError(
+ { ...validShardDocSidecar, module: 'bmm' },
+ SHARD_DOC_SIDECAR_ERROR_CODES.MODULE_INVALID,
+ 'module',
+ 'Shard-doc invalid module',
+ );
+
+ await expectShardDocValidationError(
+ { ...validShardDocSidecar, canonicalId: ' ' },
+ SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'canonicalId',
+ 'Shard-doc empty canonicalId',
+ );
+
+ await expectShardDocValidationError(
+ { ...validShardDocSidecar, sourcePath: '' },
+ SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'sourcePath',
+ 'Shard-doc empty sourcePath',
+ );
+
+ await expectShardDocValidationError(
+ { ...validShardDocSidecar, description: '' },
+ SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'description',
+ 'Shard-doc empty description',
+ );
+
+ await expectShardDocValidationError(
+ { ...validShardDocSidecar, displayName: '' },
+ SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'displayName',
+ 'Shard-doc empty displayName',
+ );
+
+ const missingShardDocDependencies = structuredClone(validShardDocSidecar);
+ delete missingShardDocDependencies.dependencies;
+ await expectShardDocValidationError(
+ missingShardDocDependencies,
+ SHARD_DOC_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING,
+ 'dependencies',
+ 'Shard-doc missing dependencies block',
+ );
+
+ await expectShardDocValidationError(
+ { ...validShardDocSidecar, dependencies: { requires: 'skill:bmad-help' } },
+ SHARD_DOC_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_INVALID,
+ 'dependencies.requires',
+ 'Shard-doc non-array dependencies.requires',
+ );
+
+ await expectShardDocValidationError(
+ { ...validShardDocSidecar, dependencies: { requires: ['skill:bmad-help'] } },
+ SHARD_DOC_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_NOT_EMPTY,
+ 'dependencies.requires',
+ 'Shard-doc non-empty dependencies.requires',
+ );
+ } catch (error) {
+ assert(false, 'Shard-doc sidecar validation suite setup', error.message);
+ } finally {
+ await fs.remove(tempShardDocRoot);
+ }
+
+ console.log('');
+
+ // ============================================================
+ // Test 4c: Index-docs Sidecar Contract Validation
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 4c: Index-docs Sidecar Contract Validation${colors.reset}\n`);
+
+ const validIndexDocsSidecar = {
+ 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: [],
+ },
+ };
+
+ const indexDocsFixtureRoot = path.join(projectRoot, 'test', 'fixtures', 'index-docs', 'sidecar-negative');
+ const indexDocsUnknownMajorFixturePath = path.join(indexDocsFixtureRoot, 'unknown-major-version', 'index-docs.artifact.yaml');
+ const indexDocsBasenameMismatchFixturePath = path.join(indexDocsFixtureRoot, 'basename-path-mismatch', 'index-docs.artifact.yaml');
+
+ const tempIndexDocsRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-index-docs-sidecar-'));
+ const tempIndexDocsSidecarPath = path.join(tempIndexDocsRoot, 'index-docs.artifact.yaml');
+ const deterministicIndexDocsSourcePath = 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml';
+
+ const writeTempIndexDocsSidecar = async (data) => {
+ await fs.writeFile(tempIndexDocsSidecarPath, yaml.stringify(data), 'utf8');
+ };
+
+ const expectIndexDocsValidationError = async (data, expectedCode, expectedFieldPath, testLabel, expectedDetail = null) => {
+ await writeTempIndexDocsSidecar(data);
+
+ try {
+ await validateIndexDocsSidecarContractFile(tempIndexDocsSidecarPath, { errorSourcePath: deterministicIndexDocsSourcePath });
+ assert(false, testLabel, 'Expected validation error but validation passed');
+ } catch (error) {
+ assert(error.code === expectedCode, `${testLabel} returns expected error code`, `Expected ${expectedCode}, got ${error.code}`);
+ assert(
+ error.fieldPath === expectedFieldPath,
+ `${testLabel} returns expected field path`,
+ `Expected ${expectedFieldPath}, got ${error.fieldPath}`,
+ );
+ assert(
+ error.sourcePath === deterministicIndexDocsSourcePath,
+ `${testLabel} returns expected source path`,
+ `Expected ${deterministicIndexDocsSourcePath}, got ${error.sourcePath}`,
+ );
+ assert(
+ typeof error.message === 'string' &&
+ error.message.includes(expectedCode) &&
+ error.message.includes(expectedFieldPath) &&
+ error.message.includes(deterministicIndexDocsSourcePath),
+ `${testLabel} includes deterministic message context`,
+ );
+ if (expectedDetail !== null) {
+ assert(
+ error.detail === expectedDetail,
+ `${testLabel} returns locked detail string`,
+ `Expected "${expectedDetail}", got "${error.detail}"`,
+ );
+ }
+ }
+ };
+
+ try {
+ await writeTempIndexDocsSidecar(validIndexDocsSidecar);
+ await validateIndexDocsSidecarContractFile(tempIndexDocsSidecarPath, { errorSourcePath: deterministicIndexDocsSourcePath });
+ assert(true, 'Valid index-docs sidecar contract passes');
+
+ for (const requiredField of INDEX_DOCS_SIDECAR_REQUIRED_FIELDS.filter((field) => field !== 'dependencies')) {
+ const invalidSidecar = structuredClone(validIndexDocsSidecar);
+ delete invalidSidecar[requiredField];
+ await expectIndexDocsValidationError(
+ invalidSidecar,
+ INDEX_DOCS_SIDECAR_ERROR_CODES.REQUIRED_FIELD_MISSING,
+ requiredField,
+ `Index-docs missing required field "${requiredField}"`,
+ );
+ }
+
+ const unknownMajorFixture = yaml.parse(await fs.readFile(indexDocsUnknownMajorFixturePath, 'utf8'));
+ await expectIndexDocsValidationError(
+ unknownMajorFixture,
+ INDEX_DOCS_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED,
+ 'schemaVersion',
+ 'Index-docs unsupported sidecar major schema version',
+ 'sidecar schema major version is unsupported',
+ );
+
+ const basenameMismatchFixture = yaml.parse(await fs.readFile(indexDocsBasenameMismatchFixturePath, 'utf8'));
+ await expectIndexDocsValidationError(
+ basenameMismatchFixture,
+ INDEX_DOCS_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH,
+ 'sourcePath',
+ 'Index-docs sourcePath mismatch',
+ 'sidecar basename does not match sourcePath basename',
+ );
+
+ const mismatchedIndexDocsBasenamePath = path.join(tempIndexDocsRoot, 'not-index-docs.artifact.yaml');
+ await fs.writeFile(mismatchedIndexDocsBasenamePath, yaml.stringify(validIndexDocsSidecar), 'utf8');
+ try {
+ await validateIndexDocsSidecarContractFile(mismatchedIndexDocsBasenamePath, {
+ errorSourcePath: 'bmad-fork/src/core/tasks/not-index-docs.artifact.yaml',
+ });
+ assert(false, 'Index-docs basename mismatch returns validation error', 'Expected validation error but validation passed');
+ } catch (error) {
+ assert(
+ error.code === INDEX_DOCS_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH,
+ 'Index-docs basename mismatch returns expected error code',
+ );
+ assert(
+ error.fieldPath === 'sourcePath',
+ 'Index-docs basename mismatch returns expected field path',
+ `Expected sourcePath, got ${error.fieldPath}`,
+ );
+ assert(
+ typeof error.message === 'string' &&
+ error.message.includes(INDEX_DOCS_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH) &&
+ error.message.includes('bmad-fork/src/core/tasks/not-index-docs.artifact.yaml'),
+ 'Index-docs basename mismatch includes deterministic message context',
+ );
+ }
+
+ await expectIndexDocsValidationError(
+ { ...validIndexDocsSidecar, artifactType: 'workflow' },
+ INDEX_DOCS_SIDECAR_ERROR_CODES.ARTIFACT_TYPE_INVALID,
+ 'artifactType',
+ 'Index-docs invalid artifactType',
+ );
+
+ await expectIndexDocsValidationError(
+ { ...validIndexDocsSidecar, module: 'bmm' },
+ INDEX_DOCS_SIDECAR_ERROR_CODES.MODULE_INVALID,
+ 'module',
+ 'Index-docs invalid module',
+ );
+
+ await expectIndexDocsValidationError(
+ { ...validIndexDocsSidecar, canonicalId: ' ' },
+ INDEX_DOCS_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'canonicalId',
+ 'Index-docs empty canonicalId',
+ );
+
+ await expectIndexDocsValidationError(
+ { ...validIndexDocsSidecar, sourcePath: '' },
+ INDEX_DOCS_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'sourcePath',
+ 'Index-docs empty sourcePath',
+ );
+
+ await expectIndexDocsValidationError(
+ { ...validIndexDocsSidecar, description: '' },
+ INDEX_DOCS_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'description',
+ 'Index-docs empty description',
+ );
+
+ await expectIndexDocsValidationError(
+ { ...validIndexDocsSidecar, displayName: '' },
+ INDEX_DOCS_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'displayName',
+ 'Index-docs empty displayName',
+ );
+
+ const missingIndexDocsDependencies = structuredClone(validIndexDocsSidecar);
+ delete missingIndexDocsDependencies.dependencies;
+ await expectIndexDocsValidationError(
+ missingIndexDocsDependencies,
+ INDEX_DOCS_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING,
+ 'dependencies',
+ 'Index-docs missing dependencies block',
+ );
+
+ await expectIndexDocsValidationError(
+ { ...validIndexDocsSidecar, dependencies: { requires: 'skill:bmad-help' } },
+ INDEX_DOCS_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_INVALID,
+ 'dependencies.requires',
+ 'Index-docs non-array dependencies.requires',
+ );
+
+ await expectIndexDocsValidationError(
+ { ...validIndexDocsSidecar, dependencies: { requires: ['skill:bmad-help'] } },
+ INDEX_DOCS_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_NOT_EMPTY,
+ 'dependencies.requires',
+ 'Index-docs non-empty dependencies.requires',
+ );
+ } catch (error) {
+ assert(false, 'Index-docs sidecar validation suite setup', error.message);
+ } finally {
+ await fs.remove(tempIndexDocsRoot);
+ }
+
+ console.log('');
+
+ // ============================================================
+ // Test 4d: Skill Metadata Filename Authority Resolution
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 4d: Skill Metadata Filename Authority Resolution${colors.reset}\n`);
+ try {
+ const convertedCapabilitySources = [
+ { label: 'help', sourceFilename: 'help.md', artifactFilename: 'help.artifact.yaml' },
+ { label: 'shard-doc', sourceFilename: 'shard-doc.xml', artifactFilename: 'shard-doc.artifact.yaml' },
+ { label: 'index-docs', sourceFilename: 'index-docs.xml', artifactFilename: 'index-docs.artifact.yaml' },
+ ];
+
+ const withResolverWorkspace = async (sourceFilename, callback) => {
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), `bmad-metadata-authority-${sourceFilename.replaceAll(/\W+/g, '-')}-`));
+ try {
+ const tasksDir = path.join(tempRoot, 'src', 'core', 'tasks');
+ await fs.ensureDir(tasksDir);
+
+ const sourcePath = path.join(tasksDir, sourceFilename);
+ await fs.writeFile(sourcePath, '# source\n', 'utf8');
+
+ const sourceStem = path.basename(sourceFilename, path.extname(sourceFilename));
+ const skillDir = path.join(tasksDir, sourceStem);
+ await fs.ensureDir(skillDir);
+
+ await callback({
+ tempRoot,
+ tasksDir,
+ sourcePath,
+ skillDir,
+ });
+ } finally {
+ await fs.remove(tempRoot);
+ }
+ };
+
+ for (const sourceConfig of convertedCapabilitySources) {
+ const { label, sourceFilename, artifactFilename } = sourceConfig;
+
+ await withResolverWorkspace(sourceFilename, async ({ tempRoot, tasksDir, sourcePath, skillDir }) => {
+ await fs.writeFile(path.join(skillDir, 'skill-manifest.yaml'), 'canonicalId: canonical\n', 'utf8');
+ await fs.writeFile(path.join(skillDir, 'bmad-config.yaml'), 'canonicalId: bmad-config\n', 'utf8');
+ await fs.writeFile(path.join(skillDir, 'manifest.yaml'), 'canonicalId: manifest\n', 'utf8');
+ await fs.writeFile(path.join(tasksDir, artifactFilename), 'canonicalId: artifact\n', 'utf8');
+
+ const resolution = await resolveSkillMetadataAuthority({
+ sourceFilePath: sourcePath,
+ projectRoot: tempRoot,
+ });
+ assert(
+ resolution.resolvedFilename === 'skill-manifest.yaml' && resolution.derivationMode === 'canonical',
+ `${label} resolver prioritizes per-skill canonical skill-manifest.yaml over legacy metadata files`,
+ );
+ });
+
+ await withResolverWorkspace(sourceFilename, async ({ tempRoot, tasksDir, sourcePath, skillDir }) => {
+ await fs.writeFile(path.join(skillDir, 'bmad-config.yaml'), 'canonicalId: bmad-config\n', 'utf8');
+ await fs.writeFile(path.join(skillDir, 'manifest.yaml'), 'canonicalId: manifest\n', 'utf8');
+ await fs.writeFile(path.join(tasksDir, artifactFilename), 'canonicalId: artifact\n', 'utf8');
+
+ const resolution = await resolveSkillMetadataAuthority({
+ sourceFilePath: sourcePath,
+ projectRoot: tempRoot,
+ });
+ assert(
+ resolution.resolvedFilename === 'bmad-config.yaml' && resolution.derivationMode === 'legacy-fallback',
+ `${label} resolver falls back to bmad-config.yaml before manifest.yaml and *.artifact.yaml`,
+ );
+ });
+
+ await withResolverWorkspace(sourceFilename, async ({ tempRoot, tasksDir, sourcePath, skillDir }) => {
+ await fs.writeFile(path.join(skillDir, 'manifest.yaml'), 'canonicalId: manifest\n', 'utf8');
+ await fs.writeFile(path.join(tasksDir, artifactFilename), 'canonicalId: artifact\n', 'utf8');
+
+ const resolution = await resolveSkillMetadataAuthority({
+ sourceFilePath: sourcePath,
+ projectRoot: tempRoot,
+ });
+ assert(
+ resolution.resolvedFilename === 'manifest.yaml' && resolution.derivationMode === 'legacy-fallback',
+ `${label} resolver falls back to manifest.yaml before *.artifact.yaml`,
+ );
+ });
+
+ await withResolverWorkspace(sourceFilename, async ({ tempRoot, tasksDir, sourcePath }) => {
+ await fs.writeFile(path.join(tasksDir, artifactFilename), 'canonicalId: artifact\n', 'utf8');
+
+ const resolution = await resolveSkillMetadataAuthority({
+ sourceFilePath: sourcePath,
+ projectRoot: tempRoot,
+ });
+ assert(
+ resolution.resolvedFilename === artifactFilename && resolution.derivationMode === 'legacy-fallback',
+ `${label} resolver supports capability-scoped *.artifact.yaml fallback`,
+ );
+ });
+
+ await withResolverWorkspace(sourceFilename, async ({ tempRoot, tasksDir, sourcePath }) => {
+ await fs.writeFile(path.join(tasksDir, 'skill-manifest.yaml'), 'canonicalId: root-canonical\n', 'utf8');
+ await fs.writeFile(path.join(tasksDir, artifactFilename), 'canonicalId: artifact\n', 'utf8');
+
+ const resolution = await resolveSkillMetadataAuthority({
+ sourceFilePath: sourcePath,
+ projectRoot: tempRoot,
+ });
+ assert(
+ resolution.resolvedFilename === artifactFilename,
+ `${label} resolver does not treat root task-folder skill-manifest.yaml as per-skill canonical authority`,
+ );
+ });
+
+ await withResolverWorkspace(sourceFilename, async ({ tempRoot, tasksDir, sourcePath, skillDir }) => {
+ await fs.writeFile(path.join(tasksDir, 'bmad-config.yaml'), 'canonicalId: root-bmad-config\n', 'utf8');
+ await fs.writeFile(path.join(skillDir, 'bmad-config.yaml'), 'canonicalId: skill-bmad-config\n', 'utf8');
+
+ try {
+ await resolveSkillMetadataAuthority({
+ sourceFilePath: sourcePath,
+ projectRoot: tempRoot,
+ });
+ assert(false, `${label} resolver rejects ambiguous bmad-config.yaml coexistence across legacy locations`);
+ } catch (error) {
+ assert(
+ error.code === SKILL_METADATA_RESOLUTION_ERROR_CODES.AMBIGUOUS_MATCH,
+ `${label} resolver emits deterministic ambiguity code for bmad-config.yaml coexistence`,
+ );
+ }
+ });
+ }
+ } catch (error) {
+ assert(false, 'Skill metadata filename authority resolver suite setup', error.message);
+ }
+
+ console.log('');
+
+ // ============================================================
+ // Test 5: Authority Split and Frontmatter Precedence
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 5: Authority Split and Precedence${colors.reset}\n`);
+
+ const tempAuthorityRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-help-authority-'));
+ const tempAuthoritySidecarPath = path.join(tempAuthorityRoot, 'help.artifact.yaml');
+ const tempAuthoritySourcePath = path.join(tempAuthorityRoot, 'help-source.md');
+ const tempAuthorityRuntimePath = path.join(tempAuthorityRoot, 'help-runtime.md');
+
+ const deterministicAuthorityPaths = {
+ sidecar: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ source: 'bmad-fork/src/core/tasks/help.md',
+ runtime: '_bmad/core/tasks/help.md',
+ };
+
+ const writeMarkdownWithFrontmatter = async (filePath, frontmatter) => {
+ const frontmatterBody = yaml.stringify(frontmatter).trimEnd();
+ await fs.writeFile(filePath, `---\n${frontmatterBody}\n---\n\n# Placeholder\n`, 'utf8');
+ };
+
+ const validAuthoritySidecar = {
+ schemaVersion: 1,
+ canonicalId: 'bmad-help',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: deterministicAuthorityPaths.source,
+ displayName: 'help',
+ description: 'Help command',
+ dependencies: {
+ requires: [],
+ },
+ };
+
+ const validAuthorityFrontmatter = {
+ name: 'help',
+ description: 'Help command',
+ canonicalId: 'bmad-help',
+ dependencies: {
+ requires: [],
+ },
+ };
+
+ const runAuthorityValidation = async () =>
+ validateHelpAuthoritySplitAndPrecedence({
+ sidecarPath: tempAuthoritySidecarPath,
+ sourceMarkdownPath: tempAuthoritySourcePath,
+ runtimeMarkdownPath: tempAuthorityRuntimePath,
+ sidecarSourcePath: deterministicAuthorityPaths.sidecar,
+ sourceMarkdownSourcePath: deterministicAuthorityPaths.source,
+ runtimeMarkdownSourcePath: deterministicAuthorityPaths.runtime,
+ });
+
+ const expectAuthorityValidationError = async (
+ sourceFrontmatter,
+ runtimeFrontmatter,
+ expectedCode,
+ expectedFieldPath,
+ expectedSourcePath,
+ testLabel,
+ ) => {
+ await writeMarkdownWithFrontmatter(tempAuthoritySourcePath, sourceFrontmatter);
+ await writeMarkdownWithFrontmatter(tempAuthorityRuntimePath, runtimeFrontmatter);
+
+ try {
+ await runAuthorityValidation();
+ assert(false, testLabel, 'Expected authority validation error but validation passed');
+ } catch (error) {
+ assert(error.code === expectedCode, `${testLabel} returns expected error code`, `Expected ${expectedCode}, got ${error.code}`);
+ assert(
+ error.fieldPath === expectedFieldPath,
+ `${testLabel} returns expected field path`,
+ `Expected ${expectedFieldPath}, got ${error.fieldPath}`,
+ );
+ assert(
+ error.sourcePath === expectedSourcePath,
+ `${testLabel} returns expected source path`,
+ `Expected ${expectedSourcePath}, got ${error.sourcePath}`,
+ );
+ assert(
+ typeof error.message === 'string' &&
+ error.message.includes(expectedCode) &&
+ error.message.includes(expectedFieldPath) &&
+ error.message.includes(expectedSourcePath),
+ `${testLabel} includes deterministic message context`,
+ );
+ }
+ };
+
+ try {
+ await fs.writeFile(tempAuthoritySidecarPath, yaml.stringify(validAuthoritySidecar), 'utf8');
+ await writeMarkdownWithFrontmatter(tempAuthoritySourcePath, validAuthorityFrontmatter);
+ await writeMarkdownWithFrontmatter(tempAuthorityRuntimePath, validAuthorityFrontmatter);
+
+ const authorityValidation = await runAuthorityValidation();
+ assert(
+ authorityValidation.authoritativePresenceKey === 'capability:bmad-help',
+ 'Authority validation returns shared authoritative presence key',
+ );
+ assert(
+ Array.isArray(authorityValidation.authoritativeRecords) && authorityValidation.authoritativeRecords.length === 2,
+ 'Authority validation returns sidecar and source authority records',
+ );
+
+ const sidecarRecord = authorityValidation.authoritativeRecords.find((record) => record.authoritySourceType === 'sidecar');
+ const sourceRecord = authorityValidation.authoritativeRecords.find((record) => record.authoritySourceType === 'source-markdown');
+
+ assert(
+ sidecarRecord && sourceRecord && sidecarRecord.authoritativePresenceKey === sourceRecord.authoritativePresenceKey,
+ 'Source markdown and sidecar records share one authoritative presence key',
+ );
+ assert(
+ sidecarRecord && sidecarRecord.authoritySourcePath === deterministicAuthorityPaths.sidecar,
+ 'Sidecar authority record preserves truthful sidecar source path',
+ );
+ assert(
+ sourceRecord && sourceRecord.authoritySourcePath === deterministicAuthorityPaths.source,
+ 'Source body authority record preserves truthful source markdown path',
+ );
+
+ const manifestGenerator = new ManifestGenerator();
+ manifestGenerator.modules = ['core'];
+ manifestGenerator.bmadDir = tempAuthorityRoot;
+ manifestGenerator.selectedIdes = [];
+ manifestGenerator.helpAuthorityRecords = authorityValidation.authoritativeRecords;
+
+ const tempManifestConfigDir = path.join(tempAuthorityRoot, '_config');
+ await fs.ensureDir(tempManifestConfigDir);
+ await manifestGenerator.writeMainManifest(tempManifestConfigDir);
+
+ const writtenManifestRaw = await fs.readFile(path.join(tempManifestConfigDir, 'manifest.yaml'), 'utf8');
+ const writtenManifest = yaml.parse(writtenManifestRaw);
+
+ assert(
+ writtenManifest.helpAuthority && Array.isArray(writtenManifest.helpAuthority.records),
+ 'Manifest generation persists help authority records',
+ );
+ assert(
+ writtenManifest.helpAuthority && writtenManifest.helpAuthority.records && writtenManifest.helpAuthority.records.length === 2,
+ 'Manifest generation persists both authority records',
+ );
+ assert(
+ writtenManifest.helpAuthority &&
+ writtenManifest.helpAuthority.records.some(
+ (record) => record.authoritySourceType === 'sidecar' && record.authoritySourcePath === deterministicAuthorityPaths.sidecar,
+ ),
+ 'Manifest generation preserves sidecar authority provenance',
+ );
+ assert(
+ writtenManifest.helpAuthority &&
+ writtenManifest.helpAuthority.records.some(
+ (record) => record.authoritySourceType === 'source-markdown' && record.authoritySourcePath === deterministicAuthorityPaths.source,
+ ),
+ 'Manifest generation preserves source-markdown authority provenance',
+ );
+
+ await expectAuthorityValidationError(
+ { ...validAuthorityFrontmatter, canonicalId: 'legacy-help' },
+ validAuthorityFrontmatter,
+ HELP_FRONTMATTER_MISMATCH_ERROR_CODES.CANONICAL_ID_MISMATCH,
+ 'canonicalId',
+ deterministicAuthorityPaths.source,
+ 'Source canonicalId mismatch',
+ );
+
+ await expectAuthorityValidationError(
+ { ...validAuthorityFrontmatter, name: 'BMAD Help' },
+ validAuthorityFrontmatter,
+ HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DISPLAY_NAME_MISMATCH,
+ 'name',
+ deterministicAuthorityPaths.source,
+ 'Source display-name mismatch',
+ );
+
+ await expectAuthorityValidationError(
+ validAuthorityFrontmatter,
+ { ...validAuthorityFrontmatter, description: 'Runtime override' },
+ HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DESCRIPTION_MISMATCH,
+ 'description',
+ deterministicAuthorityPaths.runtime,
+ 'Runtime description mismatch',
+ );
+
+ await expectAuthorityValidationError(
+ { ...validAuthorityFrontmatter, dependencies: { requires: ['skill:other'] } },
+ validAuthorityFrontmatter,
+ HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DEPENDENCIES_REQUIRES_MISMATCH,
+ 'dependencies.requires',
+ deterministicAuthorityPaths.source,
+ 'Source dependencies.requires mismatch',
+ );
+
+ const tempShardDocAuthoritySidecarPath = path.join(tempAuthorityRoot, 'shard-doc.artifact.yaml');
+ const tempShardDocAuthoritySourcePath = path.join(tempAuthorityRoot, 'shard-doc.xml');
+ const tempShardDocModuleHelpPath = path.join(tempAuthorityRoot, 'module-help.csv');
+
+ const deterministicShardDocAuthorityPaths = {
+ sidecar: 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ source: 'bmad-fork/src/core/tasks/shard-doc.xml',
+ compatibility: 'bmad-fork/src/core/module-help.csv',
+ workflowFile: '_bmad/core/tasks/shard-doc.xml',
+ };
+
+ const validShardDocAuthoritySidecar = {
+ schemaVersion: 1,
+ canonicalId: 'bmad-shard-doc',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: deterministicShardDocAuthorityPaths.source,
+ displayName: 'Shard Document',
+ description: 'Split large markdown documents into smaller files by section with an index.',
+ dependencies: {
+ requires: [],
+ },
+ };
+
+ const writeModuleHelpCsv = async (rows) => {
+ const header = 'module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs';
+ const lines = rows.map((row) =>
+ [
+ row.module ?? 'core',
+ row.phase ?? 'anytime',
+ row.name ?? 'Shard Document',
+ row.code ?? 'SD',
+ row.sequence ?? '',
+ row.workflowFile ?? '',
+ row.command ?? '',
+ row.required ?? 'false',
+ row.agent ?? '',
+ row.options ?? '',
+ row.description ?? 'Compatibility row',
+ row.outputLocation ?? '',
+ row.outputs ?? '',
+ ].join(','),
+ );
+
+ await fs.writeFile(tempShardDocModuleHelpPath, [header, ...lines].join('\n'), 'utf8');
+ };
+
+ const runShardDocAuthorityValidation = async () =>
+ validateShardDocAuthoritySplitAndPrecedence({
+ sidecarPath: tempShardDocAuthoritySidecarPath,
+ sourceXmlPath: tempShardDocAuthoritySourcePath,
+ compatibilityCatalogPath: tempShardDocModuleHelpPath,
+ sidecarSourcePath: deterministicShardDocAuthorityPaths.sidecar,
+ sourceXmlSourcePath: deterministicShardDocAuthorityPaths.source,
+ compatibilityCatalogSourcePath: deterministicShardDocAuthorityPaths.compatibility,
+ compatibilityWorkflowFilePath: deterministicShardDocAuthorityPaths.workflowFile,
+ });
+
+ const expectShardDocAuthorityValidationError = async (
+ rows,
+ expectedCode,
+ expectedFieldPath,
+ testLabel,
+ expectedSourcePath = deterministicShardDocAuthorityPaths.compatibility,
+ ) => {
+ await writeModuleHelpCsv(rows);
+
+ try {
+ await runShardDocAuthorityValidation();
+ assert(false, testLabel, 'Expected shard-doc authority validation error but validation passed');
+ } catch (error) {
+ assert(error.code === expectedCode, `${testLabel} returns expected error code`, `Expected ${expectedCode}, got ${error.code}`);
+ assert(
+ error.fieldPath === expectedFieldPath,
+ `${testLabel} returns expected field path`,
+ `Expected ${expectedFieldPath}, got ${error.fieldPath}`,
+ );
+ assert(
+ error.sourcePath === expectedSourcePath,
+ `${testLabel} returns expected source path`,
+ `Expected ${expectedSourcePath}, got ${error.sourcePath}`,
+ );
+ assert(
+ typeof error.message === 'string' &&
+ error.message.includes(expectedCode) &&
+ error.message.includes(expectedFieldPath) &&
+ error.message.includes(expectedSourcePath),
+ `${testLabel} includes deterministic message context`,
+ );
+ }
+ };
+
+ await fs.writeFile(tempShardDocAuthoritySidecarPath, yaml.stringify(validShardDocAuthoritySidecar), 'utf8');
+ await fs.writeFile(tempShardDocAuthoritySourcePath, '\n', 'utf8');
+
+ await writeModuleHelpCsv([
+ {
+ workflowFile: deterministicShardDocAuthorityPaths.workflowFile,
+ command: 'bmad-shard-doc',
+ name: 'Shard Document',
+ },
+ ]);
+
+ const shardDocAuthorityValidation = await runShardDocAuthorityValidation();
+ assert(
+ shardDocAuthorityValidation.authoritativePresenceKey === 'capability:bmad-shard-doc',
+ 'Shard-doc authority validation returns expected authoritative presence key',
+ );
+ assert(
+ Array.isArray(shardDocAuthorityValidation.authoritativeRecords) && shardDocAuthorityValidation.authoritativeRecords.length === 2,
+ 'Shard-doc authority validation returns sidecar and source authority records',
+ );
+
+ const shardDocSidecarRecord = shardDocAuthorityValidation.authoritativeRecords.find(
+ (record) => record.authoritySourceType === 'sidecar',
+ );
+ const shardDocSourceRecord = shardDocAuthorityValidation.authoritativeRecords.find(
+ (record) => record.authoritySourceType === 'source-xml',
+ );
+
+ assert(
+ shardDocSidecarRecord &&
+ shardDocSourceRecord &&
+ shardDocSidecarRecord.authoritativePresenceKey === shardDocSourceRecord.authoritativePresenceKey,
+ 'Shard-doc sidecar and source-xml records share one authoritative presence key',
+ );
+ assert(
+ shardDocSidecarRecord &&
+ shardDocSourceRecord &&
+ shardDocSidecarRecord.authoritativePresenceKey === 'capability:bmad-shard-doc' &&
+ shardDocSourceRecord.authoritativePresenceKey === 'capability:bmad-shard-doc',
+ 'Shard-doc authority records lock authoritative presence key to capability:bmad-shard-doc',
+ );
+ assert(
+ shardDocSidecarRecord && shardDocSidecarRecord.authoritySourcePath === deterministicShardDocAuthorityPaths.sidecar,
+ 'Shard-doc metadata authority record preserves sidecar source path',
+ );
+ assert(
+ shardDocSourceRecord && shardDocSourceRecord.authoritySourcePath === deterministicShardDocAuthorityPaths.source,
+ 'Shard-doc source-body authority record preserves source XML path',
+ );
+
+ await expectShardDocAuthorityValidationError(
+ [
+ {
+ workflowFile: deterministicShardDocAuthorityPaths.workflowFile,
+ command: 'legacy-shard-doc',
+ name: 'Shard Document',
+ },
+ ],
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH,
+ 'command',
+ 'Shard-doc compatibility command mismatch',
+ );
+
+ await expectShardDocAuthorityValidationError(
+ [
+ {
+ workflowFile: '_bmad/core/tasks/help.md',
+ command: 'bmad-shard-doc',
+ name: 'Shard Document',
+ },
+ ],
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_MISSING,
+ 'workflow-file',
+ 'Shard-doc missing compatibility row',
+ );
+
+ await expectShardDocAuthorityValidationError(
+ [
+ {
+ workflowFile: deterministicShardDocAuthorityPaths.workflowFile,
+ command: 'bmad-shard-doc',
+ name: 'Shard Document',
+ },
+ {
+ workflowFile: '_bmad/core/tasks/another.xml',
+ command: 'bmad-shard-doc',
+ name: 'Shard Document',
+ },
+ ],
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.DUPLICATE_CANONICAL_COMMAND,
+ 'command',
+ 'Shard-doc duplicate canonical command rows',
+ );
+
+ await fs.writeFile(
+ tempShardDocAuthoritySidecarPath,
+ yaml.stringify({
+ ...validShardDocAuthoritySidecar,
+ canonicalId: 'bmad-shard-doc-renamed',
+ }),
+ 'utf8',
+ );
+
+ await expectShardDocAuthorityValidationError(
+ [
+ {
+ workflowFile: deterministicShardDocAuthorityPaths.workflowFile,
+ command: 'bmad-shard-doc-renamed',
+ name: 'Shard Document',
+ },
+ ],
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH,
+ 'canonicalId',
+ 'Shard-doc canonicalId drift fails deterministic authority validation',
+ deterministicShardDocAuthorityPaths.sidecar,
+ );
+
+ await fs.writeFile(tempShardDocAuthoritySidecarPath, yaml.stringify(validShardDocAuthoritySidecar), 'utf8');
+
+ const tempIndexDocsAuthoritySidecarPath = path.join(tempAuthorityRoot, 'index-docs.artifact.yaml');
+ const tempIndexDocsAuthoritySourcePath = path.join(tempAuthorityRoot, 'index-docs.xml');
+ const tempIndexDocsModuleHelpPath = path.join(tempAuthorityRoot, 'index-docs-module-help.csv');
+
+ const deterministicIndexDocsAuthorityPaths = {
+ sidecar: 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml',
+ source: 'bmad-fork/src/core/tasks/index-docs.xml',
+ compatibility: 'bmad-fork/src/core/module-help.csv',
+ workflowFile: '_bmad/core/tasks/index-docs.xml',
+ };
+
+ const validIndexDocsAuthoritySidecar = {
+ schemaVersion: 1,
+ canonicalId: 'bmad-index-docs',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: deterministicIndexDocsAuthorityPaths.source,
+ displayName: 'Index Docs',
+ description:
+ 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.',
+ dependencies: {
+ requires: [],
+ },
+ };
+
+ const writeIndexDocsModuleHelpCsv = async (rows) => {
+ const header = 'module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs';
+ const lines = rows.map((row) =>
+ [
+ row.module ?? 'core',
+ row.phase ?? 'anytime',
+ row.name ?? 'Index Docs',
+ row.code ?? 'ID',
+ row.sequence ?? '',
+ row.workflowFile ?? '',
+ row.command ?? '',
+ row.required ?? 'false',
+ row.agent ?? '',
+ row.options ?? '',
+ row.description ?? 'Compatibility row',
+ row.outputLocation ?? '',
+ row.outputs ?? '',
+ ].join(','),
+ );
+
+ await fs.writeFile(tempIndexDocsModuleHelpPath, [header, ...lines].join('\n'), 'utf8');
+ };
+
+ const runIndexDocsAuthorityValidation = async () =>
+ validateIndexDocsAuthoritySplitAndPrecedence({
+ sidecarPath: tempIndexDocsAuthoritySidecarPath,
+ sourceXmlPath: tempIndexDocsAuthoritySourcePath,
+ compatibilityCatalogPath: tempIndexDocsModuleHelpPath,
+ sidecarSourcePath: deterministicIndexDocsAuthorityPaths.sidecar,
+ sourceXmlSourcePath: deterministicIndexDocsAuthorityPaths.source,
+ compatibilityCatalogSourcePath: deterministicIndexDocsAuthorityPaths.compatibility,
+ compatibilityWorkflowFilePath: deterministicIndexDocsAuthorityPaths.workflowFile,
+ });
+
+ const expectIndexDocsAuthorityValidationError = async (
+ rows,
+ expectedCode,
+ expectedFieldPath,
+ testLabel,
+ expectedSourcePath = deterministicIndexDocsAuthorityPaths.compatibility,
+ ) => {
+ await writeIndexDocsModuleHelpCsv(rows);
+
+ try {
+ await runIndexDocsAuthorityValidation();
+ assert(false, testLabel, 'Expected index-docs authority validation error but validation passed');
+ } catch (error) {
+ assert(error.code === expectedCode, `${testLabel} returns expected error code`, `Expected ${expectedCode}, got ${error.code}`);
+ assert(
+ error.fieldPath === expectedFieldPath,
+ `${testLabel} returns expected field path`,
+ `Expected ${expectedFieldPath}, got ${error.fieldPath}`,
+ );
+ assert(
+ error.sourcePath === expectedSourcePath,
+ `${testLabel} returns expected source path`,
+ `Expected ${expectedSourcePath}, got ${error.sourcePath}`,
+ );
+ assert(
+ typeof error.message === 'string' &&
+ error.message.includes(expectedCode) &&
+ error.message.includes(expectedFieldPath) &&
+ error.message.includes(expectedSourcePath),
+ `${testLabel} includes deterministic message context`,
+ );
+ }
+ };
+
+ await fs.writeFile(tempIndexDocsAuthoritySidecarPath, yaml.stringify(validIndexDocsAuthoritySidecar), 'utf8');
+ await fs.writeFile(tempIndexDocsAuthoritySourcePath, '\n', 'utf8');
+
+ await writeIndexDocsModuleHelpCsv([
+ {
+ workflowFile: deterministicIndexDocsAuthorityPaths.workflowFile,
+ command: 'bmad-index-docs',
+ name: 'Index Docs',
+ },
+ ]);
+
+ const indexDocsAuthorityValidation = await runIndexDocsAuthorityValidation();
+ assert(
+ indexDocsAuthorityValidation.authoritativePresenceKey === 'capability:bmad-index-docs',
+ 'Index-docs authority validation returns expected authoritative presence key',
+ );
+ assert(
+ Array.isArray(indexDocsAuthorityValidation.authoritativeRecords) && indexDocsAuthorityValidation.authoritativeRecords.length === 2,
+ 'Index-docs authority validation returns sidecar and source authority records',
+ );
+
+ const indexDocsSidecarRecord = indexDocsAuthorityValidation.authoritativeRecords.find(
+ (record) => record.authoritySourceType === 'sidecar',
+ );
+ const indexDocsSourceRecord = indexDocsAuthorityValidation.authoritativeRecords.find(
+ (record) => record.authoritySourceType === 'source-xml',
+ );
+
+ assert(
+ indexDocsSidecarRecord &&
+ indexDocsSourceRecord &&
+ indexDocsSidecarRecord.authoritativePresenceKey === indexDocsSourceRecord.authoritativePresenceKey,
+ 'Index-docs sidecar and source-xml records share one authoritative presence key',
+ );
+ assert(
+ indexDocsSidecarRecord &&
+ indexDocsSourceRecord &&
+ indexDocsSidecarRecord.authoritativePresenceKey === 'capability:bmad-index-docs' &&
+ indexDocsSourceRecord.authoritativePresenceKey === 'capability:bmad-index-docs',
+ 'Index-docs authority records lock authoritative presence key to capability:bmad-index-docs',
+ );
+ assert(
+ indexDocsSidecarRecord && indexDocsSidecarRecord.authoritySourcePath === deterministicIndexDocsAuthorityPaths.sidecar,
+ 'Index-docs metadata authority record preserves sidecar source path',
+ );
+ assert(
+ indexDocsSourceRecord && indexDocsSourceRecord.authoritySourcePath === deterministicIndexDocsAuthorityPaths.source,
+ 'Index-docs source-body authority record preserves source XML path',
+ );
+
+ await expectIndexDocsAuthorityValidationError(
+ [
+ {
+ workflowFile: deterministicIndexDocsAuthorityPaths.workflowFile,
+ command: 'legacy-index-docs',
+ name: 'Index Docs',
+ },
+ ],
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH,
+ 'command',
+ 'Index-docs compatibility command mismatch',
+ );
+
+ await expectIndexDocsAuthorityValidationError(
+ [
+ {
+ workflowFile: '_bmad/core/tasks/help.md',
+ command: 'bmad-index-docs',
+ name: 'Index Docs',
+ },
+ ],
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_MISSING,
+ 'workflow-file',
+ 'Index-docs missing compatibility row',
+ );
+
+ await expectIndexDocsAuthorityValidationError(
+ [
+ {
+ workflowFile: deterministicIndexDocsAuthorityPaths.workflowFile,
+ command: 'bmad-index-docs',
+ name: 'Index Docs',
+ },
+ {
+ workflowFile: '_bmad/core/tasks/another.xml',
+ command: 'bmad-index-docs',
+ name: 'Index Docs',
+ },
+ ],
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.DUPLICATE_CANONICAL_COMMAND,
+ 'command',
+ 'Index-docs duplicate canonical command rows',
+ );
+
+ await fs.writeFile(
+ tempIndexDocsAuthoritySidecarPath,
+ yaml.stringify({
+ ...validIndexDocsAuthoritySidecar,
+ canonicalId: 'bmad-index-docs-renamed',
+ }),
+ 'utf8',
+ );
+
+ await expectIndexDocsAuthorityValidationError(
+ [
+ {
+ workflowFile: deterministicIndexDocsAuthorityPaths.workflowFile,
+ command: 'bmad-index-docs-renamed',
+ name: 'Index Docs',
+ },
+ ],
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH,
+ 'canonicalId',
+ 'Index-docs canonicalId drift fails deterministic authority validation',
+ deterministicIndexDocsAuthorityPaths.sidecar,
+ );
+
+ await fs.writeFile(tempIndexDocsAuthoritySidecarPath, yaml.stringify(validIndexDocsAuthoritySidecar), 'utf8');
+ } catch (error) {
+ assert(false, 'Authority split and precedence suite setup', error.message);
+ } finally {
+ await fs.remove(tempAuthorityRoot);
+ }
+
+ console.log('');
+
+ // ============================================================
+ // Test 6: Installer Fail-Fast Pre-Generation
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 6: Installer Fail-Fast Pre-Generation${colors.reset}\n`);
+
+ const tempInstallerRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-installer-sidecar-failfast-'));
+
+ try {
+ // 6a: Existing help sidecar fail-fast behavior remains intact.
+ {
+ const installer = new Installer();
+ let shardDocValidationCalled = false;
+ let indexDocsValidationCalled = false;
+ let shardDocAuthorityValidationCalled = false;
+ let indexDocsAuthorityValidationCalled = false;
+ let helpAuthorityValidationCalled = false;
+ let generateConfigsCalled = false;
+ let manifestGenerationCalled = false;
+ let helpCatalogGenerationCalled = false;
+ let successResultCount = 0;
+
+ installer.validateShardDocSidecarContractFile = async () => {
+ shardDocValidationCalled = true;
+ };
+ installer.validateIndexDocsSidecarContractFile = async () => {
+ indexDocsValidationCalled = true;
+ };
+ installer.validateHelpSidecarContractFile = async () => {
+ const error = new Error(expectedUnsupportedMajorDetail);
+ error.code = HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED;
+ error.fieldPath = 'schemaVersion';
+ error.detail = expectedUnsupportedMajorDetail;
+ throw error;
+ };
+
+ installer.validateShardDocAuthoritySplitAndPrecedence = async () => {
+ shardDocAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ };
+ };
+ installer.validateIndexDocsAuthoritySplitAndPrecedence = async () => {
+ indexDocsAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ };
+ };
+
+ installer.validateHelpAuthoritySplitAndPrecedence = async () => {
+ helpAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-help',
+ };
+ };
+
+ installer.generateModuleConfigs = async () => {
+ generateConfigsCalled = true;
+ };
+
+ installer.mergeModuleHelpCatalogs = async () => {
+ helpCatalogGenerationCalled = true;
+ };
+
+ installer.ManifestGenerator = class ManifestGeneratorStub {
+ async generateManifests() {
+ manifestGenerationCalled = true;
+ return {
+ workflows: 0,
+ agents: 0,
+ tasks: 0,
+ tools: 0,
+ };
+ }
+ };
+
+ try {
+ await installer.runConfigurationGenerationTask({
+ message: () => {},
+ bmadDir: tempInstallerRoot,
+ moduleConfigs: { core: {} },
+ config: { ides: [] },
+ allModules: ['core'],
+ addResult: () => {
+ successResultCount += 1;
+ },
+ });
+ assert(
+ false,
+ 'Installer fail-fast blocks projection generation on help sidecar validation failure',
+ 'Expected sidecar validation failure but configuration generation completed',
+ );
+ } catch (error) {
+ assert(
+ error.code === HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED,
+ 'Installer fail-fast surfaces help sidecar validation error code',
+ `Expected ${HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED}, got ${error.code}`,
+ );
+ assert(shardDocValidationCalled, 'Installer runs shard-doc sidecar validation before help sidecar validation');
+ assert(indexDocsValidationCalled, 'Installer runs index-docs sidecar validation before help sidecar validation');
+ assert(
+ !shardDocAuthorityValidationCalled &&
+ !indexDocsAuthorityValidationCalled &&
+ !helpAuthorityValidationCalled &&
+ !generateConfigsCalled &&
+ !manifestGenerationCalled &&
+ !helpCatalogGenerationCalled,
+ 'Installer help fail-fast prevents downstream authority/config/manifest/help generation',
+ );
+ assert(
+ successResultCount === 0,
+ 'Installer help fail-fast records no successful projection milestones',
+ `Expected 0, got ${successResultCount}`,
+ );
+ }
+ }
+
+ // 6b: Shard-doc fail-fast covers Shard-doc negative matrix classes.
+ {
+ const deterministicShardDocFailFastSourcePath = 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml';
+ const shardDocFailureScenarios = [
+ {
+ label: 'missing shard-doc sidecar file',
+ code: SHARD_DOC_SIDECAR_ERROR_CODES.FILE_NOT_FOUND,
+ fieldPath: '',
+ detail: 'Expected shard-doc sidecar file was not found.',
+ },
+ {
+ label: 'malformed shard-doc sidecar YAML',
+ code: SHARD_DOC_SIDECAR_ERROR_CODES.PARSE_FAILED,
+ fieldPath: '',
+ detail: 'YAML parse failure: malformed content',
+ },
+ {
+ label: 'missing shard-doc required field',
+ code: SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_MISSING,
+ fieldPath: 'canonicalId',
+ detail: 'Missing required sidecar field "canonicalId".',
+ },
+ {
+ label: 'empty shard-doc required field',
+ code: SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ fieldPath: 'canonicalId',
+ detail: 'Required sidecar field "canonicalId" must be a non-empty string.',
+ },
+ {
+ label: 'unsupported shard-doc sidecar major schema version',
+ code: SHARD_DOC_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED,
+ fieldPath: 'schemaVersion',
+ detail: expectedUnsupportedMajorDetail,
+ },
+ {
+ label: 'shard-doc sourcePath basename mismatch',
+ code: SHARD_DOC_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH,
+ fieldPath: 'sourcePath',
+ detail: expectedBasenameMismatchDetail,
+ },
+ ];
+
+ for (const scenario of shardDocFailureScenarios) {
+ const installer = new Installer();
+ let indexDocsValidationCalled = false;
+ let helpValidationCalled = false;
+ let shardDocAuthorityValidationCalled = false;
+ let indexDocsAuthorityValidationCalled = false;
+ let helpAuthorityValidationCalled = false;
+ let generateConfigsCalled = false;
+ let manifestGenerationCalled = false;
+ let helpCatalogGenerationCalled = false;
+ let successResultCount = 0;
+
+ installer.validateShardDocSidecarContractFile = async () => {
+ const error = new Error(scenario.detail);
+ error.code = scenario.code;
+ error.fieldPath = scenario.fieldPath;
+ error.sourcePath = deterministicShardDocFailFastSourcePath;
+ error.detail = scenario.detail;
+ throw error;
+ };
+ installer.validateIndexDocsSidecarContractFile = async () => {
+ indexDocsValidationCalled = true;
+ };
+ installer.validateHelpSidecarContractFile = async () => {
+ helpValidationCalled = true;
+ };
+ installer.validateShardDocAuthoritySplitAndPrecedence = async () => {
+ shardDocAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ };
+ };
+ installer.validateIndexDocsAuthoritySplitAndPrecedence = async () => {
+ indexDocsAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ };
+ };
+ installer.validateHelpAuthoritySplitAndPrecedence = async () => {
+ helpAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-help',
+ };
+ };
+ installer.generateModuleConfigs = async () => {
+ generateConfigsCalled = true;
+ };
+ installer.mergeModuleHelpCatalogs = async () => {
+ helpCatalogGenerationCalled = true;
+ };
+ installer.ManifestGenerator = class ManifestGeneratorStub {
+ async generateManifests() {
+ manifestGenerationCalled = true;
+ return {
+ workflows: 0,
+ agents: 0,
+ tasks: 0,
+ tools: 0,
+ };
+ }
+ };
+
+ try {
+ await installer.runConfigurationGenerationTask({
+ message: () => {},
+ bmadDir: tempInstallerRoot,
+ moduleConfigs: { core: {} },
+ config: { ides: [] },
+ allModules: ['core'],
+ addResult: () => {
+ successResultCount += 1;
+ },
+ });
+ assert(false, `Installer fail-fast blocks projection generation on ${scenario.label}`);
+ } catch (error) {
+ assert(error.code === scenario.code, `Installer ${scenario.label} returns deterministic error code`);
+ assert(error.fieldPath === scenario.fieldPath, `Installer ${scenario.label} returns deterministic field path`);
+ assert(
+ error.sourcePath === deterministicShardDocFailFastSourcePath,
+ `Installer ${scenario.label} returns deterministic source path`,
+ );
+ assert(!indexDocsValidationCalled, `Installer ${scenario.label} aborts before index-docs sidecar validation`);
+ assert(!helpValidationCalled, `Installer ${scenario.label} aborts before help sidecar validation`);
+ assert(
+ !shardDocAuthorityValidationCalled &&
+ !indexDocsAuthorityValidationCalled &&
+ !helpAuthorityValidationCalled &&
+ !generateConfigsCalled &&
+ !manifestGenerationCalled &&
+ !helpCatalogGenerationCalled,
+ `Installer ${scenario.label} prevents downstream authority/config/manifest/help generation`,
+ );
+ assert(successResultCount === 0, `Installer ${scenario.label} records no successful projection milestones`);
+ }
+ }
+ }
+
+ // 6c: Shard-doc authority precedence conflict fails fast before help authority or generation.
+ {
+ const installer = new Installer();
+ let indexDocsAuthorityValidationCalled = false;
+ let helpAuthorityValidationCalled = false;
+ let generateConfigsCalled = false;
+ let manifestGenerationCalled = false;
+ let helpCatalogGenerationCalled = false;
+ let successResultCount = 0;
+
+ installer.validateShardDocSidecarContractFile = async () => {};
+ installer.validateIndexDocsSidecarContractFile = async () => {};
+ installer.validateHelpSidecarContractFile = async () => {};
+ installer.validateShardDocAuthoritySplitAndPrecedence = async () => {
+ const error = new Error('Converted shard-doc compatibility command must match sidecar canonicalId');
+ error.code = SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH;
+ error.fieldPath = 'command';
+ error.sourcePath = 'bmad-fork/src/core/module-help.csv';
+ throw error;
+ };
+ installer.validateIndexDocsAuthoritySplitAndPrecedence = async () => {
+ indexDocsAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ };
+ };
+ installer.validateHelpAuthoritySplitAndPrecedence = async () => {
+ helpAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-help',
+ };
+ };
+ installer.generateModuleConfigs = async () => {
+ generateConfigsCalled = true;
+ };
+ installer.mergeModuleHelpCatalogs = async () => {
+ helpCatalogGenerationCalled = true;
+ };
+ installer.ManifestGenerator = class ManifestGeneratorStub {
+ async generateManifests() {
+ manifestGenerationCalled = true;
+ return {
+ workflows: 0,
+ agents: 0,
+ tasks: 0,
+ tools: 0,
+ };
+ }
+ };
+
+ try {
+ await installer.runConfigurationGenerationTask({
+ message: () => {},
+ bmadDir: tempInstallerRoot,
+ moduleConfigs: { core: {} },
+ config: { ides: [] },
+ allModules: ['core'],
+ addResult: () => {
+ successResultCount += 1;
+ },
+ });
+ assert(false, 'Installer shard-doc authority mismatch fails fast pre-projection');
+ } catch (error) {
+ assert(
+ error.code === SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH,
+ 'Installer shard-doc authority mismatch returns deterministic error code',
+ );
+ assert(error.fieldPath === 'command', 'Installer shard-doc authority mismatch returns deterministic field path');
+ assert(
+ error.sourcePath === 'bmad-fork/src/core/module-help.csv',
+ 'Installer shard-doc authority mismatch returns deterministic source path',
+ );
+ assert(
+ !indexDocsAuthorityValidationCalled &&
+ !helpAuthorityValidationCalled &&
+ !generateConfigsCalled &&
+ !manifestGenerationCalled &&
+ !helpCatalogGenerationCalled,
+ 'Installer shard-doc authority mismatch blocks downstream help authority/config/manifest/help generation',
+ );
+ assert(
+ successResultCount === 3,
+ 'Installer shard-doc authority mismatch records only sidecar gate pass milestones before abort',
+ `Expected 3, got ${successResultCount}`,
+ );
+ }
+ }
+
+ // 6d: Shard-doc canonical drift fails fast before help authority or generation.
+ {
+ const installer = new Installer();
+ let indexDocsAuthorityValidationCalled = false;
+ let helpAuthorityValidationCalled = false;
+ let generateConfigsCalled = false;
+ let manifestGenerationCalled = false;
+ let helpCatalogGenerationCalled = false;
+ let successResultCount = 0;
+
+ installer.validateShardDocSidecarContractFile = async () => {};
+ installer.validateIndexDocsSidecarContractFile = async () => {};
+ installer.validateHelpSidecarContractFile = async () => {};
+ installer.validateShardDocAuthoritySplitAndPrecedence = async () => {
+ const error = new Error('Converted shard-doc sidecar canonicalId must remain locked to bmad-shard-doc');
+ error.code = SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH;
+ error.fieldPath = 'canonicalId';
+ error.sourcePath = 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml';
+ throw error;
+ };
+ installer.validateIndexDocsAuthoritySplitAndPrecedence = async () => {
+ indexDocsAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ };
+ };
+ installer.validateHelpAuthoritySplitAndPrecedence = async () => {
+ helpAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-help',
+ };
+ };
+ installer.generateModuleConfigs = async () => {
+ generateConfigsCalled = true;
+ };
+ installer.mergeModuleHelpCatalogs = async () => {
+ helpCatalogGenerationCalled = true;
+ };
+ installer.ManifestGenerator = class ManifestGeneratorStub {
+ async generateManifests() {
+ manifestGenerationCalled = true;
+ return {
+ workflows: 0,
+ agents: 0,
+ tasks: 0,
+ tools: 0,
+ };
+ }
+ };
+
+ try {
+ await installer.runConfigurationGenerationTask({
+ message: () => {},
+ bmadDir: tempInstallerRoot,
+ moduleConfigs: { core: {} },
+ config: { ides: [] },
+ allModules: ['core'],
+ addResult: () => {
+ successResultCount += 1;
+ },
+ });
+ assert(false, 'Installer shard-doc canonical drift fails fast pre-projection');
+ } catch (error) {
+ assert(
+ error.code === SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH,
+ 'Installer shard-doc canonical drift returns deterministic error code',
+ );
+ assert(error.fieldPath === 'canonicalId', 'Installer shard-doc canonical drift returns deterministic field path');
+ assert(
+ error.sourcePath === 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ 'Installer shard-doc canonical drift returns deterministic source path',
+ );
+ assert(
+ !indexDocsAuthorityValidationCalled &&
+ !helpAuthorityValidationCalled &&
+ !generateConfigsCalled &&
+ !manifestGenerationCalled &&
+ !helpCatalogGenerationCalled,
+ 'Installer shard-doc canonical drift blocks downstream help authority/config/manifest/help generation',
+ );
+ assert(
+ successResultCount === 3,
+ 'Installer shard-doc canonical drift records only sidecar gate pass milestones before abort',
+ `Expected 3, got ${successResultCount}`,
+ );
+ }
+ }
+
+ // 6e: Valid sidecars preserve fail-fast ordering and allow generation path.
+ {
+ const installer = new Installer();
+ const executionOrder = [];
+ const resultMilestones = [];
+
+ installer.validateShardDocSidecarContractFile = async () => {
+ executionOrder.push('shard-doc-sidecar');
+ };
+ installer.validateIndexDocsSidecarContractFile = async () => {
+ executionOrder.push('index-docs-sidecar');
+ };
+ installer.validateHelpSidecarContractFile = async () => {
+ executionOrder.push('help-sidecar');
+ };
+ installer.validateShardDocAuthoritySplitAndPrecedence = async () => {
+ executionOrder.push('shard-doc-authority');
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ };
+ };
+ installer.validateIndexDocsAuthoritySplitAndPrecedence = async () => {
+ executionOrder.push('index-docs-authority');
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ };
+ };
+ installer.validateHelpAuthoritySplitAndPrecedence = async () => {
+ executionOrder.push('help-authority');
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-help',
+ };
+ };
+ installer.generateModuleConfigs = async () => {
+ executionOrder.push('config-generation');
+ };
+ installer.mergeModuleHelpCatalogs = async () => {
+ executionOrder.push('help-catalog-generation');
+ };
+ installer.ManifestGenerator = class ManifestGeneratorStub {
+ async generateManifests() {
+ executionOrder.push('manifest-generation');
+ return {
+ workflows: 0,
+ agents: 0,
+ tasks: 0,
+ tools: 0,
+ };
+ }
+ };
+
+ await installer.runConfigurationGenerationTask({
+ message: () => {},
+ bmadDir: tempInstallerRoot,
+ moduleConfigs: { core: {} },
+ config: { ides: [] },
+ allModules: ['core'],
+ addResult: (name) => {
+ resultMilestones.push(name);
+ },
+ });
+
+ assert(
+ executionOrder.join(' -> ') ===
+ 'shard-doc-sidecar -> index-docs-sidecar -> help-sidecar -> shard-doc-authority -> index-docs-authority -> help-authority -> config-generation -> manifest-generation -> help-catalog-generation',
+ 'Installer valid sidecar path preserves fail-fast gate ordering and continues generation flow',
+ `Observed order: ${executionOrder.join(' -> ')}`,
+ );
+ assert(
+ resultMilestones.includes('Shard-doc sidecar contract'),
+ 'Installer valid sidecar path records explicit shard-doc sidecar gate pass milestone',
+ );
+ assert(
+ resultMilestones.includes('Index-docs sidecar contract'),
+ 'Installer valid sidecar path records explicit index-docs sidecar gate pass milestone',
+ );
+ assert(
+ resultMilestones.includes('Shard-doc authority split'),
+ 'Installer valid sidecar path records explicit shard-doc authority gate pass milestone',
+ );
+ assert(
+ resultMilestones.includes('Index-docs authority split'),
+ 'Installer valid sidecar path records explicit index-docs authority gate pass milestone',
+ );
+ }
+ } catch (error) {
+ assert(false, 'Installer fail-fast test setup', error.message);
+ } finally {
+ await fs.remove(tempInstallerRoot);
+ }
+
+ console.log('');
+
+ // ============================================================
+ // Test 7: Canonical Alias Normalization Core
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 7: Canonical Alias Normalization Core${colors.reset}\n`);
+
+ const deterministicAliasTableSourcePath = '_bmad/_config/canonical-aliases.csv';
+
+ const expectAliasNormalizationError = async (
+ operation,
+ expectedCode,
+ expectedFieldPath,
+ expectedObservedValue,
+ testLabel,
+ expectedDetail = null,
+ ) => {
+ try {
+ await Promise.resolve(operation());
+ assert(false, testLabel, 'Expected alias normalization error but operation succeeded');
+ } catch (error) {
+ assert(error.code === expectedCode, `${testLabel} returns expected error code`, `Expected ${expectedCode}, got ${error.code}`);
+ assert(
+ error.fieldPath === expectedFieldPath,
+ `${testLabel} returns expected field path`,
+ `Expected ${expectedFieldPath}, got ${error.fieldPath}`,
+ );
+ assert(
+ error.sourcePath === deterministicAliasTableSourcePath,
+ `${testLabel} returns expected source path`,
+ `Expected ${deterministicAliasTableSourcePath}, got ${error.sourcePath}`,
+ );
+ assert(
+ error.observedValue === expectedObservedValue,
+ `${testLabel} returns normalized offending value context`,
+ `Expected "${expectedObservedValue}", got "${error.observedValue}"`,
+ );
+ assert(
+ typeof error.message === 'string' &&
+ error.message.includes(expectedCode) &&
+ error.message.includes(expectedFieldPath) &&
+ error.message.includes(deterministicAliasTableSourcePath),
+ `${testLabel} includes deterministic message context`,
+ );
+ if (expectedDetail !== null) {
+ assert(
+ error.detail === expectedDetail,
+ `${testLabel} returns locked detail string`,
+ `Expected "${expectedDetail}", got "${error.detail}"`,
+ );
+ }
+ }
+ };
+
+ try {
+ const canonicalTuple = normalizeRawIdentityToTuple(' BMAD-HELP ', {
+ fieldPath: 'canonicalId',
+ sourcePath: deterministicAliasTableSourcePath,
+ });
+
+ assert(canonicalTuple.rawIdentityHasLeadingSlash === false, 'Canonical tuple sets rawIdentityHasLeadingSlash=false');
+ assert(canonicalTuple.preAliasNormalizedValue === 'bmad-help', 'Canonical tuple computes preAliasNormalizedValue=bmad-help');
+ assert(canonicalTuple.normalizedRawIdentity === 'bmad-help', 'Canonical tuple computes normalizedRawIdentity');
+
+ const canonicalResolution = resolveAliasTupleFromRows(canonicalTuple, LOCKED_EXEMPLAR_ALIAS_ROWS, {
+ sourcePath: deterministicAliasTableSourcePath,
+ });
+ assert(
+ canonicalResolution.aliasRowLocator === 'alias-row:bmad-help:canonical-id',
+ 'Canonical tuple resolves to locked canonical-id row locator',
+ );
+ assert(canonicalResolution.postAliasCanonicalId === 'bmad-help', 'Canonical tuple resolves to locked canonicalId');
+
+ const legacyResolution = await normalizeAndResolveExemplarAlias(' HELP ', {
+ fieldPath: 'canonicalId',
+ sourcePath: deterministicAliasTableSourcePath,
+ });
+ assert(legacyResolution.rawIdentityHasLeadingSlash === false, 'Legacy tuple sets rawIdentityHasLeadingSlash=false');
+ assert(legacyResolution.preAliasNormalizedValue === 'help', 'Legacy tuple computes preAliasNormalizedValue=help');
+ assert(
+ legacyResolution.aliasRowLocator === 'alias-row:bmad-help:legacy-name',
+ 'Legacy tuple resolves to locked legacy-name row locator',
+ );
+ assert(legacyResolution.postAliasCanonicalId === 'bmad-help', 'Legacy tuple resolves to locked canonicalId');
+
+ const slashResolution = await normalizeAndResolveExemplarAlias(' /BMAD-HELP ', {
+ fieldPath: 'canonicalId',
+ sourcePath: deterministicAliasTableSourcePath,
+ });
+ assert(slashResolution.rawIdentityHasLeadingSlash === true, 'Slash tuple sets rawIdentityHasLeadingSlash=true');
+ assert(slashResolution.preAliasNormalizedValue === 'bmad-help', 'Slash tuple computes preAliasNormalizedValue=bmad-help');
+ assert(
+ slashResolution.aliasRowLocator === 'alias-row:bmad-help:slash-command',
+ 'Slash tuple resolves to locked slash-command row locator',
+ );
+ assert(slashResolution.postAliasCanonicalId === 'bmad-help', 'Slash tuple resolves to locked canonicalId');
+
+ const tempAliasAuthorityRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-alias-authority-'));
+ const tempAliasSidecarPath = path.join(tempAliasAuthorityRoot, 'help.artifact.yaml');
+ const tempAliasSourcePath = path.join(tempAliasAuthorityRoot, 'help-source.md');
+ const tempAliasRuntimePath = path.join(tempAliasAuthorityRoot, 'help-runtime.md');
+ const tempAliasConfigDir = path.join(tempAliasAuthorityRoot, '_config');
+ const tempAuthorityAliasTablePath = path.join(tempAliasConfigDir, 'canonical-aliases.csv');
+ const aliasAuthorityPaths = {
+ sidecar: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ source: 'bmad-fork/src/core/tasks/help.md',
+ runtime: '_bmad/core/tasks/help.md',
+ };
+
+ const aliasFrontmatter = {
+ name: 'help',
+ description: 'Help command',
+ canonicalId: 'help',
+ dependencies: {
+ requires: [],
+ },
+ };
+
+ try {
+ await fs.writeFile(
+ tempAliasSidecarPath,
+ yaml.stringify({
+ schemaVersion: 1,
+ canonicalId: 'help',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: aliasAuthorityPaths.source,
+ displayName: 'help',
+ description: 'Help command',
+ dependencies: {
+ requires: [],
+ },
+ }),
+ 'utf8',
+ );
+ await fs.writeFile(tempAliasSourcePath, `---\n${yaml.stringify(aliasFrontmatter).trimEnd()}\n---\n\n# Help\n`, 'utf8');
+ await fs.writeFile(tempAliasRuntimePath, `---\n${yaml.stringify(aliasFrontmatter).trimEnd()}\n---\n\n# Help\n`, 'utf8');
+
+ const aliasAuthorityValidation = await validateHelpAuthoritySplitAndPrecedence({
+ sidecarPath: tempAliasSidecarPath,
+ sourceMarkdownPath: tempAliasSourcePath,
+ runtimeMarkdownPath: tempAliasRuntimePath,
+ sidecarSourcePath: aliasAuthorityPaths.sidecar,
+ sourceMarkdownSourcePath: aliasAuthorityPaths.source,
+ runtimeMarkdownSourcePath: aliasAuthorityPaths.runtime,
+ });
+
+ assert(
+ aliasAuthorityValidation.canonicalId === 'bmad-help',
+ 'Authority validation normalizes legacy canonical identity to locked canonicalId',
+ );
+ assert(
+ aliasAuthorityValidation.authoritativePresenceKey === 'capability:bmad-help',
+ 'Authority validation emits canonical presence key after alias resolution',
+ );
+
+ await fs.ensureDir(tempAliasConfigDir);
+ await fs.writeFile(
+ tempAuthorityAliasTablePath,
+ [
+ 'rowIdentity,canonicalId,normalizedAliasValue,rawIdentityHasLeadingSlash',
+ 'alias-row:bmad-help:legacy-name,bmad-help-csv,help,false',
+ ].join('\n') + '\n',
+ 'utf8',
+ );
+ const csvBackedAuthorityValidation = await validateHelpAuthoritySplitAndPrecedence({
+ sidecarPath: tempAliasSidecarPath,
+ sourceMarkdownPath: tempAliasSourcePath,
+ runtimeMarkdownPath: tempAliasRuntimePath,
+ sidecarSourcePath: aliasAuthorityPaths.sidecar,
+ sourceMarkdownSourcePath: aliasAuthorityPaths.source,
+ runtimeMarkdownSourcePath: aliasAuthorityPaths.runtime,
+ bmadDir: tempAliasAuthorityRoot,
+ });
+ assert(
+ csvBackedAuthorityValidation.canonicalId === 'bmad-help-csv',
+ 'Authority validation prefers canonical alias CSV when available',
+ );
+ assert(
+ csvBackedAuthorityValidation.authoritativePresenceKey === 'capability:bmad-help-csv',
+ 'Authority validation derives presence key from CSV-resolved canonical identity',
+ );
+ } finally {
+ await fs.remove(tempAliasAuthorityRoot);
+ }
+
+ const collapsedWhitespaceTuple = normalizeRawIdentityToTuple(' bmad\t\thelp ', {
+ fieldPath: 'canonicalId',
+ sourcePath: deterministicAliasTableSourcePath,
+ });
+ assert(
+ collapsedWhitespaceTuple.preAliasNormalizedValue === 'bmad help',
+ 'Tuple normalization collapses internal whitespace runs deterministically',
+ );
+
+ await expectAliasNormalizationError(
+ () =>
+ normalizeRawIdentityToTuple(' \n\t ', {
+ fieldPath: 'canonicalId',
+ sourcePath: deterministicAliasTableSourcePath,
+ }),
+ HELP_ALIAS_NORMALIZATION_ERROR_CODES.EMPTY_INPUT,
+ 'canonicalId',
+ '',
+ 'Empty alias input',
+ 'alias identity is empty after normalization',
+ );
+
+ await expectAliasNormalizationError(
+ () =>
+ normalizeRawIdentityToTuple('//bmad-help', {
+ fieldPath: 'canonicalId',
+ sourcePath: deterministicAliasTableSourcePath,
+ }),
+ HELP_ALIAS_NORMALIZATION_ERROR_CODES.MULTIPLE_LEADING_SLASHES,
+ 'canonicalId',
+ '//bmad-help',
+ 'Alias input with multiple leading slashes',
+ 'alias identity contains multiple leading slashes',
+ );
+
+ await expectAliasNormalizationError(
+ () =>
+ normalizeRawIdentityToTuple('/ ', {
+ fieldPath: 'canonicalId',
+ sourcePath: deterministicAliasTableSourcePath,
+ }),
+ HELP_ALIAS_NORMALIZATION_ERROR_CODES.EMPTY_PREALIAS,
+ 'preAliasNormalizedValue',
+ '/',
+ 'Alias input with empty pre-alias value',
+ 'alias preAliasNormalizedValue is empty after slash normalization',
+ );
+
+ await expectAliasNormalizationError(
+ () =>
+ normalizeAndResolveExemplarAlias('not-a-locked-alias', {
+ fieldPath: 'canonicalId',
+ sourcePath: deterministicAliasTableSourcePath,
+ }),
+ HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
+ 'preAliasNormalizedValue',
+ 'not-a-locked-alias|leadingSlash:false',
+ 'Unresolved alias tuple',
+ 'alias tuple did not resolve to any canonical alias row',
+ );
+
+ const ambiguousAliasRows = [
+ {
+ rowIdentity: 'alias-row:a',
+ canonicalId: 'bmad-help',
+ normalizedAliasValue: 'help',
+ rawIdentityHasLeadingSlash: false,
+ },
+ {
+ rowIdentity: 'alias-row:b',
+ canonicalId: 'legacy-help',
+ normalizedAliasValue: 'help',
+ rawIdentityHasLeadingSlash: false,
+ },
+ ];
+ const ambiguousTuple = normalizeRawIdentityToTuple('help', {
+ fieldPath: 'canonicalId',
+ sourcePath: deterministicAliasTableSourcePath,
+ });
+ await expectAliasNormalizationError(
+ () =>
+ resolveAliasTupleFromRows(ambiguousTuple, ambiguousAliasRows, {
+ sourcePath: deterministicAliasTableSourcePath,
+ }),
+ HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
+ 'preAliasNormalizedValue',
+ 'help|leadingSlash:false',
+ 'Ambiguous alias tuple resolution',
+ 'alias tuple resolved ambiguously to multiple canonical alias rows',
+ );
+
+ const shardDocAliasRows = [
+ {
+ rowIdentity: 'alias-row:bmad-shard-doc:canonical-id',
+ canonicalId: 'bmad-shard-doc',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: false,
+ },
+ {
+ rowIdentity: 'alias-row:bmad-shard-doc:legacy-name',
+ canonicalId: 'bmad-shard-doc',
+ normalizedAliasValue: 'shard-doc',
+ rawIdentityHasLeadingSlash: false,
+ },
+ {
+ rowIdentity: 'alias-row:bmad-shard-doc:slash-command',
+ canonicalId: 'bmad-shard-doc',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: true,
+ },
+ ];
+
+ const shardDocSlashResolution = await normalizeAndResolveExemplarAlias('/bmad-shard-doc', {
+ fieldPath: 'canonicalId',
+ sourcePath: deterministicAliasTableSourcePath,
+ aliasRows: shardDocAliasRows,
+ aliasTableSourcePath: deterministicAliasTableSourcePath,
+ });
+ assert(
+ shardDocSlashResolution.postAliasCanonicalId === 'bmad-shard-doc' &&
+ shardDocSlashResolution.aliasRowLocator === 'alias-row:bmad-shard-doc:slash-command',
+ 'Alias resolver normalizes shard-doc slash-command tuple with explicit shard-doc alias rows',
+ );
+
+ await expectAliasNormalizationError(
+ () =>
+ normalizeAndResolveExemplarAlias('/bmad-shard-doc', {
+ fieldPath: 'canonicalId',
+ sourcePath: deterministicAliasTableSourcePath,
+ aliasRows: LOCKED_EXEMPLAR_ALIAS_ROWS,
+ aliasTableSourcePath: deterministicAliasTableSourcePath,
+ }),
+ HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
+ 'preAliasNormalizedValue',
+ 'bmad-shard-doc|leadingSlash:true',
+ 'Shard-doc alias tuple unresolved without shard-doc alias table rows',
+ 'alias tuple did not resolve to any canonical alias row',
+ );
+
+ const ambiguousShardDocRows = [
+ ...shardDocAliasRows,
+ {
+ rowIdentity: 'alias-row:bmad-shard-doc:slash-command:duplicate',
+ canonicalId: 'bmad-shard-doc-alt',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: true,
+ },
+ ];
+ await expectAliasNormalizationError(
+ () =>
+ normalizeAndResolveExemplarAlias('/bmad-shard-doc', {
+ fieldPath: 'canonicalId',
+ sourcePath: deterministicAliasTableSourcePath,
+ aliasRows: ambiguousShardDocRows,
+ aliasTableSourcePath: deterministicAliasTableSourcePath,
+ }),
+ HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
+ 'preAliasNormalizedValue',
+ 'bmad-shard-doc|leadingSlash:true',
+ 'Shard-doc alias tuple ambiguous when duplicate shard-doc slash-command rows exist',
+ 'alias tuple resolved ambiguously to multiple canonical alias rows',
+ );
+
+ const tempAliasTableRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-canonical-alias-table-'));
+ const tempAliasTablePath = path.join(tempAliasTableRoot, 'canonical-aliases.csv');
+ const csvRows = [
+ 'rowIdentity,canonicalId,normalizedAliasValue,rawIdentityHasLeadingSlash',
+ 'alias-row:bmad-help:canonical-id,bmad-help,bmad-help,false',
+ 'alias-row:bmad-help:legacy-name,bmad-help,help,false',
+ 'alias-row:bmad-help:slash-command,bmad-help,bmad-help,true',
+ ];
+ try {
+ await fs.writeFile(tempAliasTablePath, `${csvRows.join('\n')}\n`, 'utf8');
+ const csvTuple = normalizeRawIdentityToTuple('/bmad-help', {
+ fieldPath: 'canonicalId',
+ sourcePath: deterministicAliasTableSourcePath,
+ });
+ const csvResolution = await resolveAliasTupleUsingCanonicalAliasCsv(csvTuple, tempAliasTablePath, {
+ sourcePath: deterministicAliasTableSourcePath,
+ });
+ assert(
+ csvResolution.aliasRowLocator === 'alias-row:bmad-help:slash-command',
+ 'CSV-backed tuple resolution maps slash-command alias row locator',
+ );
+ assert(csvResolution.postAliasCanonicalId === 'bmad-help', 'CSV-backed tuple resolution maps canonicalId');
+
+ const manifestGenerator = new ManifestGenerator();
+ const normalizedHelpAuthorityRecords = await manifestGenerator.normalizeHelpAuthorityRecords([
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'help',
+ authoritativePresenceKey: 'capability:legacy-help',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: aliasAuthorityPaths.sidecar,
+ sourcePath: aliasAuthorityPaths.source,
+ },
+ ]);
+ assert(
+ normalizedHelpAuthorityRecords.length === 1 && normalizedHelpAuthorityRecords[0].canonicalId === 'bmad-help',
+ 'Manifest generator normalizes legacy canonical identities using alias tuple resolution',
+ );
+ assert(
+ normalizedHelpAuthorityRecords.length === 1 &&
+ normalizedHelpAuthorityRecords[0].authoritativePresenceKey === 'capability:bmad-help',
+ 'Manifest generator canonicalizes authoritative presence key from normalized canonicalId',
+ );
+
+ await expectAliasNormalizationError(
+ () =>
+ manifestGenerator.normalizeHelpAuthorityRecords([
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'not-a-locked-alias',
+ authoritativePresenceKey: 'capability:not-a-locked-alias',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: aliasAuthorityPaths.sidecar,
+ sourcePath: aliasAuthorityPaths.source,
+ },
+ ]),
+ HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
+ 'preAliasNormalizedValue',
+ 'not-a-locked-alias|leadingSlash:false',
+ 'Manifest generator fails unresolved canonical identity normalization',
+ 'alias tuple did not resolve to any canonical alias row',
+ );
+
+ await expectAliasNormalizationError(
+ () =>
+ resolveAliasTupleUsingCanonicalAliasCsv(csvTuple, path.join(tempAliasTableRoot, 'missing.csv'), {
+ sourcePath: deterministicAliasTableSourcePath,
+ }),
+ HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
+ 'aliasTablePath',
+ path.join(tempAliasTableRoot, 'missing.csv'),
+ 'CSV-backed alias resolution with missing table file',
+ 'canonical alias table file was not found',
+ );
+ } finally {
+ await fs.remove(tempAliasTableRoot);
+ }
+ } catch (error) {
+ assert(false, 'Canonical alias normalization suite setup', error.message);
+ }
+
+ console.log('');
+
+ // ============================================================
+ // Test 8: Additive Task Manifest Projection
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 8: Additive Task Manifest Projection${colors.reset}\n`);
+
+ const tempTaskManifestRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-task-manifest-'));
+ try {
+ const manifestGenerator = new ManifestGenerator();
+ manifestGenerator.bmadDir = tempTaskManifestRoot;
+ manifestGenerator.bmadFolderName = '_bmad';
+ manifestGenerator.tasks = [
+ {
+ name: 'help',
+ displayName: 'help',
+ description: 'Help command',
+ module: 'core',
+ path: 'core/tasks/help.md',
+ standalone: true,
+ },
+ {
+ name: 'validate-workflow',
+ displayName: 'validate-workflow',
+ description: 'Validate workflow',
+ module: 'core',
+ path: 'core/tasks/validate-workflow.xml',
+ standalone: true,
+ },
+ {
+ name: 'shard-doc',
+ displayName: 'Shard Document',
+ description: 'Split large markdown documents into smaller files by section with an index.',
+ module: 'core',
+ path: 'core/tasks/shard-doc.xml',
+ standalone: true,
+ },
+ {
+ 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: 'core/tasks/index-docs.xml',
+ standalone: true,
+ },
+ ];
+ manifestGenerator.helpAuthorityRecords = [
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-help',
+ authoritativePresenceKey: 'capability:bmad-help',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ sourcePath: 'bmad-fork/src/core/tasks/help.md',
+ },
+ ];
+ manifestGenerator.taskAuthorityRecords = [
+ ...manifestGenerator.helpAuthorityRecords,
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-shard-doc',
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml',
+ },
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-index-docs',
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml',
+ sourcePath: 'bmad-fork/src/core/tasks/index-docs.xml',
+ },
+ ];
+ const tempTaskManifestConfigDir = path.join(tempTaskManifestRoot, '_config');
+ await fs.ensureDir(tempTaskManifestConfigDir);
+ await manifestGenerator.writeTaskManifest(tempTaskManifestConfigDir);
+
+ const writtenTaskManifestRaw = await fs.readFile(path.join(tempTaskManifestConfigDir, 'task-manifest.csv'), 'utf8');
+ const writtenTaskManifestLines = writtenTaskManifestRaw.trim().split('\n');
+ const expectedHeader =
+ 'name,displayName,description,module,path,standalone,legacyName,canonicalId,authoritySourceType,authoritySourcePath';
+
+ assert(
+ writtenTaskManifestLines[0] === expectedHeader,
+ 'Task manifest writes compatibility-prefix columns with locked canonical appended column order',
+ );
+
+ const writtenTaskManifestRecords = csv.parse(writtenTaskManifestRaw, {
+ columns: true,
+ skip_empty_lines: true,
+ trim: true,
+ });
+ const helpTaskRow = writtenTaskManifestRecords.find((record) => record.module === 'core' && record.name === 'help');
+ const validateTaskRow = writtenTaskManifestRecords.find((record) => record.module === 'core' && record.name === 'validate-workflow');
+ const shardDocTaskRow = writtenTaskManifestRecords.find((record) => record.module === 'core' && record.name === 'shard-doc');
+ const indexDocsTaskRow = writtenTaskManifestRecords.find((record) => record.module === 'core' && record.name === 'index-docs');
+
+ assert(!!helpTaskRow, 'Task manifest includes exemplar help row');
+ assert(helpTaskRow && helpTaskRow.legacyName === 'help', 'Task manifest help row sets legacyName=help');
+ assert(helpTaskRow && helpTaskRow.canonicalId === 'bmad-help', 'Task manifest help row sets canonicalId=bmad-help');
+ assert(helpTaskRow && helpTaskRow.authoritySourceType === 'sidecar', 'Task manifest help row sets authoritySourceType=sidecar');
+ assert(
+ helpTaskRow && helpTaskRow.authoritySourcePath === 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ 'Task manifest help row sets authoritySourcePath to sidecar source path',
+ );
+
+ assert(!!validateTaskRow, 'Task manifest preserves non-exemplar rows');
+ assert(
+ validateTaskRow && validateTaskRow.legacyName === 'validate-workflow',
+ 'Task manifest non-exemplar rows remain additive-compatible with default legacyName',
+ );
+ assert(!!shardDocTaskRow, 'Task manifest includes converted shard-doc row');
+ assert(shardDocTaskRow && shardDocTaskRow.legacyName === 'shard-doc', 'Task manifest shard-doc row sets legacyName=shard-doc');
+ assert(
+ shardDocTaskRow && shardDocTaskRow.canonicalId === 'bmad-shard-doc',
+ 'Task manifest shard-doc row sets canonicalId=bmad-shard-doc',
+ );
+ assert(
+ shardDocTaskRow && shardDocTaskRow.authoritySourceType === 'sidecar',
+ 'Task manifest shard-doc row sets authoritySourceType=sidecar',
+ );
+ assert(
+ shardDocTaskRow && shardDocTaskRow.authoritySourcePath === 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ 'Task manifest shard-doc row sets authoritySourcePath to shard-doc sidecar source path',
+ );
+ assert(!!indexDocsTaskRow, 'Task manifest includes converted index-docs row');
+ assert(indexDocsTaskRow && indexDocsTaskRow.legacyName === 'index-docs', 'Task manifest index-docs row sets legacyName=index-docs');
+ assert(
+ indexDocsTaskRow && indexDocsTaskRow.canonicalId === 'bmad-index-docs',
+ 'Task manifest index-docs row sets canonicalId=bmad-index-docs',
+ );
+ assert(
+ indexDocsTaskRow && indexDocsTaskRow.authoritySourceType === 'sidecar',
+ 'Task manifest index-docs row sets authoritySourceType=sidecar',
+ );
+ assert(
+ indexDocsTaskRow && indexDocsTaskRow.authoritySourcePath === 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml',
+ 'Task manifest index-docs row sets authoritySourcePath to index-docs sidecar source path',
+ );
+
+ await manifestGenerator.writeTaskManifest(tempTaskManifestConfigDir);
+ const repeatedTaskManifestRaw = await fs.readFile(path.join(tempTaskManifestConfigDir, 'task-manifest.csv'), 'utf8');
+ assert(
+ repeatedTaskManifestRaw === writtenTaskManifestRaw,
+ 'Task manifest shard-doc canonical row values remain deterministic across repeated generation runs',
+ );
+
+ let capturedAuthorityValidationOptions = null;
+ let capturedShardDocAuthorityValidationOptions = null;
+ let capturedIndexDocsAuthorityValidationOptions = null;
+ let capturedManifestHelpAuthorityRecords = null;
+ let capturedManifestTaskAuthorityRecords = null;
+ let capturedInstalledFiles = null;
+
+ const installer = new Installer();
+ installer.validateShardDocSidecarContractFile = async () => {};
+ installer.validateIndexDocsSidecarContractFile = async () => {};
+ installer.validateHelpSidecarContractFile = async () => {};
+ installer.validateShardDocAuthoritySplitAndPrecedence = async (options) => {
+ capturedShardDocAuthorityValidationOptions = options;
+ return {
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ authoritativeRecords: [
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-shard-doc',
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: options.sidecarSourcePath,
+ sourcePath: options.sourceXmlSourcePath,
+ },
+ {
+ recordType: 'source-body-authority',
+ canonicalId: 'bmad-shard-doc',
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ authoritySourceType: 'source-xml',
+ authoritySourcePath: options.sourceXmlSourcePath,
+ sourcePath: options.sourceXmlSourcePath,
+ },
+ ],
+ };
+ };
+ installer.validateIndexDocsAuthoritySplitAndPrecedence = async (options) => {
+ capturedIndexDocsAuthorityValidationOptions = options;
+ return {
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ authoritativeRecords: [
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-index-docs',
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: options.sidecarSourcePath,
+ sourcePath: options.sourceXmlSourcePath,
+ },
+ {
+ recordType: 'source-body-authority',
+ canonicalId: 'bmad-index-docs',
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ authoritySourceType: 'source-xml',
+ authoritySourcePath: options.sourceXmlSourcePath,
+ sourcePath: options.sourceXmlSourcePath,
+ },
+ ],
+ };
+ };
+ installer.validateHelpAuthoritySplitAndPrecedence = async (options) => {
+ capturedAuthorityValidationOptions = options;
+ return {
+ authoritativePresenceKey: 'capability:bmad-help',
+ authoritativeRecords: [
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-help',
+ authoritativePresenceKey: 'capability:bmad-help',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: options.sidecarSourcePath,
+ sourcePath: options.sourceMarkdownSourcePath,
+ },
+ ],
+ };
+ };
+ installer.generateModuleConfigs = async () => {};
+ installer.mergeModuleHelpCatalogs = async () => {};
+ installer.ManifestGenerator = class ManifestGeneratorStub {
+ async generateManifests(_bmadDir, _selectedModules, _installedFiles, options = {}) {
+ capturedInstalledFiles = _installedFiles;
+ capturedManifestHelpAuthorityRecords = options.helpAuthorityRecords;
+ capturedManifestTaskAuthorityRecords = options.taskAuthorityRecords;
+ return {
+ workflows: 0,
+ agents: 0,
+ tasks: 0,
+ tools: 0,
+ };
+ }
+ };
+
+ await installer.runConfigurationGenerationTask({
+ message: () => {},
+ bmadDir: tempTaskManifestRoot,
+ moduleConfigs: { core: {} },
+ config: { ides: [] },
+ allModules: ['core'],
+ addResult: () => {},
+ });
+
+ assert(
+ capturedAuthorityValidationOptions &&
+ capturedAuthorityValidationOptions.sidecarSourcePath === 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ 'Installer passes locked sidecar source path to authority validation',
+ );
+ assert(
+ capturedAuthorityValidationOptions &&
+ capturedAuthorityValidationOptions.sourceMarkdownSourcePath === 'bmad-fork/src/core/tasks/help.md',
+ 'Installer passes locked source-markdown path to authority validation',
+ );
+ assert(
+ capturedAuthorityValidationOptions && capturedAuthorityValidationOptions.runtimeMarkdownSourcePath === '_bmad/core/tasks/help.md',
+ 'Installer passes locked runtime markdown path to authority validation',
+ );
+ assert(
+ capturedShardDocAuthorityValidationOptions &&
+ capturedShardDocAuthorityValidationOptions.sidecarSourcePath === 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ 'Installer passes locked shard-doc sidecar source path to shard-doc authority validation',
+ );
+ assert(
+ capturedShardDocAuthorityValidationOptions &&
+ capturedShardDocAuthorityValidationOptions.sourceXmlSourcePath === 'bmad-fork/src/core/tasks/shard-doc.xml',
+ 'Installer passes locked shard-doc source XML path to shard-doc authority validation',
+ );
+ assert(
+ capturedShardDocAuthorityValidationOptions &&
+ capturedShardDocAuthorityValidationOptions.compatibilityCatalogSourcePath === 'bmad-fork/src/core/module-help.csv',
+ 'Installer passes locked module-help source path to shard-doc authority validation',
+ );
+ assert(
+ capturedIndexDocsAuthorityValidationOptions &&
+ capturedIndexDocsAuthorityValidationOptions.sidecarSourcePath === 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml',
+ 'Installer passes locked index-docs sidecar source path to index-docs authority validation',
+ );
+ assert(
+ capturedIndexDocsAuthorityValidationOptions &&
+ capturedIndexDocsAuthorityValidationOptions.sourceXmlSourcePath === 'bmad-fork/src/core/tasks/index-docs.xml',
+ 'Installer passes locked index-docs source XML path to index-docs authority validation',
+ );
+ assert(
+ capturedIndexDocsAuthorityValidationOptions &&
+ capturedIndexDocsAuthorityValidationOptions.compatibilityCatalogSourcePath === 'bmad-fork/src/core/module-help.csv',
+ 'Installer passes locked module-help source path to index-docs authority validation',
+ );
+ assert(
+ Array.isArray(capturedManifestHelpAuthorityRecords) &&
+ capturedManifestHelpAuthorityRecords[0] &&
+ capturedManifestHelpAuthorityRecords[0].authoritySourcePath === 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ 'Installer passes sidecar authority path into manifest generation options',
+ );
+ assert(
+ Array.isArray(capturedManifestTaskAuthorityRecords) &&
+ capturedManifestTaskAuthorityRecords.some(
+ (record) =>
+ record &&
+ record.canonicalId === 'bmad-shard-doc' &&
+ record.authoritySourceType === 'sidecar' &&
+ record.authoritySourcePath === 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ ),
+ 'Installer passes shard-doc sidecar authority records into task-manifest projection options',
+ );
+ assert(
+ Array.isArray(capturedManifestTaskAuthorityRecords) &&
+ capturedManifestTaskAuthorityRecords.some(
+ (record) =>
+ record &&
+ record.canonicalId === 'bmad-index-docs' &&
+ record.authoritySourceType === 'sidecar' &&
+ record.authoritySourcePath === 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml',
+ ),
+ 'Installer passes index-docs sidecar authority records into task-manifest projection options',
+ );
+ assert(
+ Array.isArray(capturedInstalledFiles) &&
+ capturedInstalledFiles.some((filePath) => filePath.endsWith('/_config/canonical-aliases.csv')),
+ 'Installer pre-registers canonical-aliases.csv for files-manifest tracking',
+ );
+ } catch (error) {
+ assert(false, 'Additive task manifest projection suite setup', error.message);
+ } finally {
+ await fs.remove(tempTaskManifestRoot);
+ }
+
+ console.log('');
+
+ // ============================================================
+ // Test 9: Canonical Alias Table Projection
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 9: Canonical Alias Table Projection${colors.reset}\n`);
+
+ const tempCanonicalAliasRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-canonical-alias-projection-'));
+ try {
+ const manifestGenerator = new ManifestGenerator();
+ manifestGenerator.bmadDir = tempCanonicalAliasRoot;
+ manifestGenerator.bmadFolderName = '_bmad';
+ manifestGenerator.helpAuthorityRecords = [
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-help',
+ authoritativePresenceKey: 'capability:bmad-help',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ sourcePath: 'bmad-fork/src/core/tasks/help.md',
+ },
+ ];
+ manifestGenerator.taskAuthorityRecords = [
+ ...manifestGenerator.helpAuthorityRecords,
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-shard-doc',
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml',
+ },
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-index-docs',
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml',
+ sourcePath: 'bmad-fork/src/core/tasks/index-docs.xml',
+ },
+ ];
+
+ const tempCanonicalAliasConfigDir = path.join(tempCanonicalAliasRoot, '_config');
+ await fs.ensureDir(tempCanonicalAliasConfigDir);
+ const canonicalAliasPath = await manifestGenerator.writeCanonicalAliasManifest(tempCanonicalAliasConfigDir);
+
+ const canonicalAliasRaw = await fs.readFile(canonicalAliasPath, 'utf8');
+ const canonicalAliasLines = canonicalAliasRaw.trim().split('\n');
+ const expectedCanonicalAliasHeader =
+ 'canonicalId,alias,aliasType,authoritySourceType,authoritySourcePath,rowIdentity,normalizedAliasValue,rawIdentityHasLeadingSlash,resolutionEligibility';
+ assert(
+ canonicalAliasLines[0] === expectedCanonicalAliasHeader,
+ 'Canonical alias table writes locked compatibility-prefix plus tuple eligibility column order',
+ );
+
+ const canonicalAliasRows = csv.parse(canonicalAliasRaw, {
+ columns: true,
+ skip_empty_lines: true,
+ trim: true,
+ });
+ assert(canonicalAliasRows.length === 9, 'Canonical alias table emits help + shard-doc + index-docs canonical alias exemplar rows');
+ assert(
+ canonicalAliasRows.map((row) => row.aliasType).join(',') ===
+ 'canonical-id,legacy-name,slash-command,canonical-id,legacy-name,slash-command,canonical-id,legacy-name,slash-command',
+ 'Canonical alias table preserves locked deterministic row ordering',
+ );
+
+ const expectedRowsByIdentity = new Map([
+ [
+ 'alias-row:bmad-help:canonical-id',
+ {
+ canonicalId: 'bmad-help',
+ alias: 'bmad-help',
+ aliasType: 'canonical-id',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ normalizedAliasValue: 'bmad-help',
+ rawIdentityHasLeadingSlash: 'false',
+ resolutionEligibility: 'canonical-id-only',
+ },
+ ],
+ [
+ 'alias-row:bmad-help:legacy-name',
+ {
+ canonicalId: 'bmad-help',
+ alias: 'help',
+ aliasType: 'legacy-name',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ normalizedAliasValue: 'help',
+ rawIdentityHasLeadingSlash: 'false',
+ resolutionEligibility: 'legacy-name-only',
+ },
+ ],
+ [
+ 'alias-row:bmad-help:slash-command',
+ {
+ canonicalId: 'bmad-help',
+ alias: '/bmad-help',
+ aliasType: 'slash-command',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ normalizedAliasValue: 'bmad-help',
+ rawIdentityHasLeadingSlash: 'true',
+ resolutionEligibility: 'slash-command-only',
+ },
+ ],
+ [
+ 'alias-row:bmad-shard-doc:canonical-id',
+ {
+ canonicalId: 'bmad-shard-doc',
+ alias: 'bmad-shard-doc',
+ aliasType: 'canonical-id',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: 'false',
+ resolutionEligibility: 'canonical-id-only',
+ },
+ ],
+ [
+ 'alias-row:bmad-shard-doc:legacy-name',
+ {
+ canonicalId: 'bmad-shard-doc',
+ alias: 'shard-doc',
+ aliasType: 'legacy-name',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ normalizedAliasValue: 'shard-doc',
+ rawIdentityHasLeadingSlash: 'false',
+ resolutionEligibility: 'legacy-name-only',
+ },
+ ],
+ [
+ 'alias-row:bmad-shard-doc:slash-command',
+ {
+ canonicalId: 'bmad-shard-doc',
+ alias: '/bmad-shard-doc',
+ aliasType: 'slash-command',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: 'true',
+ resolutionEligibility: 'slash-command-only',
+ },
+ ],
+ [
+ 'alias-row:bmad-index-docs:canonical-id',
+ {
+ canonicalId: 'bmad-index-docs',
+ alias: 'bmad-index-docs',
+ aliasType: 'canonical-id',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml',
+ normalizedAliasValue: 'bmad-index-docs',
+ rawIdentityHasLeadingSlash: 'false',
+ resolutionEligibility: 'canonical-id-only',
+ },
+ ],
+ [
+ 'alias-row:bmad-index-docs:legacy-name',
+ {
+ canonicalId: 'bmad-index-docs',
+ alias: 'index-docs',
+ aliasType: 'legacy-name',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml',
+ normalizedAliasValue: 'index-docs',
+ rawIdentityHasLeadingSlash: 'false',
+ resolutionEligibility: 'legacy-name-only',
+ },
+ ],
+ [
+ 'alias-row:bmad-index-docs:slash-command',
+ {
+ canonicalId: 'bmad-index-docs',
+ alias: '/bmad-index-docs',
+ aliasType: 'slash-command',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml',
+ normalizedAliasValue: 'bmad-index-docs',
+ rawIdentityHasLeadingSlash: 'true',
+ resolutionEligibility: 'slash-command-only',
+ },
+ ],
+ ]);
+
+ for (const [rowIdentity, expectedRow] of expectedRowsByIdentity) {
+ const matchingRows = canonicalAliasRows.filter((row) => row.rowIdentity === rowIdentity);
+ assert(matchingRows.length === 1, `Canonical alias table emits exactly one ${rowIdentity} exemplar row`);
+
+ const row = matchingRows[0];
+ assert(
+ row && row.authoritySourceType === 'sidecar' && row.authoritySourcePath === expectedRow.authoritySourcePath,
+ `${rowIdentity} exemplar row uses locked sidecar provenance`,
+ );
+ assert(row && row.canonicalId === expectedRow.canonicalId, `${rowIdentity} exemplar row locks canonicalId contract`);
+ assert(row && row.alias === expectedRow.alias, `${rowIdentity} exemplar row locks alias contract`);
+ assert(row && row.aliasType === expectedRow.aliasType, `${rowIdentity} exemplar row locks aliasType contract`);
+ assert(row && row.rowIdentity === rowIdentity, `${rowIdentity} exemplar row locks rowIdentity contract`);
+ assert(
+ row && row.normalizedAliasValue === expectedRow.normalizedAliasValue,
+ `${rowIdentity} exemplar row locks normalizedAliasValue contract`,
+ );
+ assert(
+ row && row.rawIdentityHasLeadingSlash === expectedRow.rawIdentityHasLeadingSlash,
+ `${rowIdentity} exemplar row locks rawIdentityHasLeadingSlash contract`,
+ );
+ assert(
+ row && row.resolutionEligibility === expectedRow.resolutionEligibility,
+ `${rowIdentity} exemplar row locks resolutionEligibility contract`,
+ );
+ }
+
+ const validateLockedCanonicalAliasProjection = (rows) => {
+ for (const [rowIdentity, expectedRow] of expectedRowsByIdentity) {
+ const matchingRows = rows.filter((row) => row.rowIdentity === rowIdentity);
+ if (matchingRows.length === 0) {
+ return { valid: false, reason: `missing:${rowIdentity}` };
+ }
+ if (matchingRows.length > 1) {
+ return { valid: false, reason: `conflict:${rowIdentity}` };
+ }
+
+ const row = matchingRows[0];
+ if (
+ row.canonicalId !== expectedRow.canonicalId ||
+ row.alias !== expectedRow.alias ||
+ row.aliasType !== expectedRow.aliasType ||
+ row.authoritySourceType !== 'sidecar' ||
+ row.authoritySourcePath !== expectedRow.authoritySourcePath ||
+ row.rowIdentity !== rowIdentity ||
+ row.normalizedAliasValue !== expectedRow.normalizedAliasValue ||
+ row.rawIdentityHasLeadingSlash !== expectedRow.rawIdentityHasLeadingSlash ||
+ row.resolutionEligibility !== expectedRow.resolutionEligibility
+ ) {
+ return { valid: false, reason: `conflict:${rowIdentity}` };
+ }
+ }
+
+ if (rows.length !== expectedRowsByIdentity.size) {
+ return { valid: false, reason: 'conflict:extra-rows' };
+ }
+
+ return { valid: true, reason: 'ok' };
+ };
+
+ const baselineProjectionValidation = validateLockedCanonicalAliasProjection(canonicalAliasRows);
+ assert(
+ baselineProjectionValidation.valid,
+ 'Canonical alias projection validator passes when all required exemplar rows are present exactly once',
+ baselineProjectionValidation.reason,
+ );
+
+ const missingLegacyRows = canonicalAliasRows.filter((row) => row.rowIdentity !== 'alias-row:bmad-shard-doc:legacy-name');
+ const missingLegacyValidation = validateLockedCanonicalAliasProjection(missingLegacyRows);
+ assert(
+ !missingLegacyValidation.valid && missingLegacyValidation.reason === 'missing:alias-row:bmad-shard-doc:legacy-name',
+ 'Canonical alias projection validator fails when required shard-doc legacy-name row is missing',
+ );
+
+ const conflictingRows = [
+ ...canonicalAliasRows,
+ {
+ ...canonicalAliasRows.find((row) => row.rowIdentity === 'alias-row:bmad-help:slash-command'),
+ },
+ ];
+ const conflictingValidation = validateLockedCanonicalAliasProjection(conflictingRows);
+ assert(
+ !conflictingValidation.valid && conflictingValidation.reason === 'conflict:alias-row:bmad-help:slash-command',
+ 'Canonical alias projection validator fails when conflicting duplicate exemplar rows appear',
+ );
+
+ const fallbackManifestGenerator = new ManifestGenerator();
+ fallbackManifestGenerator.bmadDir = tempCanonicalAliasRoot;
+ fallbackManifestGenerator.bmadFolderName = '_bmad';
+ fallbackManifestGenerator.helpAuthorityRecords = [];
+ fallbackManifestGenerator.taskAuthorityRecords = [];
+ fallbackManifestGenerator.includeConvertedShardDocAliasRows = true;
+ const fallbackCanonicalAliasPath = await fallbackManifestGenerator.writeCanonicalAliasManifest(tempCanonicalAliasConfigDir);
+ const fallbackCanonicalAliasRaw = await fs.readFile(fallbackCanonicalAliasPath, 'utf8');
+ const fallbackCanonicalAliasRows = csv.parse(fallbackCanonicalAliasRaw, {
+ columns: true,
+ skip_empty_lines: true,
+ trim: true,
+ });
+ assert(
+ fallbackCanonicalAliasRows.every((row) => {
+ if (row.authoritySourceType !== 'sidecar') {
+ return false;
+ }
+ if (row.canonicalId === 'bmad-help') {
+ return row.authoritySourcePath === 'bmad-fork/src/core/tasks/help/skill-manifest.yaml';
+ }
+ if (row.canonicalId === 'bmad-shard-doc') {
+ return row.authoritySourcePath === 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml';
+ }
+ return false;
+ }),
+ 'Canonical alias table falls back to locked sidecar provenance when authority records are unavailable',
+ );
+
+ const tempGeneratedBmadDir = path.join(tempCanonicalAliasRoot, '_bmad');
+ await fs.ensureDir(tempGeneratedBmadDir);
+ const manifestStats = await new ManifestGenerator().generateManifests(
+ tempGeneratedBmadDir,
+ [],
+ [path.join(tempGeneratedBmadDir, '_config', 'canonical-aliases.csv')],
+ {
+ ides: [],
+ preservedModules: [],
+ helpAuthorityRecords: manifestGenerator.helpAuthorityRecords,
+ taskAuthorityRecords: manifestGenerator.taskAuthorityRecords,
+ },
+ );
+
+ assert(
+ Array.isArray(manifestStats.manifestFiles) &&
+ manifestStats.manifestFiles.some((filePath) => filePath.endsWith('/_config/canonical-aliases.csv')),
+ 'Manifest generation includes canonical-aliases.csv in output sequencing',
+ );
+
+ const writtenFilesManifestRaw = await fs.readFile(path.join(tempGeneratedBmadDir, '_config', 'files-manifest.csv'), 'utf8');
+ assert(
+ writtenFilesManifestRaw.includes('"_config/canonical-aliases.csv"'),
+ 'Files manifest tracks canonical-aliases.csv when pre-registered by installer flow',
+ );
+ } catch (error) {
+ assert(false, 'Canonical alias projection suite setup', error.message);
+ } finally {
+ await fs.remove(tempCanonicalAliasRoot);
+ }
+
+ console.log('');
+
+ // ============================================================
+ // Test 10: Help Catalog Projection + Command Label Contract
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 10: Help Catalog Projection + Command Label Contract${colors.reset}\n`);
+
+ const tempHelpCatalogRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-help-catalog-projection-'));
+ try {
+ const installer = new Installer();
+ installer.helpAuthorityRecords = [
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-help',
+ authoritativePresenceKey: 'capability:bmad-help',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ sourcePath: 'bmad-fork/src/core/tasks/help.md',
+ },
+ ];
+
+ const sidecarAwareExemplar = await buildSidecarAwareExemplarHelpRow({
+ helpAuthorityRecords: installer.helpAuthorityRecords,
+ });
+ assert(
+ sidecarAwareExemplar.commandValue === 'bmad-help',
+ 'Sidecar-aware exemplar help row derives raw command from canonical identity',
+ );
+ assert(
+ sidecarAwareExemplar.displayedCommandLabel === '/bmad-help',
+ 'Sidecar-aware exemplar help row renders displayed label with exactly one leading slash',
+ );
+ assert(
+ sidecarAwareExemplar.authoritySourcePath === EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH,
+ 'Sidecar-aware exemplar help row locks authority source path to sidecar metadata file',
+ );
+
+ const legacySidecarPath = path.join(tempHelpCatalogRoot, 'legacy-help.artifact.yaml');
+ await fs.writeFile(
+ legacySidecarPath,
+ yaml.stringify({
+ schemaVersion: 1,
+ canonicalId: 'help',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: 'bmad-fork/src/core/tasks/help.md',
+ displayName: 'help',
+ description: 'Legacy exemplar alias canonical id',
+ dependencies: { requires: [] },
+ }),
+ 'utf8',
+ );
+ const legacyIdentityExemplar = await buildSidecarAwareExemplarHelpRow({
+ sidecarPath: legacySidecarPath,
+ helpAuthorityRecords: installer.helpAuthorityRecords,
+ });
+ assert(
+ legacyIdentityExemplar.commandValue === 'bmad-help',
+ 'Sidecar-aware exemplar help row normalizes legacy sidecar canonicalId to locked canonical identity',
+ );
+
+ await installer.mergeModuleHelpCatalogs(tempHelpCatalogRoot);
+
+ const generatedHelpPath = path.join(tempHelpCatalogRoot, '_config', 'bmad-help.csv');
+ const generatedCommandLabelReportPath = path.join(tempHelpCatalogRoot, '_config', 'bmad-help-command-label-report.csv');
+ const generatedPipelineReportPath = path.join(tempHelpCatalogRoot, '_config', 'bmad-help-catalog-pipeline.csv');
+ const generatedHelpRaw = await fs.readFile(generatedHelpPath, 'utf8');
+ const generatedHelpLines = generatedHelpRaw.trim().split('\n');
+ const expectedHelpHeader =
+ 'module,phase,name,code,sequence,workflow-file,command,required,agent-name,agent-command,agent-display-name,agent-title,options,description,output-location,outputs';
+ assert(generatedHelpLines[0] === expectedHelpHeader, 'Help catalog header remains additive-compatible for existing consumers');
+
+ const generatedHelpRows = csv.parse(generatedHelpRaw, {
+ columns: true,
+ skip_empty_lines: true,
+ trim: true,
+ });
+
+ const exemplarRows = generatedHelpRows.filter((row) => row.command === 'bmad-help');
+ const shardDocRows = generatedHelpRows.filter((row) => row.command === 'bmad-shard-doc');
+ const indexDocsRows = generatedHelpRows.filter((row) => row.command === 'bmad-index-docs');
+ assert(exemplarRows.length === 1, 'Help catalog emits exactly one exemplar raw command row for bmad-help');
+ assert(
+ exemplarRows[0] && exemplarRows[0].name === 'bmad-help',
+ 'Help catalog exemplar row preserves locked bmad-help workflow identity',
+ );
+ assert(shardDocRows.length === 1, 'Help catalog emits exactly one shard-doc raw command row for bmad-shard-doc');
+ assert(
+ shardDocRows[0] && shardDocRows[0]['workflow-file'] === '_bmad/core/tasks/shard-doc.xml',
+ 'Help catalog shard-doc row preserves locked shard-doc workflow identity',
+ );
+ assert(indexDocsRows.length === 1, 'Help catalog emits exactly one index-docs raw command row for bmad-index-docs');
+ assert(
+ indexDocsRows[0] && indexDocsRows[0]['workflow-file'] === '_bmad/core/tasks/index-docs.xml',
+ 'Help catalog index-docs row preserves locked index-docs workflow identity',
+ );
+
+ const sidecarRaw = await fs.readFile(path.join(projectRoot, 'src', 'core', 'tasks', 'help.artifact.yaml'), 'utf8');
+ const sidecarData = yaml.parse(sidecarRaw);
+ assert(
+ exemplarRows[0] && exemplarRows[0].description === sidecarData.description,
+ 'Help catalog exemplar row description is sourced from sidecar metadata',
+ );
+
+ const commandLabelRows = installer.helpCatalogCommandLabelReportRows || [];
+ const helpCommandLabelRow = commandLabelRows.find((row) => row.canonicalId === 'bmad-help');
+ const shardDocCommandLabelRow = commandLabelRows.find((row) => row.canonicalId === 'bmad-shard-doc');
+ const indexDocsCommandLabelRow = commandLabelRows.find((row) => row.canonicalId === 'bmad-index-docs');
+ assert(commandLabelRows.length === 3, 'Installer emits command-label report rows for help, shard-doc, and index-docs canonical ids');
+ assert(
+ helpCommandLabelRow &&
+ helpCommandLabelRow.rawCommandValue === 'bmad-help' &&
+ helpCommandLabelRow.displayedCommandLabel === '/bmad-help',
+ 'Command-label report locks raw and displayed command values for exemplar',
+ );
+ assert(
+ helpCommandLabelRow &&
+ helpCommandLabelRow.authoritySourceType === 'sidecar' &&
+ helpCommandLabelRow.authoritySourcePath === 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ 'Command-label report includes sidecar provenance linkage',
+ );
+ assert(
+ shardDocCommandLabelRow &&
+ shardDocCommandLabelRow.rawCommandValue === 'bmad-shard-doc' &&
+ shardDocCommandLabelRow.displayedCommandLabel === '/bmad-shard-doc',
+ 'Command-label report locks raw and displayed command values for shard-doc',
+ );
+ assert(
+ shardDocCommandLabelRow &&
+ shardDocCommandLabelRow.authoritySourceType === 'sidecar' &&
+ shardDocCommandLabelRow.authoritySourcePath === 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ 'Command-label report includes shard-doc sidecar provenance linkage',
+ );
+ assert(
+ indexDocsCommandLabelRow &&
+ indexDocsCommandLabelRow.rawCommandValue === 'bmad-index-docs' &&
+ indexDocsCommandLabelRow.displayedCommandLabel === '/bmad-index-docs',
+ 'Command-label report locks raw and displayed command values for index-docs',
+ );
+ assert(
+ indexDocsCommandLabelRow &&
+ indexDocsCommandLabelRow.authoritySourceType === 'sidecar' &&
+ indexDocsCommandLabelRow.authoritySourcePath === 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml',
+ 'Command-label report includes index-docs sidecar provenance linkage',
+ );
+ const generatedCommandLabelReportRaw = await fs.readFile(generatedCommandLabelReportPath, 'utf8');
+ const generatedCommandLabelReportRows = csv.parse(generatedCommandLabelReportRaw, {
+ columns: true,
+ skip_empty_lines: true,
+ trim: true,
+ });
+ const generatedHelpCommandLabelRow = generatedCommandLabelReportRows.find((row) => row.canonicalId === 'bmad-help');
+ const generatedShardDocCommandLabelRow = generatedCommandLabelReportRows.find((row) => row.canonicalId === 'bmad-shard-doc');
+ const generatedIndexDocsCommandLabelRow = generatedCommandLabelReportRows.find((row) => row.canonicalId === 'bmad-index-docs');
+ assert(
+ generatedCommandLabelReportRows.length === 3 &&
+ generatedHelpCommandLabelRow &&
+ generatedHelpCommandLabelRow.displayedCommandLabel === '/bmad-help' &&
+ generatedHelpCommandLabelRow.rowCountForCanonicalId === '1' &&
+ generatedShardDocCommandLabelRow &&
+ generatedShardDocCommandLabelRow.displayedCommandLabel === '/bmad-shard-doc' &&
+ generatedShardDocCommandLabelRow.rowCountForCanonicalId === '1' &&
+ generatedIndexDocsCommandLabelRow &&
+ generatedIndexDocsCommandLabelRow.displayedCommandLabel === '/bmad-index-docs' &&
+ generatedIndexDocsCommandLabelRow.rowCountForCanonicalId === '1',
+ 'Installer persists command-label report artifact with locked help, shard-doc, and index-docs label contract values',
+ );
+
+ const baselineLabelContract = evaluateExemplarCommandLabelReportRows(commandLabelRows);
+ assert(
+ baselineLabelContract.valid,
+ 'Command-label validator passes when exactly one exemplar /bmad-help displayed label row exists',
+ baselineLabelContract.reason,
+ );
+ const baselineShardDocLabelContract = evaluateExemplarCommandLabelReportRows(commandLabelRows, {
+ canonicalId: 'bmad-shard-doc',
+ displayedCommandLabel: '/bmad-shard-doc',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ });
+ assert(
+ baselineShardDocLabelContract.valid,
+ 'Command-label validator passes when exactly one /bmad-shard-doc displayed label row exists',
+ baselineShardDocLabelContract.reason,
+ );
+ const baselineIndexDocsLabelContract = evaluateExemplarCommandLabelReportRows(commandLabelRows, {
+ canonicalId: 'bmad-index-docs',
+ displayedCommandLabel: '/bmad-index-docs',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml',
+ });
+ assert(
+ baselineIndexDocsLabelContract.valid,
+ 'Command-label validator passes when exactly one /bmad-index-docs displayed label row exists',
+ baselineIndexDocsLabelContract.reason,
+ );
+
+ const commandDocsSourcePath = path.join(projectRoot, 'docs', 'reference', 'commands.md');
+ const commandDocsMarkdown = await fs.readFile(commandDocsSourcePath, 'utf8');
+ const commandDocConsistency = validateCommandDocSurfaceConsistency(commandDocsMarkdown, {
+ sourcePath: 'docs/reference/commands.md',
+ generatedSurfacePath: '_bmad/_config/bmad-help-command-label-report.csv',
+ commandLabelRows,
+ canonicalId: 'bmad-shard-doc',
+ expectedDisplayedCommandLabel: '/bmad-shard-doc',
+ disallowedAliasLabels: ['/shard-doc'],
+ });
+ assert(
+ commandDocConsistency.generatedCanonicalCommand === '/bmad-shard-doc',
+ 'Command-doc consistency validator passes when generated shard-doc command matches command docs canonical label',
+ );
+
+ const missingCanonicalCommandDocsMarkdown = commandDocsMarkdown.replace(
+ '| `/bmad-shard-doc` | Split a large markdown file into smaller sections |',
+ '| `/bmad-shard-doc-renamed` | Split a large markdown file into smaller sections |',
+ );
+ try {
+ validateCommandDocSurfaceConsistency(missingCanonicalCommandDocsMarkdown, {
+ sourcePath: 'docs/reference/commands.md',
+ generatedSurfacePath: '_bmad/_config/bmad-help-command-label-report.csv',
+ commandLabelRows,
+ canonicalId: 'bmad-shard-doc',
+ expectedDisplayedCommandLabel: '/bmad-shard-doc',
+ disallowedAliasLabels: ['/shard-doc'],
+ });
+ assert(false, 'Command-doc consistency validator rejects missing canonical shard-doc command rows');
+ } catch (error) {
+ assert(
+ error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_CANONICAL_COMMAND_MISSING,
+ 'Command-doc consistency validator emits deterministic diagnostics for missing canonical shard-doc command docs row',
+ `Expected ${PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_CANONICAL_COMMAND_MISSING}, got ${error.code}`,
+ );
+ }
+
+ const aliasAmbiguousCommandDocsMarkdown = `${commandDocsMarkdown}\n| \`/shard-doc\` | Legacy alias |\n`;
+ try {
+ validateCommandDocSurfaceConsistency(aliasAmbiguousCommandDocsMarkdown, {
+ sourcePath: 'docs/reference/commands.md',
+ generatedSurfacePath: '_bmad/_config/bmad-help-command-label-report.csv',
+ commandLabelRows,
+ canonicalId: 'bmad-shard-doc',
+ expectedDisplayedCommandLabel: '/bmad-shard-doc',
+ disallowedAliasLabels: ['/shard-doc'],
+ });
+ assert(false, 'Command-doc consistency validator rejects shard-doc alias ambiguity in command docs');
+ } catch (error) {
+ assert(
+ error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_ALIAS_AMBIGUOUS,
+ 'Command-doc consistency validator emits deterministic diagnostics for shard-doc alias ambiguity in command docs',
+ `Expected ${PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_ALIAS_AMBIGUOUS}, got ${error.code}`,
+ );
+ }
+
+ try {
+ validateCommandDocSurfaceConsistency(commandDocsMarkdown, {
+ sourcePath: 'docs/reference/commands.md',
+ generatedSurfacePath: '_bmad/_config/bmad-help-command-label-report.csv',
+ commandLabelRows: [
+ helpCommandLabelRow,
+ {
+ ...shardDocCommandLabelRow,
+ displayedCommandLabel: '/shard-doc',
+ },
+ ],
+ canonicalId: 'bmad-shard-doc',
+ expectedDisplayedCommandLabel: '/bmad-shard-doc',
+ disallowedAliasLabels: ['/shard-doc'],
+ });
+ assert(false, 'Command-doc consistency validator rejects generated shard-doc command-label drift');
+ } catch (error) {
+ assert(
+ error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_GENERATED_SURFACE_MISMATCH,
+ 'Command-doc consistency validator emits deterministic diagnostics for generated shard-doc command-label drift',
+ `Expected ${PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_GENERATED_SURFACE_MISMATCH}, got ${error.code}`,
+ );
+ }
+
+ const invalidLegacyLabelContract = evaluateExemplarCommandLabelReportRows([
+ {
+ ...helpCommandLabelRow,
+ displayedCommandLabel: 'help',
+ },
+ ]);
+ assert(
+ !invalidLegacyLabelContract.valid && invalidLegacyLabelContract.reason === 'invalid-displayed-label:help',
+ 'Command-label validator fails on alternate displayed label form "help"',
+ );
+
+ const invalidSlashHelpLabelContract = evaluateExemplarCommandLabelReportRows([
+ {
+ ...helpCommandLabelRow,
+ displayedCommandLabel: '/help',
+ },
+ ]);
+ assert(
+ !invalidSlashHelpLabelContract.valid && invalidSlashHelpLabelContract.reason === 'invalid-displayed-label:/help',
+ 'Command-label validator fails on alternate displayed label form "/help"',
+ );
+
+ const invalidShardDocLabelContract = evaluateExemplarCommandLabelReportRows(
+ [
+ helpCommandLabelRow,
+ {
+ ...shardDocCommandLabelRow,
+ displayedCommandLabel: '/shard-doc',
+ },
+ ],
+ {
+ canonicalId: 'bmad-shard-doc',
+ displayedCommandLabel: '/bmad-shard-doc',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ },
+ );
+ assert(
+ !invalidShardDocLabelContract.valid && invalidShardDocLabelContract.reason === 'invalid-displayed-label:/shard-doc',
+ 'Command-label validator fails on alternate shard-doc displayed label form "/shard-doc"',
+ );
+
+ const pipelineRows = installer.helpCatalogPipelineRows || [];
+ assert(pipelineRows.length === 2, 'Installer emits two stage rows for help catalog pipeline evidence linkage');
+ const installedStageRow = pipelineRows.find((row) => row.stage === 'installed-compatibility-row');
+ const mergedStageRow = pipelineRows.find((row) => row.stage === 'merged-config-row');
+
+ assert(
+ installedStageRow &&
+ installedStageRow.issuingComponent === EXEMPLAR_HELP_CATALOG_ISSUING_COMPONENT &&
+ installedStageRow.commandAuthoritySourceType === 'sidecar' &&
+ installedStageRow.commandAuthoritySourcePath === 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ 'Installed compatibility stage row preserves sidecar command provenance and issuing component linkage',
+ );
+ assert(
+ mergedStageRow &&
+ mergedStageRow.issuingComponent === INSTALLER_HELP_CATALOG_MERGE_COMPONENT &&
+ mergedStageRow.commandAuthoritySourceType === 'sidecar' &&
+ mergedStageRow.commandAuthoritySourcePath === 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ 'Merged config stage row preserves sidecar command provenance and merge issuing component linkage',
+ );
+ assert(
+ pipelineRows.every((row) => row.status === 'PASS' && typeof row.issuingComponentBindingEvidence === 'string'),
+ 'Pipeline rows include deterministic PASS status and non-empty issuing-component evidence linkage',
+ );
+ const generatedPipelineReportRaw = await fs.readFile(generatedPipelineReportPath, 'utf8');
+ const generatedPipelineReportRows = csv.parse(generatedPipelineReportRaw, {
+ columns: true,
+ skip_empty_lines: true,
+ trim: true,
+ });
+ assert(
+ generatedPipelineReportRows.length === 2 &&
+ generatedPipelineReportRows.every(
+ (row) =>
+ row.commandAuthoritySourceType === 'sidecar' &&
+ row.commandAuthoritySourcePath === 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ ),
+ 'Installer persists pipeline stage artifact with sidecar command provenance linkage for both stages',
+ );
+
+ const tempAltLabelRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-help-catalog-alt-label-'));
+ try {
+ const moduleDir = path.join(tempAltLabelRoot, 'modx');
+ await fs.ensureDir(moduleDir);
+ await fs.writeFile(
+ path.join(moduleDir, 'module-help.csv'),
+ [
+ 'module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs',
+ 'modx,anytime,alt-help,AH,,_bmad/core/tasks/help.md,/help,false,,,Alt help label,,,',
+ ].join('\n') + '\n',
+ 'utf8',
+ );
+
+ const alternateLabelInstaller = new Installer();
+ alternateLabelInstaller.helpAuthorityRecords = installer.helpAuthorityRecords;
+ try {
+ await alternateLabelInstaller.mergeModuleHelpCatalogs(tempAltLabelRoot);
+ assert(
+ false,
+ 'Installer command-label contract rejects alternate rendered labels in merged help catalog',
+ 'Expected command label contract failure for /help but merge succeeded',
+ );
+ } catch (error) {
+ assert(
+ error.code === HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED,
+ 'Installer command-label contract returns deterministic failure code for alternate labels',
+ `Expected ${HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED}, got ${error.code}`,
+ );
+ }
+ } finally {
+ await fs.remove(tempAltLabelRoot);
+ }
+ } catch (error) {
+ assert(false, 'Help catalog projection suite setup', error.message);
+ } finally {
+ await fs.remove(tempHelpCatalogRoot);
+ }
+
+ console.log('');
+
+ // ============================================================
+ // Test 11: Export Projection from Sidecar Canonical ID
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 11: Export Projection from Sidecar Canonical ID${colors.reset}\n`);
+
+ const tempExportRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-export-projection-'));
+ try {
+ const codexSetup = new CodexSetup();
+ const skillsDir = path.join(tempExportRoot, '.agents', 'skills');
+ await fs.ensureDir(skillsDir);
+ await fs.ensureDir(path.join(tempExportRoot, 'bmad-fork', 'src', 'core', 'tasks'));
+ await fs.writeFile(
+ path.join(tempExportRoot, 'bmad-fork', 'src', 'core', 'tasks', 'help.artifact.yaml'),
+ yaml.stringify({
+ schemaVersion: 1,
+ canonicalId: 'bmad-help',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: 'bmad-fork/src/core/tasks/help.md',
+ displayName: 'help',
+ description: 'Help command',
+ dependencies: { requires: [] },
+ }),
+ 'utf8',
+ );
+ await fs.writeFile(
+ path.join(tempExportRoot, 'bmad-fork', 'src', 'core', 'tasks', 'shard-doc.artifact.yaml'),
+ yaml.stringify({
+ schemaVersion: 1,
+ canonicalId: 'bmad-shard-doc',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml',
+ displayName: 'Shard Document',
+ description: 'Split large markdown documents into smaller files by section with an index.',
+ dependencies: { requires: [] },
+ }),
+ 'utf8',
+ );
+ await fs.writeFile(
+ path.join(tempExportRoot, 'bmad-fork', 'src', 'core', 'tasks', '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',
+ );
+
+ const exemplarTaskArtifact = {
+ type: 'task',
+ name: 'help',
+ module: 'core',
+ sourcePath: path.join(tempExportRoot, '_bmad', 'core', 'tasks', 'help.md'),
+ relativePath: path.join('core', 'tasks', 'help.md'),
+ content: '---\nname: help\ndescription: Help command\ncanonicalId: bmad-help\n---\n\n# help\n',
+ };
+ const shardDocTaskArtifact = {
+ type: 'task',
+ name: 'shard-doc',
+ module: 'core',
+ sourcePath: path.join(tempExportRoot, '_bmad', 'core', 'tasks', 'shard-doc.xml'),
+ relativePath: path.join('core', 'tasks', 'shard-doc.md'),
+ content: 'Split markdown docs\n',
+ };
+ const indexDocsTaskArtifact = {
+ type: 'task',
+ name: 'index-docs',
+ module: 'core',
+ sourcePath: path.join(tempExportRoot, '_bmad', 'core', 'tasks', 'index-docs.xml'),
+ relativePath: path.join('core', 'tasks', 'index-docs.md'),
+ content: 'Index docs\n',
+ };
+
+ const writtenCount = await codexSetup.writeSkillArtifacts(skillsDir, [exemplarTaskArtifact], 'task', {
+ projectDir: tempExportRoot,
+ });
+ assert(writtenCount === 1, 'Codex export writes one exemplar skill artifact');
+
+ const exemplarSkillPath = path.join(skillsDir, 'bmad-help', 'SKILL.md');
+ assert(await fs.pathExists(exemplarSkillPath), 'Codex export derives exemplar skill path from sidecar canonical identity');
+
+ const exemplarSkillRaw = await fs.readFile(exemplarSkillPath, 'utf8');
+ const exemplarFrontmatterMatch = exemplarSkillRaw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
+ const exemplarFrontmatter = exemplarFrontmatterMatch ? yaml.parse(exemplarFrontmatterMatch[1]) : null;
+ assert(
+ exemplarFrontmatter && exemplarFrontmatter.name === 'bmad-help',
+ 'Codex export frontmatter sets required name from sidecar canonical identity',
+ );
+ assert(
+ exemplarFrontmatter && Object.keys(exemplarFrontmatter).sort().join(',') === 'description,name',
+ 'Codex export frontmatter remains constrained to required name plus optional description',
+ );
+
+ const exportDerivationRecord = codexSetup.exportDerivationRecords.find((row) => row.exportPath === '.agents/skills/bmad-help/SKILL.md');
+ assert(
+ exportDerivationRecord &&
+ exportDerivationRecord.exportIdDerivationSourceType === EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE &&
+ exportDerivationRecord.exportIdDerivationSourcePath === 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ 'Codex export records exemplar derivation source metadata from sidecar canonical-id',
+ );
+
+ const shardDocWrittenCount = await codexSetup.writeSkillArtifacts(skillsDir, [shardDocTaskArtifact], 'task', {
+ projectDir: tempExportRoot,
+ });
+ assert(shardDocWrittenCount === 1, 'Codex export writes one shard-doc converted skill artifact');
+
+ const shardDocSkillPath = path.join(skillsDir, 'bmad-shard-doc', 'SKILL.md');
+ assert(await fs.pathExists(shardDocSkillPath), 'Codex export derives shard-doc skill path from sidecar canonical identity');
+
+ const shardDocSkillRaw = await fs.readFile(shardDocSkillPath, 'utf8');
+ const shardDocFrontmatterMatch = shardDocSkillRaw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
+ const shardDocFrontmatter = shardDocFrontmatterMatch ? yaml.parse(shardDocFrontmatterMatch[1]) : null;
+ assert(
+ shardDocFrontmatter && shardDocFrontmatter.name === 'bmad-shard-doc',
+ 'Codex export frontmatter sets shard-doc required name from sidecar canonical identity',
+ );
+
+ const shardDocExportDerivationRecord = codexSetup.exportDerivationRecords.find(
+ (row) => row.exportPath === '.agents/skills/bmad-shard-doc/SKILL.md',
+ );
+ assert(
+ shardDocExportDerivationRecord &&
+ shardDocExportDerivationRecord.exportIdDerivationSourceType === EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE &&
+ shardDocExportDerivationRecord.exportIdDerivationSourcePath === 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml' &&
+ shardDocExportDerivationRecord.sourcePath === 'bmad-fork/src/core/tasks/shard-doc.xml',
+ 'Codex export records shard-doc sidecar-canonical derivation metadata and source path',
+ );
+
+ const indexDocsWrittenCount = await codexSetup.writeSkillArtifacts(skillsDir, [indexDocsTaskArtifact], 'task', {
+ projectDir: tempExportRoot,
+ });
+ assert(indexDocsWrittenCount === 1, 'Codex export writes one index-docs converted skill artifact');
+
+ const indexDocsSkillPath = path.join(skillsDir, 'bmad-index-docs', 'SKILL.md');
+ assert(await fs.pathExists(indexDocsSkillPath), 'Codex export derives index-docs skill path from sidecar canonical identity');
+
+ const indexDocsSkillRaw = await fs.readFile(indexDocsSkillPath, 'utf8');
+ const indexDocsFrontmatterMatch = indexDocsSkillRaw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
+ const indexDocsFrontmatter = indexDocsFrontmatterMatch ? yaml.parse(indexDocsFrontmatterMatch[1]) : null;
+ assert(
+ indexDocsFrontmatter && indexDocsFrontmatter.name === 'bmad-index-docs',
+ 'Codex export frontmatter sets index-docs required name from sidecar canonical identity',
+ );
+
+ const indexDocsExportDerivationRecord = codexSetup.exportDerivationRecords.find(
+ (row) => row.exportPath === '.agents/skills/bmad-index-docs/SKILL.md',
+ );
+ assert(
+ indexDocsExportDerivationRecord &&
+ indexDocsExportDerivationRecord.exportIdDerivationSourceType === EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE &&
+ indexDocsExportDerivationRecord.exportIdDerivationSourcePath === 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml' &&
+ indexDocsExportDerivationRecord.sourcePath === 'bmad-fork/src/core/tasks/index-docs.xml',
+ 'Codex export records index-docs sidecar-canonical derivation metadata and source path',
+ );
+
+ const duplicateExportSetup = new CodexSetup();
+ const duplicateSkillDir = path.join(tempExportRoot, '.agents', 'skills-duplicate-check');
+ await fs.ensureDir(duplicateSkillDir);
+ try {
+ await duplicateExportSetup.writeSkillArtifacts(
+ duplicateSkillDir,
+ [
+ shardDocTaskArtifact,
+ {
+ ...shardDocTaskArtifact,
+ content: 'Duplicate shard-doc export artifact\n',
+ },
+ ],
+ 'task',
+ {
+ projectDir: tempExportRoot,
+ },
+ );
+ assert(
+ false,
+ 'Codex export rejects duplicate shard-doc canonical-id skill export surfaces',
+ 'Expected duplicate export-surface failure but export succeeded',
+ );
+ } catch (error) {
+ assert(
+ error.code === CODEX_EXPORT_DERIVATION_ERROR_CODES.DUPLICATE_EXPORT_SURFACE,
+ 'Codex export duplicate shard-doc canonical-id rejection returns deterministic failure code',
+ `Expected ${CODEX_EXPORT_DERIVATION_ERROR_CODES.DUPLICATE_EXPORT_SURFACE}, got ${error.code}`,
+ );
+ }
+
+ const tempSubmoduleRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-export-submodule-root-'));
+ try {
+ const submoduleRootSetup = new CodexSetup();
+ const submoduleSkillsDir = path.join(tempSubmoduleRoot, '.agents', 'skills');
+ await fs.ensureDir(submoduleSkillsDir);
+ await fs.ensureDir(path.join(tempSubmoduleRoot, 'src', 'core', 'tasks'));
+ await fs.writeFile(
+ path.join(tempSubmoduleRoot, 'src', 'core', 'tasks', 'help.artifact.yaml'),
+ yaml.stringify({
+ schemaVersion: 1,
+ canonicalId: 'bmad-help',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: 'bmad-fork/src/core/tasks/help.md',
+ displayName: 'help',
+ description: 'Help command',
+ dependencies: { requires: [] },
+ }),
+ 'utf8',
+ );
+
+ await submoduleRootSetup.writeSkillArtifacts(submoduleSkillsDir, [exemplarTaskArtifact], 'task', {
+ projectDir: tempSubmoduleRoot,
+ });
+
+ const submoduleExportDerivationRecord = submoduleRootSetup.exportDerivationRecords.find(
+ (row) => row.exportPath === '.agents/skills/bmad-help/SKILL.md',
+ );
+ assert(
+ submoduleExportDerivationRecord &&
+ submoduleExportDerivationRecord.exportIdDerivationSourcePath === 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ 'Codex export locks exemplar derivation source-path contract when running from submodule root',
+ );
+ } finally {
+ await fs.remove(tempSubmoduleRoot);
+ }
+
+ const tempNoSidecarRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-export-missing-sidecar-'));
+ try {
+ const noSidecarSetup = new CodexSetup();
+ const noSidecarSkillDir = path.join(tempNoSidecarRoot, '.agents', 'skills');
+ await fs.ensureDir(noSidecarSkillDir);
+
+ try {
+ await noSidecarSetup.writeSkillArtifacts(noSidecarSkillDir, [exemplarTaskArtifact], 'task', {
+ projectDir: tempNoSidecarRoot,
+ });
+ assert(
+ false,
+ 'Codex export fails when exemplar sidecar metadata is missing',
+ 'Expected sidecar file-not-found failure but export succeeded',
+ );
+ } catch (error) {
+ assert(
+ error.code === CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
+ 'Codex export missing sidecar failure returns deterministic error code',
+ `Expected ${CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND}, got ${error.code}`,
+ );
+ }
+ } finally {
+ await fs.remove(tempNoSidecarRoot);
+ }
+
+ const tempInferenceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-export-no-inference-'));
+ try {
+ const noInferenceSetup = new CodexSetup();
+ const noInferenceSkillDir = path.join(tempInferenceRoot, '.agents', 'skills');
+ await fs.ensureDir(noInferenceSkillDir);
+ await fs.ensureDir(path.join(tempInferenceRoot, 'bmad-fork', 'src', 'core', 'tasks'));
+ await fs.writeFile(
+ path.join(tempInferenceRoot, 'bmad-fork', 'src', 'core', 'tasks', 'help.artifact.yaml'),
+ yaml.stringify({
+ schemaVersion: 1,
+ canonicalId: 'nonexistent-help-id',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: 'bmad-fork/src/core/tasks/help.md',
+ displayName: 'help',
+ description: 'Help command',
+ dependencies: { requires: [] },
+ }),
+ 'utf8',
+ );
+
+ try {
+ await noInferenceSetup.writeSkillArtifacts(noInferenceSkillDir, [exemplarTaskArtifact], 'task', {
+ projectDir: tempInferenceRoot,
+ });
+ assert(
+ false,
+ 'Codex export rejects path-inferred exemplar id when sidecar canonical-id derivation is unresolved',
+ 'Expected canonical-id derivation failure but export succeeded',
+ );
+ } catch (error) {
+ assert(
+ error.code === CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED,
+ 'Codex export unresolved canonical-id derivation returns deterministic failure code',
+ `Expected ${CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED}, got ${error.code}`,
+ );
+ }
+ } finally {
+ await fs.remove(tempInferenceRoot);
+ }
+
+ const tempShardDocInferenceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-export-no-shard-doc-inference-'));
+ try {
+ const noShardDocInferenceSetup = new CodexSetup();
+ const noShardDocInferenceSkillDir = path.join(tempShardDocInferenceRoot, '.agents', 'skills');
+ await fs.ensureDir(noShardDocInferenceSkillDir);
+ await fs.ensureDir(path.join(tempShardDocInferenceRoot, 'bmad-fork', 'src', 'core', 'tasks'));
+ await fs.writeFile(
+ path.join(tempShardDocInferenceRoot, 'bmad-fork', 'src', 'core', 'tasks', 'shard-doc.artifact.yaml'),
+ yaml.stringify({
+ schemaVersion: 1,
+ canonicalId: 'nonexistent-shard-doc-id',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml',
+ displayName: 'Shard Document',
+ description: 'Split large markdown documents into smaller files by section with an index.',
+ dependencies: { requires: [] },
+ }),
+ 'utf8',
+ );
+
+ try {
+ await noShardDocInferenceSetup.writeSkillArtifacts(noShardDocInferenceSkillDir, [shardDocTaskArtifact], 'task', {
+ projectDir: tempShardDocInferenceRoot,
+ });
+ assert(
+ false,
+ 'Codex export rejects path-inferred shard-doc id when sidecar canonical-id derivation is unresolved',
+ 'Expected shard-doc canonical-id derivation failure but export succeeded',
+ );
+ } catch (error) {
+ assert(
+ error.code === CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED,
+ 'Codex export unresolved shard-doc canonical-id derivation returns deterministic failure code',
+ `Expected ${CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED}, got ${error.code}`,
+ );
+ }
+ } finally {
+ await fs.remove(tempShardDocInferenceRoot);
+ }
+
+ const tempIndexDocsInferenceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-export-no-index-docs-inference-'));
+ try {
+ const noIndexDocsInferenceSetup = new CodexSetup();
+ const noIndexDocsInferenceSkillDir = path.join(tempIndexDocsInferenceRoot, '.agents', 'skills');
+ await fs.ensureDir(noIndexDocsInferenceSkillDir);
+ await fs.ensureDir(path.join(tempIndexDocsInferenceRoot, 'bmad-fork', 'src', 'core', 'tasks'));
+ await fs.writeFile(
+ path.join(tempIndexDocsInferenceRoot, 'bmad-fork', 'src', 'core', 'tasks', 'index-docs.artifact.yaml'),
+ yaml.stringify({
+ schemaVersion: 1,
+ canonicalId: 'nonexistent-index-docs-id',
+ 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',
+ );
+
+ try {
+ await noIndexDocsInferenceSetup.writeSkillArtifacts(noIndexDocsInferenceSkillDir, [indexDocsTaskArtifact], 'task', {
+ projectDir: tempIndexDocsInferenceRoot,
+ });
+ assert(
+ false,
+ 'Codex export rejects path-inferred index-docs id when sidecar canonical-id derivation is unresolved',
+ 'Expected index-docs canonical-id derivation failure but export succeeded',
+ );
+ } catch (error) {
+ assert(
+ error.code === CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED,
+ 'Codex export unresolved index-docs canonical-id derivation returns deterministic failure code',
+ `Expected ${CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED}, got ${error.code}`,
+ );
+ }
+ } finally {
+ await fs.remove(tempIndexDocsInferenceRoot);
+ }
+
+ const compatibilitySetup = new CodexSetup();
+ const compatibilityIdentity = await compatibilitySetup.resolveSkillIdentityFromArtifact(
+ {
+ type: 'workflow-command',
+ name: 'create-story',
+ module: 'bmm',
+ relativePath: path.join('bmm', 'workflows', 'create-story.md'),
+ },
+ tempExportRoot,
+ );
+ assert(
+ compatibilityIdentity.skillName === 'bmad-bmm-create-story' && compatibilityIdentity.exportIdDerivationSourceType === 'path-derived',
+ 'Codex export preserves non-exemplar path-derived skill identity behavior',
+ );
+ } catch (error) {
+ assert(false, 'Export projection suite setup', error.message);
+ } finally {
+ await fs.remove(tempExportRoot);
+ }
+
+ console.log('');
+
+ // ============================================================
+ // Test 12: QA Agent Compilation
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 12: QA Agent Compilation${colors.reset}\n`);
try {
const builder = new YamlXmlBuilder();
@@ -186,6 +4009,2044 @@ async function runTests() {
console.log('');
+ // ============================================================
+ // Test 13: Projection Consumer Compatibility Contracts
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 13: Projection Consumer Compatibility${colors.reset}\n`);
+
+ const tempCompatibilityRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-projection-compatibility-'));
+ try {
+ const tempCompatibilityConfigDir = path.join(tempCompatibilityRoot, '_config');
+ await fs.ensureDir(tempCompatibilityConfigDir);
+
+ const buildCsvLine = (columns, row) =>
+ columns
+ .map((column) => {
+ const value = String(row[column] ?? '');
+ return value.includes(',') ? `"${value.replaceAll('"', '""')}"` : value;
+ })
+ .join(',');
+
+ const taskManifestColumns = [
+ ...TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS,
+ ...TASK_MANIFEST_CANONICAL_ADDITIVE_COLUMNS,
+ 'futureAdditiveField',
+ ];
+ const validTaskRows = [
+ {
+ name: 'help',
+ displayName: 'help',
+ description: 'Help command',
+ module: 'core',
+ path: '{project-root}/_bmad/core/tasks/help.md',
+ standalone: 'true',
+ legacyName: 'help',
+ canonicalId: 'bmad-help',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ futureAdditiveField: 'canonical-additive',
+ },
+ {
+ name: 'create-story',
+ displayName: 'Create Story',
+ description: 'Create a dedicated story file',
+ module: 'bmm',
+ path: '{project-root}/_bmad/bmm/workflows/2-creation/create-story/workflow.yaml',
+ standalone: 'true',
+ legacyName: 'create-story',
+ canonicalId: '',
+ authoritySourceType: '',
+ authoritySourcePath: '',
+ futureAdditiveField: 'canonical-additive',
+ },
+ ];
+ const validTaskManifestCsv =
+ [taskManifestColumns.join(','), ...validTaskRows.map((row) => buildCsvLine(taskManifestColumns, row))].join('\n') + '\n';
+ await fs.writeFile(path.join(tempCompatibilityConfigDir, 'task-manifest.csv'), validTaskManifestCsv, 'utf8');
+
+ const validatedTaskSurface = validateTaskManifestCompatibilitySurface(validTaskManifestCsv, {
+ sourcePath: '_bmad/_config/task-manifest.csv',
+ });
+ assert(
+ validatedTaskSurface.headerColumns[0] === 'name' &&
+ validatedTaskSurface.headerColumns[TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS.length] === 'legacyName',
+ 'Task-manifest compatibility validator enforces locked prefix plus additive canonical ordering',
+ );
+ assert(
+ validatedTaskSurface.headerColumns.at(-1) === 'futureAdditiveField',
+ 'Task-manifest compatibility validator allows additive columns appended after locked canonical columns',
+ );
+
+ validateTaskManifestLoaderEntries(validatedTaskSurface.rows, {
+ sourcePath: '_bmad/_config/task-manifest.csv',
+ headerColumns: validatedTaskSurface.headerColumns,
+ });
+ assert(true, 'Task-manifest loader compatibility validator accepts known loader columns with additive fields');
+
+ const taskToolGenerator = new TaskToolCommandGenerator();
+ const loadedTaskRows = await taskToolGenerator.loadTaskManifest(tempCompatibilityRoot);
+ assert(
+ Array.isArray(loadedTaskRows) &&
+ loadedTaskRows.length === 2 &&
+ loadedTaskRows[0].name === 'help' &&
+ loadedTaskRows[1].name === 'create-story',
+ 'Task-manifest loader remains parseable when additive columns are present',
+ );
+
+ const legacyTaskManifestColumns = [...TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS];
+ const legacyTaskManifestCsv =
+ [legacyTaskManifestColumns.join(','), buildCsvLine(legacyTaskManifestColumns, validTaskRows[0])].join('\n') + '\n';
+ const legacyTaskSurface = validateTaskManifestCompatibilitySurface(legacyTaskManifestCsv, {
+ sourcePath: '_bmad/_config/task-manifest.csv',
+ allowLegacyPrefixOnly: true,
+ });
+ assert(
+ legacyTaskSurface.isLegacyPrefixOnlyHeader === true,
+ 'Task-manifest compatibility validator supports legacy prefix-only headers during migration reads',
+ );
+ try {
+ validateTaskManifestCompatibilitySurface(legacyTaskManifestCsv, {
+ sourcePath: '_bmad/_config/task-manifest.csv',
+ });
+ assert(false, 'Task-manifest strict validator rejects legacy prefix-only header without migration mode');
+ } catch (error) {
+ assert(
+ error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_HEADER_CANONICAL_MISMATCH,
+ 'Task-manifest strict validator emits deterministic canonical mismatch error for legacy prefix-only headers',
+ );
+ }
+
+ const reorderedTaskManifestColumns = [...taskManifestColumns];
+ [reorderedTaskManifestColumns[0], reorderedTaskManifestColumns[1]] = [reorderedTaskManifestColumns[1], reorderedTaskManifestColumns[0]];
+ const invalidTaskManifestCsv =
+ [reorderedTaskManifestColumns.join(','), buildCsvLine(reorderedTaskManifestColumns, validTaskRows[0])].join('\n') + '\n';
+ try {
+ validateTaskManifestCompatibilitySurface(invalidTaskManifestCsv, {
+ sourcePath: '_bmad/_config/task-manifest.csv',
+ });
+ assert(false, 'Task-manifest validator rejects non-additive reordered compatibility-prefix headers');
+ } catch (error) {
+ assert(
+ error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_HEADER_PREFIX_MISMATCH && error.fieldPath === 'header[0]',
+ 'Task-manifest validator emits deterministic diagnostics for reordered compatibility-prefix headers',
+ );
+ }
+
+ const helpCatalogColumns = [
+ ...HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS,
+ ...HELP_CATALOG_CANONICAL_ADDITIVE_COLUMNS,
+ 'futureAdditiveField',
+ ];
+ const validHelpRows = [
+ {
+ module: 'core',
+ phase: 'anytime',
+ name: 'bmad-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: 'Help command',
+ 'output-location': '',
+ outputs: '',
+ futureAdditiveField: 'canonical-additive',
+ },
+ {
+ 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: 'Shard document command',
+ 'output-location': '',
+ outputs: '',
+ futureAdditiveField: 'canonical-additive',
+ },
+ {
+ 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: 'Index docs command',
+ 'output-location': '',
+ outputs: '',
+ futureAdditiveField: 'canonical-additive',
+ },
+ {
+ module: 'bmm',
+ phase: 'planning',
+ name: 'create-story',
+ code: 'CS',
+ sequence: '',
+ 'workflow-file': '_bmad/bmm/workflows/2-creation/create-story/workflow.yaml',
+ command: 'bmad-bmm-create-story',
+ required: 'false',
+ 'agent-name': 'sm',
+ 'agent-command': 'bmad:agent:sm',
+ 'agent-display-name': 'Scrum Master',
+ 'agent-title': 'SM',
+ options: '',
+ description: 'Create next story',
+ 'output-location': '',
+ outputs: '',
+ futureAdditiveField: 'canonical-additive',
+ },
+ ];
+ const validHelpCatalogCsv =
+ [helpCatalogColumns.join(','), ...validHelpRows.map((row) => buildCsvLine(helpCatalogColumns, row))].join('\n') + '\n';
+ await fs.writeFile(path.join(tempCompatibilityConfigDir, 'bmad-help.csv'), validHelpCatalogCsv, 'utf8');
+
+ const validatedHelpSurface = validateHelpCatalogCompatibilitySurface(validHelpCatalogCsv, {
+ sourcePath: '_bmad/_config/bmad-help.csv',
+ });
+ assert(
+ validatedHelpSurface.headerColumns[5] === 'workflow-file' && validatedHelpSurface.headerColumns[6] === 'command',
+ 'Help-catalog compatibility validator preserves workflow-file and command compatibility columns',
+ );
+ assert(
+ validatedHelpSurface.headerColumns.at(-1) === 'futureAdditiveField',
+ 'Help-catalog compatibility validator allows additive columns appended after locked canonical columns',
+ );
+
+ validateHelpCatalogLoaderEntries(validatedHelpSurface.rows, {
+ sourcePath: '_bmad/_config/bmad-help.csv',
+ headerColumns: validatedHelpSurface.headerColumns,
+ });
+ validateGithubCopilotHelpLoaderEntries(validatedHelpSurface.rows, {
+ sourcePath: '_bmad/_config/bmad-help.csv',
+ headerColumns: validatedHelpSurface.headerColumns,
+ });
+ assert(true, 'Help-catalog and GitHub Copilot loader compatibility validators accept stable command/workflow-file contracts');
+
+ const githubCopilotSetup = new GitHubCopilotSetup();
+ const loadedHelpRows = await githubCopilotSetup.loadBmadHelp(tempCompatibilityRoot);
+ assert(
+ Array.isArray(loadedHelpRows) &&
+ loadedHelpRows.length === 4 &&
+ loadedHelpRows.some((row) => row['workflow-file'] === '_bmad/core/tasks/help.md' && row.command === 'bmad-help') &&
+ loadedHelpRows.some((row) => row['workflow-file'] === '_bmad/core/tasks/shard-doc.xml' && row.command === 'bmad-shard-doc') &&
+ loadedHelpRows.some((row) => row['workflow-file'] === '_bmad/core/tasks/index-docs.xml' && row.command === 'bmad-index-docs'),
+ 'GitHub Copilot help loader remains parseable with additive help-catalog columns',
+ );
+
+ const reorderedHelpCatalogColumns = [...helpCatalogColumns];
+ [reorderedHelpCatalogColumns[5], reorderedHelpCatalogColumns[6]] = [reorderedHelpCatalogColumns[6], reorderedHelpCatalogColumns[5]];
+ const invalidHelpCatalogCsv =
+ [reorderedHelpCatalogColumns.join(','), buildCsvLine(reorderedHelpCatalogColumns, validHelpRows[0])].join('\n') + '\n';
+ try {
+ validateHelpCatalogCompatibilitySurface(invalidHelpCatalogCsv, {
+ sourcePath: '_bmad/_config/bmad-help.csv',
+ });
+ assert(false, 'Help-catalog validator rejects non-additive reordered compatibility headers');
+ } catch (error) {
+ assert(
+ error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_HEADER_PREFIX_MISMATCH && error.fieldPath === 'header[5]',
+ 'Help-catalog validator emits deterministic diagnostics for reordered compatibility headers',
+ );
+ }
+
+ const missingShardDocRows = validHelpRows.filter((row) => row.command !== 'bmad-shard-doc');
+ const missingShardDocCsv =
+ [helpCatalogColumns.join(','), ...missingShardDocRows.map((row) => buildCsvLine(helpCatalogColumns, row))].join('\n') + '\n';
+ try {
+ validateHelpCatalogCompatibilitySurface(missingShardDocCsv, {
+ sourcePath: '_bmad/_config/bmad-help.csv',
+ });
+ assert(false, 'Help-catalog validator rejects missing shard-doc canonical command rows');
+ } catch (error) {
+ assert(
+ error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_SHARD_DOC_ROW_CONTRACT_FAILED &&
+ error.fieldPath === 'rows[*].command' &&
+ error.observedValue === '0',
+ 'Help-catalog validator emits deterministic diagnostics for missing shard-doc canonical command rows',
+ );
+ }
+
+ const missingIndexDocsRows = validHelpRows.filter((row) => row.command !== 'bmad-index-docs');
+ const missingIndexDocsCsv =
+ [helpCatalogColumns.join(','), ...missingIndexDocsRows.map((row) => buildCsvLine(helpCatalogColumns, row))].join('\n') + '\n';
+ try {
+ validateHelpCatalogCompatibilitySurface(missingIndexDocsCsv, {
+ sourcePath: '_bmad/_config/bmad-help.csv',
+ });
+ assert(false, 'Help-catalog validator rejects missing index-docs canonical command rows');
+ } catch (error) {
+ assert(
+ error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_INDEX_DOCS_ROW_CONTRACT_FAILED &&
+ error.fieldPath === 'rows[*].command' &&
+ error.observedValue === '0',
+ 'Help-catalog validator emits deterministic diagnostics for missing index-docs canonical command rows',
+ );
+ }
+
+ const shardDocBaselineRow = validHelpRows.find((row) => row.command === 'bmad-shard-doc');
+ const duplicateShardDocCsv =
+ [
+ helpCatalogColumns.join(','),
+ ...[...validHelpRows, { ...shardDocBaselineRow, name: 'Shard Document Duplicate' }].map((row) =>
+ buildCsvLine(helpCatalogColumns, row),
+ ),
+ ].join('\n') + '\n';
+ try {
+ validateHelpCatalogCompatibilitySurface(duplicateShardDocCsv, {
+ sourcePath: '_bmad/_config/bmad-help.csv',
+ });
+ assert(false, 'Help-catalog validator rejects duplicate shard-doc canonical command rows');
+ } catch (error) {
+ assert(
+ error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_SHARD_DOC_ROW_CONTRACT_FAILED &&
+ error.fieldPath === 'rows[*].command' &&
+ error.observedValue === '2',
+ 'Help-catalog validator emits deterministic diagnostics for duplicate shard-doc canonical command rows',
+ );
+ }
+
+ const missingWorkflowFileRows = [
+ {
+ ...validHelpRows[0],
+ 'workflow-file': '',
+ command: 'bmad-help',
+ },
+ ];
+ const missingWorkflowFileCsv =
+ [helpCatalogColumns.join(','), ...missingWorkflowFileRows.map((row) => buildCsvLine(helpCatalogColumns, row))].join('\n') + '\n';
+ await fs.writeFile(path.join(tempCompatibilityConfigDir, 'bmad-help.csv'), missingWorkflowFileCsv, 'utf8');
+ try {
+ await githubCopilotSetup.loadBmadHelp(tempCompatibilityRoot);
+ assert(false, 'GitHub Copilot help loader rejects rows that drop workflow-file while keeping command values');
+ } catch (error) {
+ assert(
+ error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.GITHUB_COPILOT_WORKFLOW_FILE_MISSING &&
+ error.fieldPath === 'rows[0].workflow-file',
+ 'GitHub Copilot help loader emits deterministic diagnostics for missing workflow-file compatibility breaks',
+ );
+ }
+ } catch (error) {
+ assert(false, 'Projection compatibility suite setup', error.message);
+ } finally {
+ await fs.remove(tempCompatibilityRoot);
+ }
+
+ console.log('');
+
+ // ============================================================
+ // Test 14: Deterministic Validation Artifact Suite
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 14: Deterministic Validation Artifact Suite${colors.reset}\n`);
+
+ const tempValidationHarnessRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-help-validation-suite-'));
+ try {
+ const tempProjectRoot = tempValidationHarnessRoot;
+ const tempBmadDir = path.join(tempProjectRoot, '_bmad');
+ const tempConfigDir = path.join(tempBmadDir, '_config');
+ const tempSourceTasksDir = path.join(tempProjectRoot, 'bmad-fork', 'src', 'core', 'tasks');
+ const tempSkillDir = path.join(tempProjectRoot, '.agents', 'skills', 'bmad-help');
+
+ await fs.ensureDir(tempConfigDir);
+ await fs.ensureDir(path.join(tempBmadDir, 'core', 'tasks'));
+ await fs.ensureDir(path.join(tempBmadDir, 'core'));
+ await fs.ensureDir(tempSourceTasksDir);
+ await fs.ensureDir(tempSkillDir);
+
+ 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 sidecarFixture = {
+ schemaVersion: 1,
+ canonicalId: 'bmad-help',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: 'bmad-fork/src/core/tasks/help.md',
+ displayName: 'help',
+ description: 'Help command',
+ dependencies: {
+ requires: [],
+ },
+ };
+ await fs.writeFile(path.join(tempSourceTasksDir, 'help.artifact.yaml'), yaml.stringify(sidecarFixture), 'utf8');
+ await fs.writeFile(
+ path.join(tempSourceTasksDir, 'help.md'),
+ `---\n${yaml
+ .stringify({
+ name: 'help',
+ description: 'Help command',
+ canonicalId: 'bmad-help',
+ dependencies: { requires: [] },
+ })
+ .trimEnd()}\n---\n\n# Source Help\n`,
+ 'utf8',
+ );
+ await fs.writeFile(
+ path.join(tempBmadDir, 'core', 'tasks', 'help.md'),
+ `---\n${yaml
+ .stringify({
+ name: 'help',
+ description: 'Help command',
+ canonicalId: 'bmad-help',
+ dependencies: { requires: [] },
+ })
+ .trimEnd()}\n---\n\n# Runtime Help\n`,
+ 'utf8',
+ );
+ await fs.writeFile(
+ path.join(tempSkillDir, 'SKILL.md'),
+ `---\n${yaml.stringify({ name: 'bmad-help', description: 'Help command' }).trimEnd()}\n---\n\n# Skill\n`,
+ 'utf8',
+ );
+
+ await writeCsv(
+ path.join(tempConfigDir, 'task-manifest.csv'),
+ [...TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS, ...TASK_MANIFEST_CANONICAL_ADDITIVE_COLUMNS],
+ [
+ {
+ name: 'help',
+ displayName: 'help',
+ description: 'Help command',
+ module: 'core',
+ path: '_bmad/core/tasks/help.md',
+ standalone: 'true',
+ legacyName: 'help',
+ canonicalId: 'bmad-help',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ },
+ ],
+ );
+ await writeCsv(
+ path.join(tempConfigDir, 'canonical-aliases.csv'),
+ [
+ 'canonicalId',
+ 'alias',
+ 'aliasType',
+ 'authoritySourceType',
+ 'authoritySourcePath',
+ 'rowIdentity',
+ 'normalizedAliasValue',
+ 'rawIdentityHasLeadingSlash',
+ 'resolutionEligibility',
+ ],
+ [
+ {
+ canonicalId: 'bmad-help',
+ alias: 'bmad-help',
+ aliasType: 'canonical-id',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ rowIdentity: 'alias-row:bmad-help:canonical-id',
+ normalizedAliasValue: 'bmad-help',
+ rawIdentityHasLeadingSlash: 'false',
+ resolutionEligibility: 'canonical-id-only',
+ },
+ {
+ canonicalId: 'bmad-help',
+ alias: 'help',
+ aliasType: 'legacy-name',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ rowIdentity: 'alias-row:bmad-help:legacy-name',
+ normalizedAliasValue: 'help',
+ rawIdentityHasLeadingSlash: 'false',
+ resolutionEligibility: 'legacy-name-only',
+ },
+ {
+ canonicalId: 'bmad-help',
+ alias: '/bmad-help',
+ aliasType: 'slash-command',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ rowIdentity: 'alias-row:bmad-help:slash-command',
+ normalizedAliasValue: 'bmad-help',
+ rawIdentityHasLeadingSlash: 'true',
+ resolutionEligibility: 'slash-command-only',
+ },
+ ],
+ );
+ await writeCsv(
+ path.join(tempConfigDir, 'bmad-help.csv'),
+ [...HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS, ...HELP_CATALOG_CANONICAL_ADDITIVE_COLUMNS],
+ [
+ {
+ module: 'core',
+ phase: 'anytime',
+ name: 'bmad-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: 'Help command',
+ '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(tempBmadDir, 'core', 'module-help.csv'),
+ [
+ 'module',
+ 'phase',
+ 'name',
+ 'code',
+ 'sequence',
+ 'workflow-file',
+ 'command',
+ 'required',
+ 'agent',
+ 'options',
+ 'description',
+ 'output-location',
+ 'outputs',
+ ],
+ [
+ {
+ module: 'core',
+ phase: 'anytime',
+ name: 'bmad-help',
+ code: 'BH',
+ sequence: '',
+ 'workflow-file': '_bmad/core/tasks/help.md',
+ command: 'bmad-help',
+ required: 'false',
+ agent: '',
+ options: '',
+ description: 'Help command',
+ '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: '',
+ 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: '',
+ 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, 'bmad-help-catalog-pipeline.csv'),
+ [
+ 'stage',
+ 'artifactPath',
+ 'rowIdentity',
+ 'canonicalId',
+ 'sourcePath',
+ 'rowCountForStageCanonicalId',
+ 'commandValue',
+ 'expectedCommandValue',
+ 'descriptionValue',
+ 'expectedDescriptionValue',
+ 'descriptionAuthoritySourceType',
+ 'descriptionAuthoritySourcePath',
+ 'commandAuthoritySourceType',
+ 'commandAuthoritySourcePath',
+ 'issuerOwnerClass',
+ 'issuingComponent',
+ 'issuingComponentBindingEvidence',
+ 'stageStatus',
+ 'status',
+ ],
+ [
+ {
+ stage: 'installed-compatibility-row',
+ artifactPath: '_bmad/core/module-help.csv',
+ rowIdentity: 'module-help-row:bmad-help',
+ canonicalId: 'bmad-help',
+ sourcePath: 'bmad-fork/src/core/tasks/help.md',
+ rowCountForStageCanonicalId: '1',
+ commandValue: 'bmad-help',
+ expectedCommandValue: 'bmad-help',
+ descriptionValue: 'Help command',
+ expectedDescriptionValue: 'Help command',
+ descriptionAuthoritySourceType: 'sidecar',
+ descriptionAuthoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ commandAuthoritySourceType: 'sidecar',
+ commandAuthoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ issuerOwnerClass: 'installer',
+ issuingComponent: 'bmad-fork/tools/cli/installers/lib/core/help-catalog-generator.js::buildSidecarAwareExemplarHelpRow()',
+ issuingComponentBindingEvidence: 'deterministic',
+ stageStatus: 'PASS',
+ status: 'PASS',
+ },
+ {
+ stage: 'merged-config-row',
+ artifactPath: '_bmad/_config/bmad-help.csv',
+ rowIdentity: 'merged-help-row:bmad-help',
+ canonicalId: 'bmad-help',
+ sourcePath: 'bmad-fork/src/core/tasks/help.md',
+ rowCountForStageCanonicalId: '1',
+ commandValue: 'bmad-help',
+ expectedCommandValue: 'bmad-help',
+ descriptionValue: 'Help command',
+ expectedDescriptionValue: 'Help command',
+ descriptionAuthoritySourceType: 'sidecar',
+ descriptionAuthoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ commandAuthoritySourceType: 'sidecar',
+ commandAuthoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ issuerOwnerClass: 'installer',
+ issuingComponent: 'bmad-fork/tools/cli/installers/lib/core/installer.js::mergeModuleHelpCatalogs()',
+ issuingComponentBindingEvidence: 'deterministic',
+ stageStatus: 'PASS',
+ status: 'PASS',
+ },
+ ],
+ );
+ await writeCsv(
+ path.join(tempConfigDir, 'bmad-help-command-label-report.csv'),
+ [
+ 'surface',
+ 'canonicalId',
+ 'rawCommandValue',
+ 'displayedCommandLabel',
+ 'normalizedDisplayedLabel',
+ 'rowCountForCanonicalId',
+ 'authoritySourceType',
+ 'authoritySourcePath',
+ 'status',
+ 'failureReason',
+ ],
+ [
+ {
+ surface: '_bmad/_config/bmad-help.csv',
+ canonicalId: 'bmad-help',
+ rawCommandValue: 'bmad-help',
+ displayedCommandLabel: '/bmad-help',
+ normalizedDisplayedLabel: '/bmad-help',
+ rowCountForCanonicalId: '1',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ status: 'PASS',
+ failureReason: '',
+ },
+ ],
+ );
+
+ const harness = new HelpValidationHarness();
+ const firstRun = await harness.generateAndValidate({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ sidecarPath: path.join(tempSourceTasksDir, 'help.artifact.yaml'),
+ sourceMarkdownPath: path.join(tempSourceTasksDir, 'help.md'),
+ });
+ assert(
+ firstRun.terminalStatus === 'PASS' && firstRun.generatedArtifactCount === HELP_VALIDATION_ARTIFACT_REGISTRY.length,
+ 'Help validation harness generates and validates all required artifacts',
+ );
+
+ const artifactPathsById = new Map(
+ HELP_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), `Help validation harness outputs artifact ${artifactId}`);
+ }
+
+ const artifactThreeBaselineRows = csv.parse(await fs.readFile(artifactPathsById.get(3), 'utf8'), {
+ columns: true,
+ skip_empty_lines: true,
+ });
+ const manifestProvenanceRow = artifactThreeBaselineRows.find((row) => row.artifactPath === '_bmad/_config/task-manifest.csv');
+ let manifestReplayEvidence = null;
+ try {
+ manifestReplayEvidence = JSON.parse(String(manifestProvenanceRow?.issuingComponentBindingEvidence || ''));
+ } catch {
+ manifestReplayEvidence = null;
+ }
+ assert(
+ manifestReplayEvidence &&
+ manifestReplayEvidence.evidenceVersion === 1 &&
+ manifestReplayEvidence.observationMethod === 'validator-observed-baseline-plus-isolated-single-component-perturbation' &&
+ typeof manifestReplayEvidence.baselineArtifactSha256 === 'string' &&
+ manifestReplayEvidence.baselineArtifactSha256.length === 64 &&
+ typeof manifestReplayEvidence.mutatedArtifactSha256 === 'string' &&
+ manifestReplayEvidence.mutatedArtifactSha256.length === 64 &&
+ manifestReplayEvidence.baselineArtifactSha256 !== manifestReplayEvidence.mutatedArtifactSha256 &&
+ manifestReplayEvidence.perturbationApplied === true &&
+ Number(manifestReplayEvidence.baselineTargetRowCount) > Number(manifestReplayEvidence.mutatedTargetRowCount) &&
+ manifestReplayEvidence.targetedRowLocator === manifestProvenanceRow.rowIdentity,
+ 'Help validation harness emits validator-observed replay evidence with baseline/perturbation impact',
+ );
+
+ 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',
+ sidecarPath: path.join(tempSourceTasksDir, 'help.artifact.yaml'),
+ sourceMarkdownPath: path.join(tempSourceTasksDir, 'help.md'),
+ });
+
+ 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, 'Help validation harness outputs are byte-stable across unchanged repeated runs');
+
+ await fs.remove(path.join(tempSkillDir, 'SKILL.md'));
+ const noIdeInstaller = new Installer();
+ noIdeInstaller.codexExportDerivationRecords = [];
+ const noIdeValidationOptions = await noIdeInstaller.buildHelpValidationOptions({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ });
+ assert(
+ noIdeValidationOptions.requireExportSkillProjection === false,
+ 'Installer help validation options disable export-surface requirement for no-IDE/non-Codex flow',
+ );
+ const noIdeRun = await harness.generateAndValidate({
+ ...noIdeValidationOptions,
+ sidecarPath: path.join(tempSourceTasksDir, 'help.artifact.yaml'),
+ sourceMarkdownPath: path.join(tempSourceTasksDir, 'help.md'),
+ });
+ assert(
+ noIdeRun.terminalStatus === 'PASS',
+ 'Help validation harness remains terminal-PASS for no-IDE/non-Codex flow when core projection surfaces are present',
+ );
+ const noIdeStandaloneValidation = await harness.validateGeneratedArtifacts({
+ projectDir: tempProjectRoot,
+ bmadFolderName: '_bmad',
+ });
+ assert(
+ noIdeStandaloneValidation.status === 'PASS',
+ 'Help validation harness infers no-IDE export prerequisite context during standalone validation when options are omitted',
+ );
+ try {
+ await harness.buildObservedBindingEvidence({
+ artifactPath: '_bmad/_config/task-manifest.csv',
+ absolutePath: path.join(tempBmadDir, '_config', 'task-manifest.csv'),
+ componentPath: 'bmad-fork/tools/cli/installers/lib/core/manifest-generator.js',
+ rowIdentity: 'issued-artifact:missing-claim-row',
+ optionalSurface: false,
+ runtimeFolder: '_bmad',
+ });
+ assert(false, 'Help replay evidence generation rejects unmapped claimed rowIdentity');
+ } catch (error) {
+ assert(
+ error.code === HELP_VALIDATION_ERROR_CODES.REQUIRED_ROW_IDENTITY_MISSING,
+ 'Help replay evidence generation emits deterministic missing-claimed-rowIdentity error code',
+ );
+ }
+ await fs.writeFile(
+ path.join(tempSkillDir, 'SKILL.md'),
+ `---\n${yaml.stringify({ name: 'bmad-help', description: 'Help command' }).trimEnd()}\n---\n\n# Skill\n`,
+ 'utf8',
+ );
+
+ await fs.remove(path.join(tempConfigDir, 'task-manifest.csv'));
+ try {
+ await harness.generateAndValidate({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ sidecarPath: path.join(tempSourceTasksDir, 'help.artifact.yaml'),
+ sourceMarkdownPath: path.join(tempSourceTasksDir, 'help.md'),
+ });
+ assert(false, 'Help validation harness fails when required projection input surfaces are missing');
+ } catch (error) {
+ assert(
+ error.code === HELP_VALIDATION_ERROR_CODES.REQUIRED_ARTIFACT_MISSING,
+ 'Help validation harness emits deterministic missing-input-surface error code',
+ );
+ }
+ await writeCsv(
+ path.join(tempConfigDir, 'task-manifest.csv'),
+ [...TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS, ...TASK_MANIFEST_CANONICAL_ADDITIVE_COLUMNS],
+ [
+ {
+ name: 'help',
+ displayName: 'help',
+ description: 'Help command',
+ module: 'core',
+ path: '_bmad/core/tasks/help.md',
+ standalone: 'true',
+ legacyName: 'help',
+ canonicalId: 'bmad-help',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/help/skill-manifest.yaml',
+ },
+ ],
+ );
+ await harness.generateAndValidate({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ sidecarPath: path.join(tempSourceTasksDir, 'help.artifact.yaml'),
+ sourceMarkdownPath: path.join(tempSourceTasksDir, 'help.md'),
+ });
+
+ await fs.remove(artifactPathsById.get(14));
+ try {
+ await harness.validateGeneratedArtifacts({ projectDir: tempProjectRoot });
+ assert(false, 'Help validation harness fails when a required artifact is missing');
+ } catch (error) {
+ assert(
+ error.code === HELP_VALIDATION_ERROR_CODES.REQUIRED_ARTIFACT_MISSING,
+ 'Help validation harness emits deterministic missing-artifact error code',
+ );
+ }
+
+ await harness.generateAndValidate({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ sidecarPath: path.join(tempSourceTasksDir, 'help.artifact.yaml'),
+ sourceMarkdownPath: path.join(tempSourceTasksDir, 'help.md'),
+ });
+
+ const artifactTwoPath = artifactPathsById.get(2);
+ const artifactTwoContent = await fs.readFile(artifactTwoPath, 'utf8');
+ const artifactTwoLines = artifactTwoContent.split('\n');
+ artifactTwoLines[0] = artifactTwoLines[0].replace('surface', 'brokenSurface');
+ await fs.writeFile(artifactTwoPath, artifactTwoLines.join('\n'), 'utf8');
+ try {
+ await harness.validateGeneratedArtifacts({ projectDir: tempProjectRoot });
+ assert(false, 'Help validation harness rejects schema/header drift');
+ } catch (error) {
+ assert(
+ error.code === HELP_VALIDATION_ERROR_CODES.CSV_SCHEMA_MISMATCH,
+ 'Help validation harness emits deterministic schema-mismatch error code',
+ );
+ }
+
+ await harness.generateAndValidate({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ sidecarPath: path.join(tempSourceTasksDir, 'help.artifact.yaml'),
+ sourceMarkdownPath: path.join(tempSourceTasksDir, 'help.md'),
+ });
+
+ const artifactNinePath = artifactPathsById.get(9);
+ const artifactNineHeader = (await fs.readFile(artifactNinePath, 'utf8')).split('\n')[0];
+ await fs.writeFile(artifactNinePath, `${artifactNineHeader}\n`, 'utf8');
+ try {
+ await harness.validateGeneratedArtifacts({ projectDir: tempProjectRoot });
+ assert(false, 'Help validation harness rejects header-only required-identity artifacts');
+ } catch (error) {
+ assert(
+ error.code === HELP_VALIDATION_ERROR_CODES.REQUIRED_ROW_IDENTITY_MISSING,
+ 'Help validation harness emits deterministic missing-row error code for header-only artifacts',
+ );
+ }
+
+ await harness.generateAndValidate({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ sidecarPath: path.join(tempSourceTasksDir, 'help.artifact.yaml'),
+ sourceMarkdownPath: path.join(tempSourceTasksDir, 'help.md'),
+ });
+
+ const artifactThreePath = artifactPathsById.get(3);
+ const artifactThreeContent = await fs.readFile(artifactThreePath, 'utf8');
+ const artifactThreeRows = csv.parse(artifactThreeContent, {
+ columns: true,
+ skip_empty_lines: true,
+ });
+ artifactThreeRows[0].rowIdentity = '';
+ await writeCsv(
+ artifactThreePath,
+ [
+ 'rowIdentity',
+ 'artifactPath',
+ 'canonicalId',
+ 'issuerOwnerClass',
+ 'evidenceIssuerComponent',
+ 'evidenceMethod',
+ 'issuingComponent',
+ 'issuingComponentBindingBasis',
+ 'issuingComponentBindingEvidence',
+ 'claimScope',
+ 'status',
+ ],
+ artifactThreeRows,
+ );
+ try {
+ await harness.validateGeneratedArtifacts({ projectDir: tempProjectRoot });
+ assert(false, 'Help validation harness rejects missing required row identity values');
+ } catch (error) {
+ assert(
+ error.code === HELP_VALIDATION_ERROR_CODES.REQUIRED_ROW_IDENTITY_MISSING,
+ 'Help validation harness emits deterministic row-identity error code',
+ );
+ }
+
+ await harness.generateAndValidate({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ sidecarPath: path.join(tempSourceTasksDir, 'help.artifact.yaml'),
+ sourceMarkdownPath: path.join(tempSourceTasksDir, 'help.md'),
+ });
+
+ const artifactFourPath = artifactPathsById.get(4);
+ const artifactFourRows = csv.parse(await fs.readFile(artifactFourPath, 'utf8'), {
+ columns: true,
+ skip_empty_lines: true,
+ });
+ artifactFourRows[0].issuedArtifactEvidenceRowIdentity = '';
+ await writeCsv(
+ artifactFourPath,
+ [
+ 'surface',
+ 'sourcePath',
+ 'legacyName',
+ 'canonicalId',
+ 'displayName',
+ 'normalizedCapabilityKey',
+ 'authoritySourceType',
+ 'authoritySourcePath',
+ 'issuerOwnerClass',
+ 'issuingComponent',
+ 'issuedArtifactEvidencePath',
+ 'issuedArtifactEvidenceRowIdentity',
+ 'issuingComponentBindingEvidence',
+ 'status',
+ ],
+ artifactFourRows,
+ );
+ try {
+ await harness.validateGeneratedArtifacts({ projectDir: tempProjectRoot });
+ assert(false, 'Help validation harness rejects PASS rows missing required evidence-link fields');
+ } catch (error) {
+ assert(
+ error.code === HELP_VALIDATION_ERROR_CODES.REQUIRED_EVIDENCE_LINK_MISSING,
+ 'Help validation harness emits deterministic evidence-link error code for missing row identity link',
+ );
+ }
+
+ await harness.generateAndValidate({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ sidecarPath: path.join(tempSourceTasksDir, 'help.artifact.yaml'),
+ sourceMarkdownPath: path.join(tempSourceTasksDir, 'help.md'),
+ });
+
+ const artifactNineTamperedRows = csv.parse(await fs.readFile(artifactPathsById.get(9), 'utf8'), {
+ columns: true,
+ skip_empty_lines: true,
+ });
+ artifactNineTamperedRows[0].issuingComponent = 'self-attested-generator-component';
+ await writeCsv(
+ artifactPathsById.get(9),
+ [
+ 'stage',
+ 'artifactPath',
+ 'rowIdentity',
+ 'canonicalId',
+ 'sourcePath',
+ 'rowCountForStageCanonicalId',
+ 'commandValue',
+ 'expectedCommandValue',
+ 'descriptionValue',
+ 'expectedDescriptionValue',
+ 'descriptionAuthoritySourceType',
+ 'descriptionAuthoritySourcePath',
+ 'commandAuthoritySourceType',
+ 'commandAuthoritySourcePath',
+ 'issuerOwnerClass',
+ 'issuingComponent',
+ 'issuedArtifactEvidencePath',
+ 'issuedArtifactEvidenceRowIdentity',
+ 'issuingComponentBindingEvidence',
+ 'stageStatus',
+ 'status',
+ ],
+ artifactNineTamperedRows,
+ );
+ try {
+ await harness.validateGeneratedArtifacts({ projectDir: tempProjectRoot });
+ assert(false, 'Help validation harness rejects self-attested issuer claims that diverge from validator evidence');
+ } catch (error) {
+ assert(
+ error.code === HELP_VALIDATION_ERROR_CODES.SELF_ATTESTED_ISSUER_CLAIM,
+ 'Help validation harness emits deterministic self-attested issuer-claim rejection code',
+ );
+ }
+
+ await harness.generateAndValidate({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ sidecarPath: path.join(tempSourceTasksDir, 'help.artifact.yaml'),
+ sourceMarkdownPath: path.join(tempSourceTasksDir, 'help.md'),
+ });
+
+ const artifactThreeTamperedRows = csv.parse(await fs.readFile(artifactPathsById.get(3), 'utf8'), {
+ columns: true,
+ skip_empty_lines: true,
+ });
+ artifactThreeTamperedRows[0].issuingComponentBindingEvidence = '{"broken":true}';
+ await writeCsv(
+ artifactPathsById.get(3),
+ [
+ 'rowIdentity',
+ 'artifactPath',
+ 'canonicalId',
+ 'issuerOwnerClass',
+ 'evidenceIssuerComponent',
+ 'evidenceMethod',
+ 'issuingComponent',
+ 'issuingComponentBindingBasis',
+ 'issuingComponentBindingEvidence',
+ 'claimScope',
+ 'status',
+ ],
+ artifactThreeTamperedRows,
+ );
+ try {
+ await harness.validateGeneratedArtifacts({ projectDir: tempProjectRoot });
+ assert(false, 'Help validation harness rejects malformed replay-evidence payloads');
+ } catch (error) {
+ assert(
+ error.code === HELP_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID,
+ 'Help validation harness emits deterministic replay-evidence validation error code',
+ );
+ }
+
+ await fs.writeFile(path.join(tempSourceTasksDir, 'bmad-config.yaml'), 'canonicalId: root-bmad-config\n', 'utf8');
+ await fs.ensureDir(path.join(tempSourceTasksDir, 'help'));
+ await fs.writeFile(path.join(tempSourceTasksDir, 'help', 'bmad-config.yaml'), 'canonicalId: help-bmad-config\n', 'utf8');
+ try {
+ await harness.generateValidationArtifacts({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ sourceMarkdownPath: path.join(tempSourceTasksDir, 'help.md'),
+ });
+ assert(false, 'Help validation harness normalizes metadata-resolution ambiguity into harness-native deterministic error');
+ } catch (error) {
+ assert(
+ error.code === HELP_VALIDATION_ERROR_CODES.METADATA_RESOLUTION_FAILED,
+ 'Help validation harness emits deterministic metadata-resolution error code',
+ );
+ }
+ } catch (error) {
+ assert(false, 'Deterministic validation artifact suite setup', error.message);
+ } finally {
+ await fs.remove(tempValidationHarnessRoot);
+ }
+
+ console.log('');
+
+ // ============================================================
+ // Test 15: Shard-doc Validation Artifact Suite
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 15: Shard-doc Validation Artifact Suite${colors.reset}\n`);
+
+ const tempShardDocValidationHarnessRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-shard-doc-validation-suite-'));
+ try {
+ const tempProjectRoot = tempShardDocValidationHarnessRoot;
+ const tempBmadDir = path.join(tempProjectRoot, '_bmad');
+ const tempConfigDir = path.join(tempBmadDir, '_config');
+ const tempSourceTasksDir = path.join(tempProjectRoot, 'bmad-fork', 'src', 'core', 'tasks');
+ const commandLabelReportPath = path.join(tempConfigDir, 'bmad-help-command-label-report.csv');
+
+ await fs.ensureDir(tempConfigDir);
+ await fs.ensureDir(tempSourceTasksDir);
+
+ const writeCsv = async (filePath, columns, rows) => {
+ const buildCsvLine = (values) =>
+ values
+ .map((value) => {
+ const text = String(value ?? '');
+ return text.includes(',') || text.includes('"') ? `"${text.replaceAll('"', '""')}"` : text;
+ })
+ .join(',');
+ const lines = [columns.join(','), ...rows.map((row) => buildCsvLine(columns.map((column) => row[column] ?? '')))];
+ await fs.writeFile(filePath, `${lines.join('\n')}\n`, 'utf8');
+ };
+
+ const commandLabelReportColumns = [
+ 'surface',
+ 'canonicalId',
+ 'rawCommandValue',
+ 'displayedCommandLabel',
+ 'normalizedDisplayedLabel',
+ 'rowCountForCanonicalId',
+ 'authoritySourceType',
+ 'authoritySourcePath',
+ 'status',
+ 'failureReason',
+ ];
+ const commandLabelReportRows = [
+ {
+ surface: '_bmad/_config/bmad-help.csv',
+ canonicalId: 'bmad-shard-doc',
+ rawCommandValue: 'bmad-shard-doc',
+ displayedCommandLabel: '/bmad-shard-doc',
+ normalizedDisplayedLabel: '/bmad-shard-doc',
+ rowCountForCanonicalId: '1',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ status: 'PASS',
+ failureReason: '',
+ },
+ ];
+
+ await fs.writeFile(
+ path.join(tempSourceTasksDir, 'shard-doc.artifact.yaml'),
+ yaml.stringify({
+ schemaVersion: 1,
+ canonicalId: 'bmad-shard-doc',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml',
+ displayName: 'Shard Document',
+ description: 'Split large markdown documents into smaller files by section with an index.',
+ dependencies: {
+ requires: [],
+ },
+ }),
+ 'utf8',
+ );
+ await fs.writeFile(
+ path.join(tempSourceTasksDir, 'shard-doc.xml'),
+ 'Split markdown docs\n',
+ 'utf8',
+ );
+
+ await writeCsv(
+ path.join(tempConfigDir, 'task-manifest.csv'),
+ [...TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS, ...TASK_MANIFEST_CANONICAL_ADDITIVE_COLUMNS],
+ [
+ {
+ name: 'shard-doc',
+ displayName: 'Shard Document',
+ description: 'Split large markdown documents into smaller files by section with an index.',
+ module: 'core',
+ path: '_bmad/core/tasks/shard-doc.xml',
+ standalone: 'true',
+ legacyName: 'shard-doc',
+ canonicalId: 'bmad-shard-doc',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.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-shard-doc',
+ alias: 'bmad-shard-doc',
+ aliasType: 'canonical-id',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ rowIdentity: 'alias-row:bmad-shard-doc:canonical-id',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: 'false',
+ resolutionEligibility: 'canonical-id-only',
+ },
+ {
+ canonicalId: 'bmad-shard-doc',
+ alias: 'shard-doc',
+ aliasType: 'legacy-name',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ rowIdentity: 'alias-row:bmad-shard-doc:legacy-name',
+ normalizedAliasValue: 'shard-doc',
+ rawIdentityHasLeadingSlash: 'false',
+ resolutionEligibility: 'legacy-name-only',
+ },
+ {
+ canonicalId: 'bmad-shard-doc',
+ alias: '/bmad-shard-doc',
+ aliasType: 'slash-command',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ rowIdentity: 'alias-row:bmad-shard-doc:slash-command',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: 'true',
+ resolutionEligibility: 'slash-command-only',
+ },
+ ],
+ );
+ await writeCsv(commandLabelReportPath, commandLabelReportColumns, commandLabelReportRows);
+
+ const authorityRecords = [
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-shard-doc',
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml',
+ },
+ {
+ recordType: 'source-body-authority',
+ canonicalId: 'bmad-shard-doc',
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ authoritySourceType: 'source-xml',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml',
+ },
+ ];
+
+ const harness = new ShardDocValidationHarness();
+ const firstRun = await harness.generateAndValidate({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ shardDocAuthorityRecords: authorityRecords,
+ });
+ assert(
+ firstRun.terminalStatus === 'PASS' && firstRun.generatedArtifactCount === SHARD_DOC_VALIDATION_ARTIFACT_REGISTRY.length,
+ 'Shard-doc validation harness generates and validates all required artifacts',
+ );
+
+ const artifactPathsById = new Map(
+ SHARD_DOC_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), `Shard-doc validation harness outputs artifact ${artifactId}`);
+ }
+
+ const firstArtifactContents = new Map();
+ for (const [artifactId, artifactPath] of artifactPathsById.entries()) {
+ firstArtifactContents.set(artifactId, await fs.readFile(artifactPath, 'utf8'));
+ }
+
+ await harness.generateAndValidate({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ shardDocAuthorityRecords: authorityRecords,
+ });
+ let deterministicOutputs = true;
+ for (const [artifactId, artifactPath] of artifactPathsById.entries()) {
+ const rerunContent = await fs.readFile(artifactPath, 'utf8');
+ if (rerunContent !== firstArtifactContents.get(artifactId)) {
+ deterministicOutputs = false;
+ break;
+ }
+ }
+ assert(deterministicOutputs, 'Shard-doc 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, 'Shard-doc replay evidence generation rejects missing claimed rowIdentity');
+ } catch (error) {
+ assert(
+ error.code === SHARD_DOC_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING,
+ 'Shard-doc 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, 'Shard-doc replay evidence generation rejects issuing-component contract mismatch');
+ } catch (error) {
+ assert(
+ error.code === SHARD_DOC_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID,
+ 'Shard-doc 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, SHARD_DOC_VALIDATION_ARTIFACT_REGISTRY[10].columns, artifactElevenRows);
+ try {
+ await harness.validateGeneratedArtifacts({ projectDir: tempProjectRoot });
+ assert(false, 'Shard-doc validation harness rejects malformed replay-evidence payloads');
+ } catch (error) {
+ assert(
+ error.code === SHARD_DOC_VALIDATION_ERROR_CODES.REPLAY_EVIDENCE_INVALID,
+ 'Shard-doc validation harness emits deterministic replay-evidence validation error code',
+ );
+ }
+
+ await harness.generateAndValidate({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ shardDocAuthorityRecords: authorityRecords,
+ });
+
+ await fs.remove(artifactPathsById.get(8));
+ try {
+ await harness.validateGeneratedArtifacts({ projectDir: tempProjectRoot });
+ assert(false, 'Shard-doc validation harness fails when a required artifact is missing');
+ } catch (error) {
+ assert(
+ error.code === SHARD_DOC_VALIDATION_ERROR_CODES.REQUIRED_ARTIFACT_MISSING,
+ 'Shard-doc validation harness emits deterministic missing-artifact error code',
+ );
+ }
+
+ await harness.generateAndValidate({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ shardDocAuthorityRecords: authorityRecords,
+ });
+
+ await fs.remove(commandLabelReportPath);
+ try {
+ await harness.generateValidationArtifacts({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ shardDocAuthorityRecords: authorityRecords,
+ });
+ assert(false, 'Shard-doc validation harness rejects missing command-label report input surface');
+ } catch (error) {
+ assert(
+ error.code === SHARD_DOC_VALIDATION_ERROR_CODES.REQUIRED_ARTIFACT_MISSING,
+ 'Shard-doc 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, 'Shard-doc validation harness rejects schema/header drift');
+ } catch (error) {
+ assert(
+ error.code === SHARD_DOC_VALIDATION_ERROR_CODES.CSV_SCHEMA_MISMATCH,
+ 'Shard-doc validation harness emits deterministic schema-mismatch error code',
+ );
+ }
+
+ await harness.generateAndValidate({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ shardDocAuthorityRecords: authorityRecords,
+ });
+
+ const artifactEightPath = artifactPathsById.get(8);
+ const artifactEightRows = csv.parse(await fs.readFile(artifactEightPath, 'utf8'), {
+ columns: true,
+ skip_empty_lines: true,
+ });
+ const artifactSixInventoryRow = artifactEightRows.find((row) => row.artifactId === '6');
+ if (artifactSixInventoryRow) {
+ artifactSixInventoryRow.artifactPath = 'validation/shard-doc/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, 'Shard-doc validation harness rejects inventory deterministic-identifier drift');
+ } catch (error) {
+ assert(
+ error.code === SHARD_DOC_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING,
+ 'Shard-doc validation harness emits deterministic inventory-row validation error code',
+ );
+ }
+
+ await harness.generateAndValidate({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ shardDocAuthorityRecords: authorityRecords,
+ });
+
+ const artifactTwoPath = artifactPathsById.get(2);
+ const artifactTwoRows = csv.parse(await fs.readFile(artifactTwoPath, 'utf8'), {
+ columns: true,
+ skip_empty_lines: true,
+ });
+ const filteredAuthorityRows = artifactTwoRows.filter((row) => row.recordType !== 'source-body-authority');
+ await writeCsv(
+ artifactTwoPath,
+ ['rowIdentity', 'recordType', 'canonicalId', 'authoritativePresenceKey', 'authoritySourceType', 'authoritySourcePath', 'status'],
+ filteredAuthorityRows,
+ );
+ try {
+ await harness.validateGeneratedArtifacts({ projectDir: tempProjectRoot });
+ assert(false, 'Shard-doc validation harness rejects missing source-body authority records');
+ } catch (error) {
+ assert(
+ error.code === SHARD_DOC_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING,
+ 'Shard-doc validation harness emits deterministic missing-row error code',
+ );
+ }
+
+ await fs.writeFile(path.join(tempSourceTasksDir, 'bmad-config.yaml'), 'canonicalId: root-bmad-config\n', 'utf8');
+ await fs.ensureDir(path.join(tempSourceTasksDir, 'shard-doc'));
+ await fs.writeFile(path.join(tempSourceTasksDir, 'shard-doc', 'bmad-config.yaml'), 'canonicalId: shard-doc-bmad-config\n', 'utf8');
+ try {
+ await harness.generateValidationArtifacts({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ sourceXmlPath: path.join(tempSourceTasksDir, 'shard-doc.xml'),
+ });
+ assert(false, 'Shard-doc validation harness normalizes metadata-resolution ambiguity into harness-native deterministic error');
+ } catch (error) {
+ assert(
+ error.code === SHARD_DOC_VALIDATION_ERROR_CODES.METADATA_RESOLUTION_FAILED,
+ 'Shard-doc validation harness emits deterministic metadata-resolution error code',
+ );
+ }
+ } catch (error) {
+ assert(false, 'Shard-doc validation artifact suite setup', error.message);
+ } finally {
+ await fs.remove(tempShardDocValidationHarnessRoot);
+ }
+
+ 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/skill-manifest.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/skill-manifest.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/skill-manifest.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/skill-manifest.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/skill-manifest.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/skill-manifest.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',
+ );
+ }
+
+ await fs.writeFile(path.join(tempSourceTasksDir, 'bmad-config.yaml'), 'canonicalId: root-bmad-config\n', 'utf8');
+ await fs.ensureDir(path.join(tempSourceTasksDir, 'index-docs'));
+ await fs.writeFile(path.join(tempSourceTasksDir, 'index-docs', 'bmad-config.yaml'), 'canonicalId: index-docs-bmad-config\n', 'utf8');
+ try {
+ await harness.generateValidationArtifacts({
+ projectDir: tempProjectRoot,
+ bmadDir: tempBmadDir,
+ bmadFolderName: '_bmad',
+ sourceXmlPath: path.join(tempSourceTasksDir, 'index-docs.xml'),
+ });
+ assert(false, 'Index-docs validation harness normalizes metadata-resolution ambiguity into harness-native deterministic error');
+ } catch (error) {
+ assert(
+ error.code === INDEX_DOCS_VALIDATION_ERROR_CODES.METADATA_RESOLUTION_FAILED,
+ 'Index-docs validation harness emits deterministic metadata-resolution 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/help-alias-normalizer.js b/tools/cli/installers/lib/core/help-alias-normalizer.js
new file mode 100644
index 000000000..600de5e66
--- /dev/null
+++ b/tools/cli/installers/lib/core/help-alias-normalizer.js
@@ -0,0 +1,266 @@
+const path = require('node:path');
+const fs = require('fs-extra');
+const csv = require('csv-parse/sync');
+
+const HELP_ALIAS_NORMALIZATION_ERROR_CODES = Object.freeze({
+ EMPTY_INPUT: 'ERR_CAPABILITY_ALIAS_EMPTY_INPUT',
+ MULTIPLE_LEADING_SLASHES: 'ERR_CAPABILITY_ALIAS_MULTIPLE_LEADING_SLASHES',
+ EMPTY_PREALIAS: 'ERR_CAPABILITY_ALIAS_EMPTY_PREALIAS',
+ UNRESOLVED: 'ERR_CAPABILITY_ALIAS_UNRESOLVED',
+});
+
+const EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH = '_bmad/_config/canonical-aliases.csv';
+
+const LOCKED_EXEMPLAR_ALIAS_ROWS = Object.freeze([
+ Object.freeze({
+ rowIdentity: 'alias-row:bmad-help:canonical-id',
+ canonicalId: 'bmad-help',
+ normalizedAliasValue: 'bmad-help',
+ rawIdentityHasLeadingSlash: false,
+ }),
+ Object.freeze({
+ rowIdentity: 'alias-row:bmad-help:legacy-name',
+ canonicalId: 'bmad-help',
+ normalizedAliasValue: 'help',
+ rawIdentityHasLeadingSlash: false,
+ }),
+ Object.freeze({
+ rowIdentity: 'alias-row:bmad-help:slash-command',
+ canonicalId: 'bmad-help',
+ normalizedAliasValue: 'bmad-help',
+ rawIdentityHasLeadingSlash: true,
+ }),
+]);
+
+class HelpAliasNormalizationError extends Error {
+ constructor({ code, detail, fieldPath, sourcePath, observedValue }) {
+ const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath}, observedValue=${observedValue})`;
+ super(message);
+ this.name = 'HelpAliasNormalizationError';
+ this.code = code;
+ this.detail = detail;
+ this.fieldPath = fieldPath;
+ this.sourcePath = sourcePath;
+ this.observedValue = observedValue;
+ this.fullMessage = message;
+ }
+}
+
+function normalizeSourcePath(value) {
+ if (!value) return '';
+ return String(value).replaceAll('\\', '/');
+}
+
+function collapseWhitespace(value) {
+ return String(value).replaceAll(/\s+/g, ' ');
+}
+
+function parseBoolean(value) {
+ if (typeof value === 'boolean') return value;
+ if (typeof value === 'number') return value === 1;
+
+ const normalized = String(value ?? '')
+ .trim()
+ .toLowerCase();
+ if (normalized === 'true' || normalized === '1') return true;
+ if (normalized === 'false' || normalized === '0') return false;
+ return null;
+}
+
+function throwAliasNormalizationError({ code, detail, fieldPath, sourcePath, observedValue }) {
+ throw new HelpAliasNormalizationError({
+ code,
+ detail,
+ fieldPath,
+ sourcePath,
+ observedValue,
+ });
+}
+
+function normalizeRawIdentityToTuple(rawIdentity, options = {}) {
+ const fieldPath = options.fieldPath || 'rawIdentity';
+ const sourcePath = normalizeSourcePath(options.sourcePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH);
+ const normalizedRawIdentity = collapseWhitespace(
+ String(rawIdentity ?? '')
+ .trim()
+ .toLowerCase(),
+ );
+
+ if (!normalizedRawIdentity) {
+ throwAliasNormalizationError({
+ code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.EMPTY_INPUT,
+ detail: 'alias identity is empty after normalization',
+ fieldPath,
+ sourcePath,
+ observedValue: normalizedRawIdentity,
+ });
+ }
+
+ if (/^\/{2,}/.test(normalizedRawIdentity)) {
+ throwAliasNormalizationError({
+ code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.MULTIPLE_LEADING_SLASHES,
+ detail: 'alias identity contains multiple leading slashes',
+ fieldPath,
+ sourcePath,
+ observedValue: normalizedRawIdentity,
+ });
+ }
+
+ const rawIdentityHasLeadingSlash = normalizedRawIdentity.startsWith('/');
+ const preAliasNormalizedValue = rawIdentityHasLeadingSlash ? normalizedRawIdentity.slice(1) : normalizedRawIdentity;
+
+ if (!preAliasNormalizedValue) {
+ throwAliasNormalizationError({
+ code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.EMPTY_PREALIAS,
+ detail: 'alias preAliasNormalizedValue is empty after slash normalization',
+ fieldPath: 'preAliasNormalizedValue',
+ sourcePath,
+ observedValue: normalizedRawIdentity,
+ });
+ }
+
+ return {
+ normalizedRawIdentity,
+ rawIdentityHasLeadingSlash,
+ preAliasNormalizedValue,
+ };
+}
+
+function normalizeAliasRows(aliasRows, aliasTableSourcePath = EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH) {
+ if (!Array.isArray(aliasRows)) return [];
+
+ const normalizedRows = [];
+ const sourcePath = normalizeSourcePath(aliasTableSourcePath);
+
+ for (const row of aliasRows) {
+ if (!row || typeof row !== 'object' || Array.isArray(row)) {
+ continue;
+ }
+
+ const canonicalId = collapseWhitespace(
+ String(row.canonicalId ?? '')
+ .trim()
+ .toLowerCase(),
+ );
+ const rowIdentity = String(row.rowIdentity ?? '').trim();
+ const parsedLeadingSlash = parseBoolean(row.rawIdentityHasLeadingSlash);
+ const normalizedAliasValue = collapseWhitespace(
+ String(row.normalizedAliasValue ?? '')
+ .trim()
+ .toLowerCase(),
+ );
+
+ if (!rowIdentity || !canonicalId || parsedLeadingSlash === null || !normalizedAliasValue) {
+ continue;
+ }
+
+ normalizedRows.push({
+ rowIdentity,
+ canonicalId,
+ rawIdentityHasLeadingSlash: parsedLeadingSlash,
+ normalizedAliasValue,
+ sourcePath,
+ });
+ }
+
+ normalizedRows.sort((left, right) => left.rowIdentity.localeCompare(right.rowIdentity));
+ return normalizedRows;
+}
+
+function resolveAliasTupleFromRows(tuple, aliasRows, options = {}) {
+ const sourcePath = normalizeSourcePath(options.sourcePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH);
+ const normalizedRows = normalizeAliasRows(aliasRows, sourcePath);
+
+ const matches = normalizedRows.filter(
+ (row) =>
+ row.rawIdentityHasLeadingSlash === tuple.rawIdentityHasLeadingSlash && row.normalizedAliasValue === tuple.preAliasNormalizedValue,
+ );
+
+ if (matches.length === 0) {
+ throwAliasNormalizationError({
+ code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
+ detail: 'alias tuple did not resolve to any canonical alias row',
+ fieldPath: 'preAliasNormalizedValue',
+ sourcePath,
+ observedValue: `${tuple.preAliasNormalizedValue}|leadingSlash:${tuple.rawIdentityHasLeadingSlash}`,
+ });
+ }
+
+ if (matches.length > 1) {
+ throwAliasNormalizationError({
+ code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
+ detail: 'alias tuple resolved ambiguously to multiple canonical alias rows',
+ fieldPath: 'preAliasNormalizedValue',
+ sourcePath,
+ observedValue: `${tuple.preAliasNormalizedValue}|leadingSlash:${tuple.rawIdentityHasLeadingSlash}`,
+ });
+ }
+
+ const match = matches[0];
+ return {
+ aliasRowLocator: match.rowIdentity,
+ postAliasCanonicalId: match.canonicalId,
+ aliasResolutionSourcePath: sourcePath,
+ };
+}
+
+async function resolveAliasTupleUsingCanonicalAliasCsv(tuple, aliasTablePath, options = {}) {
+ const sourcePath = normalizeSourcePath(options.sourcePath || aliasTablePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH);
+
+ if (!aliasTablePath || !(await fs.pathExists(aliasTablePath))) {
+ throwAliasNormalizationError({
+ code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
+ detail: 'canonical alias table file was not found',
+ fieldPath: 'aliasTablePath',
+ sourcePath,
+ observedValue: aliasTablePath || '',
+ });
+ }
+
+ const csvRaw = await fs.readFile(aliasTablePath, 'utf8');
+ const parsedRows = csv.parse(csvRaw, {
+ columns: true,
+ skip_empty_lines: true,
+ trim: true,
+ });
+
+ return resolveAliasTupleFromRows(tuple, parsedRows, { sourcePath });
+}
+
+async function normalizeAndResolveExemplarAlias(rawIdentity, options = {}) {
+ const tuple = normalizeRawIdentityToTuple(rawIdentity, {
+ fieldPath: options.fieldPath || 'rawIdentity',
+ sourcePath: options.sourcePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH,
+ });
+
+ let resolution;
+ if (Array.isArray(options.aliasRows)) {
+ resolution = resolveAliasTupleFromRows(tuple, options.aliasRows, {
+ sourcePath: options.aliasTableSourcePath || options.sourcePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH,
+ });
+ } else if (options.aliasTablePath) {
+ resolution = await resolveAliasTupleUsingCanonicalAliasCsv(tuple, options.aliasTablePath, {
+ sourcePath: options.aliasTableSourcePath || options.sourcePath || normalizeSourcePath(path.resolve(options.aliasTablePath)),
+ });
+ } else {
+ resolution = resolveAliasTupleFromRows(tuple, LOCKED_EXEMPLAR_ALIAS_ROWS, {
+ sourcePath: options.aliasTableSourcePath || options.sourcePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH,
+ });
+ }
+
+ return {
+ ...tuple,
+ ...resolution,
+ };
+}
+
+module.exports = {
+ HELP_ALIAS_NORMALIZATION_ERROR_CODES,
+ EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH,
+ LOCKED_EXEMPLAR_ALIAS_ROWS,
+ HelpAliasNormalizationError,
+ normalizeRawIdentityToTuple,
+ resolveAliasTupleFromRows,
+ resolveAliasTupleUsingCanonicalAliasCsv,
+ normalizeAndResolveExemplarAlias,
+};
diff --git a/tools/cli/installers/lib/core/help-authority-validator.js b/tools/cli/installers/lib/core/help-authority-validator.js
new file mode 100644
index 000000000..08dc58129
--- /dev/null
+++ b/tools/cli/installers/lib/core/help-authority-validator.js
@@ -0,0 +1,401 @@
+const path = require('node:path');
+const fs = require('fs-extra');
+const yaml = require('yaml');
+const { getProjectRoot, getSourcePath } = require('../../../lib/project-root');
+const { normalizeAndResolveExemplarAlias } = require('./help-alias-normalizer');
+const { resolveSkillMetadataAuthority } = require('./sidecar-contract-validator');
+
+const HELP_AUTHORITY_VALIDATION_ERROR_CODES = Object.freeze({
+ SIDECAR_FILE_NOT_FOUND: 'ERR_HELP_AUTHORITY_SIDECAR_FILE_NOT_FOUND',
+ SIDECAR_FILENAME_AMBIGUOUS: 'ERR_HELP_AUTHORITY_SIDECAR_FILENAME_AMBIGUOUS',
+ SIDECAR_PARSE_FAILED: 'ERR_HELP_AUTHORITY_SIDECAR_PARSE_FAILED',
+ SIDECAR_INVALID_METADATA: 'ERR_HELP_AUTHORITY_SIDECAR_INVALID_METADATA',
+ MARKDOWN_FILE_NOT_FOUND: 'ERR_HELP_AUTHORITY_MARKDOWN_FILE_NOT_FOUND',
+ FRONTMATTER_PARSE_FAILED: 'ERR_HELP_AUTHORITY_FRONTMATTER_PARSE_FAILED',
+});
+
+const HELP_FRONTMATTER_MISMATCH_ERROR_CODES = Object.freeze({
+ CANONICAL_ID_MISMATCH: 'ERR_FRONTMATTER_CANONICAL_ID_MISMATCH',
+ DISPLAY_NAME_MISMATCH: 'ERR_FRONTMATTER_DISPLAY_NAME_MISMATCH',
+ DESCRIPTION_MISMATCH: 'ERR_FRONTMATTER_DESCRIPTION_MISMATCH',
+ DEPENDENCIES_REQUIRES_MISMATCH: 'ERR_FRONTMATTER_DEPENDENCIES_REQUIRES_MISMATCH',
+});
+
+const FRONTMATTER_MISMATCH_DETAILS = Object.freeze({
+ [HELP_FRONTMATTER_MISMATCH_ERROR_CODES.CANONICAL_ID_MISMATCH]: 'frontmatter canonicalId must match sidecar canonicalId',
+ [HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DISPLAY_NAME_MISMATCH]: 'frontmatter name must match sidecar displayName',
+ [HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DESCRIPTION_MISMATCH]: 'frontmatter description must match sidecar description',
+ [HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DEPENDENCIES_REQUIRES_MISMATCH]:
+ 'frontmatter dependencies.requires must match sidecar dependencies.requires',
+});
+
+class HelpAuthorityValidationError extends Error {
+ constructor({ code, detail, fieldPath, sourcePath, observedValue, expectedValue }) {
+ const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath})`;
+ super(message);
+ this.name = 'HelpAuthorityValidationError';
+ this.code = code;
+ this.detail = detail;
+ this.fieldPath = fieldPath;
+ this.sourcePath = sourcePath;
+ this.observedValue = observedValue;
+ this.expectedValue = expectedValue;
+ this.fullMessage = message;
+ }
+}
+
+function normalizeSourcePath(value) {
+ if (!value) return '';
+ return String(value).replaceAll('\\', '/');
+}
+
+function toProjectRelativePath(filePath) {
+ const projectRoot = getProjectRoot();
+ const relative = path.relative(projectRoot, filePath);
+
+ if (!relative || relative.startsWith('..')) {
+ return normalizeSourcePath(path.resolve(filePath));
+ }
+
+ return normalizeSourcePath(relative);
+}
+
+function hasOwn(obj, key) {
+ return Object.prototype.hasOwnProperty.call(obj, key);
+}
+
+function isBlankString(value) {
+ return typeof value !== 'string' || value.trim().length === 0;
+}
+
+function ensureSidecarMetadata(sidecarData, sidecarSourcePath) {
+ const requiredFields = ['canonicalId', 'displayName', 'description', 'dependencies'];
+ for (const requiredField of requiredFields) {
+ if (!hasOwn(sidecarData, requiredField)) {
+ throw new HelpAuthorityValidationError({
+ code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
+ detail: `Missing required sidecar metadata field "${requiredField}"`,
+ fieldPath: requiredField,
+ sourcePath: sidecarSourcePath,
+ });
+ }
+ }
+
+ const requiredStringFields = ['canonicalId', 'displayName', 'description'];
+ for (const requiredStringField of requiredStringFields) {
+ if (isBlankString(sidecarData[requiredStringField])) {
+ throw new HelpAuthorityValidationError({
+ code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
+ detail: `Required sidecar metadata field "${requiredStringField}" must be a non-empty string`,
+ fieldPath: requiredStringField,
+ sourcePath: sidecarSourcePath,
+ });
+ }
+ }
+
+ const requires = sidecarData.dependencies?.requires;
+ if (!Array.isArray(requires)) {
+ throw new HelpAuthorityValidationError({
+ code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
+ detail: 'Sidecar metadata field "dependencies.requires" must be an array',
+ fieldPath: 'dependencies.requires',
+ sourcePath: sidecarSourcePath,
+ observedValue: requires,
+ expectedValue: [],
+ });
+ }
+}
+
+function serializeNormalizedDependencyTargets(value) {
+ if (!Array.isArray(value)) return null;
+
+ const normalized = value
+ .map((target) =>
+ String(target ?? '')
+ .trim()
+ .toLowerCase(),
+ )
+ .filter((target) => target.length > 0)
+ .sort();
+
+ return JSON.stringify(normalized);
+}
+
+function frontmatterMatchValue(value) {
+ if (typeof value === 'string') {
+ return value.trim();
+ }
+ if (value === null || value === undefined) {
+ return '';
+ }
+ return String(value).trim();
+}
+
+function createFrontmatterMismatchError(code, fieldPath, sourcePath, observedValue, expectedValue) {
+ throw new HelpAuthorityValidationError({
+ code,
+ detail: FRONTMATTER_MISMATCH_DETAILS[code],
+ fieldPath,
+ sourcePath,
+ observedValue,
+ expectedValue,
+ });
+}
+
+function validateFrontmatterPrecedence(frontmatter, sidecarData, markdownSourcePath) {
+ if (!frontmatter || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
+ return;
+ }
+
+ const sidecarCanonicalId = frontmatterMatchValue(sidecarData.canonicalId);
+ const sidecarDisplayName = frontmatterMatchValue(sidecarData.displayName);
+ const sidecarDescription = frontmatterMatchValue(sidecarData.description);
+
+ if (hasOwn(frontmatter, 'canonicalId')) {
+ const observedCanonicalId = frontmatterMatchValue(frontmatter.canonicalId);
+ if (observedCanonicalId.length > 0 && observedCanonicalId !== sidecarCanonicalId) {
+ createFrontmatterMismatchError(
+ HELP_FRONTMATTER_MISMATCH_ERROR_CODES.CANONICAL_ID_MISMATCH,
+ 'canonicalId',
+ markdownSourcePath,
+ observedCanonicalId,
+ sidecarCanonicalId,
+ );
+ }
+ }
+
+ if (hasOwn(frontmatter, 'name')) {
+ const observedName = frontmatterMatchValue(frontmatter.name);
+ if (observedName.length > 0 && observedName !== sidecarDisplayName) {
+ createFrontmatterMismatchError(
+ HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DISPLAY_NAME_MISMATCH,
+ 'name',
+ markdownSourcePath,
+ observedName,
+ sidecarDisplayName,
+ );
+ }
+ }
+
+ if (hasOwn(frontmatter, 'description')) {
+ const observedDescription = frontmatterMatchValue(frontmatter.description);
+ if (observedDescription.length > 0 && observedDescription !== sidecarDescription) {
+ createFrontmatterMismatchError(
+ HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DESCRIPTION_MISMATCH,
+ 'description',
+ markdownSourcePath,
+ observedDescription,
+ sidecarDescription,
+ );
+ }
+ }
+
+ const hasDependencyRequires =
+ frontmatter.dependencies &&
+ typeof frontmatter.dependencies === 'object' &&
+ !Array.isArray(frontmatter.dependencies) &&
+ hasOwn(frontmatter.dependencies, 'requires');
+
+ if (hasDependencyRequires) {
+ const observedSerialized = serializeNormalizedDependencyTargets(frontmatter.dependencies.requires);
+ const expectedSerialized = serializeNormalizedDependencyTargets(sidecarData.dependencies.requires);
+
+ if (observedSerialized === null || observedSerialized !== expectedSerialized) {
+ createFrontmatterMismatchError(
+ HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DEPENDENCIES_REQUIRES_MISMATCH,
+ 'dependencies.requires',
+ markdownSourcePath,
+ observedSerialized,
+ expectedSerialized,
+ );
+ }
+ }
+}
+
+async function parseMarkdownFrontmatter(markdownPath, markdownSourcePath) {
+ if (!(await fs.pathExists(markdownPath))) {
+ throw new HelpAuthorityValidationError({
+ code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.MARKDOWN_FILE_NOT_FOUND,
+ detail: 'Expected markdown surface file was not found',
+ fieldPath: '',
+ sourcePath: markdownSourcePath,
+ });
+ }
+
+ let markdownRaw;
+ try {
+ markdownRaw = await fs.readFile(markdownPath, 'utf8');
+ } catch (error) {
+ throw new HelpAuthorityValidationError({
+ code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.FRONTMATTER_PARSE_FAILED,
+ detail: `Unable to read markdown content: ${error.message}`,
+ fieldPath: '',
+ sourcePath: markdownSourcePath,
+ });
+ }
+
+ const frontmatterMatch = markdownRaw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
+ if (!frontmatterMatch) {
+ return {};
+ }
+
+ try {
+ const parsed = yaml.parse(frontmatterMatch[1]);
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return {};
+ }
+ return parsed;
+ } catch (error) {
+ throw new HelpAuthorityValidationError({
+ code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.FRONTMATTER_PARSE_FAILED,
+ detail: `YAML frontmatter parse failure: ${error.message}`,
+ fieldPath: '',
+ sourcePath: markdownSourcePath,
+ });
+ }
+}
+
+function buildHelpAuthorityRecords({ canonicalId, sidecarSourcePath, sourceMarkdownSourcePath }) {
+ const authoritativePresenceKey = `capability:${canonicalId}`;
+
+ return [
+ {
+ recordType: 'metadata-authority',
+ canonicalId,
+ authoritativePresenceKey,
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: sidecarSourcePath,
+ sourcePath: sourceMarkdownSourcePath,
+ },
+ {
+ recordType: 'source-body-authority',
+ canonicalId,
+ authoritativePresenceKey,
+ authoritySourceType: 'source-markdown',
+ authoritySourcePath: sourceMarkdownSourcePath,
+ sourcePath: sourceMarkdownSourcePath,
+ },
+ ];
+}
+
+async function validateHelpAuthoritySplitAndPrecedence(options = {}) {
+ const sourceMarkdownPath = options.sourceMarkdownPath || getSourcePath('core', 'tasks', 'help.md');
+ const runtimeMarkdownPath = options.runtimeMarkdownPath || '';
+
+ let resolvedMetadataAuthority;
+ try {
+ resolvedMetadataAuthority = await resolveSkillMetadataAuthority({
+ sourceFilePath: sourceMarkdownPath,
+ metadataPath: options.sidecarPath || '',
+ metadataSourcePath: options.sidecarSourcePath || '',
+ ambiguousErrorCode: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS,
+ });
+ } catch (error) {
+ throw new HelpAuthorityValidationError({
+ code: error.code || HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS,
+ detail: error.detail || error.message,
+ fieldPath: error.fieldPath || '',
+ sourcePath: normalizeSourcePath(error.sourcePath || toProjectRelativePath(sourceMarkdownPath)),
+ });
+ }
+
+ const sidecarPath = resolvedMetadataAuthority.resolvedAbsolutePath;
+
+ const sidecarSourcePath = normalizeSourcePath(
+ options.sidecarSourcePath || resolvedMetadataAuthority.canonicalTargetSourcePath || resolvedMetadataAuthority.resolvedSourcePath,
+ );
+ const sourceMarkdownSourcePath = normalizeSourcePath(options.sourceMarkdownSourcePath || toProjectRelativePath(sourceMarkdownPath));
+ const runtimeMarkdownSourcePath = normalizeSourcePath(
+ options.runtimeMarkdownSourcePath || (runtimeMarkdownPath ? toProjectRelativePath(runtimeMarkdownPath) : ''),
+ );
+
+ if (!sidecarPath || !(await fs.pathExists(sidecarPath))) {
+ throw new HelpAuthorityValidationError({
+ code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
+ detail: 'Expected sidecar metadata file was not found',
+ fieldPath: '',
+ sourcePath: sidecarSourcePath,
+ });
+ }
+
+ let sidecarData;
+ try {
+ const sidecarRaw = await fs.readFile(sidecarPath, 'utf8');
+ sidecarData = yaml.parse(sidecarRaw);
+ } catch (error) {
+ throw new HelpAuthorityValidationError({
+ code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
+ detail: `YAML parse failure: ${error.message}`,
+ fieldPath: '',
+ sourcePath: sidecarSourcePath,
+ });
+ }
+
+ if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
+ throw new HelpAuthorityValidationError({
+ code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
+ detail: 'Sidecar root must be a YAML mapping object',
+ fieldPath: '',
+ sourcePath: sidecarSourcePath,
+ });
+ }
+
+ ensureSidecarMetadata(sidecarData, sidecarSourcePath);
+
+ const sourceFrontmatter = await parseMarkdownFrontmatter(sourceMarkdownPath, sourceMarkdownSourcePath);
+ validateFrontmatterPrecedence(sourceFrontmatter, sidecarData, sourceMarkdownSourcePath);
+
+ const checkedSurfaces = [sourceMarkdownSourcePath];
+
+ if (runtimeMarkdownPath && (await fs.pathExists(runtimeMarkdownPath))) {
+ const runtimeFrontmatter = await parseMarkdownFrontmatter(runtimeMarkdownPath, runtimeMarkdownSourcePath);
+ validateFrontmatterPrecedence(runtimeFrontmatter, sidecarData, runtimeMarkdownSourcePath);
+ checkedSurfaces.push(runtimeMarkdownSourcePath);
+ }
+
+ const aliasResolutionOptions = {
+ fieldPath: 'canonicalId',
+ sourcePath: sidecarSourcePath,
+ };
+
+ const inferredAliasTablePath =
+ options.aliasTablePath || (options.bmadDir ? path.join(options.bmadDir, '_config', 'canonical-aliases.csv') : '');
+
+ if (inferredAliasTablePath && (await fs.pathExists(inferredAliasTablePath))) {
+ aliasResolutionOptions.aliasTablePath = inferredAliasTablePath;
+ aliasResolutionOptions.aliasTableSourcePath = normalizeSourcePath(
+ options.aliasTableSourcePath || toProjectRelativePath(inferredAliasTablePath),
+ );
+ }
+
+ const resolvedSidecarIdentity = await normalizeAndResolveExemplarAlias(sidecarData.canonicalId, aliasResolutionOptions);
+ const canonicalId = resolvedSidecarIdentity.postAliasCanonicalId;
+ const authoritativeRecords = buildHelpAuthorityRecords({
+ canonicalId,
+ sidecarSourcePath,
+ sourceMarkdownSourcePath,
+ });
+
+ return {
+ canonicalId,
+ authoritativePresenceKey: `capability:${canonicalId}`,
+ authoritativeRecords,
+ checkedSurfaces,
+ metadataAuthority: {
+ resolvedPath: normalizeSourcePath(resolvedMetadataAuthority.resolvedSourcePath || sidecarSourcePath),
+ resolvedFilename: normalizeSourcePath(resolvedMetadataAuthority.resolvedFilename || ''),
+ canonicalTargetFilename: normalizeSourcePath(resolvedMetadataAuthority.canonicalTargetFilename || 'skill-manifest.yaml'),
+ canonicalTargetPath: normalizeSourcePath(resolvedMetadataAuthority.canonicalTargetSourcePath || sidecarSourcePath),
+ derivationMode: normalizeSourcePath(resolvedMetadataAuthority.derivationMode || ''),
+ },
+ };
+}
+
+module.exports = {
+ HELP_AUTHORITY_VALIDATION_ERROR_CODES,
+ HELP_FRONTMATTER_MISMATCH_ERROR_CODES,
+ HelpAuthorityValidationError,
+ buildHelpAuthorityRecords,
+ serializeNormalizedDependencyTargets,
+ validateHelpAuthoritySplitAndPrecedence,
+};
diff --git a/tools/cli/installers/lib/core/help-catalog-generator.js b/tools/cli/installers/lib/core/help-catalog-generator.js
new file mode 100644
index 000000000..58ae25ce9
--- /dev/null
+++ b/tools/cli/installers/lib/core/help-catalog-generator.js
@@ -0,0 +1,397 @@
+const fs = require('fs-extra');
+const path = require('node:path');
+const yaml = require('yaml');
+const { getSourcePath, getProjectRoot } = require('../../../lib/project-root');
+const { normalizeAndResolveExemplarAlias } = require('./help-alias-normalizer');
+const { resolveSkillMetadataAuthority } = require('./sidecar-contract-validator');
+
+const EXEMPLAR_HELP_CATALOG_CANONICAL_ID = 'bmad-help';
+const EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH = 'bmad-fork/src/core/tasks/help/skill-manifest.yaml';
+const EXEMPLAR_HELP_CATALOG_SOURCE_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
+const EXEMPLAR_HELP_CATALOG_ISSUING_COMPONENT =
+ 'bmad-fork/tools/cli/installers/lib/core/help-catalog-generator.js::buildSidecarAwareExemplarHelpRow()';
+const INSTALLER_HELP_CATALOG_MERGE_COMPONENT = 'bmad-fork/tools/cli/installers/lib/core/installer.js::mergeModuleHelpCatalogs()';
+
+const HELP_CATALOG_GENERATION_ERROR_CODES = Object.freeze({
+ SIDECAR_FILE_NOT_FOUND: 'ERR_HELP_CATALOG_SIDECAR_FILE_NOT_FOUND',
+ SIDECAR_FILENAME_AMBIGUOUS: 'ERR_HELP_CATALOG_SIDECAR_FILENAME_AMBIGUOUS',
+ SIDECAR_PARSE_FAILED: 'ERR_HELP_CATALOG_SIDECAR_PARSE_FAILED',
+ SIDECAR_INVALID_METADATA: 'ERR_HELP_CATALOG_SIDECAR_INVALID_METADATA',
+ CANONICAL_ID_MISMATCH: 'ERR_HELP_CATALOG_CANONICAL_ID_MISMATCH',
+ COMMAND_LABEL_CONTRACT_FAILED: 'ERR_HELP_COMMAND_LABEL_CONTRACT_FAILED',
+});
+
+class HelpCatalogGenerationError extends Error {
+ constructor({ code, detail, fieldPath, sourcePath, observedValue, expectedValue }) {
+ const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath})`;
+ super(message);
+ this.name = 'HelpCatalogGenerationError';
+ this.code = code;
+ this.detail = detail;
+ this.fieldPath = fieldPath;
+ this.sourcePath = sourcePath;
+ this.observedValue = observedValue;
+ this.expectedValue = expectedValue;
+ this.fullMessage = message;
+ }
+}
+
+function normalizeSourcePath(value) {
+ if (!value) return '';
+ return String(value).replaceAll('\\', '/');
+}
+
+function toProjectRelativePath(filePath) {
+ const projectRoot = getProjectRoot();
+ const relative = path.relative(projectRoot, filePath);
+
+ if (!relative || relative.startsWith('..')) {
+ return normalizeSourcePath(path.resolve(filePath));
+ }
+
+ return normalizeSourcePath(relative);
+}
+
+function frontmatterMatchValue(value) {
+ if (typeof value === 'string') {
+ return value.trim();
+ }
+ if (value === null || value === undefined) {
+ return '';
+ }
+ return String(value).trim();
+}
+
+function createGenerationError(code, fieldPath, sourcePath, detail, observedValue, expectedValue) {
+ throw new HelpCatalogGenerationError({
+ code,
+ detail,
+ fieldPath,
+ sourcePath,
+ observedValue,
+ expectedValue,
+ });
+}
+
+async function loadExemplarHelpSidecar(sidecarPath = '') {
+ const sourceMarkdownPath = getSourcePath('core', 'tasks', 'help.md');
+ let resolvedMetadataAuthority;
+ try {
+ resolvedMetadataAuthority = await resolveSkillMetadataAuthority({
+ sourceFilePath: sourceMarkdownPath,
+ metadataPath: sidecarPath,
+ ambiguousErrorCode: HELP_CATALOG_GENERATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS,
+ });
+ } catch (error) {
+ createGenerationError(
+ error.code || HELP_CATALOG_GENERATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS,
+ error.fieldPath || '',
+ normalizeSourcePath(error.sourcePath || toProjectRelativePath(sourceMarkdownPath)),
+ error.detail || error.message,
+ );
+ }
+
+ const resolvedMetadataPath = resolvedMetadataAuthority.resolvedAbsolutePath;
+ const sourcePath = normalizeSourcePath(
+ resolvedMetadataAuthority.canonicalTargetSourcePath || resolvedMetadataAuthority.resolvedSourcePath,
+ );
+ if (!resolvedMetadataPath || !(await fs.pathExists(resolvedMetadataPath))) {
+ createGenerationError(
+ HELP_CATALOG_GENERATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
+ '',
+ sourcePath,
+ 'Expected sidecar metadata file was not found',
+ );
+ }
+
+ let sidecarData;
+ try {
+ sidecarData = yaml.parse(await fs.readFile(resolvedMetadataPath, 'utf8'));
+ } catch (error) {
+ createGenerationError(
+ HELP_CATALOG_GENERATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
+ '',
+ sourcePath,
+ `YAML parse failure: ${error.message}`,
+ );
+ }
+
+ if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
+ createGenerationError(
+ HELP_CATALOG_GENERATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
+ '',
+ sourcePath,
+ 'Sidecar root must be a YAML mapping object',
+ );
+ }
+
+ const canonicalId = frontmatterMatchValue(sidecarData.canonicalId);
+ const displayName = frontmatterMatchValue(sidecarData.displayName);
+ const description = frontmatterMatchValue(sidecarData.description);
+ const missingStringField =
+ canonicalId.length === 0 ? 'canonicalId' : displayName.length === 0 ? 'displayName' : description.length === 0 ? 'description' : '';
+ if (missingStringField.length > 0) {
+ const observedValues = {
+ canonicalId,
+ displayName,
+ description,
+ };
+ createGenerationError(
+ HELP_CATALOG_GENERATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
+ missingStringField,
+ sourcePath,
+ 'Sidecar canonicalId, displayName, and description must be non-empty strings',
+ observedValues[missingStringField],
+ );
+ }
+
+ return {
+ canonicalId,
+ displayName,
+ description,
+ sourcePath,
+ resolvedFilename: normalizeSourcePath(resolvedMetadataAuthority.resolvedFilename || ''),
+ canonicalTargetFilename: normalizeSourcePath(resolvedMetadataAuthority.canonicalTargetFilename || 'skill-manifest.yaml'),
+ derivationMode: normalizeSourcePath(resolvedMetadataAuthority.derivationMode || ''),
+ };
+}
+
+function normalizeDisplayedCommandLabel(label) {
+ const trimmed = frontmatterMatchValue(label);
+ if (!trimmed) return '';
+
+ const hasLeadingSlash = trimmed.startsWith('/');
+ const withoutLeadingSlash = trimmed.replace(/^\/+/, '').trim();
+ const normalizedBody = withoutLeadingSlash.toLowerCase().replaceAll(/\s+/g, ' ');
+ if (!normalizedBody) return hasLeadingSlash ? '/' : '';
+
+ return hasLeadingSlash ? `/${normalizedBody}` : normalizedBody;
+}
+
+function renderDisplayedCommandLabel(rawCommandValue) {
+ const normalizedRaw = frontmatterMatchValue(rawCommandValue).replace(/^\/+/, '');
+ if (!normalizedRaw) {
+ return '/';
+ }
+ return `/${normalizedRaw}`;
+}
+
+function resolveCanonicalIdFromAuthorityRecords(helpAuthorityRecords = []) {
+ if (!Array.isArray(helpAuthorityRecords)) return '';
+
+ const sidecarRecord = helpAuthorityRecords.find(
+ (record) =>
+ record &&
+ typeof record === 'object' &&
+ record.authoritySourceType === 'sidecar' &&
+ frontmatterMatchValue(record.authoritySourcePath) === EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH &&
+ frontmatterMatchValue(record.canonicalId).length > 0,
+ );
+
+ return sidecarRecord ? frontmatterMatchValue(sidecarRecord.canonicalId) : '';
+}
+
+function evaluateExemplarCommandLabelReportRows(rows, options = {}) {
+ const expectedCanonicalId = frontmatterMatchValue(options.canonicalId || EXEMPLAR_HELP_CATALOG_CANONICAL_ID);
+ const expectedDisplayedLabel = frontmatterMatchValue(options.displayedCommandLabel || `/${expectedCanonicalId}`);
+ const expectedAuthoritySourceType = frontmatterMatchValue(options.authoritySourceType || 'sidecar');
+ const expectedAuthoritySourcePath = frontmatterMatchValue(options.authoritySourcePath || EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH);
+ const normalizedExpectedDisplayedLabel = normalizeDisplayedCommandLabel(expectedDisplayedLabel);
+
+ const targetRows = (Array.isArray(rows) ? rows : []).filter(
+ (row) => frontmatterMatchValue(row && row.canonicalId) === expectedCanonicalId,
+ );
+
+ if (targetRows.length !== 1) {
+ return { valid: false, reason: `row-count:${targetRows.length}` };
+ }
+
+ const row = targetRows[0];
+ const rawCommandValue = frontmatterMatchValue(row.rawCommandValue);
+ if (rawCommandValue !== expectedCanonicalId) {
+ return { valid: false, reason: `invalid-raw-command-value:${rawCommandValue || ''}` };
+ }
+
+ const displayedCommandLabel = frontmatterMatchValue(row.displayedCommandLabel);
+ if (displayedCommandLabel !== expectedDisplayedLabel) {
+ return { valid: false, reason: `invalid-displayed-label:${displayedCommandLabel || ''}` };
+ }
+
+ const normalizedDisplayedLabel = normalizeDisplayedCommandLabel(row.normalizedDisplayedLabel || row.displayedCommandLabel);
+ if (normalizedDisplayedLabel !== normalizedExpectedDisplayedLabel) {
+ return { valid: false, reason: `invalid-normalized-displayed-label:${normalizedDisplayedLabel || ''}` };
+ }
+
+ const rowCountForCanonicalId = Number.parseInt(String(row.rowCountForCanonicalId ?? ''), 10);
+ if (!Number.isFinite(rowCountForCanonicalId) || rowCountForCanonicalId !== 1) {
+ return { valid: false, reason: `invalid-row-count-for-canonical-id:${String(row.rowCountForCanonicalId ?? '')}` };
+ }
+
+ if (frontmatterMatchValue(row.authoritySourceType) !== expectedAuthoritySourceType) {
+ return {
+ valid: false,
+ reason: `invalid-authority-source-type:${frontmatterMatchValue(row.authoritySourceType) || ''}`,
+ };
+ }
+
+ if (frontmatterMatchValue(row.authoritySourcePath) !== expectedAuthoritySourcePath) {
+ return {
+ valid: false,
+ reason: `invalid-authority-source-path:${frontmatterMatchValue(row.authoritySourcePath) || ''}`,
+ };
+ }
+
+ return { valid: true, reason: 'ok' };
+}
+
+function buildExemplarHelpCatalogRow({ canonicalId, description }) {
+ return {
+ module: 'core',
+ phase: 'anytime',
+ name: 'bmad-help',
+ code: 'BH',
+ sequence: '',
+ 'workflow-file': '_bmad/core/tasks/help.md',
+ command: canonicalId,
+ required: 'false',
+ 'agent-name': '',
+ 'agent-command': '',
+ 'agent-display-name': '',
+ 'agent-title': '',
+ options: '',
+ description,
+ 'output-location': '',
+ outputs: '',
+ };
+}
+
+function buildPipelineStageRows({ bmadFolderName, canonicalId, commandValue, descriptionValue, authoritySourcePath, sourcePath }) {
+ const runtimeFolder = frontmatterMatchValue(bmadFolderName) || '_bmad';
+ const bindingEvidence = `authority:${authoritySourcePath}|source:${sourcePath}|canonical:${canonicalId}|command:${commandValue}`;
+
+ return [
+ {
+ stage: 'installed-compatibility-row',
+ artifactPath: `${runtimeFolder}/core/module-help.csv`,
+ rowIdentity: 'module-help-row:bmad-help',
+ canonicalId,
+ sourcePath,
+ rowCountForStageCanonicalId: 1,
+ commandValue,
+ expectedCommandValue: canonicalId,
+ descriptionValue,
+ expectedDescriptionValue: descriptionValue,
+ descriptionAuthoritySourceType: 'sidecar',
+ descriptionAuthoritySourcePath: authoritySourcePath,
+ commandAuthoritySourceType: 'sidecar',
+ commandAuthoritySourcePath: authoritySourcePath,
+ issuerOwnerClass: 'installer',
+ issuingComponent: EXEMPLAR_HELP_CATALOG_ISSUING_COMPONENT,
+ issuingComponentBindingEvidence: `${EXEMPLAR_HELP_CATALOG_ISSUING_COMPONENT}|${bindingEvidence}`,
+ stageStatus: 'PASS',
+ status: 'PASS',
+ },
+ {
+ stage: 'merged-config-row',
+ artifactPath: `${runtimeFolder}/_config/bmad-help.csv`,
+ rowIdentity: 'merged-help-row:bmad-help',
+ canonicalId,
+ sourcePath,
+ rowCountForStageCanonicalId: 1,
+ commandValue,
+ expectedCommandValue: canonicalId,
+ descriptionValue,
+ expectedDescriptionValue: descriptionValue,
+ descriptionAuthoritySourceType: 'sidecar',
+ descriptionAuthoritySourcePath: authoritySourcePath,
+ commandAuthoritySourceType: 'sidecar',
+ commandAuthoritySourcePath: authoritySourcePath,
+ issuerOwnerClass: 'installer',
+ issuingComponent: INSTALLER_HELP_CATALOG_MERGE_COMPONENT,
+ issuingComponentBindingEvidence: `${INSTALLER_HELP_CATALOG_MERGE_COMPONENT}|${bindingEvidence}`,
+ stageStatus: 'PASS',
+ status: 'PASS',
+ },
+ ];
+}
+
+async function buildSidecarAwareExemplarHelpRow(options = {}) {
+ const authorityCanonicalId = resolveCanonicalIdFromAuthorityRecords(options.helpAuthorityRecords);
+ const sidecarMetadata = await loadExemplarHelpSidecar(options.sidecarPath);
+ const canonicalIdentityResolution = await normalizeAndResolveExemplarAlias(sidecarMetadata.canonicalId, {
+ fieldPath: 'canonicalId',
+ sourcePath: sidecarMetadata.sourcePath,
+ aliasTablePath: options.aliasTablePath,
+ aliasTableSourcePath: options.aliasTableSourcePath,
+ });
+ const canonicalId = canonicalIdentityResolution.postAliasCanonicalId;
+
+ if (authorityCanonicalId && authorityCanonicalId !== canonicalId) {
+ createGenerationError(
+ HELP_CATALOG_GENERATION_ERROR_CODES.CANONICAL_ID_MISMATCH,
+ 'canonicalId',
+ sidecarMetadata.sourcePath,
+ 'Authority record canonicalId does not match sidecar canonicalId',
+ authorityCanonicalId,
+ canonicalId,
+ );
+ }
+
+ const commandValue = canonicalId;
+ const displayedCommandLabel = renderDisplayedCommandLabel(commandValue);
+ const normalizedDisplayedLabel = normalizeDisplayedCommandLabel(displayedCommandLabel);
+ const row = buildExemplarHelpCatalogRow({
+ canonicalId: commandValue,
+ description: sidecarMetadata.description,
+ });
+
+ const pipelineStageRows = buildPipelineStageRows({
+ bmadFolderName: options.bmadFolderName || '_bmad',
+ canonicalId,
+ commandValue,
+ descriptionValue: sidecarMetadata.description,
+ authoritySourcePath: EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH,
+ sourcePath: EXEMPLAR_HELP_CATALOG_SOURCE_MARKDOWN_SOURCE_PATH,
+ });
+
+ const commandLabelReportRow = {
+ surface: `${frontmatterMatchValue(options.bmadFolderName) || '_bmad'}/_config/bmad-help.csv`,
+ canonicalId,
+ rawCommandValue: commandValue,
+ displayedCommandLabel,
+ normalizedDisplayedLabel,
+ rowCountForCanonicalId: 1,
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH,
+ status: 'PASS',
+ };
+
+ return {
+ canonicalId,
+ legacyName: sidecarMetadata.displayName,
+ commandValue,
+ displayedCommandLabel,
+ normalizedDisplayedLabel,
+ descriptionValue: sidecarMetadata.description,
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH,
+ sourcePath: EXEMPLAR_HELP_CATALOG_SOURCE_MARKDOWN_SOURCE_PATH,
+ row,
+ pipelineStageRows,
+ commandLabelReportRow,
+ };
+}
+
+module.exports = {
+ HELP_CATALOG_GENERATION_ERROR_CODES,
+ HelpCatalogGenerationError,
+ EXEMPLAR_HELP_CATALOG_CANONICAL_ID,
+ EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH,
+ EXEMPLAR_HELP_CATALOG_SOURCE_MARKDOWN_SOURCE_PATH,
+ EXEMPLAR_HELP_CATALOG_ISSUING_COMPONENT,
+ INSTALLER_HELP_CATALOG_MERGE_COMPONENT,
+ normalizeDisplayedCommandLabel,
+ renderDisplayedCommandLabel,
+ evaluateExemplarCommandLabelReportRows,
+ buildSidecarAwareExemplarHelpRow,
+};
diff --git a/tools/cli/installers/lib/core/help-validation-harness.js b/tools/cli/installers/lib/core/help-validation-harness.js
new file mode 100644
index 000000000..d75c9858a
--- /dev/null
+++ b/tools/cli/installers/lib/core/help-validation-harness.js
@@ -0,0 +1,2736 @@
+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 {
+ validateHelpSidecarContractFile,
+ HELP_SIDECAR_ERROR_CODES,
+ resolveSkillMetadataAuthority,
+} = require('./sidecar-contract-validator');
+const { validateHelpAuthoritySplitAndPrecedence, HELP_FRONTMATTER_MISMATCH_ERROR_CODES } = require('./help-authority-validator');
+const { ManifestGenerator } = require('./manifest-generator');
+const { buildSidecarAwareExemplarHelpRow } = require('./help-catalog-generator');
+const { CodexSetup } = require('../ide/codex');
+
+const HELP_VALIDATION_ERROR_CODES = Object.freeze({
+ REQUIRED_ARTIFACT_MISSING: 'ERR_HELP_VALIDATION_REQUIRED_ARTIFACT_MISSING',
+ METADATA_RESOLUTION_FAILED: 'ERR_HELP_VALIDATION_METADATA_RESOLUTION_FAILED',
+ CSV_SCHEMA_MISMATCH: 'ERR_HELP_VALIDATION_CSV_SCHEMA_MISMATCH',
+ REQUIRED_ROW_IDENTITY_MISSING: 'ERR_HELP_VALIDATION_REQUIRED_ROW_IDENTITY_MISSING',
+ REQUIRED_EVIDENCE_LINK_MISSING: 'ERR_HELP_VALIDATION_REQUIRED_EVIDENCE_LINK_MISSING',
+ EVIDENCE_LINK_REFERENCE_INVALID: 'ERR_HELP_VALIDATION_EVIDENCE_LINK_REFERENCE_INVALID',
+ BINDING_EVIDENCE_INVALID: 'ERR_HELP_VALIDATION_BINDING_EVIDENCE_INVALID',
+ ISSUER_PREREQUISITE_MISSING: 'ERR_HELP_VALIDATION_ISSUER_PREREQUISITE_MISSING',
+ SELF_ATTESTED_ISSUER_CLAIM: 'ERR_HELP_VALIDATION_SELF_ATTESTED_ISSUER_CLAIM',
+ YAML_SCHEMA_MISMATCH: 'ERR_HELP_VALIDATION_YAML_SCHEMA_MISMATCH',
+ DECISION_RECORD_SCHEMA_MISMATCH: 'ERR_HELP_VALIDATION_DECISION_RECORD_SCHEMA_MISMATCH',
+ DECISION_RECORD_PARSE_FAILED: 'ERR_HELP_VALIDATION_DECISION_RECORD_PARSE_FAILED',
+});
+
+const SIDEcar_AUTHORITY_SOURCE_PATH = 'bmad-fork/src/core/tasks/help/skill-manifest.yaml';
+const SOURCE_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
+const EVIDENCE_ISSUER_COMPONENT = 'bmad-fork/tools/cli/installers/lib/core/help-validation-harness.js';
+
+const FRONTMATTER_MISMATCH_DETAILS = Object.freeze({
+ [HELP_FRONTMATTER_MISMATCH_ERROR_CODES.CANONICAL_ID_MISMATCH]: 'frontmatter canonicalId must match sidecar canonicalId',
+ [HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DISPLAY_NAME_MISMATCH]: 'frontmatter name must match sidecar displayName',
+ [HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DESCRIPTION_MISMATCH]: 'frontmatter description must match sidecar description',
+ [HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DEPENDENCIES_REQUIRES_MISMATCH]:
+ 'frontmatter dependencies.requires must match sidecar dependencies.requires',
+});
+
+const HELP_VALIDATION_ARTIFACT_REGISTRY = Object.freeze([
+ Object.freeze({
+ artifactId: 1,
+ relativePath: path.join('validation', 'help', 'bmad-help-sidecar-snapshot.yaml'),
+ type: 'yaml',
+ requiredTopLevelKeys: ['schemaVersion', 'canonicalId', 'artifactType', 'module', 'sourcePath', 'displayName', 'description', 'status'],
+ }),
+ Object.freeze({
+ artifactId: 2,
+ relativePath: path.join('validation', 'help', 'bmad-help-runtime-comparison.csv'),
+ type: 'csv',
+ columns: [
+ 'surface',
+ 'runtimePath',
+ 'sourcePath',
+ 'canonicalId',
+ 'normalizedCapabilityKey',
+ 'visibleName',
+ 'inclusionClassification',
+ 'contentAuthoritySourceType',
+ 'contentAuthoritySourcePath',
+ 'metadataAuthoritySourceType',
+ 'metadataAuthoritySourcePath',
+ 'status',
+ ],
+ }),
+ Object.freeze({
+ artifactId: 3,
+ relativePath: path.join('validation', 'help', 'bmad-help-issued-artifact-provenance.csv'),
+ type: 'csv',
+ columns: [
+ 'rowIdentity',
+ 'artifactPath',
+ 'canonicalId',
+ 'issuerOwnerClass',
+ 'evidenceIssuerComponent',
+ 'evidenceMethod',
+ 'issuingComponent',
+ 'issuingComponentBindingBasis',
+ 'issuingComponentBindingEvidence',
+ 'claimScope',
+ 'status',
+ ],
+ requiredRowIdentityFields: ['rowIdentity'],
+ }),
+ Object.freeze({
+ artifactId: 4,
+ relativePath: path.join('validation', 'help', 'bmad-help-manifest-comparison.csv'),
+ type: 'csv',
+ columns: [
+ 'surface',
+ 'sourcePath',
+ 'legacyName',
+ 'canonicalId',
+ 'displayName',
+ 'normalizedCapabilityKey',
+ 'authoritySourceType',
+ 'authoritySourcePath',
+ 'issuerOwnerClass',
+ 'issuingComponent',
+ 'issuedArtifactEvidencePath',
+ 'issuedArtifactEvidenceRowIdentity',
+ 'issuingComponentBindingEvidence',
+ 'status',
+ ],
+ requiredRowIdentityFields: ['issuedArtifactEvidenceRowIdentity'],
+ }),
+ Object.freeze({
+ artifactId: 5,
+ relativePath: path.join('validation', 'help', 'bmad-help-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', 'help', 'bmad-help-description-provenance.csv'),
+ type: 'csv',
+ columns: [
+ 'surface',
+ 'sourcePath',
+ 'canonicalId',
+ 'descriptionValue',
+ 'expectedDescriptionValue',
+ 'descriptionAuthoritySourceType',
+ 'descriptionAuthoritySourcePath',
+ 'issuedArtifactEvidencePath',
+ 'issuedArtifactEvidenceRowIdentity',
+ 'status',
+ ],
+ requiredRowIdentityFields: ['issuedArtifactEvidenceRowIdentity'],
+ }),
+ Object.freeze({
+ artifactId: 7,
+ relativePath: path.join('validation', 'help', 'bmad-help-export-comparison.csv'),
+ type: 'csv',
+ columns: [
+ 'exportPath',
+ 'sourcePath',
+ 'canonicalId',
+ 'visibleId',
+ 'visibleSurfaceClass',
+ 'normalizedVisibleKey',
+ 'authoritySourceType',
+ 'authoritySourcePath',
+ 'exportIdDerivationSourceType',
+ 'exportIdDerivationSourcePath',
+ 'issuerOwnerClass',
+ 'issuingComponent',
+ 'issuedArtifactEvidencePath',
+ 'issuedArtifactEvidenceRowIdentity',
+ 'issuingComponentBindingEvidence',
+ 'status',
+ ],
+ requiredRowIdentityFields: ['issuedArtifactEvidenceRowIdentity'],
+ }),
+ Object.freeze({
+ artifactId: 8,
+ relativePath: path.join('validation', 'help', 'bmad-help-command-label-report.csv'),
+ type: 'csv',
+ columns: [
+ 'surface',
+ 'sourcePath',
+ 'canonicalId',
+ 'rawCommandValue',
+ 'displayedCommandLabel',
+ 'normalizedDisplayedLabel',
+ 'rowCountForCanonicalId',
+ 'authoritySourceType',
+ 'authoritySourcePath',
+ 'issuedArtifactEvidencePath',
+ 'issuedArtifactEvidenceRowIdentity',
+ 'status',
+ ],
+ requiredRowIdentityFields: ['issuedArtifactEvidenceRowIdentity'],
+ }),
+ Object.freeze({
+ artifactId: 9,
+ relativePath: path.join('validation', 'help', 'bmad-help-catalog-pipeline.csv'),
+ type: 'csv',
+ columns: [
+ 'stage',
+ 'artifactPath',
+ 'rowIdentity',
+ 'canonicalId',
+ 'sourcePath',
+ 'rowCountForStageCanonicalId',
+ 'commandValue',
+ 'expectedCommandValue',
+ 'descriptionValue',
+ 'expectedDescriptionValue',
+ 'descriptionAuthoritySourceType',
+ 'descriptionAuthoritySourcePath',
+ 'commandAuthoritySourceType',
+ 'commandAuthoritySourcePath',
+ 'issuerOwnerClass',
+ 'issuingComponent',
+ 'issuedArtifactEvidencePath',
+ 'issuedArtifactEvidenceRowIdentity',
+ 'issuingComponentBindingEvidence',
+ 'stageStatus',
+ 'status',
+ ],
+ requiredRowIdentityFields: ['rowIdentity', 'issuedArtifactEvidenceRowIdentity'],
+ }),
+ Object.freeze({
+ artifactId: 10,
+ relativePath: path.join('validation', 'help', 'bmad-help-duplicate-report.csv'),
+ type: 'csv',
+ columns: [
+ 'surface',
+ 'ownerClass',
+ 'sourcePath',
+ 'canonicalId',
+ 'normalizedCapabilityKey',
+ 'visibleName',
+ 'visibleId',
+ 'visibleSurfaceClass',
+ 'normalizedVisibleKey',
+ 'authorityRole',
+ 'authoritySourceType',
+ 'authoritySourcePath',
+ 'authoritativePresenceKey',
+ 'groupedAuthoritativePresenceCount',
+ 'groupedAuthoritativeSourceRecordCount',
+ 'groupedAuthoritativeSourcePathSet',
+ 'rawIdentityHasLeadingSlash',
+ 'preAliasNormalizedValue',
+ 'postAliasCanonicalId',
+ 'aliasRowLocator',
+ 'aliasResolutionEvidence',
+ 'aliasResolutionSourcePath',
+ 'conflictingProjectedRecordCount',
+ 'wrapperAuthoritativeRecordCount',
+ 'status',
+ ],
+ }),
+ Object.freeze({
+ artifactId: 11,
+ relativePath: path.join('validation', 'help', 'bmad-help-dependency-report.csv'),
+ type: 'csv',
+ columns: [
+ 'declaredIn',
+ 'sourcePath',
+ 'targetType',
+ 'targetId',
+ 'normalizedTargetId',
+ 'expectedOwnerClass',
+ 'resolutionCandidateCount',
+ 'resolvedOwnerClass',
+ 'resolvedSurface',
+ 'resolvedPath',
+ 'authoritySourceType',
+ 'authoritySourcePath',
+ 'failureReason',
+ 'status',
+ ],
+ }),
+ Object.freeze({
+ artifactId: 12,
+ relativePath: path.join('decision-records', 'help-native-skills-exit.md'),
+ type: 'markdown',
+ requiredFrontmatterKeys: ['capability', 'goNoGo', 'status'],
+ }),
+ Object.freeze({
+ artifactId: 13,
+ relativePath: path.join('validation', 'help', 'bmad-help-sidecar-negative-validation.csv'),
+ type: 'csv',
+ columns: [
+ 'scenario',
+ 'fixturePath',
+ 'observedSchemaVersion',
+ 'observedSourcePathValue',
+ 'observedSidecarBasename',
+ 'expectedFailureCode',
+ 'observedFailureCode',
+ 'expectedFailureDetail',
+ 'observedFailureDetail',
+ 'status',
+ ],
+ }),
+ Object.freeze({
+ artifactId: 14,
+ relativePath: path.join('validation', 'help', 'bmad-help-frontmatter-mismatch-validation.csv'),
+ type: 'csv',
+ columns: [
+ 'scenario',
+ 'fixturePath',
+ 'frontmatterSurfacePath',
+ 'observedFrontmatterKeyPath',
+ 'mismatchedField',
+ 'observedFrontmatterValue',
+ 'expectedSidecarValue',
+ 'expectedAuthoritativeSourceType',
+ 'expectedAuthoritativeSourcePath',
+ 'expectedFailureCode',
+ 'observedFailureCode',
+ 'expectedFailureDetail',
+ 'observedFailureDetail',
+ 'observedAuthoritativeSourceType',
+ 'observedAuthoritativeSourcePath',
+ 'status',
+ ],
+ }),
+]);
+
+class HelpValidationHarnessError extends Error {
+ constructor({ code, detail, artifactId, fieldPath, sourcePath, observedValue, expectedValue }) {
+ const message = `${code}: ${detail} (artifact=${artifactId}, fieldPath=${fieldPath}, sourcePath=${sourcePath})`;
+ super(message);
+ this.name = 'HelpValidationHarnessError';
+ 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 normalizeDependencyTargets(value) {
+ const normalized = Array.isArray(value)
+ ? value
+ .map((target) => normalizeValue(String(target || '').toLowerCase()))
+ .filter((target) => target.length > 0)
+ .sort()
+ : [];
+ return JSON.stringify(normalized);
+}
+
+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 buildIssuedArtifactRowIdentity(artifactPath) {
+ return `issued-artifact:${String(artifactPath || '').replaceAll('/', '-')}`;
+}
+
+function buildAliasResolutionEvidence(preAliasNormalizedValue, rawIdentityHasLeadingSlash, aliasRowLocator) {
+ const canonicalId = 'bmad-help';
+ return `applied:${preAliasNormalizedValue}|leadingSlash:${rawIdentityHasLeadingSlash}->${canonicalId}|rows:${aliasRowLocator}`;
+}
+
+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 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 parseFrontmatter(markdownContent) {
+ const frontmatterMatch = String(markdownContent || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
+ if (!frontmatterMatch) return {};
+ const parsed = yaml.parse(frontmatterMatch[1]);
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return {};
+ }
+ return parsed;
+}
+
+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`;
+}
+
+const MODULE_HELP_COMPAT_COLUMNS = Object.freeze([
+ 'module',
+ 'phase',
+ 'name',
+ 'code',
+ 'sequence',
+ 'workflow-file',
+ 'command',
+ 'required',
+ 'agent',
+ 'options',
+ 'description',
+ 'output-location',
+ 'outputs',
+]);
+
+const HELP_CATALOG_COLUMNS = Object.freeze([
+ 'module',
+ 'phase',
+ 'name',
+ 'code',
+ 'sequence',
+ 'workflow-file',
+ 'command',
+ 'required',
+ 'agent-name',
+ 'agent-command',
+ 'agent-display-name',
+ 'agent-title',
+ 'options',
+ 'description',
+ 'output-location',
+ 'outputs',
+]);
+
+function countExemplarSkillProjectionRows(markdownContent) {
+ const frontmatter = parseFrontmatter(markdownContent);
+ return normalizeValue(frontmatter.name) === 'bmad-help' ? 1 : 0;
+}
+
+function countManifestClaimRows(csvContent, runtimeFolder) {
+ const expectedTaskPath = normalizePath(`${runtimeFolder}/core/tasks/help.md`).toLowerCase();
+ return parseCsvRows(csvContent).filter((row) => {
+ const canonicalId = normalizeValue(row.canonicalId).toLowerCase();
+ const moduleName = normalizeValue(row.module).toLowerCase();
+ const name = normalizeValue(row.name).toLowerCase();
+ const taskPath = normalizePath(normalizeValue(row.path)).toLowerCase();
+ return canonicalId === 'bmad-help' && moduleName === 'core' && name === 'help' && taskPath === expectedTaskPath;
+ }).length;
+}
+
+function countHelpCatalogClaimRows(csvContent) {
+ return parseCsvRows(csvContent).filter((row) => {
+ const command = normalizeValue(row.command).toLowerCase().replace(/^\/+/, '');
+ const workflowFile = normalizePath(normalizeValue(row['workflow-file'])).toLowerCase();
+ return command === 'bmad-help' && workflowFile.endsWith('/core/tasks/help.md');
+ }).length;
+}
+
+function buildReplaySidecarFixture({ canonicalId = 'bmad-help', description = 'Help command' } = {}) {
+ return {
+ schemaVersion: 1,
+ canonicalId,
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ displayName: 'help',
+ description,
+ dependencies: {
+ requires: [],
+ },
+ };
+}
+
+function replayFailurePayload(error) {
+ return canonicalJsonStringify({
+ replayFailureCode: normalizeValue(error?.code || 'ERR_HELP_VALIDATION_REPLAY_COMPONENT_FAILED'),
+ replayFailureDetail: normalizeValue(error?.detail || error?.message || 'component replay failed'),
+ });
+}
+
+function isSha256(value) {
+ return /^[a-f0-9]{64}$/.test(String(value || ''));
+}
+
+class HelpValidationHarness {
+ constructor() {
+ this.registry = HELP_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', 'help');
+ const decisionRecordsRoot = path.join(planningArtifactsRoot, 'decision-records');
+ return {
+ projectDir,
+ planningArtifactsRoot,
+ validationRoot,
+ decisionRecordsRoot,
+ };
+ }
+
+ async resolveSourceArtifactPaths(options = {}) {
+ const projectDir = path.resolve(options.projectDir || process.cwd());
+
+ const sourceMarkdownCandidates = [
+ options.sourceMarkdownPath,
+ path.join(projectDir, 'bmad-fork', 'src', 'core', 'tasks', 'help.md'),
+ path.join(projectDir, 'src', 'core', 'tasks', 'help.md'),
+ getSourcePath('core', 'tasks', 'help.md'),
+ ].filter(Boolean);
+
+ const resolveExistingPath = async (candidates) => {
+ for (const candidate of candidates) {
+ if (await fs.pathExists(candidate)) {
+ return candidate;
+ }
+ }
+ return candidates[0];
+ };
+
+ const sourceMarkdownPath = await resolveExistingPath(sourceMarkdownCandidates);
+
+ let resolvedMetadataAuthority;
+ try {
+ resolvedMetadataAuthority = await resolveSkillMetadataAuthority({
+ sourceFilePath: sourceMarkdownPath,
+ metadataPath: options.sidecarPath || '',
+ projectRoot: projectDir,
+ ambiguousErrorCode: HELP_VALIDATION_ERROR_CODES.METADATA_RESOLUTION_FAILED,
+ });
+ } catch (error) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.METADATA_RESOLUTION_FAILED,
+ detail: error.detail || error.message || 'metadata authority resolution failed',
+ artifactId: 1,
+ fieldPath: normalizeValue(error.fieldPath || ''),
+ sourcePath: normalizePath(error.sourcePath || SIDEcar_AUTHORITY_SOURCE_PATH),
+ observedValue: normalizeValue(error.code || ''),
+ expectedValue: 'unambiguous metadata authority candidate',
+ });
+ }
+
+ return {
+ sidecarPath: resolvedMetadataAuthority.resolvedAbsolutePath || options.sidecarPath || '',
+ sourceMarkdownPath,
+ metadataAuthority: resolvedMetadataAuthority,
+ };
+ }
+
+ async readSidecarMetadata(sidecarPath) {
+ const parsed = yaml.parse(await fs.readFile(sidecarPath, 'utf8'));
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return {
+ schemaVersion: 1,
+ canonicalId: 'bmad-help',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ displayName: 'help',
+ description: 'Help command',
+ dependencies: { requires: [] },
+ };
+ }
+ return {
+ schemaVersion: parsed.schemaVersion ?? 1,
+ canonicalId: normalizeValue(parsed.canonicalId || 'bmad-help'),
+ artifactType: normalizeValue(parsed.artifactType || 'task'),
+ module: normalizeValue(parsed.module || 'core'),
+ sourcePath: normalizeValue(parsed.sourcePath || SOURCE_MARKDOWN_SOURCE_PATH),
+ displayName: normalizeValue(parsed.displayName || 'help'),
+ description: normalizeValue(parsed.description || 'Help command'),
+ dependencies: parsed.dependencies && typeof parsed.dependencies === 'object' ? parsed.dependencies : { requires: [] },
+ };
+ }
+
+ async readCsvSurface(csvPath) {
+ if (!(await fs.pathExists(csvPath))) {
+ return [];
+ }
+ const content = await fs.readFile(csvPath, 'utf8');
+ return parseCsvRows(content);
+ }
+
+ async assertRequiredInputSurfaceExists({ artifactId, absolutePath, sourcePath, description }) {
+ if (await fs.pathExists(absolutePath)) {
+ return;
+ }
+ throw new HelpValidationHarnessError({
+ code: HELP_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 HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.REQUIRED_ROW_IDENTITY_MISSING,
+ detail,
+ artifactId,
+ fieldPath,
+ sourcePath: normalizePath(sourcePath),
+ observedValue: '',
+ expectedValue: 'required row',
+ });
+ }
+
+ async writeCsvArtifact(filePath, columns, rows) {
+ const sortedRows = sortRowsDeterministically(rows, columns);
+ await fs.writeFile(filePath, serializeCsv(columns, sortedRows), 'utf8');
+ }
+
+ async ensureValidationFixtures(outputPaths, sidecarMetadata) {
+ const sidecarNegativeRoot = path.join(outputPaths.validationRoot, 'fixtures', 'sidecar-negative');
+ const frontmatterMismatchRoot = path.join(outputPaths.validationRoot, 'fixtures', 'frontmatter-mismatch');
+ await fs.ensureDir(sidecarNegativeRoot);
+ await fs.ensureDir(frontmatterMismatchRoot);
+
+ const unknownMajorFixturePath = path.join(sidecarNegativeRoot, 'unknown-major-version', 'help.artifact.yaml');
+ const basenameMismatchFixturePath = path.join(sidecarNegativeRoot, 'basename-path-mismatch', 'help.artifact.yaml');
+ await fs.ensureDir(path.dirname(unknownMajorFixturePath));
+ await fs.ensureDir(path.dirname(basenameMismatchFixturePath));
+
+ const unknownMajorFixture = {
+ ...sidecarMetadata,
+ schemaVersion: 2,
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ };
+ const basenameMismatchFixture = {
+ ...sidecarMetadata,
+ schemaVersion: 1,
+ sourcePath: 'bmad-fork/src/core/tasks/not-help.md',
+ };
+
+ await fs.writeFile(unknownMajorFixturePath, yaml.stringify(unknownMajorFixture), 'utf8');
+ await fs.writeFile(basenameMismatchFixturePath, yaml.stringify(basenameMismatchFixture), 'utf8');
+
+ const sourceMismatchRoot = path.join(frontmatterMismatchRoot, 'source');
+ const runtimeMismatchRoot = path.join(frontmatterMismatchRoot, 'runtime');
+ await fs.ensureDir(sourceMismatchRoot);
+ await fs.ensureDir(runtimeMismatchRoot);
+
+ const baseFrontmatter = {
+ name: sidecarMetadata.displayName,
+ description: sidecarMetadata.description,
+ canonicalId: sidecarMetadata.canonicalId,
+ dependencies: {
+ requires: Array.isArray(sidecarMetadata.dependencies.requires) ? sidecarMetadata.dependencies.requires : [],
+ },
+ };
+
+ const buildMarkdown = (frontmatter) => `---\n${yaml.stringify(frontmatter).trimEnd()}\n---\n\n# Fixture\n`;
+
+ const scenarios = [
+ {
+ id: 'canonical-id-mismatch',
+ keyPath: 'canonicalId',
+ mismatchField: 'canonicalId',
+ makeFrontmatter: () => ({ ...baseFrontmatter, canonicalId: 'legacy-help' }),
+ },
+ {
+ id: 'display-name-mismatch',
+ keyPath: 'name',
+ mismatchField: 'displayName',
+ makeFrontmatter: () => ({ ...baseFrontmatter, name: 'BMAD Help' }),
+ },
+ {
+ id: 'description-mismatch',
+ keyPath: 'description',
+ mismatchField: 'description',
+ makeFrontmatter: () => ({ ...baseFrontmatter, description: 'Runtime override' }),
+ },
+ {
+ id: 'dependencies-mismatch',
+ keyPath: 'dependencies.requires',
+ mismatchField: 'dependencies.requires',
+ makeFrontmatter: () => ({ ...baseFrontmatter, dependencies: { requires: ['skill:demo'] } }),
+ },
+ ];
+
+ for (const scenario of scenarios) {
+ const sourcePath = path.join(sourceMismatchRoot, `${scenario.id}.md`);
+ const runtimePath = path.join(runtimeMismatchRoot, `${scenario.id}.md`);
+ await fs.writeFile(sourcePath, buildMarkdown(scenario.makeFrontmatter()), 'utf8');
+ await fs.writeFile(runtimePath, buildMarkdown(scenario.makeFrontmatter()), 'utf8');
+ }
+
+ return {
+ unknownMajorFixturePath,
+ basenameMismatchFixturePath,
+ sourceMismatchRoot,
+ runtimeMismatchRoot,
+ };
+ }
+
+ buildArtifactPathsMap(outputPaths) {
+ const artifactPaths = new Map();
+ for (const artifact of this.registry) {
+ artifactPaths.set(artifact.artifactId, path.join(outputPaths.planningArtifactsRoot, artifact.relativePath));
+ }
+ return artifactPaths;
+ }
+
+ resolveReplayContract({ artifactPath, componentPath, rowIdentity, runtimeFolder }) {
+ const claimedRowIdentity = normalizeValue(rowIdentity);
+ if (!claimedRowIdentity) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.REQUIRED_ROW_IDENTITY_MISSING,
+ detail: 'Claimed replay rowIdentity is required',
+ artifactId: 3,
+ fieldPath: 'rowIdentity',
+ sourcePath: artifactPath,
+ observedValue: claimedRowIdentity,
+ expectedValue: 'non-empty value',
+ });
+ }
+
+ const expectedRowIdentity = buildIssuedArtifactRowIdentity(artifactPath);
+ if (claimedRowIdentity !== expectedRowIdentity) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.REQUIRED_ROW_IDENTITY_MISSING,
+ detail: 'Claimed replay rowIdentity does not match artifact claim rowIdentity contract',
+ artifactId: 3,
+ fieldPath: 'rowIdentity',
+ sourcePath: artifactPath,
+ observedValue: claimedRowIdentity,
+ expectedValue: expectedRowIdentity,
+ });
+ }
+
+ const contractsByClaimRowIdentity = 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}/core/module-help.csv`),
+ {
+ artifactPath: `${runtimeFolder}/core/module-help.csv`,
+ componentPathIncludes: 'help-catalog-generator.js',
+ mutationKind: 'component-input-perturbation:help-catalog-generator/sidecar-canonical-id',
+ run: ({ workspaceRoot, perturbed }) =>
+ this.runHelpCatalogGeneratorReplay({
+ workspaceRoot,
+ runtimeFolder,
+ perturbed,
+ }),
+ },
+ ],
+ [
+ buildIssuedArtifactRowIdentity(`${runtimeFolder}/_config/bmad-help.csv`),
+ {
+ artifactPath: `${runtimeFolder}/_config/bmad-help.csv`,
+ componentPathIncludes: 'installer.js::mergemodulehelpcatalogs',
+ mutationKind: 'component-input-perturbation:installer/help-authority-records',
+ run: ({ workspaceRoot, perturbed }) =>
+ this.runInstallerMergeReplay({
+ workspaceRoot,
+ runtimeFolder,
+ perturbed,
+ }),
+ },
+ ],
+ [
+ buildIssuedArtifactRowIdentity('.agents/skills/bmad-help/SKILL.md'),
+ {
+ artifactPath: '.agents/skills/bmad-help/SKILL.md',
+ componentPathIncludes: 'ide/codex.js',
+ mutationKind: 'component-input-perturbation:codex/sidecar-canonical-id',
+ run: ({ workspaceRoot, perturbed }) => this.runCodexExportReplay({ workspaceRoot, perturbed }),
+ },
+ ],
+ ]);
+
+ const contract = contractsByClaimRowIdentity.get(claimedRowIdentity);
+ if (!contract) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.REQUIRED_ROW_IDENTITY_MISSING,
+ detail: 'Claimed rowIdentity is not mapped to a replay contract',
+ artifactId: 3,
+ fieldPath: 'rowIdentity',
+ sourcePath: artifactPath,
+ observedValue: claimedRowIdentity,
+ expectedValue: 'known issued-artifact claim rowIdentity',
+ });
+ }
+
+ const normalizedComponentPath = normalizeValue(componentPath).toLowerCase();
+ if (
+ normalizeValue(artifactPath) !== normalizeValue(contract.artifactPath) ||
+ !normalizedComponentPath.includes(String(contract.componentPathIncludes || '').toLowerCase())
+ ) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID,
+ detail: 'Claimed replay rowIdentity/component pair does not match replay contract mapping',
+ artifactId: 3,
+ fieldPath: 'issuingComponent',
+ sourcePath: 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.taskAuthorityRecords = [
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-help',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ },
+ ];
+ generator.helpAuthorityRecords = [...generator.taskAuthorityRecords];
+ generator.tasks = perturbed
+ ? []
+ : [
+ {
+ name: 'help',
+ displayName: 'help',
+ description: 'Help command',
+ module: 'core',
+ path: `${runtimeFolder}/core/tasks/help.md`,
+ standalone: 'true',
+ },
+ ];
+
+ await generator.writeTaskManifest(cfgDir);
+ const outputPath = path.join(cfgDir, 'task-manifest.csv');
+ const content = await fs.readFile(outputPath, 'utf8');
+ return {
+ content,
+ targetRowCount: countManifestClaimRows(content, runtimeFolder),
+ };
+ }
+
+ async runHelpCatalogGeneratorReplay({ workspaceRoot, runtimeFolder, perturbed }) {
+ const sidecarPath = path.join(workspaceRoot, 'src', 'core', 'tasks', 'help.artifact.yaml');
+ await fs.ensureDir(path.dirname(sidecarPath));
+ await fs.writeFile(
+ sidecarPath,
+ yaml.stringify(
+ buildReplaySidecarFixture({
+ canonicalId: perturbed ? 'bmad-help-replay-perturbed' : 'bmad-help',
+ }),
+ ),
+ 'utf8',
+ );
+
+ const generated = await buildSidecarAwareExemplarHelpRow({
+ sidecarPath,
+ bmadFolderName: runtimeFolder,
+ });
+ const content = serializeCsv(HELP_CATALOG_COLUMNS, [generated.row]);
+ return {
+ content,
+ targetRowCount: countHelpCatalogClaimRows(content),
+ };
+ }
+
+ 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 moduleHelpFixtureRows = [
+ {
+ module: 'core',
+ phase: 'anytime',
+ name: 'bmad-help',
+ code: 'BH',
+ sequence: '',
+ 'workflow-file': `${runtimeFolder}/core/tasks/help.md`,
+ command: 'bmad-help',
+ required: 'false',
+ agent: '',
+ options: '',
+ description: 'Help command',
+ '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: '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(coreDir, 'module-help.csv'), serializeCsv(MODULE_HELP_COMPAT_COLUMNS, moduleHelpFixtureRows), 'utf8');
+ 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 = perturbed
+ ? [
+ {
+ canonicalId: 'bmad-help-replay-perturbed',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ },
+ ]
+ : [];
+
+ await installer.mergeModuleHelpCatalogs(bmadDir);
+ const outputPath = path.join(cfgDir, 'bmad-help.csv');
+ const content = await fs.readFile(outputPath, 'utf8');
+ return {
+ content,
+ targetRowCount: countHelpCatalogClaimRows(content),
+ };
+ }
+
+ async runCodexExportReplay({ workspaceRoot, perturbed }) {
+ const projectDir = workspaceRoot;
+ const sourceDir = path.join(projectDir, 'src', 'core', 'tasks');
+ await fs.ensureDir(sourceDir);
+ await fs.writeFile(
+ path.join(sourceDir, 'help.artifact.yaml'),
+ yaml.stringify(
+ buildReplaySidecarFixture({
+ canonicalId: perturbed ? 'bmad-help-replay-perturbed' : 'bmad-help',
+ }),
+ ),
+ 'utf8',
+ );
+
+ const codex = new CodexSetup();
+ codex.exportDerivationRecords = [];
+ const artifact = {
+ type: 'task',
+ name: 'help',
+ displayName: 'help',
+ module: 'core',
+ sourcePath: path.join(sourceDir, 'help.md'),
+ relativePath: path.join('core', 'tasks', 'help.md'),
+ content: '---\nname: help\ndescription: Help command\n---\n\n# Help\n',
+ };
+
+ const destDir = path.join(projectDir, '.agents', 'skills');
+ await fs.ensureDir(destDir);
+ await codex.writeSkillArtifacts(destDir, [artifact], 'task', { projectDir });
+
+ const outputPath = path.join(destDir, 'bmad-help', 'SKILL.md');
+ const content = await fs.readFile(outputPath, 'utf8');
+ return {
+ content,
+ targetRowCount: countExemplarSkillProjectionRows(content),
+ };
+ }
+
+ async executeIsolatedReplay({ artifactPath, componentPath, rowIdentity, runtimeFolder }) {
+ const contract = this.resolveReplayContract({
+ artifactPath,
+ componentPath,
+ rowIdentity,
+ runtimeFolder,
+ });
+ const baselineWorkspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'help-replay-baseline-'));
+ const perturbedWorkspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'help-replay-perturbed-'));
+
+ try {
+ const baseline = await contract.run({ workspaceRoot: baselineWorkspaceRoot, perturbed: false });
+ if (Number(baseline.targetRowCount) <= 0) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.REQUIRED_ROW_IDENTITY_MISSING,
+ detail: 'Claimed rowIdentity target is absent in baseline component replay output',
+ artifactId: 3,
+ fieldPath: 'rowIdentity',
+ sourcePath: artifactPath,
+ observedValue: Number(baseline.targetRowCount),
+ expectedValue: `at least one row bound to ${normalizeValue(rowIdentity)}`,
+ });
+ }
+
+ let mutated;
+ try {
+ mutated = await contract.run({ workspaceRoot: perturbedWorkspaceRoot, perturbed: true });
+ } catch (error) {
+ mutated = {
+ content: replayFailurePayload(error),
+ targetRowCount: 0,
+ };
+ }
+
+ 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, optionalSurface = false, runtimeFolder }) {
+ const exists = await fs.pathExists(absolutePath);
+ if (!exists && optionalSurface) {
+ const sentinelHash = computeSha256('surface-not-required');
+ const payload = {
+ evidenceVersion: 1,
+ observationMethod: 'validator-observed-optional-surface-omitted',
+ observationOutcome: 'surface-not-required',
+ artifactPath,
+ componentPath,
+ baselineArtifactSha256: sentinelHash,
+ mutatedArtifactSha256: sentinelHash,
+ baselineRowIdentity: rowIdentity,
+ mutatedRowIdentity: rowIdentity,
+ targetedRowLocator: normalizeValue(rowIdentity),
+ rowLevelDiffSha256: computeSha256(`${artifactPath}|${componentPath}|surface-not-required`),
+ perturbationApplied: false,
+ baselineTargetRowCount: 0,
+ mutatedTargetRowCount: 0,
+ mutationKind: 'not-applicable',
+ serializationFormat: 'json-canonical-v1',
+ encoding: 'utf-8',
+ lineEndings: 'lf',
+ worktreePath: 'in-memory-isolated-replay',
+ commitSha: 'not-applicable',
+ timestampUtc: '1970-01-01T00:00:00Z',
+ };
+ return {
+ evidenceMethod: 'validator-observed-optional-surface-omitted',
+ issuingComponentBindingBasis: 'validator-observed-optional-surface-omitted',
+ issuingComponentBindingEvidence: canonicalJsonStringify(payload),
+ status: 'SKIP',
+ };
+ }
+
+ 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 payload = {
+ evidenceVersion: 1,
+ observationMethod: 'validator-observed-baseline-plus-isolated-single-component-perturbation',
+ observationOutcome: mutationResult.perturbationApplied ? 'observed-impact' : 'no-impact-observed',
+ artifactPath,
+ componentPath,
+ baselineArtifactSha256,
+ mutatedArtifactSha256,
+ baselineRowIdentity: rowIdentity,
+ mutatedRowIdentity: rowIdentity,
+ rowLevelDiffSha256: computeSha256(canonicalJsonStringify(diffPayload)),
+ perturbationApplied: Boolean(mutationResult.perturbationApplied),
+ baselineTargetRowCount: mutationResult.baselineTargetRowCount,
+ mutatedTargetRowCount: mutationResult.mutatedTargetRowCount,
+ mutationKind: mutationResult.mutationKind,
+ targetedRowLocator: mutationResult.targetedRowLocator,
+ serializationFormat: 'json-canonical-v1',
+ encoding: 'utf-8',
+ lineEndings: 'lf',
+ worktreePath: 'in-memory-isolated-replay',
+ 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: canonicalJsonStringify(payload),
+ status: 'PASS',
+ };
+ }
+
+ async createIssuedArtifactProvenanceRows({ runtimeFolder, bmadDir, projectDir, requireExportSkillProjection }) {
+ const artifactBindings = [
+ {
+ 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}/core/module-help.csv`,
+ absolutePath: path.join(bmadDir, 'core', 'module-help.csv'),
+ issuingComponent: 'bmad-fork/tools/cli/installers/lib/core/help-catalog-generator.js::buildSidecarAwareExemplarHelpRow()',
+ },
+ {
+ 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()',
+ },
+ {
+ artifactPath: '.agents/skills/bmad-help/SKILL.md',
+ absolutePath: path.join(projectDir, '.agents', 'skills', 'bmad-help', 'SKILL.md'),
+ issuingComponent: 'bmad-fork/tools/cli/installers/lib/ide/codex.js',
+ optionalSurface: !requireExportSkillProjection,
+ },
+ ];
+
+ const provenanceRows = [];
+ for (const binding of artifactBindings) {
+ const rowIdentity = buildIssuedArtifactRowIdentity(binding.artifactPath);
+ const evidence = await this.buildObservedBindingEvidence({
+ artifactPath: binding.artifactPath,
+ absolutePath: binding.absolutePath,
+ componentPath: binding.issuingComponent,
+ rowIdentity,
+ optionalSurface: Boolean(binding.optionalSurface),
+ runtimeFolder,
+ });
+ provenanceRows.push({
+ rowIdentity,
+ artifactPath: binding.artifactPath,
+ canonicalId: 'bmad-help',
+ issuerOwnerClass: 'independent-validator',
+ evidenceIssuerComponent: EVIDENCE_ISSUER_COMPONENT,
+ evidenceMethod: evidence.evidenceMethod,
+ issuingComponent: binding.issuingComponent,
+ issuingComponentBindingBasis: evidence.issuingComponentBindingBasis,
+ issuingComponentBindingEvidence: evidence.issuingComponentBindingEvidence,
+ claimScope: binding.artifactPath,
+ status: evidence.status,
+ });
+ }
+
+ return provenanceRows;
+ }
+
+ makeEvidenceLookup(provenanceRows) {
+ const byArtifactPath = new Map();
+ for (const row of provenanceRows) {
+ byArtifactPath.set(row.artifactPath, row);
+ }
+ return byArtifactPath;
+ }
+
+ 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 sourcePaths = await this.resolveSourceArtifactPaths({
+ ...options,
+ projectDir: outputPaths.projectDir,
+ });
+ const sidecarMetadata = await this.readSidecarMetadata(sourcePaths.sidecarPath);
+
+ await fs.ensureDir(outputPaths.validationRoot);
+ await fs.ensureDir(outputPaths.decisionRecordsRoot);
+
+ const runtimeTaskPath = `${runtimeFolder}/core/tasks/help.md`;
+ const runtimeModuleHelpPath = `${runtimeFolder}/core/module-help.csv`;
+ const runtimeTaskManifestPath = `${runtimeFolder}/_config/task-manifest.csv`;
+ const runtimeAliasPath = `${runtimeFolder}/_config/canonical-aliases.csv`;
+ const runtimeHelpCatalogPath = `${runtimeFolder}/_config/bmad-help.csv`;
+ const runtimePipelinePath = `${runtimeFolder}/_config/bmad-help-catalog-pipeline.csv`;
+ const runtimeCommandLabelPath = `${runtimeFolder}/_config/bmad-help-command-label-report.csv`;
+ const evidenceArtifactPath = '_bmad-output/planning-artifacts/validation/help/bmad-help-issued-artifact-provenance.csv';
+ const exportSkillPath = '.agents/skills/bmad-help/SKILL.md';
+ const exportSkillAbsolutePath = path.join(outputPaths.projectDir, '.agents', 'skills', 'bmad-help', 'SKILL.md');
+ const codexExportRows =
+ Array.isArray(options.codexExportDerivationRecords) && options.codexExportDerivationRecords.length > 0
+ ? [...options.codexExportDerivationRecords]
+ : [];
+ const requireExportSkillProjection = options.requireExportSkillProjection !== false || codexExportRows.length > 0;
+ const exportSkillProjectionExists = await fs.pathExists(exportSkillAbsolutePath);
+
+ const requiredInputSurfaces = [
+ {
+ artifactId: 1,
+ absolutePath: sourcePaths.sidecarPath,
+ sourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ description: 'sidecar metadata authority',
+ },
+ {
+ artifactId: 2,
+ absolutePath: sourcePaths.sourceMarkdownPath,
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ description: 'source markdown authority',
+ },
+ {
+ artifactId: 2,
+ absolutePath: path.join(bmadDir, 'core', 'tasks', 'help.md'),
+ sourcePath: runtimeTaskPath,
+ description: 'runtime help markdown projection',
+ },
+ {
+ artifactId: 4,
+ absolutePath: path.join(bmadDir, '_config', 'task-manifest.csv'),
+ sourcePath: runtimeTaskManifestPath,
+ description: 'task-manifest projection',
+ },
+ {
+ artifactId: 5,
+ absolutePath: path.join(bmadDir, '_config', 'canonical-aliases.csv'),
+ sourcePath: runtimeAliasPath,
+ description: 'canonical-aliases projection',
+ },
+ {
+ artifactId: 6,
+ absolutePath: path.join(bmadDir, 'core', 'module-help.csv'),
+ sourcePath: runtimeModuleHelpPath,
+ description: 'module-help projection',
+ },
+ {
+ artifactId: 8,
+ absolutePath: path.join(bmadDir, '_config', 'bmad-help.csv'),
+ sourcePath: runtimeHelpCatalogPath,
+ description: 'merged help-catalog projection',
+ },
+ {
+ artifactId: 8,
+ absolutePath: path.join(bmadDir, '_config', 'bmad-help-command-label-report.csv'),
+ sourcePath: runtimeCommandLabelPath,
+ description: 'command-label report projection',
+ },
+ {
+ artifactId: 9,
+ absolutePath: path.join(bmadDir, '_config', 'bmad-help-catalog-pipeline.csv'),
+ sourcePath: runtimePipelinePath,
+ description: 'help-catalog pipeline projection',
+ },
+ ];
+ if (requireExportSkillProjection) {
+ requiredInputSurfaces.push({
+ artifactId: 7,
+ absolutePath: exportSkillAbsolutePath,
+ sourcePath: exportSkillPath,
+ description: 'export skill projection',
+ });
+ }
+ for (const requiredSurface of requiredInputSurfaces) {
+ // Story 3.1 is fail-fast: required projection inputs must exist before generating validator outputs.
+ await this.assertRequiredInputSurfaceExists(requiredSurface);
+ }
+
+ const taskManifestRows = await this.readCsvSurface(path.join(bmadDir, '_config', 'task-manifest.csv'));
+ const aliasRows = await this.readCsvSurface(path.join(bmadDir, '_config', 'canonical-aliases.csv'));
+ const moduleHelpRows = await this.readCsvSurface(path.join(bmadDir, 'core', 'module-help.csv'));
+ const helpCatalogRows = await this.readCsvSurface(path.join(bmadDir, '_config', 'bmad-help.csv'));
+
+ const pipelineRowsInput = Array.isArray(options.helpCatalogPipelineRows) && options.helpCatalogPipelineRows.length > 0;
+ const commandLabelRowsInput =
+ Array.isArray(options.helpCatalogCommandLabelReportRows) && options.helpCatalogCommandLabelReportRows.length > 0;
+
+ const pipelineRows = pipelineRowsInput
+ ? [...options.helpCatalogPipelineRows]
+ : await this.readCsvSurface(path.join(bmadDir, '_config', 'bmad-help-catalog-pipeline.csv'));
+ const commandLabelRows = commandLabelRowsInput
+ ? [...options.helpCatalogCommandLabelReportRows]
+ : await this.readCsvSurface(path.join(bmadDir, '_config', 'bmad-help-command-label-report.csv'));
+
+ const provenanceRows = await this.createIssuedArtifactProvenanceRows({
+ runtimeFolder,
+ bmadDir,
+ projectDir: outputPaths.projectDir,
+ requireExportSkillProjection,
+ });
+ const evidenceLookup = this.makeEvidenceLookup(provenanceRows);
+
+ // Artifact 1: sidecar snapshot
+ const sidecarSnapshot = {
+ schemaVersion: sidecarMetadata.schemaVersion,
+ canonicalId: sidecarMetadata.canonicalId || 'bmad-help',
+ artifactType: sidecarMetadata.artifactType || 'task',
+ module: sidecarMetadata.module || 'core',
+ sourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ displayName: sidecarMetadata.displayName || 'help',
+ description: sidecarMetadata.description || 'Help command',
+ dependencies: {
+ requires: Array.isArray(sidecarMetadata.dependencies.requires) ? sidecarMetadata.dependencies.requires : [],
+ },
+ status: 'PASS',
+ };
+ await fs.writeFile(artifactPaths.get(1), yaml.stringify(sidecarSnapshot), 'utf8');
+
+ // Artifact 2: runtime comparison
+ const runtimeComparisonRows = [
+ {
+ surface: runtimeTaskPath,
+ runtimePath: runtimeTaskPath,
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ canonicalId: 'bmad-help',
+ normalizedCapabilityKey: 'capability:bmad-help',
+ visibleName: 'help',
+ inclusionClassification: 'included-runtime-content',
+ contentAuthoritySourceType: 'source-markdown',
+ contentAuthoritySourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ metadataAuthoritySourceType: 'sidecar',
+ metadataAuthoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ status: 'PASS',
+ },
+ {
+ surface: runtimeModuleHelpPath,
+ runtimePath: runtimeModuleHelpPath,
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ canonicalId: 'bmad-help',
+ normalizedCapabilityKey: 'capability:bmad-help',
+ visibleName: 'help',
+ inclusionClassification: 'excluded-non-content-projection',
+ contentAuthoritySourceType: 'n/a',
+ contentAuthoritySourcePath: 'n/a',
+ metadataAuthoritySourceType: 'sidecar',
+ metadataAuthoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ status: 'PASS',
+ },
+ ];
+ await this.writeCsvArtifact(artifactPaths.get(2), this.registry[1].columns, runtimeComparisonRows);
+
+ // Artifact 3: issued artifact provenance
+ await this.writeCsvArtifact(artifactPaths.get(3), this.registry[2].columns, provenanceRows);
+
+ const manifestHelpRow = this.requireRow({
+ rows: taskManifestRows,
+ predicate: (row) => normalizeValue(row.canonicalId) === 'bmad-help',
+ artifactId: 4,
+ fieldPath: 'rows[canonicalId=bmad-help]',
+ sourcePath: runtimeTaskManifestPath,
+ detail: 'Required task-manifest exemplar row is missing',
+ });
+ const manifestEvidence = this.requireRow({
+ rows: provenanceRows,
+ predicate: (row) => normalizeValue(row.artifactPath) === runtimeTaskManifestPath && normalizeValue(row.status) === 'PASS',
+ artifactId: 4,
+ fieldPath: 'rows[artifactPath=_bmad/_config/task-manifest.csv]',
+ sourcePath: evidenceArtifactPath,
+ detail: 'Required manifest issuing-component binding evidence row is missing',
+ });
+
+ // Artifact 4: manifest comparison
+ const manifestComparisonRows = [
+ {
+ surface: runtimeTaskManifestPath,
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ legacyName: normalizeValue(manifestHelpRow.legacyName || manifestHelpRow.name || 'help'),
+ canonicalId: normalizeValue(manifestHelpRow.canonicalId || 'bmad-help'),
+ displayName: normalizeValue(manifestHelpRow.displayName || 'help'),
+ normalizedCapabilityKey: 'capability:bmad-help',
+ authoritySourceType: normalizeValue(manifestHelpRow.authoritySourceType || 'sidecar'),
+ authoritySourcePath: normalizeValue(manifestHelpRow.authoritySourcePath || SIDEcar_AUTHORITY_SOURCE_PATH),
+ issuerOwnerClass: 'independent-validator',
+ issuingComponent: manifestEvidence.issuingComponent,
+ issuedArtifactEvidencePath: evidenceArtifactPath,
+ issuedArtifactEvidenceRowIdentity: manifestEvidence.rowIdentity,
+ issuingComponentBindingEvidence: manifestEvidence.issuingComponentBindingEvidence,
+ status: 'PASS',
+ },
+ ];
+ await this.writeCsvArtifact(artifactPaths.get(4), this.registry[3].columns, manifestComparisonRows);
+
+ // Artifact 5: alias table
+ const aliasRowsForExemplar = aliasRows
+ .filter((row) => normalizeValue(row.canonicalId) === 'bmad-help')
+ .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 || SIDEcar_AUTHORITY_SOURCE_PATH),
+ status: 'PASS',
+ }));
+ if (aliasRowsForExemplar.length === 0) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.REQUIRED_ROW_IDENTITY_MISSING,
+ detail: 'Required canonical alias rows for exemplar are missing',
+ artifactId: 5,
+ fieldPath: 'rows[canonicalId=bmad-help]',
+ sourcePath: runtimeAliasPath,
+ observedValue: '',
+ expectedValue: 'required row',
+ });
+ }
+ await this.writeCsvArtifact(artifactPaths.get(5), this.registry[4].columns, aliasRowsForExemplar);
+
+ // Artifact 6: description provenance
+ const moduleHelpRow = this.requireRow({
+ rows: moduleHelpRows,
+ predicate: (row) => normalizeValue(row.command).replace(/^\/+/, '') === 'bmad-help',
+ artifactId: 6,
+ fieldPath: 'rows[command=bmad-help]',
+ sourcePath: runtimeModuleHelpPath,
+ detail: 'Required module-help exemplar command row is missing',
+ });
+ const helpCatalogRow = this.requireRow({
+ rows: helpCatalogRows,
+ predicate: (row) => normalizeValue(row.command).replace(/^\/+/, '') === 'bmad-help',
+ artifactId: 6,
+ fieldPath: 'rows[command=bmad-help]',
+ sourcePath: runtimeHelpCatalogPath,
+ detail: 'Required merged help-catalog exemplar command row is missing',
+ });
+
+ const descriptionProvenanceRows = [
+ {
+ surface: runtimeTaskManifestPath,
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ canonicalId: 'bmad-help',
+ descriptionValue: normalizeValue(manifestHelpRow.description || sidecarMetadata.description),
+ expectedDescriptionValue: sidecarMetadata.description,
+ descriptionAuthoritySourceType: 'sidecar',
+ descriptionAuthoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ issuedArtifactEvidencePath: evidenceArtifactPath,
+ issuedArtifactEvidenceRowIdentity: manifestEvidence.rowIdentity,
+ status: 'PASS',
+ },
+ {
+ surface: runtimeModuleHelpPath,
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ canonicalId: 'bmad-help',
+ descriptionValue: normalizeValue(moduleHelpRow.description || sidecarMetadata.description),
+ expectedDescriptionValue: sidecarMetadata.description,
+ descriptionAuthoritySourceType: 'sidecar',
+ descriptionAuthoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ issuedArtifactEvidencePath: evidenceArtifactPath,
+ issuedArtifactEvidenceRowIdentity: this.requireRow({
+ rows: provenanceRows,
+ predicate: (row) => normalizeValue(row.artifactPath) === runtimeModuleHelpPath && normalizeValue(row.status) === 'PASS',
+ artifactId: 6,
+ fieldPath: 'rows[artifactPath=_bmad/core/module-help.csv]',
+ sourcePath: evidenceArtifactPath,
+ detail: 'Required module-help issuing-component binding evidence row is missing',
+ }).rowIdentity,
+ status: 'PASS',
+ },
+ {
+ surface: runtimeHelpCatalogPath,
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ canonicalId: 'bmad-help',
+ descriptionValue: normalizeValue(helpCatalogRow.description || sidecarMetadata.description),
+ expectedDescriptionValue: sidecarMetadata.description,
+ descriptionAuthoritySourceType: 'sidecar',
+ descriptionAuthoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ issuedArtifactEvidencePath: evidenceArtifactPath,
+ issuedArtifactEvidenceRowIdentity: this.requireRow({
+ rows: provenanceRows,
+ predicate: (row) => normalizeValue(row.artifactPath) === runtimeHelpCatalogPath && normalizeValue(row.status) === 'PASS',
+ artifactId: 6,
+ fieldPath: 'rows[artifactPath=_bmad/_config/bmad-help.csv]',
+ sourcePath: evidenceArtifactPath,
+ detail: 'Required merged help-catalog issuing-component binding evidence row is missing',
+ }).rowIdentity,
+ status: 'PASS',
+ },
+ ];
+ await this.writeCsvArtifact(artifactPaths.get(6), this.registry[5].columns, descriptionProvenanceRows);
+
+ // Artifact 7: export comparison
+ const exportEvidence = evidenceLookup.get(exportSkillPath);
+ const exportRowIdentity = normalizeValue(exportEvidence?.rowIdentity || buildIssuedArtifactRowIdentity(exportSkillPath));
+ const exportIssuingComponent = normalizeValue(exportEvidence?.issuingComponent || 'not-applicable');
+ const exportBindingEvidence = normalizeValue(exportEvidence?.issuingComponentBindingEvidence || '');
+ const exportStatus = requireExportSkillProjection || exportSkillProjectionExists ? 'PASS' : 'SKIP';
+ const exportSkillFrontmatter = exportSkillProjectionExists ? parseFrontmatter(await fs.readFile(exportSkillAbsolutePath, 'utf8')) : {};
+ const codexRecord = codexExportRows.find((row) => normalizeValue(row.canonicalId) === 'bmad-help');
+ const exportPath = normalizeValue(codexRecord?.exportPath || exportSkillPath);
+ const exportComparisonRows = [
+ {
+ exportPath,
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ canonicalId: 'bmad-help',
+ visibleId: normalizeValue(codexRecord?.visibleId || exportSkillFrontmatter.name || sidecarMetadata.canonicalId || 'bmad-help'),
+ visibleSurfaceClass: normalizeValue(codexRecord?.visibleSurfaceClass || 'export-id'),
+ normalizedVisibleKey: 'export-id:bmad-help',
+ authoritySourceType: normalizeValue(codexRecord?.authoritySourceType || 'sidecar'),
+ authoritySourcePath: normalizeValue(codexRecord?.authoritySourcePath || SIDEcar_AUTHORITY_SOURCE_PATH),
+ exportIdDerivationSourceType: normalizeValue(codexRecord?.exportIdDerivationSourceType || 'sidecar-canonical-id'),
+ exportIdDerivationSourcePath: normalizeValue(codexRecord?.exportIdDerivationSourcePath || SIDEcar_AUTHORITY_SOURCE_PATH),
+ issuerOwnerClass: exportStatus === 'PASS' ? 'independent-validator' : 'not-applicable',
+ issuingComponent: exportIssuingComponent,
+ issuedArtifactEvidencePath: exportStatus === 'PASS' ? evidenceArtifactPath : 'not-applicable',
+ issuedArtifactEvidenceRowIdentity: exportRowIdentity,
+ issuingComponentBindingEvidence: exportBindingEvidence,
+ status: exportStatus,
+ },
+ ];
+ await this.writeCsvArtifact(artifactPaths.get(7), this.registry[6].columns, exportComparisonRows);
+
+ // Artifact 8: command label report
+ const commandLabelRow = this.requireRow({
+ rows: commandLabelRows,
+ predicate: (row) => normalizeValue(row.canonicalId) === 'bmad-help',
+ artifactId: 8,
+ fieldPath: 'rows[canonicalId=bmad-help]',
+ sourcePath: runtimeCommandLabelPath,
+ detail: 'Required command-label report exemplar row is missing',
+ });
+ const commandLabelEvidence = this.requireRow({
+ rows: provenanceRows,
+ predicate: (row) => normalizeValue(row.artifactPath) === runtimeHelpCatalogPath && normalizeValue(row.status) === 'PASS',
+ artifactId: 8,
+ fieldPath: 'rows[artifactPath=_bmad/_config/bmad-help.csv]',
+ sourcePath: evidenceArtifactPath,
+ detail: 'Required command-label issuing-component binding evidence row is missing',
+ });
+ const validationCommandLabelRows = [
+ {
+ surface: runtimeHelpCatalogPath,
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ canonicalId: 'bmad-help',
+ rawCommandValue: normalizeValue(commandLabelRow.rawCommandValue || 'bmad-help').replace(/^\/+/, ''),
+ displayedCommandLabel: normalizeValue(commandLabelRow.displayedCommandLabel || '/bmad-help'),
+ normalizedDisplayedLabel: normalizeValue(commandLabelRow.normalizedDisplayedLabel || '/bmad-help'),
+ rowCountForCanonicalId: normalizeValue(commandLabelRow.rowCountForCanonicalId || 1),
+ authoritySourceType: normalizeValue(commandLabelRow.authoritySourceType || 'sidecar'),
+ authoritySourcePath: normalizeValue(commandLabelRow.authoritySourcePath || SIDEcar_AUTHORITY_SOURCE_PATH),
+ issuedArtifactEvidencePath: evidenceArtifactPath,
+ issuedArtifactEvidenceRowIdentity: commandLabelEvidence.rowIdentity,
+ status: 'PASS',
+ },
+ ];
+ await this.writeCsvArtifact(artifactPaths.get(8), this.registry[7].columns, validationCommandLabelRows);
+
+ // Artifact 9: catalog pipeline
+ const pipelineWithEvidence = pipelineRows
+ .filter((row) => normalizeValue(row.canonicalId) === 'bmad-help')
+ .map((row) => {
+ const artifactPath = normalizeValue(row.artifactPath);
+ const evidenceRow = evidenceLookup.get(artifactPath) || null;
+ return {
+ stage: normalizeValue(row.stage),
+ artifactPath,
+ rowIdentity: normalizeValue(row.rowIdentity),
+ canonicalId: 'bmad-help',
+ sourcePath: normalizeValue(row.sourcePath || SOURCE_MARKDOWN_SOURCE_PATH),
+ rowCountForStageCanonicalId: normalizeValue(row.rowCountForStageCanonicalId || 1),
+ commandValue: normalizeValue(row.commandValue || 'bmad-help'),
+ expectedCommandValue: normalizeValue(row.expectedCommandValue || 'bmad-help'),
+ descriptionValue: normalizeValue(row.descriptionValue || sidecarMetadata.description),
+ expectedDescriptionValue: normalizeValue(row.expectedDescriptionValue || sidecarMetadata.description),
+ descriptionAuthoritySourceType: normalizeValue(row.descriptionAuthoritySourceType || 'sidecar'),
+ descriptionAuthoritySourcePath: normalizeValue(row.descriptionAuthoritySourcePath || SIDEcar_AUTHORITY_SOURCE_PATH),
+ commandAuthoritySourceType: normalizeValue(row.commandAuthoritySourceType || 'sidecar'),
+ commandAuthoritySourcePath: normalizeValue(row.commandAuthoritySourcePath || SIDEcar_AUTHORITY_SOURCE_PATH),
+ issuerOwnerClass: 'independent-validator',
+ issuingComponent: normalizeValue(evidenceRow?.issuingComponent || row.issuingComponent),
+ issuedArtifactEvidencePath: evidenceArtifactPath,
+ issuedArtifactEvidenceRowIdentity: normalizeValue(evidenceRow?.rowIdentity || ''),
+ issuingComponentBindingEvidence: normalizeValue(evidenceRow?.issuingComponentBindingEvidence || ''),
+ stageStatus: normalizeValue(row.stageStatus || row.status || 'PASS'),
+ status: normalizeValue(row.status || 'PASS'),
+ };
+ });
+ if (pipelineWithEvidence.length === 0) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.REQUIRED_ROW_IDENTITY_MISSING,
+ detail: 'Required help-catalog pipeline exemplar rows are missing',
+ artifactId: 9,
+ fieldPath: 'rows[canonicalId=bmad-help]',
+ sourcePath: runtimePipelinePath,
+ observedValue: '',
+ expectedValue: 'required row',
+ });
+ }
+ await this.writeCsvArtifact(artifactPaths.get(9), this.registry[8].columns, pipelineWithEvidence);
+
+ // Artifact 10: duplicate report
+ const groupedSourcePathSet = `${SIDEcar_AUTHORITY_SOURCE_PATH}|${SOURCE_MARKDOWN_SOURCE_PATH}`;
+ const duplicateRows = [
+ {
+ surface: SOURCE_MARKDOWN_SOURCE_PATH,
+ ownerClass: 'bmad-source',
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ canonicalId: 'bmad-help',
+ normalizedCapabilityKey: 'capability:bmad-help',
+ visibleName: 'help',
+ visibleId: 'bmad-help',
+ visibleSurfaceClass: 'source-markdown',
+ normalizedVisibleKey: 'source-markdown:help',
+ authorityRole: 'authoritative',
+ authoritySourceType: 'source-markdown',
+ authoritySourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ authoritativePresenceKey: 'capability:bmad-help',
+ groupedAuthoritativePresenceCount: 1,
+ groupedAuthoritativeSourceRecordCount: 2,
+ groupedAuthoritativeSourcePathSet: groupedSourcePathSet,
+ rawIdentityHasLeadingSlash: 'false',
+ preAliasNormalizedValue: 'help',
+ postAliasCanonicalId: 'bmad-help',
+ aliasRowLocator: 'alias-row:bmad-help:legacy-name',
+ aliasResolutionEvidence: buildAliasResolutionEvidence('help', false, 'alias-row:bmad-help:legacy-name'),
+ aliasResolutionSourcePath: `${runtimeFolder}/_config/canonical-aliases.csv`,
+ conflictingProjectedRecordCount: 0,
+ wrapperAuthoritativeRecordCount: 0,
+ status: 'PASS',
+ },
+ {
+ surface: SIDEcar_AUTHORITY_SOURCE_PATH,
+ ownerClass: 'bmad-source',
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ canonicalId: 'bmad-help',
+ normalizedCapabilityKey: 'capability:bmad-help',
+ visibleName: 'help',
+ visibleId: 'bmad-help',
+ visibleSurfaceClass: 'sidecar',
+ normalizedVisibleKey: 'sidecar:bmad-help',
+ authorityRole: 'authoritative',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ authoritativePresenceKey: 'capability:bmad-help',
+ groupedAuthoritativePresenceCount: 1,
+ groupedAuthoritativeSourceRecordCount: 2,
+ groupedAuthoritativeSourcePathSet: groupedSourcePathSet,
+ rawIdentityHasLeadingSlash: 'false',
+ preAliasNormalizedValue: 'bmad-help',
+ postAliasCanonicalId: 'bmad-help',
+ aliasRowLocator: 'alias-row:bmad-help:canonical-id',
+ aliasResolutionEvidence: buildAliasResolutionEvidence('bmad-help', false, 'alias-row:bmad-help:canonical-id'),
+ aliasResolutionSourcePath: `${runtimeFolder}/_config/canonical-aliases.csv`,
+ conflictingProjectedRecordCount: 0,
+ wrapperAuthoritativeRecordCount: 0,
+ status: 'PASS',
+ },
+ {
+ surface: runtimeTaskPath,
+ ownerClass: 'bmad-generated-runtime',
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ canonicalId: 'bmad-help',
+ normalizedCapabilityKey: 'capability:bmad-help',
+ visibleName: 'help',
+ visibleId: 'bmad-help',
+ visibleSurfaceClass: 'runtime-markdown',
+ normalizedVisibleKey: 'runtime-markdown:help',
+ authorityRole: 'projected',
+ authoritySourceType: 'source-markdown',
+ authoritySourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ authoritativePresenceKey: 'capability:bmad-help',
+ groupedAuthoritativePresenceCount: 1,
+ groupedAuthoritativeSourceRecordCount: 2,
+ groupedAuthoritativeSourcePathSet: groupedSourcePathSet,
+ rawIdentityHasLeadingSlash: 'false',
+ preAliasNormalizedValue: 'help',
+ postAliasCanonicalId: 'bmad-help',
+ aliasRowLocator: 'alias-row:bmad-help:legacy-name',
+ aliasResolutionEvidence: buildAliasResolutionEvidence('help', false, 'alias-row:bmad-help:legacy-name'),
+ aliasResolutionSourcePath: `${runtimeFolder}/_config/canonical-aliases.csv`,
+ conflictingProjectedRecordCount: 0,
+ wrapperAuthoritativeRecordCount: 0,
+ status: 'PASS',
+ },
+ {
+ surface: runtimeModuleHelpPath,
+ ownerClass: 'bmad-generated-runtime',
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ canonicalId: 'bmad-help',
+ normalizedCapabilityKey: 'capability:bmad-help',
+ visibleName: 'bmad-help',
+ visibleId: '/bmad-help',
+ visibleSurfaceClass: 'module-help-command',
+ normalizedVisibleKey: 'module-help-command:/bmad-help',
+ authorityRole: 'projected',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ authoritativePresenceKey: 'capability:bmad-help',
+ groupedAuthoritativePresenceCount: 1,
+ groupedAuthoritativeSourceRecordCount: 2,
+ groupedAuthoritativeSourcePathSet: groupedSourcePathSet,
+ rawIdentityHasLeadingSlash: 'true',
+ preAliasNormalizedValue: 'bmad-help',
+ postAliasCanonicalId: 'bmad-help',
+ aliasRowLocator: 'alias-row:bmad-help:slash-command',
+ aliasResolutionEvidence: buildAliasResolutionEvidence('bmad-help', true, 'alias-row:bmad-help:slash-command'),
+ aliasResolutionSourcePath: `${runtimeFolder}/_config/canonical-aliases.csv`,
+ conflictingProjectedRecordCount: 0,
+ wrapperAuthoritativeRecordCount: 0,
+ status: 'PASS',
+ },
+ {
+ surface: runtimeTaskManifestPath,
+ ownerClass: 'bmad-generated-config',
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ canonicalId: 'bmad-help',
+ normalizedCapabilityKey: 'capability:bmad-help',
+ visibleName: 'help',
+ visibleId: 'bmad-help',
+ visibleSurfaceClass: 'task-manifest',
+ normalizedVisibleKey: 'task-manifest:help',
+ authorityRole: 'projected',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ authoritativePresenceKey: 'capability:bmad-help',
+ groupedAuthoritativePresenceCount: 1,
+ groupedAuthoritativeSourceRecordCount: 2,
+ groupedAuthoritativeSourcePathSet: groupedSourcePathSet,
+ rawIdentityHasLeadingSlash: 'false',
+ preAliasNormalizedValue: 'help',
+ postAliasCanonicalId: 'bmad-help',
+ aliasRowLocator: 'alias-row:bmad-help:legacy-name',
+ aliasResolutionEvidence: buildAliasResolutionEvidence('help', false, 'alias-row:bmad-help:legacy-name'),
+ aliasResolutionSourcePath: `${runtimeFolder}/_config/canonical-aliases.csv`,
+ conflictingProjectedRecordCount: 0,
+ wrapperAuthoritativeRecordCount: 0,
+ status: 'PASS',
+ },
+ {
+ surface: runtimeAliasPath,
+ ownerClass: 'bmad-generated-config',
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ canonicalId: 'bmad-help',
+ normalizedCapabilityKey: 'capability:bmad-help',
+ visibleName: 'bmad-help',
+ visibleId: 'bmad-help',
+ visibleSurfaceClass: 'canonical-alias-table',
+ normalizedVisibleKey: 'canonical-alias-table:bmad-help',
+ authorityRole: 'projected',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ authoritativePresenceKey: 'capability:bmad-help',
+ groupedAuthoritativePresenceCount: 1,
+ groupedAuthoritativeSourceRecordCount: 2,
+ groupedAuthoritativeSourcePathSet: groupedSourcePathSet,
+ rawIdentityHasLeadingSlash: 'false',
+ preAliasNormalizedValue: 'bmad-help',
+ postAliasCanonicalId: 'bmad-help',
+ aliasRowLocator: 'alias-row:bmad-help:canonical-id',
+ aliasResolutionEvidence: buildAliasResolutionEvidence('bmad-help', false, 'alias-row:bmad-help:canonical-id'),
+ aliasResolutionSourcePath: `${runtimeFolder}/_config/canonical-aliases.csv`,
+ conflictingProjectedRecordCount: 0,
+ wrapperAuthoritativeRecordCount: 0,
+ status: 'PASS',
+ },
+ {
+ surface: runtimeHelpCatalogPath,
+ ownerClass: 'bmad-generated-config',
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ canonicalId: 'bmad-help',
+ normalizedCapabilityKey: 'capability:bmad-help',
+ visibleName: 'bmad-help',
+ visibleId: '/bmad-help',
+ visibleSurfaceClass: 'help-catalog-command',
+ normalizedVisibleKey: 'help-catalog-command:/bmad-help',
+ authorityRole: 'projected',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ authoritativePresenceKey: 'capability:bmad-help',
+ groupedAuthoritativePresenceCount: 1,
+ groupedAuthoritativeSourceRecordCount: 2,
+ groupedAuthoritativeSourcePathSet: groupedSourcePathSet,
+ rawIdentityHasLeadingSlash: 'true',
+ preAliasNormalizedValue: 'bmad-help',
+ postAliasCanonicalId: 'bmad-help',
+ aliasRowLocator: 'alias-row:bmad-help:slash-command',
+ aliasResolutionEvidence: buildAliasResolutionEvidence('bmad-help', true, 'alias-row:bmad-help:slash-command'),
+ aliasResolutionSourcePath: `${runtimeFolder}/_config/canonical-aliases.csv`,
+ conflictingProjectedRecordCount: 0,
+ wrapperAuthoritativeRecordCount: 0,
+ status: 'PASS',
+ },
+ {
+ surface: '.agents/skills/bmad-help/SKILL.md',
+ ownerClass: 'bmad-generated-export',
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ canonicalId: 'bmad-help',
+ normalizedCapabilityKey: 'capability:bmad-help',
+ visibleName: 'bmad-help',
+ visibleId: 'bmad-help',
+ visibleSurfaceClass: 'export-id',
+ normalizedVisibleKey: 'export-id:bmad-help',
+ authorityRole: 'projected',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ authoritativePresenceKey: 'capability:bmad-help',
+ groupedAuthoritativePresenceCount: 1,
+ groupedAuthoritativeSourceRecordCount: 2,
+ groupedAuthoritativeSourcePathSet: groupedSourcePathSet,
+ rawIdentityHasLeadingSlash: 'false',
+ preAliasNormalizedValue: 'bmad-help',
+ postAliasCanonicalId: 'bmad-help',
+ aliasRowLocator: 'alias-row:bmad-help:canonical-id',
+ aliasResolutionEvidence: buildAliasResolutionEvidence('bmad-help', false, 'alias-row:bmad-help:canonical-id'),
+ aliasResolutionSourcePath: `${runtimeFolder}/_config/canonical-aliases.csv`,
+ conflictingProjectedRecordCount: 0,
+ wrapperAuthoritativeRecordCount: 0,
+ status: 'PASS',
+ },
+ ];
+ await this.writeCsvArtifact(artifactPaths.get(10), this.registry[9].columns, duplicateRows);
+
+ // Artifact 11: dependency report
+ const dependencyRows = [
+ {
+ declaredIn: 'sidecar',
+ sourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ targetType: 'declaration',
+ targetId: '[]',
+ normalizedTargetId: '[]',
+ expectedOwnerClass: 'none',
+ resolutionCandidateCount: 0,
+ resolvedOwnerClass: 'none',
+ resolvedSurface: 'none',
+ resolvedPath: 'none',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ failureReason: 'none',
+ status: 'PASS',
+ },
+ ];
+ await this.writeCsvArtifact(artifactPaths.get(11), this.registry[10].columns, dependencyRows);
+
+ // Artifact 12: decision record
+ const decisionRecord = {
+ capability: 'bmad-help',
+ goNoGo: 'GO',
+ status: 'PASS',
+ };
+ const decisionRecordContent = `---\n${yaml.stringify(decisionRecord).trimEnd()}\n---\n\n# Help Native Skills Exit\n\nStatus: PASS\n`;
+ await fs.writeFile(artifactPaths.get(12), decisionRecordContent, 'utf8');
+
+ // Fixtures for artifacts 13 and 14
+ const fixtures = await this.ensureValidationFixtures(outputPaths, sidecarMetadata);
+
+ // Artifact 13: sidecar negative validation
+ const sidecarNegativeRows = [];
+ const sidecarNegativeScenarios = [
+ {
+ scenario: 'unknown-major-version',
+ fixturePath: '_bmad-output/planning-artifacts/validation/help/fixtures/sidecar-negative/unknown-major-version/help.artifact.yaml',
+ absolutePath: fixtures.unknownMajorFixturePath,
+ expectedFailureCode: HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED,
+ expectedFailureDetail: 'sidecar schema major version is unsupported',
+ },
+ {
+ scenario: 'basename-path-mismatch',
+ fixturePath: '_bmad-output/planning-artifacts/validation/help/fixtures/sidecar-negative/basename-path-mismatch/help.artifact.yaml',
+ absolutePath: fixtures.basenameMismatchFixturePath,
+ expectedFailureCode: HELP_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH,
+ expectedFailureDetail: 'sidecar basename does not match sourcePath basename',
+ },
+ ];
+ for (const scenario of sidecarNegativeScenarios) {
+ const fixtureData = yaml.parse(await fs.readFile(scenario.absolutePath, 'utf8'));
+ let observedFailureCode = '';
+ let observedFailureDetail = '';
+ try {
+ await validateHelpSidecarContractFile(scenario.absolutePath, {
+ errorSourcePath: scenario.fixturePath,
+ });
+ } catch (error) {
+ observedFailureCode = normalizeValue(error.code);
+ observedFailureDetail = normalizeValue(error.detail);
+ }
+ sidecarNegativeRows.push({
+ scenario: scenario.scenario,
+ fixturePath: scenario.fixturePath,
+ observedSchemaVersion: normalizeValue(fixtureData.schemaVersion),
+ observedSourcePathValue: normalizeValue(fixtureData.sourcePath),
+ observedSidecarBasename: normalizeValue(path.basename(scenario.absolutePath)),
+ expectedFailureCode: scenario.expectedFailureCode,
+ observedFailureCode,
+ expectedFailureDetail: scenario.expectedFailureDetail,
+ observedFailureDetail,
+ status:
+ observedFailureCode === scenario.expectedFailureCode && observedFailureDetail === scenario.expectedFailureDetail
+ ? 'PASS'
+ : 'FAIL',
+ });
+ }
+ await this.writeCsvArtifact(artifactPaths.get(13), this.registry[12].columns, sidecarNegativeRows);
+
+ // Artifact 14: frontmatter mismatch validation
+ const mismatchRows = [];
+ const mismatchScenarios = [
+ {
+ scenario: 'canonical-id-mismatch',
+ fieldPath: 'canonicalId',
+ mismatchField: 'canonicalId',
+ expectedFailureCode: HELP_FRONTMATTER_MISMATCH_ERROR_CODES.CANONICAL_ID_MISMATCH,
+ },
+ {
+ scenario: 'display-name-mismatch',
+ fieldPath: 'name',
+ mismatchField: 'displayName',
+ expectedFailureCode: HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DISPLAY_NAME_MISMATCH,
+ },
+ {
+ scenario: 'description-mismatch',
+ fieldPath: 'description',
+ mismatchField: 'description',
+ expectedFailureCode: HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DESCRIPTION_MISMATCH,
+ },
+ {
+ scenario: 'dependencies-mismatch',
+ fieldPath: 'dependencies.requires',
+ mismatchField: 'dependencies.requires',
+ expectedFailureCode: HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DEPENDENCIES_REQUIRES_MISMATCH,
+ },
+ ];
+
+ const makeValidFrontmatterMarkdown = () =>
+ `---\n${yaml
+ .stringify({
+ name: sidecarMetadata.displayName,
+ description: sidecarMetadata.description,
+ canonicalId: sidecarMetadata.canonicalId,
+ dependencies: {
+ requires: Array.isArray(sidecarMetadata.dependencies.requires) ? sidecarMetadata.dependencies.requires : [],
+ },
+ })
+ .trimEnd()}\n---\n\n# Valid\n`;
+
+ const tempValidRuntimePath = path.join(outputPaths.validationRoot, 'fixtures', 'frontmatter-mismatch', 'runtime-valid.md');
+ const tempValidSourcePath = path.join(outputPaths.validationRoot, 'fixtures', 'frontmatter-mismatch', 'source-valid.md');
+ await fs.writeFile(tempValidRuntimePath, makeValidFrontmatterMarkdown(), 'utf8');
+ await fs.writeFile(tempValidSourcePath, makeValidFrontmatterMarkdown(), 'utf8');
+
+ for (const scope of ['source', 'runtime']) {
+ for (const scenario of mismatchScenarios) {
+ const fixturePath = path.join(outputPaths.validationRoot, 'fixtures', 'frontmatter-mismatch', scope, `${scenario.scenario}.md`);
+ const fixtureRelativePath = `_bmad-output/planning-artifacts/validation/help/fixtures/frontmatter-mismatch/${scope}/${scenario.scenario}.md`;
+ let observedFailureCode = '';
+ let observedFailureDetail = '';
+ let observedFrontmatterValue = '';
+ let expectedSidecarValue = '';
+ let observedAuthoritativeSourceType = '';
+ let observedAuthoritativeSourcePath = '';
+
+ const parsedFixture = parseFrontmatter(await fs.readFile(fixturePath, 'utf8'));
+ if (scenario.fieldPath === 'dependencies.requires') {
+ observedFrontmatterValue = normalizeDependencyTargets(parsedFixture.dependencies?.requires);
+ expectedSidecarValue = normalizeDependencyTargets(sidecarMetadata.dependencies.requires);
+ } else {
+ observedFrontmatterValue = normalizeValue(parsedFixture[scenario.fieldPath]);
+ if (scenario.fieldPath === 'canonicalId') {
+ expectedSidecarValue = sidecarMetadata.canonicalId;
+ } else if (scenario.fieldPath === 'name') {
+ expectedSidecarValue = sidecarMetadata.displayName;
+ } else {
+ expectedSidecarValue = sidecarMetadata.description;
+ }
+ }
+
+ try {
+ await validateHelpAuthoritySplitAndPrecedence({
+ sidecarPath: sourcePaths.sidecarPath,
+ sourceMarkdownPath: scope === 'source' ? fixturePath : tempValidSourcePath,
+ runtimeMarkdownPath: scope === 'runtime' ? fixturePath : tempValidRuntimePath,
+ sidecarSourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ sourceMarkdownSourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
+ runtimeMarkdownSourcePath: `${runtimeFolder}/core/tasks/help.md`,
+ });
+ } catch (error) {
+ observedFailureCode = normalizeValue(error.code);
+ observedFailureDetail = normalizeValue(error.detail);
+ observedAuthoritativeSourceType = 'sidecar';
+ observedAuthoritativeSourcePath = SIDEcar_AUTHORITY_SOURCE_PATH;
+ }
+
+ mismatchRows.push({
+ scenario: scenario.scenario,
+ fixturePath: fixtureRelativePath,
+ frontmatterSurfacePath: scope === 'source' ? SOURCE_MARKDOWN_SOURCE_PATH : `${runtimeFolder}/core/tasks/help.md`,
+ observedFrontmatterKeyPath: scenario.fieldPath,
+ mismatchedField: scenario.mismatchField,
+ observedFrontmatterValue,
+ expectedSidecarValue,
+ expectedAuthoritativeSourceType: 'sidecar',
+ expectedAuthoritativeSourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ expectedFailureCode: scenario.expectedFailureCode,
+ observedFailureCode,
+ expectedFailureDetail: FRONTMATTER_MISMATCH_DETAILS[scenario.expectedFailureCode],
+ observedFailureDetail,
+ observedAuthoritativeSourceType,
+ observedAuthoritativeSourcePath,
+ status:
+ observedFailureCode === scenario.expectedFailureCode &&
+ observedFailureDetail === FRONTMATTER_MISMATCH_DETAILS[scenario.expectedFailureCode]
+ ? 'PASS'
+ : 'FAIL',
+ });
+ }
+ }
+ await this.writeCsvArtifact(artifactPaths.get(14), this.registry[13].columns, mismatchRows);
+
+ return {
+ projectDir: outputPaths.projectDir,
+ planningArtifactsRoot: outputPaths.planningArtifactsRoot,
+ validationRoot: outputPaths.validationRoot,
+ decisionRecordsRoot: outputPaths.decisionRecordsRoot,
+ generatedArtifactCount: this.registry.length,
+ artifactPaths: Object.fromEntries([...artifactPaths.entries()].map(([artifactId, artifactPath]) => [artifactId, artifactPath])),
+ };
+ }
+
+ parseBindingEvidencePayload({ payloadRaw, artifactId, fieldPath, sourcePath }) {
+ let parsed;
+ try {
+ parsed = JSON.parse(String(payloadRaw || ''));
+ } catch (error) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID,
+ detail: `Binding evidence payload is not valid JSON (${error.message})`,
+ artifactId,
+ fieldPath,
+ sourcePath,
+ observedValue: String(payloadRaw || ''),
+ expectedValue: 'valid JSON payload',
+ });
+ }
+
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID,
+ detail: 'Binding evidence payload must be a JSON object',
+ artifactId,
+ fieldPath,
+ sourcePath,
+ observedValue: typeof parsed,
+ expectedValue: 'object',
+ });
+ }
+
+ return parsed;
+ }
+
+ validateProvenanceReplayEvidenceRow(row, sourcePath) {
+ const artifactId = 3;
+ const rowStatus = normalizeValue(row.status || 'PASS');
+ const payload = this.parseBindingEvidencePayload({
+ payloadRaw: row.issuingComponentBindingEvidence,
+ artifactId,
+ fieldPath: 'issuingComponentBindingEvidence',
+ sourcePath,
+ });
+
+ if (normalizeValue(payload.evidenceVersion) !== '1') {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID,
+ detail: 'Binding evidence payload must use evidenceVersion=1',
+ artifactId,
+ fieldPath: 'issuingComponentBindingEvidence.evidenceVersion',
+ sourcePath,
+ observedValue: normalizeValue(payload.evidenceVersion),
+ expectedValue: '1',
+ });
+ }
+
+ if (rowStatus === 'SKIP') {
+ if (normalizeValue(payload.observationMethod) !== 'validator-observed-optional-surface-omitted') {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID,
+ detail: 'Optional-surface provenance rows must use optional-surface evidence method',
+ artifactId,
+ fieldPath: 'issuingComponentBindingEvidence.observationMethod',
+ sourcePath,
+ observedValue: normalizeValue(payload.observationMethod),
+ expectedValue: 'validator-observed-optional-surface-omitted',
+ });
+ }
+ return payload;
+ }
+
+ const requiredPayloadFields = [
+ 'observationMethod',
+ 'artifactPath',
+ 'componentPath',
+ 'baselineArtifactSha256',
+ 'mutatedArtifactSha256',
+ 'baselineRowIdentity',
+ 'mutatedRowIdentity',
+ 'targetedRowLocator',
+ 'rowLevelDiffSha256',
+ 'perturbationApplied',
+ 'baselineTargetRowCount',
+ 'mutatedTargetRowCount',
+ ];
+ for (const key of requiredPayloadFields) {
+ if (normalizeValue(payload[key]).length === 0 && payload[key] !== false) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID,
+ detail: 'Required binding evidence field is missing',
+ artifactId,
+ fieldPath: `issuingComponentBindingEvidence.${key}`,
+ sourcePath,
+ observedValue: '',
+ expectedValue: key,
+ });
+ }
+ }
+
+ if (
+ normalizeValue(payload.observationMethod) !== 'validator-observed-baseline-plus-isolated-single-component-perturbation' ||
+ normalizeValue(row.evidenceMethod) !== 'validator-observed-baseline-plus-isolated-single-component-perturbation' ||
+ normalizeValue(row.issuingComponentBindingBasis) !== 'validator-observed-baseline-plus-isolated-single-component-perturbation'
+ ) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID,
+ detail: 'Replay evidence must use the baseline-plus-isolated-perturbation method',
+ artifactId,
+ fieldPath: 'evidenceMethod',
+ sourcePath,
+ observedValue: normalizeValue(row.evidenceMethod),
+ expectedValue: 'validator-observed-baseline-plus-isolated-single-component-perturbation',
+ });
+ }
+
+ if (
+ normalizeValue(payload.artifactPath) !== normalizeValue(row.artifactPath) ||
+ normalizeValue(payload.componentPath) !== normalizeValue(row.issuingComponent) ||
+ normalizeValue(payload.baselineRowIdentity) !== normalizeValue(row.rowIdentity) ||
+ normalizeValue(payload.mutatedRowIdentity) !== normalizeValue(row.rowIdentity) ||
+ normalizeValue(payload.targetedRowLocator) !== normalizeValue(row.rowIdentity)
+ ) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID,
+ detail: 'Binding evidence payload does not match provenance row contract fields',
+ artifactId,
+ fieldPath: 'issuingComponentBindingEvidence',
+ sourcePath,
+ observedValue: canonicalJsonStringify(payload),
+ expectedValue: 'payload fields aligned with provenance row fields',
+ });
+ }
+
+ if (!isSha256(payload.baselineArtifactSha256) || !isSha256(payload.mutatedArtifactSha256) || !isSha256(payload.rowLevelDiffSha256)) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID,
+ detail: 'Replay evidence hashes must be sha256 hex values',
+ artifactId,
+ fieldPath: 'issuingComponentBindingEvidence.*Sha256',
+ sourcePath,
+ observedValue: canonicalJsonStringify({
+ baselineArtifactSha256: payload.baselineArtifactSha256,
+ mutatedArtifactSha256: payload.mutatedArtifactSha256,
+ rowLevelDiffSha256: payload.rowLevelDiffSha256,
+ }),
+ expectedValue: '64-char lowercase hex values',
+ });
+ }
+
+ if (payload.baselineArtifactSha256 === payload.mutatedArtifactSha256 || payload.perturbationApplied !== true) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID,
+ detail: 'Replay evidence must show isolated perturbation impact',
+ artifactId,
+ fieldPath: 'issuingComponentBindingEvidence.perturbationApplied',
+ sourcePath,
+ observedValue: canonicalJsonStringify({
+ perturbationApplied: payload.perturbationApplied,
+ baselineArtifactSha256: payload.baselineArtifactSha256,
+ mutatedArtifactSha256: payload.mutatedArtifactSha256,
+ }),
+ expectedValue: 'perturbationApplied=true and differing baseline/mutated hashes',
+ });
+ }
+
+ if (Number(payload.baselineTargetRowCount) <= Number(payload.mutatedTargetRowCount)) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID,
+ detail: 'Replay evidence must show reduced target-row impact after perturbation',
+ artifactId,
+ fieldPath: 'issuingComponentBindingEvidence.baselineTargetRowCount',
+ sourcePath,
+ observedValue: canonicalJsonStringify({
+ baselineTargetRowCount: payload.baselineTargetRowCount,
+ mutatedTargetRowCount: payload.mutatedTargetRowCount,
+ }),
+ expectedValue: 'baselineTargetRowCount > mutatedTargetRowCount',
+ });
+ }
+
+ return payload;
+ }
+
+ assertRequiredEvidenceField({ value, artifactId, fieldPath, sourcePath }) {
+ if (normalizeValue(value).length > 0) {
+ return;
+ }
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.REQUIRED_EVIDENCE_LINK_MISSING,
+ detail: 'Required evidence-link field is missing or empty',
+ artifactId,
+ fieldPath,
+ sourcePath,
+ observedValue: normalizeValue(value),
+ expectedValue: 'non-empty value',
+ });
+ }
+
+ validateEvidenceLinkedRows({ rows, artifactId, sourcePath, evidencePath, provenanceByIdentity, requiredFields, rowArtifactPathField }) {
+ for (const [index, row] of rows.entries()) {
+ const status = normalizeValue(row.status || row.stageStatus || 'PASS');
+ if (status !== 'PASS') continue;
+
+ for (const field of requiredFields) {
+ this.assertRequiredEvidenceField({
+ value: row[field],
+ artifactId,
+ fieldPath: `rows[${index}].${field}`,
+ sourcePath,
+ });
+ }
+
+ if (normalizeValue(row.issuedArtifactEvidencePath) !== evidencePath) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.EVIDENCE_LINK_REFERENCE_INVALID,
+ detail: 'Evidence-link path does not point to required provenance artifact',
+ artifactId,
+ fieldPath: `rows[${index}].issuedArtifactEvidencePath`,
+ sourcePath,
+ observedValue: normalizeValue(row.issuedArtifactEvidencePath),
+ expectedValue: evidencePath,
+ });
+ }
+
+ const linkedEvidenceRowIdentity = normalizeValue(row.issuedArtifactEvidenceRowIdentity);
+ const provenanceRow = provenanceByIdentity.get(linkedEvidenceRowIdentity);
+ if (!provenanceRow) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.EVIDENCE_LINK_REFERENCE_INVALID,
+ detail: 'Evidence-link row identity does not resolve to provenance artifact row',
+ artifactId,
+ fieldPath: `rows[${index}].issuedArtifactEvidenceRowIdentity`,
+ sourcePath,
+ observedValue: linkedEvidenceRowIdentity,
+ expectedValue: 'existing artifact-3 rowIdentity',
+ });
+ }
+
+ if (normalizeValue(provenanceRow.status) !== 'PASS') {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.ISSUER_PREREQUISITE_MISSING,
+ detail: 'Terminal PASS requires linked provenance rows to be PASS',
+ artifactId,
+ fieldPath: `rows[${index}].issuedArtifactEvidenceRowIdentity`,
+ sourcePath,
+ observedValue: normalizeValue(provenanceRow.status),
+ expectedValue: 'PASS',
+ });
+ }
+
+ if (rowArtifactPathField && normalizeValue(row[rowArtifactPathField]) !== normalizeValue(provenanceRow.artifactPath)) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.EVIDENCE_LINK_REFERENCE_INVALID,
+ detail: 'Evidence-linked provenance row does not match claimed artifact path',
+ artifactId,
+ fieldPath: `rows[${index}].${rowArtifactPathField}`,
+ sourcePath,
+ observedValue: normalizeValue(row[rowArtifactPathField]),
+ expectedValue: normalizeValue(provenanceRow.artifactPath),
+ });
+ }
+
+ if (
+ Object.prototype.hasOwnProperty.call(row, 'issuingComponent') &&
+ normalizeValue(row.issuingComponent).length > 0 &&
+ normalizeValue(row.issuingComponent) !== normalizeValue(provenanceRow.issuingComponent)
+ ) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.SELF_ATTESTED_ISSUER_CLAIM,
+ detail: 'Issuer component claim diverges from validator-linked provenance evidence',
+ artifactId,
+ fieldPath: `rows[${index}].issuingComponent`,
+ sourcePath,
+ observedValue: normalizeValue(row.issuingComponent),
+ expectedValue: normalizeValue(provenanceRow.issuingComponent),
+ });
+ }
+
+ if (
+ Object.prototype.hasOwnProperty.call(row, 'issuingComponentBindingEvidence') &&
+ normalizeValue(row.issuingComponentBindingEvidence).length > 0 &&
+ normalizeValue(row.issuingComponentBindingEvidence) !== normalizeValue(provenanceRow.issuingComponentBindingEvidence)
+ ) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.SELF_ATTESTED_ISSUER_CLAIM,
+ detail: 'Issuer binding evidence claim diverges from validator-linked provenance evidence',
+ artifactId,
+ fieldPath: `rows[${index}].issuingComponentBindingEvidence`,
+ sourcePath,
+ observedValue: normalizeValue(row.issuingComponentBindingEvidence),
+ expectedValue: normalizeValue(provenanceRow.issuingComponentBindingEvidence),
+ });
+ }
+ }
+ }
+
+ validateIssuerPrerequisites({ artifactDataById, runtimeFolder, requireExportSkillProjection }) {
+ const evidencePath = '_bmad-output/planning-artifacts/validation/help/bmad-help-issued-artifact-provenance.csv';
+ const provenanceArtifact = artifactDataById.get(3) || { rows: [] };
+ const provenanceRows = Array.isArray(provenanceArtifact.rows) ? provenanceArtifact.rows : [];
+ const provenanceByIdentity = new Map();
+ const provenanceByArtifactPath = new Map();
+
+ for (const [index, row] of provenanceRows.entries()) {
+ const sourcePath = normalizePath((provenanceArtifact.relativePath || '').replaceAll('\\', '/'));
+ const rowIdentity = normalizeValue(row.rowIdentity);
+ this.assertRequiredEvidenceField({
+ value: rowIdentity,
+ artifactId: 3,
+ fieldPath: `rows[${index}].rowIdentity`,
+ sourcePath,
+ });
+ this.validateProvenanceReplayEvidenceRow(row, sourcePath);
+ provenanceByIdentity.set(rowIdentity, row);
+ provenanceByArtifactPath.set(normalizeValue(row.artifactPath), row);
+ }
+
+ const requiredProvenanceArtifactPaths = [
+ `${runtimeFolder}/_config/task-manifest.csv`,
+ `${runtimeFolder}/core/module-help.csv`,
+ `${runtimeFolder}/_config/bmad-help.csv`,
+ ];
+ if (requireExportSkillProjection) {
+ requiredProvenanceArtifactPaths.push('.agents/skills/bmad-help/SKILL.md');
+ }
+
+ for (const artifactPath of requiredProvenanceArtifactPaths) {
+ const row = provenanceByArtifactPath.get(artifactPath);
+ if (!row || normalizeValue(row.status) !== 'PASS') {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.ISSUER_PREREQUISITE_MISSING,
+ detail: 'Terminal PASS requires provenance prerequisite rows for all required issuing-component claims',
+ artifactId: 3,
+ fieldPath: `rows[artifactPath=${artifactPath}]`,
+ sourcePath: normalizePath(provenanceArtifact.relativePath),
+ observedValue: row ? normalizeValue(row.status) : '',
+ expectedValue: 'PASS',
+ });
+ }
+ }
+
+ const artifact4 = artifactDataById.get(4) || { rows: [], relativePath: '' };
+ this.validateEvidenceLinkedRows({
+ rows: artifact4.rows || [],
+ artifactId: 4,
+ sourcePath: normalizePath(artifact4.relativePath),
+ evidencePath,
+ provenanceByIdentity,
+ requiredFields: ['issuedArtifactEvidencePath', 'issuedArtifactEvidenceRowIdentity', 'issuingComponentBindingEvidence'],
+ });
+
+ const artifact6 = artifactDataById.get(6) || { rows: [], relativePath: '' };
+ this.validateEvidenceLinkedRows({
+ rows: artifact6.rows || [],
+ artifactId: 6,
+ sourcePath: normalizePath(artifact6.relativePath),
+ evidencePath,
+ provenanceByIdentity,
+ requiredFields: ['issuedArtifactEvidencePath', 'issuedArtifactEvidenceRowIdentity'],
+ });
+
+ const artifact7 = artifactDataById.get(7) || { rows: [], relativePath: '' };
+ this.validateEvidenceLinkedRows({
+ rows: artifact7.rows || [],
+ artifactId: 7,
+ sourcePath: normalizePath(artifact7.relativePath),
+ evidencePath,
+ provenanceByIdentity,
+ requiredFields: ['issuedArtifactEvidencePath', 'issuedArtifactEvidenceRowIdentity', 'issuingComponentBindingEvidence'],
+ });
+
+ const artifact8 = artifactDataById.get(8) || { rows: [], relativePath: '' };
+ this.validateEvidenceLinkedRows({
+ rows: artifact8.rows || [],
+ artifactId: 8,
+ sourcePath: normalizePath(artifact8.relativePath),
+ evidencePath,
+ provenanceByIdentity,
+ requiredFields: ['issuedArtifactEvidencePath', 'issuedArtifactEvidenceRowIdentity'],
+ });
+
+ const artifact9 = artifactDataById.get(9) || { rows: [], relativePath: '' };
+ this.validateEvidenceLinkedRows({
+ rows: artifact9.rows || [],
+ artifactId: 9,
+ sourcePath: normalizePath(artifact9.relativePath),
+ evidencePath,
+ provenanceByIdentity,
+ requiredFields: [
+ 'issuedArtifactEvidencePath',
+ 'issuedArtifactEvidenceRowIdentity',
+ 'issuingComponentBindingEvidence',
+ 'issuingComponent',
+ ],
+ rowArtifactPathField: 'artifactPath',
+ });
+ }
+
+ inferRequireExportSkillProjection({ artifactDataById, optionsRequireExportSkillProjection }) {
+ if (typeof optionsRequireExportSkillProjection === 'boolean') {
+ return optionsRequireExportSkillProjection;
+ }
+
+ const exportSurfacePath = '.agents/skills/bmad-help/SKILL.md';
+ const provenanceArtifact = artifactDataById.get(3) || { rows: [] };
+ const provenanceRows = Array.isArray(provenanceArtifact.rows) ? provenanceArtifact.rows : [];
+ const exportProvenanceRow = provenanceRows.find((row) => normalizeValue(row.artifactPath) === exportSurfacePath);
+ if (exportProvenanceRow) {
+ return normalizeValue(exportProvenanceRow.status) === 'PASS';
+ }
+
+ const exportArtifact = artifactDataById.get(7) || { rows: [] };
+ const exportRows = Array.isArray(exportArtifact.rows) ? exportArtifact.rows : [];
+ if (exportRows.length > 0) {
+ return exportRows.some((row) => {
+ const status = normalizeValue(row.status || row.stageStatus || '');
+ return status === 'PASS';
+ });
+ }
+
+ return false;
+ }
+
+ async validateGeneratedArtifacts(options = {}) {
+ const outputPaths = this.resolveOutputPaths(options);
+ const planningArtifactsRoot = outputPaths.planningArtifactsRoot;
+ const artifactDataById = new Map();
+
+ for (const artifact of this.registry) {
+ const artifactPath = path.join(planningArtifactsRoot, artifact.relativePath);
+ if (!(await fs.pathExists(artifactPath))) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.REQUIRED_ARTIFACT_MISSING,
+ detail: 'Required help validation artifact is missing',
+ artifactId: artifact.artifactId,
+ fieldPath: '',
+ sourcePath: normalizePath(artifact.relativePath),
+ observedValue: '',
+ expectedValue: normalizePath(artifact.relativePath),
+ });
+ }
+
+ switch (artifact.type) {
+ case 'csv': {
+ const content = await fs.readFile(artifactPath, 'utf8');
+ const observedHeader = parseCsvHeader(content);
+ const expectedHeader = artifact.columns || [];
+ const rows = parseCsvRows(content);
+ artifactDataById.set(artifact.artifactId, {
+ type: 'csv',
+ relativePath: artifact.relativePath,
+ header: observedHeader,
+ rows,
+ });
+
+ if (observedHeader.length !== expectedHeader.length) {
+ throw new HelpValidationHarnessError({
+ code: HELP_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 HelpValidationHarnessError({
+ code: HELP_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,
+ });
+ }
+ }
+
+ if (Array.isArray(artifact.requiredRowIdentityFields) && artifact.requiredRowIdentityFields.length > 0) {
+ if (rows.length === 0) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.REQUIRED_ROW_IDENTITY_MISSING,
+ detail: 'Required row identity rows are missing',
+ artifactId: artifact.artifactId,
+ fieldPath: 'rows',
+ sourcePath: normalizePath(artifact.relativePath),
+ observedValue: '',
+ expectedValue: 'at least one row',
+ });
+ }
+ for (const field of artifact.requiredRowIdentityFields) {
+ if (!expectedHeader.includes(field)) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.CSV_SCHEMA_MISMATCH,
+ detail: 'Required row identity field is missing from artifact schema',
+ artifactId: artifact.artifactId,
+ fieldPath: `header.${field}`,
+ sourcePath: normalizePath(artifact.relativePath),
+ observedValue: '',
+ expectedValue: field,
+ });
+ }
+
+ for (const [rowIndex, row] of rows.entries()) {
+ if (normalizeValue(row[field]).length === 0) {
+ const isEvidenceLinkField = field === 'issuedArtifactEvidenceRowIdentity';
+ throw new HelpValidationHarnessError({
+ code: isEvidenceLinkField
+ ? HELP_VALIDATION_ERROR_CODES.REQUIRED_EVIDENCE_LINK_MISSING
+ : HELP_VALIDATION_ERROR_CODES.REQUIRED_ROW_IDENTITY_MISSING,
+ detail: isEvidenceLinkField
+ ? 'Required evidence-link row identity is missing or empty'
+ : 'Required row identity value is missing or empty',
+ artifactId: artifact.artifactId,
+ fieldPath: `rows[${rowIndex}].${field}`,
+ sourcePath: normalizePath(artifact.relativePath),
+ observedValue: normalizeValue(row[field]),
+ expectedValue: 'non-empty value',
+ });
+ }
+ }
+ }
+ }
+ break;
+ }
+ case 'yaml': {
+ const parsed = yaml.parse(await fs.readFile(artifactPath, 'utf8'));
+ artifactDataById.set(artifact.artifactId, {
+ type: 'yaml',
+ relativePath: artifact.relativePath,
+ parsed,
+ });
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new HelpValidationHarnessError({
+ code: HELP_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 requiredKey of artifact.requiredTopLevelKeys || []) {
+ if (!Object.prototype.hasOwnProperty.call(parsed, requiredKey)) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.YAML_SCHEMA_MISMATCH,
+ detail: 'Required YAML key is missing',
+ artifactId: artifact.artifactId,
+ fieldPath: requiredKey,
+ sourcePath: normalizePath(artifact.relativePath),
+ observedValue: '',
+ expectedValue: requiredKey,
+ });
+ }
+ }
+ break;
+ }
+ case 'markdown': {
+ const content = await fs.readFile(artifactPath, 'utf8');
+ artifactDataById.set(artifact.artifactId, {
+ type: 'markdown',
+ relativePath: artifact.relativePath,
+ content,
+ });
+ let frontmatter;
+ try {
+ frontmatter = parseFrontmatter(content);
+ } catch (error) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.DECISION_RECORD_PARSE_FAILED,
+ detail: `Unable to parse decision record frontmatter (${error.message})`,
+ artifactId: artifact.artifactId,
+ fieldPath: '',
+ sourcePath: normalizePath(artifact.relativePath),
+ });
+ }
+ for (const requiredKey of artifact.requiredFrontmatterKeys || []) {
+ if (!Object.prototype.hasOwnProperty.call(frontmatter, requiredKey)) {
+ throw new HelpValidationHarnessError({
+ code: HELP_VALIDATION_ERROR_CODES.DECISION_RECORD_SCHEMA_MISMATCH,
+ detail: 'Required decision-record key is missing',
+ artifactId: artifact.artifactId,
+ fieldPath: requiredKey,
+ sourcePath: normalizePath(artifact.relativePath),
+ observedValue: '',
+ expectedValue: requiredKey,
+ });
+ }
+ }
+ break;
+ }
+ default: {
+ break;
+ }
+ }
+ }
+
+ const inferredRequireExportSkillProjection = this.inferRequireExportSkillProjection({
+ artifactDataById,
+ optionsRequireExportSkillProjection: options.requireExportSkillProjection,
+ });
+
+ this.validateIssuerPrerequisites({
+ artifactDataById,
+ runtimeFolder: normalizeValue(options.bmadFolderName || '_bmad'),
+ requireExportSkillProjection: inferredRequireExportSkillProjection,
+ });
+
+ 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 = {
+ HELP_VALIDATION_ERROR_CODES,
+ HELP_VALIDATION_ARTIFACT_REGISTRY,
+ HelpValidationHarnessError,
+ HelpValidationHarness,
+};
diff --git a/tools/cli/installers/lib/core/index-docs-authority-validator.js b/tools/cli/installers/lib/core/index-docs-authority-validator.js
new file mode 100644
index 000000000..ebd07c52e
--- /dev/null
+++ b/tools/cli/installers/lib/core/index-docs-authority-validator.js
@@ -0,0 +1,359 @@
+const path = require('node:path');
+const fs = require('fs-extra');
+const yaml = require('yaml');
+const csv = require('csv-parse/sync');
+const { getProjectRoot, getSourcePath } = require('../../../lib/project-root');
+const { resolveSkillMetadataAuthority } = require('./sidecar-contract-validator');
+
+const INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES = Object.freeze({
+ SIDECAR_FILE_NOT_FOUND: 'ERR_INDEX_DOCS_AUTHORITY_SIDECAR_FILE_NOT_FOUND',
+ SIDECAR_FILENAME_AMBIGUOUS: 'ERR_INDEX_DOCS_AUTHORITY_SIDECAR_FILENAME_AMBIGUOUS',
+ SIDECAR_PARSE_FAILED: 'ERR_INDEX_DOCS_AUTHORITY_SIDECAR_PARSE_FAILED',
+ SIDECAR_INVALID_METADATA: 'ERR_INDEX_DOCS_AUTHORITY_SIDECAR_INVALID_METADATA',
+ SIDECAR_CANONICAL_ID_MISMATCH: 'ERR_INDEX_DOCS_AUTHORITY_SIDECAR_CANONICAL_ID_MISMATCH',
+ SOURCE_XML_FILE_NOT_FOUND: 'ERR_INDEX_DOCS_AUTHORITY_SOURCE_XML_FILE_NOT_FOUND',
+ COMPATIBILITY_FILE_NOT_FOUND: 'ERR_INDEX_DOCS_AUTHORITY_COMPATIBILITY_FILE_NOT_FOUND',
+ COMPATIBILITY_PARSE_FAILED: 'ERR_INDEX_DOCS_AUTHORITY_COMPATIBILITY_PARSE_FAILED',
+ COMPATIBILITY_ROW_MISSING: 'ERR_INDEX_DOCS_AUTHORITY_COMPATIBILITY_ROW_MISSING',
+ COMPATIBILITY_ROW_DUPLICATE: 'ERR_INDEX_DOCS_AUTHORITY_COMPATIBILITY_ROW_DUPLICATE',
+ COMMAND_MISMATCH: 'ERR_INDEX_DOCS_AUTHORITY_COMMAND_MISMATCH',
+ DISPLAY_NAME_MISMATCH: 'ERR_INDEX_DOCS_AUTHORITY_DISPLAY_NAME_MISMATCH',
+ DUPLICATE_CANONICAL_COMMAND: 'ERR_INDEX_DOCS_AUTHORITY_DUPLICATE_CANONICAL_COMMAND',
+});
+
+const INDEX_DOCS_LOCKED_CANONICAL_ID = 'bmad-index-docs';
+const INDEX_DOCS_LOCKED_AUTHORITATIVE_PRESENCE_KEY = `capability:${INDEX_DOCS_LOCKED_CANONICAL_ID}`;
+
+class IndexDocsAuthorityValidationError extends Error {
+ constructor({ code, detail, fieldPath, sourcePath, observedValue, expectedValue }) {
+ const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath})`;
+ super(message);
+ this.name = 'IndexDocsAuthorityValidationError';
+ this.code = code;
+ this.detail = detail;
+ this.fieldPath = fieldPath;
+ this.sourcePath = sourcePath;
+ this.observedValue = observedValue;
+ this.expectedValue = expectedValue;
+ this.fullMessage = message;
+ }
+}
+
+function normalizeSourcePath(value) {
+ if (!value) return '';
+ return String(value).replaceAll('\\', '/');
+}
+
+function toProjectRelativePath(filePath) {
+ const projectRoot = getProjectRoot();
+ const relative = path.relative(projectRoot, filePath);
+
+ if (!relative || relative.startsWith('..')) {
+ return normalizeSourcePath(path.resolve(filePath));
+ }
+
+ return normalizeSourcePath(relative);
+}
+
+function hasOwn(obj, key) {
+ return Object.prototype.hasOwnProperty.call(obj, key);
+}
+
+function isBlankString(value) {
+ return typeof value !== 'string' || value.trim().length === 0;
+}
+
+function csvMatchValue(value) {
+ return String(value ?? '').trim();
+}
+
+function createValidationError(code, detail, fieldPath, sourcePath, observedValue, expectedValue) {
+ throw new IndexDocsAuthorityValidationError({
+ code,
+ detail,
+ fieldPath,
+ sourcePath,
+ observedValue,
+ expectedValue,
+ });
+}
+
+function ensureSidecarMetadata(sidecarData, sidecarSourcePath, sourceXmlSourcePath) {
+ const requiredFields = ['canonicalId', 'displayName', 'description', 'sourcePath'];
+ for (const requiredField of requiredFields) {
+ if (!hasOwn(sidecarData, requiredField)) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
+ `Missing required sidecar metadata field "${requiredField}"`,
+ requiredField,
+ sidecarSourcePath,
+ );
+ }
+ }
+
+ for (const requiredField of requiredFields) {
+ if (isBlankString(sidecarData[requiredField])) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
+ `Required sidecar metadata field "${requiredField}" must be a non-empty string`,
+ requiredField,
+ sidecarSourcePath,
+ );
+ }
+ }
+
+ const normalizedCanonicalId = String(sidecarData.canonicalId).trim();
+ if (normalizedCanonicalId !== INDEX_DOCS_LOCKED_CANONICAL_ID) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH,
+ 'Converted index-docs sidecar canonicalId must remain locked to bmad-index-docs',
+ 'canonicalId',
+ sidecarSourcePath,
+ normalizedCanonicalId,
+ INDEX_DOCS_LOCKED_CANONICAL_ID,
+ );
+ }
+
+ const normalizedDeclaredSourcePath = normalizeSourcePath(sidecarData.sourcePath);
+ if (normalizedDeclaredSourcePath !== sourceXmlSourcePath) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
+ 'Sidecar sourcePath must match index-docs XML source path',
+ 'sourcePath',
+ sidecarSourcePath,
+ normalizedDeclaredSourcePath,
+ sourceXmlSourcePath,
+ );
+ }
+}
+
+async function parseCompatibilityRows(compatibilityCatalogPath, compatibilityCatalogSourcePath) {
+ if (!(await fs.pathExists(compatibilityCatalogPath))) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_FILE_NOT_FOUND,
+ 'Expected module-help compatibility catalog file was not found',
+ '',
+ compatibilityCatalogSourcePath,
+ );
+ }
+
+ let csvRaw;
+ try {
+ csvRaw = await fs.readFile(compatibilityCatalogPath, 'utf8');
+ } catch (error) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_PARSE_FAILED,
+ `Unable to read compatibility catalog file: ${error.message}`,
+ '',
+ compatibilityCatalogSourcePath,
+ );
+ }
+
+ try {
+ return csv.parse(csvRaw, {
+ columns: true,
+ skip_empty_lines: true,
+ relax_column_count: true,
+ trim: true,
+ });
+ } catch (error) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_PARSE_FAILED,
+ `CSV parse failure: ${error.message}`,
+ '',
+ compatibilityCatalogSourcePath,
+ );
+ }
+}
+
+function validateCompatibilityPrecedence({ rows, displayName, workflowFilePath, compatibilityCatalogSourcePath }) {
+ const workflowMatches = rows.filter((row) => csvMatchValue(row['workflow-file']) === workflowFilePath);
+
+ if (workflowMatches.length === 0) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_MISSING,
+ 'Converted index-docs compatibility row is missing from module-help catalog',
+ 'workflow-file',
+ compatibilityCatalogSourcePath,
+ '',
+ workflowFilePath,
+ );
+ }
+
+ if (workflowMatches.length > 1) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_DUPLICATE,
+ 'Converted index-docs compatibility row appears more than once in module-help catalog',
+ 'workflow-file',
+ compatibilityCatalogSourcePath,
+ workflowMatches.length,
+ 1,
+ );
+ }
+
+ const canonicalCommandMatches = rows.filter((row) => csvMatchValue(row.command) === INDEX_DOCS_LOCKED_CANONICAL_ID);
+ if (canonicalCommandMatches.length > 1) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.DUPLICATE_CANONICAL_COMMAND,
+ 'Converted index-docs canonical command appears in more than one compatibility row',
+ 'command',
+ compatibilityCatalogSourcePath,
+ canonicalCommandMatches.length,
+ 1,
+ );
+ }
+
+ const indexDocsRow = workflowMatches[0];
+ const observedCommand = csvMatchValue(indexDocsRow.command);
+ if (!observedCommand || observedCommand !== INDEX_DOCS_LOCKED_CANONICAL_ID) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH,
+ 'Converted index-docs compatibility command must match locked canonical command bmad-index-docs',
+ 'command',
+ compatibilityCatalogSourcePath,
+ observedCommand || '',
+ INDEX_DOCS_LOCKED_CANONICAL_ID,
+ );
+ }
+
+ const observedDisplayName = csvMatchValue(indexDocsRow.name);
+ if (observedDisplayName && observedDisplayName !== displayName) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.DISPLAY_NAME_MISMATCH,
+ 'Converted index-docs compatibility name must match sidecar displayName when provided',
+ 'name',
+ compatibilityCatalogSourcePath,
+ observedDisplayName,
+ displayName,
+ );
+ }
+}
+
+function buildIndexDocsAuthorityRecords({ canonicalId, sidecarSourcePath, sourceXmlSourcePath }) {
+ return [
+ {
+ recordType: 'metadata-authority',
+ canonicalId,
+ authoritativePresenceKey: INDEX_DOCS_LOCKED_AUTHORITATIVE_PRESENCE_KEY,
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: sidecarSourcePath,
+ sourcePath: sourceXmlSourcePath,
+ },
+ {
+ recordType: 'source-body-authority',
+ canonicalId,
+ authoritativePresenceKey: INDEX_DOCS_LOCKED_AUTHORITATIVE_PRESENCE_KEY,
+ authoritySourceType: 'source-xml',
+ authoritySourcePath: sourceXmlSourcePath,
+ sourcePath: sourceXmlSourcePath,
+ },
+ ];
+}
+
+async function validateIndexDocsAuthoritySplitAndPrecedence(options = {}) {
+ const sourceXmlPath = options.sourceXmlPath || getSourcePath('core', 'tasks', 'index-docs.xml');
+ const compatibilityCatalogPath = options.compatibilityCatalogPath || getSourcePath('core', 'module-help.csv');
+ const compatibilityWorkflowFilePath = options.compatibilityWorkflowFilePath || '_bmad/core/tasks/index-docs.xml';
+
+ let resolvedMetadataAuthority;
+ try {
+ resolvedMetadataAuthority = await resolveSkillMetadataAuthority({
+ sourceFilePath: sourceXmlPath,
+ metadataPath: options.sidecarPath || '',
+ metadataSourcePath: options.sidecarSourcePath || '',
+ ambiguousErrorCode: INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS,
+ });
+ } catch (error) {
+ createValidationError(
+ error.code || INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS,
+ error.detail || error.message,
+ error.fieldPath || '',
+ normalizeSourcePath(error.sourcePath || toProjectRelativePath(sourceXmlPath)),
+ );
+ }
+
+ const sidecarPath = resolvedMetadataAuthority.resolvedAbsolutePath;
+
+ const sidecarSourcePath = normalizeSourcePath(
+ options.sidecarSourcePath || resolvedMetadataAuthority.canonicalTargetSourcePath || resolvedMetadataAuthority.resolvedSourcePath,
+ );
+ const sourceXmlSourcePath = normalizeSourcePath(options.sourceXmlSourcePath || toProjectRelativePath(sourceXmlPath));
+ const compatibilityCatalogSourcePath = normalizeSourcePath(
+ options.compatibilityCatalogSourcePath || toProjectRelativePath(compatibilityCatalogPath),
+ );
+
+ if (!sidecarPath || !(await fs.pathExists(sidecarPath))) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
+ 'Expected index-docs sidecar metadata file was not found',
+ '',
+ sidecarSourcePath,
+ );
+ }
+
+ let sidecarData;
+ try {
+ sidecarData = yaml.parse(await fs.readFile(sidecarPath, 'utf8'));
+ } catch (error) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
+ `YAML parse failure: ${error.message}`,
+ '',
+ sidecarSourcePath,
+ );
+ }
+
+ if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
+ 'Sidecar root must be a YAML mapping object',
+ '',
+ sidecarSourcePath,
+ );
+ }
+
+ ensureSidecarMetadata(sidecarData, sidecarSourcePath, sourceXmlSourcePath);
+
+ if (!(await fs.pathExists(sourceXmlPath))) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SOURCE_XML_FILE_NOT_FOUND,
+ 'Expected index-docs XML source file was not found',
+ '',
+ sourceXmlSourcePath,
+ );
+ }
+
+ const compatibilityRows = await parseCompatibilityRows(compatibilityCatalogPath, compatibilityCatalogSourcePath);
+ validateCompatibilityPrecedence({
+ rows: compatibilityRows,
+ displayName: String(sidecarData.displayName || '').trim(),
+ workflowFilePath: compatibilityWorkflowFilePath,
+ compatibilityCatalogSourcePath,
+ });
+
+ const canonicalId = INDEX_DOCS_LOCKED_CANONICAL_ID;
+ const authoritativeRecords = buildIndexDocsAuthorityRecords({
+ canonicalId,
+ sidecarSourcePath,
+ sourceXmlSourcePath,
+ });
+
+ return {
+ canonicalId,
+ authoritativePresenceKey: INDEX_DOCS_LOCKED_AUTHORITATIVE_PRESENCE_KEY,
+ authoritativeRecords,
+ metadataAuthority: {
+ resolvedPath: normalizeSourcePath(resolvedMetadataAuthority.resolvedSourcePath || sidecarSourcePath),
+ resolvedFilename: normalizeSourcePath(resolvedMetadataAuthority.resolvedFilename || ''),
+ canonicalTargetFilename: normalizeSourcePath(resolvedMetadataAuthority.canonicalTargetFilename || 'skill-manifest.yaml'),
+ canonicalTargetPath: normalizeSourcePath(resolvedMetadataAuthority.canonicalTargetSourcePath || sidecarSourcePath),
+ derivationMode: normalizeSourcePath(resolvedMetadataAuthority.derivationMode || ''),
+ },
+ };
+}
+
+module.exports = {
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES,
+ IndexDocsAuthorityValidationError,
+ validateIndexDocsAuthoritySplitAndPrecedence,
+};
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..37ce04c2e
--- /dev/null
+++ b/tools/cli/installers/lib/core/index-docs-validation-harness.js
@@ -0,0 +1,1614 @@
+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 { resolveSkillMetadataAuthority } = require('./sidecar-contract-validator');
+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/skill-manifest.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',
+ METADATA_RESOLUTION_FAILED: 'ERR_INDEX_DOCS_VALIDATION_METADATA_RESOLUTION_FAILED',
+ 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 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'));
+ let resolvedMetadataAuthority;
+ try {
+ resolvedMetadataAuthority = await resolveSkillMetadataAuthority({
+ sourceFilePath: sourceXmlPath,
+ metadataPath: options.sidecarPath || '',
+ projectRoot: outputPaths.projectDir,
+ ambiguousErrorCode: INDEX_DOCS_VALIDATION_ERROR_CODES.METADATA_RESOLUTION_FAILED,
+ });
+ } catch (error) {
+ throw new IndexDocsValidationHarnessError({
+ code: INDEX_DOCS_VALIDATION_ERROR_CODES.METADATA_RESOLUTION_FAILED,
+ detail: error.detail || error.message || 'metadata authority resolution failed',
+ artifactId: 1,
+ fieldPath: normalizeValue(error.fieldPath || ''),
+ sourcePath: normalizePath(error.sourcePath || INDEX_DOCS_SIDECAR_SOURCE_PATH),
+ observedValue: normalizeValue(error.code || ''),
+ expectedValue: 'unambiguous metadata authority candidate',
+ });
+ }
+ const sidecarPath =
+ resolvedMetadataAuthority.resolvedAbsolutePath ||
+ options.sidecarPath ||
+ path.join(path.dirname(sourceXmlPath), path.basename(sourceXmlPath, path.extname(sourceXmlPath)), 'skill-manifest.yaml');
+
+ 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 fe8b88d7c..0e204a19e 100644
--- a/tools/cli/installers/lib/core/installer.js
+++ b/tools/cli/installers/lib/core/installer.js
@@ -9,6 +9,25 @@ const { Config } = require('../../../lib/config');
const { XmlHandler } = require('../../../lib/xml-handler');
const { DependencyResolver } = require('./dependency-resolver');
const { ConfigCollector } = require('./config-collector');
+const {
+ validateHelpSidecarContractFile,
+ validateShardDocSidecarContractFile,
+ validateIndexDocsSidecarContractFile,
+} = require('./sidecar-contract-validator');
+const { validateHelpAuthoritySplitAndPrecedence } = require('./help-authority-validator');
+const { validateShardDocAuthoritySplitAndPrecedence } = require('./shard-doc-authority-validator');
+const { validateIndexDocsAuthoritySplitAndPrecedence } = require('./index-docs-authority-validator');
+const {
+ HELP_CATALOG_GENERATION_ERROR_CODES,
+ buildSidecarAwareExemplarHelpRow,
+ evaluateExemplarCommandLabelReportRows,
+ normalizeDisplayedCommandLabel,
+ renderDisplayedCommandLabel,
+} = require('./help-catalog-generator');
+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');
@@ -17,6 +36,17 @@ const { CustomHandler } = require('../custom/handler');
const prompts = require('../../../lib/prompts');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
+const EXEMPLAR_HELP_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/help/skill-manifest.yaml';
+const EXEMPLAR_HELP_SOURCE_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
+const EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml';
+const EXEMPLAR_SHARD_DOC_SOURCE_XML_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.xml';
+const EXEMPLAR_SHARD_DOC_COMPATIBILITY_CATALOG_SOURCE_PATH = 'bmad-fork/src/core/module-help.csv';
+const EXEMPLAR_SHARD_DOC_WORKFLOW_FILE_PATH = '_bmad/core/tasks/shard-doc.xml';
+const EXEMPLAR_INDEX_DOCS_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml';
+const EXEMPLAR_INDEX_DOCS_SOURCE_XML_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs.xml';
+const EXEMPLAR_INDEX_DOCS_COMPATIBILITY_CATALOG_SOURCE_PATH = 'bmad-fork/src/core/module-help.csv';
+const EXEMPLAR_INDEX_DOCS_WORKFLOW_FILE_PATH = '_bmad/core/tasks/index-docs.xml';
+
class Installer {
constructor() {
this.detector = new Detector();
@@ -29,8 +59,169 @@ class Installer {
this.dependencyResolver = new DependencyResolver();
this.configCollector = new ConfigCollector();
this.ideConfigManager = new IdeConfigManager();
+ this.validateHelpSidecarContractFile = validateHelpSidecarContractFile;
+ this.validateShardDocSidecarContractFile = validateShardDocSidecarContractFile;
+ this.validateIndexDocsSidecarContractFile = validateIndexDocsSidecarContractFile;
+ this.validateHelpAuthoritySplitAndPrecedence = validateHelpAuthoritySplitAndPrecedence;
+ this.validateShardDocAuthoritySplitAndPrecedence = validateShardDocAuthoritySplitAndPrecedence;
+ this.validateIndexDocsAuthoritySplitAndPrecedence = validateIndexDocsAuthoritySplitAndPrecedence;
+ this.ManifestGenerator = ManifestGenerator;
this.installedFiles = new Set(); // Track all installed files
this.bmadFolderName = BMAD_FOLDER_NAME;
+ this.helpCatalogPipelineRows = [];
+ this.helpCatalogCommandLabelReportRows = [];
+ this.codexExportDerivationRecords = [];
+ this.helpAuthorityRecords = [];
+ this.shardDocAuthorityRecords = [];
+ 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 }) {
+ // Validate converted-capability sidecar contracts before generating projections/manifests.
+ // Fail-fast here prevents downstream artifacts from being produced on invalid metadata.
+ message('Validating shard-doc sidecar contract...');
+ await this.validateShardDocSidecarContractFile();
+
+ message('Validating index-docs sidecar contract...');
+ await this.validateIndexDocsSidecarContractFile();
+
+ message('Validating exemplar sidecar contract...');
+ await this.validateHelpSidecarContractFile();
+
+ addResult('Shard-doc sidecar contract', 'ok', 'validated');
+ addResult('Index-docs sidecar contract', 'ok', 'validated');
+ addResult('Sidecar contract', 'ok', 'validated');
+
+ message('Validating shard-doc authority split and XML precedence...');
+ const shardDocAuthorityValidation = await this.validateShardDocAuthoritySplitAndPrecedence({
+ sidecarSourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH,
+ sourceXmlSourcePath: EXEMPLAR_SHARD_DOC_SOURCE_XML_SOURCE_PATH,
+ compatibilityCatalogSourcePath: EXEMPLAR_SHARD_DOC_COMPATIBILITY_CATALOG_SOURCE_PATH,
+ compatibilityWorkflowFilePath: EXEMPLAR_SHARD_DOC_WORKFLOW_FILE_PATH,
+ });
+ this.shardDocAuthorityRecords = shardDocAuthorityValidation.authoritativeRecords;
+ addResult('Shard-doc authority split', 'ok', shardDocAuthorityValidation.authoritativePresenceKey);
+
+ message('Validating index-docs authority split and XML precedence...');
+ const indexDocsAuthorityValidation = await this.validateIndexDocsAuthoritySplitAndPrecedence({
+ sidecarSourcePath: EXEMPLAR_INDEX_DOCS_SIDECAR_SOURCE_PATH,
+ sourceXmlSourcePath: EXEMPLAR_INDEX_DOCS_SOURCE_XML_SOURCE_PATH,
+ compatibilityCatalogSourcePath: EXEMPLAR_INDEX_DOCS_COMPATIBILITY_CATALOG_SOURCE_PATH,
+ compatibilityWorkflowFilePath: EXEMPLAR_INDEX_DOCS_WORKFLOW_FILE_PATH,
+ });
+ this.indexDocsAuthorityRecords = indexDocsAuthorityValidation.authoritativeRecords;
+ addResult('Index-docs authority split', 'ok', indexDocsAuthorityValidation.authoritativePresenceKey);
+
+ message('Validating authority split and frontmatter precedence...');
+ const helpAuthorityValidation = await this.validateHelpAuthoritySplitAndPrecedence({
+ bmadDir,
+ runtimeMarkdownPath: path.join(bmadDir, 'core', 'tasks', 'help.md'),
+ sidecarSourcePath: EXEMPLAR_HELP_SIDECAR_SOURCE_PATH,
+ sourceMarkdownSourcePath: EXEMPLAR_HELP_SOURCE_MARKDOWN_SOURCE_PATH,
+ runtimeMarkdownSourcePath: `${this.bmadFolderName}/core/tasks/help.md`,
+ });
+ this.helpAuthorityRecords = helpAuthorityValidation.authoritativeRecords;
+ addResult('Authority split', 'ok', helpAuthorityValidation.authoritativePresenceKey);
+
+ // Generate clean config.yaml files for each installed module
+ await this.generateModuleConfigs(bmadDir, moduleConfigs);
+ addResult('Configurations', 'ok', 'generated');
+
+ // Pre-register manifest files
+ const cfgDir = path.join(bmadDir, '_config');
+ this.installedFiles.add(path.join(cfgDir, 'manifest.yaml'));
+ this.installedFiles.add(path.join(cfgDir, 'workflow-manifest.csv'));
+ this.installedFiles.add(path.join(cfgDir, 'agent-manifest.csv'));
+ this.installedFiles.add(path.join(cfgDir, 'task-manifest.csv'));
+ this.installedFiles.add(path.join(cfgDir, 'canonical-aliases.csv'));
+ this.installedFiles.add(path.join(cfgDir, 'bmad-help-catalog-pipeline.csv'));
+ this.installedFiles.add(path.join(cfgDir, 'bmad-help-command-label-report.csv'));
+
+ // Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes
+ // This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv
+ message('Generating manifests...');
+ const manifestGen = new this.ManifestGenerator();
+
+ const allModulesForManifest = config._quickUpdate
+ ? config._existingModules || allModules || []
+ : config._preserveModules
+ ? [...allModules, ...config._preserveModules]
+ : allModules || [];
+
+ let modulesForCsvPreserve;
+ if (config._quickUpdate) {
+ modulesForCsvPreserve = config._existingModules || allModules || [];
+ } else {
+ modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules;
+ }
+
+ const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, [...this.installedFiles], {
+ ides: config.ides || [],
+ preservedModules: modulesForCsvPreserve,
+ helpAuthorityRecords: this.helpAuthorityRecords || [],
+ taskAuthorityRecords: [
+ ...(this.helpAuthorityRecords || []),
+ ...(this.shardDocAuthorityRecords || []),
+ ...(this.indexDocsAuthorityRecords || []),
+ ],
+ });
+
+ addResult(
+ 'Manifests',
+ 'ok',
+ `${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools`,
+ );
+
+ // Merge help catalogs
+ message('Generating help catalog...');
+ await this.mergeModuleHelpCatalogs(bmadDir);
+ addResult('Help catalog', 'ok');
+
+ return 'Configurations generated';
+ }
+
+ async buildHelpValidationOptions({ projectDir, bmadDir }) {
+ const exportSkillProjectionPath = path.join(projectDir, '.agents', 'skills', 'bmad-help', 'SKILL.md');
+ const hasCodexExportDerivationRecords =
+ Array.isArray(this.codexExportDerivationRecords) && this.codexExportDerivationRecords.length > 0;
+ const requireExportSkillProjection = hasCodexExportDerivationRecords || (await fs.pathExists(exportSkillProjectionPath));
+
+ return {
+ projectDir,
+ bmadDir,
+ bmadFolderName: this.bmadFolderName || BMAD_FOLDER_NAME,
+ helpAuthorityRecords: this.helpAuthorityRecords || [],
+ helpCatalogPipelineRows: this.helpCatalogPipelineRows || [],
+ helpCatalogCommandLabelReportRows: this.helpCatalogCommandLabelReportRows || [],
+ codexExportDerivationRecords: this.codexExportDerivationRecords || [],
+ requireExportSkillProjection,
+ };
+ }
+
+ async buildShardDocValidationOptions({ projectDir, bmadDir }) {
+ return {
+ projectDir,
+ bmadDir,
+ bmadFolderName: this.bmadFolderName || BMAD_FOLDER_NAME,
+ shardDocAuthorityRecords: this.shardDocAuthorityRecords || [],
+ helpCatalogCommandLabelReportRows: this.helpCatalogCommandLabelReportRows || [],
+ };
+ }
+
+ async buildIndexDocsValidationOptions({ projectDir, bmadDir }) {
+ return {
+ projectDir,
+ bmadDir,
+ bmadFolderName: this.bmadFolderName || BMAD_FOLDER_NAME,
+ indexDocsAuthorityRecords: this.indexDocsAuthorityRecords || [],
+ helpCatalogCommandLabelReportRows: this.helpCatalogCommandLabelReportRows || [],
+ };
}
/**
@@ -1098,54 +1289,15 @@ class Installer {
// Configuration generation task (stored as named reference for deferred execution)
const configTask = {
title: 'Generating configurations',
- task: async (message) => {
- // Generate clean config.yaml files for each installed module
- await this.generateModuleConfigs(bmadDir, moduleConfigs);
- addResult('Configurations', 'ok', 'generated');
-
- // Pre-register manifest files
- const cfgDir = path.join(bmadDir, '_config');
- this.installedFiles.add(path.join(cfgDir, 'manifest.yaml'));
- this.installedFiles.add(path.join(cfgDir, 'workflow-manifest.csv'));
- this.installedFiles.add(path.join(cfgDir, 'agent-manifest.csv'));
- this.installedFiles.add(path.join(cfgDir, 'task-manifest.csv'));
-
- // Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes
- // This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv
- message('Generating manifests...');
- const manifestGen = new ManifestGenerator();
-
- const allModulesForManifest = config._quickUpdate
- ? config._existingModules || allModules || []
- : config._preserveModules
- ? [...allModules, ...config._preserveModules]
- : allModules || [];
-
- let modulesForCsvPreserve;
- if (config._quickUpdate) {
- modulesForCsvPreserve = config._existingModules || allModules || [];
- } else {
- modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules;
- }
-
- const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, [...this.installedFiles], {
- ides: config.ides || [],
- preservedModules: modulesForCsvPreserve,
- });
-
- addResult(
- 'Manifests',
- 'ok',
- `${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools`,
- );
-
- // Merge help catalogs
- message('Generating help catalog...');
- await this.mergeModuleHelpCatalogs(bmadDir);
- addResult('Help catalog', 'ok');
-
- return 'Configurations generated';
- },
+ task: async (message) =>
+ this.runConfigurationGenerationTask({
+ message,
+ bmadDir,
+ moduleConfigs,
+ config,
+ allModules,
+ addResult,
+ }),
};
installTasks.push(configTask);
@@ -1173,6 +1325,7 @@ class Installer {
// Resolution is now available via closure-scoped taskResolution
const resolution = taskResolution;
+ this.codexExportDerivationRecords = [];
// ─────────────────────────────────────────────────────────────────────────
// IDE SETUP: Keep as spinner since it may prompt for user input
@@ -1217,6 +1370,9 @@ class Installer {
}
if (setupResult.success) {
+ if (Array.isArray(setupResult.exportDerivationRecords) && setupResult.exportDerivationRecords.length > 0) {
+ this.codexExportDerivationRecords = [...setupResult.exportDerivationRecords];
+ }
addResult(ide, 'ok', setupResult.detail || '');
} else {
addResult(ide, 'error', setupResult.error || 'failed');
@@ -1242,6 +1398,44 @@ class Installer {
// ─────────────────────────────────────────────────────────────────────────
const postIdeTasks = [];
+ postIdeTasks.push({
+ title: 'Generating validation artifacts',
+ task: async (message) => {
+ message('Generating deterministic help validation artifact suite...');
+ const validationOptions = await this.buildHelpValidationOptions({
+ projectDir,
+ bmadDir,
+ });
+ const validationRun = await this.helpValidationHarness.generateAndValidate(validationOptions);
+ this.latestHelpValidationRun = validationRun;
+ addResult('Help validation artifacts', 'ok', `${validationRun.generatedArtifactCount} artifacts`);
+
+ message('Generating deterministic shard-doc validation artifact suite...');
+ const shardDocValidationOptions = await this.buildShardDocValidationOptions({
+ projectDir,
+ bmadDir,
+ });
+ const shardDocValidationRun = await this.shardDocValidationHarness.generateAndValidate(shardDocValidationOptions);
+ this.latestShardDocValidationRun = shardDocValidationRun;
+ addResult('Shard-doc validation artifacts', 'ok', `${shardDocValidationRun.generatedArtifactCount} artifacts`);
+
+ 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`;
+ },
+ });
+
// File restoration task (only for updates)
if (
config._isUpdate &&
@@ -1690,6 +1884,140 @@ class Installer {
/**
* Private: Create directory structure
*/
+ resolveCanonicalIdFromAuthorityRecords({ authorityRecords, authoritySourcePath, fallbackCanonicalId }) {
+ const normalizedAuthoritySourcePath = String(authoritySourcePath || '')
+ .trim()
+ .replaceAll('\\', '/');
+ const normalizedFallbackCanonicalId = String(fallbackCanonicalId || '').trim();
+ const records = Array.isArray(authorityRecords) ? authorityRecords : [];
+
+ for (const record of records) {
+ if (!record || typeof record !== 'object') {
+ continue;
+ }
+
+ const recordCanonicalId = String(record.canonicalId || '').trim();
+ const recordAuthoritySourceType = String(record.authoritySourceType || '').trim();
+ const recordAuthoritySourcePath = String(record.authoritySourcePath || '')
+ .trim()
+ .replaceAll('\\', '/');
+ const recordType = String(record.recordType || '').trim();
+
+ if (
+ recordType === 'metadata-authority' &&
+ recordAuthoritySourceType === 'sidecar' &&
+ recordAuthoritySourcePath === normalizedAuthoritySourcePath &&
+ recordCanonicalId.length > 0
+ ) {
+ return recordCanonicalId;
+ }
+ }
+
+ return normalizedFallbackCanonicalId;
+ }
+
+ isExemplarHelpCatalogRow({ moduleName, name, workflowFile, command, canonicalId }) {
+ if (moduleName !== 'core') return false;
+
+ const normalizedName = String(name || '')
+ .trim()
+ .toLowerCase();
+ const normalizedWorkflowFile = String(workflowFile || '')
+ .trim()
+ .replaceAll('\\', '/')
+ .toLowerCase();
+ const normalizedCommand = String(command || '')
+ .trim()
+ .toLowerCase()
+ .replace(/^\/+/, '');
+ const normalizedCanonicalId = String(canonicalId || '')
+ .trim()
+ .toLowerCase()
+ .replace(/^\/+/, '');
+
+ const hasExemplarWorkflowPath = normalizedWorkflowFile.endsWith('/core/tasks/help.md');
+ const hasExemplarIdentity =
+ normalizedName === 'bmad-help' || normalizedCommand === normalizedCanonicalId || normalizedCommand === 'bmad-help';
+
+ return hasExemplarWorkflowPath && hasExemplarIdentity;
+ }
+
+ buildHelpCatalogRowWithAgentInfo(row, fallback, agentInfo) {
+ const agentName = String(row['agent-name'] || fallback.agentName || '').trim();
+ const agentData = agentInfo.get(agentName) || { command: '', displayName: '', title: '' };
+
+ return [
+ row.module || fallback.module || '',
+ row.phase || fallback.phase || '',
+ row.name || fallback.name || '',
+ row.code || fallback.code || '',
+ row.sequence || fallback.sequence || '',
+ row['workflow-file'] || fallback.workflowFile || '',
+ row.command || fallback.command || '',
+ row.required || fallback.required || 'false',
+ agentName,
+ row['agent-command'] || agentData.command,
+ row['agent-display-name'] || agentData.displayName,
+ row['agent-title'] || agentData.title,
+ row.options || fallback.options || '',
+ row.description || fallback.description || '',
+ row['output-location'] || fallback.outputLocation || '',
+ row.outputs || fallback.outputs || '',
+ ];
+ }
+
+ isCommandLabelCandidate({ workflowFile, name, rawCommandValue, canonicalId, legacyName, workflowFileContractPath, nameCandidates = [] }) {
+ const normalizedWorkflowFile = String(workflowFile || '')
+ .trim()
+ .replaceAll('\\', '/')
+ .toLowerCase();
+ const normalizedName = String(name || '')
+ .trim()
+ .toLowerCase();
+ const normalizedCanonicalId = String(canonicalId || '')
+ .trim()
+ .toLowerCase();
+ const normalizedLegacyName = String(legacyName || '')
+ .trim()
+ .toLowerCase();
+ const normalizedCommandValue = String(rawCommandValue || '')
+ .trim()
+ .toLowerCase()
+ .replace(/^\/+/, '');
+
+ const normalizedWorkflowFileContractPath = String(workflowFileContractPath || '')
+ .trim()
+ .replaceAll('\\', '/')
+ .toLowerCase();
+ const workflowMarker = '/core/tasks/';
+ const markerIndex = normalizedWorkflowFileContractPath.indexOf(workflowMarker);
+ const workflowSuffix = markerIndex === -1 ? normalizedWorkflowFileContractPath : normalizedWorkflowFileContractPath.slice(markerIndex);
+ const hasWorkflowMatch = workflowSuffix.length > 0 && normalizedWorkflowFile.endsWith(workflowSuffix);
+
+ const normalizedNameCandidates = (Array.isArray(nameCandidates) ? nameCandidates : [])
+ .map((candidate) =>
+ String(candidate || '')
+ .trim()
+ .toLowerCase(),
+ )
+ .filter((candidate) => candidate.length > 0);
+ const matchesNameCandidate = normalizedNameCandidates.includes(normalizedName);
+ const isCanonicalCommand = normalizedCanonicalId.length > 0 && normalizedCommandValue === normalizedCanonicalId;
+ const isLegacyCommand = normalizedLegacyName.length > 0 && normalizedCommandValue === normalizedLegacyName;
+
+ return hasWorkflowMatch && (matchesNameCandidate || isCanonicalCommand || isLegacyCommand);
+ }
+
+ async writeCsvArtifact(filePath, columns, rows) {
+ const csvLines = [columns.join(',')];
+ for (const row of rows || []) {
+ const csvRow = columns.map((column) => this.escapeCSVField(Object.prototype.hasOwnProperty.call(row, column) ? row[column] : ''));
+ csvLines.push(csvRow.join(','));
+ }
+ await fs.writeFile(filePath, csvLines.join('\n'), 'utf8');
+ this.installedFiles.add(filePath);
+ }
+
/**
* Merge all module-help.csv files into a single bmad-help.csv
* Scans all installed modules for module-help.csv and merges them
@@ -1701,6 +2029,53 @@ class Installer {
const allRows = [];
const headerRow =
'module,phase,name,code,sequence,workflow-file,command,required,agent-name,agent-command,agent-display-name,agent-title,options,description,output-location,outputs';
+ this.helpCatalogPipelineRows = [];
+ this.helpCatalogCommandLabelReportRows = [];
+
+ const sidecarAwareExemplar = await buildSidecarAwareExemplarHelpRow({
+ helpAuthorityRecords: this.helpAuthorityRecords || [],
+ bmadFolderName: this.bmadFolderName || BMAD_FOLDER_NAME,
+ });
+ const shardDocCanonicalId = this.resolveCanonicalIdFromAuthorityRecords({
+ authorityRecords: this.shardDocAuthorityRecords || [],
+ authoritySourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH,
+ fallbackCanonicalId: 'bmad-shard-doc',
+ });
+ const indexDocsCanonicalId = this.resolveCanonicalIdFromAuthorityRecords({
+ authorityRecords: this.indexDocsAuthorityRecords || [],
+ authoritySourcePath: EXEMPLAR_INDEX_DOCS_SIDECAR_SOURCE_PATH,
+ fallbackCanonicalId: 'bmad-index-docs',
+ });
+ const commandLabelContracts = [
+ {
+ canonicalId: sidecarAwareExemplar.canonicalId,
+ legacyName: sidecarAwareExemplar.legacyName,
+ displayedCommandLabel: sidecarAwareExemplar.displayedCommandLabel,
+ authoritySourceType: sidecarAwareExemplar.authoritySourceType,
+ authoritySourcePath: sidecarAwareExemplar.authoritySourcePath,
+ workflowFilePath: sidecarAwareExemplar.row['workflow-file'],
+ nameCandidates: [sidecarAwareExemplar.row.name],
+ },
+ {
+ canonicalId: shardDocCanonicalId,
+ legacyName: 'shard-doc',
+ displayedCommandLabel: renderDisplayedCommandLabel(shardDocCanonicalId),
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH,
+ workflowFilePath: EXEMPLAR_SHARD_DOC_WORKFLOW_FILE_PATH,
+ nameCandidates: ['shard document', 'shard-doc'],
+ },
+ {
+ canonicalId: indexDocsCanonicalId,
+ legacyName: 'index-docs',
+ displayedCommandLabel: renderDisplayedCommandLabel(indexDocsCanonicalId),
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: EXEMPLAR_INDEX_DOCS_SIDECAR_SOURCE_PATH,
+ workflowFilePath: EXEMPLAR_INDEX_DOCS_WORKFLOW_FILE_PATH,
+ nameCandidates: ['index docs', 'index-docs'],
+ },
+ ];
+ let exemplarRowWritten = false;
// Load agent manifest for agent info lookup
const agentManifestPath = path.join(bmadDir, '_config', 'agent-manifest.csv');
@@ -1795,29 +2170,62 @@ class Installer {
// If module column is empty, set it to this module's name (except for core which stays empty for universal tools)
const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || '';
- // Lookup agent info
- const cleanAgentName = agentName ? agentName.trim() : '';
- const agentData = agentInfo.get(cleanAgentName) || { command: '', displayName: '', title: '' };
+ const isExemplarRow = this.isExemplarHelpCatalogRow({
+ moduleName,
+ name,
+ workflowFile,
+ command,
+ canonicalId: sidecarAwareExemplar.canonicalId,
+ });
- // Build new row with agent info
- const newRow = [
- finalModule,
- phase || '',
- name || '',
- code || '',
- sequence || '',
- workflowFile || '',
- command || '',
- required || 'false',
- cleanAgentName,
- agentData.command,
- agentData.displayName,
- agentData.title,
- options || '',
- description || '',
- outputLocation || '',
- outputs || '',
- ];
+ const fallbackRow = {
+ module: finalModule,
+ phase: phase || '',
+ name: name || '',
+ code: code || '',
+ sequence: sequence || '',
+ workflowFile: workflowFile || '',
+ command: command || '',
+ required: required || 'false',
+ agentName: agentName || '',
+ options: options || '',
+ description: description || '',
+ outputLocation: outputLocation || '',
+ outputs: outputs || '',
+ };
+
+ let newRow;
+ if (isExemplarRow) {
+ if (exemplarRowWritten) {
+ continue;
+ }
+
+ newRow = this.buildHelpCatalogRowWithAgentInfo(sidecarAwareExemplar.row, fallbackRow, agentInfo);
+ exemplarRowWritten = true;
+ } else {
+ newRow = this.buildHelpCatalogRowWithAgentInfo(
+ {
+ module: finalModule,
+ phase: phase || '',
+ name: name || '',
+ code: code || '',
+ sequence: sequence || '',
+ 'workflow-file': workflowFile || '',
+ command: command || '',
+ required: required || 'false',
+ 'agent-name': agentName || '',
+ 'agent-command': '',
+ 'agent-display-name': '',
+ 'agent-title': '',
+ options: options || '',
+ description: description || '',
+ 'output-location': outputLocation || '',
+ outputs: outputs || '',
+ },
+ fallbackRow,
+ agentInfo,
+ );
+ }
allRows.push(newRow.map((c) => this.escapeCSVField(c)).join(','));
}
@@ -1832,6 +2240,30 @@ class Installer {
}
}
+ if (!exemplarRowWritten) {
+ const injectedExemplarRow = this.buildHelpCatalogRowWithAgentInfo(
+ sidecarAwareExemplar.row,
+ {
+ module: 'core',
+ phase: sidecarAwareExemplar.row.phase,
+ name: sidecarAwareExemplar.row.name,
+ code: sidecarAwareExemplar.row.code,
+ sequence: sidecarAwareExemplar.row.sequence,
+ workflowFile: sidecarAwareExemplar.row['workflow-file'],
+ command: sidecarAwareExemplar.row.command,
+ required: sidecarAwareExemplar.row.required,
+ agentName: sidecarAwareExemplar.row['agent-name'],
+ options: sidecarAwareExemplar.row.options,
+ description: sidecarAwareExemplar.row.description,
+ outputLocation: sidecarAwareExemplar.row['output-location'],
+ outputs: sidecarAwareExemplar.row.outputs,
+ },
+ agentInfo,
+ );
+ allRows.push(injectedExemplarRow.map((c) => this.escapeCSVField(c)).join(','));
+ exemplarRowWritten = true;
+ }
+
// Sort by module, then phase, then sequence
allRows.sort((a, b) => {
const colsA = this.parseCSVLine(a);
@@ -1857,17 +2289,156 @@ class Installer {
return seqA - seqB;
});
+ const commandLabelRowsFromMergedCatalog = [];
+ for (const row of allRows) {
+ const columns = this.parseCSVLine(row);
+ const workflowFile = String(columns[5] || '').trim();
+ const name = String(columns[2] || '').trim();
+ const rawCommandValue = String(columns[6] || '').trim();
+ if (!rawCommandValue) {
+ continue;
+ }
+
+ for (const contract of commandLabelContracts) {
+ const isContractCandidate = this.isCommandLabelCandidate({
+ workflowFile,
+ name,
+ rawCommandValue,
+ canonicalId: contract.canonicalId,
+ legacyName: contract.legacyName,
+ workflowFileContractPath: contract.workflowFilePath,
+ nameCandidates: contract.nameCandidates,
+ });
+ if (isContractCandidate) {
+ const displayedCommandLabel = renderDisplayedCommandLabel(rawCommandValue);
+ commandLabelRowsFromMergedCatalog.push({
+ surface: `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/bmad-help.csv`,
+ canonicalId: contract.canonicalId,
+ rawCommandValue,
+ displayedCommandLabel,
+ normalizedDisplayedLabel: normalizeDisplayedCommandLabel(displayedCommandLabel),
+ authoritySourceType: contract.authoritySourceType,
+ authoritySourcePath: contract.authoritySourcePath,
+ });
+ break;
+ }
+ }
+ }
+
+ const commandLabelRowCountByCanonicalId = new Map(commandLabelContracts.map((contract) => [contract.canonicalId, 0]));
+ for (const row of commandLabelRowsFromMergedCatalog) {
+ commandLabelRowCountByCanonicalId.set(row.canonicalId, (commandLabelRowCountByCanonicalId.get(row.canonicalId) || 0) + 1);
+ }
+ const exemplarRowCount = commandLabelRowCountByCanonicalId.get(sidecarAwareExemplar.canonicalId) || 0;
+
+ this.helpCatalogPipelineRows = sidecarAwareExemplar.pipelineStageRows.map((row) => ({
+ ...row,
+ rowCountForStageCanonicalId: exemplarRowCount,
+ stageStatus: exemplarRowCount === 1 ? 'PASS' : 'FAIL',
+ status: exemplarRowCount === 1 ? 'PASS' : 'FAIL',
+ }));
+ this.helpCatalogCommandLabelReportRows = commandLabelRowsFromMergedCatalog.map((row) => ({
+ ...row,
+ rowCountForCanonicalId: commandLabelRowCountByCanonicalId.get(row.canonicalId) || 0,
+ status: (commandLabelRowCountByCanonicalId.get(row.canonicalId) || 0) === 1 ? 'PASS' : 'FAIL',
+ }));
+
+ const commandLabelContractFailures = new Map();
+ for (const contract of commandLabelContracts) {
+ const commandLabelContractResult = evaluateExemplarCommandLabelReportRows(this.helpCatalogCommandLabelReportRows, {
+ canonicalId: contract.canonicalId,
+ displayedCommandLabel: contract.displayedCommandLabel,
+ authoritySourceType: contract.authoritySourceType,
+ authoritySourcePath: contract.authoritySourcePath,
+ });
+ if (!commandLabelContractResult.valid) {
+ commandLabelContractFailures.set(contract.canonicalId, commandLabelContractResult.reason);
+ }
+ }
+
+ if (commandLabelContractFailures.size > 0) {
+ this.helpCatalogPipelineRows = this.helpCatalogPipelineRows.map((row) => ({
+ ...row,
+ stageStatus: 'FAIL',
+ status: 'FAIL',
+ }));
+ this.helpCatalogCommandLabelReportRows = this.helpCatalogCommandLabelReportRows.map((row) => ({
+ ...row,
+ status: 'FAIL',
+ failureReason: commandLabelContractFailures.get(row.canonicalId) || row.failureReason || '',
+ }));
+
+ const commandLabelFailureSummary = [...commandLabelContractFailures.entries()]
+ .sort(([leftCanonicalId], [rightCanonicalId]) => leftCanonicalId.localeCompare(rightCanonicalId))
+ .map(([canonicalId, reason]) => `${canonicalId}:${reason}`)
+ .join('|');
+
+ const commandLabelError = new Error(
+ `${HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED}: ${commandLabelFailureSummary}`,
+ );
+ commandLabelError.code = HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED;
+ commandLabelError.detail = commandLabelFailureSummary;
+ throw commandLabelError;
+ }
+
// Write merged catalog
const outputDir = path.join(bmadDir, '_config');
await fs.ensureDir(outputDir);
const outputPath = path.join(outputDir, 'bmad-help.csv');
+ const helpCatalogPipelinePath = path.join(outputDir, 'bmad-help-catalog-pipeline.csv');
+ const commandLabelReportPath = path.join(outputDir, 'bmad-help-command-label-report.csv');
const mergedContent = [headerRow, ...allRows].join('\n');
+ validateHelpCatalogCompatibilitySurface(mergedContent, {
+ sourcePath: `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/bmad-help.csv`,
+ });
await fs.writeFile(outputPath, mergedContent, 'utf8');
// Track the installed file
this.installedFiles.add(outputPath);
+ await this.writeCsvArtifact(
+ helpCatalogPipelinePath,
+ [
+ 'stage',
+ 'artifactPath',
+ 'rowIdentity',
+ 'canonicalId',
+ 'sourcePath',
+ 'rowCountForStageCanonicalId',
+ 'commandValue',
+ 'expectedCommandValue',
+ 'descriptionValue',
+ 'expectedDescriptionValue',
+ 'descriptionAuthoritySourceType',
+ 'descriptionAuthoritySourcePath',
+ 'commandAuthoritySourceType',
+ 'commandAuthoritySourcePath',
+ 'issuerOwnerClass',
+ 'issuingComponent',
+ 'issuingComponentBindingEvidence',
+ 'stageStatus',
+ 'status',
+ ],
+ this.helpCatalogPipelineRows,
+ );
+ await this.writeCsvArtifact(
+ commandLabelReportPath,
+ [
+ 'surface',
+ 'canonicalId',
+ 'rawCommandValue',
+ 'displayedCommandLabel',
+ 'normalizedDisplayedLabel',
+ 'rowCountForCanonicalId',
+ 'authoritySourceType',
+ 'authoritySourcePath',
+ 'status',
+ 'failureReason',
+ ],
+ this.helpCatalogCommandLabelReportRows,
+ );
+
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
await prompts.log.message(` Generated bmad-help.csv: ${allRows.length} workflows`);
}
diff --git a/tools/cli/installers/lib/core/manifest-generator.js b/tools/cli/installers/lib/core/manifest-generator.js
index 06e2e3f4b..54938492f 100644
--- a/tools/cli/installers/lib/core/manifest-generator.js
+++ b/tools/cli/installers/lib/core/manifest-generator.js
@@ -5,9 +5,116 @@ const crypto = require('node:crypto');
const csv = require('csv-parse/sync');
const { getSourcePath, getModulePath } = require('../../../lib/project-root');
const prompts = require('../../../lib/prompts');
+const {
+ EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH,
+ LOCKED_EXEMPLAR_ALIAS_ROWS,
+ normalizeAndResolveExemplarAlias,
+} = require('./help-alias-normalizer');
+const { validateTaskManifestCompatibilitySurface } = require('./projection-compatibility-validator');
// Load package.json for version info
const packageJson = require('../../../../../package.json');
+const DEFAULT_EXEMPLAR_HELP_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/help/skill-manifest.yaml';
+const DEFAULT_EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml';
+const DEFAULT_EXEMPLAR_INDEX_DOCS_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml';
+const CANONICAL_ALIAS_TABLE_COLUMNS = Object.freeze([
+ 'canonicalId',
+ 'alias',
+ 'aliasType',
+ 'authoritySourceType',
+ 'authoritySourcePath',
+ 'rowIdentity',
+ 'normalizedAliasValue',
+ 'rawIdentityHasLeadingSlash',
+ 'resolutionEligibility',
+]);
+const LOCKED_CANONICAL_ALIAS_TABLE_EXEMPLAR_ROWS = Object.freeze([
+ Object.freeze({
+ canonicalId: 'bmad-help',
+ alias: 'bmad-help',
+ aliasType: 'canonical-id',
+ rowIdentity: 'alias-row:bmad-help:canonical-id',
+ normalizedAliasValue: 'bmad-help',
+ rawIdentityHasLeadingSlash: false,
+ resolutionEligibility: 'canonical-id-only',
+ }),
+ Object.freeze({
+ canonicalId: 'bmad-help',
+ alias: 'help',
+ aliasType: 'legacy-name',
+ rowIdentity: 'alias-row:bmad-help:legacy-name',
+ normalizedAliasValue: 'help',
+ rawIdentityHasLeadingSlash: false,
+ resolutionEligibility: 'legacy-name-only',
+ }),
+ Object.freeze({
+ canonicalId: 'bmad-help',
+ alias: '/bmad-help',
+ aliasType: 'slash-command',
+ rowIdentity: 'alias-row:bmad-help:slash-command',
+ normalizedAliasValue: 'bmad-help',
+ rawIdentityHasLeadingSlash: true,
+ resolutionEligibility: 'slash-command-only',
+ }),
+]);
+const LOCKED_CANONICAL_ALIAS_TABLE_SHARD_DOC_ROWS = Object.freeze([
+ Object.freeze({
+ canonicalId: 'bmad-shard-doc',
+ alias: 'bmad-shard-doc',
+ aliasType: 'canonical-id',
+ rowIdentity: 'alias-row:bmad-shard-doc:canonical-id',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: false,
+ resolutionEligibility: 'canonical-id-only',
+ }),
+ Object.freeze({
+ canonicalId: 'bmad-shard-doc',
+ alias: 'shard-doc',
+ aliasType: 'legacy-name',
+ rowIdentity: 'alias-row:bmad-shard-doc:legacy-name',
+ normalizedAliasValue: 'shard-doc',
+ rawIdentityHasLeadingSlash: false,
+ resolutionEligibility: 'legacy-name-only',
+ }),
+ Object.freeze({
+ canonicalId: 'bmad-shard-doc',
+ alias: '/bmad-shard-doc',
+ aliasType: 'slash-command',
+ rowIdentity: 'alias-row:bmad-shard-doc:slash-command',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: true,
+ resolutionEligibility: 'slash-command-only',
+ }),
+]);
+const LOCKED_CANONICAL_ALIAS_TABLE_INDEX_DOCS_ROWS = Object.freeze([
+ Object.freeze({
+ canonicalId: 'bmad-index-docs',
+ alias: 'bmad-index-docs',
+ aliasType: 'canonical-id',
+ rowIdentity: 'alias-row:bmad-index-docs:canonical-id',
+ normalizedAliasValue: 'bmad-index-docs',
+ rawIdentityHasLeadingSlash: false,
+ resolutionEligibility: 'canonical-id-only',
+ }),
+ Object.freeze({
+ canonicalId: 'bmad-index-docs',
+ alias: 'index-docs',
+ aliasType: 'legacy-name',
+ rowIdentity: 'alias-row:bmad-index-docs:legacy-name',
+ normalizedAliasValue: 'index-docs',
+ rawIdentityHasLeadingSlash: false,
+ resolutionEligibility: 'legacy-name-only',
+ }),
+ Object.freeze({
+ canonicalId: 'bmad-index-docs',
+ alias: '/bmad-index-docs',
+ aliasType: 'slash-command',
+ rowIdentity: 'alias-row:bmad-index-docs:slash-command',
+ normalizedAliasValue: 'bmad-index-docs',
+ rawIdentityHasLeadingSlash: true,
+ resolutionEligibility: 'slash-command-only',
+ }),
+]);
/**
* Generates manifest files for installed workflows, agents, and tasks
@@ -21,6 +128,74 @@ class ManifestGenerator {
this.modules = [];
this.files = [];
this.selectedIdes = [];
+ this.includeConvertedShardDocAliasRows = null;
+ this.includeConvertedIndexDocsAliasRows = null;
+ }
+
+ normalizeTaskAuthorityRecords(records) {
+ if (!Array.isArray(records)) return [];
+
+ const normalized = [];
+ for (const record of records) {
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
+ continue;
+ }
+
+ const canonicalId = String(record.canonicalId ?? '').trim();
+ const authoritySourceType = String(record.authoritySourceType ?? '').trim();
+ const authoritySourcePath = String(record.authoritySourcePath ?? '').trim();
+ const sourcePath = String(record.sourcePath ?? '')
+ .trim()
+ .replaceAll('\\', '/');
+ const recordType = String(record.recordType ?? '').trim();
+
+ if (!canonicalId || !authoritySourceType || !authoritySourcePath || !sourcePath || !recordType) {
+ continue;
+ }
+
+ normalized.push({
+ recordType,
+ canonicalId,
+ authoritySourceType,
+ authoritySourcePath,
+ sourcePath,
+ });
+ }
+
+ normalized.sort((left, right) => {
+ const leftKey = `${left.canonicalId}|${left.recordType}|${left.authoritySourceType}|${left.authoritySourcePath}|${left.sourcePath}`;
+ const rightKey = `${right.canonicalId}|${right.recordType}|${right.authoritySourceType}|${right.authoritySourcePath}|${right.sourcePath}`;
+ return leftKey.localeCompare(rightKey);
+ });
+
+ return normalized;
+ }
+
+ buildTaskAuthorityProjectionIndex(records) {
+ const projectionIndex = new Map();
+ for (const record of records) {
+ if (!record || record.recordType !== 'metadata-authority' || record.authoritySourceType !== 'sidecar') {
+ continue;
+ }
+
+ const sourceMatch = String(record.sourcePath)
+ .replaceAll('\\', '/')
+ .match(/\/src\/([^/]+)\/tasks\/([^/.]+)\.(?:md|xml)$/i);
+ if (!sourceMatch) {
+ continue;
+ }
+
+ const moduleName = sourceMatch[1];
+ const taskName = sourceMatch[2];
+ projectionIndex.set(`${moduleName}:${taskName}`, {
+ legacyName: taskName,
+ canonicalId: record.canonicalId,
+ authoritySourceType: record.authoritySourceType,
+ authoritySourcePath: record.authoritySourcePath,
+ });
+ }
+
+ return projectionIndex;
}
/**
@@ -34,6 +209,65 @@ class ManifestGenerator {
return text.trim().replaceAll(/\s+/g, ' '); // Normalize all whitespace (including newlines) to single space
}
+ /**
+ * Normalize authority records emitted by help authority validation so they can
+ * be written into downstream artifacts deterministically.
+ * @param {Array