feat(installer): add canonical help projections and wave-1 validation harness
This commit is contained in:
parent
44972d62b9
commit
51a73e28bd
|
|
@ -0,0 +1,9 @@
|
|||
schemaVersion: 1
|
||||
canonicalId: bmad-help
|
||||
artifactType: task
|
||||
module: core
|
||||
sourcePath: bmad-fork/src/core/tasks/help.md
|
||||
displayName: help
|
||||
description: "Analyzes what is done and the users query and offers advice on what to do next. Use if user says what should I do next or what do I do now"
|
||||
dependencies:
|
||||
requires: []
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,266 @@
|
|||
const path = require('node:path');
|
||||
const fs = require('fs-extra');
|
||||
const csv = require('csv-parse/sync');
|
||||
|
||||
const HELP_ALIAS_NORMALIZATION_ERROR_CODES = Object.freeze({
|
||||
EMPTY_INPUT: 'ERR_CAPABILITY_ALIAS_EMPTY_INPUT',
|
||||
MULTIPLE_LEADING_SLASHES: 'ERR_CAPABILITY_ALIAS_MULTIPLE_LEADING_SLASHES',
|
||||
EMPTY_PREALIAS: 'ERR_CAPABILITY_ALIAS_EMPTY_PREALIAS',
|
||||
UNRESOLVED: 'ERR_CAPABILITY_ALIAS_UNRESOLVED',
|
||||
});
|
||||
|
||||
const EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH = '_bmad/_config/canonical-aliases.csv';
|
||||
|
||||
const LOCKED_EXEMPLAR_ALIAS_ROWS = Object.freeze([
|
||||
Object.freeze({
|
||||
rowIdentity: 'alias-row:bmad-help:canonical-id',
|
||||
canonicalId: 'bmad-help',
|
||||
normalizedAliasValue: 'bmad-help',
|
||||
rawIdentityHasLeadingSlash: false,
|
||||
}),
|
||||
Object.freeze({
|
||||
rowIdentity: 'alias-row:bmad-help:legacy-name',
|
||||
canonicalId: 'bmad-help',
|
||||
normalizedAliasValue: 'help',
|
||||
rawIdentityHasLeadingSlash: false,
|
||||
}),
|
||||
Object.freeze({
|
||||
rowIdentity: 'alias-row:bmad-help:slash-command',
|
||||
canonicalId: 'bmad-help',
|
||||
normalizedAliasValue: 'bmad-help',
|
||||
rawIdentityHasLeadingSlash: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
class HelpAliasNormalizationError extends Error {
|
||||
constructor({ code, detail, fieldPath, sourcePath, observedValue }) {
|
||||
const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath}, observedValue=${observedValue})`;
|
||||
super(message);
|
||||
this.name = 'HelpAliasNormalizationError';
|
||||
this.code = code;
|
||||
this.detail = detail;
|
||||
this.fieldPath = fieldPath;
|
||||
this.sourcePath = sourcePath;
|
||||
this.observedValue = observedValue;
|
||||
this.fullMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSourcePath(value) {
|
||||
if (!value) return '';
|
||||
return String(value).replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
function collapseWhitespace(value) {
|
||||
return String(value).replaceAll(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function parseBoolean(value) {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return value === 1;
|
||||
|
||||
const normalized = String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1') return true;
|
||||
if (normalized === 'false' || normalized === '0') return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function throwAliasNormalizationError({ code, detail, fieldPath, sourcePath, observedValue }) {
|
||||
throw new HelpAliasNormalizationError({
|
||||
code,
|
||||
detail,
|
||||
fieldPath,
|
||||
sourcePath,
|
||||
observedValue,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRawIdentityToTuple(rawIdentity, options = {}) {
|
||||
const fieldPath = options.fieldPath || 'rawIdentity';
|
||||
const sourcePath = normalizeSourcePath(options.sourcePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH);
|
||||
const normalizedRawIdentity = collapseWhitespace(
|
||||
String(rawIdentity ?? '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!normalizedRawIdentity) {
|
||||
throwAliasNormalizationError({
|
||||
code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.EMPTY_INPUT,
|
||||
detail: 'alias identity is empty after normalization',
|
||||
fieldPath,
|
||||
sourcePath,
|
||||
observedValue: normalizedRawIdentity,
|
||||
});
|
||||
}
|
||||
|
||||
if (/^\/{2,}/.test(normalizedRawIdentity)) {
|
||||
throwAliasNormalizationError({
|
||||
code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.MULTIPLE_LEADING_SLASHES,
|
||||
detail: 'alias identity contains multiple leading slashes',
|
||||
fieldPath,
|
||||
sourcePath,
|
||||
observedValue: normalizedRawIdentity,
|
||||
});
|
||||
}
|
||||
|
||||
const rawIdentityHasLeadingSlash = normalizedRawIdentity.startsWith('/');
|
||||
const preAliasNormalizedValue = rawIdentityHasLeadingSlash ? normalizedRawIdentity.slice(1) : normalizedRawIdentity;
|
||||
|
||||
if (!preAliasNormalizedValue) {
|
||||
throwAliasNormalizationError({
|
||||
code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.EMPTY_PREALIAS,
|
||||
detail: 'alias preAliasNormalizedValue is empty after slash normalization',
|
||||
fieldPath: 'preAliasNormalizedValue',
|
||||
sourcePath,
|
||||
observedValue: normalizedRawIdentity,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
normalizedRawIdentity,
|
||||
rawIdentityHasLeadingSlash,
|
||||
preAliasNormalizedValue,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAliasRows(aliasRows, aliasTableSourcePath = EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH) {
|
||||
if (!Array.isArray(aliasRows)) return [];
|
||||
|
||||
const normalizedRows = [];
|
||||
const sourcePath = normalizeSourcePath(aliasTableSourcePath);
|
||||
|
||||
for (const row of aliasRows) {
|
||||
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonicalId = collapseWhitespace(
|
||||
String(row.canonicalId ?? '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
);
|
||||
const rowIdentity = String(row.rowIdentity ?? '').trim();
|
||||
const parsedLeadingSlash = parseBoolean(row.rawIdentityHasLeadingSlash);
|
||||
const normalizedAliasValue = collapseWhitespace(
|
||||
String(row.normalizedAliasValue ?? '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!rowIdentity || !canonicalId || parsedLeadingSlash === null || !normalizedAliasValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedRows.push({
|
||||
rowIdentity,
|
||||
canonicalId,
|
||||
rawIdentityHasLeadingSlash: parsedLeadingSlash,
|
||||
normalizedAliasValue,
|
||||
sourcePath,
|
||||
});
|
||||
}
|
||||
|
||||
normalizedRows.sort((left, right) => left.rowIdentity.localeCompare(right.rowIdentity));
|
||||
return normalizedRows;
|
||||
}
|
||||
|
||||
function resolveAliasTupleFromRows(tuple, aliasRows, options = {}) {
|
||||
const sourcePath = normalizeSourcePath(options.sourcePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH);
|
||||
const normalizedRows = normalizeAliasRows(aliasRows, sourcePath);
|
||||
|
||||
const matches = normalizedRows.filter(
|
||||
(row) =>
|
||||
row.rawIdentityHasLeadingSlash === tuple.rawIdentityHasLeadingSlash && row.normalizedAliasValue === tuple.preAliasNormalizedValue,
|
||||
);
|
||||
|
||||
if (matches.length === 0) {
|
||||
throwAliasNormalizationError({
|
||||
code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
|
||||
detail: 'alias tuple did not resolve to any canonical alias row',
|
||||
fieldPath: 'preAliasNormalizedValue',
|
||||
sourcePath,
|
||||
observedValue: `${tuple.preAliasNormalizedValue}|leadingSlash:${tuple.rawIdentityHasLeadingSlash}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
throwAliasNormalizationError({
|
||||
code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
|
||||
detail: 'alias tuple resolved ambiguously to multiple canonical alias rows',
|
||||
fieldPath: 'preAliasNormalizedValue',
|
||||
sourcePath,
|
||||
observedValue: `${tuple.preAliasNormalizedValue}|leadingSlash:${tuple.rawIdentityHasLeadingSlash}`,
|
||||
});
|
||||
}
|
||||
|
||||
const match = matches[0];
|
||||
return {
|
||||
aliasRowLocator: match.rowIdentity,
|
||||
postAliasCanonicalId: match.canonicalId,
|
||||
aliasResolutionSourcePath: sourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveAliasTupleUsingCanonicalAliasCsv(tuple, aliasTablePath, options = {}) {
|
||||
const sourcePath = normalizeSourcePath(options.sourcePath || aliasTablePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH);
|
||||
|
||||
if (!aliasTablePath || !(await fs.pathExists(aliasTablePath))) {
|
||||
throwAliasNormalizationError({
|
||||
code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
|
||||
detail: 'canonical alias table file was not found',
|
||||
fieldPath: 'aliasTablePath',
|
||||
sourcePath,
|
||||
observedValue: aliasTablePath || '',
|
||||
});
|
||||
}
|
||||
|
||||
const csvRaw = await fs.readFile(aliasTablePath, 'utf8');
|
||||
const parsedRows = csv.parse(csvRaw, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
});
|
||||
|
||||
return resolveAliasTupleFromRows(tuple, parsedRows, { sourcePath });
|
||||
}
|
||||
|
||||
async function normalizeAndResolveExemplarAlias(rawIdentity, options = {}) {
|
||||
const tuple = normalizeRawIdentityToTuple(rawIdentity, {
|
||||
fieldPath: options.fieldPath || 'rawIdentity',
|
||||
sourcePath: options.sourcePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH,
|
||||
});
|
||||
|
||||
let resolution;
|
||||
if (Array.isArray(options.aliasRows)) {
|
||||
resolution = resolveAliasTupleFromRows(tuple, options.aliasRows, {
|
||||
sourcePath: options.aliasTableSourcePath || options.sourcePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH,
|
||||
});
|
||||
} else if (options.aliasTablePath) {
|
||||
resolution = await resolveAliasTupleUsingCanonicalAliasCsv(tuple, options.aliasTablePath, {
|
||||
sourcePath: options.aliasTableSourcePath || options.sourcePath || normalizeSourcePath(path.resolve(options.aliasTablePath)),
|
||||
});
|
||||
} else {
|
||||
resolution = resolveAliasTupleFromRows(tuple, LOCKED_EXEMPLAR_ALIAS_ROWS, {
|
||||
sourcePath: options.aliasTableSourcePath || options.sourcePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...tuple,
|
||||
...resolution,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HELP_ALIAS_NORMALIZATION_ERROR_CODES,
|
||||
EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH,
|
||||
LOCKED_EXEMPLAR_ALIAS_ROWS,
|
||||
HelpAliasNormalizationError,
|
||||
normalizeRawIdentityToTuple,
|
||||
resolveAliasTupleFromRows,
|
||||
resolveAliasTupleUsingCanonicalAliasCsv,
|
||||
normalizeAndResolveExemplarAlias,
|
||||
};
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
const path = require('node:path');
|
||||
const fs = require('fs-extra');
|
||||
const yaml = require('yaml');
|
||||
const { getProjectRoot, getSourcePath } = require('../../../lib/project-root');
|
||||
const { normalizeAndResolveExemplarAlias } = require('./help-alias-normalizer');
|
||||
|
||||
const HELP_AUTHORITY_VALIDATION_ERROR_CODES = Object.freeze({
|
||||
SIDECAR_FILE_NOT_FOUND: 'ERR_HELP_AUTHORITY_SIDECAR_FILE_NOT_FOUND',
|
||||
SIDECAR_PARSE_FAILED: 'ERR_HELP_AUTHORITY_SIDECAR_PARSE_FAILED',
|
||||
SIDECAR_INVALID_METADATA: 'ERR_HELP_AUTHORITY_SIDECAR_INVALID_METADATA',
|
||||
MARKDOWN_FILE_NOT_FOUND: 'ERR_HELP_AUTHORITY_MARKDOWN_FILE_NOT_FOUND',
|
||||
FRONTMATTER_PARSE_FAILED: 'ERR_HELP_AUTHORITY_FRONTMATTER_PARSE_FAILED',
|
||||
});
|
||||
|
||||
const HELP_FRONTMATTER_MISMATCH_ERROR_CODES = Object.freeze({
|
||||
CANONICAL_ID_MISMATCH: 'ERR_FRONTMATTER_CANONICAL_ID_MISMATCH',
|
||||
DISPLAY_NAME_MISMATCH: 'ERR_FRONTMATTER_DISPLAY_NAME_MISMATCH',
|
||||
DESCRIPTION_MISMATCH: 'ERR_FRONTMATTER_DESCRIPTION_MISMATCH',
|
||||
DEPENDENCIES_REQUIRES_MISMATCH: 'ERR_FRONTMATTER_DEPENDENCIES_REQUIRES_MISMATCH',
|
||||
});
|
||||
|
||||
const FRONTMATTER_MISMATCH_DETAILS = Object.freeze({
|
||||
[HELP_FRONTMATTER_MISMATCH_ERROR_CODES.CANONICAL_ID_MISMATCH]: 'frontmatter canonicalId must match sidecar canonicalId',
|
||||
[HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DISPLAY_NAME_MISMATCH]: 'frontmatter name must match sidecar displayName',
|
||||
[HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DESCRIPTION_MISMATCH]: 'frontmatter description must match sidecar description',
|
||||
[HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DEPENDENCIES_REQUIRES_MISMATCH]:
|
||||
'frontmatter dependencies.requires must match sidecar dependencies.requires',
|
||||
});
|
||||
|
||||
class HelpAuthorityValidationError extends Error {
|
||||
constructor({ code, detail, fieldPath, sourcePath, observedValue, expectedValue }) {
|
||||
const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath})`;
|
||||
super(message);
|
||||
this.name = 'HelpAuthorityValidationError';
|
||||
this.code = code;
|
||||
this.detail = detail;
|
||||
this.fieldPath = fieldPath;
|
||||
this.sourcePath = sourcePath;
|
||||
this.observedValue = observedValue;
|
||||
this.expectedValue = expectedValue;
|
||||
this.fullMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSourcePath(value) {
|
||||
if (!value) return '';
|
||||
return String(value).replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
function toProjectRelativePath(filePath) {
|
||||
const projectRoot = getProjectRoot();
|
||||
const relative = path.relative(projectRoot, filePath);
|
||||
|
||||
if (!relative || relative.startsWith('..')) {
|
||||
return normalizeSourcePath(path.resolve(filePath));
|
||||
}
|
||||
|
||||
return normalizeSourcePath(relative);
|
||||
}
|
||||
|
||||
function hasOwn(obj, key) {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
function isBlankString(value) {
|
||||
return typeof value !== 'string' || value.trim().length === 0;
|
||||
}
|
||||
|
||||
function ensureSidecarMetadata(sidecarData, sidecarSourcePath) {
|
||||
const requiredFields = ['canonicalId', 'displayName', 'description', 'dependencies'];
|
||||
for (const requiredField of requiredFields) {
|
||||
if (!hasOwn(sidecarData, requiredField)) {
|
||||
throw new HelpAuthorityValidationError({
|
||||
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
|
||||
detail: `Missing required sidecar metadata field "${requiredField}"`,
|
||||
fieldPath: requiredField,
|
||||
sourcePath: sidecarSourcePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const requiredStringFields = ['canonicalId', 'displayName', 'description'];
|
||||
for (const requiredStringField of requiredStringFields) {
|
||||
if (isBlankString(sidecarData[requiredStringField])) {
|
||||
throw new HelpAuthorityValidationError({
|
||||
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
|
||||
detail: `Required sidecar metadata field "${requiredStringField}" must be a non-empty string`,
|
||||
fieldPath: requiredStringField,
|
||||
sourcePath: sidecarSourcePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const requires = sidecarData.dependencies?.requires;
|
||||
if (!Array.isArray(requires)) {
|
||||
throw new HelpAuthorityValidationError({
|
||||
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
|
||||
detail: 'Sidecar metadata field "dependencies.requires" must be an array',
|
||||
fieldPath: 'dependencies.requires',
|
||||
sourcePath: sidecarSourcePath,
|
||||
observedValue: requires,
|
||||
expectedValue: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function serializeNormalizedDependencyTargets(value) {
|
||||
if (!Array.isArray(value)) return null;
|
||||
|
||||
const normalized = value
|
||||
.map((target) =>
|
||||
String(target ?? '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
)
|
||||
.filter((target) => target.length > 0)
|
||||
.sort();
|
||||
|
||||
return JSON.stringify(normalized);
|
||||
}
|
||||
|
||||
function frontmatterMatchValue(value) {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim();
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function createFrontmatterMismatchError(code, fieldPath, sourcePath, observedValue, expectedValue) {
|
||||
throw new HelpAuthorityValidationError({
|
||||
code,
|
||||
detail: FRONTMATTER_MISMATCH_DETAILS[code],
|
||||
fieldPath,
|
||||
sourcePath,
|
||||
observedValue,
|
||||
expectedValue,
|
||||
});
|
||||
}
|
||||
|
||||
function validateFrontmatterPrecedence(frontmatter, sidecarData, markdownSourcePath) {
|
||||
if (!frontmatter || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sidecarCanonicalId = frontmatterMatchValue(sidecarData.canonicalId);
|
||||
const sidecarDisplayName = frontmatterMatchValue(sidecarData.displayName);
|
||||
const sidecarDescription = frontmatterMatchValue(sidecarData.description);
|
||||
|
||||
if (hasOwn(frontmatter, 'canonicalId')) {
|
||||
const observedCanonicalId = frontmatterMatchValue(frontmatter.canonicalId);
|
||||
if (observedCanonicalId.length > 0 && observedCanonicalId !== sidecarCanonicalId) {
|
||||
createFrontmatterMismatchError(
|
||||
HELP_FRONTMATTER_MISMATCH_ERROR_CODES.CANONICAL_ID_MISMATCH,
|
||||
'canonicalId',
|
||||
markdownSourcePath,
|
||||
observedCanonicalId,
|
||||
sidecarCanonicalId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOwn(frontmatter, 'name')) {
|
||||
const observedName = frontmatterMatchValue(frontmatter.name);
|
||||
if (observedName.length > 0 && observedName !== sidecarDisplayName) {
|
||||
createFrontmatterMismatchError(
|
||||
HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DISPLAY_NAME_MISMATCH,
|
||||
'name',
|
||||
markdownSourcePath,
|
||||
observedName,
|
||||
sidecarDisplayName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOwn(frontmatter, 'description')) {
|
||||
const observedDescription = frontmatterMatchValue(frontmatter.description);
|
||||
if (observedDescription.length > 0 && observedDescription !== sidecarDescription) {
|
||||
createFrontmatterMismatchError(
|
||||
HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DESCRIPTION_MISMATCH,
|
||||
'description',
|
||||
markdownSourcePath,
|
||||
observedDescription,
|
||||
sidecarDescription,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hasDependencyRequires =
|
||||
frontmatter.dependencies &&
|
||||
typeof frontmatter.dependencies === 'object' &&
|
||||
!Array.isArray(frontmatter.dependencies) &&
|
||||
hasOwn(frontmatter.dependencies, 'requires');
|
||||
|
||||
if (hasDependencyRequires) {
|
||||
const observedSerialized = serializeNormalizedDependencyTargets(frontmatter.dependencies.requires);
|
||||
const expectedSerialized = serializeNormalizedDependencyTargets(sidecarData.dependencies.requires);
|
||||
|
||||
if (observedSerialized === null || observedSerialized !== expectedSerialized) {
|
||||
createFrontmatterMismatchError(
|
||||
HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DEPENDENCIES_REQUIRES_MISMATCH,
|
||||
'dependencies.requires',
|
||||
markdownSourcePath,
|
||||
observedSerialized,
|
||||
expectedSerialized,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function parseMarkdownFrontmatter(markdownPath, markdownSourcePath) {
|
||||
if (!(await fs.pathExists(markdownPath))) {
|
||||
throw new HelpAuthorityValidationError({
|
||||
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.MARKDOWN_FILE_NOT_FOUND,
|
||||
detail: 'Expected markdown surface file was not found',
|
||||
fieldPath: '<file>',
|
||||
sourcePath: markdownSourcePath,
|
||||
});
|
||||
}
|
||||
|
||||
let markdownRaw;
|
||||
try {
|
||||
markdownRaw = await fs.readFile(markdownPath, 'utf8');
|
||||
} catch (error) {
|
||||
throw new HelpAuthorityValidationError({
|
||||
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.FRONTMATTER_PARSE_FAILED,
|
||||
detail: `Unable to read markdown content: ${error.message}`,
|
||||
fieldPath: '<document>',
|
||||
sourcePath: markdownSourcePath,
|
||||
});
|
||||
}
|
||||
|
||||
const frontmatterMatch = markdownRaw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!frontmatterMatch) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = yaml.parse(frontmatterMatch[1]);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
throw new HelpAuthorityValidationError({
|
||||
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.FRONTMATTER_PARSE_FAILED,
|
||||
detail: `YAML frontmatter parse failure: ${error.message}`,
|
||||
fieldPath: '<frontmatter>',
|
||||
sourcePath: markdownSourcePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildHelpAuthorityRecords({ canonicalId, sidecarSourcePath, sourceMarkdownSourcePath }) {
|
||||
const authoritativePresenceKey = `capability:${canonicalId}`;
|
||||
|
||||
return [
|
||||
{
|
||||
recordType: 'metadata-authority',
|
||||
canonicalId,
|
||||
authoritativePresenceKey,
|
||||
authoritySourceType: 'sidecar',
|
||||
authoritySourcePath: sidecarSourcePath,
|
||||
sourcePath: sourceMarkdownSourcePath,
|
||||
},
|
||||
{
|
||||
recordType: 'source-body-authority',
|
||||
canonicalId,
|
||||
authoritativePresenceKey,
|
||||
authoritySourceType: 'source-markdown',
|
||||
authoritySourcePath: sourceMarkdownSourcePath,
|
||||
sourcePath: sourceMarkdownSourcePath,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function validateHelpAuthoritySplitAndPrecedence(options = {}) {
|
||||
const sidecarPath = options.sidecarPath || getSourcePath('core', 'tasks', 'help.artifact.yaml');
|
||||
const sourceMarkdownPath = options.sourceMarkdownPath || getSourcePath('core', 'tasks', 'help.md');
|
||||
const runtimeMarkdownPath = options.runtimeMarkdownPath || '';
|
||||
|
||||
const sidecarSourcePath = normalizeSourcePath(options.sidecarSourcePath || toProjectRelativePath(sidecarPath));
|
||||
const sourceMarkdownSourcePath = normalizeSourcePath(options.sourceMarkdownSourcePath || toProjectRelativePath(sourceMarkdownPath));
|
||||
const runtimeMarkdownSourcePath = normalizeSourcePath(
|
||||
options.runtimeMarkdownSourcePath || (runtimeMarkdownPath ? toProjectRelativePath(runtimeMarkdownPath) : ''),
|
||||
);
|
||||
|
||||
if (!(await fs.pathExists(sidecarPath))) {
|
||||
throw new HelpAuthorityValidationError({
|
||||
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
|
||||
detail: 'Expected sidecar metadata file was not found',
|
||||
fieldPath: '<file>',
|
||||
sourcePath: sidecarSourcePath,
|
||||
});
|
||||
}
|
||||
|
||||
let sidecarData;
|
||||
try {
|
||||
const sidecarRaw = await fs.readFile(sidecarPath, 'utf8');
|
||||
sidecarData = yaml.parse(sidecarRaw);
|
||||
} catch (error) {
|
||||
throw new HelpAuthorityValidationError({
|
||||
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
|
||||
detail: `YAML parse failure: ${error.message}`,
|
||||
fieldPath: '<document>',
|
||||
sourcePath: sidecarSourcePath,
|
||||
});
|
||||
}
|
||||
|
||||
if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
|
||||
throw new HelpAuthorityValidationError({
|
||||
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
|
||||
detail: 'Sidecar root must be a YAML mapping object',
|
||||
fieldPath: '<document>',
|
||||
sourcePath: sidecarSourcePath,
|
||||
});
|
||||
}
|
||||
|
||||
ensureSidecarMetadata(sidecarData, sidecarSourcePath);
|
||||
|
||||
const sourceFrontmatter = await parseMarkdownFrontmatter(sourceMarkdownPath, sourceMarkdownSourcePath);
|
||||
validateFrontmatterPrecedence(sourceFrontmatter, sidecarData, sourceMarkdownSourcePath);
|
||||
|
||||
const checkedSurfaces = [sourceMarkdownSourcePath];
|
||||
|
||||
if (runtimeMarkdownPath && (await fs.pathExists(runtimeMarkdownPath))) {
|
||||
const runtimeFrontmatter = await parseMarkdownFrontmatter(runtimeMarkdownPath, runtimeMarkdownSourcePath);
|
||||
validateFrontmatterPrecedence(runtimeFrontmatter, sidecarData, runtimeMarkdownSourcePath);
|
||||
checkedSurfaces.push(runtimeMarkdownSourcePath);
|
||||
}
|
||||
|
||||
const aliasResolutionOptions = {
|
||||
fieldPath: 'canonicalId',
|
||||
sourcePath: sidecarSourcePath,
|
||||
};
|
||||
|
||||
const inferredAliasTablePath =
|
||||
options.aliasTablePath || (options.bmadDir ? path.join(options.bmadDir, '_config', 'canonical-aliases.csv') : '');
|
||||
|
||||
if (inferredAliasTablePath && (await fs.pathExists(inferredAliasTablePath))) {
|
||||
aliasResolutionOptions.aliasTablePath = inferredAliasTablePath;
|
||||
aliasResolutionOptions.aliasTableSourcePath = normalizeSourcePath(
|
||||
options.aliasTableSourcePath || toProjectRelativePath(inferredAliasTablePath),
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSidecarIdentity = await normalizeAndResolveExemplarAlias(sidecarData.canonicalId, aliasResolutionOptions);
|
||||
const canonicalId = resolvedSidecarIdentity.postAliasCanonicalId;
|
||||
const authoritativeRecords = buildHelpAuthorityRecords({
|
||||
canonicalId,
|
||||
sidecarSourcePath,
|
||||
sourceMarkdownSourcePath,
|
||||
});
|
||||
|
||||
return {
|
||||
canonicalId,
|
||||
authoritativePresenceKey: `capability:${canonicalId}`,
|
||||
authoritativeRecords,
|
||||
checkedSurfaces,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HELP_AUTHORITY_VALIDATION_ERROR_CODES,
|
||||
HELP_FRONTMATTER_MISMATCH_ERROR_CODES,
|
||||
HelpAuthorityValidationError,
|
||||
buildHelpAuthorityRecords,
|
||||
serializeNormalizedDependencyTargets,
|
||||
validateHelpAuthoritySplitAndPrecedence,
|
||||
};
|
||||
|
|
@ -0,0 +1,367 @@
|
|||
const fs = require('fs-extra');
|
||||
const path = require('node:path');
|
||||
const yaml = require('yaml');
|
||||
const { getSourcePath, getProjectRoot } = require('../../../lib/project-root');
|
||||
const { normalizeAndResolveExemplarAlias } = require('./help-alias-normalizer');
|
||||
|
||||
const EXEMPLAR_HELP_CATALOG_CANONICAL_ID = 'bmad-help';
|
||||
const EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.artifact.yaml';
|
||||
const EXEMPLAR_HELP_CATALOG_SOURCE_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
|
||||
const EXEMPLAR_HELP_CATALOG_ISSUING_COMPONENT =
|
||||
'bmad-fork/tools/cli/installers/lib/core/help-catalog-generator.js::buildSidecarAwareExemplarHelpRow()';
|
||||
const INSTALLER_HELP_CATALOG_MERGE_COMPONENT = 'bmad-fork/tools/cli/installers/lib/core/installer.js::mergeModuleHelpCatalogs()';
|
||||
|
||||
const HELP_CATALOG_GENERATION_ERROR_CODES = Object.freeze({
|
||||
SIDECAR_FILE_NOT_FOUND: 'ERR_HELP_CATALOG_SIDECAR_FILE_NOT_FOUND',
|
||||
SIDECAR_PARSE_FAILED: 'ERR_HELP_CATALOG_SIDECAR_PARSE_FAILED',
|
||||
SIDECAR_INVALID_METADATA: 'ERR_HELP_CATALOG_SIDECAR_INVALID_METADATA',
|
||||
CANONICAL_ID_MISMATCH: 'ERR_HELP_CATALOG_CANONICAL_ID_MISMATCH',
|
||||
COMMAND_LABEL_CONTRACT_FAILED: 'ERR_HELP_COMMAND_LABEL_CONTRACT_FAILED',
|
||||
});
|
||||
|
||||
class HelpCatalogGenerationError extends Error {
|
||||
constructor({ code, detail, fieldPath, sourcePath, observedValue, expectedValue }) {
|
||||
const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath})`;
|
||||
super(message);
|
||||
this.name = 'HelpCatalogGenerationError';
|
||||
this.code = code;
|
||||
this.detail = detail;
|
||||
this.fieldPath = fieldPath;
|
||||
this.sourcePath = sourcePath;
|
||||
this.observedValue = observedValue;
|
||||
this.expectedValue = expectedValue;
|
||||
this.fullMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSourcePath(value) {
|
||||
if (!value) return '';
|
||||
return String(value).replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
function toProjectRelativePath(filePath) {
|
||||
const projectRoot = getProjectRoot();
|
||||
const relative = path.relative(projectRoot, filePath);
|
||||
|
||||
if (!relative || relative.startsWith('..')) {
|
||||
return normalizeSourcePath(path.resolve(filePath));
|
||||
}
|
||||
|
||||
return normalizeSourcePath(relative);
|
||||
}
|
||||
|
||||
function frontmatterMatchValue(value) {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim();
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function createGenerationError(code, fieldPath, sourcePath, detail, observedValue, expectedValue) {
|
||||
throw new HelpCatalogGenerationError({
|
||||
code,
|
||||
detail,
|
||||
fieldPath,
|
||||
sourcePath,
|
||||
observedValue,
|
||||
expectedValue,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadExemplarHelpSidecar(sidecarPath = getSourcePath('core', 'tasks', 'help.artifact.yaml')) {
|
||||
const sourcePath = normalizeSourcePath(toProjectRelativePath(sidecarPath));
|
||||
if (!(await fs.pathExists(sidecarPath))) {
|
||||
createGenerationError(
|
||||
HELP_CATALOG_GENERATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
|
||||
'<file>',
|
||||
sourcePath,
|
||||
'Expected sidecar metadata file was not found',
|
||||
);
|
||||
}
|
||||
|
||||
let sidecarData;
|
||||
try {
|
||||
sidecarData = yaml.parse(await fs.readFile(sidecarPath, 'utf8'));
|
||||
} catch (error) {
|
||||
createGenerationError(
|
||||
HELP_CATALOG_GENERATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
|
||||
'<document>',
|
||||
sourcePath,
|
||||
`YAML parse failure: ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
|
||||
createGenerationError(
|
||||
HELP_CATALOG_GENERATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
|
||||
'<document>',
|
||||
sourcePath,
|
||||
'Sidecar root must be a YAML mapping object',
|
||||
);
|
||||
}
|
||||
|
||||
const canonicalId = frontmatterMatchValue(sidecarData.canonicalId);
|
||||
const displayName = frontmatterMatchValue(sidecarData.displayName);
|
||||
const description = frontmatterMatchValue(sidecarData.description);
|
||||
const missingStringField =
|
||||
canonicalId.length === 0 ? 'canonicalId' : displayName.length === 0 ? 'displayName' : description.length === 0 ? 'description' : '';
|
||||
if (missingStringField.length > 0) {
|
||||
const observedValues = {
|
||||
canonicalId,
|
||||
displayName,
|
||||
description,
|
||||
};
|
||||
createGenerationError(
|
||||
HELP_CATALOG_GENERATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
|
||||
missingStringField,
|
||||
sourcePath,
|
||||
'Sidecar canonicalId, displayName, and description must be non-empty strings',
|
||||
observedValues[missingStringField],
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
canonicalId,
|
||||
displayName,
|
||||
description,
|
||||
sourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDisplayedCommandLabel(label) {
|
||||
const trimmed = frontmatterMatchValue(label);
|
||||
if (!trimmed) return '';
|
||||
|
||||
const hasLeadingSlash = trimmed.startsWith('/');
|
||||
const withoutLeadingSlash = trimmed.replace(/^\/+/, '').trim();
|
||||
const normalizedBody = withoutLeadingSlash.toLowerCase().replaceAll(/\s+/g, ' ');
|
||||
if (!normalizedBody) return hasLeadingSlash ? '/' : '';
|
||||
|
||||
return hasLeadingSlash ? `/${normalizedBody}` : normalizedBody;
|
||||
}
|
||||
|
||||
function renderDisplayedCommandLabel(rawCommandValue) {
|
||||
const normalizedRaw = frontmatterMatchValue(rawCommandValue).replace(/^\/+/, '');
|
||||
if (!normalizedRaw) {
|
||||
return '/';
|
||||
}
|
||||
return `/${normalizedRaw}`;
|
||||
}
|
||||
|
||||
function resolveCanonicalIdFromAuthorityRecords(helpAuthorityRecords = []) {
|
||||
if (!Array.isArray(helpAuthorityRecords)) return '';
|
||||
|
||||
const sidecarRecord = helpAuthorityRecords.find(
|
||||
(record) =>
|
||||
record &&
|
||||
typeof record === 'object' &&
|
||||
record.authoritySourceType === 'sidecar' &&
|
||||
frontmatterMatchValue(record.authoritySourcePath) === EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH &&
|
||||
frontmatterMatchValue(record.canonicalId).length > 0,
|
||||
);
|
||||
|
||||
return sidecarRecord ? frontmatterMatchValue(sidecarRecord.canonicalId) : '';
|
||||
}
|
||||
|
||||
function evaluateExemplarCommandLabelReportRows(rows, options = {}) {
|
||||
const expectedCanonicalId = frontmatterMatchValue(options.canonicalId || EXEMPLAR_HELP_CATALOG_CANONICAL_ID);
|
||||
const expectedDisplayedLabel = frontmatterMatchValue(options.displayedCommandLabel || `/${expectedCanonicalId}`);
|
||||
const normalizedExpectedDisplayedLabel = normalizeDisplayedCommandLabel(expectedDisplayedLabel);
|
||||
|
||||
const targetRows = (Array.isArray(rows) ? rows : []).filter(
|
||||
(row) => frontmatterMatchValue(row && row.canonicalId) === expectedCanonicalId,
|
||||
);
|
||||
|
||||
if (targetRows.length !== 1) {
|
||||
return { valid: false, reason: `row-count:${targetRows.length}` };
|
||||
}
|
||||
|
||||
const row = targetRows[0];
|
||||
const rawCommandValue = frontmatterMatchValue(row.rawCommandValue);
|
||||
if (rawCommandValue !== expectedCanonicalId) {
|
||||
return { valid: false, reason: `invalid-raw-command-value:${rawCommandValue || '<empty>'}` };
|
||||
}
|
||||
|
||||
const displayedCommandLabel = frontmatterMatchValue(row.displayedCommandLabel);
|
||||
if (displayedCommandLabel !== expectedDisplayedLabel) {
|
||||
return { valid: false, reason: `invalid-displayed-label:${displayedCommandLabel || '<empty>'}` };
|
||||
}
|
||||
|
||||
const normalizedDisplayedLabel = normalizeDisplayedCommandLabel(row.normalizedDisplayedLabel || row.displayedCommandLabel);
|
||||
if (normalizedDisplayedLabel !== normalizedExpectedDisplayedLabel) {
|
||||
return { valid: false, reason: `invalid-normalized-displayed-label:${normalizedDisplayedLabel || '<empty>'}` };
|
||||
}
|
||||
|
||||
const rowCountForCanonicalId = Number.parseInt(String(row.rowCountForCanonicalId ?? ''), 10);
|
||||
if (!Number.isFinite(rowCountForCanonicalId) || rowCountForCanonicalId !== 1) {
|
||||
return { valid: false, reason: `invalid-row-count-for-canonical-id:${String(row.rowCountForCanonicalId ?? '<empty>')}` };
|
||||
}
|
||||
|
||||
if (frontmatterMatchValue(row.authoritySourceType) !== 'sidecar') {
|
||||
return { valid: false, reason: `invalid-authority-source-type:${frontmatterMatchValue(row.authoritySourceType) || '<empty>'}` };
|
||||
}
|
||||
|
||||
if (frontmatterMatchValue(row.authoritySourcePath) !== EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `invalid-authority-source-path:${frontmatterMatchValue(row.authoritySourcePath) || '<empty>'}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, reason: 'ok' };
|
||||
}
|
||||
|
||||
function buildExemplarHelpCatalogRow({ canonicalId, description }) {
|
||||
return {
|
||||
module: 'core',
|
||||
phase: 'anytime',
|
||||
name: 'bmad-help',
|
||||
code: 'BH',
|
||||
sequence: '',
|
||||
'workflow-file': '_bmad/core/tasks/help.md',
|
||||
command: canonicalId,
|
||||
required: 'false',
|
||||
'agent-name': '',
|
||||
'agent-command': '',
|
||||
'agent-display-name': '',
|
||||
'agent-title': '',
|
||||
options: '',
|
||||
description,
|
||||
'output-location': '',
|
||||
outputs: '',
|
||||
};
|
||||
}
|
||||
|
||||
function buildPipelineStageRows({ bmadFolderName, canonicalId, commandValue, descriptionValue, authoritySourcePath, sourcePath }) {
|
||||
const runtimeFolder = frontmatterMatchValue(bmadFolderName) || '_bmad';
|
||||
const bindingEvidence = `authority:${authoritySourcePath}|source:${sourcePath}|canonical:${canonicalId}|command:${commandValue}`;
|
||||
|
||||
return [
|
||||
{
|
||||
stage: 'installed-compatibility-row',
|
||||
artifactPath: `${runtimeFolder}/core/module-help.csv`,
|
||||
rowIdentity: 'module-help-row:bmad-help',
|
||||
canonicalId,
|
||||
sourcePath,
|
||||
rowCountForStageCanonicalId: 1,
|
||||
commandValue,
|
||||
expectedCommandValue: canonicalId,
|
||||
descriptionValue,
|
||||
expectedDescriptionValue: descriptionValue,
|
||||
descriptionAuthoritySourceType: 'sidecar',
|
||||
descriptionAuthoritySourcePath: authoritySourcePath,
|
||||
commandAuthoritySourceType: 'sidecar',
|
||||
commandAuthoritySourcePath: authoritySourcePath,
|
||||
issuerOwnerClass: 'installer',
|
||||
issuingComponent: EXEMPLAR_HELP_CATALOG_ISSUING_COMPONENT,
|
||||
issuingComponentBindingEvidence: `${EXEMPLAR_HELP_CATALOG_ISSUING_COMPONENT}|${bindingEvidence}`,
|
||||
stageStatus: 'PASS',
|
||||
status: 'PASS',
|
||||
},
|
||||
{
|
||||
stage: 'merged-config-row',
|
||||
artifactPath: `${runtimeFolder}/_config/bmad-help.csv`,
|
||||
rowIdentity: 'merged-help-row:bmad-help',
|
||||
canonicalId,
|
||||
sourcePath,
|
||||
rowCountForStageCanonicalId: 1,
|
||||
commandValue,
|
||||
expectedCommandValue: canonicalId,
|
||||
descriptionValue,
|
||||
expectedDescriptionValue: descriptionValue,
|
||||
descriptionAuthoritySourceType: 'sidecar',
|
||||
descriptionAuthoritySourcePath: authoritySourcePath,
|
||||
commandAuthoritySourceType: 'sidecar',
|
||||
commandAuthoritySourcePath: authoritySourcePath,
|
||||
issuerOwnerClass: 'installer',
|
||||
issuingComponent: INSTALLER_HELP_CATALOG_MERGE_COMPONENT,
|
||||
issuingComponentBindingEvidence: `${INSTALLER_HELP_CATALOG_MERGE_COMPONENT}|${bindingEvidence}`,
|
||||
stageStatus: 'PASS',
|
||||
status: 'PASS',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function buildSidecarAwareExemplarHelpRow(options = {}) {
|
||||
const authorityCanonicalId = resolveCanonicalIdFromAuthorityRecords(options.helpAuthorityRecords);
|
||||
const sidecarMetadata = await loadExemplarHelpSidecar(options.sidecarPath);
|
||||
const canonicalIdentityResolution = await normalizeAndResolveExemplarAlias(sidecarMetadata.canonicalId, {
|
||||
fieldPath: 'canonicalId',
|
||||
sourcePath: sidecarMetadata.sourcePath,
|
||||
aliasTablePath: options.aliasTablePath,
|
||||
aliasTableSourcePath: options.aliasTableSourcePath,
|
||||
});
|
||||
const canonicalId = canonicalIdentityResolution.postAliasCanonicalId;
|
||||
|
||||
if (authorityCanonicalId && authorityCanonicalId !== canonicalId) {
|
||||
createGenerationError(
|
||||
HELP_CATALOG_GENERATION_ERROR_CODES.CANONICAL_ID_MISMATCH,
|
||||
'canonicalId',
|
||||
sidecarMetadata.sourcePath,
|
||||
'Authority record canonicalId does not match sidecar canonicalId',
|
||||
authorityCanonicalId,
|
||||
canonicalId,
|
||||
);
|
||||
}
|
||||
|
||||
const commandValue = canonicalId;
|
||||
const displayedCommandLabel = renderDisplayedCommandLabel(commandValue);
|
||||
const normalizedDisplayedLabel = normalizeDisplayedCommandLabel(displayedCommandLabel);
|
||||
const row = buildExemplarHelpCatalogRow({
|
||||
canonicalId: commandValue,
|
||||
description: sidecarMetadata.description,
|
||||
});
|
||||
|
||||
const pipelineStageRows = buildPipelineStageRows({
|
||||
bmadFolderName: options.bmadFolderName || '_bmad',
|
||||
canonicalId,
|
||||
commandValue,
|
||||
descriptionValue: sidecarMetadata.description,
|
||||
authoritySourcePath: EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH,
|
||||
sourcePath: EXEMPLAR_HELP_CATALOG_SOURCE_MARKDOWN_SOURCE_PATH,
|
||||
});
|
||||
|
||||
const commandLabelReportRow = {
|
||||
surface: `${frontmatterMatchValue(options.bmadFolderName) || '_bmad'}/_config/bmad-help.csv`,
|
||||
canonicalId,
|
||||
rawCommandValue: commandValue,
|
||||
displayedCommandLabel,
|
||||
normalizedDisplayedLabel,
|
||||
rowCountForCanonicalId: 1,
|
||||
authoritySourceType: 'sidecar',
|
||||
authoritySourcePath: EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH,
|
||||
status: 'PASS',
|
||||
};
|
||||
|
||||
return {
|
||||
canonicalId,
|
||||
legacyName: sidecarMetadata.displayName,
|
||||
commandValue,
|
||||
displayedCommandLabel,
|
||||
normalizedDisplayedLabel,
|
||||
descriptionValue: sidecarMetadata.description,
|
||||
authoritySourceType: 'sidecar',
|
||||
authoritySourcePath: EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH,
|
||||
sourcePath: EXEMPLAR_HELP_CATALOG_SOURCE_MARKDOWN_SOURCE_PATH,
|
||||
row,
|
||||
pipelineStageRows,
|
||||
commandLabelReportRow,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HELP_CATALOG_GENERATION_ERROR_CODES,
|
||||
HelpCatalogGenerationError,
|
||||
EXEMPLAR_HELP_CATALOG_CANONICAL_ID,
|
||||
EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH,
|
||||
EXEMPLAR_HELP_CATALOG_SOURCE_MARKDOWN_SOURCE_PATH,
|
||||
EXEMPLAR_HELP_CATALOG_ISSUING_COMPONENT,
|
||||
INSTALLER_HELP_CATALOG_MERGE_COMPONENT,
|
||||
normalizeDisplayedCommandLabel,
|
||||
renderDisplayedCommandLabel,
|
||||
evaluateExemplarCommandLabelReportRows,
|
||||
buildSidecarAwareExemplarHelpRow,
|
||||
};
|
||||
|
|
@ -9,6 +9,17 @@ const { Config } = require('../../../lib/config');
|
|||
const { XmlHandler } = require('../../../lib/xml-handler');
|
||||
const { DependencyResolver } = require('./dependency-resolver');
|
||||
const { ConfigCollector } = require('./config-collector');
|
||||
const { validateHelpSidecarContractFile } = require('./sidecar-contract-validator');
|
||||
const { validateHelpAuthoritySplitAndPrecedence } = require('./help-authority-validator');
|
||||
const {
|
||||
HELP_CATALOG_GENERATION_ERROR_CODES,
|
||||
buildSidecarAwareExemplarHelpRow,
|
||||
evaluateExemplarCommandLabelReportRows,
|
||||
normalizeDisplayedCommandLabel,
|
||||
renderDisplayedCommandLabel,
|
||||
} = require('./help-catalog-generator');
|
||||
const { validateHelpCatalogCompatibilitySurface } = require('./projection-compatibility-validator');
|
||||
const { Wave1ValidationHarness } = require('./wave-1-validation-harness');
|
||||
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
||||
const { CLIUtils } = require('../../../lib/cli-utils');
|
||||
const { ManifestGenerator } = require('./manifest-generator');
|
||||
|
|
@ -17,6 +28,9 @@ const { CustomHandler } = require('../custom/handler');
|
|||
const prompts = require('../../../lib/prompts');
|
||||
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
|
||||
|
||||
const EXEMPLAR_HELP_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.artifact.yaml';
|
||||
const EXEMPLAR_HELP_SOURCE_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
|
||||
|
||||
class Installer {
|
||||
constructor() {
|
||||
this.detector = new Detector();
|
||||
|
|
@ -29,8 +43,104 @@ class Installer {
|
|||
this.dependencyResolver = new DependencyResolver();
|
||||
this.configCollector = new ConfigCollector();
|
||||
this.ideConfigManager = new IdeConfigManager();
|
||||
this.validateHelpSidecarContractFile = validateHelpSidecarContractFile;
|
||||
this.validateHelpAuthoritySplitAndPrecedence = validateHelpAuthoritySplitAndPrecedence;
|
||||
this.ManifestGenerator = ManifestGenerator;
|
||||
this.installedFiles = new Set(); // Track all installed files
|
||||
this.bmadFolderName = BMAD_FOLDER_NAME;
|
||||
this.helpCatalogPipelineRows = [];
|
||||
this.helpCatalogCommandLabelReportRows = [];
|
||||
this.codexExportDerivationRecords = [];
|
||||
this.latestWave1ValidationRun = null;
|
||||
this.wave1ValidationHarness = new Wave1ValidationHarness();
|
||||
}
|
||||
|
||||
async runConfigurationGenerationTask({ message, bmadDir, moduleConfigs, config, allModules, addResult }) {
|
||||
// Validate exemplar sidecar contract before generating projections/manifests.
|
||||
// Fail-fast here prevents downstream artifacts from being produced on invalid metadata.
|
||||
message('Validating exemplar sidecar contract...');
|
||||
await this.validateHelpSidecarContractFile();
|
||||
addResult('Sidecar contract', 'ok', 'validated');
|
||||
|
||||
message('Validating authority split and frontmatter precedence...');
|
||||
const helpAuthorityValidation = await this.validateHelpAuthoritySplitAndPrecedence({
|
||||
bmadDir,
|
||||
runtimeMarkdownPath: path.join(bmadDir, 'core', 'tasks', 'help.md'),
|
||||
sidecarSourcePath: EXEMPLAR_HELP_SIDECAR_SOURCE_PATH,
|
||||
sourceMarkdownSourcePath: EXEMPLAR_HELP_SOURCE_MARKDOWN_SOURCE_PATH,
|
||||
runtimeMarkdownSourcePath: `${this.bmadFolderName}/core/tasks/help.md`,
|
||||
});
|
||||
this.helpAuthorityRecords = helpAuthorityValidation.authoritativeRecords;
|
||||
addResult('Authority split', 'ok', helpAuthorityValidation.authoritativePresenceKey);
|
||||
|
||||
// Generate clean config.yaml files for each installed module
|
||||
await this.generateModuleConfigs(bmadDir, moduleConfigs);
|
||||
addResult('Configurations', 'ok', 'generated');
|
||||
|
||||
// Pre-register manifest files
|
||||
const cfgDir = path.join(bmadDir, '_config');
|
||||
this.installedFiles.add(path.join(cfgDir, 'manifest.yaml'));
|
||||
this.installedFiles.add(path.join(cfgDir, 'workflow-manifest.csv'));
|
||||
this.installedFiles.add(path.join(cfgDir, 'agent-manifest.csv'));
|
||||
this.installedFiles.add(path.join(cfgDir, 'task-manifest.csv'));
|
||||
this.installedFiles.add(path.join(cfgDir, 'canonical-aliases.csv'));
|
||||
this.installedFiles.add(path.join(cfgDir, 'bmad-help-catalog-pipeline.csv'));
|
||||
this.installedFiles.add(path.join(cfgDir, 'bmad-help-command-label-report.csv'));
|
||||
|
||||
// Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes
|
||||
// This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv
|
||||
message('Generating manifests...');
|
||||
const manifestGen = new this.ManifestGenerator();
|
||||
|
||||
const allModulesForManifest = config._quickUpdate
|
||||
? config._existingModules || allModules || []
|
||||
: config._preserveModules
|
||||
? [...allModules, ...config._preserveModules]
|
||||
: allModules || [];
|
||||
|
||||
let modulesForCsvPreserve;
|
||||
if (config._quickUpdate) {
|
||||
modulesForCsvPreserve = config._existingModules || allModules || [];
|
||||
} else {
|
||||
modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules;
|
||||
}
|
||||
|
||||
const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, [...this.installedFiles], {
|
||||
ides: config.ides || [],
|
||||
preservedModules: modulesForCsvPreserve,
|
||||
helpAuthorityRecords: this.helpAuthorityRecords || [],
|
||||
});
|
||||
|
||||
addResult(
|
||||
'Manifests',
|
||||
'ok',
|
||||
`${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools`,
|
||||
);
|
||||
|
||||
// Merge help catalogs
|
||||
message('Generating help catalog...');
|
||||
await this.mergeModuleHelpCatalogs(bmadDir);
|
||||
addResult('Help catalog', 'ok');
|
||||
|
||||
return 'Configurations generated';
|
||||
}
|
||||
|
||||
async buildWave1ValidationOptions({ projectDir, bmadDir }) {
|
||||
const exportSkillProjectionPath = path.join(projectDir, '.agents', 'skills', 'bmad-help', 'SKILL.md');
|
||||
const hasCodexExportDerivationRecords =
|
||||
Array.isArray(this.codexExportDerivationRecords) && this.codexExportDerivationRecords.length > 0;
|
||||
const requireExportSkillProjection = hasCodexExportDerivationRecords || (await fs.pathExists(exportSkillProjectionPath));
|
||||
|
||||
return {
|
||||
projectDir,
|
||||
bmadDir,
|
||||
bmadFolderName: this.bmadFolderName || BMAD_FOLDER_NAME,
|
||||
helpAuthorityRecords: this.helpAuthorityRecords || [],
|
||||
helpCatalogPipelineRows: this.helpCatalogPipelineRows || [],
|
||||
helpCatalogCommandLabelReportRows: this.helpCatalogCommandLabelReportRows || [],
|
||||
codexExportDerivationRecords: this.codexExportDerivationRecords || [],
|
||||
requireExportSkillProjection,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1098,54 +1208,15 @@ class Installer {
|
|||
// Configuration generation task (stored as named reference for deferred execution)
|
||||
const configTask = {
|
||||
title: 'Generating configurations',
|
||||
task: async (message) => {
|
||||
// Generate clean config.yaml files for each installed module
|
||||
await this.generateModuleConfigs(bmadDir, moduleConfigs);
|
||||
addResult('Configurations', 'ok', 'generated');
|
||||
|
||||
// Pre-register manifest files
|
||||
const cfgDir = path.join(bmadDir, '_config');
|
||||
this.installedFiles.add(path.join(cfgDir, 'manifest.yaml'));
|
||||
this.installedFiles.add(path.join(cfgDir, 'workflow-manifest.csv'));
|
||||
this.installedFiles.add(path.join(cfgDir, 'agent-manifest.csv'));
|
||||
this.installedFiles.add(path.join(cfgDir, 'task-manifest.csv'));
|
||||
|
||||
// Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes
|
||||
// This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv
|
||||
message('Generating manifests...');
|
||||
const manifestGen = new ManifestGenerator();
|
||||
|
||||
const allModulesForManifest = config._quickUpdate
|
||||
? config._existingModules || allModules || []
|
||||
: config._preserveModules
|
||||
? [...allModules, ...config._preserveModules]
|
||||
: allModules || [];
|
||||
|
||||
let modulesForCsvPreserve;
|
||||
if (config._quickUpdate) {
|
||||
modulesForCsvPreserve = config._existingModules || allModules || [];
|
||||
} else {
|
||||
modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules;
|
||||
}
|
||||
|
||||
const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, [...this.installedFiles], {
|
||||
ides: config.ides || [],
|
||||
preservedModules: modulesForCsvPreserve,
|
||||
});
|
||||
|
||||
addResult(
|
||||
'Manifests',
|
||||
'ok',
|
||||
`${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools`,
|
||||
);
|
||||
|
||||
// Merge help catalogs
|
||||
message('Generating help catalog...');
|
||||
await this.mergeModuleHelpCatalogs(bmadDir);
|
||||
addResult('Help catalog', 'ok');
|
||||
|
||||
return 'Configurations generated';
|
||||
},
|
||||
task: async (message) =>
|
||||
this.runConfigurationGenerationTask({
|
||||
message,
|
||||
bmadDir,
|
||||
moduleConfigs,
|
||||
config,
|
||||
allModules,
|
||||
addResult,
|
||||
}),
|
||||
};
|
||||
installTasks.push(configTask);
|
||||
|
||||
|
|
@ -1173,6 +1244,7 @@ class Installer {
|
|||
|
||||
// Resolution is now available via closure-scoped taskResolution
|
||||
const resolution = taskResolution;
|
||||
this.codexExportDerivationRecords = [];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// IDE SETUP: Keep as spinner since it may prompt for user input
|
||||
|
|
@ -1217,6 +1289,9 @@ class Installer {
|
|||
}
|
||||
|
||||
if (setupResult.success) {
|
||||
if (Array.isArray(setupResult.exportDerivationRecords) && setupResult.exportDerivationRecords.length > 0) {
|
||||
this.codexExportDerivationRecords = [...setupResult.exportDerivationRecords];
|
||||
}
|
||||
addResult(ide, 'ok', setupResult.detail || '');
|
||||
} else {
|
||||
addResult(ide, 'error', setupResult.error || 'failed');
|
||||
|
|
@ -1242,6 +1317,21 @@ class Installer {
|
|||
// ─────────────────────────────────────────────────────────────────────────
|
||||
const postIdeTasks = [];
|
||||
|
||||
postIdeTasks.push({
|
||||
title: 'Generating validation artifacts',
|
||||
task: async (message) => {
|
||||
message('Generating deterministic wave-1 validation artifact suite...');
|
||||
const validationOptions = await this.buildWave1ValidationOptions({
|
||||
projectDir,
|
||||
bmadDir,
|
||||
});
|
||||
const validationRun = await this.wave1ValidationHarness.generateAndValidate(validationOptions);
|
||||
this.latestWave1ValidationRun = validationRun;
|
||||
addResult('Validation artifacts', 'ok', `${validationRun.generatedArtifactCount} artifacts`);
|
||||
return `${validationRun.generatedArtifactCount} validation artifacts generated`;
|
||||
},
|
||||
});
|
||||
|
||||
// File restoration task (only for updates)
|
||||
if (
|
||||
config._isUpdate &&
|
||||
|
|
@ -1690,6 +1780,94 @@ class Installer {
|
|||
/**
|
||||
* Private: Create directory structure
|
||||
*/
|
||||
isExemplarHelpCatalogRow({ moduleName, name, workflowFile, command, canonicalId }) {
|
||||
if (moduleName !== 'core') return false;
|
||||
|
||||
const normalizedName = String(name || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const normalizedWorkflowFile = String(workflowFile || '')
|
||||
.trim()
|
||||
.replaceAll('\\', '/')
|
||||
.toLowerCase();
|
||||
const normalizedCommand = String(command || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\/+/, '');
|
||||
const normalizedCanonicalId = String(canonicalId || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\/+/, '');
|
||||
|
||||
const hasExemplarWorkflowPath = normalizedWorkflowFile.endsWith('/core/tasks/help.md');
|
||||
const hasExemplarIdentity =
|
||||
normalizedName === 'bmad-help' || normalizedCommand === normalizedCanonicalId || normalizedCommand === 'bmad-help';
|
||||
|
||||
return hasExemplarWorkflowPath && hasExemplarIdentity;
|
||||
}
|
||||
|
||||
buildHelpCatalogRowWithAgentInfo(row, fallback, agentInfo) {
|
||||
const agentName = String(row['agent-name'] || fallback.agentName || '').trim();
|
||||
const agentData = agentInfo.get(agentName) || { command: '', displayName: '', title: '' };
|
||||
|
||||
return [
|
||||
row.module || fallback.module || '',
|
||||
row.phase || fallback.phase || '',
|
||||
row.name || fallback.name || '',
|
||||
row.code || fallback.code || '',
|
||||
row.sequence || fallback.sequence || '',
|
||||
row['workflow-file'] || fallback.workflowFile || '',
|
||||
row.command || fallback.command || '',
|
||||
row.required || fallback.required || 'false',
|
||||
agentName,
|
||||
row['agent-command'] || agentData.command,
|
||||
row['agent-display-name'] || agentData.displayName,
|
||||
row['agent-title'] || agentData.title,
|
||||
row.options || fallback.options || '',
|
||||
row.description || fallback.description || '',
|
||||
row['output-location'] || fallback.outputLocation || '',
|
||||
row.outputs || fallback.outputs || '',
|
||||
];
|
||||
}
|
||||
|
||||
isExemplarCommandLabelCandidate({ workflowFile, name, rawCommandValue, canonicalId, legacyName }) {
|
||||
const normalizedWorkflowFile = String(workflowFile || '')
|
||||
.trim()
|
||||
.replaceAll('\\', '/')
|
||||
.toLowerCase();
|
||||
const normalizedName = String(name || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const normalizedCanonicalId = String(canonicalId || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const normalizedLegacyName = String(legacyName || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const normalizedCommandValue = String(rawCommandValue || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\/+/, '');
|
||||
|
||||
const isHelpWorkflow = normalizedWorkflowFile.endsWith('/core/tasks/help.md');
|
||||
const isExemplarIdentity =
|
||||
normalizedName === 'bmad-help' ||
|
||||
normalizedCommandValue === normalizedCanonicalId ||
|
||||
(normalizedLegacyName.length > 0 && normalizedCommandValue === normalizedLegacyName);
|
||||
|
||||
return isHelpWorkflow && isExemplarIdentity;
|
||||
}
|
||||
|
||||
async writeCsvArtifact(filePath, columns, rows) {
|
||||
const csvLines = [columns.join(',')];
|
||||
for (const row of rows || []) {
|
||||
const csvRow = columns.map((column) => this.escapeCSVField(Object.prototype.hasOwnProperty.call(row, column) ? row[column] : ''));
|
||||
csvLines.push(csvRow.join(','));
|
||||
}
|
||||
await fs.writeFile(filePath, csvLines.join('\n'), 'utf8');
|
||||
this.installedFiles.add(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all module-help.csv files into a single bmad-help.csv
|
||||
* Scans all installed modules for module-help.csv and merges them
|
||||
|
|
@ -1701,6 +1879,14 @@ class Installer {
|
|||
const allRows = [];
|
||||
const headerRow =
|
||||
'module,phase,name,code,sequence,workflow-file,command,required,agent-name,agent-command,agent-display-name,agent-title,options,description,output-location,outputs';
|
||||
this.helpCatalogPipelineRows = [];
|
||||
this.helpCatalogCommandLabelReportRows = [];
|
||||
|
||||
const sidecarAwareExemplar = await buildSidecarAwareExemplarHelpRow({
|
||||
helpAuthorityRecords: this.helpAuthorityRecords || [],
|
||||
bmadFolderName: this.bmadFolderName || BMAD_FOLDER_NAME,
|
||||
});
|
||||
let exemplarRowWritten = false;
|
||||
|
||||
// Load agent manifest for agent info lookup
|
||||
const agentManifestPath = path.join(bmadDir, '_config', 'agent-manifest.csv');
|
||||
|
|
@ -1795,29 +1981,62 @@ class Installer {
|
|||
// If module column is empty, set it to this module's name (except for core which stays empty for universal tools)
|
||||
const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || '';
|
||||
|
||||
// Lookup agent info
|
||||
const cleanAgentName = agentName ? agentName.trim() : '';
|
||||
const agentData = agentInfo.get(cleanAgentName) || { command: '', displayName: '', title: '' };
|
||||
const isExemplarRow = this.isExemplarHelpCatalogRow({
|
||||
moduleName,
|
||||
name,
|
||||
workflowFile,
|
||||
command,
|
||||
canonicalId: sidecarAwareExemplar.canonicalId,
|
||||
});
|
||||
|
||||
// Build new row with agent info
|
||||
const newRow = [
|
||||
finalModule,
|
||||
phase || '',
|
||||
name || '',
|
||||
code || '',
|
||||
sequence || '',
|
||||
workflowFile || '',
|
||||
command || '',
|
||||
required || 'false',
|
||||
cleanAgentName,
|
||||
agentData.command,
|
||||
agentData.displayName,
|
||||
agentData.title,
|
||||
options || '',
|
||||
description || '',
|
||||
outputLocation || '',
|
||||
outputs || '',
|
||||
];
|
||||
const fallbackRow = {
|
||||
module: finalModule,
|
||||
phase: phase || '',
|
||||
name: name || '',
|
||||
code: code || '',
|
||||
sequence: sequence || '',
|
||||
workflowFile: workflowFile || '',
|
||||
command: command || '',
|
||||
required: required || 'false',
|
||||
agentName: agentName || '',
|
||||
options: options || '',
|
||||
description: description || '',
|
||||
outputLocation: outputLocation || '',
|
||||
outputs: outputs || '',
|
||||
};
|
||||
|
||||
let newRow;
|
||||
if (isExemplarRow) {
|
||||
if (exemplarRowWritten) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newRow = this.buildHelpCatalogRowWithAgentInfo(sidecarAwareExemplar.row, fallbackRow, agentInfo);
|
||||
exemplarRowWritten = true;
|
||||
} else {
|
||||
newRow = this.buildHelpCatalogRowWithAgentInfo(
|
||||
{
|
||||
module: finalModule,
|
||||
phase: phase || '',
|
||||
name: name || '',
|
||||
code: code || '',
|
||||
sequence: sequence || '',
|
||||
'workflow-file': workflowFile || '',
|
||||
command: command || '',
|
||||
required: required || 'false',
|
||||
'agent-name': agentName || '',
|
||||
'agent-command': '',
|
||||
'agent-display-name': '',
|
||||
'agent-title': '',
|
||||
options: options || '',
|
||||
description: description || '',
|
||||
'output-location': outputLocation || '',
|
||||
outputs: outputs || '',
|
||||
},
|
||||
fallbackRow,
|
||||
agentInfo,
|
||||
);
|
||||
}
|
||||
|
||||
allRows.push(newRow.map((c) => this.escapeCSVField(c)).join(','));
|
||||
}
|
||||
|
|
@ -1832,6 +2051,30 @@ class Installer {
|
|||
}
|
||||
}
|
||||
|
||||
if (!exemplarRowWritten) {
|
||||
const injectedExemplarRow = this.buildHelpCatalogRowWithAgentInfo(
|
||||
sidecarAwareExemplar.row,
|
||||
{
|
||||
module: 'core',
|
||||
phase: sidecarAwareExemplar.row.phase,
|
||||
name: sidecarAwareExemplar.row.name,
|
||||
code: sidecarAwareExemplar.row.code,
|
||||
sequence: sidecarAwareExemplar.row.sequence,
|
||||
workflowFile: sidecarAwareExemplar.row['workflow-file'],
|
||||
command: sidecarAwareExemplar.row.command,
|
||||
required: sidecarAwareExemplar.row.required,
|
||||
agentName: sidecarAwareExemplar.row['agent-name'],
|
||||
options: sidecarAwareExemplar.row.options,
|
||||
description: sidecarAwareExemplar.row.description,
|
||||
outputLocation: sidecarAwareExemplar.row['output-location'],
|
||||
outputs: sidecarAwareExemplar.row.outputs,
|
||||
},
|
||||
agentInfo,
|
||||
);
|
||||
allRows.push(injectedExemplarRow.map((c) => this.escapeCSVField(c)).join(','));
|
||||
exemplarRowWritten = true;
|
||||
}
|
||||
|
||||
// Sort by module, then phase, then sequence
|
||||
allRows.sort((a, b) => {
|
||||
const colsA = this.parseCSVLine(a);
|
||||
|
|
@ -1857,17 +2100,136 @@ class Installer {
|
|||
return seqA - seqB;
|
||||
});
|
||||
|
||||
const commandLabelRowsFromMergedCatalog = [];
|
||||
for (const row of allRows) {
|
||||
const columns = this.parseCSVLine(row);
|
||||
const workflowFile = String(columns[5] || '').trim();
|
||||
const name = String(columns[2] || '').trim();
|
||||
const rawCommandValue = String(columns[6] || '').trim();
|
||||
if (!rawCommandValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.isExemplarCommandLabelCandidate({
|
||||
workflowFile,
|
||||
name,
|
||||
rawCommandValue,
|
||||
canonicalId: sidecarAwareExemplar.canonicalId,
|
||||
legacyName: sidecarAwareExemplar.legacyName,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const displayedCommandLabel = renderDisplayedCommandLabel(rawCommandValue);
|
||||
commandLabelRowsFromMergedCatalog.push({
|
||||
surface: `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/bmad-help.csv`,
|
||||
canonicalId: sidecarAwareExemplar.canonicalId,
|
||||
rawCommandValue,
|
||||
displayedCommandLabel,
|
||||
normalizedDisplayedLabel: normalizeDisplayedCommandLabel(displayedCommandLabel),
|
||||
authoritySourceType: sidecarAwareExemplar.authoritySourceType,
|
||||
authoritySourcePath: sidecarAwareExemplar.authoritySourcePath,
|
||||
});
|
||||
}
|
||||
|
||||
const exemplarRowCount = commandLabelRowsFromMergedCatalog.length;
|
||||
|
||||
this.helpCatalogPipelineRows = sidecarAwareExemplar.pipelineStageRows.map((row) => ({
|
||||
...row,
|
||||
rowCountForStageCanonicalId: exemplarRowCount,
|
||||
stageStatus: exemplarRowCount === 1 ? 'PASS' : 'FAIL',
|
||||
status: exemplarRowCount === 1 ? 'PASS' : 'FAIL',
|
||||
}));
|
||||
this.helpCatalogCommandLabelReportRows = commandLabelRowsFromMergedCatalog.map((row) => ({
|
||||
...row,
|
||||
rowCountForCanonicalId: exemplarRowCount,
|
||||
status: exemplarRowCount === 1 ? 'PASS' : 'FAIL',
|
||||
}));
|
||||
|
||||
const commandLabelContractResult = evaluateExemplarCommandLabelReportRows(this.helpCatalogCommandLabelReportRows, {
|
||||
canonicalId: sidecarAwareExemplar.canonicalId,
|
||||
displayedCommandLabel: sidecarAwareExemplar.displayedCommandLabel,
|
||||
});
|
||||
if (!commandLabelContractResult.valid) {
|
||||
this.helpCatalogPipelineRows = this.helpCatalogPipelineRows.map((row) => ({
|
||||
...row,
|
||||
stageStatus: 'FAIL',
|
||||
status: 'FAIL',
|
||||
}));
|
||||
this.helpCatalogCommandLabelReportRows = this.helpCatalogCommandLabelReportRows.map((row) => ({
|
||||
...row,
|
||||
status: 'FAIL',
|
||||
failureReason: commandLabelContractResult.reason,
|
||||
}));
|
||||
|
||||
const commandLabelError = new Error(
|
||||
`${HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED}: ${commandLabelContractResult.reason}`,
|
||||
);
|
||||
commandLabelError.code = HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED;
|
||||
commandLabelError.detail = commandLabelContractResult.reason;
|
||||
throw commandLabelError;
|
||||
}
|
||||
|
||||
// Write merged catalog
|
||||
const outputDir = path.join(bmadDir, '_config');
|
||||
await fs.ensureDir(outputDir);
|
||||
const outputPath = path.join(outputDir, 'bmad-help.csv');
|
||||
const helpCatalogPipelinePath = path.join(outputDir, 'bmad-help-catalog-pipeline.csv');
|
||||
const commandLabelReportPath = path.join(outputDir, 'bmad-help-command-label-report.csv');
|
||||
|
||||
const mergedContent = [headerRow, ...allRows].join('\n');
|
||||
validateHelpCatalogCompatibilitySurface(mergedContent, {
|
||||
sourcePath: `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/bmad-help.csv`,
|
||||
});
|
||||
await fs.writeFile(outputPath, mergedContent, 'utf8');
|
||||
|
||||
// Track the installed file
|
||||
this.installedFiles.add(outputPath);
|
||||
|
||||
await this.writeCsvArtifact(
|
||||
helpCatalogPipelinePath,
|
||||
[
|
||||
'stage',
|
||||
'artifactPath',
|
||||
'rowIdentity',
|
||||
'canonicalId',
|
||||
'sourcePath',
|
||||
'rowCountForStageCanonicalId',
|
||||
'commandValue',
|
||||
'expectedCommandValue',
|
||||
'descriptionValue',
|
||||
'expectedDescriptionValue',
|
||||
'descriptionAuthoritySourceType',
|
||||
'descriptionAuthoritySourcePath',
|
||||
'commandAuthoritySourceType',
|
||||
'commandAuthoritySourcePath',
|
||||
'issuerOwnerClass',
|
||||
'issuingComponent',
|
||||
'issuingComponentBindingEvidence',
|
||||
'stageStatus',
|
||||
'status',
|
||||
],
|
||||
this.helpCatalogPipelineRows,
|
||||
);
|
||||
await this.writeCsvArtifact(
|
||||
commandLabelReportPath,
|
||||
[
|
||||
'surface',
|
||||
'canonicalId',
|
||||
'rawCommandValue',
|
||||
'displayedCommandLabel',
|
||||
'normalizedDisplayedLabel',
|
||||
'rowCountForCanonicalId',
|
||||
'authoritySourceType',
|
||||
'authoritySourcePath',
|
||||
'status',
|
||||
'failureReason',
|
||||
],
|
||||
this.helpCatalogCommandLabelReportRows,
|
||||
);
|
||||
|
||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
||||
await prompts.log.message(` Generated bmad-help.csv: ${allRows.length} workflows`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,56 @@ const crypto = require('node:crypto');
|
|||
const csv = require('csv-parse/sync');
|
||||
const { getSourcePath, getModulePath } = require('../../../lib/project-root');
|
||||
const prompts = require('../../../lib/prompts');
|
||||
const {
|
||||
EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH,
|
||||
LOCKED_EXEMPLAR_ALIAS_ROWS,
|
||||
normalizeAndResolveExemplarAlias,
|
||||
} = require('./help-alias-normalizer');
|
||||
const { validateTaskManifestCompatibilitySurface } = require('./projection-compatibility-validator');
|
||||
|
||||
// Load package.json for version info
|
||||
const packageJson = require('../../../../../package.json');
|
||||
const DEFAULT_EXEMPLAR_HELP_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.artifact.yaml';
|
||||
const CANONICAL_ALIAS_TABLE_COLUMNS = Object.freeze([
|
||||
'canonicalId',
|
||||
'alias',
|
||||
'aliasType',
|
||||
'authoritySourceType',
|
||||
'authoritySourcePath',
|
||||
'rowIdentity',
|
||||
'normalizedAliasValue',
|
||||
'rawIdentityHasLeadingSlash',
|
||||
'resolutionEligibility',
|
||||
]);
|
||||
const LOCKED_CANONICAL_ALIAS_TABLE_EXEMPLAR_ROWS = Object.freeze([
|
||||
Object.freeze({
|
||||
canonicalId: 'bmad-help',
|
||||
alias: 'bmad-help',
|
||||
aliasType: 'canonical-id',
|
||||
rowIdentity: 'alias-row:bmad-help:canonical-id',
|
||||
normalizedAliasValue: 'bmad-help',
|
||||
rawIdentityHasLeadingSlash: false,
|
||||
resolutionEligibility: 'canonical-id-only',
|
||||
}),
|
||||
Object.freeze({
|
||||
canonicalId: 'bmad-help',
|
||||
alias: 'help',
|
||||
aliasType: 'legacy-name',
|
||||
rowIdentity: 'alias-row:bmad-help:legacy-name',
|
||||
normalizedAliasValue: 'help',
|
||||
rawIdentityHasLeadingSlash: false,
|
||||
resolutionEligibility: 'legacy-name-only',
|
||||
}),
|
||||
Object.freeze({
|
||||
canonicalId: 'bmad-help',
|
||||
alias: '/bmad-help',
|
||||
aliasType: 'slash-command',
|
||||
rowIdentity: 'alias-row:bmad-help:slash-command',
|
||||
normalizedAliasValue: 'bmad-help',
|
||||
rawIdentityHasLeadingSlash: true,
|
||||
resolutionEligibility: 'slash-command-only',
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Generates manifest files for installed workflows, agents, and tasks
|
||||
|
|
@ -34,6 +81,65 @@ class ManifestGenerator {
|
|||
return text.trim().replaceAll(/\s+/g, ' '); // Normalize all whitespace (including newlines) to single space
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize authority records emitted by help authority validation so they can
|
||||
* be written into downstream artifacts deterministically.
|
||||
* @param {Array<object>} records - Raw authority records
|
||||
* @returns {Array<object>} Normalized and sorted records
|
||||
*/
|
||||
async normalizeHelpAuthorityRecords(records) {
|
||||
if (!Array.isArray(records)) return [];
|
||||
|
||||
const normalized = [];
|
||||
const canonicalAliasTablePath = this.bmadDir ? path.join(this.bmadDir, '_config', 'canonical-aliases.csv') : '';
|
||||
const hasCanonicalAliasTable = canonicalAliasTablePath ? await fs.pathExists(canonicalAliasTablePath) : false;
|
||||
const canonicalAliasSourcePath = hasCanonicalAliasTable
|
||||
? `${this.bmadFolderName || '_bmad'}/_config/canonical-aliases.csv`
|
||||
: EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH;
|
||||
|
||||
for (const record of records) {
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawCanonicalIdentity = String(record.canonicalId ?? '').trim();
|
||||
const authoritySourceType = String(record.authoritySourceType ?? '').trim();
|
||||
const authoritySourcePath = String(record.authoritySourcePath ?? '').trim();
|
||||
const sourcePath = String(record.sourcePath ?? '').trim();
|
||||
const recordType = String(record.recordType ?? '').trim();
|
||||
|
||||
if (!rawCanonicalIdentity || !authoritySourceType || !authoritySourcePath || !sourcePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonicalIdentityResolution = await normalizeAndResolveExemplarAlias(rawCanonicalIdentity, {
|
||||
fieldPath: 'canonicalId',
|
||||
sourcePath: authoritySourcePath,
|
||||
aliasTablePath: hasCanonicalAliasTable ? canonicalAliasTablePath : undefined,
|
||||
aliasRows: hasCanonicalAliasTable ? undefined : LOCKED_EXEMPLAR_ALIAS_ROWS,
|
||||
aliasTableSourcePath: canonicalAliasSourcePath,
|
||||
});
|
||||
const canonicalId = canonicalIdentityResolution.postAliasCanonicalId;
|
||||
|
||||
normalized.push({
|
||||
recordType,
|
||||
canonicalId,
|
||||
authoritativePresenceKey: `capability:${canonicalId}`,
|
||||
authoritySourceType,
|
||||
authoritySourcePath,
|
||||
sourcePath,
|
||||
});
|
||||
}
|
||||
|
||||
normalized.sort((left, right) => {
|
||||
const leftKey = `${left.canonicalId}|${left.recordType}|${left.authoritySourceType}|${left.authoritySourcePath}|${left.sourcePath}`;
|
||||
const rightKey = `${right.canonicalId}|${right.recordType}|${right.authoritySourceType}|${right.authoritySourcePath}|${right.sourcePath}`;
|
||||
return leftKey.localeCompare(rightKey);
|
||||
});
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all manifests for the installation
|
||||
* @param {string} bmadDir - _bmad
|
||||
|
|
@ -75,6 +181,8 @@ class ManifestGenerator {
|
|||
throw new TypeError('ManifestGenerator expected `options.ides` to be an array.');
|
||||
}
|
||||
|
||||
this.helpAuthorityRecords = await this.normalizeHelpAuthorityRecords(options.helpAuthorityRecords);
|
||||
|
||||
// Filter out any undefined/null values from IDE list
|
||||
this.selectedIdes = resolvedIdes.filter((ide) => ide && typeof ide === 'string');
|
||||
|
||||
|
|
@ -96,6 +204,7 @@ class ManifestGenerator {
|
|||
await this.writeWorkflowManifest(cfgDir),
|
||||
await this.writeAgentManifest(cfgDir),
|
||||
await this.writeTaskManifest(cfgDir),
|
||||
await this.writeCanonicalAliasManifest(cfgDir),
|
||||
await this.writeToolManifest(cfgDir),
|
||||
await this.writeFilesManifest(cfgDir),
|
||||
];
|
||||
|
|
@ -630,6 +739,12 @@ class ManifestGenerator {
|
|||
ides: this.selectedIdes,
|
||||
};
|
||||
|
||||
if (this.helpAuthorityRecords.length > 0) {
|
||||
manifest.helpAuthority = {
|
||||
records: this.helpAuthorityRecords,
|
||||
};
|
||||
}
|
||||
|
||||
// Clean the manifest to remove any non-serializable values
|
||||
const cleanManifest = structuredClone(manifest);
|
||||
|
||||
|
|
@ -842,22 +957,51 @@ class ManifestGenerator {
|
|||
async writeTaskManifest(cfgDir) {
|
||||
const csvPath = path.join(cfgDir, 'task-manifest.csv');
|
||||
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
|
||||
const compatibilitySurfacePath = `${this.bmadFolderName || '_bmad'}/_config/task-manifest.csv`;
|
||||
const sidecarAuthorityRecord = Array.isArray(this.helpAuthorityRecords)
|
||||
? this.helpAuthorityRecords.find(
|
||||
(record) => record?.canonicalId === 'bmad-help' && record?.authoritySourceType === 'sidecar' && record?.authoritySourcePath,
|
||||
)
|
||||
: null;
|
||||
const exemplarAuthoritySourceType = sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourceType : 'sidecar';
|
||||
const exemplarAuthoritySourcePath = sidecarAuthorityRecord
|
||||
? sidecarAuthorityRecord.authoritySourcePath
|
||||
: 'bmad-fork/src/core/tasks/help.artifact.yaml';
|
||||
|
||||
// Read existing manifest to preserve entries
|
||||
const existingEntries = new Map();
|
||||
if (await fs.pathExists(csvPath)) {
|
||||
const content = await fs.readFile(csvPath, 'utf8');
|
||||
validateTaskManifestCompatibilitySurface(content, {
|
||||
sourcePath: compatibilitySurfacePath,
|
||||
allowLegacyPrefixOnly: true,
|
||||
});
|
||||
const records = csv.parse(content, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
});
|
||||
for (const record of records) {
|
||||
existingEntries.set(`${record.module}:${record.name}`, record);
|
||||
if (!record?.module || !record?.name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
existingEntries.set(`${record.module}:${record.name}`, {
|
||||
name: record.name,
|
||||
displayName: record.displayName,
|
||||
description: record.description,
|
||||
module: record.module,
|
||||
path: record.path,
|
||||
standalone: record.standalone,
|
||||
legacyName: record.legacyName || record.name,
|
||||
canonicalId: record.canonicalId || '',
|
||||
authoritySourceType: record.authoritySourceType || '',
|
||||
authoritySourcePath: record.authoritySourcePath || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create CSV header with standalone column
|
||||
let csvContent = 'name,displayName,description,module,path,standalone\n';
|
||||
// Create CSV header with compatibility-prefix columns followed by additive wave-1 columns.
|
||||
let csvContent = 'name,displayName,description,module,path,standalone,legacyName,canonicalId,authoritySourceType,authoritySourcePath\n';
|
||||
|
||||
// Combine existing and new tasks
|
||||
const allTasks = new Map();
|
||||
|
|
@ -870,6 +1014,9 @@ class ManifestGenerator {
|
|||
// Add/update new tasks
|
||||
for (const task of this.tasks) {
|
||||
const key = `${task.module}:${task.name}`;
|
||||
const previousRecord = allTasks.get(key);
|
||||
const isExemplarHelpTask = task.module === 'core' && task.name === 'help';
|
||||
|
||||
allTasks.set(key, {
|
||||
name: task.name,
|
||||
displayName: task.displayName,
|
||||
|
|
@ -877,11 +1024,17 @@ class ManifestGenerator {
|
|||
module: task.module,
|
||||
path: task.path,
|
||||
standalone: task.standalone,
|
||||
legacyName: isExemplarHelpTask ? 'help' : previousRecord?.legacyName || task.name,
|
||||
canonicalId: isExemplarHelpTask ? 'bmad-help' : previousRecord?.canonicalId || '',
|
||||
authoritySourceType: isExemplarHelpTask ? exemplarAuthoritySourceType : previousRecord?.authoritySourceType || '',
|
||||
authoritySourcePath: isExemplarHelpTask ? exemplarAuthoritySourcePath : previousRecord?.authoritySourcePath || '',
|
||||
});
|
||||
}
|
||||
|
||||
// Write all tasks
|
||||
for (const [, record] of allTasks) {
|
||||
// Write all tasks in deterministic order.
|
||||
const sortedTaskKeys = [...allTasks.keys()].sort((left, right) => left.localeCompare(right));
|
||||
for (const taskKey of sortedTaskKeys) {
|
||||
const record = allTasks.get(taskKey);
|
||||
const row = [
|
||||
escapeCsv(record.name),
|
||||
escapeCsv(record.displayName),
|
||||
|
|
@ -889,14 +1042,89 @@ class ManifestGenerator {
|
|||
escapeCsv(record.module),
|
||||
escapeCsv(record.path),
|
||||
escapeCsv(record.standalone),
|
||||
escapeCsv(record.legacyName || record.name),
|
||||
escapeCsv(record.canonicalId || ''),
|
||||
escapeCsv(record.authoritySourceType || ''),
|
||||
escapeCsv(record.authoritySourcePath || ''),
|
||||
].join(',');
|
||||
csvContent += row + '\n';
|
||||
}
|
||||
|
||||
validateTaskManifestCompatibilitySurface(csvContent, {
|
||||
sourcePath: compatibilitySurfacePath,
|
||||
});
|
||||
|
||||
await fs.writeFile(csvPath, csvContent);
|
||||
return csvPath;
|
||||
}
|
||||
|
||||
resolveExemplarAliasAuthorityRecord() {
|
||||
const sidecarAuthorityRecord = Array.isArray(this.helpAuthorityRecords)
|
||||
? this.helpAuthorityRecords.find(
|
||||
(record) => record?.canonicalId === 'bmad-help' && record?.authoritySourceType === 'sidecar' && record?.authoritySourcePath,
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
authoritySourceType: sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourceType : 'sidecar',
|
||||
authoritySourcePath: sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourcePath : DEFAULT_EXEMPLAR_HELP_SIDECAR_SOURCE_PATH,
|
||||
};
|
||||
}
|
||||
|
||||
buildCanonicalAliasProjectionRows(authoritySourceType, authoritySourcePath) {
|
||||
return LOCKED_CANONICAL_ALIAS_TABLE_EXEMPLAR_ROWS.map((row) => ({
|
||||
canonicalId: row.canonicalId,
|
||||
alias: row.alias,
|
||||
aliasType: row.aliasType,
|
||||
authoritySourceType,
|
||||
authoritySourcePath,
|
||||
rowIdentity: row.rowIdentity,
|
||||
normalizedAliasValue: row.normalizedAliasValue,
|
||||
rawIdentityHasLeadingSlash: row.rawIdentityHasLeadingSlash,
|
||||
resolutionEligibility: row.resolutionEligibility,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write canonical alias table projection CSV.
|
||||
* @returns {string} Path to the canonical alias projection file
|
||||
*/
|
||||
async writeCanonicalAliasManifest(cfgDir) {
|
||||
const csvPath = path.join(cfgDir, 'canonical-aliases.csv');
|
||||
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
|
||||
const { authoritySourceType, authoritySourcePath } = this.resolveExemplarAliasAuthorityRecord();
|
||||
const projectedRows = this.buildCanonicalAliasProjectionRows(authoritySourceType, authoritySourcePath);
|
||||
|
||||
let csvContent = `${CANONICAL_ALIAS_TABLE_COLUMNS.join(',')}\n`;
|
||||
for (const row of projectedRows) {
|
||||
const serializedRow = [
|
||||
escapeCsv(row.canonicalId),
|
||||
escapeCsv(row.alias),
|
||||
escapeCsv(row.aliasType),
|
||||
escapeCsv(row.authoritySourceType),
|
||||
escapeCsv(row.authoritySourcePath),
|
||||
escapeCsv(row.rowIdentity),
|
||||
escapeCsv(row.normalizedAliasValue),
|
||||
escapeCsv(row.rawIdentityHasLeadingSlash),
|
||||
escapeCsv(row.resolutionEligibility),
|
||||
].join(',');
|
||||
csvContent += `${serializedRow}\n`;
|
||||
}
|
||||
|
||||
await fs.writeFile(csvPath, csvContent);
|
||||
|
||||
const trackedPath = `${this.bmadFolderName || '_bmad'}/_config/canonical-aliases.csv`;
|
||||
if (!this.files.some((file) => file.path === trackedPath)) {
|
||||
this.files.push({
|
||||
type: 'config',
|
||||
name: 'canonical-aliases',
|
||||
module: '_config',
|
||||
path: trackedPath,
|
||||
});
|
||||
}
|
||||
|
||||
return csvPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write tool manifest CSV
|
||||
* @returns {string} Path to the manifest file
|
||||
|
|
|
|||
|
|
@ -0,0 +1,407 @@
|
|||
const csv = require('csv-parse/sync');
|
||||
|
||||
const TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS = Object.freeze(['name', 'displayName', 'description', 'module', 'path', 'standalone']);
|
||||
|
||||
const TASK_MANIFEST_WAVE1_ADDITIVE_COLUMNS = Object.freeze(['legacyName', 'canonicalId', 'authoritySourceType', 'authoritySourcePath']);
|
||||
|
||||
const HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS = Object.freeze([
|
||||
'module',
|
||||
'phase',
|
||||
'name',
|
||||
'code',
|
||||
'sequence',
|
||||
'workflow-file',
|
||||
'command',
|
||||
'required',
|
||||
]);
|
||||
|
||||
const HELP_CATALOG_WAVE1_ADDITIVE_COLUMNS = Object.freeze([
|
||||
'agent-name',
|
||||
'agent-command',
|
||||
'agent-display-name',
|
||||
'agent-title',
|
||||
'options',
|
||||
'description',
|
||||
'output-location',
|
||||
'outputs',
|
||||
]);
|
||||
|
||||
const PROJECTION_COMPATIBILITY_ERROR_CODES = Object.freeze({
|
||||
TASK_MANIFEST_CSV_PARSE_FAILED: 'ERR_TASK_MANIFEST_COMPAT_PARSE_FAILED',
|
||||
TASK_MANIFEST_HEADER_PREFIX_MISMATCH: 'ERR_TASK_MANIFEST_COMPAT_HEADER_PREFIX_MISMATCH',
|
||||
TASK_MANIFEST_HEADER_WAVE1_MISMATCH: 'ERR_TASK_MANIFEST_COMPAT_HEADER_WAVE1_MISMATCH',
|
||||
TASK_MANIFEST_REQUIRED_COLUMN_MISSING: 'ERR_TASK_MANIFEST_COMPAT_REQUIRED_COLUMN_MISSING',
|
||||
TASK_MANIFEST_ROW_FIELD_EMPTY: 'ERR_TASK_MANIFEST_COMPAT_ROW_FIELD_EMPTY',
|
||||
HELP_CATALOG_CSV_PARSE_FAILED: 'ERR_HELP_CATALOG_COMPAT_PARSE_FAILED',
|
||||
HELP_CATALOG_HEADER_PREFIX_MISMATCH: 'ERR_HELP_CATALOG_COMPAT_HEADER_PREFIX_MISMATCH',
|
||||
HELP_CATALOG_HEADER_WAVE1_MISMATCH: 'ERR_HELP_CATALOG_COMPAT_HEADER_WAVE1_MISMATCH',
|
||||
HELP_CATALOG_REQUIRED_COLUMN_MISSING: 'ERR_HELP_CATALOG_COMPAT_REQUIRED_COLUMN_MISSING',
|
||||
HELP_CATALOG_EXEMPLAR_ROW_CONTRACT_FAILED: 'ERR_HELP_CATALOG_COMPAT_EXEMPLAR_ROW_CONTRACT_FAILED',
|
||||
GITHUB_COPILOT_WORKFLOW_FILE_MISSING: 'ERR_GITHUB_COPILOT_HELP_WORKFLOW_FILE_MISSING',
|
||||
});
|
||||
|
||||
class ProjectionCompatibilityError extends Error {
|
||||
constructor({ code, detail, surface, fieldPath, sourcePath, observedValue, expectedValue }) {
|
||||
const message = `${code}: ${detail} (surface=${surface}, fieldPath=${fieldPath}, sourcePath=${sourcePath})`;
|
||||
super(message);
|
||||
this.name = 'ProjectionCompatibilityError';
|
||||
this.code = code;
|
||||
this.detail = detail;
|
||||
this.surface = surface;
|
||||
this.fieldPath = fieldPath;
|
||||
this.sourcePath = sourcePath;
|
||||
this.observedValue = observedValue;
|
||||
this.expectedValue = expectedValue;
|
||||
this.fullMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSourcePath(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
function normalizeValue(value) {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function throwCompatibilityError({ code, detail, surface, fieldPath, sourcePath, observedValue, expectedValue }) {
|
||||
throw new ProjectionCompatibilityError({
|
||||
code,
|
||||
detail,
|
||||
surface,
|
||||
fieldPath,
|
||||
sourcePath,
|
||||
observedValue,
|
||||
expectedValue,
|
||||
});
|
||||
}
|
||||
|
||||
function parseHeaderColumns(csvContent, { code, surface, sourcePath }) {
|
||||
try {
|
||||
const parsed = csv.parse(String(csvContent ?? ''), {
|
||||
to_line: 1,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
});
|
||||
const headerColumns = Array.isArray(parsed) && parsed.length > 0 ? parsed[0].map(String) : [];
|
||||
if (headerColumns.length === 0) {
|
||||
throwCompatibilityError({
|
||||
code,
|
||||
detail: 'CSV surface is missing a header row',
|
||||
surface,
|
||||
fieldPath: '<header>',
|
||||
sourcePath,
|
||||
observedValue: '<empty>',
|
||||
expectedValue: 'comma-separated header columns',
|
||||
});
|
||||
}
|
||||
return headerColumns;
|
||||
} catch (error) {
|
||||
if (error instanceof ProjectionCompatibilityError) {
|
||||
throw error;
|
||||
}
|
||||
throwCompatibilityError({
|
||||
code,
|
||||
detail: `Unable to parse CSV header: ${error.message}`,
|
||||
surface,
|
||||
fieldPath: '<header>',
|
||||
sourcePath,
|
||||
observedValue: '<parse-failure>',
|
||||
expectedValue: 'valid CSV header',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function parseRowsWithHeaders(csvContent, { code, surface, sourcePath }) {
|
||||
try {
|
||||
return csv.parse(String(csvContent ?? ''), {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
});
|
||||
} catch (error) {
|
||||
throwCompatibilityError({
|
||||
code,
|
||||
detail: `Unable to parse CSV rows: ${error.message}`,
|
||||
surface,
|
||||
fieldPath: '<rows>',
|
||||
sourcePath,
|
||||
observedValue: '<parse-failure>',
|
||||
expectedValue: 'valid CSV rows',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertLockedColumns({ headerColumns, expectedColumns, offset, code, detail, surface, sourcePath }) {
|
||||
for (const [index, expectedValue] of expectedColumns.entries()) {
|
||||
const headerIndex = offset + index;
|
||||
const observedValue = normalizeValue(headerColumns[headerIndex]);
|
||||
if (observedValue !== expectedValue) {
|
||||
throwCompatibilityError({
|
||||
code,
|
||||
detail,
|
||||
surface,
|
||||
fieldPath: `header[${headerIndex}]`,
|
||||
sourcePath,
|
||||
observedValue: observedValue || '<missing>',
|
||||
expectedValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertRequiredColumns({ headerColumns, requiredColumns, code, surface, sourcePath }) {
|
||||
const headerSet = new Set(headerColumns.map((column) => normalizeValue(column)));
|
||||
for (const column of requiredColumns) {
|
||||
if (!headerSet.has(column)) {
|
||||
throwCompatibilityError({
|
||||
code,
|
||||
detail: 'Required compatibility column is missing from projection surface',
|
||||
surface,
|
||||
fieldPath: `header.${column}`,
|
||||
sourcePath,
|
||||
observedValue: '<missing>',
|
||||
expectedValue: column,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCommandValue(value) {
|
||||
return normalizeValue(value).toLowerCase().replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function normalizeWorkflowPath(value) {
|
||||
return normalizeSourcePath(value).toLowerCase();
|
||||
}
|
||||
|
||||
function validateTaskManifestLoaderEntries(rows, options = {}) {
|
||||
const surface = options.surface || 'task-manifest-loader';
|
||||
const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/task-manifest.csv');
|
||||
const headerColumns = Array.isArray(options.headerColumns) ? options.headerColumns : Object.keys(rows?.[0] || {});
|
||||
const requiredColumns = ['name', 'module', 'path'];
|
||||
|
||||
assertRequiredColumns({
|
||||
headerColumns,
|
||||
requiredColumns,
|
||||
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_REQUIRED_COLUMN_MISSING,
|
||||
surface,
|
||||
sourcePath,
|
||||
});
|
||||
|
||||
for (let index = 0; index < (Array.isArray(rows) ? rows.length : 0); index += 1) {
|
||||
const row = rows[index];
|
||||
for (const requiredColumn of requiredColumns) {
|
||||
if (!row || normalizeValue(row[requiredColumn]).length === 0) {
|
||||
throwCompatibilityError({
|
||||
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_ROW_FIELD_EMPTY,
|
||||
detail: 'Task-manifest row is missing a required compatibility value',
|
||||
surface,
|
||||
fieldPath: `rows[${index}].${requiredColumn}`,
|
||||
sourcePath,
|
||||
observedValue: normalizeValue(row ? row[requiredColumn] : '') || '<empty>',
|
||||
expectedValue: 'non-empty string',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateHelpCatalogLoaderEntries(rows, options = {}) {
|
||||
const surface = options.surface || 'bmad-help-catalog-loader';
|
||||
const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/bmad-help.csv');
|
||||
const headerColumns = Array.isArray(options.headerColumns) ? options.headerColumns : Object.keys(rows?.[0] || {});
|
||||
const requiredColumns = ['name', 'workflow-file', 'command'];
|
||||
|
||||
assertRequiredColumns({
|
||||
headerColumns,
|
||||
requiredColumns,
|
||||
code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_REQUIRED_COLUMN_MISSING,
|
||||
surface,
|
||||
sourcePath,
|
||||
});
|
||||
|
||||
const parsedRows = Array.isArray(rows) ? rows : [];
|
||||
for (const [index, row] of parsedRows.entries()) {
|
||||
const rawCommandValue = normalizeValue(row.command);
|
||||
if (rawCommandValue.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizeValue(row['workflow-file']).length === 0) {
|
||||
throwCompatibilityError({
|
||||
code: PROJECTION_COMPATIBILITY_ERROR_CODES.GITHUB_COPILOT_WORKFLOW_FILE_MISSING,
|
||||
detail: 'Rows with command values must preserve workflow-file for prompt generation loaders',
|
||||
surface,
|
||||
fieldPath: `rows[${index}].workflow-file`,
|
||||
sourcePath,
|
||||
observedValue: '<empty>',
|
||||
expectedValue: 'non-empty string',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const exemplarRows = parsedRows.filter(
|
||||
(row) =>
|
||||
normalizeCommandValue(row.command) === 'bmad-help' && normalizeWorkflowPath(row['workflow-file']).endsWith('/core/tasks/help.md'),
|
||||
);
|
||||
if (exemplarRows.length !== 1) {
|
||||
throwCompatibilityError({
|
||||
code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_EXEMPLAR_ROW_CONTRACT_FAILED,
|
||||
detail: 'Exactly one exemplar bmad-help compatibility row is required for help catalog consumers',
|
||||
surface,
|
||||
fieldPath: 'rows[*].command',
|
||||
sourcePath,
|
||||
observedValue: String(exemplarRows.length),
|
||||
expectedValue: '1',
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateGithubCopilotHelpLoaderEntries(rows, options = {}) {
|
||||
const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/bmad-help.csv');
|
||||
return validateHelpCatalogLoaderEntries(rows, {
|
||||
...options,
|
||||
sourcePath,
|
||||
surface: options.surface || 'github-copilot-help-loader',
|
||||
});
|
||||
}
|
||||
|
||||
function validateTaskManifestCompatibilitySurface(csvContent, options = {}) {
|
||||
const surface = options.surface || 'task-manifest-loader';
|
||||
const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/task-manifest.csv');
|
||||
const allowLegacyPrefixOnly = options.allowLegacyPrefixOnly === true;
|
||||
|
||||
const headerColumns = parseHeaderColumns(csvContent, {
|
||||
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_CSV_PARSE_FAILED,
|
||||
surface,
|
||||
sourcePath,
|
||||
});
|
||||
|
||||
const isLegacyPrefixOnlyHeader = headerColumns.length === TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS.length;
|
||||
if (allowLegacyPrefixOnly && isLegacyPrefixOnlyHeader) {
|
||||
assertLockedColumns({
|
||||
headerColumns,
|
||||
expectedColumns: TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS,
|
||||
offset: 0,
|
||||
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_HEADER_PREFIX_MISMATCH,
|
||||
detail: 'Task-manifest compatibility-prefix header ordering changed (non-additive change)',
|
||||
surface,
|
||||
sourcePath,
|
||||
});
|
||||
|
||||
const rows = parseRowsWithHeaders(csvContent, {
|
||||
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_CSV_PARSE_FAILED,
|
||||
surface,
|
||||
sourcePath,
|
||||
});
|
||||
validateTaskManifestLoaderEntries(rows, {
|
||||
surface,
|
||||
sourcePath,
|
||||
headerColumns,
|
||||
});
|
||||
|
||||
return { headerColumns, rows, isLegacyPrefixOnlyHeader: true };
|
||||
}
|
||||
|
||||
assertLockedColumns({
|
||||
headerColumns,
|
||||
expectedColumns: TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS,
|
||||
offset: 0,
|
||||
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_HEADER_PREFIX_MISMATCH,
|
||||
detail: 'Task-manifest compatibility-prefix header ordering changed (non-additive change)',
|
||||
surface,
|
||||
sourcePath,
|
||||
});
|
||||
assertLockedColumns({
|
||||
headerColumns,
|
||||
expectedColumns: TASK_MANIFEST_WAVE1_ADDITIVE_COLUMNS,
|
||||
offset: TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS.length,
|
||||
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_HEADER_WAVE1_MISMATCH,
|
||||
detail: 'Task-manifest wave-1 additive columns must remain appended after compatibility-prefix columns',
|
||||
surface,
|
||||
sourcePath,
|
||||
});
|
||||
|
||||
const rows = parseRowsWithHeaders(csvContent, {
|
||||
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_CSV_PARSE_FAILED,
|
||||
surface,
|
||||
sourcePath,
|
||||
});
|
||||
|
||||
validateTaskManifestLoaderEntries(rows, {
|
||||
surface,
|
||||
sourcePath,
|
||||
headerColumns,
|
||||
});
|
||||
|
||||
return { headerColumns, rows };
|
||||
}
|
||||
|
||||
function validateHelpCatalogCompatibilitySurface(csvContent, options = {}) {
|
||||
const surface = options.surface || 'bmad-help-catalog-loader';
|
||||
const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/bmad-help.csv');
|
||||
|
||||
const headerColumns = parseHeaderColumns(csvContent, {
|
||||
code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_CSV_PARSE_FAILED,
|
||||
surface,
|
||||
sourcePath,
|
||||
});
|
||||
|
||||
assertLockedColumns({
|
||||
headerColumns,
|
||||
expectedColumns: HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS,
|
||||
offset: 0,
|
||||
code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_HEADER_PREFIX_MISMATCH,
|
||||
detail: 'Help-catalog compatibility-prefix header ordering changed (non-additive change)',
|
||||
surface,
|
||||
sourcePath,
|
||||
});
|
||||
assertLockedColumns({
|
||||
headerColumns,
|
||||
expectedColumns: HELP_CATALOG_WAVE1_ADDITIVE_COLUMNS,
|
||||
offset: HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS.length,
|
||||
code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_HEADER_WAVE1_MISMATCH,
|
||||
detail: 'Help-catalog wave-1 additive columns must remain appended after compatibility-prefix columns',
|
||||
surface,
|
||||
sourcePath,
|
||||
});
|
||||
|
||||
const rows = parseRowsWithHeaders(csvContent, {
|
||||
code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_CSV_PARSE_FAILED,
|
||||
surface,
|
||||
sourcePath,
|
||||
});
|
||||
|
||||
validateHelpCatalogLoaderEntries(rows, {
|
||||
surface,
|
||||
sourcePath,
|
||||
headerColumns,
|
||||
});
|
||||
validateGithubCopilotHelpLoaderEntries(rows, {
|
||||
sourcePath,
|
||||
headerColumns,
|
||||
});
|
||||
|
||||
return { headerColumns, rows };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROJECTION_COMPATIBILITY_ERROR_CODES,
|
||||
ProjectionCompatibilityError,
|
||||
TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS,
|
||||
TASK_MANIFEST_WAVE1_ADDITIVE_COLUMNS,
|
||||
HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS,
|
||||
HELP_CATALOG_WAVE1_ADDITIVE_COLUMNS,
|
||||
validateTaskManifestCompatibilitySurface,
|
||||
validateTaskManifestLoaderEntries,
|
||||
validateHelpCatalogCompatibilitySurface,
|
||||
validateHelpCatalogLoaderEntries,
|
||||
validateGithubCopilotHelpLoaderEntries,
|
||||
};
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
const path = require('node:path');
|
||||
const fs = require('fs-extra');
|
||||
const yaml = require('yaml');
|
||||
const { getProjectRoot, getSourcePath } = require('../../../lib/project-root');
|
||||
|
||||
const HELP_SIDECAR_REQUIRED_FIELDS = Object.freeze([
|
||||
'schemaVersion',
|
||||
'canonicalId',
|
||||
'artifactType',
|
||||
'module',
|
||||
'sourcePath',
|
||||
'displayName',
|
||||
'description',
|
||||
'dependencies',
|
||||
]);
|
||||
|
||||
const HELP_SIDECAR_ERROR_CODES = Object.freeze({
|
||||
FILE_NOT_FOUND: 'ERR_HELP_SIDECAR_FILE_NOT_FOUND',
|
||||
PARSE_FAILED: 'ERR_HELP_SIDECAR_PARSE_FAILED',
|
||||
INVALID_ROOT_OBJECT: 'ERR_HELP_SIDECAR_INVALID_ROOT_OBJECT',
|
||||
REQUIRED_FIELD_MISSING: 'ERR_HELP_SIDECAR_REQUIRED_FIELD_MISSING',
|
||||
REQUIRED_FIELD_EMPTY: 'ERR_HELP_SIDECAR_REQUIRED_FIELD_EMPTY',
|
||||
ARTIFACT_TYPE_INVALID: 'ERR_HELP_SIDECAR_ARTIFACT_TYPE_INVALID',
|
||||
MODULE_INVALID: 'ERR_HELP_SIDECAR_MODULE_INVALID',
|
||||
DEPENDENCIES_MISSING: 'ERR_HELP_SIDECAR_DEPENDENCIES_MISSING',
|
||||
DEPENDENCIES_REQUIRES_INVALID: 'ERR_HELP_SIDECAR_DEPENDENCIES_REQUIRES_INVALID',
|
||||
DEPENDENCIES_REQUIRES_NOT_EMPTY: 'ERR_HELP_SIDECAR_DEPENDENCIES_REQUIRES_NOT_EMPTY',
|
||||
MAJOR_VERSION_UNSUPPORTED: 'ERR_SIDECAR_MAJOR_VERSION_UNSUPPORTED',
|
||||
SOURCEPATH_BASENAME_MISMATCH: 'ERR_SIDECAR_SOURCEPATH_BASENAME_MISMATCH',
|
||||
});
|
||||
|
||||
const HELP_EXEMPLAR_CANONICAL_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
|
||||
const HELP_EXEMPLAR_SUPPORTED_SCHEMA_MAJOR = 1;
|
||||
|
||||
class SidecarContractError extends Error {
|
||||
constructor({ code, detail, fieldPath, sourcePath }) {
|
||||
const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath})`;
|
||||
super(message);
|
||||
this.name = 'SidecarContractError';
|
||||
this.code = code;
|
||||
this.detail = detail;
|
||||
this.fieldPath = fieldPath;
|
||||
this.sourcePath = sourcePath;
|
||||
this.fullMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSourcePath(value) {
|
||||
if (!value) return '';
|
||||
return String(value).replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
function toProjectRelativePath(filePath) {
|
||||
const projectRoot = getProjectRoot();
|
||||
const relative = path.relative(projectRoot, filePath);
|
||||
|
||||
if (!relative || relative.startsWith('..')) {
|
||||
return normalizeSourcePath(path.resolve(filePath));
|
||||
}
|
||||
|
||||
return normalizeSourcePath(relative);
|
||||
}
|
||||
|
||||
function hasOwn(obj, key) {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
function isBlankString(value) {
|
||||
return typeof value !== 'string' || value.trim().length === 0;
|
||||
}
|
||||
|
||||
function parseSchemaMajorVersion(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const match = trimmed.match(/^(\d+)(?:\.\d+)?$/);
|
||||
if (!match) return null;
|
||||
return Number.parseInt(match[1], 10);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getExpectedSidecarBasenameFromSourcePath(sourcePathValue) {
|
||||
const normalized = normalizeSourcePath(sourcePathValue).trim();
|
||||
if (!normalized) return '';
|
||||
|
||||
const sourceBasename = path.posix.basename(normalized);
|
||||
if (!sourceBasename) return '';
|
||||
|
||||
const sourceExt = path.posix.extname(sourceBasename);
|
||||
const baseWithoutExt = sourceExt ? sourceBasename.slice(0, -sourceExt.length) : sourceBasename;
|
||||
if (!baseWithoutExt) return '';
|
||||
|
||||
return `${baseWithoutExt}.artifact.yaml`;
|
||||
}
|
||||
|
||||
function createValidationError(code, fieldPath, sourcePath, detail) {
|
||||
throw new SidecarContractError({
|
||||
code,
|
||||
fieldPath,
|
||||
sourcePath,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
|
||||
function validateHelpSidecarContractData(sidecarData, options = {}) {
|
||||
const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/help.artifact.yaml');
|
||||
|
||||
if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
|
||||
createValidationError(
|
||||
HELP_SIDECAR_ERROR_CODES.INVALID_ROOT_OBJECT,
|
||||
'<document>',
|
||||
sourcePath,
|
||||
'Sidecar root must be a YAML mapping object.',
|
||||
);
|
||||
}
|
||||
|
||||
for (const field of HELP_SIDECAR_REQUIRED_FIELDS) {
|
||||
if (!hasOwn(sidecarData, field)) {
|
||||
if (field === 'dependencies') {
|
||||
createValidationError(
|
||||
HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING,
|
||||
field,
|
||||
sourcePath,
|
||||
'Exemplar sidecar requires an explicit dependencies block.',
|
||||
);
|
||||
}
|
||||
|
||||
createValidationError(
|
||||
HELP_SIDECAR_ERROR_CODES.REQUIRED_FIELD_MISSING,
|
||||
field,
|
||||
sourcePath,
|
||||
`Missing required sidecar field "${field}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const requiredNonEmptyStringFields = ['canonicalId', 'sourcePath', 'displayName', 'description'];
|
||||
for (const field of requiredNonEmptyStringFields) {
|
||||
if (isBlankString(sidecarData[field])) {
|
||||
createValidationError(
|
||||
HELP_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
|
||||
field,
|
||||
sourcePath,
|
||||
`Required sidecar field "${field}" must be a non-empty string.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const schemaMajorVersion = parseSchemaMajorVersion(sidecarData.schemaVersion);
|
||||
if (schemaMajorVersion !== HELP_EXEMPLAR_SUPPORTED_SCHEMA_MAJOR) {
|
||||
createValidationError(
|
||||
HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED,
|
||||
'schemaVersion',
|
||||
sourcePath,
|
||||
'sidecar schema major version is unsupported',
|
||||
);
|
||||
}
|
||||
|
||||
if (sidecarData.artifactType !== 'task') {
|
||||
createValidationError(
|
||||
HELP_SIDECAR_ERROR_CODES.ARTIFACT_TYPE_INVALID,
|
||||
'artifactType',
|
||||
sourcePath,
|
||||
'Wave-1 exemplar requires artifactType to equal "task".',
|
||||
);
|
||||
}
|
||||
|
||||
if (sidecarData.module !== 'core') {
|
||||
createValidationError(
|
||||
HELP_SIDECAR_ERROR_CODES.MODULE_INVALID,
|
||||
'module',
|
||||
sourcePath,
|
||||
'Wave-1 exemplar requires module to equal "core".',
|
||||
);
|
||||
}
|
||||
|
||||
const dependencies = sidecarData.dependencies;
|
||||
if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) {
|
||||
createValidationError(
|
||||
HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING,
|
||||
'dependencies',
|
||||
sourcePath,
|
||||
'Exemplar sidecar requires an explicit dependencies object.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasOwn(dependencies, 'requires') || !Array.isArray(dependencies.requires)) {
|
||||
createValidationError(
|
||||
HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_INVALID,
|
||||
'dependencies.requires',
|
||||
sourcePath,
|
||||
'Exemplar dependencies.requires must be an array.',
|
||||
);
|
||||
}
|
||||
|
||||
if (dependencies.requires.length > 0) {
|
||||
createValidationError(
|
||||
HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_NOT_EMPTY,
|
||||
'dependencies.requires',
|
||||
sourcePath,
|
||||
'Wave-1 exemplar requires explicit zero dependencies: dependencies.requires must be [].',
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedDeclaredSourcePath = normalizeSourcePath(sidecarData.sourcePath);
|
||||
const sidecarBasename = path.posix.basename(sourcePath);
|
||||
const expectedSidecarBasename = getExpectedSidecarBasenameFromSourcePath(normalizedDeclaredSourcePath);
|
||||
|
||||
const sourcePathMismatch = normalizedDeclaredSourcePath !== HELP_EXEMPLAR_CANONICAL_SOURCE_PATH;
|
||||
const basenameMismatch = !expectedSidecarBasename || sidecarBasename !== expectedSidecarBasename;
|
||||
|
||||
if (sourcePathMismatch || basenameMismatch) {
|
||||
createValidationError(
|
||||
HELP_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH,
|
||||
'sourcePath',
|
||||
sourcePath,
|
||||
'sidecar basename does not match sourcePath basename',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateHelpSidecarContractFile(sidecarPath = getSourcePath('core', 'tasks', 'help.artifact.yaml'), options = {}) {
|
||||
const normalizedSourcePath = normalizeSourcePath(options.errorSourcePath || toProjectRelativePath(sidecarPath));
|
||||
|
||||
if (!(await fs.pathExists(sidecarPath))) {
|
||||
createValidationError(
|
||||
HELP_SIDECAR_ERROR_CODES.FILE_NOT_FOUND,
|
||||
'<file>',
|
||||
normalizedSourcePath,
|
||||
'Expected exemplar sidecar file was not found.',
|
||||
);
|
||||
}
|
||||
|
||||
let parsedSidecar;
|
||||
try {
|
||||
const sidecarRaw = await fs.readFile(sidecarPath, 'utf8');
|
||||
parsedSidecar = yaml.parse(sidecarRaw);
|
||||
} catch (error) {
|
||||
createValidationError(
|
||||
HELP_SIDECAR_ERROR_CODES.PARSE_FAILED,
|
||||
'<document>',
|
||||
normalizedSourcePath,
|
||||
`YAML parse failure: ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
validateHelpSidecarContractData(parsedSidecar, { errorSourcePath: normalizedSourcePath });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HELP_SIDECAR_REQUIRED_FIELDS,
|
||||
HELP_SIDECAR_ERROR_CODES,
|
||||
SidecarContractError,
|
||||
validateHelpSidecarContractData,
|
||||
validateHelpSidecarContractFile,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,14 +8,51 @@ const { AgentCommandGenerator } = require('./shared/agent-command-generator');
|
|||
const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator');
|
||||
const { getTasksFromBmad } = require('./shared/bmad-artifacts');
|
||||
const { toDashPath, customAgentDashName } = require('./shared/path-utils');
|
||||
const { normalizeAndResolveExemplarAlias } = require('../core/help-alias-normalizer');
|
||||
const prompts = require('../../../lib/prompts');
|
||||
|
||||
const CODEX_EXPORT_DERIVATION_ERROR_CODES = Object.freeze({
|
||||
SIDECAR_FILE_NOT_FOUND: 'ERR_CODEX_EXPORT_SIDECAR_FILE_NOT_FOUND',
|
||||
SIDECAR_PARSE_FAILED: 'ERR_CODEX_EXPORT_SIDECAR_PARSE_FAILED',
|
||||
CANONICAL_ID_MISSING: 'ERR_CODEX_EXPORT_CANONICAL_ID_MISSING',
|
||||
CANONICAL_ID_DERIVATION_FAILED: 'ERR_CODEX_EXPORT_CANONICAL_ID_DERIVATION_FAILED',
|
||||
});
|
||||
|
||||
const EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
|
||||
const EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.artifact.yaml';
|
||||
const EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE = 'sidecar-canonical-id';
|
||||
const EXEMPLAR_SIDECAR_SOURCE_CANDIDATES = Object.freeze([
|
||||
Object.freeze({
|
||||
segments: ['bmad-fork', 'src', 'core', 'tasks', 'help.artifact.yaml'],
|
||||
}),
|
||||
Object.freeze({
|
||||
segments: ['src', 'core', 'tasks', 'help.artifact.yaml'],
|
||||
}),
|
||||
]);
|
||||
|
||||
class CodexExportDerivationError extends Error {
|
||||
constructor({ code, detail, fieldPath, sourcePath, observedValue, cause = null }) {
|
||||
const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath}, observedValue=${observedValue})`;
|
||||
super(message);
|
||||
this.name = 'CodexExportDerivationError';
|
||||
this.code = code;
|
||||
this.detail = detail;
|
||||
this.fieldPath = fieldPath;
|
||||
this.sourcePath = sourcePath;
|
||||
this.observedValue = observedValue;
|
||||
if (cause) {
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex setup handler (CLI mode)
|
||||
*/
|
||||
class CodexSetup extends BaseIdeSetup {
|
||||
constructor() {
|
||||
super('codex', 'Codex', false);
|
||||
this.exportDerivationRecords = [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -31,6 +68,7 @@ class CodexSetup extends BaseIdeSetup {
|
|||
const mode = 'cli';
|
||||
|
||||
const { artifacts, counts } = await this.collectClaudeArtifacts(projectDir, bmadDir, options);
|
||||
this.exportDerivationRecords = [];
|
||||
|
||||
// Clean up old .codex/prompts locations (both global and project)
|
||||
const oldGlobalDir = this.getOldCodexPromptDir(null, 'global');
|
||||
|
|
@ -46,7 +84,7 @@ class CodexSetup extends BaseIdeSetup {
|
|||
// Collect and write agent skills
|
||||
const agentGen = new AgentCommandGenerator(this.bmadFolderName);
|
||||
const { artifacts: agentArtifacts } = await agentGen.collectAgentArtifacts(bmadDir, options.selectedModules || []);
|
||||
const agentCount = await this.writeSkillArtifacts(destDir, agentArtifacts, 'agent-launcher');
|
||||
const agentCount = await this.writeSkillArtifacts(destDir, agentArtifacts, 'agent-launcher', { projectDir });
|
||||
|
||||
// Collect and write task skills
|
||||
const tasks = await getTasksFromBmad(bmadDir, options.selectedModules || []);
|
||||
|
|
@ -77,12 +115,12 @@ class CodexSetup extends BaseIdeSetup {
|
|||
...artifact,
|
||||
content: ttGen.generateCommandContent(artifact, artifact.type),
|
||||
}));
|
||||
const tasksWritten = await this.writeSkillArtifacts(destDir, taskSkillArtifacts, 'task');
|
||||
const tasksWritten = await this.writeSkillArtifacts(destDir, taskSkillArtifacts, 'task', { projectDir });
|
||||
|
||||
// Collect and write workflow skills
|
||||
const workflowGenerator = new WorkflowCommandGenerator(this.bmadFolderName);
|
||||
const { artifacts: workflowArtifacts } = await workflowGenerator.collectWorkflowArtifacts(bmadDir);
|
||||
const workflowCount = await this.writeSkillArtifacts(destDir, workflowArtifacts, 'workflow-command');
|
||||
const workflowCount = await this.writeSkillArtifacts(destDir, workflowArtifacts, 'workflow-command', { projectDir });
|
||||
|
||||
const written = agentCount + workflowCount + tasksWritten;
|
||||
|
||||
|
|
@ -99,6 +137,7 @@ class CodexSetup extends BaseIdeSetup {
|
|||
counts,
|
||||
destination: destDir,
|
||||
written,
|
||||
exportDerivationRecords: [...this.exportDerivationRecords],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +246,148 @@ class CodexSetup extends BaseIdeSetup {
|
|||
* @param {string} artifactType - Type filter (e.g., 'agent-launcher', 'workflow-command', 'task')
|
||||
* @returns {number} Number of skills written
|
||||
*/
|
||||
async writeSkillArtifacts(destDir, artifacts, artifactType) {
|
||||
isExemplarHelpTaskArtifact(artifact = {}) {
|
||||
if (artifact.type !== 'task' || artifact.module !== 'core') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedName = String(artifact.name || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const normalizedRelativePath = String(artifact.relativePath || '')
|
||||
.trim()
|
||||
.replaceAll('\\', '/')
|
||||
.toLowerCase();
|
||||
const normalizedSourcePath = String(artifact.sourcePath || '')
|
||||
.trim()
|
||||
.replaceAll('\\', '/')
|
||||
.toLowerCase();
|
||||
|
||||
if (normalizedName !== 'help') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizedRelativePath.endsWith('/core/tasks/help.md') || normalizedSourcePath.endsWith('/core/tasks/help.md');
|
||||
}
|
||||
|
||||
throwExportDerivationError({ code, detail, fieldPath, sourcePath, observedValue, cause = null }) {
|
||||
throw new CodexExportDerivationError({
|
||||
code,
|
||||
detail,
|
||||
fieldPath,
|
||||
sourcePath,
|
||||
observedValue,
|
||||
cause,
|
||||
});
|
||||
}
|
||||
|
||||
async loadExemplarHelpSidecar(projectDir) {
|
||||
for (const candidate of EXEMPLAR_SIDECAR_SOURCE_CANDIDATES) {
|
||||
const sidecarPath = path.join(projectDir, ...candidate.segments);
|
||||
if (await fs.pathExists(sidecarPath)) {
|
||||
let sidecarData;
|
||||
try {
|
||||
sidecarData = yaml.parse(await fs.readFile(sidecarPath, 'utf8'));
|
||||
} catch (error) {
|
||||
this.throwExportDerivationError({
|
||||
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
|
||||
detail: `YAML parse failure: ${error.message}`,
|
||||
fieldPath: '<document>',
|
||||
sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
|
||||
observedValue: '<parse-error>',
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
|
||||
this.throwExportDerivationError({
|
||||
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
|
||||
detail: 'sidecar root must be a YAML mapping object',
|
||||
fieldPath: '<document>',
|
||||
sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
|
||||
observedValue: typeof sidecarData,
|
||||
});
|
||||
}
|
||||
|
||||
const canonicalId = String(sidecarData.canonicalId || '').trim();
|
||||
if (canonicalId.length === 0) {
|
||||
this.throwExportDerivationError({
|
||||
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_MISSING,
|
||||
detail: 'sidecar canonicalId is required for exemplar export derivation',
|
||||
fieldPath: 'canonicalId',
|
||||
sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
|
||||
observedValue: canonicalId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
canonicalId,
|
||||
sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.throwExportDerivationError({
|
||||
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
|
||||
detail: 'expected exemplar sidecar metadata file was not found',
|
||||
fieldPath: '<file>',
|
||||
sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
|
||||
observedValue: projectDir,
|
||||
});
|
||||
}
|
||||
|
||||
async resolveSkillIdentityFromArtifact(artifact, projectDir) {
|
||||
const inferredSkillName = toDashPath(artifact.relativePath).replace(/\.md$/, '');
|
||||
const isExemplarHelpTask = this.isExemplarHelpTaskArtifact(artifact);
|
||||
if (!isExemplarHelpTask) {
|
||||
return {
|
||||
skillName: inferredSkillName,
|
||||
canonicalId: inferredSkillName,
|
||||
exportIdDerivationSourceType: 'path-derived',
|
||||
exportIdDerivationSourcePath: String(artifact.relativePath || ''),
|
||||
};
|
||||
}
|
||||
|
||||
const sidecarData = await this.loadExemplarHelpSidecar(projectDir);
|
||||
|
||||
let canonicalResolution;
|
||||
try {
|
||||
canonicalResolution = await normalizeAndResolveExemplarAlias(sidecarData.canonicalId, {
|
||||
fieldPath: 'canonicalId',
|
||||
sourcePath: sidecarData.sourcePath,
|
||||
});
|
||||
} catch (error) {
|
||||
this.throwExportDerivationError({
|
||||
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED,
|
||||
detail: `failed to derive exemplar export id from sidecar canonicalId (${error.code || error.message})`,
|
||||
fieldPath: 'canonicalId',
|
||||
sourcePath: sidecarData.sourcePath,
|
||||
observedValue: sidecarData.canonicalId,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
const skillName = String(canonicalResolution.postAliasCanonicalId || '').trim();
|
||||
if (skillName.length === 0) {
|
||||
this.throwExportDerivationError({
|
||||
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED,
|
||||
detail: 'resolved canonical export id is empty',
|
||||
fieldPath: 'canonicalId',
|
||||
sourcePath: sidecarData.sourcePath,
|
||||
observedValue: sidecarData.canonicalId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
skillName,
|
||||
canonicalId: skillName,
|
||||
exportIdDerivationSourceType: EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE,
|
||||
exportIdDerivationSourcePath: sidecarData.sourcePath,
|
||||
exportIdDerivationEvidence: `applied:${canonicalResolution.preAliasNormalizedValue}|leadingSlash:${canonicalResolution.rawIdentityHasLeadingSlash}->${canonicalResolution.postAliasCanonicalId}|rows:${canonicalResolution.aliasRowLocator}`,
|
||||
};
|
||||
}
|
||||
|
||||
async writeSkillArtifacts(destDir, artifacts, artifactType, options = {}) {
|
||||
let writtenCount = 0;
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
|
|
@ -217,8 +397,8 @@ class CodexSetup extends BaseIdeSetup {
|
|||
}
|
||||
|
||||
// Get the dash-format name (e.g., bmad-bmm-create-prd.md) and remove .md
|
||||
const flatName = toDashPath(artifact.relativePath);
|
||||
const skillName = flatName.replace(/\.md$/, '');
|
||||
const exportIdentity = await this.resolveSkillIdentityFromArtifact(artifact, options.projectDir || process.cwd());
|
||||
const skillName = exportIdentity.skillName;
|
||||
|
||||
// Create skill directory
|
||||
const skillDir = path.join(destDir, skillName);
|
||||
|
|
@ -229,8 +409,26 @@ class CodexSetup extends BaseIdeSetup {
|
|||
|
||||
// Write SKILL.md with platform-native line endings
|
||||
const platformContent = skillContent.replaceAll('\n', os.EOL);
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), platformContent, 'utf8');
|
||||
const skillPath = path.join(skillDir, 'SKILL.md');
|
||||
await fs.writeFile(skillPath, platformContent, 'utf8');
|
||||
writtenCount++;
|
||||
|
||||
if (exportIdentity.exportIdDerivationSourceType === EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE) {
|
||||
this.exportDerivationRecords.push({
|
||||
exportPath: path.join('.agents', 'skills', skillName, 'SKILL.md').replaceAll('\\', '/'),
|
||||
sourcePath: EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH,
|
||||
canonicalId: exportIdentity.canonicalId,
|
||||
visibleId: skillName,
|
||||
visibleSurfaceClass: 'export-id',
|
||||
authoritySourceType: 'sidecar',
|
||||
authoritySourcePath: exportIdentity.exportIdDerivationSourcePath,
|
||||
exportIdDerivationSourceType: exportIdentity.exportIdDerivationSourceType,
|
||||
exportIdDerivationSourcePath: exportIdentity.exportIdDerivationSourcePath,
|
||||
issuingComponent: 'bmad-fork/tools/cli/installers/lib/ide/codex.js',
|
||||
issuingComponentBindingEvidence: exportIdentity.exportIdDerivationEvidence || '',
|
||||
generatedSkillPath: skillPath.replaceAll('\\', '/'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return writtenCount;
|
||||
|
|
@ -437,4 +635,9 @@ class CodexSetup extends BaseIdeSetup {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = { CodexSetup };
|
||||
module.exports = {
|
||||
CodexSetup,
|
||||
CODEX_EXPORT_DERIVATION_ERROR_CODES,
|
||||
CodexExportDerivationError,
|
||||
EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ const { BMAD_FOLDER_NAME, toDashPath } = require('./shared/path-utils');
|
|||
const fs = require('fs-extra');
|
||||
const csv = require('csv-parse/sync');
|
||||
const yaml = require('yaml');
|
||||
const {
|
||||
ProjectionCompatibilityError,
|
||||
validateHelpCatalogLoaderEntries,
|
||||
validateGithubCopilotHelpLoaderEntries,
|
||||
} = require('../core/projection-compatibility-validator');
|
||||
|
||||
/**
|
||||
* GitHub Copilot setup handler
|
||||
|
|
@ -131,12 +136,20 @@ class GitHubCopilotSetup extends BaseIdeSetup {
|
|||
|
||||
try {
|
||||
const csvContent = await fs.readFile(helpPath, 'utf8');
|
||||
return csv.parse(csvContent, {
|
||||
const rows = csv.parse(csvContent, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
});
|
||||
} catch {
|
||||
const sourcePath = `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/bmad-help.csv`;
|
||||
validateHelpCatalogLoaderEntries(rows, { sourcePath });
|
||||
validateGithubCopilotHelpLoaderEntries(rows, { sourcePath });
|
||||
return rows;
|
||||
} catch (error) {
|
||||
// Gracefully degrade if help CSV is unreadable/malformed
|
||||
// but fail-fast on deterministic compatibility contract violations.
|
||||
if (error instanceof ProjectionCompatibilityError) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const path = require('node:path');
|
|||
const fs = require('fs-extra');
|
||||
const csv = require('csv-parse/sync');
|
||||
const { toColonName, toColonPath, toDashPath, BMAD_FOLDER_NAME } = require('./path-utils');
|
||||
const { validateTaskManifestLoaderEntries } = require('../../core/projection-compatibility-validator');
|
||||
|
||||
/**
|
||||
* Generates command files for standalone tasks and tools
|
||||
|
|
@ -197,10 +198,14 @@ Follow all instructions in the ${type} file exactly as written.
|
|||
}
|
||||
|
||||
const csvContent = await fs.readFile(manifestPath, 'utf8');
|
||||
return csv.parse(csvContent, {
|
||||
const records = csv.parse(csvContent, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
});
|
||||
validateTaskManifestLoaderEntries(records, {
|
||||
sourcePath: `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/task-manifest.csv`,
|
||||
});
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue