feat(installer): complete wave-2 shard-doc parity and validation gates

This commit is contained in:
Dicky Moore 2026-03-03 17:26:12 +00:00
parent 51a73e28bd
commit d24ef0633f
12 changed files with 2559 additions and 261 deletions

View File

@ -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: []

View File

@ -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: []

View File

@ -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: []

File diff suppressed because it is too large Load Diff

View File

@ -169,6 +169,8 @@ function resolveCanonicalIdFromAuthorityRecords(helpAuthorityRecords = []) {
function evaluateExemplarCommandLabelReportRows(rows, options = {}) { function evaluateExemplarCommandLabelReportRows(rows, options = {}) {
const expectedCanonicalId = frontmatterMatchValue(options.canonicalId || EXEMPLAR_HELP_CATALOG_CANONICAL_ID); const expectedCanonicalId = frontmatterMatchValue(options.canonicalId || EXEMPLAR_HELP_CATALOG_CANONICAL_ID);
const expectedDisplayedLabel = frontmatterMatchValue(options.displayedCommandLabel || `/${expectedCanonicalId}`); 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 normalizedExpectedDisplayedLabel = normalizeDisplayedCommandLabel(expectedDisplayedLabel);
const targetRows = (Array.isArray(rows) ? rows : []).filter( 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 ?? '<empty>')}` }; return { valid: false, reason: `invalid-row-count-for-canonical-id:${String(row.rowCountForCanonicalId ?? '<empty>')}` };
} }
if (frontmatterMatchValue(row.authoritySourceType) !== 'sidecar') { if (frontmatterMatchValue(row.authoritySourceType) !== expectedAuthoritySourceType) {
return { valid: false, reason: `invalid-authority-source-type:${frontmatterMatchValue(row.authoritySourceType) || '<empty>'}` }; return {
valid: false,
reason: `invalid-authority-source-type:${frontmatterMatchValue(row.authoritySourceType) || '<empty>'}`,
};
} }
if (frontmatterMatchValue(row.authoritySourcePath) !== EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH) { if (frontmatterMatchValue(row.authoritySourcePath) !== expectedAuthoritySourcePath) {
return { return {
valid: false, valid: false,
reason: `invalid-authority-source-path:${frontmatterMatchValue(row.authoritySourcePath) || '<empty>'}`, reason: `invalid-authority-source-path:${frontmatterMatchValue(row.authoritySourcePath) || '<empty>'}`,

View File

@ -9,8 +9,9 @@ const { Config } = require('../../../lib/config');
const { XmlHandler } = require('../../../lib/xml-handler'); const { XmlHandler } = require('../../../lib/xml-handler');
const { DependencyResolver } = require('./dependency-resolver'); const { DependencyResolver } = require('./dependency-resolver');
const { ConfigCollector } = require('./config-collector'); 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 { validateHelpAuthoritySplitAndPrecedence } = require('./help-authority-validator');
const { validateShardDocAuthoritySplitAndPrecedence } = require('./shard-doc-authority-validator');
const { const {
HELP_CATALOG_GENERATION_ERROR_CODES, HELP_CATALOG_GENERATION_ERROR_CODES,
buildSidecarAwareExemplarHelpRow, 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_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_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 { class Installer {
constructor() { constructor() {
@ -44,7 +49,9 @@ class Installer {
this.configCollector = new ConfigCollector(); this.configCollector = new ConfigCollector();
this.ideConfigManager = new IdeConfigManager(); this.ideConfigManager = new IdeConfigManager();
this.validateHelpSidecarContractFile = validateHelpSidecarContractFile; this.validateHelpSidecarContractFile = validateHelpSidecarContractFile;
this.validateShardDocSidecarContractFile = validateShardDocSidecarContractFile;
this.validateHelpAuthoritySplitAndPrecedence = validateHelpAuthoritySplitAndPrecedence; this.validateHelpAuthoritySplitAndPrecedence = validateHelpAuthoritySplitAndPrecedence;
this.validateShardDocAuthoritySplitAndPrecedence = validateShardDocAuthoritySplitAndPrecedence;
this.ManifestGenerator = ManifestGenerator; this.ManifestGenerator = ManifestGenerator;
this.installedFiles = new Set(); // Track all installed files this.installedFiles = new Set(); // Track all installed files
this.bmadFolderName = BMAD_FOLDER_NAME; this.bmadFolderName = BMAD_FOLDER_NAME;
@ -56,12 +63,27 @@ class Installer {
} }
async runConfigurationGenerationTask({ message, bmadDir, moduleConfigs, config, allModules, addResult }) { 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. // 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...'); message('Validating exemplar sidecar contract...');
await this.validateHelpSidecarContractFile(); await this.validateHelpSidecarContractFile();
addResult('Shard-doc sidecar contract', 'ok', 'validated');
addResult('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...'); message('Validating authority split and frontmatter precedence...');
const helpAuthorityValidation = await this.validateHelpAuthoritySplitAndPrecedence({ const helpAuthorityValidation = await this.validateHelpAuthoritySplitAndPrecedence({
bmadDir, bmadDir,
@ -109,6 +131,7 @@ class Installer {
ides: config.ides || [], ides: config.ides || [],
preservedModules: modulesForCsvPreserve, preservedModules: modulesForCsvPreserve,
helpAuthorityRecords: this.helpAuthorityRecords || [], helpAuthorityRecords: this.helpAuthorityRecords || [],
taskAuthorityRecords: [...(this.helpAuthorityRecords || []), ...(this.shardDocAuthorityRecords || [])],
}); });
addResult( addResult(
@ -1780,6 +1803,38 @@ class Installer {
/** /**
* Private: Create directory structure * 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 }) { isExemplarHelpCatalogRow({ moduleName, name, workflowFile, command, canonicalId }) {
if (moduleName !== 'core') return false; 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 || '') const normalizedWorkflowFile = String(workflowFile || '')
.trim() .trim()
.replaceAll('\\', '/') .replaceAll('\\', '/')
@ -1849,13 +1904,27 @@ class Installer {
.toLowerCase() .toLowerCase()
.replace(/^\/+/, ''); .replace(/^\/+/, '');
const isHelpWorkflow = normalizedWorkflowFile.endsWith('/core/tasks/help.md'); const normalizedWorkflowFileContractPath = String(workflowFileContractPath || '')
const isExemplarIdentity = .trim()
normalizedName === 'bmad-help' || .replaceAll('\\', '/')
normalizedCommandValue === normalizedCanonicalId || .toLowerCase();
(normalizedLegacyName.length > 0 && normalizedCommandValue === normalizedLegacyName); 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) { async writeCsvArtifact(filePath, columns, rows) {
@ -1886,6 +1955,31 @@ class Installer {
helpAuthorityRecords: this.helpAuthorityRecords || [], helpAuthorityRecords: this.helpAuthorityRecords || [],
bmadFolderName: this.bmadFolderName || BMAD_FOLDER_NAME, 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; let exemplarRowWritten = false;
// Load agent manifest for agent info lookup // Load agent manifest for agent info lookup
@ -2110,31 +2204,37 @@ class Installer {
continue; continue;
} }
if ( for (const contract of commandLabelContracts) {
!this.isExemplarCommandLabelCandidate({ const isContractCandidate = this.isCommandLabelCandidate({
workflowFile, workflowFile,
name, name,
rawCommandValue, rawCommandValue,
canonicalId: sidecarAwareExemplar.canonicalId, canonicalId: contract.canonicalId,
legacyName: sidecarAwareExemplar.legacyName, legacyName: contract.legacyName,
}) workflowFileContractPath: contract.workflowFilePath,
) { nameCandidates: contract.nameCandidates,
continue; });
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) => ({ this.helpCatalogPipelineRows = sidecarAwareExemplar.pipelineStageRows.map((row) => ({
...row, ...row,
@ -2144,15 +2244,24 @@ class Installer {
})); }));
this.helpCatalogCommandLabelReportRows = commandLabelRowsFromMergedCatalog.map((row) => ({ this.helpCatalogCommandLabelReportRows = commandLabelRowsFromMergedCatalog.map((row) => ({
...row, ...row,
rowCountForCanonicalId: exemplarRowCount, rowCountForCanonicalId: commandLabelRowCountByCanonicalId.get(row.canonicalId) || 0,
status: exemplarRowCount === 1 ? 'PASS' : 'FAIL', status: (commandLabelRowCountByCanonicalId.get(row.canonicalId) || 0) === 1 ? 'PASS' : 'FAIL',
})); }));
const commandLabelContractResult = evaluateExemplarCommandLabelReportRows(this.helpCatalogCommandLabelReportRows, { const commandLabelContractFailures = new Map();
canonicalId: sidecarAwareExemplar.canonicalId, for (const contract of commandLabelContracts) {
displayedCommandLabel: sidecarAwareExemplar.displayedCommandLabel, const commandLabelContractResult = evaluateExemplarCommandLabelReportRows(this.helpCatalogCommandLabelReportRows, {
}); canonicalId: contract.canonicalId,
if (!commandLabelContractResult.valid) { 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) => ({ this.helpCatalogPipelineRows = this.helpCatalogPipelineRows.map((row) => ({
...row, ...row,
stageStatus: 'FAIL', stageStatus: 'FAIL',
@ -2161,14 +2270,19 @@ class Installer {
this.helpCatalogCommandLabelReportRows = this.helpCatalogCommandLabelReportRows.map((row) => ({ this.helpCatalogCommandLabelReportRows = this.helpCatalogCommandLabelReportRows.map((row) => ({
...row, ...row,
status: 'FAIL', 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( 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.code = HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED;
commandLabelError.detail = commandLabelContractResult.reason; commandLabelError.detail = commandLabelFailureSummary;
throw commandLabelError; throw commandLabelError;
} }

View File

@ -15,6 +15,7 @@ const { validateTaskManifestCompatibilitySurface } = require('./projection-compa
// Load package.json for version info // Load package.json for version info
const packageJson = require('../../../../../package.json'); const packageJson = require('../../../../../package.json');
const DEFAULT_EXEMPLAR_HELP_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.artifact.yaml'; 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([ const CANONICAL_ALIAS_TABLE_COLUMNS = Object.freeze([
'canonicalId', 'canonicalId',
'alias', 'alias',
@ -55,6 +56,35 @@ const LOCKED_CANONICAL_ALIAS_TABLE_EXEMPLAR_ROWS = Object.freeze([
resolutionEligibility: 'slash-command-only', 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 * Generates manifest files for installed workflows, agents, and tasks
@ -68,6 +98,73 @@ class ManifestGenerator {
this.modules = []; this.modules = [];
this.files = []; this.files = [];
this.selectedIdes = []; 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); 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 // Filter out any undefined/null values from IDE list
this.selectedIdes = resolvedIdes.filter((ide) => ide && typeof ide === 'string'); this.selectedIdes = resolvedIdes.filter((ide) => ide && typeof ide === 'string');
@ -958,15 +1062,10 @@ class ManifestGenerator {
const csvPath = path.join(cfgDir, 'task-manifest.csv'); const csvPath = path.join(cfgDir, 'task-manifest.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`; const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
const compatibilitySurfacePath = `${this.bmadFolderName || '_bmad'}/_config/task-manifest.csv`; const compatibilitySurfacePath = `${this.bmadFolderName || '_bmad'}/_config/task-manifest.csv`;
const sidecarAuthorityRecord = Array.isArray(this.helpAuthorityRecords) const taskAuthorityRecords = Array.isArray(this.taskAuthorityRecords)
? this.helpAuthorityRecords.find( ? this.taskAuthorityRecords
(record) => record?.canonicalId === 'bmad-help' && record?.authoritySourceType === 'sidecar' && record?.authoritySourcePath, : this.normalizeTaskAuthorityRecords(this.helpAuthorityRecords);
) const taskAuthorityProjectionIndex = this.buildTaskAuthorityProjectionIndex(taskAuthorityRecords);
: null;
const exemplarAuthoritySourceType = sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourceType : 'sidecar';
const exemplarAuthoritySourcePath = sidecarAuthorityRecord
? sidecarAuthorityRecord.authoritySourcePath
: 'bmad-fork/src/core/tasks/help.artifact.yaml';
// Read existing manifest to preserve entries // Read existing manifest to preserve entries
const existingEntries = new Map(); const existingEntries = new Map();
@ -1015,7 +1114,7 @@ class ManifestGenerator {
for (const task of this.tasks) { for (const task of this.tasks) {
const key = `${task.module}:${task.name}`; const key = `${task.module}:${task.name}`;
const previousRecord = allTasks.get(key); const previousRecord = allTasks.get(key);
const isExemplarHelpTask = task.module === 'core' && task.name === 'help'; const authorityProjection = taskAuthorityProjectionIndex.get(key);
allTasks.set(key, { allTasks.set(key, {
name: task.name, name: task.name,
@ -1024,10 +1123,10 @@ class ManifestGenerator {
module: task.module, module: task.module,
path: task.path, path: task.path,
standalone: task.standalone, standalone: task.standalone,
legacyName: isExemplarHelpTask ? 'help' : previousRecord?.legacyName || task.name, legacyName: authorityProjection ? authorityProjection.legacyName : previousRecord?.legacyName || task.name,
canonicalId: isExemplarHelpTask ? 'bmad-help' : previousRecord?.canonicalId || '', canonicalId: authorityProjection ? authorityProjection.canonicalId : previousRecord?.canonicalId || '',
authoritySourceType: isExemplarHelpTask ? exemplarAuthoritySourceType : previousRecord?.authoritySourceType || '', authoritySourceType: authorityProjection ? authorityProjection.authoritySourceType : previousRecord?.authoritySourceType || '',
authoritySourcePath: isExemplarHelpTask ? exemplarAuthoritySourcePath : previousRecord?.authoritySourcePath || '', authoritySourcePath: authorityProjection ? authorityProjection.authoritySourcePath : previousRecord?.authoritySourcePath || '',
}); });
} }
@ -1070,18 +1169,64 @@ class ManifestGenerator {
}; };
} }
buildCanonicalAliasProjectionRows(authoritySourceType, authoritySourcePath) { resolveShardDocAliasAuthorityRecord() {
return LOCKED_CANONICAL_ALIAS_TABLE_EXEMPLAR_ROWS.map((row) => ({ const sidecarAuthorityRecord = Array.isArray(this.taskAuthorityRecords)
canonicalId: row.canonicalId, ? this.taskAuthorityRecords.find(
alias: row.alias, (record) => record?.canonicalId === 'bmad-shard-doc' && record?.authoritySourceType === 'sidecar' && record?.authoritySourcePath,
aliasType: row.aliasType, )
authoritySourceType, : null;
authoritySourcePath, return {
rowIdentity: row.rowIdentity, authoritySourceType: sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourceType : 'sidecar',
normalizedAliasValue: row.normalizedAliasValue, authoritySourcePath: sidecarAuthorityRecord
rawIdentityHasLeadingSlash: row.rawIdentityHasLeadingSlash, ? sidecarAuthorityRecord.authoritySourcePath
resolutionEligibility: row.resolutionEligibility, : 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) { async writeCanonicalAliasManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'canonical-aliases.csv'); const csvPath = path.join(cfgDir, 'canonical-aliases.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`; const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
const { authoritySourceType, authoritySourcePath } = this.resolveExemplarAliasAuthorityRecord(); const projectedRows = this.buildCanonicalAliasProjectionRows();
const projectedRows = this.buildCanonicalAliasProjectionRows(authoritySourceType, authoritySourcePath);
let csvContent = `${CANONICAL_ALIAS_TABLE_COLUMNS.join(',')}\n`; let csvContent = `${CANONICAL_ALIAS_TABLE_COLUMNS.join(',')}\n`;
for (const row of projectedRows) { for (const row of projectedRows) {

View File

@ -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_HEADER_WAVE1_MISMATCH: 'ERR_HELP_CATALOG_COMPAT_HEADER_WAVE1_MISMATCH',
HELP_CATALOG_REQUIRED_COLUMN_MISSING: 'ERR_HELP_CATALOG_COMPAT_REQUIRED_COLUMN_MISSING', 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_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', 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 { class ProjectionCompatibilityError extends Error {
@ -177,6 +183,37 @@ function normalizeWorkflowPath(value) {
return normalizeSourcePath(value).toLowerCase(); 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: '<no-slash-command-rows>',
expectedValue: '| `/bmad-...` |',
});
}
return commands;
}
function validateTaskManifestLoaderEntries(rows, options = {}) { function validateTaskManifestLoaderEntries(rows, options = {}) {
const surface = options.surface || 'task-manifest-loader'; const surface = options.surface || 'task-manifest-loader';
const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/task-manifest.csv'); 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; return true;
} }
@ -392,6 +446,84 @@ function validateHelpCatalogCompatibilitySurface(csvContent, options = {}) {
return { headerColumns, rows }; 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: '<missing>',
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('|') || '<missing>',
expectedValue: expectedDisplayedCommandLabel,
});
}
return {
canonicalId,
expectedDisplayedCommandLabel,
documentedCommands,
generatedCanonicalCommand: expectedDisplayedCommandLabel,
};
}
module.exports = { module.exports = {
PROJECTION_COMPATIBILITY_ERROR_CODES, PROJECTION_COMPATIBILITY_ERROR_CODES,
ProjectionCompatibilityError, ProjectionCompatibilityError,
@ -404,4 +536,5 @@ module.exports = {
validateHelpCatalogCompatibilitySurface, validateHelpCatalogCompatibilitySurface,
validateHelpCatalogLoaderEntries, validateHelpCatalogLoaderEntries,
validateGithubCopilotHelpLoaderEntries, validateGithubCopilotHelpLoaderEntries,
validateCommandDocSurfaceConsistency,
}; };

View File

@ -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',
'<file>',
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}`,
'<document>',
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}`,
'<document>',
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,
'<missing>',
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 || '<empty>',
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',
'<file>',
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}`,
'<document>',
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',
'<document>',
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',
'<file>',
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,
};

View File

@ -14,6 +14,8 @@ const HELP_SIDECAR_REQUIRED_FIELDS = Object.freeze([
'dependencies', 'dependencies',
]); ]);
const SHARD_DOC_SIDECAR_REQUIRED_FIELDS = Object.freeze([...HELP_SIDECAR_REQUIRED_FIELDS]);
const HELP_SIDECAR_ERROR_CODES = Object.freeze({ const HELP_SIDECAR_ERROR_CODES = Object.freeze({
FILE_NOT_FOUND: 'ERR_HELP_SIDECAR_FILE_NOT_FOUND', FILE_NOT_FOUND: 'ERR_HELP_SIDECAR_FILE_NOT_FOUND',
PARSE_FAILED: 'ERR_HELP_SIDECAR_PARSE_FAILED', 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', 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_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 { class SidecarContractError extends Error {
constructor({ code, detail, fieldPath, sourcePath }) { constructor({ code, detail, fieldPath, sourcePath }) {
@ -108,43 +126,42 @@ function createValidationError(code, fieldPath, sourcePath, detail) {
}); });
} }
function validateHelpSidecarContractData(sidecarData, options = {}) { function validateSidecarContractData(sidecarData, options) {
const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/help.artifact.yaml'); const {
sourcePath,
requiredFields,
requiredNonEmptyStringFields,
errorCodes,
expectedArtifactType,
expectedModule,
expectedCanonicalSourcePath,
missingDependenciesDetail,
dependenciesObjectDetail,
dependenciesRequiresArrayDetail,
dependenciesRequiresNotEmptyDetail,
artifactTypeDetail,
moduleDetail,
requiresMustBeEmpty,
} = options;
if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) { if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
createValidationError( createValidationError(errorCodes.INVALID_ROOT_OBJECT, '<document>', sourcePath, 'Sidecar root must be a YAML mapping object.');
HELP_SIDECAR_ERROR_CODES.INVALID_ROOT_OBJECT,
'<document>',
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 (!hasOwn(sidecarData, field)) {
if (field === 'dependencies') { if (field === 'dependencies') {
createValidationError( createValidationError(errorCodes.DEPENDENCIES_MISSING, field, sourcePath, missingDependenciesDetail);
HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING,
field,
sourcePath,
'Exemplar sidecar requires an explicit dependencies block.',
);
} }
createValidationError( createValidationError(errorCodes.REQUIRED_FIELD_MISSING, field, sourcePath, `Missing required sidecar field "${field}".`);
HELP_SIDECAR_ERROR_CODES.REQUIRED_FIELD_MISSING,
field,
sourcePath,
`Missing required sidecar field "${field}".`,
);
} }
} }
const requiredNonEmptyStringFields = ['canonicalId', 'sourcePath', 'displayName', 'description'];
for (const field of requiredNonEmptyStringFields) { for (const field of requiredNonEmptyStringFields) {
if (isBlankString(sidecarData[field])) { if (isBlankString(sidecarData[field])) {
createValidationError( createValidationError(
HELP_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY, errorCodes.REQUIRED_FIELD_EMPTY,
field, field,
sourcePath, sourcePath,
`Required sidecar field "${field}" must be a non-empty string.`, `Required sidecar field "${field}" must be a non-empty string.`,
@ -153,58 +170,33 @@ function validateHelpSidecarContractData(sidecarData, options = {}) {
} }
const schemaMajorVersion = parseSchemaMajorVersion(sidecarData.schemaVersion); const schemaMajorVersion = parseSchemaMajorVersion(sidecarData.schemaVersion);
if (schemaMajorVersion !== HELP_EXEMPLAR_SUPPORTED_SCHEMA_MAJOR) { if (schemaMajorVersion !== SIDECAR_SUPPORTED_SCHEMA_MAJOR) {
createValidationError( createValidationError(errorCodes.MAJOR_VERSION_UNSUPPORTED, 'schemaVersion', sourcePath, 'sidecar schema major version is unsupported');
HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED,
'schemaVersion',
sourcePath,
'sidecar schema major version is unsupported',
);
} }
if (sidecarData.artifactType !== 'task') { if (sidecarData.artifactType !== expectedArtifactType) {
createValidationError( createValidationError(errorCodes.ARTIFACT_TYPE_INVALID, 'artifactType', sourcePath, artifactTypeDetail);
HELP_SIDECAR_ERROR_CODES.ARTIFACT_TYPE_INVALID,
'artifactType',
sourcePath,
'Wave-1 exemplar requires artifactType to equal "task".',
);
} }
if (sidecarData.module !== 'core') { if (sidecarData.module !== expectedModule) {
createValidationError( createValidationError(errorCodes.MODULE_INVALID, 'module', sourcePath, moduleDetail);
HELP_SIDECAR_ERROR_CODES.MODULE_INVALID,
'module',
sourcePath,
'Wave-1 exemplar requires module to equal "core".',
);
} }
const dependencies = sidecarData.dependencies; const dependencies = sidecarData.dependencies;
if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) { if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) {
createValidationError( createValidationError(errorCodes.DEPENDENCIES_MISSING, 'dependencies', sourcePath, dependenciesObjectDetail);
HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING,
'dependencies',
sourcePath,
'Exemplar sidecar requires an explicit dependencies object.',
);
} }
if (!hasOwn(dependencies, 'requires') || !Array.isArray(dependencies.requires)) { if (!hasOwn(dependencies, 'requires') || !Array.isArray(dependencies.requires)) {
createValidationError( createValidationError(errorCodes.DEPENDENCIES_REQUIRES_INVALID, 'dependencies.requires', sourcePath, dependenciesRequiresArrayDetail);
HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_INVALID,
'dependencies.requires',
sourcePath,
'Exemplar dependencies.requires must be an array.',
);
} }
if (dependencies.requires.length > 0) { if (requiresMustBeEmpty && dependencies.requires.length > 0) {
createValidationError( createValidationError(
HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_NOT_EMPTY, errorCodes.DEPENDENCIES_REQUIRES_NOT_EMPTY,
'dependencies.requires', 'dependencies.requires',
sourcePath, 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 sidecarBasename = path.posix.basename(sourcePath);
const expectedSidecarBasename = getExpectedSidecarBasenameFromSourcePath(normalizedDeclaredSourcePath); const expectedSidecarBasename = getExpectedSidecarBasenameFromSourcePath(normalizedDeclaredSourcePath);
const sourcePathMismatch = normalizedDeclaredSourcePath !== HELP_EXEMPLAR_CANONICAL_SOURCE_PATH; const sourcePathMismatch = normalizedDeclaredSourcePath !== expectedCanonicalSourcePath;
const basenameMismatch = !expectedSidecarBasename || sidecarBasename !== expectedSidecarBasename; const basenameMismatch = !expectedSidecarBasename || sidecarBasename !== expectedSidecarBasename;
if (sourcePathMismatch || basenameMismatch) { if (sourcePathMismatch || basenameMismatch) {
createValidationError( createValidationError(
HELP_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH, errorCodes.SOURCEPATH_BASENAME_MISMATCH,
'sourcePath', 'sourcePath',
sourcePath, sourcePath,
'sidecar basename does not match sourcePath basename', '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 = {}) { async function validateHelpSidecarContractFile(sidecarPath = getSourcePath('core', 'tasks', 'help.artifact.yaml'), options = {}) {
const normalizedSourcePath = normalizeSourcePath(options.errorSourcePath || toProjectRelativePath(sidecarPath)); const normalizedSourcePath = normalizeSourcePath(options.errorSourcePath || toProjectRelativePath(sidecarPath));
@ -253,10 +285,42 @@ async function validateHelpSidecarContractFile(sidecarPath = getSourcePath('core
validateHelpSidecarContractData(parsedSidecar, { errorSourcePath: normalizedSourcePath }); 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,
'<file>',
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,
'<document>',
normalizedSourcePath,
`YAML parse failure: ${error.message}`,
);
}
validateShardDocSidecarContractData(parsedSidecar, { errorSourcePath: normalizedSourcePath });
}
module.exports = { module.exports = {
HELP_SIDECAR_REQUIRED_FIELDS, HELP_SIDECAR_REQUIRED_FIELDS,
SHARD_DOC_SIDECAR_REQUIRED_FIELDS,
HELP_SIDECAR_ERROR_CODES, HELP_SIDECAR_ERROR_CODES,
SHARD_DOC_SIDECAR_ERROR_CODES,
SidecarContractError, SidecarContractError,
validateHelpSidecarContractData, validateHelpSidecarContractData,
validateHelpSidecarContractFile, validateHelpSidecarContractFile,
validateShardDocSidecarContractData,
validateShardDocSidecarContractFile,
}; };

View File

@ -854,13 +854,16 @@ class Wave1ValidationHarness {
const generator = new ManifestGenerator(); const generator = new ManifestGenerator();
generator.bmadFolderName = runtimeFolder; generator.bmadFolderName = runtimeFolder;
generator.helpAuthorityRecords = [ generator.taskAuthorityRecords = [
{ {
recordType: 'metadata-authority',
canonicalId: 'bmad-help', canonicalId: 'bmad-help',
authoritySourceType: 'sidecar', authoritySourceType: 'sidecar',
authoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH, authoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
}, },
]; ];
generator.helpAuthorityRecords = [...generator.taskAuthorityRecords];
generator.tasks = perturbed generator.tasks = perturbed
? [] ? []
: [ : [
@ -931,6 +934,21 @@ class Wave1ValidationHarness {
'output-location': '', 'output-location': '',
outputs: '', 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(path.join(coreDir, 'module-help.csv'), serializeCsv(MODULE_HELP_COMPAT_COLUMNS, moduleHelpFixtureRows), 'utf8');
await fs.writeFile( await fs.writeFile(

View File

@ -16,19 +16,62 @@ const CODEX_EXPORT_DERIVATION_ERROR_CODES = Object.freeze({
SIDECAR_PARSE_FAILED: 'ERR_CODEX_EXPORT_SIDECAR_PARSE_FAILED', SIDECAR_PARSE_FAILED: 'ERR_CODEX_EXPORT_SIDECAR_PARSE_FAILED',
CANONICAL_ID_MISSING: 'ERR_CODEX_EXPORT_CANONICAL_ID_MISSING', CANONICAL_ID_MISSING: 'ERR_CODEX_EXPORT_CANONICAL_ID_MISSING',
CANONICAL_ID_DERIVATION_FAILED: 'ERR_CODEX_EXPORT_CANONICAL_ID_DERIVATION_FAILED', 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_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_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_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({ 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({ 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 { class CodexExportDerivationError extends Error {
constructor({ code, detail, fieldPath, sourcePath, observedValue, cause = null }) { constructor({ code, detail, fieldPath, sourcePath, observedValue, cause = null }) {
@ -53,6 +96,7 @@ class CodexSetup extends BaseIdeSetup {
constructor() { constructor() {
super('codex', 'Codex', false); super('codex', 'Codex', false);
this.exportDerivationRecords = []; this.exportDerivationRecords = [];
this.exportSurfaceIdentityOwners = new Map();
} }
/** /**
@ -69,6 +113,7 @@ class CodexSetup extends BaseIdeSetup {
const { artifacts, counts } = await this.collectClaudeArtifacts(projectDir, bmadDir, options); const { artifacts, counts } = await this.collectClaudeArtifacts(projectDir, bmadDir, options);
this.exportDerivationRecords = []; this.exportDerivationRecords = [];
this.exportSurfaceIdentityOwners = new Map();
// Clean up old .codex/prompts locations (both global and project) // Clean up old .codex/prompts locations (both global and project)
const oldGlobalDir = this.getOldCodexPromptDir(null, 'global'); 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') * @param {string} artifactType - Type filter (e.g., 'agent-launcher', 'workflow-command', 'task')
* @returns {number} Number of skills written * @returns {number} Number of skills written
*/ */
isExemplarHelpTaskArtifact(artifact = {}) { getConvertedTaskExportTarget(artifact = {}) {
if (artifact.type !== 'task' || artifact.module !== 'core') { if (artifact.type !== 'task' || artifact.module !== 'core') {
return false; return null;
} }
const normalizedName = String(artifact.name || '') const normalizedName = String(artifact.name || '')
.trim() .trim()
.toLowerCase(); .toLowerCase();
const exportTarget = EXEMPLAR_CONVERTED_TASK_EXPORT_TARGETS[normalizedName];
if (!exportTarget) {
return null;
}
const normalizedRelativePath = String(artifact.relativePath || '') const normalizedRelativePath = String(artifact.relativePath || '')
.trim() .trim()
.replaceAll('\\', '/') .replaceAll('\\', '/')
@ -263,11 +313,17 @@ class CodexSetup extends BaseIdeSetup {
.replaceAll('\\', '/') .replaceAll('\\', '/')
.toLowerCase(); .toLowerCase();
if (normalizedName !== 'help') { const normalizedRelativePathWithRoot = normalizedRelativePath.startsWith('/') ? normalizedRelativePath : `/${normalizedRelativePath}`;
return false; 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 }) { throwExportDerivationError({ code, detail, fieldPath, sourcePath, observedValue, cause = null }) {
@ -281,8 +337,8 @@ class CodexSetup extends BaseIdeSetup {
}); });
} }
async loadExemplarHelpSidecar(projectDir) { async loadConvertedTaskSidecar(projectDir, exportTarget) {
for (const candidate of EXEMPLAR_SIDECAR_SOURCE_CANDIDATES) { for (const candidate of exportTarget.sidecarSourceCandidates) {
const sidecarPath = path.join(projectDir, ...candidate.segments); const sidecarPath = path.join(projectDir, ...candidate.segments);
if (await fs.pathExists(sidecarPath)) { if (await fs.pathExists(sidecarPath)) {
let sidecarData; let sidecarData;
@ -293,7 +349,7 @@ class CodexSetup extends BaseIdeSetup {
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_PARSE_FAILED, code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
detail: `YAML parse failure: ${error.message}`, detail: `YAML parse failure: ${error.message}`,
fieldPath: '<document>', fieldPath: '<document>',
sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH, sourcePath: exportTarget.sidecarSourcePath,
observedValue: '<parse-error>', observedValue: '<parse-error>',
cause: error, cause: error,
}); });
@ -304,7 +360,7 @@ class CodexSetup extends BaseIdeSetup {
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_PARSE_FAILED, code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
detail: 'sidecar root must be a YAML mapping object', detail: 'sidecar root must be a YAML mapping object',
fieldPath: '<document>', fieldPath: '<document>',
sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH, sourcePath: exportTarget.sidecarSourcePath,
observedValue: typeof sidecarData, observedValue: typeof sidecarData,
}); });
} }
@ -315,14 +371,14 @@ class CodexSetup extends BaseIdeSetup {
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_MISSING, code: CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_MISSING,
detail: 'sidecar canonicalId is required for exemplar export derivation', detail: 'sidecar canonicalId is required for exemplar export derivation',
fieldPath: 'canonicalId', fieldPath: 'canonicalId',
sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH, sourcePath: exportTarget.sidecarSourcePath,
observedValue: canonicalId, observedValue: canonicalId,
}); });
} }
return { return {
canonicalId, 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, code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
detail: 'expected exemplar sidecar metadata file was not found', detail: 'expected exemplar sidecar metadata file was not found',
fieldPath: '<file>', fieldPath: '<file>',
sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH, sourcePath: exportTarget.sidecarSourcePath,
observedValue: projectDir, observedValue: projectDir,
}); });
} }
async resolveSkillIdentityFromArtifact(artifact, projectDir) { async resolveSkillIdentityFromArtifact(artifact, projectDir) {
const inferredSkillName = toDashPath(artifact.relativePath).replace(/\.md$/, ''); const inferredSkillName = toDashPath(artifact.relativePath).replace(/\.md$/, '');
const isExemplarHelpTask = this.isExemplarHelpTaskArtifact(artifact); const exportTarget = this.getConvertedTaskExportTarget(artifact);
if (!isExemplarHelpTask) { if (!exportTarget) {
return { return {
skillName: inferredSkillName, skillName: inferredSkillName,
canonicalId: 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; let canonicalResolution;
try { try {
canonicalResolution = await normalizeAndResolveExemplarAlias(sidecarData.canonicalId, { const aliasResolutionOptions = {
fieldPath: 'canonicalId', fieldPath: 'canonicalId',
sourcePath: sidecarData.sourcePath, 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) { } catch (error) {
this.throwExportDerivationError({ this.throwExportDerivationError({
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED, code: CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED,
@ -383,6 +444,7 @@ class CodexSetup extends BaseIdeSetup {
canonicalId: skillName, canonicalId: skillName,
exportIdDerivationSourceType: EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE, exportIdDerivationSourceType: EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE,
exportIdDerivationSourcePath: sidecarData.sourcePath, exportIdDerivationSourcePath: sidecarData.sourcePath,
exportIdDerivationTaskSourcePath: exportTarget.taskSourcePath,
exportIdDerivationEvidence: `applied:${canonicalResolution.preAliasNormalizedValue}|leadingSlash:${canonicalResolution.rawIdentityHasLeadingSlash}->${canonicalResolution.postAliasCanonicalId}|rows:${canonicalResolution.aliasRowLocator}`, exportIdDerivationEvidence: `applied:${canonicalResolution.preAliasNormalizedValue}|leadingSlash:${canonicalResolution.rawIdentityHasLeadingSlash}->${canonicalResolution.postAliasCanonicalId}|rows:${canonicalResolution.aliasRowLocator}`,
}; };
} }
@ -402,6 +464,33 @@ class CodexSetup extends BaseIdeSetup {
// Create skill directory // Create skill directory
const skillDir = path.join(destDir, skillName); 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 || '<unknown>'),
};
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); await fs.ensureDir(skillDir);
// Transform content: rewrite frontmatter for skills format // Transform content: rewrite frontmatter for skills format
@ -409,14 +498,14 @@ class CodexSetup extends BaseIdeSetup {
// Write SKILL.md with platform-native line endings // Write SKILL.md with platform-native line endings
const platformContent = skillContent.replaceAll('\n', os.EOL); const platformContent = skillContent.replaceAll('\n', os.EOL);
const skillPath = path.join(skillDir, 'SKILL.md');
await fs.writeFile(skillPath, platformContent, 'utf8'); await fs.writeFile(skillPath, platformContent, 'utf8');
this.exportSurfaceIdentityOwners.set(normalizedSkillPath, ownerRecord);
writtenCount++; writtenCount++;
if (exportIdentity.exportIdDerivationSourceType === EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE) { if (exportIdentity.exportIdDerivationSourceType === EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE) {
this.exportDerivationRecords.push({ this.exportDerivationRecords.push({
exportPath: path.join('.agents', 'skills', skillName, 'SKILL.md').replaceAll('\\', '/'), 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, canonicalId: exportIdentity.canonicalId,
visibleId: skillName, visibleId: skillName,
visibleSurfaceClass: 'export-id', visibleSurfaceClass: 'export-id',