// lib/validators/architecture-discovery.mjs // EXTERNAL CONTRACT — drift-WARN, never drift-FAIL. // // The architecture/ directory is owned by the separate `ultra-cc-architect` // plugin. ultraplan-local validates only DISCOVERY (file present at canonical // path) and tolerates internal-format drift via warnings. // // Never read body content beyond first heading. Never assert frontmatter shape. import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { issue } from '../util/result.mjs'; const CANONICAL_OVERVIEW = 'overview.md'; const CANONICAL_GAPS = 'gaps.md'; const KNOWN_ALTERNATIVES = ['architecture-overview.md', 'overview.markdown', 'README.md']; export function discoverArchitecture(projectDir) { const archDir = projectDir ? join(projectDir, 'architecture') : null; const result = { found: false, overview: null, gaps: null, looseFiles: [], warnings: [], }; if (!archDir || !existsSync(archDir) || !statSync(archDir).isDirectory()) { return result; } const overviewPath = join(archDir, CANONICAL_OVERVIEW); if (existsSync(overviewPath) && statSync(overviewPath).isFile()) { result.found = true; result.overview = overviewPath; } else { for (const alt of KNOWN_ALTERNATIVES) { const altPath = join(archDir, alt); if (existsSync(altPath) && statSync(altPath).isFile()) { result.found = true; result.overview = altPath; result.warnings.push(issue( 'ARCH_NON_CANONICAL_OVERVIEW', `Architecture file at non-canonical path: ${alt}`, `Canonical contract is architecture/overview.md. The ultra-cc-architect plugin may have drifted; this is a warning, not a blocker.`, )); break; } } } const gapsPath = join(archDir, CANONICAL_GAPS); if (existsSync(gapsPath) && statSync(gapsPath).isFile()) result.gaps = gapsPath; const all = readdirSync(archDir).filter(f => /\.md$/i.test(f)); result.looseFiles = all .filter(f => f !== CANONICAL_OVERVIEW && f !== CANONICAL_GAPS && !KNOWN_ALTERNATIVES.includes(f)) .map(f => join(archDir, f)); if (result.looseFiles.length > 0) { result.warnings.push(issue( 'ARCH_LOOSE_FILES', `Found ${result.looseFiles.length} unrecognized architecture file(s)`, `Architecture contract expects overview.md (+ optional gaps.md). Loose files may indicate format drift in ultra-cc-architect.`, )); } if (result.found && result.overview) { try { const text = readFileSync(result.overview, 'utf-8'); const firstHeading = text.match(/^#\s+(.+?)\s*$/m); result.firstHeading = firstHeading ? firstHeading[1] : null; } catch { /* ignore — only sniff */ } } return result; } if (import.meta.url === `file://${process.argv[1]}`) { const projectDir = process.argv[2]; const wantJson = process.argv.includes('--json'); if (!projectDir) { process.stderr.write('Usage: architecture-discovery.mjs [--json]\n'); process.exit(2); } const r = discoverArchitecture(projectDir); if (wantJson) { process.stdout.write(JSON.stringify(r, null, 2) + '\n'); } else { process.stdout.write(`architecture-discovery: ${r.found ? 'FOUND' : 'NONE'} ${r.overview || projectDir}\n`); for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`); } process.exit(0); }