Skip to main content
Glama
ArchitectParser.ts8.9 kB
/** * ArchitectParser * * DESIGN PATTERNS: * - Service pattern for business logic encapsulation * - Single responsibility principle * - Caching for performance optimization * * CODING STANDARDS: * - Use async/await for asynchronous operations * - Throw descriptive errors for error cases * - Keep methods focused and well-named * - Document complex logic with comments * * AVOID: * - Mixing concerns (keep focused on single domain) * - Direct tool implementation (services should be tool-agnostic) */ import * as fs from 'node:fs/promises'; import * as yaml from 'js-yaml'; import * as path from 'node:path'; import type { ArchitectConfig, Feature } from '../../types'; import { TemplatesManagerService } from '@agiflowai/aicode-utils'; import { ARCHITECT_FILENAMES, ARCHITECT_FILENAME, ARCHITECT_FILENAME_HIDDEN, MAX_ARCHITECT_FILE_SIZE, } from '../../constants'; import { architectConfigSchema } from '../../schemas'; import { ParseArchitectError, InvalidConfigError } from '../../utils/errors'; export class ArchitectParser { private configCache: Map<string, ArchitectConfig> = new Map(); private workspaceRoot: string; constructor(workspaceRoot?: string) { this.workspaceRoot = workspaceRoot || process.cwd(); } /** * Find the architect file in a directory, checking both .architect.yaml and architect.yaml * Returns the full path if found, null otherwise */ async findArchitectFile(dirPath: string): Promise<string | null> { for (const filename of ARCHITECT_FILENAMES) { const filePath = path.join(dirPath, filename); try { await fs.access(filePath); return filePath; } catch { // File doesn't exist, try next } } return null; } /** * Parse architect.yaml or .architect.yaml from a template directory */ async parseArchitectFile(templatePath: string): Promise<ArchitectConfig | null> { let architectPath: string; // Check if templatePath is already a full file path to an architect file if ( templatePath.endsWith(ARCHITECT_FILENAME_HIDDEN) || templatePath.endsWith(ARCHITECT_FILENAME) ) { architectPath = templatePath; } else { // Check if templatePath is an existing file (for arbitrary YAML validation) try { const stat = await fs.stat(templatePath); if (stat.isFile()) { architectPath = templatePath; } else { // It's a directory, find the architect file in it const foundPath = await this.findArchitectFile(templatePath); if (!foundPath) { return null; } architectPath = foundPath; } } catch { // Path doesn't exist or can't be accessed, try as directory const foundPath = await this.findArchitectFile(templatePath); if (!foundPath) { return null; } architectPath = foundPath; } } // Check cache first if (this.configCache.has(architectPath)) { return this.configCache.get(architectPath)!; } try { // Read file as buffer first to check size atomically (prevents TOCTOU race condition) const buffer = await fs.readFile(architectPath); // Validate file size to prevent DoS if (buffer.length > MAX_ARCHITECT_FILE_SIZE) { throw new ParseArchitectError( `File size (${buffer.length} bytes) exceeds maximum allowed size (${MAX_ARCHITECT_FILE_SIZE} bytes)`, ); } const content = buffer.toString('utf-8'); // Handle empty file if (!content || content.trim() === '') { const emptyConfig: ArchitectConfig = { features: [] }; this.configCache.set(architectPath, emptyConfig); return emptyConfig; } let rawConfig: unknown; try { rawConfig = yaml.load(content); } catch (yamlError) { throw new ParseArchitectError(String(yamlError)); } // Validate and parse using Zod schema const parseResult = architectConfigSchema.safeParse(rawConfig || {}); if (!parseResult.success) { const issues = parseResult.error.issues.map((issue) => ({ path: issue.path, message: issue.message, code: issue.code, })); const errorMessages = issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join(', '); throw new InvalidConfigError(errorMessages, issues); } const validatedConfig: ArchitectConfig = parseResult.data; // Cache the result this.configCache.set(architectPath, validatedConfig); return validatedConfig; } catch (error) { // Re-throw our custom errors if (error instanceof ParseArchitectError || error instanceof InvalidConfigError) { throw error; } throw new ParseArchitectError(String(error)); } } /** * Parse the global architect.yaml or .architect.yaml * @param globalPath - Optional path to global architect file * @returns Parsed config or null if not found */ async parseGlobalArchitectFile(globalPath?: string): Promise<ArchitectConfig | null> { // Default to the architect file in the templates directory let resolvedPath: string | null; if (globalPath) { resolvedPath = globalPath; } else { const templatesRoot = await TemplatesManagerService.findTemplatesPath(this.workspaceRoot); if (!templatesRoot) { return null; } resolvedPath = await this.findArchitectFile(templatesRoot); if (!resolvedPath) { return null; } } // Check cache first if (this.configCache.has(resolvedPath)) { return this.configCache.get(resolvedPath)!; } try { const content = await fs.readFile(resolvedPath, 'utf-8'); const rawConfig: unknown = yaml.load(content); // Try standard architect.yaml format first (with features array) const parseResult = architectConfigSchema.safeParse(rawConfig); if (parseResult.success && parseResult.data.features.length > 0) { this.configCache.set(resolvedPath, parseResult.data); return parseResult.data; } // Legacy format: global architect.yaml with prompts/examples structure // This format extracts patterns from a prompts array for backward compatibility const features: Feature[] = []; if ( rawConfig && typeof rawConfig === 'object' && 'prompts' in rawConfig && Array.isArray((rawConfig as Record<string, unknown>).prompts) ) { const prompts = (rawConfig as Record<string, unknown>).prompts as unknown[]; for (const prompt of prompts) { if (prompt && typeof prompt === 'object' && 'examples' in prompt) { const examples = (prompt as Record<string, unknown>).examples; if (Array.isArray(examples)) { for (const example of examples) { if (example && typeof example === 'object' && 'pattern' in example) { const ex = example as Record<string, unknown>; features.push({ name: String(ex.pattern || ''), design_pattern: String(ex.pattern || ''), includes: Array.isArray(ex.files) ? (ex.files as string[]) : [], description: String( ex.description || (prompt as Record<string, unknown>).description || '', ), }); } } } } } } const globalConfig: ArchitectConfig = { features }; this.configCache.set(resolvedPath, globalConfig); return globalConfig; } catch { // Global architect.yaml is optional, return null if file read fails return null; } } /** * Parse architect.yaml or .architect.yaml from a project directory * (same directory as project.json) */ async parseProjectArchitectFile(projectPath: string): Promise<ArchitectConfig | null> { return this.parseArchitectFile(projectPath); } /** * Merge multiple architect configs (template-specific and global) */ mergeConfigs(...configs: (ArchitectConfig | null)[]): ArchitectConfig { const mergedFeatures: Feature[] = []; const seenNames = new Set<string>(); for (const config of configs) { if (!config || !config.features) continue; for (const feature of config.features) { // Avoid duplicates based on name or architecture const featureName = feature.name || feature.architecture || 'unnamed'; if (!seenNames.has(featureName)) { mergedFeatures.push(feature); seenNames.add(featureName); } } } return { features: mergedFeatures }; } /** * Clear the config cache */ clearCache(): void { this.configCache.clear(); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/AgiFlow/aicode-toolkit'

If you have feedback or need assistance with the MCP directory API, please join our Discord server