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/test/test-installation-components.js b/test/test-installation-components.js index 646bd9ef7..b06db039c 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -12,9 +12,62 @@ */ 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, + validateHelpSidecarContractFile, +} = 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 { + 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_WAVE1_ADDITIVE_COLUMNS, + HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS, + HELP_CATALOG_WAVE1_ADDITIVE_COLUMNS, + validateTaskManifestCompatibilitySurface, + validateTaskManifestLoaderEntries, + validateHelpCatalogCompatibilitySurface, + validateHelpCatalogLoaderEntries, + validateGithubCopilotHelpLoaderEntries, +} = require('../tools/cli/installers/lib/core/projection-compatibility-validator'); +const { + WAVE1_VALIDATION_ERROR_CODES, + WAVE1_VALIDATION_ARTIFACT_REGISTRY, + Wave1ValidationHarness, +} = require('../tools/cli/installers/lib/core/wave-1-validation-harness'); // ANSI colors const colors = { @@ -158,9 +211,1613 @@ 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.artifact.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 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.artifact.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', + ); + } 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 { + const installer = new Installer(); + let authorityValidationCalled = false; + let generateConfigsCalled = false; + let manifestGenerationCalled = false; + let helpCatalogGenerationCalled = false; + let successResultCount = 0; + + 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.validateHelpAuthoritySplitAndPrecedence = async () => { + authorityValidationCalled = 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 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 sidecar validation error code', + `Expected ${HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED}, got ${error.code}`, + ); + assert( + !authorityValidationCalled && !generateConfigsCalled && !manifestGenerationCalled && !helpCatalogGenerationCalled, + 'Installer fail-fast prevents downstream authority/config/manifest/help generation', + ); + assert( + successResultCount === 0, + 'Installer fail-fast records no successful projection milestones', + `Expected 0, got ${successResultCount}`, + ); + } + } 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.artifact.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 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, + }, + ]; + manifestGenerator.helpAuthorityRecords = [ + { + recordType: 'metadata-authority', + canonicalId: 'bmad-help', + authoritativePresenceKey: 'capability:bmad-help', + authoritySourceType: 'sidecar', + authoritySourcePath: 'bmad-fork/src/core/tasks/help.artifact.yaml', + sourcePath: 'bmad-fork/src/core/tasks/help.md', + }, + ]; + + 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 wave-1 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'); + + 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.artifact.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', + ); + + let capturedAuthorityValidationOptions = null; + let capturedManifestHelpAuthorityRecords = null; + let capturedInstalledFiles = null; + + const installer = new Installer(); + installer.validateHelpSidecarContractFile = async () => {}; + 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; + 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.artifact.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( + Array.isArray(capturedManifestHelpAuthorityRecords) && + capturedManifestHelpAuthorityRecords[0] && + capturedManifestHelpAuthorityRecords[0].authoritySourcePath === 'bmad-fork/src/core/tasks/help.artifact.yaml', + 'Installer passes sidecar authority path into manifest generation 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.artifact.yaml', + sourcePath: 'bmad-fork/src/core/tasks/help.md', + }, + ]; + + 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 === 3, 'Canonical alias table emits exactly three exemplar rows'); + assert( + canonicalAliasRows.map((row) => row.aliasType).join(',') === 'canonical-id,legacy-name,slash-command', + 'Canonical alias table preserves locked deterministic row ordering', + ); + + const expectedRowsByType = new Map([ + [ + 'canonical-id', + { + canonicalId: 'bmad-help', + alias: 'bmad-help', + rowIdentity: 'alias-row:bmad-help:canonical-id', + normalizedAliasValue: 'bmad-help', + rawIdentityHasLeadingSlash: 'false', + resolutionEligibility: 'canonical-id-only', + }, + ], + [ + 'legacy-name', + { + canonicalId: 'bmad-help', + alias: 'help', + rowIdentity: 'alias-row:bmad-help:legacy-name', + normalizedAliasValue: 'help', + rawIdentityHasLeadingSlash: 'false', + resolutionEligibility: 'legacy-name-only', + }, + ], + [ + 'slash-command', + { + canonicalId: 'bmad-help', + alias: '/bmad-help', + rowIdentity: 'alias-row:bmad-help:slash-command', + normalizedAliasValue: 'bmad-help', + rawIdentityHasLeadingSlash: 'true', + resolutionEligibility: 'slash-command-only', + }, + ], + ]); + + for (const [aliasType, expectedRow] of expectedRowsByType) { + const matchingRows = canonicalAliasRows.filter((row) => row.aliasType === aliasType); + assert(matchingRows.length === 1, `Canonical alias table emits exactly one ${aliasType} exemplar row`); + + const row = matchingRows[0]; + assert( + row && row.authoritySourceType === 'sidecar' && row.authoritySourcePath === 'bmad-fork/src/core/tasks/help.artifact.yaml', + `${aliasType} exemplar row uses sidecar provenance fields`, + ); + assert(row && row.canonicalId === expectedRow.canonicalId, `${aliasType} exemplar row locks canonicalId contract`); + assert(row && row.alias === expectedRow.alias, `${aliasType} exemplar row locks alias contract`); + assert(row && row.rowIdentity === expectedRow.rowIdentity, `${aliasType} exemplar row locks rowIdentity contract`); + assert( + row && row.normalizedAliasValue === expectedRow.normalizedAliasValue, + `${aliasType} exemplar row locks normalizedAliasValue contract`, + ); + assert( + row && row.rawIdentityHasLeadingSlash === expectedRow.rawIdentityHasLeadingSlash, + `${aliasType} exemplar row locks rawIdentityHasLeadingSlash contract`, + ); + assert( + row && row.resolutionEligibility === expectedRow.resolutionEligibility, + `${aliasType} exemplar row locks resolutionEligibility contract`, + ); + } + + const validateLockedCanonicalAliasProjection = (rows) => { + for (const [aliasType, expectedRow] of expectedRowsByType) { + const matchingRows = rows.filter((row) => row.canonicalId === 'bmad-help' && row.aliasType === aliasType); + if (matchingRows.length === 0) { + return { valid: false, reason: `missing:${aliasType}` }; + } + if (matchingRows.length > 1) { + return { valid: false, reason: `conflict:${aliasType}` }; + } + + const row = matchingRows[0]; + if ( + row.rowIdentity !== expectedRow.rowIdentity || + row.normalizedAliasValue !== expectedRow.normalizedAliasValue || + row.rawIdentityHasLeadingSlash !== expectedRow.rawIdentityHasLeadingSlash || + row.resolutionEligibility !== expectedRow.resolutionEligibility + ) { + return { valid: false, reason: `conflict:${aliasType}` }; + } + } + + if (rows.length !== expectedRowsByType.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.aliasType !== 'legacy-name'); + const missingLegacyValidation = validateLockedCanonicalAliasProjection(missingLegacyRows); + assert( + !missingLegacyValidation.valid && missingLegacyValidation.reason === 'missing:legacy-name', + 'Canonical alias projection validator fails when required legacy-name row is missing', + ); + + const conflictingRows = [ + ...canonicalAliasRows, + { + ...canonicalAliasRows.find((row) => row.aliasType === 'slash-command'), + rowIdentity: 'alias-row:bmad-help:slash-command:duplicate', + }, + ]; + const conflictingValidation = validateLockedCanonicalAliasProjection(conflictingRows); + assert( + !conflictingValidation.valid && conflictingValidation.reason === 'conflict: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 = []; + 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) => row.authoritySourceType === 'sidecar' && row.authoritySourcePath === 'bmad-fork/src/core/tasks/help.artifact.yaml', + ), + '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, + }, + ); + + 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.artifact.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'); + 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', + ); + + 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 || []; + assert(commandLabelRows.length === 1, 'Installer emits one command-label report row for exemplar canonical id'); + assert( + commandLabelRows[0] && + commandLabelRows[0].rawCommandValue === 'bmad-help' && + commandLabelRows[0].displayedCommandLabel === '/bmad-help', + 'Command-label report locks raw and displayed command values for exemplar', + ); + assert( + commandLabelRows[0] && + commandLabelRows[0].authoritySourceType === 'sidecar' && + commandLabelRows[0].authoritySourcePath === 'bmad-fork/src/core/tasks/help.artifact.yaml', + 'Command-label report includes sidecar provenance linkage', + ); + const generatedCommandLabelReportRaw = await fs.readFile(generatedCommandLabelReportPath, 'utf8'); + const generatedCommandLabelReportRows = csv.parse(generatedCommandLabelReportRaw, { + columns: true, + skip_empty_lines: true, + trim: true, + }); + assert( + generatedCommandLabelReportRows.length === 1 && + generatedCommandLabelReportRows[0].displayedCommandLabel === '/bmad-help' && + generatedCommandLabelReportRows[0].rowCountForCanonicalId === '1', + 'Installer persists command-label report artifact with locked exemplar 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 invalidLegacyLabelContract = evaluateExemplarCommandLabelReportRows([ + { + ...commandLabelRows[0], + displayedCommandLabel: 'help', + }, + ]); + assert( + !invalidLegacyLabelContract.valid && invalidLegacyLabelContract.reason === 'invalid-displayed-label:help', + 'Command-label validator fails on alternate displayed label form "help"', + ); + + const invalidSlashHelpLabelContract = evaluateExemplarCommandLabelReportRows([ + { + ...commandLabelRows[0], + displayedCommandLabel: '/help', + }, + ]); + assert( + !invalidSlashHelpLabelContract.valid && invalidSlashHelpLabelContract.reason === 'invalid-displayed-label:/help', + 'Command-label validator fails on alternate displayed label form "/help"', + ); + + 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.artifact.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.artifact.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.artifact.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', + ); + + 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 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.artifact.yaml', + 'Codex export records exemplar derivation source metadata from sidecar canonical-id', + ); + + 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.artifact.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 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 +1843,954 @@ 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_WAVE1_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.artifact.yaml', + futureAdditiveField: 'wave-1', + }, + { + 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: 'wave-1', + }, + ]; + 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 wave-1 ordering', + ); + assert( + validatedTaskSurface.headerColumns.at(-1) === 'futureAdditiveField', + 'Task-manifest compatibility validator allows additive columns appended after locked wave-1 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_WAVE1_MISMATCH, + 'Task-manifest strict validator emits deterministic wave-1 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_WAVE1_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: 'wave-1', + }, + { + 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: 'wave-1', + }, + ]; + 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 wave-1 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 === 2 && + loadedHelpRows[0]['workflow-file'] === '_bmad/core/tasks/help.md' && + loadedHelpRows[0].command === 'bmad-help', + '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 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-wave1-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_WAVE1_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.artifact.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.artifact.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.artifact.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.artifact.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_WAVE1_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: '', + }, + ], + ); + 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: '', + }, + ], + ); + 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.artifact.yaml', + commandAuthoritySourceType: 'sidecar', + commandAuthoritySourcePath: 'bmad-fork/src/core/tasks/help.artifact.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.artifact.yaml', + commandAuthoritySourceType: 'sidecar', + commandAuthoritySourcePath: 'bmad-fork/src/core/tasks/help.artifact.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.artifact.yaml', + status: 'PASS', + failureReason: '', + }, + ], + ); + + const harness = new Wave1ValidationHarness(); + 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 === WAVE1_VALIDATION_ARTIFACT_REGISTRY.length, + 'Wave-1 validation harness generates and validates all required artifacts', + ); + + const artifactPathsById = new Map( + WAVE1_VALIDATION_ARTIFACT_REGISTRY.map((artifact) => [ + artifact.artifactId, + path.join(tempProjectRoot, '_bmad-output', 'planning-artifacts', artifact.relativePath), + ]), + ); + for (const [artifactId, artifactPath] of artifactPathsById.entries()) { + assert(await fs.pathExists(artifactPath), `Wave-1 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, + 'Wave-1 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, 'Wave-1 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.buildWave1ValidationOptions({ + projectDir: tempProjectRoot, + bmadDir: tempBmadDir, + }); + assert( + noIdeValidationOptions.requireExportSkillProjection === false, + 'Installer wave-1 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', + 'Wave-1 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', + 'Wave-1 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, 'Wave-1 replay evidence generation rejects unmapped claimed rowIdentity'); + } catch (error) { + assert( + error.code === WAVE1_VALIDATION_ERROR_CODES.REQUIRED_ROW_IDENTITY_MISSING, + 'Wave-1 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, 'Wave-1 validation harness fails when required projection input surfaces are missing'); + } catch (error) { + assert( + error.code === WAVE1_VALIDATION_ERROR_CODES.REQUIRED_ARTIFACT_MISSING, + 'Wave-1 validation harness emits deterministic missing-input-surface error code', + ); + } + await writeCsv( + path.join(tempConfigDir, 'task-manifest.csv'), + [...TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS, ...TASK_MANIFEST_WAVE1_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.artifact.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, 'Wave-1 validation harness fails when a required artifact is missing'); + } catch (error) { + assert( + error.code === WAVE1_VALIDATION_ERROR_CODES.REQUIRED_ARTIFACT_MISSING, + 'Wave-1 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, 'Wave-1 validation harness rejects schema/header drift'); + } catch (error) { + assert( + error.code === WAVE1_VALIDATION_ERROR_CODES.CSV_SCHEMA_MISMATCH, + 'Wave-1 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, 'Wave-1 validation harness rejects header-only required-identity artifacts'); + } catch (error) { + assert( + error.code === WAVE1_VALIDATION_ERROR_CODES.REQUIRED_ROW_IDENTITY_MISSING, + 'Wave-1 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, 'Wave-1 validation harness rejects missing required row identity values'); + } catch (error) { + assert( + error.code === WAVE1_VALIDATION_ERROR_CODES.REQUIRED_ROW_IDENTITY_MISSING, + 'Wave-1 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, 'Wave-1 validation harness rejects PASS rows missing required evidence-link fields'); + } catch (error) { + assert( + error.code === WAVE1_VALIDATION_ERROR_CODES.REQUIRED_EVIDENCE_LINK_MISSING, + 'Wave-1 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, 'Wave-1 validation harness rejects self-attested issuer claims that diverge from validator evidence'); + } catch (error) { + assert( + error.code === WAVE1_VALIDATION_ERROR_CODES.SELF_ATTESTED_ISSUER_CLAIM, + 'Wave-1 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, 'Wave-1 validation harness rejects malformed replay-evidence payloads'); + } catch (error) { + assert( + error.code === WAVE1_VALIDATION_ERROR_CODES.BINDING_EVIDENCE_INVALID, + 'Wave-1 validation harness emits deterministic replay-evidence validation error code', + ); + } + } catch (error) { + assert(false, 'Deterministic validation artifact suite setup', error.message); + } finally { + await fs.remove(tempValidationHarnessRoot); + } + + 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..b5a3b02a0 --- /dev/null +++ b/tools/cli/installers/lib/core/help-authority-validator.js @@ -0,0 +1,372 @@ +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 HELP_AUTHORITY_VALIDATION_ERROR_CODES = Object.freeze({ + SIDECAR_FILE_NOT_FOUND: 'ERR_HELP_AUTHORITY_SIDECAR_FILE_NOT_FOUND', + 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 sidecarPath = options.sidecarPath || getSourcePath('core', 'tasks', 'help.artifact.yaml'); + const sourceMarkdownPath = options.sourceMarkdownPath || getSourcePath('core', 'tasks', 'help.md'); + const runtimeMarkdownPath = options.runtimeMarkdownPath || ''; + + const sidecarSourcePath = normalizeSourcePath(options.sidecarSourcePath || toProjectRelativePath(sidecarPath)); + const sourceMarkdownSourcePath = normalizeSourcePath(options.sourceMarkdownSourcePath || toProjectRelativePath(sourceMarkdownPath)); + const runtimeMarkdownSourcePath = normalizeSourcePath( + options.runtimeMarkdownSourcePath || (runtimeMarkdownPath ? toProjectRelativePath(runtimeMarkdownPath) : ''), + ); + + if (!(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, + }; +} + +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..7c410b0ae --- /dev/null +++ b/tools/cli/installers/lib/core/help-catalog-generator.js @@ -0,0 +1,367 @@ +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 EXEMPLAR_HELP_CATALOG_CANONICAL_ID = 'bmad-help'; +const EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.artifact.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_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 = getSourcePath('core', 'tasks', 'help.artifact.yaml')) { + const sourcePath = normalizeSourcePath(toProjectRelativePath(sidecarPath)); + if (!(await fs.pathExists(sidecarPath))) { + 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(sidecarPath, '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, + }; +} + +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 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) !== 'sidecar') { + return { valid: false, reason: `invalid-authority-source-type:${frontmatterMatchValue(row.authoritySourceType) || ''}` }; + } + + if (frontmatterMatchValue(row.authoritySourcePath) !== EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH) { + 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/installer.js b/tools/cli/installers/lib/core/installer.js index fe8b88d7c..c118ac1b0 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -9,6 +9,17 @@ const { Config } = require('../../../lib/config'); const { XmlHandler } = require('../../../lib/xml-handler'); const { DependencyResolver } = require('./dependency-resolver'); const { ConfigCollector } = require('./config-collector'); +const { validateHelpSidecarContractFile } = require('./sidecar-contract-validator'); +const { validateHelpAuthoritySplitAndPrecedence } = require('./help-authority-validator'); +const { + HELP_CATALOG_GENERATION_ERROR_CODES, + buildSidecarAwareExemplarHelpRow, + evaluateExemplarCommandLabelReportRows, + normalizeDisplayedCommandLabel, + renderDisplayedCommandLabel, +} = require('./help-catalog-generator'); +const { validateHelpCatalogCompatibilitySurface } = require('./projection-compatibility-validator'); +const { Wave1ValidationHarness } = require('./wave-1-validation-harness'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); const { CLIUtils } = require('../../../lib/cli-utils'); const { ManifestGenerator } = require('./manifest-generator'); @@ -17,6 +28,9 @@ 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.artifact.yaml'; +const EXEMPLAR_HELP_SOURCE_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md'; + class Installer { constructor() { this.detector = new Detector(); @@ -29,8 +43,104 @@ class Installer { this.dependencyResolver = new DependencyResolver(); this.configCollector = new ConfigCollector(); this.ideConfigManager = new IdeConfigManager(); + this.validateHelpSidecarContractFile = validateHelpSidecarContractFile; + this.validateHelpAuthoritySplitAndPrecedence = validateHelpAuthoritySplitAndPrecedence; + this.ManifestGenerator = ManifestGenerator; this.installedFiles = new Set(); // Track all installed files this.bmadFolderName = BMAD_FOLDER_NAME; + this.helpCatalogPipelineRows = []; + this.helpCatalogCommandLabelReportRows = []; + this.codexExportDerivationRecords = []; + this.latestWave1ValidationRun = null; + this.wave1ValidationHarness = new Wave1ValidationHarness(); + } + + async runConfigurationGenerationTask({ message, bmadDir, moduleConfigs, config, allModules, addResult }) { + // Validate exemplar sidecar contract before generating projections/manifests. + // Fail-fast here prevents downstream artifacts from being produced on invalid metadata. + message('Validating exemplar sidecar contract...'); + await this.validateHelpSidecarContractFile(); + addResult('Sidecar contract', 'ok', 'validated'); + + 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 || [], + }); + + 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 buildWave1ValidationOptions({ 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, + }; } /** @@ -1098,54 +1208,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 +1244,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 +1289,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 +1317,21 @@ class Installer { // ───────────────────────────────────────────────────────────────────────── const postIdeTasks = []; + postIdeTasks.push({ + title: 'Generating validation artifacts', + task: async (message) => { + message('Generating deterministic wave-1 validation artifact suite...'); + const validationOptions = await this.buildWave1ValidationOptions({ + projectDir, + bmadDir, + }); + const validationRun = await this.wave1ValidationHarness.generateAndValidate(validationOptions); + this.latestWave1ValidationRun = validationRun; + addResult('Validation artifacts', 'ok', `${validationRun.generatedArtifactCount} artifacts`); + return `${validationRun.generatedArtifactCount} validation artifacts generated`; + }, + }); + // File restoration task (only for updates) if ( config._isUpdate && @@ -1690,6 +1780,94 @@ class Installer { /** * Private: Create directory structure */ + 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 || '', + ]; + } + + isExemplarCommandLabelCandidate({ workflowFile, name, rawCommandValue, canonicalId, legacyName }) { + 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 isHelpWorkflow = normalizedWorkflowFile.endsWith('/core/tasks/help.md'); + const isExemplarIdentity = + normalizedName === 'bmad-help' || + normalizedCommandValue === normalizedCanonicalId || + (normalizedLegacyName.length > 0 && normalizedCommandValue === normalizedLegacyName); + + return isHelpWorkflow && isExemplarIdentity; + } + + 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 +1879,14 @@ 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, + }); + let exemplarRowWritten = false; // Load agent manifest for agent info lookup const agentManifestPath = path.join(bmadDir, '_config', 'agent-manifest.csv'); @@ -1795,29 +1981,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 +2051,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 +2100,136 @@ 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; + } + + if ( + !this.isExemplarCommandLabelCandidate({ + workflowFile, + name, + rawCommandValue, + canonicalId: sidecarAwareExemplar.canonicalId, + legacyName: sidecarAwareExemplar.legacyName, + }) + ) { + continue; + } + + const displayedCommandLabel = renderDisplayedCommandLabel(rawCommandValue); + commandLabelRowsFromMergedCatalog.push({ + surface: `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/bmad-help.csv`, + canonicalId: sidecarAwareExemplar.canonicalId, + rawCommandValue, + displayedCommandLabel, + normalizedDisplayedLabel: normalizeDisplayedCommandLabel(displayedCommandLabel), + authoritySourceType: sidecarAwareExemplar.authoritySourceType, + authoritySourcePath: sidecarAwareExemplar.authoritySourcePath, + }); + } + + const exemplarRowCount = commandLabelRowsFromMergedCatalog.length; + + 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: exemplarRowCount, + status: exemplarRowCount === 1 ? 'PASS' : 'FAIL', + })); + + const commandLabelContractResult = evaluateExemplarCommandLabelReportRows(this.helpCatalogCommandLabelReportRows, { + canonicalId: sidecarAwareExemplar.canonicalId, + displayedCommandLabel: sidecarAwareExemplar.displayedCommandLabel, + }); + if (!commandLabelContractResult.valid) { + this.helpCatalogPipelineRows = this.helpCatalogPipelineRows.map((row) => ({ + ...row, + stageStatus: 'FAIL', + status: 'FAIL', + })); + this.helpCatalogCommandLabelReportRows = this.helpCatalogCommandLabelReportRows.map((row) => ({ + ...row, + status: 'FAIL', + failureReason: commandLabelContractResult.reason, + })); + + const commandLabelError = new Error( + `${HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED}: ${commandLabelContractResult.reason}`, + ); + commandLabelError.code = HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED; + commandLabelError.detail = commandLabelContractResult.reason; + 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..0cd5e6d26 100644 --- a/tools/cli/installers/lib/core/manifest-generator.js +++ b/tools/cli/installers/lib/core/manifest-generator.js @@ -5,9 +5,56 @@ 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.artifact.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', + }), +]); /** * Generates manifest files for installed workflows, agents, and tasks @@ -34,6 +81,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 +181,8 @@ class ManifestGenerator { throw new TypeError('ManifestGenerator expected `options.ides` to be an array.'); } + this.helpAuthorityRecords = await this.normalizeHelpAuthorityRecords(options.helpAuthorityRecords); + // Filter out any undefined/null values from IDE list this.selectedIdes = resolvedIdes.filter((ide) => ide && typeof ide === 'string'); @@ -96,6 +204,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 +739,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 +957,51 @@ 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 sidecarAuthorityRecord = Array.isArray(this.helpAuthorityRecords) + ? this.helpAuthorityRecords.find( + (record) => record?.canonicalId === 'bmad-help' && record?.authoritySourceType === 'sidecar' && record?.authoritySourcePath, + ) + : null; + const exemplarAuthoritySourceType = sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourceType : 'sidecar'; + const exemplarAuthoritySourcePath = sidecarAuthorityRecord + ? sidecarAuthorityRecord.authoritySourcePath + : 'bmad-fork/src/core/tasks/help.artifact.yaml'; // 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 wave-1 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 +1014,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 isExemplarHelpTask = task.module === 'core' && task.name === 'help'; + allTasks.set(key, { name: task.name, displayName: task.displayName, @@ -877,11 +1024,17 @@ class ManifestGenerator { module: task.module, path: task.path, standalone: task.standalone, + legacyName: isExemplarHelpTask ? 'help' : previousRecord?.legacyName || task.name, + canonicalId: isExemplarHelpTask ? 'bmad-help' : previousRecord?.canonicalId || '', + authoritySourceType: isExemplarHelpTask ? exemplarAuthoritySourceType : previousRecord?.authoritySourceType || '', + authoritySourcePath: isExemplarHelpTask ? exemplarAuthoritySourcePath : 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 +1042,89 @@ 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, + }; + } + + buildCanonicalAliasProjectionRows(authoritySourceType, authoritySourcePath) { + return LOCKED_CANONICAL_ALIAS_TABLE_EXEMPLAR_ROWS.map((row) => ({ + canonicalId: row.canonicalId, + alias: row.alias, + aliasType: row.aliasType, + authoritySourceType, + authoritySourcePath, + rowIdentity: row.rowIdentity, + normalizedAliasValue: row.normalizedAliasValue, + rawIdentityHasLeadingSlash: row.rawIdentityHasLeadingSlash, + resolutionEligibility: row.resolutionEligibility, + })); + } + + /** + * 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 { authoritySourceType, authoritySourcePath } = this.resolveExemplarAliasAuthorityRecord(); + const projectedRows = this.buildCanonicalAliasProjectionRows(authoritySourceType, authoritySourcePath); + + 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..d82fa3e87 --- /dev/null +++ b/tools/cli/installers/lib/core/projection-compatibility-validator.js @@ -0,0 +1,407 @@ +const csv = require('csv-parse/sync'); + +const TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS = Object.freeze(['name', 'displayName', 'description', 'module', 'path', 'standalone']); + +const TASK_MANIFEST_WAVE1_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_WAVE1_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_WAVE1_MISMATCH: 'ERR_TASK_MANIFEST_COMPAT_HEADER_WAVE1_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_WAVE1_MISMATCH: 'ERR_HELP_CATALOG_COMPAT_HEADER_WAVE1_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', + GITHUB_COPILOT_WORKFLOW_FILE_MISSING: 'ERR_GITHUB_COPILOT_HELP_WORKFLOW_FILE_MISSING', +}); + +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 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', + }); + } + + 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_WAVE1_ADDITIVE_COLUMNS, + offset: TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS.length, + code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_HEADER_WAVE1_MISMATCH, + detail: 'Task-manifest wave-1 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_WAVE1_ADDITIVE_COLUMNS, + offset: HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS.length, + code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_HEADER_WAVE1_MISMATCH, + detail: 'Help-catalog wave-1 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 }; +} + +module.exports = { + PROJECTION_COMPATIBILITY_ERROR_CODES, + ProjectionCompatibilityError, + TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS, + TASK_MANIFEST_WAVE1_ADDITIVE_COLUMNS, + HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS, + HELP_CATALOG_WAVE1_ADDITIVE_COLUMNS, + validateTaskManifestCompatibilitySurface, + validateTaskManifestLoaderEntries, + validateHelpCatalogCompatibilitySurface, + validateHelpCatalogLoaderEntries, + validateGithubCopilotHelpLoaderEntries, +}; 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..a5b9e235c --- /dev/null +++ b/tools/cli/installers/lib/core/sidecar-contract-validator.js @@ -0,0 +1,262 @@ +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 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', +}); + +const HELP_EXEMPLAR_CANONICAL_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md'; +const HELP_EXEMPLAR_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) { + 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 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 getExpectedSidecarBasenameFromSourcePath(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 validateHelpSidecarContractData(sidecarData, options = {}) { + const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/help.artifact.yaml'); + + if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) { + createValidationError( + HELP_SIDECAR_ERROR_CODES.INVALID_ROOT_OBJECT, + '', + sourcePath, + 'Sidecar root must be a YAML mapping object.', + ); + } + + for (const field of HELP_SIDECAR_REQUIRED_FIELDS) { + if (!hasOwn(sidecarData, field)) { + if (field === 'dependencies') { + createValidationError( + HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING, + field, + sourcePath, + 'Exemplar sidecar requires an explicit dependencies block.', + ); + } + + createValidationError( + HELP_SIDECAR_ERROR_CODES.REQUIRED_FIELD_MISSING, + field, + sourcePath, + `Missing required sidecar field "${field}".`, + ); + } + } + + const requiredNonEmptyStringFields = ['canonicalId', 'sourcePath', 'displayName', 'description']; + for (const field of requiredNonEmptyStringFields) { + if (isBlankString(sidecarData[field])) { + createValidationError( + HELP_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY, + field, + sourcePath, + `Required sidecar field "${field}" must be a non-empty string.`, + ); + } + } + + const schemaMajorVersion = parseSchemaMajorVersion(sidecarData.schemaVersion); + if (schemaMajorVersion !== HELP_EXEMPLAR_SUPPORTED_SCHEMA_MAJOR) { + createValidationError( + HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED, + 'schemaVersion', + sourcePath, + 'sidecar schema major version is unsupported', + ); + } + + if (sidecarData.artifactType !== 'task') { + createValidationError( + HELP_SIDECAR_ERROR_CODES.ARTIFACT_TYPE_INVALID, + 'artifactType', + sourcePath, + 'Wave-1 exemplar requires artifactType to equal "task".', + ); + } + + if (sidecarData.module !== 'core') { + createValidationError( + HELP_SIDECAR_ERROR_CODES.MODULE_INVALID, + 'module', + sourcePath, + 'Wave-1 exemplar requires module to equal "core".', + ); + } + + const dependencies = sidecarData.dependencies; + if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) { + createValidationError( + HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING, + 'dependencies', + sourcePath, + 'Exemplar sidecar requires an explicit dependencies object.', + ); + } + + if (!hasOwn(dependencies, 'requires') || !Array.isArray(dependencies.requires)) { + createValidationError( + HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_INVALID, + 'dependencies.requires', + sourcePath, + 'Exemplar dependencies.requires must be an array.', + ); + } + + if (dependencies.requires.length > 0) { + createValidationError( + HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_NOT_EMPTY, + 'dependencies.requires', + sourcePath, + 'Wave-1 exemplar requires explicit zero dependencies: dependencies.requires must be [].', + ); + } + + const normalizedDeclaredSourcePath = normalizeSourcePath(sidecarData.sourcePath); + const sidecarBasename = path.posix.basename(sourcePath); + const expectedSidecarBasename = getExpectedSidecarBasenameFromSourcePath(normalizedDeclaredSourcePath); + + const sourcePathMismatch = normalizedDeclaredSourcePath !== HELP_EXEMPLAR_CANONICAL_SOURCE_PATH; + const basenameMismatch = !expectedSidecarBasename || sidecarBasename !== expectedSidecarBasename; + + if (sourcePathMismatch || basenameMismatch) { + createValidationError( + HELP_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH, + 'sourcePath', + sourcePath, + 'sidecar basename does not match sourcePath basename', + ); + } +} + +async function validateHelpSidecarContractFile(sidecarPath = getSourcePath('core', 'tasks', 'help.artifact.yaml'), options = {}) { + const normalizedSourcePath = normalizeSourcePath(options.errorSourcePath || toProjectRelativePath(sidecarPath)); + + if (!(await fs.pathExists(sidecarPath))) { + createValidationError( + HELP_SIDECAR_ERROR_CODES.FILE_NOT_FOUND, + '', + normalizedSourcePath, + 'Expected exemplar sidecar file was not found.', + ); + } + + let parsedSidecar; + try { + const sidecarRaw = await fs.readFile(sidecarPath, 'utf8'); + parsedSidecar = yaml.parse(sidecarRaw); + } catch (error) { + createValidationError( + HELP_SIDECAR_ERROR_CODES.PARSE_FAILED, + '', + normalizedSourcePath, + `YAML parse failure: ${error.message}`, + ); + } + + validateHelpSidecarContractData(parsedSidecar, { errorSourcePath: normalizedSourcePath }); +} + +module.exports = { + HELP_SIDECAR_REQUIRED_FIELDS, + HELP_SIDECAR_ERROR_CODES, + SidecarContractError, + validateHelpSidecarContractData, + validateHelpSidecarContractFile, +}; diff --git a/tools/cli/installers/lib/core/wave-1-validation-harness.js b/tools/cli/installers/lib/core/wave-1-validation-harness.js new file mode 100644 index 000000000..5b2e05e0c --- /dev/null +++ b/tools/cli/installers/lib/core/wave-1-validation-harness.js @@ -0,0 +1,2684 @@ +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 } = 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 WAVE1_VALIDATION_ERROR_CODES = Object.freeze({ + REQUIRED_ARTIFACT_MISSING: 'ERR_WAVE1_VALIDATION_REQUIRED_ARTIFACT_MISSING', + CSV_SCHEMA_MISMATCH: 'ERR_WAVE1_VALIDATION_CSV_SCHEMA_MISMATCH', + REQUIRED_ROW_IDENTITY_MISSING: 'ERR_WAVE1_VALIDATION_REQUIRED_ROW_IDENTITY_MISSING', + REQUIRED_EVIDENCE_LINK_MISSING: 'ERR_WAVE1_VALIDATION_REQUIRED_EVIDENCE_LINK_MISSING', + EVIDENCE_LINK_REFERENCE_INVALID: 'ERR_WAVE1_VALIDATION_EVIDENCE_LINK_REFERENCE_INVALID', + BINDING_EVIDENCE_INVALID: 'ERR_WAVE1_VALIDATION_BINDING_EVIDENCE_INVALID', + ISSUER_PREREQUISITE_MISSING: 'ERR_WAVE1_VALIDATION_ISSUER_PREREQUISITE_MISSING', + SELF_ATTESTED_ISSUER_CLAIM: 'ERR_WAVE1_VALIDATION_SELF_ATTESTED_ISSUER_CLAIM', + YAML_SCHEMA_MISMATCH: 'ERR_WAVE1_VALIDATION_YAML_SCHEMA_MISMATCH', + DECISION_RECORD_SCHEMA_MISMATCH: 'ERR_WAVE1_VALIDATION_DECISION_RECORD_SCHEMA_MISMATCH', + DECISION_RECORD_PARSE_FAILED: 'ERR_WAVE1_VALIDATION_DECISION_RECORD_PARSE_FAILED', +}); + +const SIDEcar_AUTHORITY_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.artifact.yaml'; +const SOURCE_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md'; +const EVIDENCE_ISSUER_COMPONENT = 'bmad-fork/tools/cli/installers/lib/core/wave-1-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 WAVE1_VALIDATION_ARTIFACT_REGISTRY = Object.freeze([ + Object.freeze({ + artifactId: 1, + relativePath: path.join('validation', 'wave-1', 'bmad-help-sidecar-snapshot.yaml'), + type: 'yaml', + requiredTopLevelKeys: ['schemaVersion', 'canonicalId', 'artifactType', 'module', 'sourcePath', 'displayName', 'description', 'status'], + }), + Object.freeze({ + artifactId: 2, + relativePath: path.join('validation', 'wave-1', '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', 'wave-1', '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', 'wave-1', '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', 'wave-1', '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', 'wave-1', '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', 'wave-1', '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', 'wave-1', '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', 'wave-1', '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', 'wave-1', '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', 'wave-1', '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', 'wave-1-native-skills-exit.md'), + type: 'markdown', + requiredFrontmatterKeys: ['wave', 'goNoGo', 'status'], + }), + Object.freeze({ + artifactId: 13, + relativePath: path.join('validation', 'wave-1', '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', 'wave-1', '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 Wave1ValidationHarnessError extends Error { + constructor({ code, detail, artifactId, fieldPath, sourcePath, observedValue, expectedValue }) { + const message = `${code}: ${detail} (artifact=${artifactId}, fieldPath=${fieldPath}, sourcePath=${sourcePath})`; + super(message); + this.name = 'Wave1ValidationHarnessError'; + 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_WAVE1_REPLAY_COMPONENT_FAILED'), + replayFailureDetail: normalizeValue(error?.detail || error?.message || 'component replay failed'), + }); +} + +function isSha256(value) { + return /^[a-f0-9]{64}$/.test(String(value || '')); +} + +class Wave1ValidationHarness { + constructor() { + this.registry = WAVE1_VALIDATION_ARTIFACT_REGISTRY; + } + + getArtifactRegistry() { + return this.registry; + } + + resolveOutputPaths(options = {}) { + const projectDir = path.resolve(options.projectDir || process.cwd()); + const planningArtifactsRoot = path.join(projectDir, '_bmad-output', 'planning-artifacts'); + const validationRoot = path.join(planningArtifactsRoot, 'validation', 'wave-1'); + const decisionRecordsRoot = path.join(planningArtifactsRoot, 'decision-records'); + return { + projectDir, + planningArtifactsRoot, + validationRoot, + decisionRecordsRoot, + }; + } + + resolveSourceArtifactPaths(options = {}) { + const projectDir = path.resolve(options.projectDir || process.cwd()); + + const sidecarCandidates = [ + options.sidecarPath, + path.join(projectDir, 'bmad-fork', 'src', 'core', 'tasks', 'help.artifact.yaml'), + path.join(projectDir, 'src', 'core', 'tasks', 'help.artifact.yaml'), + getSourcePath('core', 'tasks', 'help.artifact.yaml'), + ].filter(Boolean); + + 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]; + }; + + return Promise.all([resolveExistingPath(sidecarCandidates), resolveExistingPath(sourceMarkdownCandidates)]).then( + ([sidecarPath, sourceMarkdownPath]) => ({ + sidecarPath, + sourceMarkdownPath, + }), + ); + } + + 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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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.helpAuthorityRecords = [ + { + canonicalId: 'bmad-help', + authoritySourceType: 'sidecar', + authoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH, + }, + ]; + 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: '', + }, + ]; + 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(), 'wave1-replay-baseline-')); + const perturbedWorkspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'wave1-replay-perturbed-')); + + try { + const baseline = await contract.run({ workspaceRoot: baselineWorkspaceRoot, perturbed: false }); + if (Number(baseline.targetRowCount) <= 0) { + throw new Wave1ValidationHarnessError({ + code: WAVE1_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/wave-1/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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 = { + wave: 1, + goNoGo: 'GO', + status: 'PASS', + }; + const decisionRecordContent = `---\n${yaml.stringify(decisionRecord).trimEnd()}\n---\n\n# Wave 1 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/wave-1/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/wave-1/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/wave-1/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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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/wave-1/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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_VALIDATION_ERROR_CODES.REQUIRED_ARTIFACT_MISSING, + detail: 'Required wave-1 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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: isEvidenceLinkField + ? WAVE1_VALIDATION_ERROR_CODES.REQUIRED_EVIDENCE_LINK_MISSING + : WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 Wave1ValidationHarnessError({ + code: WAVE1_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 = { + WAVE1_VALIDATION_ERROR_CODES, + WAVE1_VALIDATION_ARTIFACT_REGISTRY, + Wave1ValidationHarnessError, + Wave1ValidationHarness, +}; diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index abee979fd..0d96db79c 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -8,14 +8,51 @@ 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 prompts = require('../../../lib/prompts'); +const CODEX_EXPORT_DERIVATION_ERROR_CODES = Object.freeze({ + SIDECAR_FILE_NOT_FOUND: 'ERR_CODEX_EXPORT_SIDECAR_FILE_NOT_FOUND', + 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', +}); + +const EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md'; +const EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.artifact.yaml'; +const EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE = 'sidecar-canonical-id'; +const EXEMPLAR_SIDECAR_SOURCE_CANDIDATES = Object.freeze([ + Object.freeze({ + segments: ['bmad-fork', 'src', 'core', 'tasks', 'help.artifact.yaml'], + }), + Object.freeze({ + segments: ['src', 'core', 'tasks', 'help.artifact.yaml'], + }), +]); + +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 = []; } /** @@ -31,6 +68,7 @@ class CodexSetup extends BaseIdeSetup { const mode = 'cli'; const { artifacts, counts } = await this.collectClaudeArtifacts(projectDir, bmadDir, options); + this.exportDerivationRecords = []; // Clean up old .codex/prompts locations (both global and project) const oldGlobalDir = this.getOldCodexPromptDir(null, 'global'); @@ -46,7 +84,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 +115,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 +137,7 @@ class CodexSetup extends BaseIdeSetup { counts, destination: destDir, written, + exportDerivationRecords: [...this.exportDerivationRecords], }; } @@ -207,7 +246,148 @@ 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) { + isExemplarHelpTaskArtifact(artifact = {}) { + if (artifact.type !== 'task' || artifact.module !== 'core') { + return false; + } + + const normalizedName = String(artifact.name || '') + .trim() + .toLowerCase(); + const normalizedRelativePath = String(artifact.relativePath || '') + .trim() + .replaceAll('\\', '/') + .toLowerCase(); + const normalizedSourcePath = String(artifact.sourcePath || '') + .trim() + .replaceAll('\\', '/') + .toLowerCase(); + + if (normalizedName !== 'help') { + return false; + } + + return normalizedRelativePath.endsWith('/core/tasks/help.md') || normalizedSourcePath.endsWith('/core/tasks/help.md'); + } + + throwExportDerivationError({ code, detail, fieldPath, sourcePath, observedValue, cause = null }) { + throw new CodexExportDerivationError({ + code, + detail, + fieldPath, + sourcePath, + observedValue, + cause, + }); + } + + async loadExemplarHelpSidecar(projectDir) { + for (const candidate of EXEMPLAR_SIDECAR_SOURCE_CANDIDATES) { + const sidecarPath = path.join(projectDir, ...candidate.segments); + if (await fs.pathExists(sidecarPath)) { + 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: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH, + 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: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH, + 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: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH, + observedValue: canonicalId, + }); + } + + return { + canonicalId, + sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH, + }; + } + } + + this.throwExportDerivationError({ + code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND, + detail: 'expected exemplar sidecar metadata file was not found', + fieldPath: '', + sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH, + observedValue: projectDir, + }); + } + + async resolveSkillIdentityFromArtifact(artifact, projectDir) { + const inferredSkillName = toDashPath(artifact.relativePath).replace(/\.md$/, ''); + const isExemplarHelpTask = this.isExemplarHelpTaskArtifact(artifact); + if (!isExemplarHelpTask) { + return { + skillName: inferredSkillName, + canonicalId: inferredSkillName, + exportIdDerivationSourceType: 'path-derived', + exportIdDerivationSourcePath: String(artifact.relativePath || ''), + }; + } + + const sidecarData = await this.loadExemplarHelpSidecar(projectDir); + + let canonicalResolution; + try { + canonicalResolution = await normalizeAndResolveExemplarAlias(sidecarData.canonicalId, { + fieldPath: 'canonicalId', + sourcePath: sidecarData.sourcePath, + }); + } 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, + 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,8 +397,8 @@ 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); @@ -229,8 +409,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'); + const skillPath = path.join(skillDir, 'SKILL.md'); + await fs.writeFile(skillPath, platformContent, 'utf8'); writtenCount++; + + if (exportIdentity.exportIdDerivationSourceType === EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE) { + this.exportDerivationRecords.push({ + exportPath: path.join('.agents', 'skills', skillName, 'SKILL.md').replaceAll('\\', '/'), + sourcePath: 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 +635,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; } /**