From d24ef0633f266e64334a92c28b8b0c62dfce9c25 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Tue, 3 Mar 2026 17:26:12 +0000 Subject: [PATCH] feat(installer): complete wave-2 shard-doc parity and validation gates --- src/core/tasks/shard-doc.artifact.yaml | 9 + .../shard-doc.artifact.yaml | 9 + .../shard-doc.artifact.yaml | 9 + test/test-installation-components.js | 1584 +++++++++++++++-- .../lib/core/help-catalog-generator.js | 11 +- tools/cli/installers/lib/core/installer.js | 190 +- .../installers/lib/core/manifest-generator.js | 200 ++- .../projection-compatibility-validator.js | 133 ++ .../lib/core/shard-doc-authority-validator.js | 334 ++++ .../lib/core/sidecar-contract-validator.js | 188 +- .../lib/core/wave-1-validation-harness.js | 20 +- tools/cli/installers/lib/ide/codex.js | 133 +- 12 files changed, 2559 insertions(+), 261 deletions(-) create mode 100644 src/core/tasks/shard-doc.artifact.yaml create mode 100644 test/fixtures/wave-2/sidecar-negative/basename-path-mismatch/shard-doc.artifact.yaml create mode 100644 test/fixtures/wave-2/sidecar-negative/unknown-major-version/shard-doc.artifact.yaml create mode 100644 tools/cli/installers/lib/core/shard-doc-authority-validator.js diff --git a/src/core/tasks/shard-doc.artifact.yaml b/src/core/tasks/shard-doc.artifact.yaml new file mode 100644 index 000000000..7444a1b30 --- /dev/null +++ b/src/core/tasks/shard-doc.artifact.yaml @@ -0,0 +1,9 @@ +schemaVersion: 1 +canonicalId: bmad-shard-doc +artifactType: task +module: core +sourcePath: bmad-fork/src/core/tasks/shard-doc.xml +displayName: Shard Document +description: "Split large markdown documents into smaller files by section with an index." +dependencies: + requires: [] diff --git a/test/fixtures/wave-2/sidecar-negative/basename-path-mismatch/shard-doc.artifact.yaml b/test/fixtures/wave-2/sidecar-negative/basename-path-mismatch/shard-doc.artifact.yaml new file mode 100644 index 000000000..d0ef1f1ab --- /dev/null +++ b/test/fixtures/wave-2/sidecar-negative/basename-path-mismatch/shard-doc.artifact.yaml @@ -0,0 +1,9 @@ +schemaVersion: 1 +canonicalId: bmad-shard-doc +artifactType: task +module: core +sourcePath: bmad-fork/src/core/tasks/not-shard-doc.xml +displayName: Shard Document +description: "Split large markdown documents into smaller files by section with an index." +dependencies: + requires: [] diff --git a/test/fixtures/wave-2/sidecar-negative/unknown-major-version/shard-doc.artifact.yaml b/test/fixtures/wave-2/sidecar-negative/unknown-major-version/shard-doc.artifact.yaml new file mode 100644 index 000000000..70efdad3c --- /dev/null +++ b/test/fixtures/wave-2/sidecar-negative/unknown-major-version/shard-doc.artifact.yaml @@ -0,0 +1,9 @@ +schemaVersion: 2 +canonicalId: bmad-shard-doc +artifactType: task +module: core +sourcePath: bmad-fork/src/core/tasks/shard-doc.xml +displayName: Shard Document +description: "Split large markdown documents into smaller files by section with an index." +dependencies: + requires: [] diff --git a/test/test-installation-components.js b/test/test-installation-components.js index b06db039c..67a42ac70 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -32,12 +32,19 @@ const { const { HELP_SIDECAR_REQUIRED_FIELDS, HELP_SIDECAR_ERROR_CODES, + SHARD_DOC_SIDECAR_REQUIRED_FIELDS, + SHARD_DOC_SIDECAR_ERROR_CODES, validateHelpSidecarContractFile, + validateShardDocSidecarContractFile, } = require('../tools/cli/installers/lib/core/sidecar-contract-validator'); const { HELP_FRONTMATTER_MISMATCH_ERROR_CODES, validateHelpAuthoritySplitAndPrecedence, } = require('../tools/cli/installers/lib/core/help-authority-validator'); +const { + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES, + validateShardDocAuthoritySplitAndPrecedence, +} = require('../tools/cli/installers/lib/core/shard-doc-authority-validator'); const { HELP_CATALOG_GENERATION_ERROR_CODES, EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH, @@ -62,6 +69,7 @@ const { validateHelpCatalogCompatibilitySurface, validateHelpCatalogLoaderEntries, validateGithubCopilotHelpLoaderEntries, + validateCommandDocSurfaceConsistency, } = require('../tools/cli/installers/lib/core/projection-compatibility-validator'); const { WAVE1_VALIDATION_ERROR_CODES, @@ -385,6 +393,202 @@ async function runTests() { console.log(''); + // ============================================================ + // Test 4b: Wave-2 shard-doc Sidecar Contract Validation + // ============================================================ + console.log(`${colors.yellow}Test Suite 4b: Wave-2 shard-doc Sidecar Contract Validation${colors.reset}\n`); + + const validShardDocSidecar = { + schemaVersion: 1, + canonicalId: 'bmad-shard-doc', + artifactType: 'task', + module: 'core', + sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml', + displayName: 'Shard Document', + description: 'Split large markdown documents into smaller files by section with an index.', + dependencies: { + requires: [], + }, + }; + + const shardDocFixtureRoot = path.join(projectRoot, 'test', 'fixtures', 'wave-2', 'sidecar-negative'); + const unknownMajorFixturePath = path.join(shardDocFixtureRoot, 'unknown-major-version', 'shard-doc.artifact.yaml'); + const basenameMismatchFixturePath = path.join(shardDocFixtureRoot, 'basename-path-mismatch', 'shard-doc.artifact.yaml'); + + const tempShardDocRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-shard-doc-sidecar-')); + const tempShardDocSidecarPath = path.join(tempShardDocRoot, 'shard-doc.artifact.yaml'); + const deterministicShardDocSourcePath = 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml'; + + const writeTempShardDocSidecar = async (data) => { + await fs.writeFile(tempShardDocSidecarPath, yaml.stringify(data), 'utf8'); + }; + + const expectShardDocValidationError = async (data, expectedCode, expectedFieldPath, testLabel, expectedDetail = null) => { + await writeTempShardDocSidecar(data); + + try { + await validateShardDocSidecarContractFile(tempShardDocSidecarPath, { errorSourcePath: deterministicShardDocSourcePath }); + assert(false, testLabel, 'Expected validation error but validation passed'); + } catch (error) { + assert(error.code === expectedCode, `${testLabel} returns expected error code`, `Expected ${expectedCode}, got ${error.code}`); + assert( + error.fieldPath === expectedFieldPath, + `${testLabel} returns expected field path`, + `Expected ${expectedFieldPath}, got ${error.fieldPath}`, + ); + assert( + error.sourcePath === deterministicShardDocSourcePath, + `${testLabel} returns expected source path`, + `Expected ${deterministicShardDocSourcePath}, got ${error.sourcePath}`, + ); + assert( + typeof error.message === 'string' && + error.message.includes(expectedCode) && + error.message.includes(expectedFieldPath) && + error.message.includes(deterministicShardDocSourcePath), + `${testLabel} includes deterministic message context`, + ); + if (expectedDetail !== null) { + assert( + error.detail === expectedDetail, + `${testLabel} returns locked detail string`, + `Expected "${expectedDetail}", got "${error.detail}"`, + ); + } + } + }; + + try { + await writeTempShardDocSidecar(validShardDocSidecar); + await validateShardDocSidecarContractFile(tempShardDocSidecarPath, { errorSourcePath: deterministicShardDocSourcePath }); + assert(true, 'Valid shard-doc sidecar contract passes'); + + for (const requiredField of SHARD_DOC_SIDECAR_REQUIRED_FIELDS.filter((field) => field !== 'dependencies')) { + const invalidSidecar = structuredClone(validShardDocSidecar); + delete invalidSidecar[requiredField]; + await expectShardDocValidationError( + invalidSidecar, + SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_MISSING, + requiredField, + `Shard-doc missing required field "${requiredField}"`, + ); + } + + const unknownMajorFixture = yaml.parse(await fs.readFile(unknownMajorFixturePath, 'utf8')); + await expectShardDocValidationError( + unknownMajorFixture, + SHARD_DOC_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED, + 'schemaVersion', + 'Shard-doc unsupported sidecar major schema version', + 'sidecar schema major version is unsupported', + ); + + const basenameMismatchFixture = yaml.parse(await fs.readFile(basenameMismatchFixturePath, 'utf8')); + await expectShardDocValidationError( + basenameMismatchFixture, + SHARD_DOC_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH, + 'sourcePath', + 'Shard-doc sourcePath mismatch', + 'sidecar basename does not match sourcePath basename', + ); + + const mismatchedShardDocBasenamePath = path.join(tempShardDocRoot, 'not-shard-doc.artifact.yaml'); + await fs.writeFile(mismatchedShardDocBasenamePath, yaml.stringify(validShardDocSidecar), 'utf8'); + try { + await validateShardDocSidecarContractFile(mismatchedShardDocBasenamePath, { + errorSourcePath: 'bmad-fork/src/core/tasks/not-shard-doc.artifact.yaml', + }); + assert(false, 'Shard-doc basename mismatch returns validation error', 'Expected validation error but validation passed'); + } catch (error) { + assert( + error.code === SHARD_DOC_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH, + 'Shard-doc basename mismatch returns expected error code', + ); + assert( + error.fieldPath === 'sourcePath', + 'Shard-doc basename mismatch returns expected field path', + `Expected sourcePath, got ${error.fieldPath}`, + ); + assert( + typeof error.message === 'string' && + error.message.includes(SHARD_DOC_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH) && + error.message.includes('bmad-fork/src/core/tasks/not-shard-doc.artifact.yaml'), + 'Shard-doc basename mismatch includes deterministic message context', + ); + } + + await expectShardDocValidationError( + { ...validShardDocSidecar, artifactType: 'workflow' }, + SHARD_DOC_SIDECAR_ERROR_CODES.ARTIFACT_TYPE_INVALID, + 'artifactType', + 'Shard-doc invalid artifactType', + ); + + await expectShardDocValidationError( + { ...validShardDocSidecar, module: 'bmm' }, + SHARD_DOC_SIDECAR_ERROR_CODES.MODULE_INVALID, + 'module', + 'Shard-doc invalid module', + ); + + await expectShardDocValidationError( + { ...validShardDocSidecar, canonicalId: ' ' }, + SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY, + 'canonicalId', + 'Shard-doc empty canonicalId', + ); + + await expectShardDocValidationError( + { ...validShardDocSidecar, sourcePath: '' }, + SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY, + 'sourcePath', + 'Shard-doc empty sourcePath', + ); + + await expectShardDocValidationError( + { ...validShardDocSidecar, description: '' }, + SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY, + 'description', + 'Shard-doc empty description', + ); + + await expectShardDocValidationError( + { ...validShardDocSidecar, displayName: '' }, + SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY, + 'displayName', + 'Shard-doc empty displayName', + ); + + const missingShardDocDependencies = structuredClone(validShardDocSidecar); + delete missingShardDocDependencies.dependencies; + await expectShardDocValidationError( + missingShardDocDependencies, + SHARD_DOC_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING, + 'dependencies', + 'Shard-doc missing dependencies block', + ); + + await expectShardDocValidationError( + { ...validShardDocSidecar, dependencies: { requires: 'skill:bmad-help' } }, + SHARD_DOC_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_INVALID, + 'dependencies.requires', + 'Shard-doc non-array dependencies.requires', + ); + + await expectShardDocValidationError( + { ...validShardDocSidecar, dependencies: { requires: ['skill:bmad-help'] } }, + SHARD_DOC_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_NOT_EMPTY, + 'dependencies.requires', + 'Shard-doc non-empty dependencies.requires', + ); + } catch (error) { + assert(false, 'Wave-2 shard-doc sidecar validation suite setup', error.message); + } finally { + await fs.remove(tempShardDocRoot); + } + + console.log(''); + // ============================================================ // Test 5: Authority Split and Frontmatter Precedence // ============================================================ @@ -576,6 +780,217 @@ async function runTests() { deterministicAuthorityPaths.source, 'Source dependencies.requires mismatch', ); + + const tempShardDocAuthoritySidecarPath = path.join(tempAuthorityRoot, 'shard-doc.artifact.yaml'); + const tempShardDocAuthoritySourcePath = path.join(tempAuthorityRoot, 'shard-doc.xml'); + const tempShardDocModuleHelpPath = path.join(tempAuthorityRoot, 'module-help.csv'); + + const deterministicShardDocAuthorityPaths = { + sidecar: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + source: 'bmad-fork/src/core/tasks/shard-doc.xml', + compatibility: 'bmad-fork/src/core/module-help.csv', + workflowFile: '_bmad/core/tasks/shard-doc.xml', + }; + + const validShardDocAuthoritySidecar = { + schemaVersion: 1, + canonicalId: 'bmad-shard-doc', + artifactType: 'task', + module: 'core', + sourcePath: deterministicShardDocAuthorityPaths.source, + displayName: 'Shard Document', + description: 'Split large markdown documents into smaller files by section with an index.', + dependencies: { + requires: [], + }, + }; + + const writeModuleHelpCsv = async (rows) => { + const header = 'module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs'; + const lines = rows.map((row) => + [ + row.module ?? 'core', + row.phase ?? 'anytime', + row.name ?? 'Shard Document', + row.code ?? 'SD', + row.sequence ?? '', + row.workflowFile ?? '', + row.command ?? '', + row.required ?? 'false', + row.agent ?? '', + row.options ?? '', + row.description ?? 'Compatibility row', + row.outputLocation ?? '', + row.outputs ?? '', + ].join(','), + ); + + await fs.writeFile(tempShardDocModuleHelpPath, [header, ...lines].join('\n'), 'utf8'); + }; + + const runShardDocAuthorityValidation = async () => + validateShardDocAuthoritySplitAndPrecedence({ + sidecarPath: tempShardDocAuthoritySidecarPath, + sourceXmlPath: tempShardDocAuthoritySourcePath, + compatibilityCatalogPath: tempShardDocModuleHelpPath, + sidecarSourcePath: deterministicShardDocAuthorityPaths.sidecar, + sourceXmlSourcePath: deterministicShardDocAuthorityPaths.source, + compatibilityCatalogSourcePath: deterministicShardDocAuthorityPaths.compatibility, + compatibilityWorkflowFilePath: deterministicShardDocAuthorityPaths.workflowFile, + }); + + const expectShardDocAuthorityValidationError = async ( + rows, + expectedCode, + expectedFieldPath, + testLabel, + expectedSourcePath = deterministicShardDocAuthorityPaths.compatibility, + ) => { + await writeModuleHelpCsv(rows); + + try { + await runShardDocAuthorityValidation(); + assert(false, testLabel, 'Expected shard-doc authority validation error but validation passed'); + } catch (error) { + assert(error.code === expectedCode, `${testLabel} returns expected error code`, `Expected ${expectedCode}, got ${error.code}`); + assert( + error.fieldPath === expectedFieldPath, + `${testLabel} returns expected field path`, + `Expected ${expectedFieldPath}, got ${error.fieldPath}`, + ); + assert( + error.sourcePath === expectedSourcePath, + `${testLabel} returns expected source path`, + `Expected ${expectedSourcePath}, got ${error.sourcePath}`, + ); + assert( + typeof error.message === 'string' && + error.message.includes(expectedCode) && + error.message.includes(expectedFieldPath) && + error.message.includes(expectedSourcePath), + `${testLabel} includes deterministic message context`, + ); + } + }; + + await fs.writeFile(tempShardDocAuthoritySidecarPath, yaml.stringify(validShardDocAuthoritySidecar), 'utf8'); + await fs.writeFile(tempShardDocAuthoritySourcePath, '\n', 'utf8'); + + await writeModuleHelpCsv([ + { + workflowFile: deterministicShardDocAuthorityPaths.workflowFile, + command: 'bmad-shard-doc', + name: 'Shard Document', + }, + ]); + + const shardDocAuthorityValidation = await runShardDocAuthorityValidation(); + assert( + shardDocAuthorityValidation.authoritativePresenceKey === 'capability:bmad-shard-doc', + 'Shard-doc authority validation returns expected authoritative presence key', + ); + assert( + Array.isArray(shardDocAuthorityValidation.authoritativeRecords) && shardDocAuthorityValidation.authoritativeRecords.length === 2, + 'Shard-doc authority validation returns sidecar and source authority records', + ); + + const shardDocSidecarRecord = shardDocAuthorityValidation.authoritativeRecords.find( + (record) => record.authoritySourceType === 'sidecar', + ); + const shardDocSourceRecord = shardDocAuthorityValidation.authoritativeRecords.find( + (record) => record.authoritySourceType === 'source-xml', + ); + + assert( + shardDocSidecarRecord && + shardDocSourceRecord && + shardDocSidecarRecord.authoritativePresenceKey === shardDocSourceRecord.authoritativePresenceKey, + 'Shard-doc sidecar and source-xml records share one authoritative presence key', + ); + assert( + shardDocSidecarRecord && + shardDocSourceRecord && + shardDocSidecarRecord.authoritativePresenceKey === 'capability:bmad-shard-doc' && + shardDocSourceRecord.authoritativePresenceKey === 'capability:bmad-shard-doc', + 'Shard-doc authority records lock authoritative presence key to capability:bmad-shard-doc', + ); + assert( + shardDocSidecarRecord && shardDocSidecarRecord.authoritySourcePath === deterministicShardDocAuthorityPaths.sidecar, + 'Shard-doc metadata authority record preserves sidecar source path', + ); + assert( + shardDocSourceRecord && shardDocSourceRecord.authoritySourcePath === deterministicShardDocAuthorityPaths.source, + 'Shard-doc source-body authority record preserves source XML path', + ); + + await expectShardDocAuthorityValidationError( + [ + { + workflowFile: deterministicShardDocAuthorityPaths.workflowFile, + command: 'legacy-shard-doc', + name: 'Shard Document', + }, + ], + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH, + 'command', + 'Shard-doc compatibility command mismatch', + ); + + await expectShardDocAuthorityValidationError( + [ + { + workflowFile: '_bmad/core/tasks/help.md', + command: 'bmad-shard-doc', + name: 'Shard Document', + }, + ], + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_MISSING, + 'workflow-file', + 'Shard-doc missing compatibility row', + ); + + await expectShardDocAuthorityValidationError( + [ + { + workflowFile: deterministicShardDocAuthorityPaths.workflowFile, + command: 'bmad-shard-doc', + name: 'Shard Document', + }, + { + workflowFile: '_bmad/core/tasks/another.xml', + command: 'bmad-shard-doc', + name: 'Shard Document', + }, + ], + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.DUPLICATE_CANONICAL_COMMAND, + 'command', + 'Shard-doc duplicate canonical command rows', + ); + + await fs.writeFile( + tempShardDocAuthoritySidecarPath, + yaml.stringify({ + ...validShardDocAuthoritySidecar, + canonicalId: 'bmad-shard-doc-renamed', + }), + 'utf8', + ); + + await expectShardDocAuthorityValidationError( + [ + { + workflowFile: deterministicShardDocAuthorityPaths.workflowFile, + command: 'bmad-shard-doc-renamed', + name: 'Shard Document', + }, + ], + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH, + 'canonicalId', + 'Shard-doc canonicalId drift fails deterministic authority validation', + deterministicShardDocAuthorityPaths.sidecar, + ); + + await fs.writeFile(tempShardDocAuthoritySidecarPath, yaml.stringify(validShardDocAuthoritySidecar), 'utf8'); } catch (error) { assert(false, 'Authority split and precedence suite setup', error.message); } finally { @@ -592,79 +1007,453 @@ async function runTests() { 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; + // 6a: Existing help sidecar fail-fast behavior remains intact. + { + const installer = new Installer(); + let shardDocValidationCalled = false; + let shardDocAuthorityValidationCalled = false; + let helpAuthorityValidationCalled = 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.validateShardDocSidecarContractFile = async () => { + shardDocValidationCalled = true; + }; + installer.validateHelpSidecarContractFile = async () => { + const error = new Error(expectedUnsupportedMajorDetail); + error.code = HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED; + error.fieldPath = 'schemaVersion'; + error.detail = expectedUnsupportedMajorDetail; + throw error; }; - }; - installer.generateModuleConfigs = async () => { - generateConfigsCalled = true; - }; - - installer.mergeModuleHelpCatalogs = async () => { - helpCatalogGenerationCalled = true; - }; - - installer.ManifestGenerator = class ManifestGeneratorStub { - async generateManifests() { - manifestGenerationCalled = true; + installer.validateShardDocAuthoritySplitAndPrecedence = async () => { + shardDocAuthorityValidationCalled = true; return { - workflows: 0, - agents: 0, - tasks: 0, - tools: 0, + authoritativeRecords: [], + authoritativePresenceKey: 'capability:bmad-shard-doc', }; - } - }; + }; + + installer.validateHelpAuthoritySplitAndPrecedence = async () => { + helpAuthorityValidationCalled = true; + return { + authoritativeRecords: [], + authoritativePresenceKey: 'capability:bmad-help', + }; + }; + + installer.generateModuleConfigs = async () => { + generateConfigsCalled = true; + }; + + installer.mergeModuleHelpCatalogs = async () => { + helpCatalogGenerationCalled = true; + }; + + installer.ManifestGenerator = class ManifestGeneratorStub { + async generateManifests() { + manifestGenerationCalled = true; + return { + workflows: 0, + agents: 0, + tasks: 0, + tools: 0, + }; + } + }; + + try { + await installer.runConfigurationGenerationTask({ + message: () => {}, + bmadDir: tempInstallerRoot, + moduleConfigs: { core: {} }, + config: { ides: [] }, + allModules: ['core'], + addResult: () => { + successResultCount += 1; + }, + }); + assert( + false, + 'Installer fail-fast blocks projection generation on help sidecar validation failure', + 'Expected sidecar validation failure but configuration generation completed', + ); + } catch (error) { + assert( + error.code === HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED, + 'Installer fail-fast surfaces help sidecar validation error code', + `Expected ${HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED}, got ${error.code}`, + ); + assert(shardDocValidationCalled, 'Installer runs shard-doc sidecar validation before help sidecar validation'); + assert( + !shardDocAuthorityValidationCalled && + !helpAuthorityValidationCalled && + !generateConfigsCalled && + !manifestGenerationCalled && + !helpCatalogGenerationCalled, + 'Installer help fail-fast prevents downstream authority/config/manifest/help generation', + ); + assert( + successResultCount === 0, + 'Installer help fail-fast records no successful projection milestones', + `Expected 0, got ${successResultCount}`, + ); + } + } + + // 6b: Shard-doc fail-fast covers Wave-2 negative matrix classes. + { + const deterministicShardDocFailFastSourcePath = 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml'; + const shardDocFailureScenarios = [ + { + label: 'missing shard-doc sidecar file', + code: SHARD_DOC_SIDECAR_ERROR_CODES.FILE_NOT_FOUND, + fieldPath: '', + detail: 'Expected shard-doc sidecar file was not found.', + }, + { + label: 'malformed shard-doc sidecar YAML', + code: SHARD_DOC_SIDECAR_ERROR_CODES.PARSE_FAILED, + fieldPath: '', + detail: 'YAML parse failure: malformed content', + }, + { + label: 'missing shard-doc required field', + code: SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_MISSING, + fieldPath: 'canonicalId', + detail: 'Missing required sidecar field "canonicalId".', + }, + { + label: 'empty shard-doc required field', + code: SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY, + fieldPath: 'canonicalId', + detail: 'Required sidecar field "canonicalId" must be a non-empty string.', + }, + { + label: 'unsupported shard-doc sidecar major schema version', + code: SHARD_DOC_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED, + fieldPath: 'schemaVersion', + detail: expectedUnsupportedMajorDetail, + }, + { + label: 'shard-doc sourcePath basename mismatch', + code: SHARD_DOC_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH, + fieldPath: 'sourcePath', + detail: expectedBasenameMismatchDetail, + }, + ]; + + for (const scenario of shardDocFailureScenarios) { + const installer = new Installer(); + let helpValidationCalled = false; + let shardDocAuthorityValidationCalled = false; + let helpAuthorityValidationCalled = false; + let generateConfigsCalled = false; + let manifestGenerationCalled = false; + let helpCatalogGenerationCalled = false; + let successResultCount = 0; + + installer.validateShardDocSidecarContractFile = async () => { + const error = new Error(scenario.detail); + error.code = scenario.code; + error.fieldPath = scenario.fieldPath; + error.sourcePath = deterministicShardDocFailFastSourcePath; + error.detail = scenario.detail; + throw error; + }; + installer.validateHelpSidecarContractFile = async () => { + helpValidationCalled = true; + }; + installer.validateShardDocAuthoritySplitAndPrecedence = async () => { + shardDocAuthorityValidationCalled = true; + return { + authoritativeRecords: [], + authoritativePresenceKey: 'capability:bmad-shard-doc', + }; + }; + installer.validateHelpAuthoritySplitAndPrecedence = async () => { + helpAuthorityValidationCalled = true; + return { + authoritativeRecords: [], + authoritativePresenceKey: 'capability:bmad-help', + }; + }; + installer.generateModuleConfigs = async () => { + generateConfigsCalled = true; + }; + installer.mergeModuleHelpCatalogs = async () => { + helpCatalogGenerationCalled = true; + }; + installer.ManifestGenerator = class ManifestGeneratorStub { + async generateManifests() { + manifestGenerationCalled = true; + return { + workflows: 0, + agents: 0, + tasks: 0, + tools: 0, + }; + } + }; + + try { + await installer.runConfigurationGenerationTask({ + message: () => {}, + bmadDir: tempInstallerRoot, + moduleConfigs: { core: {} }, + config: { ides: [] }, + allModules: ['core'], + addResult: () => { + successResultCount += 1; + }, + }); + assert(false, `Installer fail-fast blocks projection generation on ${scenario.label}`); + } catch (error) { + assert(error.code === scenario.code, `Installer ${scenario.label} returns deterministic error code`); + assert(error.fieldPath === scenario.fieldPath, `Installer ${scenario.label} returns deterministic field path`); + assert( + error.sourcePath === deterministicShardDocFailFastSourcePath, + `Installer ${scenario.label} returns deterministic source path`, + ); + assert(!helpValidationCalled, `Installer ${scenario.label} aborts before help sidecar validation`); + assert( + !shardDocAuthorityValidationCalled && + !helpAuthorityValidationCalled && + !generateConfigsCalled && + !manifestGenerationCalled && + !helpCatalogGenerationCalled, + `Installer ${scenario.label} prevents downstream authority/config/manifest/help generation`, + ); + assert(successResultCount === 0, `Installer ${scenario.label} records no successful projection milestones`); + } + } + } + + // 6c: Shard-doc authority precedence conflict fails fast before help authority or generation. + { + const installer = new Installer(); + let helpAuthorityValidationCalled = false; + let generateConfigsCalled = false; + let manifestGenerationCalled = false; + let helpCatalogGenerationCalled = false; + let successResultCount = 0; + + installer.validateShardDocSidecarContractFile = async () => {}; + installer.validateHelpSidecarContractFile = async () => {}; + installer.validateShardDocAuthoritySplitAndPrecedence = async () => { + const error = new Error('Converted shard-doc compatibility command must match sidecar canonicalId'); + error.code = SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH; + error.fieldPath = 'command'; + error.sourcePath = 'bmad-fork/src/core/module-help.csv'; + throw error; + }; + installer.validateHelpAuthoritySplitAndPrecedence = async () => { + helpAuthorityValidationCalled = true; + return { + authoritativeRecords: [], + authoritativePresenceKey: 'capability:bmad-help', + }; + }; + installer.generateModuleConfigs = async () => { + generateConfigsCalled = true; + }; + installer.mergeModuleHelpCatalogs = async () => { + helpCatalogGenerationCalled = true; + }; + installer.ManifestGenerator = class ManifestGeneratorStub { + async generateManifests() { + manifestGenerationCalled = true; + return { + workflows: 0, + agents: 0, + tasks: 0, + tools: 0, + }; + } + }; + + try { + await installer.runConfigurationGenerationTask({ + message: () => {}, + bmadDir: tempInstallerRoot, + moduleConfigs: { core: {} }, + config: { ides: [] }, + allModules: ['core'], + addResult: () => { + successResultCount += 1; + }, + }); + assert(false, 'Installer shard-doc authority mismatch fails fast pre-projection'); + } catch (error) { + assert( + error.code === SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH, + 'Installer shard-doc authority mismatch returns deterministic error code', + ); + assert(error.fieldPath === 'command', 'Installer shard-doc authority mismatch returns deterministic field path'); + assert( + error.sourcePath === 'bmad-fork/src/core/module-help.csv', + 'Installer shard-doc authority mismatch returns deterministic source path', + ); + assert( + !helpAuthorityValidationCalled && !generateConfigsCalled && !manifestGenerationCalled && !helpCatalogGenerationCalled, + 'Installer shard-doc authority mismatch blocks downstream help authority/config/manifest/help generation', + ); + assert( + successResultCount === 2, + 'Installer shard-doc authority mismatch records only sidecar gate pass milestones before abort', + `Expected 2, got ${successResultCount}`, + ); + } + } + + // 6d: Shard-doc canonical drift fails fast before help authority or generation. + { + const installer = new Installer(); + let helpAuthorityValidationCalled = false; + let generateConfigsCalled = false; + let manifestGenerationCalled = false; + let helpCatalogGenerationCalled = false; + let successResultCount = 0; + + installer.validateShardDocSidecarContractFile = async () => {}; + installer.validateHelpSidecarContractFile = async () => {}; + installer.validateShardDocAuthoritySplitAndPrecedence = async () => { + const error = new Error('Converted shard-doc sidecar canonicalId must remain locked to bmad-shard-doc'); + error.code = SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH; + error.fieldPath = 'canonicalId'; + error.sourcePath = 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml'; + throw error; + }; + installer.validateHelpAuthoritySplitAndPrecedence = async () => { + helpAuthorityValidationCalled = true; + return { + authoritativeRecords: [], + authoritativePresenceKey: 'capability:bmad-help', + }; + }; + installer.generateModuleConfigs = async () => { + generateConfigsCalled = true; + }; + installer.mergeModuleHelpCatalogs = async () => { + helpCatalogGenerationCalled = true; + }; + installer.ManifestGenerator = class ManifestGeneratorStub { + async generateManifests() { + manifestGenerationCalled = true; + return { + workflows: 0, + agents: 0, + tasks: 0, + tools: 0, + }; + } + }; + + try { + await installer.runConfigurationGenerationTask({ + message: () => {}, + bmadDir: tempInstallerRoot, + moduleConfigs: { core: {} }, + config: { ides: [] }, + allModules: ['core'], + addResult: () => { + successResultCount += 1; + }, + }); + assert(false, 'Installer shard-doc canonical drift fails fast pre-projection'); + } catch (error) { + assert( + error.code === SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH, + 'Installer shard-doc canonical drift returns deterministic error code', + ); + assert(error.fieldPath === 'canonicalId', 'Installer shard-doc canonical drift returns deterministic field path'); + assert( + error.sourcePath === 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + 'Installer shard-doc canonical drift returns deterministic source path', + ); + assert( + !helpAuthorityValidationCalled && !generateConfigsCalled && !manifestGenerationCalled && !helpCatalogGenerationCalled, + 'Installer shard-doc canonical drift blocks downstream help authority/config/manifest/help generation', + ); + assert( + successResultCount === 2, + 'Installer shard-doc canonical drift records only sidecar gate pass milestones before abort', + `Expected 2, got ${successResultCount}`, + ); + } + } + + // 6e: Valid sidecars preserve fail-fast ordering and allow generation path. + { + const installer = new Installer(); + const executionOrder = []; + const resultMilestones = []; + + installer.validateShardDocSidecarContractFile = async () => { + executionOrder.push('shard-doc-sidecar'); + }; + installer.validateHelpSidecarContractFile = async () => { + executionOrder.push('help-sidecar'); + }; + installer.validateShardDocAuthoritySplitAndPrecedence = async () => { + executionOrder.push('shard-doc-authority'); + return { + authoritativeRecords: [], + authoritativePresenceKey: 'capability:bmad-shard-doc', + }; + }; + installer.validateHelpAuthoritySplitAndPrecedence = async () => { + executionOrder.push('help-authority'); + return { + authoritativeRecords: [], + authoritativePresenceKey: 'capability:bmad-help', + }; + }; + installer.generateModuleConfigs = async () => { + executionOrder.push('config-generation'); + }; + installer.mergeModuleHelpCatalogs = async () => { + executionOrder.push('help-catalog-generation'); + }; + installer.ManifestGenerator = class ManifestGeneratorStub { + async generateManifests() { + executionOrder.push('manifest-generation'); + return { + workflows: 0, + agents: 0, + tasks: 0, + tools: 0, + }; + } + }; - try { await installer.runConfigurationGenerationTask({ message: () => {}, bmadDir: tempInstallerRoot, moduleConfigs: { core: {} }, config: { ides: [] }, allModules: ['core'], - addResult: () => { - successResultCount += 1; + addResult: (name) => { + resultMilestones.push(name); }, }); + 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}`, + executionOrder.join(' -> ') === + 'shard-doc-sidecar -> help-sidecar -> shard-doc-authority -> help-authority -> config-generation -> manifest-generation -> help-catalog-generation', + 'Installer valid sidecar path preserves fail-fast gate ordering and continues generation flow', + `Observed order: ${executionOrder.join(' -> ')}`, ); assert( - !authorityValidationCalled && !generateConfigsCalled && !manifestGenerationCalled && !helpCatalogGenerationCalled, - 'Installer fail-fast prevents downstream authority/config/manifest/help generation', + resultMilestones.includes('Shard-doc sidecar contract'), + 'Installer valid sidecar path records explicit shard-doc sidecar gate pass milestone', ); assert( - successResultCount === 0, - 'Installer fail-fast records no successful projection milestones', - `Expected 0, got ${successResultCount}`, + resultMilestones.includes('Shard-doc authority split'), + 'Installer valid sidecar path records explicit shard-doc authority gate pass milestone', ); } } catch (error) { @@ -950,6 +1739,78 @@ async function runTests() { 'alias tuple resolved ambiguously to multiple canonical alias rows', ); + const shardDocAliasRows = [ + { + rowIdentity: 'alias-row:bmad-shard-doc:canonical-id', + canonicalId: 'bmad-shard-doc', + normalizedAliasValue: 'bmad-shard-doc', + rawIdentityHasLeadingSlash: false, + }, + { + rowIdentity: 'alias-row:bmad-shard-doc:legacy-name', + canonicalId: 'bmad-shard-doc', + normalizedAliasValue: 'shard-doc', + rawIdentityHasLeadingSlash: false, + }, + { + rowIdentity: 'alias-row:bmad-shard-doc:slash-command', + canonicalId: 'bmad-shard-doc', + normalizedAliasValue: 'bmad-shard-doc', + rawIdentityHasLeadingSlash: true, + }, + ]; + + const shardDocSlashResolution = await normalizeAndResolveExemplarAlias('/bmad-shard-doc', { + fieldPath: 'canonicalId', + sourcePath: deterministicAliasTableSourcePath, + aliasRows: shardDocAliasRows, + aliasTableSourcePath: deterministicAliasTableSourcePath, + }); + assert( + shardDocSlashResolution.postAliasCanonicalId === 'bmad-shard-doc' && + shardDocSlashResolution.aliasRowLocator === 'alias-row:bmad-shard-doc:slash-command', + 'Alias resolver normalizes shard-doc slash-command tuple with explicit shard-doc alias rows', + ); + + await expectAliasNormalizationError( + () => + normalizeAndResolveExemplarAlias('/bmad-shard-doc', { + fieldPath: 'canonicalId', + sourcePath: deterministicAliasTableSourcePath, + aliasRows: LOCKED_EXEMPLAR_ALIAS_ROWS, + aliasTableSourcePath: deterministicAliasTableSourcePath, + }), + HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED, + 'preAliasNormalizedValue', + 'bmad-shard-doc|leadingSlash:true', + 'Shard-doc alias tuple unresolved without shard-doc alias table rows', + 'alias tuple did not resolve to any canonical alias row', + ); + + const ambiguousShardDocRows = [ + ...shardDocAliasRows, + { + rowIdentity: 'alias-row:bmad-shard-doc:slash-command:duplicate', + canonicalId: 'bmad-shard-doc-alt', + normalizedAliasValue: 'bmad-shard-doc', + rawIdentityHasLeadingSlash: true, + }, + ]; + await expectAliasNormalizationError( + () => + normalizeAndResolveExemplarAlias('/bmad-shard-doc', { + fieldPath: 'canonicalId', + sourcePath: deterministicAliasTableSourcePath, + aliasRows: ambiguousShardDocRows, + aliasTableSourcePath: deterministicAliasTableSourcePath, + }), + HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED, + 'preAliasNormalizedValue', + 'bmad-shard-doc|leadingSlash:true', + 'Shard-doc alias tuple ambiguous when duplicate shard-doc slash-command rows exist', + 'alias tuple resolved ambiguously to multiple canonical alias rows', + ); + const tempAliasTableRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-canonical-alias-table-')); const tempAliasTablePath = path.join(tempAliasTableRoot, 'canonical-aliases.csv'); const csvRows = [ @@ -1060,6 +1921,14 @@ async function runTests() { path: 'core/tasks/validate-workflow.xml', standalone: true, }, + { + name: 'shard-doc', + displayName: 'Shard Document', + description: 'Split large markdown documents into smaller files by section with an index.', + module: 'core', + path: 'core/tasks/shard-doc.xml', + standalone: true, + }, ]; manifestGenerator.helpAuthorityRecords = [ { @@ -1071,7 +1940,17 @@ async function runTests() { sourcePath: 'bmad-fork/src/core/tasks/help.md', }, ]; - + manifestGenerator.taskAuthorityRecords = [ + ...manifestGenerator.helpAuthorityRecords, + { + recordType: 'metadata-authority', + canonicalId: 'bmad-shard-doc', + authoritativePresenceKey: 'capability:bmad-shard-doc', + authoritySourceType: 'sidecar', + authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml', + }, + ]; const tempTaskManifestConfigDir = path.join(tempTaskManifestRoot, '_config'); await fs.ensureDir(tempTaskManifestConfigDir); await manifestGenerator.writeTaskManifest(tempTaskManifestConfigDir); @@ -1093,6 +1972,7 @@ async function runTests() { }); const helpTaskRow = writtenTaskManifestRecords.find((record) => record.module === 'core' && record.name === 'help'); const validateTaskRow = writtenTaskManifestRecords.find((record) => record.module === 'core' && record.name === 'validate-workflow'); + const shardDocTaskRow = writtenTaskManifestRecords.find((record) => record.module === 'core' && record.name === 'shard-doc'); assert(!!helpTaskRow, 'Task manifest includes exemplar help row'); assert(helpTaskRow && helpTaskRow.legacyName === 'help', 'Task manifest help row sets legacyName=help'); @@ -1108,13 +1988,61 @@ async function runTests() { validateTaskRow && validateTaskRow.legacyName === 'validate-workflow', 'Task manifest non-exemplar rows remain additive-compatible with default legacyName', ); + assert(!!shardDocTaskRow, 'Task manifest includes converted shard-doc row'); + assert(shardDocTaskRow && shardDocTaskRow.legacyName === 'shard-doc', 'Task manifest shard-doc row sets legacyName=shard-doc'); + assert( + shardDocTaskRow && shardDocTaskRow.canonicalId === 'bmad-shard-doc', + 'Task manifest shard-doc row sets canonicalId=bmad-shard-doc', + ); + assert( + shardDocTaskRow && shardDocTaskRow.authoritySourceType === 'sidecar', + 'Task manifest shard-doc row sets authoritySourceType=sidecar', + ); + assert( + shardDocTaskRow && shardDocTaskRow.authoritySourcePath === 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + 'Task manifest shard-doc row sets authoritySourcePath to shard-doc sidecar source path', + ); + + await manifestGenerator.writeTaskManifest(tempTaskManifestConfigDir); + const repeatedTaskManifestRaw = await fs.readFile(path.join(tempTaskManifestConfigDir, 'task-manifest.csv'), 'utf8'); + assert( + repeatedTaskManifestRaw === writtenTaskManifestRaw, + 'Task manifest shard-doc canonical row values remain deterministic across repeated generation runs', + ); let capturedAuthorityValidationOptions = null; + let capturedShardDocAuthorityValidationOptions = null; let capturedManifestHelpAuthorityRecords = null; + let capturedManifestTaskAuthorityRecords = null; let capturedInstalledFiles = null; const installer = new Installer(); + installer.validateShardDocSidecarContractFile = async () => {}; installer.validateHelpSidecarContractFile = async () => {}; + installer.validateShardDocAuthoritySplitAndPrecedence = async (options) => { + capturedShardDocAuthorityValidationOptions = options; + return { + authoritativePresenceKey: 'capability:bmad-shard-doc', + authoritativeRecords: [ + { + recordType: 'metadata-authority', + canonicalId: 'bmad-shard-doc', + authoritativePresenceKey: 'capability:bmad-shard-doc', + authoritySourceType: 'sidecar', + authoritySourcePath: options.sidecarSourcePath, + sourcePath: options.sourceXmlSourcePath, + }, + { + recordType: 'source-body-authority', + canonicalId: 'bmad-shard-doc', + authoritativePresenceKey: 'capability:bmad-shard-doc', + authoritySourceType: 'source-xml', + authoritySourcePath: options.sourceXmlSourcePath, + sourcePath: options.sourceXmlSourcePath, + }, + ], + }; + }; installer.validateHelpAuthoritySplitAndPrecedence = async (options) => { capturedAuthorityValidationOptions = options; return { @@ -1137,6 +2065,7 @@ async function runTests() { async generateManifests(_bmadDir, _selectedModules, _installedFiles, options = {}) { capturedInstalledFiles = _installedFiles; capturedManifestHelpAuthorityRecords = options.helpAuthorityRecords; + capturedManifestTaskAuthorityRecords = options.taskAuthorityRecords; return { workflows: 0, agents: 0, @@ -1169,12 +2098,38 @@ async function runTests() { capturedAuthorityValidationOptions && capturedAuthorityValidationOptions.runtimeMarkdownSourcePath === '_bmad/core/tasks/help.md', 'Installer passes locked runtime markdown path to authority validation', ); + assert( + capturedShardDocAuthorityValidationOptions && + capturedShardDocAuthorityValidationOptions.sidecarSourcePath === 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + 'Installer passes locked shard-doc sidecar source path to shard-doc authority validation', + ); + assert( + capturedShardDocAuthorityValidationOptions && + capturedShardDocAuthorityValidationOptions.sourceXmlSourcePath === 'bmad-fork/src/core/tasks/shard-doc.xml', + 'Installer passes locked shard-doc source XML path to shard-doc authority validation', + ); + assert( + capturedShardDocAuthorityValidationOptions && + capturedShardDocAuthorityValidationOptions.compatibilityCatalogSourcePath === 'bmad-fork/src/core/module-help.csv', + 'Installer passes locked module-help source path to shard-doc authority validation', + ); assert( 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(capturedManifestTaskAuthorityRecords) && + capturedManifestTaskAuthorityRecords.some( + (record) => + record && + record.canonicalId === 'bmad-shard-doc' && + record.authoritySourceType === 'sidecar' && + record.authoritySourcePath === 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + ), + 'Installer passes shard-doc sidecar authority records into task-manifest projection options', + ); assert( Array.isArray(capturedInstalledFiles) && capturedInstalledFiles.some((filePath) => filePath.endsWith('/_config/canonical-aliases.csv')), @@ -1208,6 +2163,17 @@ async function runTests() { sourcePath: 'bmad-fork/src/core/tasks/help.md', }, ]; + manifestGenerator.taskAuthorityRecords = [ + ...manifestGenerator.helpAuthorityRecords, + { + recordType: 'metadata-authority', + canonicalId: 'bmad-shard-doc', + authoritativePresenceKey: 'capability:bmad-shard-doc', + authoritySourceType: 'sidecar', + authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml', + }, + ]; const tempCanonicalAliasConfigDir = path.join(tempCanonicalAliasRoot, '_config'); await fs.ensureDir(tempCanonicalAliasConfigDir); @@ -1227,96 +2193,142 @@ async function runTests() { skip_empty_lines: true, trim: true, }); - assert(canonicalAliasRows.length === 3, 'Canonical alias table emits exactly three exemplar rows'); + assert(canonicalAliasRows.length === 6, 'Canonical alias table emits help + shard-doc canonical alias exemplar rows'); assert( - canonicalAliasRows.map((row) => row.aliasType).join(',') === 'canonical-id,legacy-name,slash-command', + canonicalAliasRows.map((row) => row.aliasType).join(',') === + 'canonical-id,legacy-name,slash-command,canonical-id,legacy-name,slash-command', 'Canonical alias table preserves locked deterministic row ordering', ); - const expectedRowsByType = new Map([ + const expectedRowsByIdentity = new Map([ [ - 'canonical-id', + 'alias-row:bmad-help:canonical-id', { canonicalId: 'bmad-help', alias: 'bmad-help', - rowIdentity: 'alias-row:bmad-help:canonical-id', + aliasType: 'canonical-id', + authoritySourcePath: 'bmad-fork/src/core/tasks/help.artifact.yaml', normalizedAliasValue: 'bmad-help', rawIdentityHasLeadingSlash: 'false', resolutionEligibility: 'canonical-id-only', }, ], [ - 'legacy-name', + 'alias-row:bmad-help:legacy-name', { canonicalId: 'bmad-help', alias: 'help', - rowIdentity: 'alias-row:bmad-help:legacy-name', + aliasType: 'legacy-name', + authoritySourcePath: 'bmad-fork/src/core/tasks/help.artifact.yaml', normalizedAliasValue: 'help', rawIdentityHasLeadingSlash: 'false', resolutionEligibility: 'legacy-name-only', }, ], [ - 'slash-command', + 'alias-row:bmad-help:slash-command', { canonicalId: 'bmad-help', alias: '/bmad-help', - rowIdentity: 'alias-row:bmad-help:slash-command', + aliasType: 'slash-command', + authoritySourcePath: 'bmad-fork/src/core/tasks/help.artifact.yaml', normalizedAliasValue: 'bmad-help', rawIdentityHasLeadingSlash: 'true', resolutionEligibility: 'slash-command-only', }, ], + [ + 'alias-row:bmad-shard-doc:canonical-id', + { + canonicalId: 'bmad-shard-doc', + alias: 'bmad-shard-doc', + aliasType: 'canonical-id', + authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + normalizedAliasValue: 'bmad-shard-doc', + rawIdentityHasLeadingSlash: 'false', + resolutionEligibility: 'canonical-id-only', + }, + ], + [ + 'alias-row:bmad-shard-doc:legacy-name', + { + canonicalId: 'bmad-shard-doc', + alias: 'shard-doc', + aliasType: 'legacy-name', + authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + normalizedAliasValue: 'shard-doc', + rawIdentityHasLeadingSlash: 'false', + resolutionEligibility: 'legacy-name-only', + }, + ], + [ + 'alias-row:bmad-shard-doc:slash-command', + { + canonicalId: 'bmad-shard-doc', + alias: '/bmad-shard-doc', + aliasType: 'slash-command', + authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + normalizedAliasValue: 'bmad-shard-doc', + 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`); + for (const [rowIdentity, expectedRow] of expectedRowsByIdentity) { + const matchingRows = canonicalAliasRows.filter((row) => row.rowIdentity === rowIdentity); + assert(matchingRows.length === 1, `Canonical alias table emits exactly one ${rowIdentity} exemplar row`); const row = matchingRows[0]; assert( - row && row.authoritySourceType === 'sidecar' && row.authoritySourcePath === 'bmad-fork/src/core/tasks/help.artifact.yaml', - `${aliasType} exemplar row uses sidecar provenance fields`, + row && row.authoritySourceType === 'sidecar' && row.authoritySourcePath === expectedRow.authoritySourcePath, + `${rowIdentity} exemplar row uses locked sidecar provenance`, ); - 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.canonicalId === expectedRow.canonicalId, `${rowIdentity} exemplar row locks canonicalId contract`); + assert(row && row.alias === expectedRow.alias, `${rowIdentity} exemplar row locks alias contract`); + assert(row && row.aliasType === expectedRow.aliasType, `${rowIdentity} exemplar row locks aliasType contract`); + assert(row && row.rowIdentity === rowIdentity, `${rowIdentity} exemplar row locks rowIdentity contract`); assert( row && row.normalizedAliasValue === expectedRow.normalizedAliasValue, - `${aliasType} exemplar row locks normalizedAliasValue contract`, + `${rowIdentity} exemplar row locks normalizedAliasValue contract`, ); assert( row && row.rawIdentityHasLeadingSlash === expectedRow.rawIdentityHasLeadingSlash, - `${aliasType} exemplar row locks rawIdentityHasLeadingSlash contract`, + `${rowIdentity} exemplar row locks rawIdentityHasLeadingSlash contract`, ); assert( row && row.resolutionEligibility === expectedRow.resolutionEligibility, - `${aliasType} exemplar row locks resolutionEligibility contract`, + `${rowIdentity} 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); + for (const [rowIdentity, expectedRow] of expectedRowsByIdentity) { + const matchingRows = rows.filter((row) => row.rowIdentity === rowIdentity); if (matchingRows.length === 0) { - return { valid: false, reason: `missing:${aliasType}` }; + return { valid: false, reason: `missing:${rowIdentity}` }; } if (matchingRows.length > 1) { - return { valid: false, reason: `conflict:${aliasType}` }; + return { valid: false, reason: `conflict:${rowIdentity}` }; } const row = matchingRows[0]; if ( - row.rowIdentity !== expectedRow.rowIdentity || + row.canonicalId !== expectedRow.canonicalId || + row.alias !== expectedRow.alias || + row.aliasType !== expectedRow.aliasType || + row.authoritySourceType !== 'sidecar' || + row.authoritySourcePath !== expectedRow.authoritySourcePath || + row.rowIdentity !== rowIdentity || row.normalizedAliasValue !== expectedRow.normalizedAliasValue || row.rawIdentityHasLeadingSlash !== expectedRow.rawIdentityHasLeadingSlash || row.resolutionEligibility !== expectedRow.resolutionEligibility ) { - return { valid: false, reason: `conflict:${aliasType}` }; + return { valid: false, reason: `conflict:${rowIdentity}` }; } } - if (rows.length !== expectedRowsByType.size) { + if (rows.length !== expectedRowsByIdentity.size) { return { valid: false, reason: 'conflict:extra-rows' }; } @@ -1330,23 +2342,22 @@ async function runTests() { baselineProjectionValidation.reason, ); - const missingLegacyRows = canonicalAliasRows.filter((row) => row.aliasType !== 'legacy-name'); + const missingLegacyRows = canonicalAliasRows.filter((row) => row.rowIdentity !== 'alias-row:bmad-shard-doc: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', + !missingLegacyValidation.valid && missingLegacyValidation.reason === 'missing:alias-row:bmad-shard-doc:legacy-name', + 'Canonical alias projection validator fails when required shard-doc legacy-name row is missing', ); const conflictingRows = [ ...canonicalAliasRows, { - ...canonicalAliasRows.find((row) => row.aliasType === 'slash-command'), - rowIdentity: 'alias-row:bmad-help:slash-command:duplicate', + ...canonicalAliasRows.find((row) => row.rowIdentity === 'alias-row:bmad-help:slash-command'), }, ]; const conflictingValidation = validateLockedCanonicalAliasProjection(conflictingRows); assert( - !conflictingValidation.valid && conflictingValidation.reason === 'conflict:slash-command', + !conflictingValidation.valid && conflictingValidation.reason === 'conflict:alias-row:bmad-help:slash-command', 'Canonical alias projection validator fails when conflicting duplicate exemplar rows appear', ); @@ -1354,6 +2365,8 @@ async function runTests() { fallbackManifestGenerator.bmadDir = tempCanonicalAliasRoot; fallbackManifestGenerator.bmadFolderName = '_bmad'; fallbackManifestGenerator.helpAuthorityRecords = []; + fallbackManifestGenerator.taskAuthorityRecords = []; + fallbackManifestGenerator.includeConvertedShardDocAliasRows = true; const fallbackCanonicalAliasPath = await fallbackManifestGenerator.writeCanonicalAliasManifest(tempCanonicalAliasConfigDir); const fallbackCanonicalAliasRaw = await fs.readFile(fallbackCanonicalAliasPath, 'utf8'); const fallbackCanonicalAliasRows = csv.parse(fallbackCanonicalAliasRaw, { @@ -1362,9 +2375,18 @@ async function runTests() { trim: true, }); assert( - fallbackCanonicalAliasRows.every( - (row) => row.authoritySourceType === 'sidecar' && row.authoritySourcePath === 'bmad-fork/src/core/tasks/help.artifact.yaml', - ), + fallbackCanonicalAliasRows.every((row) => { + if (row.authoritySourceType !== 'sidecar') { + return false; + } + if (row.canonicalId === 'bmad-help') { + return row.authoritySourcePath === 'bmad-fork/src/core/tasks/help.artifact.yaml'; + } + if (row.canonicalId === 'bmad-shard-doc') { + return row.authoritySourcePath === 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml'; + } + return false; + }), 'Canonical alias table falls back to locked sidecar provenance when authority records are unavailable', ); @@ -1378,6 +2400,7 @@ async function runTests() { ides: [], preservedModules: [], helpAuthorityRecords: manifestGenerator.helpAuthorityRecords, + taskAuthorityRecords: manifestGenerator.taskAuthorityRecords, }, ); @@ -1477,11 +2500,17 @@ async function runTests() { }); const exemplarRows = generatedHelpRows.filter((row) => row.command === 'bmad-help'); + const shardDocRows = generatedHelpRows.filter((row) => row.command === 'bmad-shard-doc'); assert(exemplarRows.length === 1, 'Help catalog emits exactly one exemplar raw command row for bmad-help'); assert( exemplarRows[0] && exemplarRows[0].name === 'bmad-help', 'Help catalog exemplar row preserves locked bmad-help workflow identity', ); + assert(shardDocRows.length === 1, 'Help catalog emits exactly one shard-doc raw command row for bmad-shard-doc'); + assert( + shardDocRows[0] && shardDocRows[0]['workflow-file'] === '_bmad/core/tasks/shard-doc.xml', + 'Help catalog shard-doc row preserves locked shard-doc workflow identity', + ); const sidecarRaw = await fs.readFile(path.join(projectRoot, 'src', 'core', 'tasks', 'help.artifact.yaml'), 'utf8'); const sidecarData = yaml.parse(sidecarRaw); @@ -1491,30 +2520,50 @@ async function runTests() { ); const commandLabelRows = installer.helpCatalogCommandLabelReportRows || []; - assert(commandLabelRows.length === 1, 'Installer emits one command-label report row for exemplar canonical id'); + const helpCommandLabelRow = commandLabelRows.find((row) => row.canonicalId === 'bmad-help'); + const shardDocCommandLabelRow = commandLabelRows.find((row) => row.canonicalId === 'bmad-shard-doc'); + assert(commandLabelRows.length === 2, 'Installer emits command-label report rows for help and shard-doc canonical ids'); assert( - commandLabelRows[0] && - commandLabelRows[0].rawCommandValue === 'bmad-help' && - commandLabelRows[0].displayedCommandLabel === '/bmad-help', + helpCommandLabelRow && + helpCommandLabelRow.rawCommandValue === 'bmad-help' && + helpCommandLabelRow.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', + helpCommandLabelRow && + helpCommandLabelRow.authoritySourceType === 'sidecar' && + helpCommandLabelRow.authoritySourcePath === 'bmad-fork/src/core/tasks/help.artifact.yaml', 'Command-label report includes sidecar provenance linkage', ); + assert( + shardDocCommandLabelRow && + shardDocCommandLabelRow.rawCommandValue === 'bmad-shard-doc' && + shardDocCommandLabelRow.displayedCommandLabel === '/bmad-shard-doc', + 'Command-label report locks raw and displayed command values for shard-doc', + ); + assert( + shardDocCommandLabelRow && + shardDocCommandLabelRow.authoritySourceType === 'sidecar' && + shardDocCommandLabelRow.authoritySourcePath === 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + 'Command-label report includes shard-doc sidecar provenance linkage', + ); const generatedCommandLabelReportRaw = await fs.readFile(generatedCommandLabelReportPath, 'utf8'); const generatedCommandLabelReportRows = csv.parse(generatedCommandLabelReportRaw, { columns: true, skip_empty_lines: true, trim: true, }); + const generatedHelpCommandLabelRow = generatedCommandLabelReportRows.find((row) => row.canonicalId === 'bmad-help'); + const generatedShardDocCommandLabelRow = generatedCommandLabelReportRows.find((row) => row.canonicalId === 'bmad-shard-doc'); 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', + generatedCommandLabelReportRows.length === 2 && + generatedHelpCommandLabelRow && + generatedHelpCommandLabelRow.displayedCommandLabel === '/bmad-help' && + generatedHelpCommandLabelRow.rowCountForCanonicalId === '1' && + generatedShardDocCommandLabelRow && + generatedShardDocCommandLabelRow.displayedCommandLabel === '/bmad-shard-doc' && + generatedShardDocCommandLabelRow.rowCountForCanonicalId === '1', + 'Installer persists command-label report artifact with locked help and shard-doc label contract values', ); const baselineLabelContract = evaluateExemplarCommandLabelReportRows(commandLabelRows); @@ -1523,10 +2572,100 @@ async function runTests() { 'Command-label validator passes when exactly one exemplar /bmad-help displayed label row exists', baselineLabelContract.reason, ); + const baselineShardDocLabelContract = evaluateExemplarCommandLabelReportRows(commandLabelRows, { + canonicalId: 'bmad-shard-doc', + displayedCommandLabel: '/bmad-shard-doc', + authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + }); + assert( + baselineShardDocLabelContract.valid, + 'Command-label validator passes when exactly one /bmad-shard-doc displayed label row exists', + baselineShardDocLabelContract.reason, + ); + + const commandDocsSourcePath = path.join(projectRoot, 'docs', 'reference', 'commands.md'); + const commandDocsMarkdown = await fs.readFile(commandDocsSourcePath, 'utf8'); + const commandDocConsistency = validateCommandDocSurfaceConsistency(commandDocsMarkdown, { + sourcePath: 'docs/reference/commands.md', + generatedSurfacePath: '_bmad/_config/bmad-help-command-label-report.csv', + commandLabelRows, + canonicalId: 'bmad-shard-doc', + expectedDisplayedCommandLabel: '/bmad-shard-doc', + disallowedAliasLabels: ['/shard-doc'], + }); + assert( + commandDocConsistency.generatedCanonicalCommand === '/bmad-shard-doc', + 'Command-doc consistency validator passes when generated shard-doc command matches command docs canonical label', + ); + + const missingCanonicalCommandDocsMarkdown = commandDocsMarkdown.replace( + '| `/bmad-shard-doc` | Split a large markdown file into smaller sections |', + '| `/bmad-shard-doc-renamed` | Split a large markdown file into smaller sections |', + ); + try { + validateCommandDocSurfaceConsistency(missingCanonicalCommandDocsMarkdown, { + sourcePath: 'docs/reference/commands.md', + generatedSurfacePath: '_bmad/_config/bmad-help-command-label-report.csv', + commandLabelRows, + canonicalId: 'bmad-shard-doc', + expectedDisplayedCommandLabel: '/bmad-shard-doc', + disallowedAliasLabels: ['/shard-doc'], + }); + assert(false, 'Command-doc consistency validator rejects missing canonical shard-doc command rows'); + } catch (error) { + assert( + error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_CANONICAL_COMMAND_MISSING, + 'Command-doc consistency validator emits deterministic diagnostics for missing canonical shard-doc command docs row', + `Expected ${PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_CANONICAL_COMMAND_MISSING}, got ${error.code}`, + ); + } + + const aliasAmbiguousCommandDocsMarkdown = `${commandDocsMarkdown}\n| \`/shard-doc\` | Legacy alias |\n`; + try { + validateCommandDocSurfaceConsistency(aliasAmbiguousCommandDocsMarkdown, { + sourcePath: 'docs/reference/commands.md', + generatedSurfacePath: '_bmad/_config/bmad-help-command-label-report.csv', + commandLabelRows, + canonicalId: 'bmad-shard-doc', + expectedDisplayedCommandLabel: '/bmad-shard-doc', + disallowedAliasLabels: ['/shard-doc'], + }); + assert(false, 'Command-doc consistency validator rejects shard-doc alias ambiguity in command docs'); + } catch (error) { + assert( + error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_ALIAS_AMBIGUOUS, + 'Command-doc consistency validator emits deterministic diagnostics for shard-doc alias ambiguity in command docs', + `Expected ${PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_ALIAS_AMBIGUOUS}, got ${error.code}`, + ); + } + + try { + validateCommandDocSurfaceConsistency(commandDocsMarkdown, { + sourcePath: 'docs/reference/commands.md', + generatedSurfacePath: '_bmad/_config/bmad-help-command-label-report.csv', + commandLabelRows: [ + helpCommandLabelRow, + { + ...shardDocCommandLabelRow, + displayedCommandLabel: '/shard-doc', + }, + ], + canonicalId: 'bmad-shard-doc', + expectedDisplayedCommandLabel: '/bmad-shard-doc', + disallowedAliasLabels: ['/shard-doc'], + }); + assert(false, 'Command-doc consistency validator rejects generated shard-doc command-label drift'); + } catch (error) { + assert( + error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_GENERATED_SURFACE_MISMATCH, + 'Command-doc consistency validator emits deterministic diagnostics for generated shard-doc command-label drift', + `Expected ${PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_GENERATED_SURFACE_MISMATCH}, got ${error.code}`, + ); + } const invalidLegacyLabelContract = evaluateExemplarCommandLabelReportRows([ { - ...commandLabelRows[0], + ...helpCommandLabelRow, displayedCommandLabel: 'help', }, ]); @@ -1537,7 +2676,7 @@ async function runTests() { const invalidSlashHelpLabelContract = evaluateExemplarCommandLabelReportRows([ { - ...commandLabelRows[0], + ...helpCommandLabelRow, displayedCommandLabel: '/help', }, ]); @@ -1546,6 +2685,25 @@ async function runTests() { 'Command-label validator fails on alternate displayed label form "/help"', ); + const invalidShardDocLabelContract = evaluateExemplarCommandLabelReportRows( + [ + helpCommandLabelRow, + { + ...shardDocCommandLabelRow, + displayedCommandLabel: '/shard-doc', + }, + ], + { + canonicalId: 'bmad-shard-doc', + displayedCommandLabel: '/bmad-shard-doc', + authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml', + }, + ); + assert( + !invalidShardDocLabelContract.valid && invalidShardDocLabelContract.reason === 'invalid-displayed-label:/shard-doc', + 'Command-label validator fails on alternate shard-doc displayed label form "/shard-doc"', + ); + const pipelineRows = installer.helpCatalogPipelineRows || []; assert(pipelineRows.length === 2, 'Installer emits two stage rows for help catalog pipeline evidence linkage'); const installedStageRow = pipelineRows.find((row) => row.stage === 'installed-compatibility-row'); @@ -1650,6 +2808,20 @@ async function runTests() { }), 'utf8', ); + await fs.writeFile( + path.join(tempExportRoot, 'bmad-fork', 'src', 'core', 'tasks', 'shard-doc.artifact.yaml'), + yaml.stringify({ + schemaVersion: 1, + canonicalId: 'bmad-shard-doc', + artifactType: 'task', + module: 'core', + sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml', + displayName: 'Shard Document', + description: 'Split large markdown documents into smaller files by section with an index.', + dependencies: { requires: [] }, + }), + 'utf8', + ); const exemplarTaskArtifact = { type: 'task', @@ -1659,6 +2831,14 @@ async function runTests() { relativePath: path.join('core', 'tasks', 'help.md'), content: '---\nname: help\ndescription: Help command\ncanonicalId: bmad-help\n---\n\n# help\n', }; + const shardDocTaskArtifact = { + type: 'task', + name: 'shard-doc', + module: 'core', + sourcePath: path.join(tempExportRoot, '_bmad', 'core', 'tasks', 'shard-doc.xml'), + relativePath: path.join('core', 'tasks', 'shard-doc.md'), + content: 'Split markdown docs\n', + }; const writtenCount = await codexSetup.writeSkillArtifacts(skillsDir, [exemplarTaskArtifact], 'task', { projectDir: tempExportRoot, @@ -1688,6 +2868,64 @@ async function runTests() { 'Codex export records exemplar derivation source metadata from sidecar canonical-id', ); + const shardDocWrittenCount = await codexSetup.writeSkillArtifacts(skillsDir, [shardDocTaskArtifact], 'task', { + projectDir: tempExportRoot, + }); + assert(shardDocWrittenCount === 1, 'Codex export writes one shard-doc converted skill artifact'); + + const shardDocSkillPath = path.join(skillsDir, 'bmad-shard-doc', 'SKILL.md'); + assert(await fs.pathExists(shardDocSkillPath), 'Codex export derives shard-doc skill path from sidecar canonical identity'); + + const shardDocSkillRaw = await fs.readFile(shardDocSkillPath, 'utf8'); + const shardDocFrontmatterMatch = shardDocSkillRaw.match(/^---\r?\n([\s\S]*?)\r?\n---/); + const shardDocFrontmatter = shardDocFrontmatterMatch ? yaml.parse(shardDocFrontmatterMatch[1]) : null; + assert( + shardDocFrontmatter && shardDocFrontmatter.name === 'bmad-shard-doc', + 'Codex export frontmatter sets shard-doc required name from sidecar canonical identity', + ); + + const shardDocExportDerivationRecord = codexSetup.exportDerivationRecords.find( + (row) => row.exportPath === '.agents/skills/bmad-shard-doc/SKILL.md', + ); + assert( + shardDocExportDerivationRecord && + shardDocExportDerivationRecord.exportIdDerivationSourceType === EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE && + shardDocExportDerivationRecord.exportIdDerivationSourcePath === 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml' && + shardDocExportDerivationRecord.sourcePath === 'bmad-fork/src/core/tasks/shard-doc.xml', + 'Codex export records shard-doc sidecar-canonical derivation metadata and source path', + ); + + const duplicateExportSetup = new CodexSetup(); + const duplicateSkillDir = path.join(tempExportRoot, '.agents', 'skills-duplicate-check'); + await fs.ensureDir(duplicateSkillDir); + try { + await duplicateExportSetup.writeSkillArtifacts( + duplicateSkillDir, + [ + shardDocTaskArtifact, + { + ...shardDocTaskArtifact, + content: 'Duplicate shard-doc export artifact\n', + }, + ], + 'task', + { + projectDir: tempExportRoot, + }, + ); + assert( + false, + 'Codex export rejects duplicate shard-doc canonical-id skill export surfaces', + 'Expected duplicate export-surface failure but export succeeded', + ); + } catch (error) { + assert( + error.code === CODEX_EXPORT_DERIVATION_ERROR_CODES.DUPLICATE_EXPORT_SURFACE, + 'Codex export duplicate shard-doc canonical-id rejection returns deterministic failure code', + `Expected ${CODEX_EXPORT_DERIVATION_ERROR_CODES.DUPLICATE_EXPORT_SURFACE}, got ${error.code}`, + ); + } + const tempSubmoduleRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-export-submodule-root-')); try { const submoduleRootSetup = new CodexSetup(); @@ -1792,6 +3030,47 @@ async function runTests() { await fs.remove(tempInferenceRoot); } + const tempShardDocInferenceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-export-no-shard-doc-inference-')); + try { + const noShardDocInferenceSetup = new CodexSetup(); + const noShardDocInferenceSkillDir = path.join(tempShardDocInferenceRoot, '.agents', 'skills'); + await fs.ensureDir(noShardDocInferenceSkillDir); + await fs.ensureDir(path.join(tempShardDocInferenceRoot, 'bmad-fork', 'src', 'core', 'tasks')); + await fs.writeFile( + path.join(tempShardDocInferenceRoot, 'bmad-fork', 'src', 'core', 'tasks', 'shard-doc.artifact.yaml'), + yaml.stringify({ + schemaVersion: 1, + canonicalId: 'nonexistent-shard-doc-id', + artifactType: 'task', + module: 'core', + sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml', + displayName: 'Shard Document', + description: 'Split large markdown documents into smaller files by section with an index.', + dependencies: { requires: [] }, + }), + 'utf8', + ); + + try { + await noShardDocInferenceSetup.writeSkillArtifacts(noShardDocInferenceSkillDir, [shardDocTaskArtifact], 'task', { + projectDir: tempShardDocInferenceRoot, + }); + assert( + false, + 'Codex export rejects path-inferred shard-doc id when sidecar canonical-id derivation is unresolved', + 'Expected shard-doc canonical-id derivation failure but export succeeded', + ); + } catch (error) { + assert( + error.code === CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED, + 'Codex export unresolved shard-doc canonical-id derivation returns deterministic failure code', + `Expected ${CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED}, got ${error.code}`, + ); + } + } finally { + await fs.remove(tempShardDocInferenceRoot); + } + const compatibilitySetup = new CodexSetup(); const compatibilityIdentity = await compatibilitySetup.resolveSkillIdentityFromArtifact( { @@ -1991,6 +3270,25 @@ async function runTests() { outputs: '', futureAdditiveField: 'wave-1', }, + { + module: 'core', + phase: 'anytime', + name: 'Shard Document', + code: 'SD', + sequence: '', + 'workflow-file': '_bmad/core/tasks/shard-doc.xml', + command: 'bmad-shard-doc', + required: 'false', + 'agent-name': '', + 'agent-command': '', + 'agent-display-name': '', + 'agent-title': '', + options: '', + description: 'Shard document command', + 'output-location': '', + outputs: '', + futureAdditiveField: 'wave-1', + }, { module: 'bmm', phase: 'planning', @@ -2041,9 +3339,9 @@ async function runTests() { 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', + loadedHelpRows.length === 3 && + loadedHelpRows.some((row) => row['workflow-file'] === '_bmad/core/tasks/help.md' && row.command === 'bmad-help') && + loadedHelpRows.some((row) => row['workflow-file'] === '_bmad/core/tasks/shard-doc.xml' && row.command === 'bmad-shard-doc'), 'GitHub Copilot help loader remains parseable with additive help-catalog columns', ); @@ -2063,6 +3361,45 @@ async function runTests() { ); } + const missingShardDocRows = validHelpRows.filter((row) => row.command !== 'bmad-shard-doc'); + const missingShardDocCsv = + [helpCatalogColumns.join(','), ...missingShardDocRows.map((row) => buildCsvLine(helpCatalogColumns, row))].join('\n') + '\n'; + try { + validateHelpCatalogCompatibilitySurface(missingShardDocCsv, { + sourcePath: '_bmad/_config/bmad-help.csv', + }); + assert(false, 'Help-catalog validator rejects missing shard-doc canonical command rows'); + } catch (error) { + assert( + error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_SHARD_DOC_ROW_CONTRACT_FAILED && + error.fieldPath === 'rows[*].command' && + error.observedValue === '0', + 'Help-catalog validator emits deterministic diagnostics for missing shard-doc canonical command rows', + ); + } + + const shardDocBaselineRow = validHelpRows.find((row) => row.command === 'bmad-shard-doc'); + const duplicateShardDocCsv = + [ + helpCatalogColumns.join(','), + ...[...validHelpRows, { ...shardDocBaselineRow, name: 'Shard Document Duplicate' }].map((row) => + buildCsvLine(helpCatalogColumns, row), + ), + ].join('\n') + '\n'; + try { + validateHelpCatalogCompatibilitySurface(duplicateShardDocCsv, { + sourcePath: '_bmad/_config/bmad-help.csv', + }); + assert(false, 'Help-catalog validator rejects duplicate shard-doc canonical command rows'); + } catch (error) { + assert( + error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_SHARD_DOC_ROW_CONTRACT_FAILED && + error.fieldPath === 'rows[*].command' && + error.observedValue === '2', + 'Help-catalog validator emits deterministic diagnostics for duplicate shard-doc canonical command rows', + ); + } + const missingWorkflowFileRows = [ { ...validHelpRows[0], @@ -2254,6 +3591,24 @@ async function runTests() { 'output-location': '', outputs: '', }, + { + module: 'core', + phase: 'anytime', + name: 'Shard Document', + code: 'SD', + sequence: '', + 'workflow-file': '_bmad/core/tasks/shard-doc.xml', + command: 'bmad-shard-doc', + required: 'false', + 'agent-name': '', + 'agent-command': '', + 'agent-display-name': '', + 'agent-title': '', + options: '', + description: 'Split large markdown documents into smaller files by section with an index.', + 'output-location': '', + outputs: '', + }, ], ); await writeCsv( @@ -2289,6 +3644,21 @@ async function runTests() { 'output-location': '', outputs: '', }, + { + module: 'core', + phase: 'anytime', + name: 'Shard Document', + code: 'SD', + sequence: '', + 'workflow-file': '_bmad/core/tasks/shard-doc.xml', + command: 'bmad-shard-doc', + required: 'false', + agent: '', + options: '', + description: 'Split large markdown documents into smaller files by section with an index.', + 'output-location': '', + outputs: '', + }, ], ); await writeCsv( diff --git a/tools/cli/installers/lib/core/help-catalog-generator.js b/tools/cli/installers/lib/core/help-catalog-generator.js index 7c410b0ae..6085ac26c 100644 --- a/tools/cli/installers/lib/core/help-catalog-generator.js +++ b/tools/cli/installers/lib/core/help-catalog-generator.js @@ -169,6 +169,8 @@ function resolveCanonicalIdFromAuthorityRecords(helpAuthorityRecords = []) { function evaluateExemplarCommandLabelReportRows(rows, options = {}) { const expectedCanonicalId = frontmatterMatchValue(options.canonicalId || EXEMPLAR_HELP_CATALOG_CANONICAL_ID); const expectedDisplayedLabel = frontmatterMatchValue(options.displayedCommandLabel || `/${expectedCanonicalId}`); + const expectedAuthoritySourceType = frontmatterMatchValue(options.authoritySourceType || 'sidecar'); + const expectedAuthoritySourcePath = frontmatterMatchValue(options.authoritySourcePath || EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH); const normalizedExpectedDisplayedLabel = normalizeDisplayedCommandLabel(expectedDisplayedLabel); const targetRows = (Array.isArray(rows) ? rows : []).filter( @@ -200,11 +202,14 @@ function evaluateExemplarCommandLabelReportRows(rows, options = {}) { 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.authoritySourceType) !== expectedAuthoritySourceType) { + return { + valid: false, + reason: `invalid-authority-source-type:${frontmatterMatchValue(row.authoritySourceType) || ''}`, + }; } - if (frontmatterMatchValue(row.authoritySourcePath) !== EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH) { + if (frontmatterMatchValue(row.authoritySourcePath) !== expectedAuthoritySourcePath) { return { valid: false, reason: `invalid-authority-source-path:${frontmatterMatchValue(row.authoritySourcePath) || ''}`, diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index c118ac1b0..cb05c37ff 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -9,8 +9,9 @@ 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 { validateHelpSidecarContractFile, validateShardDocSidecarContractFile } = require('./sidecar-contract-validator'); const { validateHelpAuthoritySplitAndPrecedence } = require('./help-authority-validator'); +const { validateShardDocAuthoritySplitAndPrecedence } = require('./shard-doc-authority-validator'); const { HELP_CATALOG_GENERATION_ERROR_CODES, buildSidecarAwareExemplarHelpRow, @@ -30,6 +31,10 @@ 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'; +const EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml'; +const EXEMPLAR_SHARD_DOC_SOURCE_XML_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.xml'; +const EXEMPLAR_SHARD_DOC_COMPATIBILITY_CATALOG_SOURCE_PATH = 'bmad-fork/src/core/module-help.csv'; +const EXEMPLAR_SHARD_DOC_WORKFLOW_FILE_PATH = '_bmad/core/tasks/shard-doc.xml'; class Installer { constructor() { @@ -44,7 +49,9 @@ class Installer { this.configCollector = new ConfigCollector(); this.ideConfigManager = new IdeConfigManager(); this.validateHelpSidecarContractFile = validateHelpSidecarContractFile; + this.validateShardDocSidecarContractFile = validateShardDocSidecarContractFile; this.validateHelpAuthoritySplitAndPrecedence = validateHelpAuthoritySplitAndPrecedence; + this.validateShardDocAuthoritySplitAndPrecedence = validateShardDocAuthoritySplitAndPrecedence; this.ManifestGenerator = ManifestGenerator; this.installedFiles = new Set(); // Track all installed files this.bmadFolderName = BMAD_FOLDER_NAME; @@ -56,12 +63,27 @@ class Installer { } async runConfigurationGenerationTask({ message, bmadDir, moduleConfigs, config, allModules, addResult }) { - // Validate exemplar sidecar contract before generating projections/manifests. + // Validate converted-capability sidecar contracts before generating projections/manifests. // Fail-fast here prevents downstream artifacts from being produced on invalid metadata. + message('Validating shard-doc sidecar contract...'); + await this.validateShardDocSidecarContractFile(); + message('Validating exemplar sidecar contract...'); await this.validateHelpSidecarContractFile(); + + addResult('Shard-doc sidecar contract', 'ok', 'validated'); addResult('Sidecar contract', 'ok', 'validated'); + message('Validating shard-doc authority split and XML precedence...'); + const shardDocAuthorityValidation = await this.validateShardDocAuthoritySplitAndPrecedence({ + sidecarSourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH, + sourceXmlSourcePath: EXEMPLAR_SHARD_DOC_SOURCE_XML_SOURCE_PATH, + compatibilityCatalogSourcePath: EXEMPLAR_SHARD_DOC_COMPATIBILITY_CATALOG_SOURCE_PATH, + compatibilityWorkflowFilePath: EXEMPLAR_SHARD_DOC_WORKFLOW_FILE_PATH, + }); + this.shardDocAuthorityRecords = shardDocAuthorityValidation.authoritativeRecords; + addResult('Shard-doc authority split', 'ok', shardDocAuthorityValidation.authoritativePresenceKey); + message('Validating authority split and frontmatter precedence...'); const helpAuthorityValidation = await this.validateHelpAuthoritySplitAndPrecedence({ bmadDir, @@ -109,6 +131,7 @@ class Installer { ides: config.ides || [], preservedModules: modulesForCsvPreserve, helpAuthorityRecords: this.helpAuthorityRecords || [], + taskAuthorityRecords: [...(this.helpAuthorityRecords || []), ...(this.shardDocAuthorityRecords || [])], }); addResult( @@ -1780,6 +1803,38 @@ class Installer { /** * Private: Create directory structure */ + resolveCanonicalIdFromAuthorityRecords({ authorityRecords, authoritySourcePath, fallbackCanonicalId }) { + const normalizedAuthoritySourcePath = String(authoritySourcePath || '') + .trim() + .replaceAll('\\', '/'); + const normalizedFallbackCanonicalId = String(fallbackCanonicalId || '').trim(); + const records = Array.isArray(authorityRecords) ? authorityRecords : []; + + for (const record of records) { + if (!record || typeof record !== 'object') { + continue; + } + + const recordCanonicalId = String(record.canonicalId || '').trim(); + const recordAuthoritySourceType = String(record.authoritySourceType || '').trim(); + const recordAuthoritySourcePath = String(record.authoritySourcePath || '') + .trim() + .replaceAll('\\', '/'); + const recordType = String(record.recordType || '').trim(); + + if ( + recordType === 'metadata-authority' && + recordAuthoritySourceType === 'sidecar' && + recordAuthoritySourcePath === normalizedAuthoritySourcePath && + recordCanonicalId.length > 0 + ) { + return recordCanonicalId; + } + } + + return normalizedFallbackCanonicalId; + } + isExemplarHelpCatalogRow({ moduleName, name, workflowFile, command, canonicalId }) { if (moduleName !== 'core') return false; @@ -1830,7 +1885,7 @@ class Installer { ]; } - isExemplarCommandLabelCandidate({ workflowFile, name, rawCommandValue, canonicalId, legacyName }) { + isCommandLabelCandidate({ workflowFile, name, rawCommandValue, canonicalId, legacyName, workflowFileContractPath, nameCandidates = [] }) { const normalizedWorkflowFile = String(workflowFile || '') .trim() .replaceAll('\\', '/') @@ -1849,13 +1904,27 @@ class Installer { .toLowerCase() .replace(/^\/+/, ''); - const isHelpWorkflow = normalizedWorkflowFile.endsWith('/core/tasks/help.md'); - const isExemplarIdentity = - normalizedName === 'bmad-help' || - normalizedCommandValue === normalizedCanonicalId || - (normalizedLegacyName.length > 0 && normalizedCommandValue === normalizedLegacyName); + const normalizedWorkflowFileContractPath = String(workflowFileContractPath || '') + .trim() + .replaceAll('\\', '/') + .toLowerCase(); + const workflowMarker = '/core/tasks/'; + const markerIndex = normalizedWorkflowFileContractPath.indexOf(workflowMarker); + const workflowSuffix = markerIndex === -1 ? normalizedWorkflowFileContractPath : normalizedWorkflowFileContractPath.slice(markerIndex); + const hasWorkflowMatch = workflowSuffix.length > 0 && normalizedWorkflowFile.endsWith(workflowSuffix); - return isHelpWorkflow && isExemplarIdentity; + const normalizedNameCandidates = (Array.isArray(nameCandidates) ? nameCandidates : []) + .map((candidate) => + String(candidate || '') + .trim() + .toLowerCase(), + ) + .filter((candidate) => candidate.length > 0); + const matchesNameCandidate = normalizedNameCandidates.includes(normalizedName); + const isCanonicalCommand = normalizedCanonicalId.length > 0 && normalizedCommandValue === normalizedCanonicalId; + const isLegacyCommand = normalizedLegacyName.length > 0 && normalizedCommandValue === normalizedLegacyName; + + return hasWorkflowMatch && (matchesNameCandidate || isCanonicalCommand || isLegacyCommand); } async writeCsvArtifact(filePath, columns, rows) { @@ -1886,6 +1955,31 @@ class Installer { helpAuthorityRecords: this.helpAuthorityRecords || [], bmadFolderName: this.bmadFolderName || BMAD_FOLDER_NAME, }); + const shardDocCanonicalId = this.resolveCanonicalIdFromAuthorityRecords({ + authorityRecords: this.shardDocAuthorityRecords || [], + authoritySourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH, + fallbackCanonicalId: 'bmad-shard-doc', + }); + const commandLabelContracts = [ + { + canonicalId: sidecarAwareExemplar.canonicalId, + legacyName: sidecarAwareExemplar.legacyName, + displayedCommandLabel: sidecarAwareExemplar.displayedCommandLabel, + authoritySourceType: sidecarAwareExemplar.authoritySourceType, + authoritySourcePath: sidecarAwareExemplar.authoritySourcePath, + workflowFilePath: sidecarAwareExemplar.row['workflow-file'], + nameCandidates: [sidecarAwareExemplar.row.name], + }, + { + canonicalId: shardDocCanonicalId, + legacyName: 'shard-doc', + displayedCommandLabel: renderDisplayedCommandLabel(shardDocCanonicalId), + authoritySourceType: 'sidecar', + authoritySourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH, + workflowFilePath: EXEMPLAR_SHARD_DOC_WORKFLOW_FILE_PATH, + nameCandidates: ['shard document', 'shard-doc'], + }, + ]; let exemplarRowWritten = false; // Load agent manifest for agent info lookup @@ -2110,31 +2204,37 @@ class Installer { continue; } - if ( - !this.isExemplarCommandLabelCandidate({ + for (const contract of commandLabelContracts) { + const isContractCandidate = this.isCommandLabelCandidate({ workflowFile, name, rawCommandValue, - canonicalId: sidecarAwareExemplar.canonicalId, - legacyName: sidecarAwareExemplar.legacyName, - }) - ) { - continue; + canonicalId: contract.canonicalId, + legacyName: contract.legacyName, + workflowFileContractPath: contract.workflowFilePath, + nameCandidates: contract.nameCandidates, + }); + if (isContractCandidate) { + const displayedCommandLabel = renderDisplayedCommandLabel(rawCommandValue); + commandLabelRowsFromMergedCatalog.push({ + surface: `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/bmad-help.csv`, + canonicalId: contract.canonicalId, + rawCommandValue, + displayedCommandLabel, + normalizedDisplayedLabel: normalizeDisplayedCommandLabel(displayedCommandLabel), + authoritySourceType: contract.authoritySourceType, + authoritySourcePath: contract.authoritySourcePath, + }); + break; + } } - - const 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; + const commandLabelRowCountByCanonicalId = new Map(commandLabelContracts.map((contract) => [contract.canonicalId, 0])); + for (const row of commandLabelRowsFromMergedCatalog) { + commandLabelRowCountByCanonicalId.set(row.canonicalId, (commandLabelRowCountByCanonicalId.get(row.canonicalId) || 0) + 1); + } + const exemplarRowCount = commandLabelRowCountByCanonicalId.get(sidecarAwareExemplar.canonicalId) || 0; this.helpCatalogPipelineRows = sidecarAwareExemplar.pipelineStageRows.map((row) => ({ ...row, @@ -2144,15 +2244,24 @@ class Installer { })); this.helpCatalogCommandLabelReportRows = commandLabelRowsFromMergedCatalog.map((row) => ({ ...row, - rowCountForCanonicalId: exemplarRowCount, - status: exemplarRowCount === 1 ? 'PASS' : 'FAIL', + rowCountForCanonicalId: commandLabelRowCountByCanonicalId.get(row.canonicalId) || 0, + status: (commandLabelRowCountByCanonicalId.get(row.canonicalId) || 0) === 1 ? 'PASS' : 'FAIL', })); - const commandLabelContractResult = evaluateExemplarCommandLabelReportRows(this.helpCatalogCommandLabelReportRows, { - canonicalId: sidecarAwareExemplar.canonicalId, - displayedCommandLabel: sidecarAwareExemplar.displayedCommandLabel, - }); - if (!commandLabelContractResult.valid) { + const commandLabelContractFailures = new Map(); + for (const contract of commandLabelContracts) { + const commandLabelContractResult = evaluateExemplarCommandLabelReportRows(this.helpCatalogCommandLabelReportRows, { + canonicalId: contract.canonicalId, + displayedCommandLabel: contract.displayedCommandLabel, + authoritySourceType: contract.authoritySourceType, + authoritySourcePath: contract.authoritySourcePath, + }); + if (!commandLabelContractResult.valid) { + commandLabelContractFailures.set(contract.canonicalId, commandLabelContractResult.reason); + } + } + + if (commandLabelContractFailures.size > 0) { this.helpCatalogPipelineRows = this.helpCatalogPipelineRows.map((row) => ({ ...row, stageStatus: 'FAIL', @@ -2161,14 +2270,19 @@ class Installer { this.helpCatalogCommandLabelReportRows = this.helpCatalogCommandLabelReportRows.map((row) => ({ ...row, status: 'FAIL', - failureReason: commandLabelContractResult.reason, + failureReason: commandLabelContractFailures.get(row.canonicalId) || row.failureReason || '', })); + const commandLabelFailureSummary = [...commandLabelContractFailures.entries()] + .sort(([leftCanonicalId], [rightCanonicalId]) => leftCanonicalId.localeCompare(rightCanonicalId)) + .map(([canonicalId, reason]) => `${canonicalId}:${reason}`) + .join('|'); + const commandLabelError = new Error( - `${HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED}: ${commandLabelContractResult.reason}`, + `${HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED}: ${commandLabelFailureSummary}`, ); commandLabelError.code = HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED; - commandLabelError.detail = commandLabelContractResult.reason; + commandLabelError.detail = commandLabelFailureSummary; throw commandLabelError; } diff --git a/tools/cli/installers/lib/core/manifest-generator.js b/tools/cli/installers/lib/core/manifest-generator.js index 0cd5e6d26..bffd0a320 100644 --- a/tools/cli/installers/lib/core/manifest-generator.js +++ b/tools/cli/installers/lib/core/manifest-generator.js @@ -15,6 +15,7 @@ const { validateTaskManifestCompatibilitySurface } = require('./projection-compa // 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 DEFAULT_EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml'; const CANONICAL_ALIAS_TABLE_COLUMNS = Object.freeze([ 'canonicalId', 'alias', @@ -55,6 +56,35 @@ const LOCKED_CANONICAL_ALIAS_TABLE_EXEMPLAR_ROWS = Object.freeze([ resolutionEligibility: 'slash-command-only', }), ]); +const LOCKED_CANONICAL_ALIAS_TABLE_SHARD_DOC_ROWS = Object.freeze([ + Object.freeze({ + canonicalId: 'bmad-shard-doc', + alias: 'bmad-shard-doc', + aliasType: 'canonical-id', + rowIdentity: 'alias-row:bmad-shard-doc:canonical-id', + normalizedAliasValue: 'bmad-shard-doc', + rawIdentityHasLeadingSlash: false, + resolutionEligibility: 'canonical-id-only', + }), + Object.freeze({ + canonicalId: 'bmad-shard-doc', + alias: 'shard-doc', + aliasType: 'legacy-name', + rowIdentity: 'alias-row:bmad-shard-doc:legacy-name', + normalizedAliasValue: 'shard-doc', + rawIdentityHasLeadingSlash: false, + resolutionEligibility: 'legacy-name-only', + }), + Object.freeze({ + canonicalId: 'bmad-shard-doc', + alias: '/bmad-shard-doc', + aliasType: 'slash-command', + rowIdentity: 'alias-row:bmad-shard-doc:slash-command', + normalizedAliasValue: 'bmad-shard-doc', + rawIdentityHasLeadingSlash: true, + resolutionEligibility: 'slash-command-only', + }), +]); /** * Generates manifest files for installed workflows, agents, and tasks @@ -68,6 +98,73 @@ class ManifestGenerator { this.modules = []; this.files = []; this.selectedIdes = []; + this.includeConvertedShardDocAliasRows = null; + } + + normalizeTaskAuthorityRecords(records) { + if (!Array.isArray(records)) return []; + + const normalized = []; + for (const record of records) { + if (!record || typeof record !== 'object' || Array.isArray(record)) { + continue; + } + + const canonicalId = String(record.canonicalId ?? '').trim(); + const authoritySourceType = String(record.authoritySourceType ?? '').trim(); + const authoritySourcePath = String(record.authoritySourcePath ?? '').trim(); + const sourcePath = String(record.sourcePath ?? '') + .trim() + .replaceAll('\\', '/'); + const recordType = String(record.recordType ?? '').trim(); + + if (!canonicalId || !authoritySourceType || !authoritySourcePath || !sourcePath || !recordType) { + continue; + } + + normalized.push({ + recordType, + canonicalId, + authoritySourceType, + authoritySourcePath, + sourcePath, + }); + } + + normalized.sort((left, right) => { + const leftKey = `${left.canonicalId}|${left.recordType}|${left.authoritySourceType}|${left.authoritySourcePath}|${left.sourcePath}`; + const rightKey = `${right.canonicalId}|${right.recordType}|${right.authoritySourceType}|${right.authoritySourcePath}|${right.sourcePath}`; + return leftKey.localeCompare(rightKey); + }); + + return normalized; + } + + buildTaskAuthorityProjectionIndex(records) { + const projectionIndex = new Map(); + for (const record of records) { + if (!record || record.recordType !== 'metadata-authority' || record.authoritySourceType !== 'sidecar') { + continue; + } + + const sourceMatch = String(record.sourcePath) + .replaceAll('\\', '/') + .match(/\/src\/([^/]+)\/tasks\/([^/.]+)\.(?:md|xml)$/i); + if (!sourceMatch) { + continue; + } + + const moduleName = sourceMatch[1]; + const taskName = sourceMatch[2]; + projectionIndex.set(`${moduleName}:${taskName}`, { + legacyName: taskName, + canonicalId: record.canonicalId, + authoritySourceType: record.authoritySourceType, + authoritySourcePath: record.authoritySourcePath, + }); + } + + return projectionIndex; } /** @@ -182,6 +279,13 @@ class ManifestGenerator { } this.helpAuthorityRecords = await this.normalizeHelpAuthorityRecords(options.helpAuthorityRecords); + const taskAuthorityInput = Object.prototype.hasOwnProperty.call(options, 'taskAuthorityRecords') + ? options.taskAuthorityRecords + : options.helpAuthorityRecords; + this.taskAuthorityRecords = this.normalizeTaskAuthorityRecords(taskAuthorityInput); + this.includeConvertedShardDocAliasRows = Object.prototype.hasOwnProperty.call(options, 'includeConvertedShardDocAliasRows') + ? options.includeConvertedShardDocAliasRows === true + : null; // Filter out any undefined/null values from IDE list this.selectedIdes = resolvedIdes.filter((ide) => ide && typeof ide === 'string'); @@ -958,15 +1062,10 @@ class ManifestGenerator { 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'; + const taskAuthorityRecords = Array.isArray(this.taskAuthorityRecords) + ? this.taskAuthorityRecords + : this.normalizeTaskAuthorityRecords(this.helpAuthorityRecords); + const taskAuthorityProjectionIndex = this.buildTaskAuthorityProjectionIndex(taskAuthorityRecords); // Read existing manifest to preserve entries const existingEntries = new Map(); @@ -1015,7 +1114,7 @@ class ManifestGenerator { 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'; + const authorityProjection = taskAuthorityProjectionIndex.get(key); allTasks.set(key, { name: task.name, @@ -1024,10 +1123,10 @@ 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 || '', + legacyName: authorityProjection ? authorityProjection.legacyName : previousRecord?.legacyName || task.name, + canonicalId: authorityProjection ? authorityProjection.canonicalId : previousRecord?.canonicalId || '', + authoritySourceType: authorityProjection ? authorityProjection.authoritySourceType : previousRecord?.authoritySourceType || '', + authoritySourcePath: authorityProjection ? authorityProjection.authoritySourcePath : previousRecord?.authoritySourcePath || '', }); } @@ -1070,18 +1169,64 @@ class ManifestGenerator { }; } - 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, - })); + resolveShardDocAliasAuthorityRecord() { + const sidecarAuthorityRecord = Array.isArray(this.taskAuthorityRecords) + ? this.taskAuthorityRecords.find( + (record) => record?.canonicalId === 'bmad-shard-doc' && record?.authoritySourceType === 'sidecar' && record?.authoritySourcePath, + ) + : null; + return { + authoritySourceType: sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourceType : 'sidecar', + authoritySourcePath: sidecarAuthorityRecord + ? sidecarAuthorityRecord.authoritySourcePath + : DEFAULT_EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH, + }; + } + + hasShardDocTaskAuthorityProjection() { + if (!Array.isArray(this.taskAuthorityRecords)) { + return false; + } + + return this.taskAuthorityRecords.some( + (record) => + record?.recordType === 'metadata-authority' && + record?.canonicalId === 'bmad-shard-doc' && + record?.authoritySourceType === 'sidecar' && + String(record?.authoritySourcePath || '').trim().length > 0, + ); + } + + shouldProjectShardDocAliasRows() { + if (this.includeConvertedShardDocAliasRows === true) { + return true; + } + if (this.includeConvertedShardDocAliasRows === false) { + return false; + } + + return this.hasShardDocTaskAuthorityProjection(); + } + + buildCanonicalAliasProjectionRows() { + const buildRows = (lockedRows, authorityRecord) => + lockedRows.map((row) => ({ + canonicalId: row.canonicalId, + alias: row.alias, + aliasType: row.aliasType, + authoritySourceType: authorityRecord.authoritySourceType, + authoritySourcePath: authorityRecord.authoritySourcePath, + rowIdentity: row.rowIdentity, + normalizedAliasValue: row.normalizedAliasValue, + rawIdentityHasLeadingSlash: row.rawIdentityHasLeadingSlash, + resolutionEligibility: row.resolutionEligibility, + })); + + const rows = [...buildRows(LOCKED_CANONICAL_ALIAS_TABLE_EXEMPLAR_ROWS, this.resolveExemplarAliasAuthorityRecord())]; + if (this.shouldProjectShardDocAliasRows()) { + rows.push(...buildRows(LOCKED_CANONICAL_ALIAS_TABLE_SHARD_DOC_ROWS, this.resolveShardDocAliasAuthorityRecord())); + } + return rows; } /** @@ -1091,8 +1236,7 @@ class ManifestGenerator { 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); + const projectedRows = this.buildCanonicalAliasProjectionRows(); let csvContent = `${CANONICAL_ALIAS_TABLE_COLUMNS.join(',')}\n`; for (const row of projectedRows) { diff --git a/tools/cli/installers/lib/core/projection-compatibility-validator.js b/tools/cli/installers/lib/core/projection-compatibility-validator.js index d82fa3e87..257bbd20f 100644 --- a/tools/cli/installers/lib/core/projection-compatibility-validator.js +++ b/tools/cli/installers/lib/core/projection-compatibility-validator.js @@ -37,7 +37,13 @@ const PROJECTION_COMPATIBILITY_ERROR_CODES = Object.freeze({ 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', + HELP_CATALOG_SHARD_DOC_ROW_CONTRACT_FAILED: 'ERR_HELP_CATALOG_COMPAT_SHARD_DOC_ROW_CONTRACT_FAILED', GITHUB_COPILOT_WORKFLOW_FILE_MISSING: 'ERR_GITHUB_COPILOT_HELP_WORKFLOW_FILE_MISSING', + COMMAND_DOC_PARSE_FAILED: 'ERR_COMMAND_DOC_CONSISTENCY_PARSE_FAILED', + COMMAND_DOC_CANONICAL_COMMAND_MISSING: 'ERR_COMMAND_DOC_CONSISTENCY_CANONICAL_COMMAND_MISSING', + COMMAND_DOC_CANONICAL_COMMAND_AMBIGUOUS: 'ERR_COMMAND_DOC_CONSISTENCY_CANONICAL_COMMAND_AMBIGUOUS', + COMMAND_DOC_ALIAS_AMBIGUOUS: 'ERR_COMMAND_DOC_CONSISTENCY_ALIAS_AMBIGUOUS', + COMMAND_DOC_GENERATED_SURFACE_MISMATCH: 'ERR_COMMAND_DOC_CONSISTENCY_GENERATED_SURFACE_MISMATCH', }); class ProjectionCompatibilityError extends Error { @@ -177,6 +183,37 @@ function normalizeWorkflowPath(value) { return normalizeSourcePath(value).toLowerCase(); } +function normalizeDisplayedCommandLabel(value) { + const normalized = normalizeValue(value).toLowerCase().replace(/^\/+/, ''); + return normalized.length > 0 ? `/${normalized}` : ''; +} + +function parseDocumentedSlashCommands(markdownContent, options = {}) { + const sourcePath = normalizeSourcePath(options.sourcePath || 'docs/reference/commands.md'); + const surface = options.surface || 'command-doc-consistency'; + const content = String(markdownContent ?? ''); + const commandPattern = /\|\s*`(\/[^`]+)`\s*\|/g; + const commands = []; + let match; + while ((match = commandPattern.exec(content)) !== null) { + commands.push(normalizeDisplayedCommandLabel(match[1])); + } + + if (commands.length === 0) { + throwCompatibilityError({ + code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_PARSE_FAILED, + detail: 'Unable to find slash-command rows in command reference markdown', + surface, + fieldPath: 'docs.reference.commands', + sourcePath, + observedValue: '', + expectedValue: '| `/bmad-...` |', + }); + } + + return commands; +} + function validateTaskManifestLoaderEntries(rows, options = {}) { const surface = options.surface || 'task-manifest-loader'; const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/task-manifest.csv'); @@ -261,6 +298,23 @@ function validateHelpCatalogLoaderEntries(rows, options = {}) { }); } + const shardDocRows = parsedRows.filter( + (row) => + normalizeCommandValue(row.command) === 'bmad-shard-doc' && + normalizeWorkflowPath(row['workflow-file']).endsWith('/core/tasks/shard-doc.xml'), + ); + if (shardDocRows.length !== 1) { + throwCompatibilityError({ + code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_SHARD_DOC_ROW_CONTRACT_FAILED, + detail: 'Exactly one shard-doc compatibility row is required for help catalog consumers', + surface, + fieldPath: 'rows[*].command', + sourcePath, + observedValue: String(shardDocRows.length), + expectedValue: '1', + }); + } + return true; } @@ -392,6 +446,84 @@ function validateHelpCatalogCompatibilitySurface(csvContent, options = {}) { return { headerColumns, rows }; } +function validateCommandDocSurfaceConsistency(commandDocMarkdown, options = {}) { + const surface = options.surface || 'command-doc-consistency'; + const sourcePath = normalizeSourcePath(options.sourcePath || 'docs/reference/commands.md'); + const canonicalId = normalizeValue(options.canonicalId || 'bmad-shard-doc'); + const expectedDisplayedCommandLabel = normalizeDisplayedCommandLabel(options.expectedDisplayedCommandLabel || '/bmad-shard-doc'); + const disallowedAliasLabels = Array.isArray(options.disallowedAliasLabels) ? options.disallowedAliasLabels : ['/shard-doc']; + const commandLabelRows = Array.isArray(options.commandLabelRows) ? options.commandLabelRows : []; + + const documentedCommands = parseDocumentedSlashCommands(commandDocMarkdown, { + sourcePath, + surface, + }); + const documentedCanonicalMatches = documentedCommands.filter((commandLabel) => commandLabel === expectedDisplayedCommandLabel); + if (documentedCanonicalMatches.length === 0) { + throwCompatibilityError({ + code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_CANONICAL_COMMAND_MISSING, + detail: 'Expected canonical command is missing from command reference markdown', + surface, + fieldPath: 'docs.reference.commands.canonical-command', + sourcePath, + observedValue: '', + expectedValue: expectedDisplayedCommandLabel, + }); + } + if (documentedCanonicalMatches.length > 1) { + throwCompatibilityError({ + code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_CANONICAL_COMMAND_AMBIGUOUS, + detail: 'Canonical command appears multiple times in command reference markdown', + surface, + fieldPath: 'docs.reference.commands.canonical-command', + sourcePath, + observedValue: String(documentedCanonicalMatches.length), + expectedValue: '1', + }); + } + + const normalizedDisallowedAliases = disallowedAliasLabels.map((label) => normalizeDisplayedCommandLabel(label)).filter(Boolean); + const presentDisallowedAlias = normalizedDisallowedAliases.find((label) => documentedCommands.includes(label)); + if (presentDisallowedAlias) { + throwCompatibilityError({ + code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_ALIAS_AMBIGUOUS, + detail: 'Disallowed alias command detected in command reference markdown', + surface, + fieldPath: 'docs.reference.commands.alias-command', + sourcePath, + observedValue: presentDisallowedAlias, + expectedValue: expectedDisplayedCommandLabel, + }); + } + + const generatedCanonicalRows = commandLabelRows.filter((row) => normalizeValue(row.canonicalId) === canonicalId); + const generatedMatchingRows = generatedCanonicalRows.filter( + (row) => normalizeDisplayedCommandLabel(row.displayedCommandLabel) === expectedDisplayedCommandLabel, + ); + if (generatedCanonicalRows.length === 0 || generatedMatchingRows.length !== 1) { + throwCompatibilityError({ + code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_GENERATED_SURFACE_MISMATCH, + detail: 'Generated command-label surface does not match canonical command-doc contract', + surface, + fieldPath: 'generated.command-label-report', + sourcePath: normalizeSourcePath(options.generatedSurfacePath || '_bmad/_config/bmad-help-command-label-report.csv'), + observedValue: + generatedCanonicalRows + .map((row) => normalizeDisplayedCommandLabel(row.displayedCommandLabel)) + .filter(Boolean) + .join('|') || '', + expectedValue: expectedDisplayedCommandLabel, + }); + } + + return { + canonicalId, + expectedDisplayedCommandLabel, + documentedCommands, + generatedCanonicalCommand: expectedDisplayedCommandLabel, + }; +} + module.exports = { PROJECTION_COMPATIBILITY_ERROR_CODES, ProjectionCompatibilityError, @@ -404,4 +536,5 @@ module.exports = { validateHelpCatalogCompatibilitySurface, validateHelpCatalogLoaderEntries, validateGithubCopilotHelpLoaderEntries, + validateCommandDocSurfaceConsistency, }; diff --git a/tools/cli/installers/lib/core/shard-doc-authority-validator.js b/tools/cli/installers/lib/core/shard-doc-authority-validator.js new file mode 100644 index 000000000..a6f3a8ef1 --- /dev/null +++ b/tools/cli/installers/lib/core/shard-doc-authority-validator.js @@ -0,0 +1,334 @@ +const path = require('node:path'); +const fs = require('fs-extra'); +const yaml = require('yaml'); +const csv = require('csv-parse/sync'); +const { getProjectRoot, getSourcePath } = require('../../../lib/project-root'); + +const SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES = Object.freeze({ + SIDECAR_FILE_NOT_FOUND: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_FILE_NOT_FOUND', + SIDECAR_PARSE_FAILED: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_PARSE_FAILED', + SIDECAR_INVALID_METADATA: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_INVALID_METADATA', + SIDECAR_CANONICAL_ID_MISMATCH: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_CANONICAL_ID_MISMATCH', + SOURCE_XML_FILE_NOT_FOUND: 'ERR_SHARD_DOC_AUTHORITY_SOURCE_XML_FILE_NOT_FOUND', + COMPATIBILITY_FILE_NOT_FOUND: 'ERR_SHARD_DOC_AUTHORITY_COMPATIBILITY_FILE_NOT_FOUND', + COMPATIBILITY_PARSE_FAILED: 'ERR_SHARD_DOC_AUTHORITY_COMPATIBILITY_PARSE_FAILED', + COMPATIBILITY_ROW_MISSING: 'ERR_SHARD_DOC_AUTHORITY_COMPATIBILITY_ROW_MISSING', + COMPATIBILITY_ROW_DUPLICATE: 'ERR_SHARD_DOC_AUTHORITY_COMPATIBILITY_ROW_DUPLICATE', + COMMAND_MISMATCH: 'ERR_SHARD_DOC_AUTHORITY_COMMAND_MISMATCH', + DISPLAY_NAME_MISMATCH: 'ERR_SHARD_DOC_AUTHORITY_DISPLAY_NAME_MISMATCH', + DUPLICATE_CANONICAL_COMMAND: 'ERR_SHARD_DOC_AUTHORITY_DUPLICATE_CANONICAL_COMMAND', +}); + +const SHARD_DOC_LOCKED_CANONICAL_ID = 'bmad-shard-doc'; +const SHARD_DOC_LOCKED_AUTHORITATIVE_PRESENCE_KEY = `capability:${SHARD_DOC_LOCKED_CANONICAL_ID}`; + +class ShardDocAuthorityValidationError extends Error { + constructor({ code, detail, fieldPath, sourcePath, observedValue, expectedValue }) { + const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath})`; + super(message); + this.name = 'ShardDocAuthorityValidationError'; + this.code = code; + this.detail = detail; + this.fieldPath = fieldPath; + this.sourcePath = sourcePath; + this.observedValue = observedValue; + this.expectedValue = expectedValue; + this.fullMessage = message; + } +} + +function normalizeSourcePath(value) { + if (!value) return ''; + return String(value).replaceAll('\\', '/'); +} + +function toProjectRelativePath(filePath) { + const projectRoot = getProjectRoot(); + const relative = path.relative(projectRoot, filePath); + + if (!relative || relative.startsWith('..')) { + return normalizeSourcePath(path.resolve(filePath)); + } + + return normalizeSourcePath(relative); +} + +function hasOwn(obj, key) { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +function isBlankString(value) { + return typeof value !== 'string' || value.trim().length === 0; +} + +function csvMatchValue(value) { + return String(value ?? '').trim(); +} + +function createValidationError(code, detail, fieldPath, sourcePath, observedValue, expectedValue) { + throw new ShardDocAuthorityValidationError({ + code, + detail, + fieldPath, + sourcePath, + observedValue, + expectedValue, + }); +} + +function ensureSidecarMetadata(sidecarData, sidecarSourcePath, sourceXmlSourcePath) { + const requiredFields = ['canonicalId', 'displayName', 'description', 'sourcePath']; + for (const requiredField of requiredFields) { + if (!hasOwn(sidecarData, requiredField)) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA, + `Missing required sidecar metadata field "${requiredField}"`, + requiredField, + sidecarSourcePath, + ); + } + } + + for (const requiredField of requiredFields) { + if (isBlankString(sidecarData[requiredField])) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA, + `Required sidecar metadata field "${requiredField}" must be a non-empty string`, + requiredField, + sidecarSourcePath, + ); + } + } + + const normalizedCanonicalId = String(sidecarData.canonicalId).trim(); + if (normalizedCanonicalId !== SHARD_DOC_LOCKED_CANONICAL_ID) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH, + 'Converted shard-doc sidecar canonicalId must remain locked to bmad-shard-doc', + 'canonicalId', + sidecarSourcePath, + normalizedCanonicalId, + SHARD_DOC_LOCKED_CANONICAL_ID, + ); + } + + const normalizedDeclaredSourcePath = normalizeSourcePath(sidecarData.sourcePath); + if (normalizedDeclaredSourcePath !== sourceXmlSourcePath) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA, + 'Sidecar sourcePath must match shard-doc XML source path', + 'sourcePath', + sidecarSourcePath, + normalizedDeclaredSourcePath, + sourceXmlSourcePath, + ); + } +} + +async function parseCompatibilityRows(compatibilityCatalogPath, compatibilityCatalogSourcePath) { + if (!(await fs.pathExists(compatibilityCatalogPath))) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_FILE_NOT_FOUND, + 'Expected module-help compatibility catalog file was not found', + '', + compatibilityCatalogSourcePath, + ); + } + + let csvRaw; + try { + csvRaw = await fs.readFile(compatibilityCatalogPath, 'utf8'); + } catch (error) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_PARSE_FAILED, + `Unable to read compatibility catalog file: ${error.message}`, + '', + compatibilityCatalogSourcePath, + ); + } + + try { + return csv.parse(csvRaw, { + columns: true, + skip_empty_lines: true, + relax_column_count: true, + trim: true, + }); + } catch (error) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_PARSE_FAILED, + `CSV parse failure: ${error.message}`, + '', + compatibilityCatalogSourcePath, + ); + } +} + +function validateCompatibilityPrecedence({ rows, displayName, workflowFilePath, compatibilityCatalogSourcePath }) { + const workflowMatches = rows.filter((row) => csvMatchValue(row['workflow-file']) === workflowFilePath); + + if (workflowMatches.length === 0) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_MISSING, + 'Converted shard-doc compatibility row is missing from module-help catalog', + 'workflow-file', + compatibilityCatalogSourcePath, + '', + workflowFilePath, + ); + } + + if (workflowMatches.length > 1) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_DUPLICATE, + 'Converted shard-doc compatibility row appears more than once in module-help catalog', + 'workflow-file', + compatibilityCatalogSourcePath, + workflowMatches.length, + 1, + ); + } + + const canonicalCommandMatches = rows.filter((row) => csvMatchValue(row.command) === SHARD_DOC_LOCKED_CANONICAL_ID); + if (canonicalCommandMatches.length > 1) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.DUPLICATE_CANONICAL_COMMAND, + 'Converted shard-doc canonical command appears in more than one compatibility row', + 'command', + compatibilityCatalogSourcePath, + canonicalCommandMatches.length, + 1, + ); + } + + const shardDocRow = workflowMatches[0]; + const observedCommand = csvMatchValue(shardDocRow.command); + if (!observedCommand || observedCommand !== SHARD_DOC_LOCKED_CANONICAL_ID) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH, + 'Converted shard-doc compatibility command must match locked canonical command bmad-shard-doc', + 'command', + compatibilityCatalogSourcePath, + observedCommand || '', + SHARD_DOC_LOCKED_CANONICAL_ID, + ); + } + + const observedDisplayName = csvMatchValue(shardDocRow.name); + if (observedDisplayName && observedDisplayName !== displayName) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.DISPLAY_NAME_MISMATCH, + 'Converted shard-doc compatibility name must match sidecar displayName when provided', + 'name', + compatibilityCatalogSourcePath, + observedDisplayName, + displayName, + ); + } +} + +function buildShardDocAuthorityRecords({ canonicalId, sidecarSourcePath, sourceXmlSourcePath }) { + return [ + { + recordType: 'metadata-authority', + canonicalId, + authoritativePresenceKey: SHARD_DOC_LOCKED_AUTHORITATIVE_PRESENCE_KEY, + authoritySourceType: 'sidecar', + authoritySourcePath: sidecarSourcePath, + sourcePath: sourceXmlSourcePath, + }, + { + recordType: 'source-body-authority', + canonicalId, + authoritativePresenceKey: SHARD_DOC_LOCKED_AUTHORITATIVE_PRESENCE_KEY, + authoritySourceType: 'source-xml', + authoritySourcePath: sourceXmlSourcePath, + sourcePath: sourceXmlSourcePath, + }, + ]; +} + +async function validateShardDocAuthoritySplitAndPrecedence(options = {}) { + const sidecarPath = options.sidecarPath || getSourcePath('core', 'tasks', 'shard-doc.artifact.yaml'); + const sourceXmlPath = options.sourceXmlPath || getSourcePath('core', 'tasks', 'shard-doc.xml'); + const compatibilityCatalogPath = options.compatibilityCatalogPath || getSourcePath('core', 'module-help.csv'); + const compatibilityWorkflowFilePath = options.compatibilityWorkflowFilePath || '_bmad/core/tasks/shard-doc.xml'; + + const sidecarSourcePath = normalizeSourcePath(options.sidecarSourcePath || toProjectRelativePath(sidecarPath)); + const sourceXmlSourcePath = normalizeSourcePath(options.sourceXmlSourcePath || toProjectRelativePath(sourceXmlPath)); + const compatibilityCatalogSourcePath = normalizeSourcePath( + options.compatibilityCatalogSourcePath || toProjectRelativePath(compatibilityCatalogPath), + ); + + if (!(await fs.pathExists(sidecarPath))) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND, + 'Expected shard-doc sidecar metadata file was not found', + '', + sidecarSourcePath, + ); + } + + let sidecarData; + try { + const sidecarRaw = await fs.readFile(sidecarPath, 'utf8'); + sidecarData = yaml.parse(sidecarRaw); + } catch (error) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_PARSE_FAILED, + `YAML parse failure: ${error.message}`, + '', + sidecarSourcePath, + ); + } + + if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA, + 'Sidecar root must be a YAML mapping object', + '', + sidecarSourcePath, + ); + } + + ensureSidecarMetadata(sidecarData, sidecarSourcePath, sourceXmlSourcePath); + + if (!(await fs.pathExists(sourceXmlPath))) { + createValidationError( + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SOURCE_XML_FILE_NOT_FOUND, + 'Expected shard-doc XML source file was not found', + '', + sourceXmlSourcePath, + ); + } + + const compatibilityRows = await parseCompatibilityRows(compatibilityCatalogPath, compatibilityCatalogSourcePath); + validateCompatibilityPrecedence({ + rows: compatibilityRows, + displayName: sidecarData.displayName.trim(), + workflowFilePath: compatibilityWorkflowFilePath, + compatibilityCatalogSourcePath, + }); + + const canonicalId = SHARD_DOC_LOCKED_CANONICAL_ID; + const authoritativeRecords = buildShardDocAuthorityRecords({ + canonicalId, + sidecarSourcePath, + sourceXmlSourcePath, + }); + + return { + canonicalId, + authoritativePresenceKey: SHARD_DOC_LOCKED_AUTHORITATIVE_PRESENCE_KEY, + authoritativeRecords, + checkedSurfaces: [sourceXmlSourcePath, compatibilityCatalogSourcePath], + }; +} + +module.exports = { + SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES, + SHARD_DOC_LOCKED_CANONICAL_ID, + ShardDocAuthorityValidationError, + buildShardDocAuthorityRecords, + validateShardDocAuthoritySplitAndPrecedence, +}; diff --git a/tools/cli/installers/lib/core/sidecar-contract-validator.js b/tools/cli/installers/lib/core/sidecar-contract-validator.js index a5b9e235c..b9c4c02ba 100644 --- a/tools/cli/installers/lib/core/sidecar-contract-validator.js +++ b/tools/cli/installers/lib/core/sidecar-contract-validator.js @@ -14,6 +14,8 @@ const HELP_SIDECAR_REQUIRED_FIELDS = Object.freeze([ 'dependencies', ]); +const SHARD_DOC_SIDECAR_REQUIRED_FIELDS = Object.freeze([...HELP_SIDECAR_REQUIRED_FIELDS]); + const HELP_SIDECAR_ERROR_CODES = Object.freeze({ FILE_NOT_FOUND: 'ERR_HELP_SIDECAR_FILE_NOT_FOUND', PARSE_FAILED: 'ERR_HELP_SIDECAR_PARSE_FAILED', @@ -29,8 +31,24 @@ const HELP_SIDECAR_ERROR_CODES = Object.freeze({ SOURCEPATH_BASENAME_MISMATCH: 'ERR_SIDECAR_SOURCEPATH_BASENAME_MISMATCH', }); +const SHARD_DOC_SIDECAR_ERROR_CODES = Object.freeze({ + FILE_NOT_FOUND: 'ERR_SHARD_DOC_SIDECAR_FILE_NOT_FOUND', + PARSE_FAILED: 'ERR_SHARD_DOC_SIDECAR_PARSE_FAILED', + INVALID_ROOT_OBJECT: 'ERR_SHARD_DOC_SIDECAR_INVALID_ROOT_OBJECT', + REQUIRED_FIELD_MISSING: 'ERR_SHARD_DOC_SIDECAR_REQUIRED_FIELD_MISSING', + REQUIRED_FIELD_EMPTY: 'ERR_SHARD_DOC_SIDECAR_REQUIRED_FIELD_EMPTY', + ARTIFACT_TYPE_INVALID: 'ERR_SHARD_DOC_SIDECAR_ARTIFACT_TYPE_INVALID', + MODULE_INVALID: 'ERR_SHARD_DOC_SIDECAR_MODULE_INVALID', + DEPENDENCIES_MISSING: 'ERR_SHARD_DOC_SIDECAR_DEPENDENCIES_MISSING', + DEPENDENCIES_REQUIRES_INVALID: 'ERR_SHARD_DOC_SIDECAR_DEPENDENCIES_REQUIRES_INVALID', + DEPENDENCIES_REQUIRES_NOT_EMPTY: 'ERR_SHARD_DOC_SIDECAR_DEPENDENCIES_REQUIRES_NOT_EMPTY', + MAJOR_VERSION_UNSUPPORTED: 'ERR_SHARD_DOC_SIDECAR_MAJOR_VERSION_UNSUPPORTED', + SOURCEPATH_BASENAME_MISMATCH: 'ERR_SHARD_DOC_SIDECAR_SOURCEPATH_BASENAME_MISMATCH', +}); + const HELP_EXEMPLAR_CANONICAL_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md'; -const HELP_EXEMPLAR_SUPPORTED_SCHEMA_MAJOR = 1; +const SHARD_DOC_CANONICAL_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.xml'; +const SIDECAR_SUPPORTED_SCHEMA_MAJOR = 1; class SidecarContractError extends Error { constructor({ code, detail, fieldPath, sourcePath }) { @@ -108,43 +126,42 @@ function createValidationError(code, fieldPath, sourcePath, detail) { }); } -function validateHelpSidecarContractData(sidecarData, options = {}) { - const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/help.artifact.yaml'); +function validateSidecarContractData(sidecarData, options) { + const { + sourcePath, + requiredFields, + requiredNonEmptyStringFields, + errorCodes, + expectedArtifactType, + expectedModule, + expectedCanonicalSourcePath, + missingDependenciesDetail, + dependenciesObjectDetail, + dependenciesRequiresArrayDetail, + dependenciesRequiresNotEmptyDetail, + artifactTypeDetail, + moduleDetail, + requiresMustBeEmpty, + } = options; if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) { - createValidationError( - HELP_SIDECAR_ERROR_CODES.INVALID_ROOT_OBJECT, - '', - sourcePath, - 'Sidecar root must be a YAML mapping object.', - ); + createValidationError(errorCodes.INVALID_ROOT_OBJECT, '', sourcePath, 'Sidecar root must be a YAML mapping object.'); } - for (const field of HELP_SIDECAR_REQUIRED_FIELDS) { + for (const field of requiredFields) { if (!hasOwn(sidecarData, field)) { if (field === 'dependencies') { - createValidationError( - HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING, - field, - sourcePath, - 'Exemplar sidecar requires an explicit dependencies block.', - ); + createValidationError(errorCodes.DEPENDENCIES_MISSING, field, sourcePath, missingDependenciesDetail); } - createValidationError( - HELP_SIDECAR_ERROR_CODES.REQUIRED_FIELD_MISSING, - field, - sourcePath, - `Missing required sidecar field "${field}".`, - ); + createValidationError(errorCodes.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, + errorCodes.REQUIRED_FIELD_EMPTY, field, sourcePath, `Required sidecar field "${field}" must be a non-empty string.`, @@ -153,58 +170,33 @@ function validateHelpSidecarContractData(sidecarData, options = {}) { } 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 (schemaMajorVersion !== SIDECAR_SUPPORTED_SCHEMA_MAJOR) { + createValidationError(errorCodes.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.artifactType !== expectedArtifactType) { + createValidationError(errorCodes.ARTIFACT_TYPE_INVALID, 'artifactType', sourcePath, artifactTypeDetail); } - if (sidecarData.module !== 'core') { - createValidationError( - HELP_SIDECAR_ERROR_CODES.MODULE_INVALID, - 'module', - sourcePath, - 'Wave-1 exemplar requires module to equal "core".', - ); + if (sidecarData.module !== expectedModule) { + createValidationError(errorCodes.MODULE_INVALID, 'module', sourcePath, moduleDetail); } 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.', - ); + createValidationError(errorCodes.DEPENDENCIES_MISSING, 'dependencies', sourcePath, dependenciesObjectDetail); } 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.', - ); + createValidationError(errorCodes.DEPENDENCIES_REQUIRES_INVALID, 'dependencies.requires', sourcePath, dependenciesRequiresArrayDetail); } - if (dependencies.requires.length > 0) { + if (requiresMustBeEmpty && dependencies.requires.length > 0) { createValidationError( - HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_NOT_EMPTY, + errorCodes.DEPENDENCIES_REQUIRES_NOT_EMPTY, 'dependencies.requires', sourcePath, - 'Wave-1 exemplar requires explicit zero dependencies: dependencies.requires must be [].', + dependenciesRequiresNotEmptyDetail, ); } @@ -212,12 +204,12 @@ function validateHelpSidecarContractData(sidecarData, options = {}) { const sidecarBasename = path.posix.basename(sourcePath); const expectedSidecarBasename = getExpectedSidecarBasenameFromSourcePath(normalizedDeclaredSourcePath); - const sourcePathMismatch = normalizedDeclaredSourcePath !== HELP_EXEMPLAR_CANONICAL_SOURCE_PATH; + const sourcePathMismatch = normalizedDeclaredSourcePath !== expectedCanonicalSourcePath; const basenameMismatch = !expectedSidecarBasename || sidecarBasename !== expectedSidecarBasename; if (sourcePathMismatch || basenameMismatch) { createValidationError( - HELP_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH, + errorCodes.SOURCEPATH_BASENAME_MISMATCH, 'sourcePath', sourcePath, 'sidecar basename does not match sourcePath basename', @@ -225,6 +217,46 @@ function validateHelpSidecarContractData(sidecarData, options = {}) { } } +function validateHelpSidecarContractData(sidecarData, options = {}) { + const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/help.artifact.yaml'); + validateSidecarContractData(sidecarData, { + sourcePath, + requiredFields: HELP_SIDECAR_REQUIRED_FIELDS, + requiredNonEmptyStringFields: ['canonicalId', 'sourcePath', 'displayName', 'description'], + errorCodes: HELP_SIDECAR_ERROR_CODES, + expectedArtifactType: 'task', + expectedModule: 'core', + expectedCanonicalSourcePath: HELP_EXEMPLAR_CANONICAL_SOURCE_PATH, + missingDependenciesDetail: 'Exemplar sidecar requires an explicit dependencies block.', + dependenciesObjectDetail: 'Exemplar sidecar requires an explicit dependencies object.', + dependenciesRequiresArrayDetail: 'Exemplar dependencies.requires must be an array.', + dependenciesRequiresNotEmptyDetail: 'Wave-1 exemplar requires explicit zero dependencies: dependencies.requires must be [].', + artifactTypeDetail: 'Wave-1 exemplar requires artifactType to equal "task".', + moduleDetail: 'Wave-1 exemplar requires module to equal "core".', + requiresMustBeEmpty: true, + }); +} + +function validateShardDocSidecarContractData(sidecarData, options = {}) { + const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/shard-doc.artifact.yaml'); + validateSidecarContractData(sidecarData, { + sourcePath, + requiredFields: SHARD_DOC_SIDECAR_REQUIRED_FIELDS, + requiredNonEmptyStringFields: ['canonicalId', 'sourcePath', 'displayName', 'description'], + errorCodes: SHARD_DOC_SIDECAR_ERROR_CODES, + expectedArtifactType: 'task', + expectedModule: 'core', + expectedCanonicalSourcePath: SHARD_DOC_CANONICAL_SOURCE_PATH, + missingDependenciesDetail: 'Shard-doc sidecar requires an explicit dependencies block.', + dependenciesObjectDetail: 'Shard-doc sidecar requires an explicit dependencies object.', + dependenciesRequiresArrayDetail: 'Shard-doc dependencies.requires must be an array.', + dependenciesRequiresNotEmptyDetail: 'Wave-2 shard-doc contract requires explicit zero dependencies: dependencies.requires must be [].', + artifactTypeDetail: 'Wave-2 shard-doc contract requires artifactType to equal "task".', + moduleDetail: 'Wave-2 shard-doc contract requires module to equal "core".', + requiresMustBeEmpty: true, + }); +} + async function validateHelpSidecarContractFile(sidecarPath = getSourcePath('core', 'tasks', 'help.artifact.yaml'), options = {}) { const normalizedSourcePath = normalizeSourcePath(options.errorSourcePath || toProjectRelativePath(sidecarPath)); @@ -253,10 +285,42 @@ async function validateHelpSidecarContractFile(sidecarPath = getSourcePath('core validateHelpSidecarContractData(parsedSidecar, { errorSourcePath: normalizedSourcePath }); } +async function validateShardDocSidecarContractFile(sidecarPath = getSourcePath('core', 'tasks', 'shard-doc.artifact.yaml'), options = {}) { + const normalizedSourcePath = normalizeSourcePath(options.errorSourcePath || toProjectRelativePath(sidecarPath)); + + if (!(await fs.pathExists(sidecarPath))) { + createValidationError( + SHARD_DOC_SIDECAR_ERROR_CODES.FILE_NOT_FOUND, + '', + normalizedSourcePath, + 'Expected shard-doc sidecar file was not found.', + ); + } + + let parsedSidecar; + try { + const sidecarRaw = await fs.readFile(sidecarPath, 'utf8'); + parsedSidecar = yaml.parse(sidecarRaw); + } catch (error) { + createValidationError( + SHARD_DOC_SIDECAR_ERROR_CODES.PARSE_FAILED, + '', + normalizedSourcePath, + `YAML parse failure: ${error.message}`, + ); + } + + validateShardDocSidecarContractData(parsedSidecar, { errorSourcePath: normalizedSourcePath }); +} + module.exports = { HELP_SIDECAR_REQUIRED_FIELDS, + SHARD_DOC_SIDECAR_REQUIRED_FIELDS, HELP_SIDECAR_ERROR_CODES, + SHARD_DOC_SIDECAR_ERROR_CODES, SidecarContractError, validateHelpSidecarContractData, validateHelpSidecarContractFile, + validateShardDocSidecarContractData, + validateShardDocSidecarContractFile, }; diff --git a/tools/cli/installers/lib/core/wave-1-validation-harness.js b/tools/cli/installers/lib/core/wave-1-validation-harness.js index 5b2e05e0c..e321d03ad 100644 --- a/tools/cli/installers/lib/core/wave-1-validation-harness.js +++ b/tools/cli/installers/lib/core/wave-1-validation-harness.js @@ -854,13 +854,16 @@ class Wave1ValidationHarness { const generator = new ManifestGenerator(); generator.bmadFolderName = runtimeFolder; - generator.helpAuthorityRecords = [ + generator.taskAuthorityRecords = [ { + recordType: 'metadata-authority', canonicalId: 'bmad-help', authoritySourceType: 'sidecar', authoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH, + sourcePath: SOURCE_MARKDOWN_SOURCE_PATH, }, ]; + generator.helpAuthorityRecords = [...generator.taskAuthorityRecords]; generator.tasks = perturbed ? [] : [ @@ -931,6 +934,21 @@ class Wave1ValidationHarness { 'output-location': '', outputs: '', }, + { + module: 'core', + phase: 'anytime', + name: 'Shard Document', + code: 'SD', + sequence: '', + 'workflow-file': `${runtimeFolder}/core/tasks/shard-doc.xml`, + command: 'bmad-shard-doc', + required: 'false', + agent: '', + options: '', + description: 'Split large markdown documents into smaller files by section with an index.', + 'output-location': '', + outputs: '', + }, ]; await fs.writeFile(path.join(coreDir, 'module-help.csv'), serializeCsv(MODULE_HELP_COMPAT_COLUMNS, moduleHelpFixtureRows), 'utf8'); await fs.writeFile( diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index 0d96db79c..b38b69037 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -16,19 +16,62 @@ const CODEX_EXPORT_DERIVATION_ERROR_CODES = Object.freeze({ SIDECAR_PARSE_FAILED: 'ERR_CODEX_EXPORT_SIDECAR_PARSE_FAILED', CANONICAL_ID_MISSING: 'ERR_CODEX_EXPORT_CANONICAL_ID_MISSING', CANONICAL_ID_DERIVATION_FAILED: 'ERR_CODEX_EXPORT_CANONICAL_ID_DERIVATION_FAILED', + DUPLICATE_EXPORT_SURFACE: 'ERR_CODEX_EXPORT_DUPLICATE_EXPORT_SURFACE', }); const EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md'; +const EXEMPLAR_SHARD_DOC_TASK_XML_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.xml'; const EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.artifact.yaml'; +const EXEMPLAR_SHARD_DOC_SIDECAR_CONTRACT_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml'; const EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE = 'sidecar-canonical-id'; -const EXEMPLAR_SIDECAR_SOURCE_CANDIDATES = Object.freeze([ +const SHARD_DOC_EXPORT_ALIAS_ROWS = Object.freeze([ Object.freeze({ - segments: ['bmad-fork', 'src', 'core', 'tasks', 'help.artifact.yaml'], + rowIdentity: 'alias-row:bmad-shard-doc:canonical-id', + canonicalId: 'bmad-shard-doc', + normalizedAliasValue: 'bmad-shard-doc', + rawIdentityHasLeadingSlash: false, }), Object.freeze({ - segments: ['src', 'core', 'tasks', 'help.artifact.yaml'], + rowIdentity: 'alias-row:bmad-shard-doc:legacy-name', + canonicalId: 'bmad-shard-doc', + normalizedAliasValue: 'shard-doc', + rawIdentityHasLeadingSlash: false, + }), + Object.freeze({ + rowIdentity: 'alias-row:bmad-shard-doc:slash-command', + canonicalId: 'bmad-shard-doc', + normalizedAliasValue: 'bmad-shard-doc', + rawIdentityHasLeadingSlash: true, }), ]); +const EXEMPLAR_CONVERTED_TASK_EXPORT_TARGETS = Object.freeze({ + help: Object.freeze({ + taskSourcePath: EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH, + sourcePathSuffix: '/core/tasks/help.md', + sidecarSourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH, + sidecarSourceCandidates: Object.freeze([ + Object.freeze({ + segments: ['bmad-fork', 'src', 'core', 'tasks', 'help.artifact.yaml'], + }), + Object.freeze({ + segments: ['src', 'core', 'tasks', 'help.artifact.yaml'], + }), + ]), + }), + 'shard-doc': Object.freeze({ + taskSourcePath: EXEMPLAR_SHARD_DOC_TASK_XML_SOURCE_PATH, + sourcePathSuffix: '/core/tasks/shard-doc.xml', + sidecarSourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_CONTRACT_SOURCE_PATH, + sidecarSourceCandidates: Object.freeze([ + Object.freeze({ + segments: ['bmad-fork', 'src', 'core', 'tasks', 'shard-doc.artifact.yaml'], + }), + Object.freeze({ + segments: ['src', 'core', 'tasks', 'shard-doc.artifact.yaml'], + }), + ]), + }), +}); class CodexExportDerivationError extends Error { constructor({ code, detail, fieldPath, sourcePath, observedValue, cause = null }) { @@ -53,6 +96,7 @@ class CodexSetup extends BaseIdeSetup { constructor() { super('codex', 'Codex', false); this.exportDerivationRecords = []; + this.exportSurfaceIdentityOwners = new Map(); } /** @@ -69,6 +113,7 @@ class CodexSetup extends BaseIdeSetup { const { artifacts, counts } = await this.collectClaudeArtifacts(projectDir, bmadDir, options); this.exportDerivationRecords = []; + this.exportSurfaceIdentityOwners = new Map(); // Clean up old .codex/prompts locations (both global and project) const oldGlobalDir = this.getOldCodexPromptDir(null, 'global'); @@ -246,14 +291,19 @@ class CodexSetup extends BaseIdeSetup { * @param {string} artifactType - Type filter (e.g., 'agent-launcher', 'workflow-command', 'task') * @returns {number} Number of skills written */ - isExemplarHelpTaskArtifact(artifact = {}) { + getConvertedTaskExportTarget(artifact = {}) { if (artifact.type !== 'task' || artifact.module !== 'core') { - return false; + return null; } const normalizedName = String(artifact.name || '') .trim() .toLowerCase(); + const exportTarget = EXEMPLAR_CONVERTED_TASK_EXPORT_TARGETS[normalizedName]; + if (!exportTarget) { + return null; + } + const normalizedRelativePath = String(artifact.relativePath || '') .trim() .replaceAll('\\', '/') @@ -263,11 +313,17 @@ class CodexSetup extends BaseIdeSetup { .replaceAll('\\', '/') .toLowerCase(); - if (normalizedName !== 'help') { - return false; + const normalizedRelativePathWithRoot = normalizedRelativePath.startsWith('/') ? normalizedRelativePath : `/${normalizedRelativePath}`; + if (!normalizedRelativePathWithRoot.endsWith(`/core/tasks/${normalizedName}.md`)) { + return null; } - return normalizedRelativePath.endsWith('/core/tasks/help.md') || normalizedSourcePath.endsWith('/core/tasks/help.md'); + const normalizedSourcePathWithRoot = normalizedSourcePath.startsWith('/') ? normalizedSourcePath : `/${normalizedSourcePath}`; + if (normalizedSourcePath && !normalizedSourcePathWithRoot.endsWith(exportTarget.sourcePathSuffix)) { + return null; + } + + return exportTarget; } throwExportDerivationError({ code, detail, fieldPath, sourcePath, observedValue, cause = null }) { @@ -281,8 +337,8 @@ class CodexSetup extends BaseIdeSetup { }); } - async loadExemplarHelpSidecar(projectDir) { - for (const candidate of EXEMPLAR_SIDECAR_SOURCE_CANDIDATES) { + async loadConvertedTaskSidecar(projectDir, exportTarget) { + for (const candidate of exportTarget.sidecarSourceCandidates) { const sidecarPath = path.join(projectDir, ...candidate.segments); if (await fs.pathExists(sidecarPath)) { let sidecarData; @@ -293,7 +349,7 @@ class CodexSetup extends BaseIdeSetup { code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_PARSE_FAILED, detail: `YAML parse failure: ${error.message}`, fieldPath: '', - sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH, + sourcePath: exportTarget.sidecarSourcePath, observedValue: '', cause: error, }); @@ -304,7 +360,7 @@ class CodexSetup extends BaseIdeSetup { 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, + sourcePath: exportTarget.sidecarSourcePath, observedValue: typeof sidecarData, }); } @@ -315,14 +371,14 @@ class CodexSetup extends BaseIdeSetup { 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, + sourcePath: exportTarget.sidecarSourcePath, observedValue: canonicalId, }); } return { canonicalId, - sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH, + sourcePath: exportTarget.sidecarSourcePath, }; } } @@ -331,15 +387,15 @@ class CodexSetup extends BaseIdeSetup { 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, + sourcePath: exportTarget.sidecarSourcePath, observedValue: projectDir, }); } async resolveSkillIdentityFromArtifact(artifact, projectDir) { const inferredSkillName = toDashPath(artifact.relativePath).replace(/\.md$/, ''); - const isExemplarHelpTask = this.isExemplarHelpTaskArtifact(artifact); - if (!isExemplarHelpTask) { + const exportTarget = this.getConvertedTaskExportTarget(artifact); + if (!exportTarget) { return { skillName: inferredSkillName, canonicalId: inferredSkillName, @@ -348,14 +404,19 @@ class CodexSetup extends BaseIdeSetup { }; } - const sidecarData = await this.loadExemplarHelpSidecar(projectDir); + const sidecarData = await this.loadConvertedTaskSidecar(projectDir, exportTarget); let canonicalResolution; try { - canonicalResolution = await normalizeAndResolveExemplarAlias(sidecarData.canonicalId, { + const aliasResolutionOptions = { fieldPath: 'canonicalId', sourcePath: sidecarData.sourcePath, - }); + }; + if (exportTarget.taskSourcePath === EXEMPLAR_SHARD_DOC_TASK_XML_SOURCE_PATH) { + aliasResolutionOptions.aliasRows = SHARD_DOC_EXPORT_ALIAS_ROWS; + aliasResolutionOptions.aliasTableSourcePath = '_bmad/_config/canonical-aliases.csv'; + } + canonicalResolution = await normalizeAndResolveExemplarAlias(sidecarData.canonicalId, aliasResolutionOptions); } catch (error) { this.throwExportDerivationError({ code: CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED, @@ -383,6 +444,7 @@ class CodexSetup extends BaseIdeSetup { canonicalId: skillName, exportIdDerivationSourceType: EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE, exportIdDerivationSourcePath: sidecarData.sourcePath, + exportIdDerivationTaskSourcePath: exportTarget.taskSourcePath, exportIdDerivationEvidence: `applied:${canonicalResolution.preAliasNormalizedValue}|leadingSlash:${canonicalResolution.rawIdentityHasLeadingSlash}->${canonicalResolution.postAliasCanonicalId}|rows:${canonicalResolution.aliasRowLocator}`, }; } @@ -402,6 +464,33 @@ class CodexSetup extends BaseIdeSetup { // Create skill directory const skillDir = path.join(destDir, skillName); + const skillPath = path.join(skillDir, 'SKILL.md'); + const normalizedSkillPath = skillPath.replaceAll('\\', '/'); + const ownerRecord = { + artifactType, + sourcePath: String(artifact.sourcePath || artifact.relativePath || ''), + }; + const existingOwner = this.exportSurfaceIdentityOwners.get(normalizedSkillPath); + if (existingOwner) { + this.throwExportDerivationError({ + code: CODEX_EXPORT_DERIVATION_ERROR_CODES.DUPLICATE_EXPORT_SURFACE, + detail: `duplicate export surface path already claimed by ${existingOwner.artifactType}:${existingOwner.sourcePath}`, + fieldPath: 'canonicalId', + sourcePath: ownerRecord.sourcePath, + observedValue: normalizedSkillPath, + }); + } + + if (await fs.pathExists(skillPath)) { + this.throwExportDerivationError({ + code: CODEX_EXPORT_DERIVATION_ERROR_CODES.DUPLICATE_EXPORT_SURFACE, + detail: 'duplicate export surface path already exists on disk', + fieldPath: 'canonicalId', + sourcePath: ownerRecord.sourcePath, + observedValue: normalizedSkillPath, + }); + } + await fs.ensureDir(skillDir); // Transform content: rewrite frontmatter for skills format @@ -409,14 +498,14 @@ class CodexSetup extends BaseIdeSetup { // Write SKILL.md with platform-native line endings const platformContent = skillContent.replaceAll('\n', os.EOL); - const skillPath = path.join(skillDir, 'SKILL.md'); await fs.writeFile(skillPath, platformContent, 'utf8'); + this.exportSurfaceIdentityOwners.set(normalizedSkillPath, ownerRecord); writtenCount++; if (exportIdentity.exportIdDerivationSourceType === EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE) { this.exportDerivationRecords.push({ exportPath: path.join('.agents', 'skills', skillName, 'SKILL.md').replaceAll('\\', '/'), - sourcePath: EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH, + sourcePath: exportIdentity.exportIdDerivationTaskSourcePath || EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH, canonicalId: exportIdentity.canonicalId, visibleId: skillName, visibleSurfaceClass: 'export-id',