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} records - Raw authority records + * @returns {Array} Normalized and sorted records + */ + async normalizeHelpAuthorityRecords(records) { + if (!Array.isArray(records)) return []; + + const normalized = []; + const canonicalAliasTablePath = this.bmadDir ? path.join(this.bmadDir, '_config', 'canonical-aliases.csv') : ''; + const hasCanonicalAliasTable = canonicalAliasTablePath ? await fs.pathExists(canonicalAliasTablePath) : false; + const canonicalAliasSourcePath = hasCanonicalAliasTable + ? `${this.bmadFolderName || '_bmad'}/_config/canonical-aliases.csv` + : EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH; + + for (const record of records) { + if (!record || typeof record !== 'object' || Array.isArray(record)) { + continue; + } + + const rawCanonicalIdentity = String(record.canonicalId ?? '').trim(); + const authoritySourceType = String(record.authoritySourceType ?? '').trim(); + const authoritySourcePath = String(record.authoritySourcePath ?? '').trim(); + const sourcePath = String(record.sourcePath ?? '').trim(); + const recordType = String(record.recordType ?? '').trim(); + + if (!rawCanonicalIdentity || !authoritySourceType || !authoritySourcePath || !sourcePath) { + continue; + } + + const canonicalIdentityResolution = await normalizeAndResolveExemplarAlias(rawCanonicalIdentity, { + fieldPath: 'canonicalId', + sourcePath: authoritySourcePath, + aliasTablePath: hasCanonicalAliasTable ? canonicalAliasTablePath : undefined, + aliasRows: hasCanonicalAliasTable ? undefined : LOCKED_EXEMPLAR_ALIAS_ROWS, + aliasTableSourcePath: canonicalAliasSourcePath, + }); + const canonicalId = canonicalIdentityResolution.postAliasCanonicalId; + + normalized.push({ + recordType, + canonicalId, + authoritativePresenceKey: `capability:${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; + } + /** * Generate all manifests for the installation * @param {string} bmadDir - _bmad @@ -75,6 +309,18 @@ class ManifestGenerator { throw new TypeError('ManifestGenerator expected `options.ides` to be an array.'); } + this.helpAuthorityRecords = await this.normalizeHelpAuthorityRecords(options.helpAuthorityRecords); + const taskAuthorityInput = Object.prototype.hasOwnProperty.call(options, 'taskAuthorityRecords') + ? options.taskAuthorityRecords + : options.helpAuthorityRecords; + this.taskAuthorityRecords = this.normalizeTaskAuthorityRecords(taskAuthorityInput); + this.includeConvertedShardDocAliasRows = Object.prototype.hasOwnProperty.call(options, 'includeConvertedShardDocAliasRows') + ? options.includeConvertedShardDocAliasRows === true + : null; + this.includeConvertedIndexDocsAliasRows = Object.prototype.hasOwnProperty.call(options, 'includeConvertedIndexDocsAliasRows') + ? options.includeConvertedIndexDocsAliasRows === true + : null; + // Filter out any undefined/null values from IDE list this.selectedIdes = resolvedIdes.filter((ide) => ide && typeof ide === 'string'); @@ -96,6 +342,7 @@ class ManifestGenerator { await this.writeWorkflowManifest(cfgDir), await this.writeAgentManifest(cfgDir), await this.writeTaskManifest(cfgDir), + await this.writeCanonicalAliasManifest(cfgDir), await this.writeToolManifest(cfgDir), await this.writeFilesManifest(cfgDir), ]; @@ -630,6 +877,12 @@ class ManifestGenerator { ides: this.selectedIdes, }; + if (this.helpAuthorityRecords.length > 0) { + manifest.helpAuthority = { + records: this.helpAuthorityRecords, + }; + } + // Clean the manifest to remove any non-serializable values const cleanManifest = structuredClone(manifest); @@ -842,22 +1095,46 @@ class ManifestGenerator { async writeTaskManifest(cfgDir) { const csvPath = path.join(cfgDir, 'task-manifest.csv'); const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`; + const compatibilitySurfacePath = `${this.bmadFolderName || '_bmad'}/_config/task-manifest.csv`; + const taskAuthorityRecords = Array.isArray(this.taskAuthorityRecords) + ? this.taskAuthorityRecords + : this.normalizeTaskAuthorityRecords(this.helpAuthorityRecords); + const taskAuthorityProjectionIndex = this.buildTaskAuthorityProjectionIndex(taskAuthorityRecords); // Read existing manifest to preserve entries const existingEntries = new Map(); if (await fs.pathExists(csvPath)) { const content = await fs.readFile(csvPath, 'utf8'); + validateTaskManifestCompatibilitySurface(content, { + sourcePath: compatibilitySurfacePath, + allowLegacyPrefixOnly: true, + }); const records = csv.parse(content, { columns: true, skip_empty_lines: true, }); for (const record of records) { - existingEntries.set(`${record.module}:${record.name}`, record); + if (!record?.module || !record?.name) { + continue; + } + + existingEntries.set(`${record.module}:${record.name}`, { + name: record.name, + displayName: record.displayName, + description: record.description, + module: record.module, + path: record.path, + standalone: record.standalone, + legacyName: record.legacyName || record.name, + canonicalId: record.canonicalId || '', + authoritySourceType: record.authoritySourceType || '', + authoritySourcePath: record.authoritySourcePath || '', + }); } } - // Create CSV header with standalone column - let csvContent = 'name,displayName,description,module,path,standalone\n'; + // Create CSV header with compatibility-prefix columns followed by additive canonical-identity columns. + let csvContent = 'name,displayName,description,module,path,standalone,legacyName,canonicalId,authoritySourceType,authoritySourcePath\n'; // Combine existing and new tasks const allTasks = new Map(); @@ -870,6 +1147,9 @@ class ManifestGenerator { // Add/update new tasks for (const task of this.tasks) { const key = `${task.module}:${task.name}`; + const previousRecord = allTasks.get(key); + const authorityProjection = taskAuthorityProjectionIndex.get(key); + allTasks.set(key, { name: task.name, displayName: task.displayName, @@ -877,11 +1157,17 @@ class ManifestGenerator { module: task.module, path: task.path, standalone: task.standalone, + legacyName: authorityProjection ? authorityProjection.legacyName : previousRecord?.legacyName || task.name, + canonicalId: authorityProjection ? authorityProjection.canonicalId : previousRecord?.canonicalId || '', + authoritySourceType: authorityProjection ? authorityProjection.authoritySourceType : previousRecord?.authoritySourceType || '', + authoritySourcePath: authorityProjection ? authorityProjection.authoritySourcePath : previousRecord?.authoritySourcePath || '', }); } - // Write all tasks - for (const [, record] of allTasks) { + // Write all tasks in deterministic order. + const sortedTaskKeys = [...allTasks.keys()].sort((left, right) => left.localeCompare(right)); + for (const taskKey of sortedTaskKeys) { + const record = allTasks.get(taskKey); const row = [ escapeCsv(record.name), escapeCsv(record.displayName), @@ -889,14 +1175,176 @@ class ManifestGenerator { escapeCsv(record.module), escapeCsv(record.path), escapeCsv(record.standalone), + escapeCsv(record.legacyName || record.name), + escapeCsv(record.canonicalId || ''), + escapeCsv(record.authoritySourceType || ''), + escapeCsv(record.authoritySourcePath || ''), ].join(','); csvContent += row + '\n'; } + validateTaskManifestCompatibilitySurface(csvContent, { + sourcePath: compatibilitySurfacePath, + }); + await fs.writeFile(csvPath, csvContent); return csvPath; } + resolveExemplarAliasAuthorityRecord() { + const sidecarAuthorityRecord = Array.isArray(this.helpAuthorityRecords) + ? this.helpAuthorityRecords.find( + (record) => record?.canonicalId === 'bmad-help' && record?.authoritySourceType === 'sidecar' && record?.authoritySourcePath, + ) + : null; + return { + authoritySourceType: sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourceType : 'sidecar', + authoritySourcePath: sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourcePath : DEFAULT_EXEMPLAR_HELP_SIDECAR_SOURCE_PATH, + }; + } + + resolveShardDocAliasAuthorityRecord() { + const sidecarAuthorityRecord = Array.isArray(this.taskAuthorityRecords) + ? this.taskAuthorityRecords.find( + (record) => record?.canonicalId === 'bmad-shard-doc' && record?.authoritySourceType === 'sidecar' && record?.authoritySourcePath, + ) + : null; + return { + authoritySourceType: sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourceType : 'sidecar', + authoritySourcePath: sidecarAuthorityRecord + ? sidecarAuthorityRecord.authoritySourcePath + : DEFAULT_EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH, + }; + } + + resolveIndexDocsAliasAuthorityRecord() { + const sidecarAuthorityRecord = Array.isArray(this.taskAuthorityRecords) + ? this.taskAuthorityRecords.find( + (record) => record?.canonicalId === 'bmad-index-docs' && record?.authoritySourceType === 'sidecar' && record?.authoritySourcePath, + ) + : null; + return { + authoritySourceType: sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourceType : 'sidecar', + authoritySourcePath: sidecarAuthorityRecord + ? sidecarAuthorityRecord.authoritySourcePath + : DEFAULT_EXEMPLAR_INDEX_DOCS_SIDECAR_SOURCE_PATH, + }; + } + + hasShardDocTaskAuthorityProjection() { + if (!Array.isArray(this.taskAuthorityRecords)) { + return false; + } + + return this.taskAuthorityRecords.some( + (record) => + record?.recordType === 'metadata-authority' && + record?.canonicalId === 'bmad-shard-doc' && + record?.authoritySourceType === 'sidecar' && + String(record?.authoritySourcePath || '').trim().length > 0, + ); + } + + shouldProjectShardDocAliasRows() { + if (this.includeConvertedShardDocAliasRows === true) { + return true; + } + if (this.includeConvertedShardDocAliasRows === false) { + return false; + } + + return this.hasShardDocTaskAuthorityProjection(); + } + + hasIndexDocsTaskAuthorityProjection() { + if (!Array.isArray(this.taskAuthorityRecords)) { + return false; + } + + return this.taskAuthorityRecords.some( + (record) => + record?.recordType === 'metadata-authority' && + record?.canonicalId === 'bmad-index-docs' && + record?.authoritySourceType === 'sidecar' && + String(record?.authoritySourcePath || '').trim().length > 0, + ); + } + + shouldProjectIndexDocsAliasRows() { + if (this.includeConvertedIndexDocsAliasRows === true) { + return true; + } + if (this.includeConvertedIndexDocsAliasRows === false) { + return false; + } + + return this.hasIndexDocsTaskAuthorityProjection(); + } + + buildCanonicalAliasProjectionRows() { + const buildRows = (lockedRows, authorityRecord) => + lockedRows.map((row) => ({ + canonicalId: row.canonicalId, + alias: row.alias, + aliasType: row.aliasType, + authoritySourceType: authorityRecord.authoritySourceType, + authoritySourcePath: authorityRecord.authoritySourcePath, + rowIdentity: row.rowIdentity, + normalizedAliasValue: row.normalizedAliasValue, + rawIdentityHasLeadingSlash: row.rawIdentityHasLeadingSlash, + resolutionEligibility: row.resolutionEligibility, + })); + + const rows = [...buildRows(LOCKED_CANONICAL_ALIAS_TABLE_EXEMPLAR_ROWS, this.resolveExemplarAliasAuthorityRecord())]; + if (this.shouldProjectShardDocAliasRows()) { + rows.push(...buildRows(LOCKED_CANONICAL_ALIAS_TABLE_SHARD_DOC_ROWS, this.resolveShardDocAliasAuthorityRecord())); + } + if (this.shouldProjectIndexDocsAliasRows()) { + rows.push(...buildRows(LOCKED_CANONICAL_ALIAS_TABLE_INDEX_DOCS_ROWS, this.resolveIndexDocsAliasAuthorityRecord())); + } + return rows; + } + + /** + * Write canonical alias table projection CSV. + * @returns {string} Path to the canonical alias projection file + */ + async writeCanonicalAliasManifest(cfgDir) { + const csvPath = path.join(cfgDir, 'canonical-aliases.csv'); + const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`; + const projectedRows = this.buildCanonicalAliasProjectionRows(); + + let csvContent = `${CANONICAL_ALIAS_TABLE_COLUMNS.join(',')}\n`; + for (const row of projectedRows) { + const serializedRow = [ + escapeCsv(row.canonicalId), + escapeCsv(row.alias), + escapeCsv(row.aliasType), + escapeCsv(row.authoritySourceType), + escapeCsv(row.authoritySourcePath), + escapeCsv(row.rowIdentity), + escapeCsv(row.normalizedAliasValue), + escapeCsv(row.rawIdentityHasLeadingSlash), + escapeCsv(row.resolutionEligibility), + ].join(','); + csvContent += `${serializedRow}\n`; + } + + await fs.writeFile(csvPath, csvContent); + + const trackedPath = `${this.bmadFolderName || '_bmad'}/_config/canonical-aliases.csv`; + if (!this.files.some((file) => file.path === trackedPath)) { + this.files.push({ + type: 'config', + name: 'canonical-aliases', + module: '_config', + path: trackedPath, + }); + } + + return csvPath; + } + /** * Write tool manifest CSV * @returns {string} Path to the manifest file diff --git a/tools/cli/installers/lib/core/projection-compatibility-validator.js b/tools/cli/installers/lib/core/projection-compatibility-validator.js new file mode 100644 index 000000000..44e4df2ba --- /dev/null +++ b/tools/cli/installers/lib/core/projection-compatibility-validator.js @@ -0,0 +1,558 @@ +const csv = require('csv-parse/sync'); + +const TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS = Object.freeze(['name', 'displayName', 'description', 'module', 'path', 'standalone']); + +const TASK_MANIFEST_CANONICAL_ADDITIVE_COLUMNS = Object.freeze(['legacyName', 'canonicalId', 'authoritySourceType', 'authoritySourcePath']); + +const HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS = Object.freeze([ + 'module', + 'phase', + 'name', + 'code', + 'sequence', + 'workflow-file', + 'command', + 'required', +]); + +const HELP_CATALOG_CANONICAL_ADDITIVE_COLUMNS = Object.freeze([ + 'agent-name', + 'agent-command', + 'agent-display-name', + 'agent-title', + 'options', + 'description', + 'output-location', + 'outputs', +]); + +const PROJECTION_COMPATIBILITY_ERROR_CODES = Object.freeze({ + TASK_MANIFEST_CSV_PARSE_FAILED: 'ERR_TASK_MANIFEST_COMPAT_PARSE_FAILED', + TASK_MANIFEST_HEADER_PREFIX_MISMATCH: 'ERR_TASK_MANIFEST_COMPAT_HEADER_PREFIX_MISMATCH', + TASK_MANIFEST_HEADER_CANONICAL_MISMATCH: 'ERR_TASK_MANIFEST_COMPAT_HEADER_CANONICAL_MISMATCH', + TASK_MANIFEST_REQUIRED_COLUMN_MISSING: 'ERR_TASK_MANIFEST_COMPAT_REQUIRED_COLUMN_MISSING', + TASK_MANIFEST_ROW_FIELD_EMPTY: 'ERR_TASK_MANIFEST_COMPAT_ROW_FIELD_EMPTY', + HELP_CATALOG_CSV_PARSE_FAILED: 'ERR_HELP_CATALOG_COMPAT_PARSE_FAILED', + HELP_CATALOG_HEADER_PREFIX_MISMATCH: 'ERR_HELP_CATALOG_COMPAT_HEADER_PREFIX_MISMATCH', + HELP_CATALOG_HEADER_CANONICAL_MISMATCH: 'ERR_HELP_CATALOG_COMPAT_HEADER_CANONICAL_MISMATCH', + HELP_CATALOG_REQUIRED_COLUMN_MISSING: 'ERR_HELP_CATALOG_COMPAT_REQUIRED_COLUMN_MISSING', + HELP_CATALOG_EXEMPLAR_ROW_CONTRACT_FAILED: 'ERR_HELP_CATALOG_COMPAT_EXEMPLAR_ROW_CONTRACT_FAILED', + HELP_CATALOG_SHARD_DOC_ROW_CONTRACT_FAILED: 'ERR_HELP_CATALOG_COMPAT_SHARD_DOC_ROW_CONTRACT_FAILED', + HELP_CATALOG_INDEX_DOCS_ROW_CONTRACT_FAILED: 'ERR_HELP_CATALOG_COMPAT_INDEX_DOCS_ROW_CONTRACT_FAILED', + GITHUB_COPILOT_WORKFLOW_FILE_MISSING: 'ERR_GITHUB_COPILOT_HELP_WORKFLOW_FILE_MISSING', + COMMAND_DOC_PARSE_FAILED: 'ERR_COMMAND_DOC_CONSISTENCY_PARSE_FAILED', + COMMAND_DOC_CANONICAL_COMMAND_MISSING: 'ERR_COMMAND_DOC_CONSISTENCY_CANONICAL_COMMAND_MISSING', + COMMAND_DOC_CANONICAL_COMMAND_AMBIGUOUS: 'ERR_COMMAND_DOC_CONSISTENCY_CANONICAL_COMMAND_AMBIGUOUS', + COMMAND_DOC_ALIAS_AMBIGUOUS: 'ERR_COMMAND_DOC_CONSISTENCY_ALIAS_AMBIGUOUS', + COMMAND_DOC_GENERATED_SURFACE_MISMATCH: 'ERR_COMMAND_DOC_CONSISTENCY_GENERATED_SURFACE_MISMATCH', +}); + +class ProjectionCompatibilityError extends Error { + constructor({ code, detail, surface, fieldPath, sourcePath, observedValue, expectedValue }) { + const message = `${code}: ${detail} (surface=${surface}, fieldPath=${fieldPath}, sourcePath=${sourcePath})`; + super(message); + this.name = 'ProjectionCompatibilityError'; + this.code = code; + this.detail = detail; + this.surface = surface; + this.fieldPath = fieldPath; + this.sourcePath = sourcePath; + this.observedValue = observedValue; + this.expectedValue = expectedValue; + this.fullMessage = message; + } +} + +function normalizeSourcePath(value) { + return String(value || '') + .trim() + .replaceAll('\\', '/'); +} + +function normalizeValue(value) { + return String(value ?? '').trim(); +} + +function throwCompatibilityError({ code, detail, surface, fieldPath, sourcePath, observedValue, expectedValue }) { + throw new ProjectionCompatibilityError({ + code, + detail, + surface, + fieldPath, + sourcePath, + observedValue, + expectedValue, + }); +} + +function parseHeaderColumns(csvContent, { code, surface, sourcePath }) { + try { + const parsed = csv.parse(String(csvContent ?? ''), { + to_line: 1, + skip_empty_lines: true, + trim: true, + }); + const headerColumns = Array.isArray(parsed) && parsed.length > 0 ? parsed[0].map(String) : []; + if (headerColumns.length === 0) { + throwCompatibilityError({ + code, + detail: 'CSV surface is missing a header row', + surface, + fieldPath: '
', + sourcePath, + observedValue: '', + expectedValue: 'comma-separated header columns', + }); + } + return headerColumns; + } catch (error) { + if (error instanceof ProjectionCompatibilityError) { + throw error; + } + throwCompatibilityError({ + code, + detail: `Unable to parse CSV header: ${error.message}`, + surface, + fieldPath: '
', + sourcePath, + observedValue: '', + expectedValue: 'valid CSV header', + }); + } +} + +function parseRowsWithHeaders(csvContent, { code, surface, sourcePath }) { + try { + return csv.parse(String(csvContent ?? ''), { + columns: true, + skip_empty_lines: true, + trim: true, + }); + } catch (error) { + throwCompatibilityError({ + code, + detail: `Unable to parse CSV rows: ${error.message}`, + surface, + fieldPath: '', + sourcePath, + observedValue: '', + expectedValue: 'valid CSV rows', + }); + } +} + +function assertLockedColumns({ headerColumns, expectedColumns, offset, code, detail, surface, sourcePath }) { + for (const [index, expectedValue] of expectedColumns.entries()) { + const headerIndex = offset + index; + const observedValue = normalizeValue(headerColumns[headerIndex]); + if (observedValue !== expectedValue) { + throwCompatibilityError({ + code, + detail, + surface, + fieldPath: `header[${headerIndex}]`, + sourcePath, + observedValue: observedValue || '', + expectedValue, + }); + } + } +} + +function assertRequiredColumns({ headerColumns, requiredColumns, code, surface, sourcePath }) { + const headerSet = new Set(headerColumns.map((column) => normalizeValue(column))); + for (const column of requiredColumns) { + if (!headerSet.has(column)) { + throwCompatibilityError({ + code, + detail: 'Required compatibility column is missing from projection surface', + surface, + fieldPath: `header.${column}`, + sourcePath, + observedValue: '', + expectedValue: column, + }); + } + } +} + +function normalizeCommandValue(value) { + return normalizeValue(value).toLowerCase().replace(/^\/+/, ''); +} + +function normalizeWorkflowPath(value) { + return normalizeSourcePath(value).toLowerCase(); +} + +function normalizeDisplayedCommandLabel(value) { + const normalized = normalizeValue(value).toLowerCase().replace(/^\/+/, ''); + return normalized.length > 0 ? `/${normalized}` : ''; +} + +function parseDocumentedSlashCommands(markdownContent, options = {}) { + const sourcePath = normalizeSourcePath(options.sourcePath || 'docs/reference/commands.md'); + const surface = options.surface || 'command-doc-consistency'; + const content = String(markdownContent ?? ''); + const commandPattern = /\|\s*`(\/[^`]+)`\s*\|/g; + const commands = []; + let match; + while ((match = commandPattern.exec(content)) !== null) { + commands.push(normalizeDisplayedCommandLabel(match[1])); + } + + if (commands.length === 0) { + throwCompatibilityError({ + code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_PARSE_FAILED, + detail: 'Unable to find slash-command rows in command reference markdown', + surface, + fieldPath: 'docs.reference.commands', + sourcePath, + observedValue: '', + expectedValue: '| `/bmad-...` |', + }); + } + + return commands; +} + +function validateTaskManifestLoaderEntries(rows, options = {}) { + const surface = options.surface || 'task-manifest-loader'; + const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/task-manifest.csv'); + const headerColumns = Array.isArray(options.headerColumns) ? options.headerColumns : Object.keys(rows?.[0] || {}); + const requiredColumns = ['name', 'module', 'path']; + + assertRequiredColumns({ + headerColumns, + requiredColumns, + code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_REQUIRED_COLUMN_MISSING, + surface, + sourcePath, + }); + + for (let index = 0; index < (Array.isArray(rows) ? rows.length : 0); index += 1) { + const row = rows[index]; + for (const requiredColumn of requiredColumns) { + if (!row || normalizeValue(row[requiredColumn]).length === 0) { + throwCompatibilityError({ + code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_ROW_FIELD_EMPTY, + detail: 'Task-manifest row is missing a required compatibility value', + surface, + fieldPath: `rows[${index}].${requiredColumn}`, + sourcePath, + observedValue: normalizeValue(row ? row[requiredColumn] : '') || '', + expectedValue: 'non-empty string', + }); + } + } + } + + return true; +} + +function validateHelpCatalogLoaderEntries(rows, options = {}) { + const surface = options.surface || 'bmad-help-catalog-loader'; + const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/bmad-help.csv'); + const headerColumns = Array.isArray(options.headerColumns) ? options.headerColumns : Object.keys(rows?.[0] || {}); + const requiredColumns = ['name', 'workflow-file', 'command']; + + assertRequiredColumns({ + headerColumns, + requiredColumns, + code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_REQUIRED_COLUMN_MISSING, + surface, + sourcePath, + }); + + const parsedRows = Array.isArray(rows) ? rows : []; + for (const [index, row] of parsedRows.entries()) { + const rawCommandValue = normalizeValue(row.command); + if (rawCommandValue.length === 0) { + continue; + } + + if (normalizeValue(row['workflow-file']).length === 0) { + throwCompatibilityError({ + code: PROJECTION_COMPATIBILITY_ERROR_CODES.GITHUB_COPILOT_WORKFLOW_FILE_MISSING, + detail: 'Rows with command values must preserve workflow-file for prompt generation loaders', + surface, + fieldPath: `rows[${index}].workflow-file`, + sourcePath, + observedValue: '', + expectedValue: 'non-empty string', + }); + } + } + + const exemplarRows = parsedRows.filter( + (row) => + normalizeCommandValue(row.command) === 'bmad-help' && normalizeWorkflowPath(row['workflow-file']).endsWith('/core/tasks/help.md'), + ); + if (exemplarRows.length !== 1) { + throwCompatibilityError({ + code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_EXEMPLAR_ROW_CONTRACT_FAILED, + detail: 'Exactly one exemplar bmad-help compatibility row is required for help catalog consumers', + surface, + fieldPath: 'rows[*].command', + sourcePath, + observedValue: String(exemplarRows.length), + expectedValue: '1', + }); + } + + const shardDocRows = parsedRows.filter( + (row) => + normalizeCommandValue(row.command) === 'bmad-shard-doc' && + normalizeWorkflowPath(row['workflow-file']).endsWith('/core/tasks/shard-doc.xml'), + ); + if (shardDocRows.length !== 1) { + throwCompatibilityError({ + code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_SHARD_DOC_ROW_CONTRACT_FAILED, + detail: 'Exactly one shard-doc compatibility row is required for help catalog consumers', + surface, + fieldPath: 'rows[*].command', + sourcePath, + observedValue: String(shardDocRows.length), + expectedValue: '1', + }); + } + + const indexDocsRows = parsedRows.filter( + (row) => + normalizeCommandValue(row.command) === 'bmad-index-docs' && + normalizeWorkflowPath(row['workflow-file']).endsWith('/core/tasks/index-docs.xml'), + ); + if (indexDocsRows.length !== 1) { + throwCompatibilityError({ + code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_INDEX_DOCS_ROW_CONTRACT_FAILED, + detail: 'Exactly one index-docs compatibility row is required for help catalog consumers', + surface, + fieldPath: 'rows[*].command', + sourcePath, + observedValue: String(indexDocsRows.length), + expectedValue: '1', + }); + } + + return true; +} + +function validateGithubCopilotHelpLoaderEntries(rows, options = {}) { + const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/bmad-help.csv'); + return validateHelpCatalogLoaderEntries(rows, { + ...options, + sourcePath, + surface: options.surface || 'github-copilot-help-loader', + }); +} + +function validateTaskManifestCompatibilitySurface(csvContent, options = {}) { + const surface = options.surface || 'task-manifest-loader'; + const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/task-manifest.csv'); + const allowLegacyPrefixOnly = options.allowLegacyPrefixOnly === true; + + const headerColumns = parseHeaderColumns(csvContent, { + code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_CSV_PARSE_FAILED, + surface, + sourcePath, + }); + + const isLegacyPrefixOnlyHeader = headerColumns.length === TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS.length; + if (allowLegacyPrefixOnly && isLegacyPrefixOnlyHeader) { + assertLockedColumns({ + headerColumns, + expectedColumns: TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS, + offset: 0, + code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_HEADER_PREFIX_MISMATCH, + detail: 'Task-manifest compatibility-prefix header ordering changed (non-additive change)', + surface, + sourcePath, + }); + + const rows = parseRowsWithHeaders(csvContent, { + code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_CSV_PARSE_FAILED, + surface, + sourcePath, + }); + validateTaskManifestLoaderEntries(rows, { + surface, + sourcePath, + headerColumns, + }); + + return { headerColumns, rows, isLegacyPrefixOnlyHeader: true }; + } + + assertLockedColumns({ + headerColumns, + expectedColumns: TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS, + offset: 0, + code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_HEADER_PREFIX_MISMATCH, + detail: 'Task-manifest compatibility-prefix header ordering changed (non-additive change)', + surface, + sourcePath, + }); + assertLockedColumns({ + headerColumns, + expectedColumns: TASK_MANIFEST_CANONICAL_ADDITIVE_COLUMNS, + offset: TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS.length, + code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_HEADER_CANONICAL_MISMATCH, + detail: 'Task-manifest canonical additive columns must remain appended after compatibility-prefix columns', + surface, + sourcePath, + }); + + const rows = parseRowsWithHeaders(csvContent, { + code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_CSV_PARSE_FAILED, + surface, + sourcePath, + }); + + validateTaskManifestLoaderEntries(rows, { + surface, + sourcePath, + headerColumns, + }); + + return { headerColumns, rows }; +} + +function validateHelpCatalogCompatibilitySurface(csvContent, options = {}) { + const surface = options.surface || 'bmad-help-catalog-loader'; + const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/bmad-help.csv'); + + const headerColumns = parseHeaderColumns(csvContent, { + code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_CSV_PARSE_FAILED, + surface, + sourcePath, + }); + + assertLockedColumns({ + headerColumns, + expectedColumns: HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS, + offset: 0, + code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_HEADER_PREFIX_MISMATCH, + detail: 'Help-catalog compatibility-prefix header ordering changed (non-additive change)', + surface, + sourcePath, + }); + assertLockedColumns({ + headerColumns, + expectedColumns: HELP_CATALOG_CANONICAL_ADDITIVE_COLUMNS, + offset: HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS.length, + code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_HEADER_CANONICAL_MISMATCH, + detail: 'Help-catalog canonical additive columns must remain appended after compatibility-prefix columns', + surface, + sourcePath, + }); + + const rows = parseRowsWithHeaders(csvContent, { + code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_CSV_PARSE_FAILED, + surface, + sourcePath, + }); + + validateHelpCatalogLoaderEntries(rows, { + surface, + sourcePath, + headerColumns, + }); + validateGithubCopilotHelpLoaderEntries(rows, { + sourcePath, + headerColumns, + }); + + return { headerColumns, rows }; +} + +function validateCommandDocSurfaceConsistency(commandDocMarkdown, options = {}) { + const surface = options.surface || 'command-doc-consistency'; + const sourcePath = normalizeSourcePath(options.sourcePath || 'docs/reference/commands.md'); + const canonicalId = normalizeValue(options.canonicalId || 'bmad-shard-doc'); + const expectedDisplayedCommandLabel = normalizeDisplayedCommandLabel(options.expectedDisplayedCommandLabel || '/bmad-shard-doc'); + const disallowedAliasLabels = Array.isArray(options.disallowedAliasLabels) ? options.disallowedAliasLabels : ['/shard-doc']; + const commandLabelRows = Array.isArray(options.commandLabelRows) ? options.commandLabelRows : []; + + const documentedCommands = parseDocumentedSlashCommands(commandDocMarkdown, { + sourcePath, + surface, + }); + const documentedCanonicalMatches = documentedCommands.filter((commandLabel) => commandLabel === expectedDisplayedCommandLabel); + if (documentedCanonicalMatches.length === 0) { + throwCompatibilityError({ + code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_CANONICAL_COMMAND_MISSING, + detail: 'Expected canonical command is missing from command reference markdown', + surface, + fieldPath: 'docs.reference.commands.canonical-command', + sourcePath, + observedValue: '', + expectedValue: expectedDisplayedCommandLabel, + }); + } + if (documentedCanonicalMatches.length > 1) { + throwCompatibilityError({ + code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_CANONICAL_COMMAND_AMBIGUOUS, + detail: 'Canonical command appears multiple times in command reference markdown', + surface, + fieldPath: 'docs.reference.commands.canonical-command', + sourcePath, + observedValue: String(documentedCanonicalMatches.length), + expectedValue: '1', + }); + } + + const normalizedDisallowedAliases = disallowedAliasLabels.map((label) => normalizeDisplayedCommandLabel(label)).filter(Boolean); + const presentDisallowedAlias = normalizedDisallowedAliases.find((label) => documentedCommands.includes(label)); + if (presentDisallowedAlias) { + throwCompatibilityError({ + code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_ALIAS_AMBIGUOUS, + detail: 'Disallowed alias command detected in command reference markdown', + surface, + fieldPath: 'docs.reference.commands.alias-command', + sourcePath, + observedValue: presentDisallowedAlias, + expectedValue: expectedDisplayedCommandLabel, + }); + } + + const generatedCanonicalRows = commandLabelRows.filter((row) => normalizeValue(row.canonicalId) === canonicalId); + const generatedMatchingRows = generatedCanonicalRows.filter( + (row) => normalizeDisplayedCommandLabel(row.displayedCommandLabel) === expectedDisplayedCommandLabel, + ); + if (generatedCanonicalRows.length === 0 || generatedMatchingRows.length !== 1) { + throwCompatibilityError({ + code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_GENERATED_SURFACE_MISMATCH, + detail: 'Generated command-label surface does not match canonical command-doc contract', + surface, + fieldPath: 'generated.command-label-report', + sourcePath: normalizeSourcePath(options.generatedSurfacePath || '_bmad/_config/bmad-help-command-label-report.csv'), + observedValue: + generatedCanonicalRows + .map((row) => normalizeDisplayedCommandLabel(row.displayedCommandLabel)) + .filter(Boolean) + .join('|') || '', + expectedValue: expectedDisplayedCommandLabel, + }); + } + + return { + canonicalId, + expectedDisplayedCommandLabel, + documentedCommands, + generatedCanonicalCommand: expectedDisplayedCommandLabel, + }; +} + +module.exports = { + PROJECTION_COMPATIBILITY_ERROR_CODES, + ProjectionCompatibilityError, + 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, +}; diff --git a/tools/cli/installers/lib/core/shard-doc-authority-validator.js b/tools/cli/installers/lib/core/shard-doc-authority-validator.js new file mode 100644 index 000000000..982d9744a --- /dev/null +++ b/tools/cli/installers/lib/core/shard-doc-authority-validator.js @@ -0,0 +1,363 @@ +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 SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES = Object.freeze({ + SIDECAR_FILE_NOT_FOUND: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_FILE_NOT_FOUND', + SIDECAR_FILENAME_AMBIGUOUS: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_FILENAME_AMBIGUOUS', + SIDECAR_PARSE_FAILED: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_PARSE_FAILED', + SIDECAR_INVALID_METADATA: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_INVALID_METADATA', + SIDECAR_CANONICAL_ID_MISMATCH: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_CANONICAL_ID_MISMATCH', + SOURCE_XML_FILE_NOT_FOUND: 'ERR_SHARD_DOC_AUTHORITY_SOURCE_XML_FILE_NOT_FOUND', + COMPATIBILITY_FILE_NOT_FOUND: 'ERR_SHARD_DOC_AUTHORITY_COMPATIBILITY_FILE_NOT_FOUND', + COMPATIBILITY_PARSE_FAILED: 'ERR_SHARD_DOC_AUTHORITY_COMPATIBILITY_PARSE_FAILED', + COMPATIBILITY_ROW_MISSING: 'ERR_SHARD_DOC_AUTHORITY_COMPATIBILITY_ROW_MISSING', + COMPATIBILITY_ROW_DUPLICATE: 'ERR_SHARD_DOC_AUTHORITY_COMPATIBILITY_ROW_DUPLICATE', + COMMAND_MISMATCH: 'ERR_SHARD_DOC_AUTHORITY_COMMAND_MISMATCH', + DISPLAY_NAME_MISMATCH: 'ERR_SHARD_DOC_AUTHORITY_DISPLAY_NAME_MISMATCH', + DUPLICATE_CANONICAL_COMMAND: 'ERR_SHARD_DOC_AUTHORITY_DUPLICATE_CANONICAL_COMMAND', +}); + +const SHARD_DOC_LOCKED_CANONICAL_ID = 'bmad-shard-doc'; +const SHARD_DOC_LOCKED_AUTHORITATIVE_PRESENCE_KEY = `capability:${SHARD_DOC_LOCKED_CANONICAL_ID}`; + +class ShardDocAuthorityValidationError extends Error { + constructor({ code, detail, fieldPath, sourcePath, observedValue, expectedValue }) { + const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath})`; + super(message); + this.name = 'ShardDocAuthorityValidationError'; + 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 ShardDocAuthorityValidationError({ + 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( + SHARD_DOC_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( + SHARD_DOC_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 !== SHARD_DOC_LOCKED_CANONICAL_ID) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH, + 'Converted shard-doc sidecar canonicalId must remain locked to bmad-shard-doc', + 'canonicalId', + sidecarSourcePath, + normalizedCanonicalId, + SHARD_DOC_LOCKED_CANONICAL_ID, + ); + } + + const normalizedDeclaredSourcePath = normalizeSourcePath(sidecarData.sourcePath); + if (normalizedDeclaredSourcePath !== sourceXmlSourcePath) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA, + 'Sidecar sourcePath must match shard-doc XML source path', + 'sourcePath', + sidecarSourcePath, + normalizedDeclaredSourcePath, + sourceXmlSourcePath, + ); + } +} + +async function parseCompatibilityRows(compatibilityCatalogPath, compatibilityCatalogSourcePath) { + if (!(await fs.pathExists(compatibilityCatalogPath))) { + createValidationError( + SHARD_DOC_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( + SHARD_DOC_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( + SHARD_DOC_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( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_MISSING, + 'Converted shard-doc compatibility row is missing from module-help catalog', + 'workflow-file', + compatibilityCatalogSourcePath, + '', + workflowFilePath, + ); + } + + if (workflowMatches.length > 1) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_DUPLICATE, + 'Converted shard-doc compatibility row appears more than once in module-help catalog', + 'workflow-file', + compatibilityCatalogSourcePath, + workflowMatches.length, + 1, + ); + } + + const canonicalCommandMatches = rows.filter((row) => csvMatchValue(row.command) === SHARD_DOC_LOCKED_CANONICAL_ID); + if (canonicalCommandMatches.length > 1) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.DUPLICATE_CANONICAL_COMMAND, + 'Converted shard-doc canonical command appears in more than one compatibility row', + 'command', + compatibilityCatalogSourcePath, + canonicalCommandMatches.length, + 1, + ); + } + + const shardDocRow = workflowMatches[0]; + const observedCommand = csvMatchValue(shardDocRow.command); + if (!observedCommand || observedCommand !== SHARD_DOC_LOCKED_CANONICAL_ID) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH, + 'Converted shard-doc compatibility command must match locked canonical command bmad-shard-doc', + 'command', + compatibilityCatalogSourcePath, + observedCommand || '', + SHARD_DOC_LOCKED_CANONICAL_ID, + ); + } + + const observedDisplayName = csvMatchValue(shardDocRow.name); + if (observedDisplayName && observedDisplayName !== displayName) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.DISPLAY_NAME_MISMATCH, + 'Converted shard-doc compatibility name must match sidecar displayName when provided', + 'name', + compatibilityCatalogSourcePath, + observedDisplayName, + displayName, + ); + } +} + +function buildShardDocAuthorityRecords({ canonicalId, sidecarSourcePath, sourceXmlSourcePath }) { + return [ + { + recordType: 'metadata-authority', + canonicalId, + authoritativePresenceKey: SHARD_DOC_LOCKED_AUTHORITATIVE_PRESENCE_KEY, + authoritySourceType: 'sidecar', + authoritySourcePath: sidecarSourcePath, + sourcePath: sourceXmlSourcePath, + }, + { + recordType: 'source-body-authority', + canonicalId, + authoritativePresenceKey: SHARD_DOC_LOCKED_AUTHORITATIVE_PRESENCE_KEY, + authoritySourceType: 'source-xml', + authoritySourcePath: sourceXmlSourcePath, + sourcePath: sourceXmlSourcePath, + }, + ]; +} + +async function validateShardDocAuthoritySplitAndPrecedence(options = {}) { + const sourceXmlPath = options.sourceXmlPath || getSourcePath('core', 'tasks', 'shard-doc.xml'); + const compatibilityCatalogPath = options.compatibilityCatalogPath || getSourcePath('core', 'module-help.csv'); + const compatibilityWorkflowFilePath = options.compatibilityWorkflowFilePath || '_bmad/core/tasks/shard-doc.xml'; + + let resolvedMetadataAuthority; + try { + resolvedMetadataAuthority = await resolveSkillMetadataAuthority({ + sourceFilePath: sourceXmlPath, + metadataPath: options.sidecarPath || '', + metadataSourcePath: options.sidecarSourcePath || '', + ambiguousErrorCode: SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS, + }); + } catch (error) { + createValidationError( + error.code || SHARD_DOC_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( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND, + 'Expected shard-doc sidecar metadata file was not found', + '', + sidecarSourcePath, + ); + } + + let sidecarData; + try { + const sidecarRaw = await fs.readFile(sidecarPath, 'utf8'); + sidecarData = yaml.parse(sidecarRaw); + } catch (error) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_PARSE_FAILED, + `YAML parse failure: ${error.message}`, + '', + sidecarSourcePath, + ); + } + + if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) { + createValidationError( + SHARD_DOC_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( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SOURCE_XML_FILE_NOT_FOUND, + 'Expected shard-doc XML source file was not found', + '', + sourceXmlSourcePath, + ); + } + + const compatibilityRows = await parseCompatibilityRows(compatibilityCatalogPath, compatibilityCatalogSourcePath); + validateCompatibilityPrecedence({ + rows: compatibilityRows, + displayName: sidecarData.displayName.trim(), + workflowFilePath: compatibilityWorkflowFilePath, + compatibilityCatalogSourcePath, + }); + + const canonicalId = SHARD_DOC_LOCKED_CANONICAL_ID; + const authoritativeRecords = buildShardDocAuthorityRecords({ + canonicalId, + sidecarSourcePath, + sourceXmlSourcePath, + }); + + return { + canonicalId, + authoritativePresenceKey: SHARD_DOC_LOCKED_AUTHORITATIVE_PRESENCE_KEY, + authoritativeRecords, + checkedSurfaces: [sourceXmlSourcePath, compatibilityCatalogSourcePath], + 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 = { + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES, + SHARD_DOC_LOCKED_CANONICAL_ID, + ShardDocAuthorityValidationError, + buildShardDocAuthorityRecords, + validateShardDocAuthoritySplitAndPrecedence, +}; diff --git a/tools/cli/installers/lib/core/shard-doc-validation-harness.js b/tools/cli/installers/lib/core/shard-doc-validation-harness.js new file mode 100644 index 000000000..665a1934b --- /dev/null +++ b/tools/cli/installers/lib/core/shard-doc-validation-harness.js @@ -0,0 +1,1612 @@ +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 SHARD_DOC_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml'; +const SHARD_DOC_SOURCE_XML_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.xml'; +const SHARD_DOC_EVIDENCE_ISSUER_COMPONENT = 'bmad-fork/tools/cli/installers/lib/core/shard-doc-validation-harness.js'; + +const SHARD_DOC_VALIDATION_ERROR_CODES = Object.freeze({ + REQUIRED_ARTIFACT_MISSING: 'ERR_SHARD_DOC_VALIDATION_REQUIRED_ARTIFACT_MISSING', + METADATA_RESOLUTION_FAILED: 'ERR_SHARD_DOC_VALIDATION_METADATA_RESOLUTION_FAILED', + CSV_SCHEMA_MISMATCH: 'ERR_SHARD_DOC_VALIDATION_CSV_SCHEMA_MISMATCH', + REQUIRED_ROW_MISSING: 'ERR_SHARD_DOC_VALIDATION_REQUIRED_ROW_MISSING', + YAML_SCHEMA_MISMATCH: 'ERR_SHARD_DOC_VALIDATION_YAML_SCHEMA_MISMATCH', + BINDING_EVIDENCE_INVALID: 'ERR_SHARD_DOC_VALIDATION_BINDING_EVIDENCE_INVALID', + COMPATIBILITY_GATE_FAILED: 'ERR_SHARD_DOC_VALIDATION_COMPATIBILITY_GATE_FAILED', + REPLAY_EVIDENCE_INVALID: 'ERR_SHARD_DOC_VALIDATION_REPLAY_EVIDENCE_INVALID', +}); + +const SHARD_DOC_VALIDATION_ARTIFACT_REGISTRY = Object.freeze([ + Object.freeze({ + artifactId: 1, + relativePath: path.join('validation', 'shard-doc', 'shard-doc-sidecar-snapshot.yaml'), + type: 'yaml', + requiredTopLevelKeys: ['schemaVersion', 'canonicalId', 'artifactType', 'module', 'sourcePath', 'displayName', 'description', 'status'], + }), + Object.freeze({ + artifactId: 2, + relativePath: path.join('validation', 'shard-doc', 'shard-doc-authority-records.csv'), + type: 'csv', + columns: [ + 'rowIdentity', + 'recordType', + 'canonicalId', + 'authoritativePresenceKey', + 'authoritySourceType', + 'authoritySourcePath', + 'status', + ], + requiredRowIdentityFields: ['rowIdentity'], + }), + Object.freeze({ + artifactId: 3, + relativePath: path.join('validation', 'shard-doc', 'shard-doc-task-manifest-comparison.csv'), + type: 'csv', + columns: [ + 'surface', + 'sourcePath', + 'name', + 'module', + 'path', + 'legacyName', + 'canonicalId', + 'authoritySourceType', + 'authoritySourcePath', + 'status', + ], + }), + Object.freeze({ + artifactId: 4, + relativePath: path.join('validation', 'shard-doc', 'shard-doc-help-catalog-comparison.csv'), + type: 'csv', + columns: ['surface', 'sourcePath', 'name', 'workflowFile', 'command', 'rowCountForCanonicalCommand', 'status'], + }), + Object.freeze({ + artifactId: 5, + relativePath: path.join('validation', 'shard-doc', 'shard-doc-alias-table.csv'), + type: 'csv', + columns: [ + 'rowIdentity', + 'canonicalId', + 'alias', + 'aliasType', + 'normalizedAliasValue', + 'rawIdentityHasLeadingSlash', + 'resolutionEligibility', + 'authoritySourceType', + 'authoritySourcePath', + 'status', + ], + requiredRowIdentityFields: ['rowIdentity'], + }), + Object.freeze({ + artifactId: 6, + relativePath: path.join('validation', 'shard-doc', 'shard-doc-command-label-report.csv'), + type: 'csv', + columns: [ + 'surface', + 'canonicalId', + 'rawCommandValue', + 'displayedCommandLabel', + 'normalizedDisplayedLabel', + 'rowCountForCanonicalId', + 'authoritySourceType', + 'authoritySourcePath', + 'status', + ], + }), + Object.freeze({ + artifactId: 7, + relativePath: path.join('validation', 'shard-doc', 'shard-doc-duplicate-report.csv'), + type: 'csv', + columns: ['surface', 'canonicalId', 'normalizedVisibleKey', 'matchingRowCount', 'status'], + }), + Object.freeze({ + artifactId: 8, + relativePath: path.join('validation', 'shard-doc', 'shard-doc-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', 'shard-doc', 'shard-doc-compatibility-gates.csv'), + type: 'csv', + columns: ['gateId', 'surface', 'sourcePath', 'status', 'failureCode', 'failureDetail'], + requiredRowIdentityFields: ['gateId'], + }), + Object.freeze({ + artifactId: 10, + relativePath: path.join('validation', 'shard-doc', 'shard-doc-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', 'shard-doc', 'shard-doc-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', 'shard-doc', 'shard-doc-gate-summary.csv'), + type: 'csv', + columns: ['gateId', 'status', 'detail', 'sourcePath'], + requiredRowIdentityFields: ['gateId'], + }), +]); + +class ShardDocValidationHarnessError extends Error { + constructor({ code, detail, artifactId, fieldPath, sourcePath, observedValue, expectedValue }) { + const message = `${code}: ${detail} (artifact=${artifactId}, fieldPath=${fieldPath}, sourcePath=${sourcePath})`; + super(message); + this.name = 'ShardDocValidationHarnessError'; + 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 countShardDocManifestClaimRows(csvContent, runtimeFolder) { + const expectedPath = normalizePath(`${runtimeFolder}/core/tasks/shard-doc.xml`).toLowerCase(); + return parseCsvRows(csvContent).filter((row) => { + return ( + normalizeValue(row.canonicalId) === 'bmad-shard-doc' && + normalizeValue(row.name).toLowerCase() === 'shard-doc' && + normalizeValue(row.module).toLowerCase() === 'core' && + normalizePath(normalizeValue(row.path)).toLowerCase() === expectedPath + ); + }).length; +} + +function countShardDocHelpCatalogClaimRows(csvContent) { + return parseCsvRows(csvContent).filter((row) => { + const command = normalizeValue(row.command).replace(/^\/+/, '').toLowerCase(); + const workflowFile = normalizePath(normalizeValue(row['workflow-file'])).toLowerCase(); + return command === 'bmad-shard-doc' && workflowFile.endsWith('/core/tasks/shard-doc.xml'); + }).length; +} + +class ShardDocValidationHarness { + constructor() { + this.registry = SHARD_DOC_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', 'shard-doc'); + 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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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-shard-doc', + authoritySourceType: 'sidecar', + authoritySourcePath: SHARD_DOC_SIDECAR_SOURCE_PATH, + sourcePath: SHARD_DOC_SOURCE_XML_SOURCE_PATH, + }, + ]; + generator.tasks = perturbed + ? [] + : [ + { + name: 'shard-doc', + displayName: 'Shard Document', + description: 'Split large markdown documents into smaller files by section with an index.', + module: 'core', + path: `${runtimeFolder}/core/tasks/shard-doc.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: countShardDocManifestClaimRows(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: perturbed ? 'shard-doc' : '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(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.shardDocAuthorityRecords = [ + { + recordType: 'metadata-authority', + canonicalId: 'bmad-shard-doc', + authoritySourceType: 'sidecar', + authoritySourcePath: SHARD_DOC_SIDECAR_SOURCE_PATH, + sourcePath: SHARD_DOC_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: countShardDocHelpCatalogClaimRows(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(), 'shard-doc-replay-baseline-')); + const perturbedWorkspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'shard-doc-replay-perturbed-')); + + try { + const baseline = await contract.run({ workspaceRoot: baselineWorkspaceRoot, perturbed: false }); + if (Number(baseline.targetRowCount) <= 0) { + throw new ShardDocValidationHarnessError({ + code: SHARD_DOC_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-shard-doc', + issuerOwnerClass: 'independent-validator', + evidenceIssuerComponent: SHARD_DOC_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/shard-doc/shard-doc-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/shard-doc/shard-doc-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, SHARD_DOC_SOURCE_XML_SOURCE_PATH))) + ? path.join(outputPaths.projectDir, SHARD_DOC_SOURCE_XML_SOURCE_PATH) + : getSourcePath('core', 'tasks', 'shard-doc.xml')); + let resolvedMetadataAuthority; + try { + resolvedMetadataAuthority = await resolveSkillMetadataAuthority({ + sourceFilePath: sourceXmlPath, + metadataPath: options.sidecarPath || '', + projectRoot: outputPaths.projectDir, + ambiguousErrorCode: SHARD_DOC_VALIDATION_ERROR_CODES.METADATA_RESOLUTION_FAILED, + }); + } catch (error) { + throw new ShardDocValidationHarnessError({ + code: SHARD_DOC_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 || SHARD_DOC_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: SHARD_DOC_SIDECAR_SOURCE_PATH, + description: 'shard-doc sidecar metadata authority', + }); + await this.assertRequiredInputSurfaceExists({ + artifactId: 2, + absolutePath: sourceXmlPath, + sourcePath: SHARD_DOC_SOURCE_XML_SOURCE_PATH, + description: 'shard-doc XML source authority', + }); + await this.assertRequiredInputSurfaceExists({ + artifactId: 3, + absolutePath: path.join(bmadDir, '_config', 'task-manifest.csv'), + sourcePath: `${runtimeFolder}/_config/task-manifest.csv`, + description: 'task-manifest projection surface', + }); + await this.assertRequiredInputSurfaceExists({ + artifactId: 4, + absolutePath: path.join(bmadDir, '_config', 'bmad-help.csv'), + sourcePath: `${runtimeFolder}/_config/bmad-help.csv`, + description: 'help-catalog projection surface', + }); + await this.assertRequiredInputSurfaceExists({ + artifactId: 5, + absolutePath: path.join(bmadDir, '_config', 'canonical-aliases.csv'), + sourcePath: `${runtimeFolder}/_config/canonical-aliases.csv`, + description: 'canonical-aliases projection surface', + }); + + const sidecarMetadata = yaml.parse(await fs.readFile(sidecarPath, 'utf8')); + const 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 shardDocTaskRow = this.requireRow({ + rows: taskManifestRows, + predicate: (row) => + normalizeValue(row.module).toLowerCase() === 'core' && + normalizeValue(row.name).toLowerCase() === 'shard-doc' && + normalizeValue(row.canonicalId) === 'bmad-shard-doc', + artifactId: 3, + fieldPath: 'rows[module=core,name=shard-doc,canonicalId=bmad-shard-doc]', + sourcePath: `${runtimeFolder}/_config/task-manifest.csv`, + detail: 'Required shard-doc task-manifest canonical row is missing', + }); + const shardDocHelpRows = helpCatalogRows.filter( + (row) => + normalizeValue(row.command).replace(/^\/+/, '') === 'bmad-shard-doc' && + normalizePath(normalizeValue(row['workflow-file'])).toLowerCase().endsWith('/core/tasks/shard-doc.xml'), + ); + if (shardDocHelpRows.length !== 1) { + throw new ShardDocValidationHarnessError({ + code: SHARD_DOC_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Expected exactly one shard-doc help-catalog command row', + artifactId: 4, + fieldPath: 'rows[*].command', + sourcePath: `${runtimeFolder}/_config/bmad-help.csv`, + observedValue: String(shardDocHelpRows.length), + expectedValue: '1', + }); + } + + const shardDocAliasRows = aliasRows.filter((row) => normalizeValue(row.canonicalId) === 'bmad-shard-doc'); + const requiredAliasTypes = new Set(['canonical-id', 'legacy-name', 'slash-command']); + const observedAliasTypes = new Set(shardDocAliasRows.map((row) => normalizeValue(row.aliasType))); + for (const aliasType of requiredAliasTypes) { + if (!observedAliasTypes.has(aliasType)) { + throw new ShardDocValidationHarnessError({ + code: SHARD_DOC_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Required shard-doc alias type row is missing', + artifactId: 5, + fieldPath: 'rows[*].aliasType', + sourcePath: `${runtimeFolder}/_config/canonical-aliases.csv`, + observedValue: [...observedAliasTypes].join('|') || '', + expectedValue: aliasType, + }); + } + } + + const shardDocCommandLabelRows = commandLabelRows.filter((row) => normalizeValue(row.canonicalId) === 'bmad-shard-doc'); + if (shardDocCommandLabelRows.length !== 1) { + throw new ShardDocValidationHarnessError({ + code: SHARD_DOC_VALIDATION_ERROR_CODES.REQUIRED_ROW_MISSING, + detail: 'Expected exactly one shard-doc command-label row', + artifactId: 6, + fieldPath: 'rows[*].canonicalId', + sourcePath: `${runtimeFolder}/_config/bmad-help-command-label-report.csv`, + observedValue: String(shardDocCommandLabelRows.length), + expectedValue: '1', + }); + } + const shardDocCommandLabelRow = shardDocCommandLabelRows[0]; + + const authorityRecordsInput = Array.isArray(options.shardDocAuthorityRecords) ? options.shardDocAuthorityRecords : []; + const authorityRecords = + authorityRecordsInput.length > 0 + ? authorityRecordsInput.map((record) => ({ + rowIdentity: `authority-record:${normalizeValue(record.recordType || 'unknown')}`, + recordType: normalizeValue(record.recordType), + canonicalId: normalizeValue(record.canonicalId), + authoritativePresenceKey: normalizeValue(record.authoritativePresenceKey), + authoritySourceType: normalizeValue(record.authoritySourceType), + authoritySourcePath: normalizeValue(record.authoritySourcePath), + status: 'PASS', + })) + : [ + { + rowIdentity: 'authority-record:metadata-authority', + recordType: 'metadata-authority', + canonicalId: 'bmad-shard-doc', + authoritativePresenceKey: 'capability:bmad-shard-doc', + authoritySourceType: 'sidecar', + authoritySourcePath: SHARD_DOC_SIDECAR_SOURCE_PATH, + status: 'PASS', + }, + { + rowIdentity: 'authority-record:source-body-authority', + recordType: 'source-body-authority', + canonicalId: 'bmad-shard-doc', + authoritativePresenceKey: 'capability:bmad-shard-doc', + authoritySourceType: 'source-xml', + authoritySourcePath: SHARD_DOC_SOURCE_XML_SOURCE_PATH, + status: 'PASS', + }, + ]; + + // Artifact 1 + const sidecarSnapshot = { + schemaVersion: sidecarMetadata?.schemaVersion ?? 1, + canonicalId: normalizeValue(sidecarMetadata?.canonicalId || 'bmad-shard-doc'), + artifactType: normalizeValue(sidecarMetadata?.artifactType || 'task'), + module: normalizeValue(sidecarMetadata?.module || 'core'), + sourcePath: SHARD_DOC_SIDECAR_SOURCE_PATH, + displayName: normalizeValue(sidecarMetadata?.displayName || 'Shard Document'), + description: normalizeValue( + sidecarMetadata?.description || 'Split large markdown documents into smaller files by section with an index.', + ), + status: 'PASS', + }; + await fs.writeFile(artifactPaths.get(1), yaml.stringify(sidecarSnapshot), 'utf8'); + + // Artifact 2 + await this.writeCsvArtifact(artifactPaths.get(2), this.registry[1].columns, authorityRecords); + + // Artifact 3 + const taskManifestComparisonRows = [ + { + surface: `${runtimeFolder}/_config/task-manifest.csv`, + sourcePath: SHARD_DOC_SOURCE_XML_SOURCE_PATH, + name: normalizeValue(shardDocTaskRow.name || 'shard-doc'), + module: normalizeValue(shardDocTaskRow.module || 'core'), + path: normalizeValue(shardDocTaskRow.path || `${runtimeFolder}/core/tasks/shard-doc.xml`), + legacyName: normalizeValue(shardDocTaskRow.legacyName || 'shard-doc'), + canonicalId: normalizeValue(shardDocTaskRow.canonicalId || 'bmad-shard-doc'), + authoritySourceType: normalizeValue(shardDocTaskRow.authoritySourceType || 'sidecar'), + authoritySourcePath: normalizeValue(shardDocTaskRow.authoritySourcePath || SHARD_DOC_SIDECAR_SOURCE_PATH), + status: 'PASS', + }, + ]; + await this.writeCsvArtifact(artifactPaths.get(3), this.registry[2].columns, taskManifestComparisonRows); + + // Artifact 4 + const shardDocHelpRow = shardDocHelpRows[0]; + const helpCatalogComparisonRows = [ + { + surface: `${runtimeFolder}/_config/bmad-help.csv`, + sourcePath: SHARD_DOC_SOURCE_XML_SOURCE_PATH, + name: normalizeValue(shardDocHelpRow.name || 'Shard Document'), + workflowFile: normalizeValue(shardDocHelpRow['workflow-file'] || '_bmad/core/tasks/shard-doc.xml'), + command: normalizeValue(shardDocHelpRow.command || 'bmad-shard-doc').replace(/^\/+/, ''), + rowCountForCanonicalCommand: String(shardDocHelpRows.length), + status: shardDocHelpRows.length === 1 ? 'PASS' : 'FAIL', + }, + ]; + await this.writeCsvArtifact(artifactPaths.get(4), this.registry[3].columns, helpCatalogComparisonRows); + + // Artifact 5 + const aliasTableRows = shardDocAliasRows.map((row) => ({ + rowIdentity: normalizeValue(row.rowIdentity), + canonicalId: normalizeValue(row.canonicalId), + alias: normalizeValue(row.alias), + aliasType: normalizeValue(row.aliasType), + normalizedAliasValue: normalizeValue(row.normalizedAliasValue), + rawIdentityHasLeadingSlash: normalizeValue(row.rawIdentityHasLeadingSlash), + resolutionEligibility: normalizeValue(row.resolutionEligibility), + authoritySourceType: normalizeValue(row.authoritySourceType || 'sidecar'), + authoritySourcePath: normalizeValue(row.authoritySourcePath || SHARD_DOC_SIDECAR_SOURCE_PATH), + status: 'PASS', + })); + await this.writeCsvArtifact(artifactPaths.get(5), this.registry[4].columns, aliasTableRows); + + // Artifact 6 + const commandLabelReportRows = [ + { + surface: normalizeValue(shardDocCommandLabelRow.surface || `${runtimeFolder}/_config/bmad-help.csv`), + canonicalId: 'bmad-shard-doc', + rawCommandValue: normalizeValue(shardDocCommandLabelRow.rawCommandValue || 'bmad-shard-doc').replace(/^\/+/, ''), + displayedCommandLabel: normalizeValue(shardDocCommandLabelRow.displayedCommandLabel || '/bmad-shard-doc'), + normalizedDisplayedLabel: normalizeDisplayedCommandLabel( + normalizeValue( + shardDocCommandLabelRow.normalizedDisplayedLabel || shardDocCommandLabelRow.displayedCommandLabel || '/bmad-shard-doc', + ), + ), + rowCountForCanonicalId: normalizeValue(shardDocCommandLabelRow.rowCountForCanonicalId || '1'), + authoritySourceType: normalizeValue(shardDocCommandLabelRow.authoritySourceType || 'sidecar'), + authoritySourcePath: normalizeValue(shardDocCommandLabelRow.authoritySourcePath || SHARD_DOC_SIDECAR_SOURCE_PATH), + status: normalizeValue(shardDocCommandLabelRow.status || 'PASS') || 'PASS', + }, + ]; + await this.writeCsvArtifact(artifactPaths.get(6), this.registry[5].columns, commandLabelReportRows); + + // Artifact 7 + const duplicateRows = [ + { + surface: `${runtimeFolder}/_config/bmad-help.csv`, + canonicalId: 'bmad-shard-doc', + normalizedVisibleKey: 'help-catalog-command:/bmad-shard-doc', + matchingRowCount: String(shardDocHelpRows.length), + status: shardDocHelpRows.length === 1 ? 'PASS' : 'FAIL', + }, + ]; + await this.writeCsvArtifact(artifactPaths.get(7), this.registry[6].columns, duplicateRows); + + // Artifact 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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_VALIDATION_ERROR_CODES.REQUIRED_ARTIFACT_MISSING, + detail: 'Required shard-doc 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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_VALIDATION_ERROR_CODES.YAML_SCHEMA_MISMATCH, + detail: 'Required YAML key is missing', + artifactId: artifact.artifactId, + fieldPath: key, + sourcePath: normalizePath(artifact.relativePath), + observedValue: '', + expectedValue: key, + }); + } + } + artifactDataById.set(artifact.artifactId, { type: 'yaml', parsed }); + } + } + + const authorityRows = artifactDataById.get(2)?.rows || []; + this.requireRow({ + rows: authorityRows, + predicate: (row) => + normalizeValue(row.recordType) === 'metadata-authority' && + normalizeValue(row.canonicalId) === 'bmad-shard-doc' && + normalizeValue(row.authoritativePresenceKey) === 'capability:bmad-shard-doc', + artifactId: 2, + fieldPath: 'rows[*].recordType', + sourcePath: normalizePath(this.registry[1].relativePath), + detail: 'Metadata authority record for shard-doc is missing', + }); + this.requireRow({ + rows: authorityRows, + predicate: (row) => + normalizeValue(row.recordType) === 'source-body-authority' && + normalizeValue(row.canonicalId) === 'bmad-shard-doc' && + normalizeValue(row.authoritativePresenceKey) === 'capability:bmad-shard-doc', + artifactId: 2, + fieldPath: 'rows[*].recordType', + sourcePath: normalizePath(this.registry[1].relativePath), + detail: 'Source-body authority record for shard-doc is missing', + }); + + const 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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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) !== SHARD_DOC_EVIDENCE_ISSUER_COMPONENT + ) { + throw new ShardDocValidationHarnessError({ + code: SHARD_DOC_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: SHARD_DOC_EVIDENCE_ISSUER_COMPONENT, + }), + }); + } + if (!normalizeValue(provenanceRow.issuingComponentBindingEvidence)) { + throw new ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 ShardDocValidationHarnessError({ + code: SHARD_DOC_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 = { + SHARD_DOC_VALIDATION_ERROR_CODES, + SHARD_DOC_VALIDATION_ARTIFACT_REGISTRY, + ShardDocValidationHarnessError, + ShardDocValidationHarness, +}; diff --git a/tools/cli/installers/lib/core/sidecar-contract-validator.js b/tools/cli/installers/lib/core/sidecar-contract-validator.js new file mode 100644 index 000000000..e8b056cd2 --- /dev/null +++ b/tools/cli/installers/lib/core/sidecar-contract-validator.js @@ -0,0 +1,621 @@ +const path = require('node:path'); +const fs = require('fs-extra'); +const yaml = require('yaml'); +const { getProjectRoot, getSourcePath } = require('../../../lib/project-root'); + +const HELP_SIDECAR_REQUIRED_FIELDS = Object.freeze([ + 'schemaVersion', + 'canonicalId', + 'artifactType', + 'module', + 'sourcePath', + 'displayName', + 'description', + 'dependencies', +]); + +const SHARD_DOC_SIDECAR_REQUIRED_FIELDS = Object.freeze([...HELP_SIDECAR_REQUIRED_FIELDS]); +const INDEX_DOCS_SIDECAR_REQUIRED_FIELDS = Object.freeze([...HELP_SIDECAR_REQUIRED_FIELDS]); + +const HELP_SIDECAR_ERROR_CODES = Object.freeze({ + FILE_NOT_FOUND: 'ERR_HELP_SIDECAR_FILE_NOT_FOUND', + PARSE_FAILED: 'ERR_HELP_SIDECAR_PARSE_FAILED', + INVALID_ROOT_OBJECT: 'ERR_HELP_SIDECAR_INVALID_ROOT_OBJECT', + REQUIRED_FIELD_MISSING: 'ERR_HELP_SIDECAR_REQUIRED_FIELD_MISSING', + REQUIRED_FIELD_EMPTY: 'ERR_HELP_SIDECAR_REQUIRED_FIELD_EMPTY', + ARTIFACT_TYPE_INVALID: 'ERR_HELP_SIDECAR_ARTIFACT_TYPE_INVALID', + MODULE_INVALID: 'ERR_HELP_SIDECAR_MODULE_INVALID', + DEPENDENCIES_MISSING: 'ERR_HELP_SIDECAR_DEPENDENCIES_MISSING', + DEPENDENCIES_REQUIRES_INVALID: 'ERR_HELP_SIDECAR_DEPENDENCIES_REQUIRES_INVALID', + DEPENDENCIES_REQUIRES_NOT_EMPTY: 'ERR_HELP_SIDECAR_DEPENDENCIES_REQUIRES_NOT_EMPTY', + MAJOR_VERSION_UNSUPPORTED: 'ERR_SIDECAR_MAJOR_VERSION_UNSUPPORTED', + SOURCEPATH_BASENAME_MISMATCH: 'ERR_SIDECAR_SOURCEPATH_BASENAME_MISMATCH', + METADATA_FILENAME_AMBIGUOUS: 'ERR_HELP_SIDECAR_METADATA_FILENAME_AMBIGUOUS', +}); + +const SHARD_DOC_SIDECAR_ERROR_CODES = Object.freeze({ + FILE_NOT_FOUND: 'ERR_SHARD_DOC_SIDECAR_FILE_NOT_FOUND', + PARSE_FAILED: 'ERR_SHARD_DOC_SIDECAR_PARSE_FAILED', + INVALID_ROOT_OBJECT: 'ERR_SHARD_DOC_SIDECAR_INVALID_ROOT_OBJECT', + REQUIRED_FIELD_MISSING: 'ERR_SHARD_DOC_SIDECAR_REQUIRED_FIELD_MISSING', + REQUIRED_FIELD_EMPTY: 'ERR_SHARD_DOC_SIDECAR_REQUIRED_FIELD_EMPTY', + ARTIFACT_TYPE_INVALID: 'ERR_SHARD_DOC_SIDECAR_ARTIFACT_TYPE_INVALID', + MODULE_INVALID: 'ERR_SHARD_DOC_SIDECAR_MODULE_INVALID', + DEPENDENCIES_MISSING: 'ERR_SHARD_DOC_SIDECAR_DEPENDENCIES_MISSING', + DEPENDENCIES_REQUIRES_INVALID: 'ERR_SHARD_DOC_SIDECAR_DEPENDENCIES_REQUIRES_INVALID', + DEPENDENCIES_REQUIRES_NOT_EMPTY: 'ERR_SHARD_DOC_SIDECAR_DEPENDENCIES_REQUIRES_NOT_EMPTY', + MAJOR_VERSION_UNSUPPORTED: 'ERR_SHARD_DOC_SIDECAR_MAJOR_VERSION_UNSUPPORTED', + SOURCEPATH_BASENAME_MISMATCH: 'ERR_SHARD_DOC_SIDECAR_SOURCEPATH_BASENAME_MISMATCH', + METADATA_FILENAME_AMBIGUOUS: 'ERR_SHARD_DOC_SIDECAR_METADATA_FILENAME_AMBIGUOUS', +}); + +const INDEX_DOCS_SIDECAR_ERROR_CODES = Object.freeze({ + FILE_NOT_FOUND: 'ERR_INDEX_DOCS_SIDECAR_FILE_NOT_FOUND', + PARSE_FAILED: 'ERR_INDEX_DOCS_SIDECAR_PARSE_FAILED', + INVALID_ROOT_OBJECT: 'ERR_INDEX_DOCS_SIDECAR_INVALID_ROOT_OBJECT', + REQUIRED_FIELD_MISSING: 'ERR_INDEX_DOCS_SIDECAR_REQUIRED_FIELD_MISSING', + REQUIRED_FIELD_EMPTY: 'ERR_INDEX_DOCS_SIDECAR_REQUIRED_FIELD_EMPTY', + ARTIFACT_TYPE_INVALID: 'ERR_INDEX_DOCS_SIDECAR_ARTIFACT_TYPE_INVALID', + MODULE_INVALID: 'ERR_INDEX_DOCS_SIDECAR_MODULE_INVALID', + DEPENDENCIES_MISSING: 'ERR_INDEX_DOCS_SIDECAR_DEPENDENCIES_MISSING', + DEPENDENCIES_REQUIRES_INVALID: 'ERR_INDEX_DOCS_SIDECAR_DEPENDENCIES_REQUIRES_INVALID', + DEPENDENCIES_REQUIRES_NOT_EMPTY: 'ERR_INDEX_DOCS_SIDECAR_DEPENDENCIES_REQUIRES_NOT_EMPTY', + MAJOR_VERSION_UNSUPPORTED: 'ERR_INDEX_DOCS_SIDECAR_MAJOR_VERSION_UNSUPPORTED', + SOURCEPATH_BASENAME_MISMATCH: 'ERR_INDEX_DOCS_SIDECAR_SOURCEPATH_BASENAME_MISMATCH', + METADATA_FILENAME_AMBIGUOUS: 'ERR_INDEX_DOCS_SIDECAR_METADATA_FILENAME_AMBIGUOUS', +}); + +const HELP_EXEMPLAR_CANONICAL_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md'; +const SHARD_DOC_CANONICAL_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.xml'; +const INDEX_DOCS_CANONICAL_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs.xml'; +const SKILL_METADATA_CANONICAL_FILENAME = 'skill-manifest.yaml'; +const SKILL_METADATA_LEGACY_FILENAMES = Object.freeze(['bmad-config.yaml', 'manifest.yaml']); +const SKILL_METADATA_DERIVATION_MODES = Object.freeze({ + CANONICAL: 'canonical', + LEGACY_FALLBACK: 'legacy-fallback', +}); +const SKILL_METADATA_RESOLUTION_ERROR_CODES = Object.freeze({ + AMBIGUOUS_MATCH: 'ERR_SKILL_METADATA_FILENAME_AMBIGUOUS', +}); +const SIDECAR_SUPPORTED_SCHEMA_MAJOR = 1; + +class SidecarContractError extends Error { + constructor({ code, detail, fieldPath, sourcePath }) { + const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath})`; + super(message); + this.name = 'SidecarContractError'; + this.code = code; + this.detail = detail; + this.fieldPath = fieldPath; + this.sourcePath = sourcePath; + this.fullMessage = message; + } +} + +function normalizeSourcePath(value) { + if (!value) return ''; + return String(value).replaceAll('\\', '/'); +} + +function toProjectRelativePath(filePath, projectRoot = getProjectRoot()) { + const relative = path.relative(projectRoot, filePath); + + if (!relative || relative.startsWith('..')) { + return normalizeSourcePath(path.resolve(filePath)); + } + + return normalizeSourcePath(relative); +} + +function dedupeAndSort(values) { + const normalized = new Set(); + for (const value of values || []) { + const text = normalizeSourcePath(value).trim(); + if (text.length > 0) { + normalized.add(text); + } + } + return [...normalized].sort((left, right) => left.localeCompare(right)); +} + +function hasOwn(obj, key) { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +function isBlankString(value) { + return typeof value !== 'string' || value.trim().length === 0; +} + +function parseSchemaMajorVersion(value) { + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.trunc(value); + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^(\d+)(?:\.\d+)?$/); + if (!match) return null; + return Number.parseInt(match[1], 10); + } + + return null; +} + +function classifyMetadataFilename(filename) { + const normalizedFilename = String(filename || '') + .trim() + .toLowerCase(); + if (normalizedFilename === SKILL_METADATA_CANONICAL_FILENAME) { + return SKILL_METADATA_DERIVATION_MODES.CANONICAL; + } + if (SKILL_METADATA_LEGACY_FILENAMES.includes(normalizedFilename) || normalizedFilename.endsWith('.artifact.yaml')) { + return SKILL_METADATA_DERIVATION_MODES.LEGACY_FALLBACK; + } + return SKILL_METADATA_DERIVATION_MODES.LEGACY_FALLBACK; +} + +function getMetadataStemFromSourcePath(sourcePathValue) { + const normalizedSourcePath = normalizeSourcePath(sourcePathValue).trim(); + if (!normalizedSourcePath) return ''; + + const sourceBasename = path.posix.basename(normalizedSourcePath); + if (!sourceBasename) return ''; + + const sourceExt = path.posix.extname(sourceBasename); + const baseWithoutExt = sourceExt ? sourceBasename.slice(0, -sourceExt.length) : sourceBasename; + return baseWithoutExt.trim(); +} + +function buildSkillMetadataResolutionPlan({ sourceFilePath, projectRoot = getProjectRoot() }) { + const absoluteSourceFilePath = path.resolve(sourceFilePath); + const sourceDirAbsolutePath = path.dirname(absoluteSourceFilePath); + const metadataStem = getMetadataStemFromSourcePath(absoluteSourceFilePath); + const skillFolderAbsolutePath = path.join(sourceDirAbsolutePath, metadataStem); + const canonicalTargetAbsolutePath = path.join(skillFolderAbsolutePath, SKILL_METADATA_CANONICAL_FILENAME); + + const candidateGroups = [ + { + precedenceToken: SKILL_METADATA_CANONICAL_FILENAME, + derivationMode: SKILL_METADATA_DERIVATION_MODES.CANONICAL, + // Canonical authority is per-skill only; root task-folder canonical files are not eligible. + explicitCandidates: [canonicalTargetAbsolutePath], + wildcardDirectories: [], + }, + { + precedenceToken: 'bmad-config.yaml', + derivationMode: SKILL_METADATA_DERIVATION_MODES.LEGACY_FALLBACK, + explicitCandidates: [path.join(skillFolderAbsolutePath, 'bmad-config.yaml'), path.join(sourceDirAbsolutePath, 'bmad-config.yaml')], + wildcardDirectories: [], + }, + { + precedenceToken: 'manifest.yaml', + derivationMode: SKILL_METADATA_DERIVATION_MODES.LEGACY_FALLBACK, + explicitCandidates: [path.join(skillFolderAbsolutePath, 'manifest.yaml'), path.join(sourceDirAbsolutePath, 'manifest.yaml')], + wildcardDirectories: [], + }, + { + precedenceToken: `${metadataStem}.artifact.yaml`, + derivationMode: SKILL_METADATA_DERIVATION_MODES.LEGACY_FALLBACK, + explicitCandidates: [ + path.join(sourceDirAbsolutePath, `${metadataStem}.artifact.yaml`), + path.join(skillFolderAbsolutePath, `${metadataStem}.artifact.yaml`), + ], + wildcardDirectories: [], + }, + ]; + + return { + metadataStem, + canonicalTargetAbsolutePath, + canonicalTargetSourcePath: toProjectRelativePath(canonicalTargetAbsolutePath, projectRoot), + candidateGroups, + }; +} + +async function resolveCandidateGroupMatches(group = {}) { + const explicitMatches = []; + for (const candidatePath of group.explicitCandidates || []) { + if (await fs.pathExists(candidatePath)) { + explicitMatches.push(path.resolve(candidatePath)); + } + } + + const wildcardMatches = []; + for (const wildcardDirectory of group.wildcardDirectories || []) { + if (!(await fs.pathExists(wildcardDirectory))) { + continue; + } + const directoryEntries = await fs.readdir(wildcardDirectory, { withFileTypes: true }); + for (const entry of directoryEntries) { + if (!entry.isFile()) continue; + const filename = String(entry.name || '').trim(); + if (!filename.toLowerCase().endsWith('.artifact.yaml')) continue; + wildcardMatches.push(path.join(wildcardDirectory, filename)); + } + } + + return dedupeAndSort([...explicitMatches, ...wildcardMatches]); +} + +async function resolveSkillMetadataAuthority({ + sourceFilePath, + metadataPath = '', + metadataSourcePath = '', + projectRoot = getProjectRoot(), + ambiguousErrorCode = SKILL_METADATA_RESOLUTION_ERROR_CODES.AMBIGUOUS_MATCH, +}) { + const resolutionPlan = buildSkillMetadataResolutionPlan({ + sourceFilePath, + projectRoot, + }); + + const resolvedMetadataPath = String(metadataPath || '').trim(); + if (resolvedMetadataPath.length > 0) { + const resolvedAbsolutePath = path.resolve(resolvedMetadataPath); + const resolvedFilename = path.posix.basename(normalizeSourcePath(resolvedAbsolutePath)); + return { + resolvedAbsolutePath, + resolvedSourcePath: normalizeSourcePath(metadataSourcePath || toProjectRelativePath(resolvedAbsolutePath, projectRoot)), + resolvedFilename, + canonicalTargetFilename: SKILL_METADATA_CANONICAL_FILENAME, + canonicalTargetSourcePath: resolutionPlan.canonicalTargetSourcePath, + derivationMode: classifyMetadataFilename(resolvedFilename), + precedenceToken: resolvedFilename, + }; + } + + for (const group of resolutionPlan.candidateGroups) { + const matches = await resolveCandidateGroupMatches(group); + if (matches.length === 0) { + continue; + } + + if (matches.length > 1) { + throw new SidecarContractError({ + code: ambiguousErrorCode, + detail: `metadata filename resolution is ambiguous for precedence "${group.precedenceToken}": ${matches.join('|')}`, + fieldPath: '', + sourcePath: resolutionPlan.canonicalTargetSourcePath, + }); + } + + const resolvedAbsolutePath = matches[0]; + const resolvedFilename = path.posix.basename(normalizeSourcePath(resolvedAbsolutePath)); + return { + resolvedAbsolutePath, + resolvedSourcePath: normalizeSourcePath(toProjectRelativePath(resolvedAbsolutePath, projectRoot)), + resolvedFilename, + canonicalTargetFilename: SKILL_METADATA_CANONICAL_FILENAME, + canonicalTargetSourcePath: resolutionPlan.canonicalTargetSourcePath, + derivationMode: group.derivationMode, + precedenceToken: group.precedenceToken, + }; + } + + return { + resolvedAbsolutePath: '', + resolvedSourcePath: '', + resolvedFilename: '', + canonicalTargetFilename: SKILL_METADATA_CANONICAL_FILENAME, + canonicalTargetSourcePath: resolutionPlan.canonicalTargetSourcePath, + derivationMode: '', + precedenceToken: '', + }; +} + +function getExpectedLegacyArtifactBasenameFromSourcePath(sourcePathValue) { + const normalized = normalizeSourcePath(sourcePathValue).trim(); + if (!normalized) return ''; + + const sourceBasename = path.posix.basename(normalized); + if (!sourceBasename) return ''; + + const sourceExt = path.posix.extname(sourceBasename); + const baseWithoutExt = sourceExt ? sourceBasename.slice(0, -sourceExt.length) : sourceBasename; + if (!baseWithoutExt) return ''; + + return `${baseWithoutExt}.artifact.yaml`; +} + +function createValidationError(code, fieldPath, sourcePath, detail) { + throw new SidecarContractError({ + code, + fieldPath, + sourcePath, + detail, + }); +} + +function validateSidecarContractData(sidecarData, options) { + const { + sourcePath, + requiredFields, + requiredNonEmptyStringFields, + errorCodes, + expectedArtifactType, + expectedModule, + expectedCanonicalSourcePath, + missingDependenciesDetail, + dependenciesObjectDetail, + dependenciesRequiresArrayDetail, + dependenciesRequiresNotEmptyDetail, + artifactTypeDetail, + moduleDetail, + requiresMustBeEmpty, + } = options; + + if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) { + createValidationError(errorCodes.INVALID_ROOT_OBJECT, '', sourcePath, 'Sidecar root must be a YAML mapping object.'); + } + + for (const field of requiredFields) { + if (!hasOwn(sidecarData, field)) { + if (field === 'dependencies') { + createValidationError(errorCodes.DEPENDENCIES_MISSING, field, sourcePath, missingDependenciesDetail); + } + + createValidationError(errorCodes.REQUIRED_FIELD_MISSING, field, sourcePath, `Missing required sidecar field "${field}".`); + } + } + + for (const field of requiredNonEmptyStringFields) { + if (isBlankString(sidecarData[field])) { + createValidationError( + errorCodes.REQUIRED_FIELD_EMPTY, + field, + sourcePath, + `Required sidecar field "${field}" must be a non-empty string.`, + ); + } + } + + const schemaMajorVersion = parseSchemaMajorVersion(sidecarData.schemaVersion); + if (schemaMajorVersion !== SIDECAR_SUPPORTED_SCHEMA_MAJOR) { + createValidationError(errorCodes.MAJOR_VERSION_UNSUPPORTED, 'schemaVersion', sourcePath, 'sidecar schema major version is unsupported'); + } + + if (sidecarData.artifactType !== expectedArtifactType) { + createValidationError(errorCodes.ARTIFACT_TYPE_INVALID, 'artifactType', sourcePath, artifactTypeDetail); + } + + if (sidecarData.module !== expectedModule) { + createValidationError(errorCodes.MODULE_INVALID, 'module', sourcePath, moduleDetail); + } + + const dependencies = sidecarData.dependencies; + if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) { + createValidationError(errorCodes.DEPENDENCIES_MISSING, 'dependencies', sourcePath, dependenciesObjectDetail); + } + + if (!hasOwn(dependencies, 'requires') || !Array.isArray(dependencies.requires)) { + createValidationError(errorCodes.DEPENDENCIES_REQUIRES_INVALID, 'dependencies.requires', sourcePath, dependenciesRequiresArrayDetail); + } + + if (requiresMustBeEmpty && dependencies.requires.length > 0) { + createValidationError( + errorCodes.DEPENDENCIES_REQUIRES_NOT_EMPTY, + 'dependencies.requires', + sourcePath, + dependenciesRequiresNotEmptyDetail, + ); + } + + const normalizedDeclaredSourcePath = normalizeSourcePath(sidecarData.sourcePath); + const sidecarBasename = path.posix.basename(normalizeSourcePath(sourcePath)).toLowerCase(); + const expectedLegacyArtifactBasename = getExpectedLegacyArtifactBasenameFromSourcePath(normalizedDeclaredSourcePath).toLowerCase(); + const allowedMetadataBasenames = new Set([SKILL_METADATA_CANONICAL_FILENAME, ...SKILL_METADATA_LEGACY_FILENAMES]); + if (expectedLegacyArtifactBasename.length > 0) { + allowedMetadataBasenames.add(expectedLegacyArtifactBasename); + } + + const sourcePathMismatch = normalizedDeclaredSourcePath !== expectedCanonicalSourcePath; + const basenameMismatch = !allowedMetadataBasenames.has(sidecarBasename); + + if (sourcePathMismatch || basenameMismatch) { + createValidationError( + errorCodes.SOURCEPATH_BASENAME_MISMATCH, + 'sourcePath', + sourcePath, + 'sidecar basename does not match sourcePath basename', + ); + } +} + +function validateHelpSidecarContractData(sidecarData, options = {}) { + const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/help/skill-manifest.yaml'); + validateSidecarContractData(sidecarData, { + sourcePath, + requiredFields: HELP_SIDECAR_REQUIRED_FIELDS, + requiredNonEmptyStringFields: ['canonicalId', 'sourcePath', 'displayName', 'description'], + errorCodes: HELP_SIDECAR_ERROR_CODES, + expectedArtifactType: 'task', + expectedModule: 'core', + expectedCanonicalSourcePath: HELP_EXEMPLAR_CANONICAL_SOURCE_PATH, + missingDependenciesDetail: 'Exemplar sidecar requires an explicit dependencies block.', + dependenciesObjectDetail: 'Exemplar sidecar requires an explicit dependencies object.', + dependenciesRequiresArrayDetail: 'Exemplar dependencies.requires must be an array.', + dependenciesRequiresNotEmptyDetail: 'help exemplar requires explicit zero dependencies: dependencies.requires must be [].', + artifactTypeDetail: 'help exemplar requires artifactType to equal "task".', + moduleDetail: 'help exemplar requires module to equal "core".', + requiresMustBeEmpty: true, + }); +} + +function validateShardDocSidecarContractData(sidecarData, options = {}) { + const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/shard-doc/skill-manifest.yaml'); + validateSidecarContractData(sidecarData, { + sourcePath, + requiredFields: SHARD_DOC_SIDECAR_REQUIRED_FIELDS, + requiredNonEmptyStringFields: ['canonicalId', 'sourcePath', 'displayName', 'description'], + errorCodes: SHARD_DOC_SIDECAR_ERROR_CODES, + expectedArtifactType: 'task', + expectedModule: 'core', + expectedCanonicalSourcePath: SHARD_DOC_CANONICAL_SOURCE_PATH, + missingDependenciesDetail: 'Shard-doc sidecar requires an explicit dependencies block.', + dependenciesObjectDetail: 'Shard-doc sidecar requires an explicit dependencies object.', + dependenciesRequiresArrayDetail: 'Shard-doc dependencies.requires must be an array.', + dependenciesRequiresNotEmptyDetail: 'Shard-doc contract requires explicit zero dependencies: dependencies.requires must be [].', + artifactTypeDetail: 'Shard-doc contract requires artifactType to equal "task".', + moduleDetail: 'Shard-doc contract requires module to equal "core".', + requiresMustBeEmpty: true, + }); +} + +function validateIndexDocsSidecarContractData(sidecarData, options = {}) { + const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/index-docs/skill-manifest.yaml'); + validateSidecarContractData(sidecarData, { + sourcePath, + requiredFields: INDEX_DOCS_SIDECAR_REQUIRED_FIELDS, + requiredNonEmptyStringFields: ['canonicalId', 'sourcePath', 'displayName', 'description'], + errorCodes: INDEX_DOCS_SIDECAR_ERROR_CODES, + expectedArtifactType: 'task', + expectedModule: 'core', + expectedCanonicalSourcePath: INDEX_DOCS_CANONICAL_SOURCE_PATH, + missingDependenciesDetail: 'Index-docs sidecar requires an explicit dependencies block.', + dependenciesObjectDetail: 'Index-docs sidecar requires an explicit dependencies object.', + dependenciesRequiresArrayDetail: 'Index-docs dependencies.requires must be an array.', + dependenciesRequiresNotEmptyDetail: 'Index-docs contract requires explicit zero dependencies: dependencies.requires must be [].', + artifactTypeDetail: 'Index-docs contract requires artifactType to equal "task".', + moduleDetail: 'Index-docs contract requires module to equal "core".', + requiresMustBeEmpty: true, + }); +} + +async function validateHelpSidecarContractFile(sidecarPath = '', options = {}) { + const sourceFilePath = options.sourceFilePath || getSourcePath('core', 'tasks', 'help.md'); + const resolvedMetadataAuthority = await resolveSkillMetadataAuthority({ + sourceFilePath, + metadataPath: sidecarPath, + metadataSourcePath: options.errorSourcePath, + ambiguousErrorCode: HELP_SIDECAR_ERROR_CODES.METADATA_FILENAME_AMBIGUOUS, + }); + const resolvedSidecarPath = resolvedMetadataAuthority.resolvedAbsolutePath; + const normalizedSourcePath = normalizeSourcePath( + options.errorSourcePath || resolvedMetadataAuthority.resolvedSourcePath || resolvedMetadataAuthority.canonicalTargetSourcePath, + ); + + if (!resolvedSidecarPath || !(await fs.pathExists(resolvedSidecarPath))) { + createValidationError( + HELP_SIDECAR_ERROR_CODES.FILE_NOT_FOUND, + '', + normalizedSourcePath, + 'Expected exemplar sidecar file was not found.', + ); + } + + let parsedSidecar; + try { + const sidecarRaw = await fs.readFile(resolvedSidecarPath, 'utf8'); + parsedSidecar = yaml.parse(sidecarRaw); + } catch (error) { + createValidationError( + HELP_SIDECAR_ERROR_CODES.PARSE_FAILED, + '', + normalizedSourcePath, + `YAML parse failure: ${error.message}`, + ); + } + + validateHelpSidecarContractData(parsedSidecar, { errorSourcePath: normalizedSourcePath }); + return resolvedMetadataAuthority; +} + +async function validateShardDocSidecarContractFile(sidecarPath = '', options = {}) { + const sourceFilePath = options.sourceFilePath || getSourcePath('core', 'tasks', 'shard-doc.xml'); + const resolvedMetadataAuthority = await resolveSkillMetadataAuthority({ + sourceFilePath, + metadataPath: sidecarPath, + metadataSourcePath: options.errorSourcePath, + ambiguousErrorCode: SHARD_DOC_SIDECAR_ERROR_CODES.METADATA_FILENAME_AMBIGUOUS, + }); + const resolvedSidecarPath = resolvedMetadataAuthority.resolvedAbsolutePath; + const normalizedSourcePath = normalizeSourcePath( + options.errorSourcePath || resolvedMetadataAuthority.resolvedSourcePath || resolvedMetadataAuthority.canonicalTargetSourcePath, + ); + + if (!resolvedSidecarPath || !(await fs.pathExists(resolvedSidecarPath))) { + createValidationError( + SHARD_DOC_SIDECAR_ERROR_CODES.FILE_NOT_FOUND, + '', + normalizedSourcePath, + 'Expected shard-doc sidecar file was not found.', + ); + } + + let parsedSidecar; + try { + const sidecarRaw = await fs.readFile(resolvedSidecarPath, 'utf8'); + parsedSidecar = yaml.parse(sidecarRaw); + } catch (error) { + createValidationError( + SHARD_DOC_SIDECAR_ERROR_CODES.PARSE_FAILED, + '', + normalizedSourcePath, + `YAML parse failure: ${error.message}`, + ); + } + + validateShardDocSidecarContractData(parsedSidecar, { errorSourcePath: normalizedSourcePath }); + return resolvedMetadataAuthority; +} + +async function validateIndexDocsSidecarContractFile(sidecarPath = '', options = {}) { + const sourceFilePath = options.sourceFilePath || getSourcePath('core', 'tasks', 'index-docs.xml'); + const resolvedMetadataAuthority = await resolveSkillMetadataAuthority({ + sourceFilePath, + metadataPath: sidecarPath, + metadataSourcePath: options.errorSourcePath, + ambiguousErrorCode: INDEX_DOCS_SIDECAR_ERROR_CODES.METADATA_FILENAME_AMBIGUOUS, + }); + const resolvedSidecarPath = resolvedMetadataAuthority.resolvedAbsolutePath; + const normalizedSourcePath = normalizeSourcePath( + options.errorSourcePath || resolvedMetadataAuthority.resolvedSourcePath || resolvedMetadataAuthority.canonicalTargetSourcePath, + ); + + if (!resolvedSidecarPath || !(await fs.pathExists(resolvedSidecarPath))) { + createValidationError( + INDEX_DOCS_SIDECAR_ERROR_CODES.FILE_NOT_FOUND, + '', + normalizedSourcePath, + 'Expected index-docs sidecar file was not found.', + ); + } + + let parsedSidecar; + try { + const sidecarRaw = await fs.readFile(resolvedSidecarPath, 'utf8'); + parsedSidecar = yaml.parse(sidecarRaw); + } catch (error) { + createValidationError( + INDEX_DOCS_SIDECAR_ERROR_CODES.PARSE_FAILED, + '', + normalizedSourcePath, + `YAML parse failure: ${error.message}`, + ); + } + + validateIndexDocsSidecarContractData(parsedSidecar, { errorSourcePath: normalizedSourcePath }); + return resolvedMetadataAuthority; +} + +module.exports = { + HELP_SIDECAR_REQUIRED_FIELDS, + SHARD_DOC_SIDECAR_REQUIRED_FIELDS, + INDEX_DOCS_SIDECAR_REQUIRED_FIELDS, + HELP_SIDECAR_ERROR_CODES, + SHARD_DOC_SIDECAR_ERROR_CODES, + INDEX_DOCS_SIDECAR_ERROR_CODES, + SKILL_METADATA_CANONICAL_FILENAME, + SKILL_METADATA_DERIVATION_MODES, + SKILL_METADATA_LEGACY_FILENAMES, + SKILL_METADATA_RESOLUTION_ERROR_CODES, + SidecarContractError, + resolveSkillMetadataAuthority, + validateHelpSidecarContractData, + validateHelpSidecarContractFile, + validateShardDocSidecarContractData, + validateShardDocSidecarContractFile, + validateIndexDocsSidecarContractData, + validateIndexDocsSidecarContractFile, +}; diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index abee979fd..da087c0b4 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -8,14 +8,134 @@ const { AgentCommandGenerator } = require('./shared/agent-command-generator'); const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator'); const { getTasksFromBmad } = require('./shared/bmad-artifacts'); const { toDashPath, customAgentDashName } = require('./shared/path-utils'); +const { normalizeAndResolveExemplarAlias } = require('../core/help-alias-normalizer'); +const { resolveSkillMetadataAuthority } = require('../core/sidecar-contract-validator'); const prompts = require('../../../lib/prompts'); +const CODEX_EXPORT_DERIVATION_ERROR_CODES = Object.freeze({ + SIDECAR_FILE_NOT_FOUND: 'ERR_CODEX_EXPORT_SIDECAR_FILE_NOT_FOUND', + SIDECAR_FILENAME_AMBIGUOUS: 'ERR_CODEX_EXPORT_SIDECAR_FILENAME_AMBIGUOUS', + SIDECAR_PARSE_FAILED: 'ERR_CODEX_EXPORT_SIDECAR_PARSE_FAILED', + CANONICAL_ID_MISSING: 'ERR_CODEX_EXPORT_CANONICAL_ID_MISSING', + CANONICAL_ID_DERIVATION_FAILED: 'ERR_CODEX_EXPORT_CANONICAL_ID_DERIVATION_FAILED', + DUPLICATE_EXPORT_SURFACE: 'ERR_CODEX_EXPORT_DUPLICATE_EXPORT_SURFACE', +}); + +const EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md'; +const EXEMPLAR_SHARD_DOC_TASK_XML_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.xml'; +const EXEMPLAR_INDEX_DOCS_TASK_XML_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs.xml'; +const EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH = 'bmad-fork/src/core/tasks/help/skill-manifest.yaml'; +const EXEMPLAR_SHARD_DOC_SIDECAR_CONTRACT_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml'; +const EXEMPLAR_INDEX_DOCS_SIDECAR_CONTRACT_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml'; +const EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE = 'sidecar-canonical-id'; +const SHARD_DOC_EXPORT_ALIAS_ROWS = Object.freeze([ + Object.freeze({ + rowIdentity: 'alias-row:bmad-shard-doc:canonical-id', + canonicalId: 'bmad-shard-doc', + normalizedAliasValue: 'bmad-shard-doc', + rawIdentityHasLeadingSlash: false, + }), + Object.freeze({ + rowIdentity: 'alias-row:bmad-shard-doc:legacy-name', + canonicalId: 'bmad-shard-doc', + normalizedAliasValue: 'shard-doc', + rawIdentityHasLeadingSlash: false, + }), + Object.freeze({ + rowIdentity: 'alias-row:bmad-shard-doc:slash-command', + canonicalId: 'bmad-shard-doc', + normalizedAliasValue: 'bmad-shard-doc', + rawIdentityHasLeadingSlash: true, + }), +]); +const INDEX_DOCS_EXPORT_ALIAS_ROWS = Object.freeze([ + Object.freeze({ + rowIdentity: 'alias-row:bmad-index-docs:canonical-id', + canonicalId: 'bmad-index-docs', + normalizedAliasValue: 'bmad-index-docs', + rawIdentityHasLeadingSlash: false, + }), + Object.freeze({ + rowIdentity: 'alias-row:bmad-index-docs:legacy-name', + canonicalId: 'bmad-index-docs', + normalizedAliasValue: 'index-docs', + rawIdentityHasLeadingSlash: false, + }), + Object.freeze({ + rowIdentity: 'alias-row:bmad-index-docs:slash-command', + canonicalId: 'bmad-index-docs', + normalizedAliasValue: 'bmad-index-docs', + rawIdentityHasLeadingSlash: true, + }), +]); +const EXEMPLAR_CONVERTED_TASK_EXPORT_TARGETS = Object.freeze({ + help: Object.freeze({ + taskSourcePath: EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH, + sourcePathSuffix: '/core/tasks/help.md', + sidecarSourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH, + sourceFileCandidates: Object.freeze([ + Object.freeze({ + segments: ['bmad-fork', 'src', 'core', 'tasks', 'help.md'], + }), + Object.freeze({ + segments: ['src', 'core', 'tasks', 'help.md'], + }), + ]), + }), + 'shard-doc': Object.freeze({ + taskSourcePath: EXEMPLAR_SHARD_DOC_TASK_XML_SOURCE_PATH, + sourcePathSuffix: '/core/tasks/shard-doc.xml', + sidecarSourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_CONTRACT_SOURCE_PATH, + aliasRows: SHARD_DOC_EXPORT_ALIAS_ROWS, + sourceFileCandidates: Object.freeze([ + Object.freeze({ + segments: ['bmad-fork', 'src', 'core', 'tasks', 'shard-doc.xml'], + }), + Object.freeze({ + segments: ['src', 'core', 'tasks', 'shard-doc.xml'], + }), + ]), + }), + 'index-docs': Object.freeze({ + taskSourcePath: EXEMPLAR_INDEX_DOCS_TASK_XML_SOURCE_PATH, + sourcePathSuffix: '/core/tasks/index-docs.xml', + sidecarSourcePath: EXEMPLAR_INDEX_DOCS_SIDECAR_CONTRACT_SOURCE_PATH, + aliasRows: INDEX_DOCS_EXPORT_ALIAS_ROWS, + sourceFileCandidates: Object.freeze([ + Object.freeze({ + segments: ['bmad-fork', 'src', 'core', 'tasks', 'index-docs.xml'], + }), + Object.freeze({ + segments: ['src', 'core', 'tasks', 'index-docs.xml'], + }), + ]), + }), +}); + +class CodexExportDerivationError extends Error { + constructor({ code, detail, fieldPath, sourcePath, observedValue, cause = null }) { + const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath}, observedValue=${observedValue})`; + super(message); + this.name = 'CodexExportDerivationError'; + this.code = code; + this.detail = detail; + this.fieldPath = fieldPath; + this.sourcePath = sourcePath; + this.observedValue = observedValue; + if (cause) { + this.cause = cause; + } + } +} + /** * Codex setup handler (CLI mode) */ class CodexSetup extends BaseIdeSetup { constructor() { super('codex', 'Codex', false); + this.exportDerivationRecords = []; + this.exportSurfaceIdentityOwners = new Map(); } /** @@ -31,6 +151,8 @@ class CodexSetup extends BaseIdeSetup { const mode = 'cli'; const { artifacts, counts } = await this.collectClaudeArtifacts(projectDir, bmadDir, options); + this.exportDerivationRecords = []; + this.exportSurfaceIdentityOwners = new Map(); // Clean up old .codex/prompts locations (both global and project) const oldGlobalDir = this.getOldCodexPromptDir(null, 'global'); @@ -46,7 +168,7 @@ class CodexSetup extends BaseIdeSetup { // Collect and write agent skills const agentGen = new AgentCommandGenerator(this.bmadFolderName); const { artifacts: agentArtifacts } = await agentGen.collectAgentArtifacts(bmadDir, options.selectedModules || []); - const agentCount = await this.writeSkillArtifacts(destDir, agentArtifacts, 'agent-launcher'); + const agentCount = await this.writeSkillArtifacts(destDir, agentArtifacts, 'agent-launcher', { projectDir }); // Collect and write task skills const tasks = await getTasksFromBmad(bmadDir, options.selectedModules || []); @@ -77,12 +199,12 @@ class CodexSetup extends BaseIdeSetup { ...artifact, content: ttGen.generateCommandContent(artifact, artifact.type), })); - const tasksWritten = await this.writeSkillArtifacts(destDir, taskSkillArtifacts, 'task'); + const tasksWritten = await this.writeSkillArtifacts(destDir, taskSkillArtifacts, 'task', { projectDir }); // Collect and write workflow skills const workflowGenerator = new WorkflowCommandGenerator(this.bmadFolderName); const { artifacts: workflowArtifacts } = await workflowGenerator.collectWorkflowArtifacts(bmadDir); - const workflowCount = await this.writeSkillArtifacts(destDir, workflowArtifacts, 'workflow-command'); + const workflowCount = await this.writeSkillArtifacts(destDir, workflowArtifacts, 'workflow-command', { projectDir }); const written = agentCount + workflowCount + tasksWritten; @@ -99,6 +221,7 @@ class CodexSetup extends BaseIdeSetup { counts, destination: destDir, written, + exportDerivationRecords: [...this.exportDerivationRecords], }; } @@ -207,7 +330,203 @@ class CodexSetup extends BaseIdeSetup { * @param {string} artifactType - Type filter (e.g., 'agent-launcher', 'workflow-command', 'task') * @returns {number} Number of skills written */ - async writeSkillArtifacts(destDir, artifacts, artifactType) { + getConvertedTaskExportTarget(artifact = {}) { + if (artifact.type !== 'task' || artifact.module !== 'core') { + return null; + } + + const normalizedName = String(artifact.name || '') + .trim() + .toLowerCase(); + const exportTarget = EXEMPLAR_CONVERTED_TASK_EXPORT_TARGETS[normalizedName]; + if (!exportTarget) { + return null; + } + + const normalizedRelativePath = String(artifact.relativePath || '') + .trim() + .replaceAll('\\', '/') + .toLowerCase(); + const normalizedSourcePath = String(artifact.sourcePath || '') + .trim() + .replaceAll('\\', '/') + .toLowerCase(); + + const normalizedRelativePathWithRoot = normalizedRelativePath.startsWith('/') ? normalizedRelativePath : `/${normalizedRelativePath}`; + if (!normalizedRelativePathWithRoot.endsWith(`/core/tasks/${normalizedName}.md`)) { + return null; + } + + const normalizedSourcePathWithRoot = normalizedSourcePath.startsWith('/') ? normalizedSourcePath : `/${normalizedSourcePath}`; + if (normalizedSourcePath && !normalizedSourcePathWithRoot.endsWith(exportTarget.sourcePathSuffix)) { + return null; + } + + return exportTarget; + } + + throwExportDerivationError({ code, detail, fieldPath, sourcePath, observedValue, cause = null }) { + throw new CodexExportDerivationError({ + code, + detail, + fieldPath, + sourcePath, + observedValue, + cause, + }); + } + + async loadConvertedTaskSidecar(projectDir, exportTarget) { + const sourceCandidates = (exportTarget.sourceFileCandidates || []).map((candidate) => path.join(projectDir, ...candidate.segments)); + if (sourceCandidates.length === 0) { + this.throwExportDerivationError({ + code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND, + detail: 'expected exemplar metadata source candidates are missing', + fieldPath: '', + sourcePath: exportTarget.sidecarSourcePath, + observedValue: projectDir, + }); + } + + let resolvedMetadataAuthority = null; + for (const sourceCandidate of sourceCandidates) { + try { + const resolution = await resolveSkillMetadataAuthority({ + sourceFilePath: sourceCandidate, + projectRoot: projectDir, + ambiguousErrorCode: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS, + }); + if (!resolvedMetadataAuthority) { + resolvedMetadataAuthority = resolution; + } + if (resolution.resolvedAbsolutePath && (await fs.pathExists(resolution.resolvedAbsolutePath))) { + resolvedMetadataAuthority = resolution; + break; + } + } catch (error) { + this.throwExportDerivationError({ + code: error.code || CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS, + detail: error.detail || error.message, + fieldPath: error.fieldPath || '', + sourcePath: exportTarget.sidecarSourcePath, + observedValue: error.sourcePath || projectDir, + cause: error, + }); + } + } + + const sidecarPath = resolvedMetadataAuthority.resolvedAbsolutePath; + if (!sidecarPath || !(await fs.pathExists(sidecarPath))) { + this.throwExportDerivationError({ + code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND, + detail: 'expected exemplar sidecar metadata file was not found', + fieldPath: '', + sourcePath: exportTarget.sidecarSourcePath, + observedValue: projectDir, + }); + } + + let sidecarData; + try { + sidecarData = yaml.parse(await fs.readFile(sidecarPath, 'utf8')); + } catch (error) { + this.throwExportDerivationError({ + code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_PARSE_FAILED, + detail: `YAML parse failure: ${error.message}`, + fieldPath: '', + sourcePath: exportTarget.sidecarSourcePath, + observedValue: '', + cause: error, + }); + } + + if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) { + this.throwExportDerivationError({ + code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_PARSE_FAILED, + detail: 'sidecar root must be a YAML mapping object', + fieldPath: '', + sourcePath: exportTarget.sidecarSourcePath, + observedValue: typeof sidecarData, + }); + } + + const canonicalId = String(sidecarData.canonicalId || '').trim(); + if (canonicalId.length === 0) { + this.throwExportDerivationError({ + code: CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_MISSING, + detail: 'sidecar canonicalId is required for exemplar export derivation', + fieldPath: 'canonicalId', + sourcePath: exportTarget.sidecarSourcePath, + observedValue: canonicalId, + }); + } + + return { + canonicalId, + sourcePath: exportTarget.sidecarSourcePath, + resolvedFilename: String(resolvedMetadataAuthority.resolvedFilename || ''), + derivationMode: String(resolvedMetadataAuthority.derivationMode || ''), + }; + } + + async resolveSkillIdentityFromArtifact(artifact, projectDir) { + const inferredSkillName = toDashPath(artifact.relativePath).replace(/\.md$/, ''); + const exportTarget = this.getConvertedTaskExportTarget(artifact); + if (!exportTarget) { + return { + skillName: inferredSkillName, + canonicalId: inferredSkillName, + exportIdDerivationSourceType: 'path-derived', + exportIdDerivationSourcePath: String(artifact.relativePath || ''), + }; + } + + const sidecarData = await this.loadConvertedTaskSidecar(projectDir, exportTarget); + + let canonicalResolution; + try { + const aliasResolutionOptions = { + fieldPath: 'canonicalId', + sourcePath: sidecarData.sourcePath, + }; + if (Array.isArray(exportTarget.aliasRows)) { + aliasResolutionOptions.aliasRows = exportTarget.aliasRows; + aliasResolutionOptions.aliasTableSourcePath = '_bmad/_config/canonical-aliases.csv'; + } + canonicalResolution = await normalizeAndResolveExemplarAlias(sidecarData.canonicalId, aliasResolutionOptions); + } catch (error) { + this.throwExportDerivationError({ + code: CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED, + detail: `failed to derive exemplar export id from sidecar canonicalId (${error.code || error.message})`, + fieldPath: 'canonicalId', + sourcePath: sidecarData.sourcePath, + observedValue: sidecarData.canonicalId, + cause: error, + }); + } + + const skillName = String(canonicalResolution.postAliasCanonicalId || '').trim(); + if (skillName.length === 0) { + this.throwExportDerivationError({ + code: CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED, + detail: 'resolved canonical export id is empty', + fieldPath: 'canonicalId', + sourcePath: sidecarData.sourcePath, + observedValue: sidecarData.canonicalId, + }); + } + + return { + skillName, + canonicalId: skillName, + exportIdDerivationSourceType: EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE, + exportIdDerivationSourcePath: sidecarData.sourcePath, + exportIdDerivationTaskSourcePath: exportTarget.taskSourcePath, + exportIdDerivationEvidence: `applied:${canonicalResolution.preAliasNormalizedValue}|leadingSlash:${canonicalResolution.rawIdentityHasLeadingSlash}->${canonicalResolution.postAliasCanonicalId}|rows:${canonicalResolution.aliasRowLocator}`, + }; + } + + async writeSkillArtifacts(destDir, artifacts, artifactType, options = {}) { let writtenCount = 0; for (const artifact of artifacts) { @@ -217,11 +536,38 @@ class CodexSetup extends BaseIdeSetup { } // Get the dash-format name (e.g., bmad-bmm-create-prd.md) and remove .md - const flatName = toDashPath(artifact.relativePath); - const skillName = flatName.replace(/\.md$/, ''); + const exportIdentity = await this.resolveSkillIdentityFromArtifact(artifact, options.projectDir || process.cwd()); + const skillName = exportIdentity.skillName; // Create skill directory const skillDir = path.join(destDir, skillName); + const skillPath = path.join(skillDir, 'SKILL.md'); + const normalizedSkillPath = skillPath.replaceAll('\\', '/'); + const ownerRecord = { + artifactType, + sourcePath: String(artifact.sourcePath || artifact.relativePath || ''), + }; + const existingOwner = this.exportSurfaceIdentityOwners.get(normalizedSkillPath); + if (existingOwner) { + this.throwExportDerivationError({ + code: CODEX_EXPORT_DERIVATION_ERROR_CODES.DUPLICATE_EXPORT_SURFACE, + detail: `duplicate export surface path already claimed by ${existingOwner.artifactType}:${existingOwner.sourcePath}`, + fieldPath: 'canonicalId', + sourcePath: ownerRecord.sourcePath, + observedValue: normalizedSkillPath, + }); + } + + if (await fs.pathExists(skillPath)) { + this.throwExportDerivationError({ + code: CODEX_EXPORT_DERIVATION_ERROR_CODES.DUPLICATE_EXPORT_SURFACE, + detail: 'duplicate export surface path already exists on disk', + fieldPath: 'canonicalId', + sourcePath: ownerRecord.sourcePath, + observedValue: normalizedSkillPath, + }); + } + await fs.ensureDir(skillDir); // Transform content: rewrite frontmatter for skills format @@ -229,8 +575,26 @@ class CodexSetup extends BaseIdeSetup { // Write SKILL.md with platform-native line endings const platformContent = skillContent.replaceAll('\n', os.EOL); - await fs.writeFile(path.join(skillDir, 'SKILL.md'), platformContent, 'utf8'); + await fs.writeFile(skillPath, platformContent, 'utf8'); + this.exportSurfaceIdentityOwners.set(normalizedSkillPath, ownerRecord); writtenCount++; + + if (exportIdentity.exportIdDerivationSourceType === EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE) { + this.exportDerivationRecords.push({ + exportPath: path.join('.agents', 'skills', skillName, 'SKILL.md').replaceAll('\\', '/'), + sourcePath: exportIdentity.exportIdDerivationTaskSourcePath || EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH, + canonicalId: exportIdentity.canonicalId, + visibleId: skillName, + visibleSurfaceClass: 'export-id', + authoritySourceType: 'sidecar', + authoritySourcePath: exportIdentity.exportIdDerivationSourcePath, + exportIdDerivationSourceType: exportIdentity.exportIdDerivationSourceType, + exportIdDerivationSourcePath: exportIdentity.exportIdDerivationSourcePath, + issuingComponent: 'bmad-fork/tools/cli/installers/lib/ide/codex.js', + issuingComponentBindingEvidence: exportIdentity.exportIdDerivationEvidence || '', + generatedSkillPath: skillPath.replaceAll('\\', '/'), + }); + } } return writtenCount; @@ -437,4 +801,9 @@ class CodexSetup extends BaseIdeSetup { } } -module.exports = { CodexSetup }; +module.exports = { + CodexSetup, + CODEX_EXPORT_DERIVATION_ERROR_CODES, + CodexExportDerivationError, + EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE, +}; diff --git a/tools/cli/installers/lib/ide/github-copilot.js b/tools/cli/installers/lib/ide/github-copilot.js index 059127f81..fa1a5edac 100644 --- a/tools/cli/installers/lib/ide/github-copilot.js +++ b/tools/cli/installers/lib/ide/github-copilot.js @@ -6,6 +6,11 @@ const { BMAD_FOLDER_NAME, toDashPath } = require('./shared/path-utils'); const fs = require('fs-extra'); const csv = require('csv-parse/sync'); const yaml = require('yaml'); +const { + ProjectionCompatibilityError, + validateHelpCatalogLoaderEntries, + validateGithubCopilotHelpLoaderEntries, +} = require('../core/projection-compatibility-validator'); /** * GitHub Copilot setup handler @@ -131,12 +136,20 @@ class GitHubCopilotSetup extends BaseIdeSetup { try { const csvContent = await fs.readFile(helpPath, 'utf8'); - return csv.parse(csvContent, { + const rows = csv.parse(csvContent, { columns: true, skip_empty_lines: true, }); - } catch { + const sourcePath = `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/bmad-help.csv`; + validateHelpCatalogLoaderEntries(rows, { sourcePath }); + validateGithubCopilotHelpLoaderEntries(rows, { sourcePath }); + return rows; + } catch (error) { // Gracefully degrade if help CSV is unreadable/malformed + // but fail-fast on deterministic compatibility contract violations. + if (error instanceof ProjectionCompatibilityError) { + throw error; + } return null; } } diff --git a/tools/cli/installers/lib/ide/shared/task-tool-command-generator.js b/tools/cli/installers/lib/ide/shared/task-tool-command-generator.js index 93e5b9a81..475d9afab 100644 --- a/tools/cli/installers/lib/ide/shared/task-tool-command-generator.js +++ b/tools/cli/installers/lib/ide/shared/task-tool-command-generator.js @@ -2,6 +2,7 @@ const path = require('node:path'); const fs = require('fs-extra'); const csv = require('csv-parse/sync'); const { toColonName, toColonPath, toDashPath, BMAD_FOLDER_NAME } = require('./path-utils'); +const { validateTaskManifestLoaderEntries } = require('../../core/projection-compatibility-validator'); /** * Generates command files for standalone tasks and tools @@ -197,10 +198,14 @@ Follow all instructions in the ${type} file exactly as written. } const csvContent = await fs.readFile(manifestPath, 'utf8'); - return csv.parse(csvContent, { + const records = csv.parse(csvContent, { columns: true, skip_empty_lines: true, }); + validateTaskManifestLoaderEntries(records, { + sourcePath: `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/task-manifest.csv`, + }); + return records; } /**