aiwg
Version:
Cognitive architecture for AI-augmented software development with structured memory, ensemble validation, and closed-loop correction. FAIR-aligned artifacts, 84% cost reduction via human-in-the-loop, standards adopted by 100+ organizations.
473 lines (420 loc) • 15.2 kB
JavaScript
/**
* @fileoverview MetadataLoader - Extract and validate YAML frontmatter from markdown files
*
* Responsibilities:
* - Extract YAML frontmatter from markdown files (.claude/commands/*.md, .claude/agents/*.md)
* - Validate metadata schema (required: framework, optional: output-path, context-paths, version)
* - Safe YAML parsing (prevent code injection, use yaml safe-load)
* - Cache metadata to avoid repeated file reads
* - Batch loading for 103 files (45 commands + 58 agents)
*
* Component: FID-007 - Framework-Scoped Workspace Management
* Implementation Plan: W2-T4 (5 hours)
* Spec: .aiwg/working/FID-007-implementation-plan.md (Section 4.1, lines 255-303)
* Use Case: UC-012 (Section 11.2-11.3 - Metadata Format)
* ADR: ADR-007 (Framework-Scoped Workspace Architecture)
*
* @module tools/workspace/metadata-loader
* @requires yaml
* @requires fs/promises
* @requires path
*/
import { readFile, stat } from 'fs/promises';
import { resolve, dirname } from 'path';
import YAML from 'yaml';
/**
* Custom error class for metadata not found
*/
export class MetadataNotFoundError extends Error {
constructor(filePath, message = 'No YAML frontmatter found') {
super(`${message}: ${filePath}`);
this.name = 'MetadataNotFoundError';
this.filePath = filePath;
}
}
/**
* Custom error class for invalid metadata
*/
export class InvalidMetadataError extends Error {
constructor(filePath, validationErrors, message = 'Metadata validation failed') {
super(`${message}: ${filePath}\n Errors: ${validationErrors.join(', ')}`);
this.name = 'InvalidMetadataError';
this.filePath = filePath;
this.validationErrors = validationErrors;
}
}
/**
* Custom error class for YAML parse errors
*/
export class YAMLParseError extends Error {
constructor(filePath, originalError, message = 'YAML parsing failed') {
super(`${message}: ${filePath}\n ${originalError.message}`);
this.name = 'YAMLParseError';
this.filePath = filePath;
this.originalError = originalError;
}
}
/**
* MetadataLoader - Extract and validate YAML frontmatter from markdown files
*
* @example
* const loader = new MetadataLoader();
*
* // Load single file
* const metadata = await loader.loadFromFile('.claude/commands/flow-inception-to-elaboration.md');
* console.log(metadata.framework); // 'sdlc-complete'
*
* // Load batch
* const allMetadata = await loader.loadBatch([
* '.claude/commands/flow-inception-to-elaboration.md',
* '.claude/commands/flow-security-review-cycle.md'
* ]);
*
* // Load by ID
* const cmdMeta = await loader.loadCommandMetadata('flow-inception-to-elaboration');
* const agentMeta = await loader.loadAgentMetadata('architecture-designer');
*/
export class MetadataLoader {
/**
* Create a new MetadataLoader instance
*
* @param {boolean} [cacheEnabled=true] - Enable in-memory caching of metadata
* @param {string} [defaultFramework='sdlc-complete'] - Default framework if metadata missing
*/
constructor(cacheEnabled = true, defaultFramework = 'sdlc-complete') {
/** @type {boolean} */
this.cacheEnabled = cacheEnabled;
/** @type {string} */
this.defaultFramework = defaultFramework;
/** @type {Map<string, {metadata: object, mtime: Date}>} */
this.cache = new Map();
/** @type {RegExp} */
this.frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---/;
}
// ==========================================================================
// Load Operations
// ==========================================================================
/**
* Load metadata from a single file
*
* @param {string} filePath - Absolute path to markdown file
* @returns {Promise<object>} Parsed and validated metadata
* @throws {MetadataNotFoundError} If no frontmatter found
* @throws {YAMLParseError} If YAML is malformed
* @throws {InvalidMetadataError} If metadata fails validation
*
* @example
* const metadata = await loader.loadFromFile('.claude/commands/flow-inception.md');
* // Returns: { framework: 'sdlc-complete', 'output-path': '...', ... }
*/
async loadFromFile(filePath) {
const absolutePath = resolve(filePath);
// Check cache first
if (this.cacheEnabled) {
const cached = await this.getCached(absolutePath);
if (cached) {
return cached;
}
}
// Read file
const content = await readFile(absolutePath, 'utf-8');
// Extract frontmatter
const frontmatter = this.extractFrontmatter(content);
if (!frontmatter) {
throw new MetadataNotFoundError(absolutePath);
}
// Parse YAML safely
const metadata = this.parseYAML(frontmatter, absolutePath);
// Normalize field names (kebab-case to camelCase for internal use)
const normalized = this.normalizeMetadata(metadata);
// Validate schema
this.validateMetadata(normalized, absolutePath);
// Cache result
if (this.cacheEnabled) {
const stats = await stat(absolutePath);
this.cache.set(absolutePath, {
metadata: normalized,
mtime: stats.mtime
});
}
return normalized;
}
/**
* Load metadata from multiple files in parallel
*
* @param {string[]} filePaths - Array of absolute file paths
* @returns {Promise<Map<string, object>>} Map of filePath → metadata
*
* @example
* const results = await loader.loadBatch([
* '.claude/commands/cmd1.md',
* '.claude/commands/cmd2.md'
* ]);
* console.log(results.get('.claude/commands/cmd1.md').framework);
*/
async loadBatch(filePaths) {
const promises = filePaths.map(async (path) => {
try {
const metadata = await this.loadFromFile(path);
return [path, metadata];
} catch (error) {
console.warn(`⚠️ Failed to load metadata from ${path}: ${error.message}`);
return [path, null];
}
});
const results = await Promise.all(promises);
return new Map(results.filter(([, metadata]) => metadata !== null));
}
/**
* Load command metadata by command ID
*
* @param {string} commandId - Command identifier (e.g., 'flow-inception-to-elaboration')
* @param {string} [commandsDir='.claude/commands'] - Commands directory
* @returns {Promise<object>} Command metadata
*
* @example
* const metadata = await loader.loadCommandMetadata('flow-inception-to-elaboration');
*/
async loadCommandMetadata(commandId, commandsDir = '.claude/commands') {
const filePath = resolve(commandsDir, `${commandId}.md`);
return this.loadFromFile(filePath);
}
/**
* Load agent metadata by agent ID
*
* @param {string} agentId - Agent identifier (e.g., 'architecture-designer')
* @param {string} [agentsDir='.claude/agents'] - Agents directory
* @returns {Promise<object>} Agent metadata
*
* @example
* const metadata = await loader.loadAgentMetadata('architecture-designer');
*/
async loadAgentMetadata(agentId, agentsDir = '.claude/agents') {
const filePath = resolve(agentsDir, `${agentId}.md`);
return this.loadFromFile(filePath);
}
// ==========================================================================
// Validation
// ==========================================================================
/**
* Validate metadata against schema
*
* @param {object} metadata - Metadata object to validate
* @param {string} filePath - File path (for error messages)
* @throws {InvalidMetadataError} If validation fails
*
* Required fields: framework
* Optional fields: frameworkVersion, outputPath, contextPaths, version
*/
validateMetadata(metadata, filePath) {
const errors = [];
// Check required fields
if (!this.hasRequiredFields(metadata)) {
// Framework property missing - use default with warning
console.warn(`⚠️ Framework property missing in ${filePath}, defaulting to '${this.defaultFramework}'`);
metadata.framework = this.defaultFramework;
}
// Validate framework format (kebab-case)
if (metadata.framework && !/^[a-z0-9-]+$/.test(metadata.framework)) {
errors.push(`Invalid framework ID format: '${metadata.framework}' (must be kebab-case: lowercase letters, numbers, hyphens)`);
}
// Validate frameworkVersion format (semver-ish: X.Y)
if (metadata.frameworkVersion && !/^\d+\.\d+$/.test(metadata.frameworkVersion)) {
errors.push(`Invalid framework-version format: '${metadata.frameworkVersion}' (must be X.Y format, e.g., '1.0')`);
}
// Validate contextPaths is array if present
if (metadata.contextPaths && !Array.isArray(metadata.contextPaths)) {
errors.push(`context-paths must be an array, got ${typeof metadata.contextPaths}`);
}
// Validate outputPath is string if present
if (metadata.outputPath && typeof metadata.outputPath !== 'string') {
errors.push(`output-path must be a string, got ${typeof metadata.outputPath}`);
}
if (errors.length > 0) {
throw new InvalidMetadataError(filePath, errors);
}
}
/**
* Check if metadata has all required fields
*
* @param {object} metadata - Metadata object
* @returns {boolean} True if all required fields present
*/
hasRequiredFields(metadata) {
return metadata && typeof metadata.framework === 'string';
}
// ==========================================================================
// Cache Operations
// ==========================================================================
/**
* Get metadata from cache if valid
*
* @param {string} filePath - Absolute file path
* @returns {Promise<object|null>} Cached metadata or null if cache miss/stale
*/
async getCached(filePath) {
if (!this.cache.has(filePath)) {
return null;
}
// Check if file has been modified since cache
try {
const stats = await stat(filePath);
const cached = this.cache.get(filePath);
if (stats.mtime.getTime() === cached.mtime.getTime()) {
return cached.metadata;
}
// File modified, invalidate cache
this.cache.delete(filePath);
return null;
} catch (error) {
// File doesn't exist or stat failed, invalidate cache
this.cache.delete(filePath);
return null;
}
}
/**
* Clear all cached metadata
*/
clearCache() {
this.cache.clear();
}
/**
* Invalidate cache for a specific file
*
* @param {string} filePath - Absolute file path
*/
invalidateCache(filePath) {
const absolutePath = resolve(filePath);
this.cache.delete(absolutePath);
}
// ==========================================================================
// Utilities
// ==========================================================================
/**
* Extract YAML frontmatter from markdown content
*
* @param {string} content - Markdown file content
* @returns {string|null} YAML string or null if no frontmatter
*
* @example
* const yaml = loader.extractFrontmatter('---\nframework: sdlc\n---\n# Doc');
* // Returns: 'framework: sdlc\n'
*/
extractFrontmatter(content) {
const match = content.match(this.frontmatterRegex);
return match ? match[1] : null;
}
/**
* Parse YAML string safely (prevents code injection)
*
* @param {string} yamlString - YAML content
* @param {string} filePath - File path (for error messages)
* @returns {object} Parsed YAML object
* @throws {YAMLParseError} If parsing fails
*
* Security: Uses YAML.parse with strict mode to prevent arbitrary code execution
* Only allows: strings, numbers, booleans, null, arrays, objects
*/
parseYAML(yamlString, filePath) {
try {
// Use YAML.parse with strict mode - safe by default, prevents code execution
// The yaml library (v2.x) is safe by default and doesn't execute arbitrary code
const parsed = YAML.parse(yamlString, {
strict: true,
uniqueKeys: true,
maxAliasCount: 100 // Prevent billion laughs attack
});
return parsed || {};
} catch (error) {
throw new YAMLParseError(filePath, error);
}
}
/**
* Normalize metadata field names from kebab-case to camelCase
*
* Converts:
* - framework-version → frameworkVersion
* - output-path → outputPath
* - context-paths → contextPaths
* - command-id → commandId
* - agent-id → agentId
*
* Preserves: framework, version (no hyphens)
*
* @param {object} raw - Raw metadata object with kebab-case keys
* @returns {object} Normalized metadata with camelCase keys
*
* @example
* const normalized = loader.normalizeMetadata({
* 'framework': 'sdlc-complete',
* 'framework-version': '1.0',
* 'output-path': 'frameworks/...'
* });
* // Returns: { framework: 'sdlc-complete', frameworkVersion: '1.0', outputPath: '...' }
*/
normalizeMetadata(raw) {
const normalized = {};
const keyMap = {
'framework': 'framework',
'framework-version': 'frameworkVersion',
'output-path': 'outputPath',
'context-paths': 'contextPaths',
'command-id': 'commandId',
'agent-id': 'agentId',
'template-id': 'templateId',
'output-base': 'outputBase',
'version': 'version',
'description': 'description',
'name': 'name'
};
for (const [kebabKey, value] of Object.entries(raw)) {
const camelKey = keyMap[kebabKey] || kebabKey;
normalized[camelKey] = value;
}
return normalized;
}
}
// ==========================================================================
// CLI Usage (if run directly)
// ==========================================================================
/**
* CLI usage: node metadata-loader.mjs <file-path>
*
* Loads and displays metadata from a single file
*/
async function main() {
if (process.argv.length < 3) {
console.error('Usage: node metadata-loader.mjs <file-path>');
console.error('');
console.error('Example:');
console.error(' node metadata-loader.mjs .claude/commands/flow-inception-to-elaboration.md');
process.exit(1);
}
const filePath = process.argv[2];
const loader = new MetadataLoader();
try {
const metadata = await loader.loadFromFile(filePath);
console.log('✓ Metadata loaded successfully:');
console.log(JSON.stringify(metadata, null, 2));
} catch (error) {
if (error instanceof MetadataNotFoundError) {
console.error(`✗ ${error.message}`);
console.error(' Make sure the file has YAML frontmatter between --- delimiters');
} else if (error instanceof YAMLParseError) {
console.error(`✗ ${error.message}`);
console.error(' Check YAML syntax (indentation, quotes, etc.)');
} else if (error instanceof InvalidMetadataError) {
console.error(`✗ ${error.message}`);
console.error(' Fix validation errors and try again');
} else {
console.error(`✗ Unexpected error: ${error.message}`);
console.error(error.stack);
}
process.exit(1);
}
}
// Run CLI if executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}