#!/usr/bin/env node /** * Module boundary checker. * * Rules (see specs/F-001-scaffold/DESIGN.md): * R1: Files inside /modules// may only import their own module * subtree, /shared/, Node builtins, or npm packages. * R2: Files outside modules may import a module only through its index.ts. * Deep imports into /modules//... are violations. * * Usage: node scripts/check-module-boundaries.mjs * Exit codes: 0 = clean, 1 = violations found, 2 = usage error. */ import { readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; function isBareSpecifier(specifier) { return !specifier.startsWith('.') && !specifier.startsWith('/'); } function walk(dir, files = []) { for (const entry of readdirSync(dir)) { const full = path.join(dir, entry); const stat = statSync(full); if (stat.isDirectory()) { if (entry === 'node_modules' || entry === 'dist' || entry === 'fixtures') continue; walk(full, files); } else if (entry.endsWith('.ts') && !entry.endsWith('.d.ts')) { files.push(full); } } return files; } function extractSpecifiers(source) { const specifiers = []; const fromRegex = /(?:import|export)\s+[^'"]*?from\s*['"]([^'"]+)['"]/g; const sideEffectRegex = /^\s*import\s*['"]([^'"]+)['"]/gm; const dynamicRegex = /import\(\s*['"]([^'"]+)['"]\s*\)/g; let match; while ((match = fromRegex.exec(source)) !== null) specifiers.push(match[1]); while ((match = sideEffectRegex.exec(source)) !== null) specifiers.push(match[1]); while ((match = dynamicRegex.exec(source)) !== null) specifiers.push(match[1]); return specifiers; } /** Strip a .js/.ts extension so we can compare logical paths. */ function stripExtension(p) { return p.replace(/\.(js|ts|mjs|cjs)$/, ''); } function checkFile(file, rootAbs, violations) { const source = readFileSync(file, 'utf8'); const relFile = path.relative(rootAbs, file); const fileDir = path.dirname(file); const relFileParts = relFile.split(path.sep); const sourceInModule = relFileParts[0] === 'modules' && relFileParts.length >= 2 ? relFileParts[1] : null; for (const specifier of extractSpecifiers(source)) { if (isBareSpecifier(specifier)) continue; // npm package or node builtin const targetAbs = stripExtension(path.resolve(fileDir, specifier)); const relTarget = path.relative(rootAbs, targetAbs); const targetParts = relTarget.split(path.sep); const targetInModule = targetParts[0] === 'modules' && targetParts.length >= 2 ? targetParts[1] : null; if (sourceInModule !== null) { // R1: stay inside own module or go to shared const ownModule = relTarget.startsWith(path.join('modules', sourceInModule) + path.sep); const toShared = targetParts[0] === 'shared'; if (!ownModule && !toShared) { violations.push( `R1 violation: ${relFile} imports "${specifier}" (escapes module "${sourceInModule}")`, ); } } else if (targetInModule !== null) { // R2: outside code may only use a module's public index const isIndex = targetParts.length === 3 && targetParts[2] === 'index'; if (!isIndex) { violations.push( `R2 violation: ${relFile} imports "${specifier}" (deep import into module "${targetInModule}", use its index)`, ); } } } } async function main() { const rootArg = process.argv[2]; if (!rootArg) { console.error('Usage: node scripts/check-module-boundaries.mjs '); process.exit(2); } const rootAbs = path.resolve(rootArg); const files = walk(rootAbs); const violations = []; for (const file of files) { checkFile(file, rootAbs, violations); } if (violations.length > 0) { for (const violation of violations) console.error(violation); console.error(`Boundary check FAILED: ${violations.length} violation(s)`); process.exit(1); } console.log(`Boundary check OK: ${files.length} file(s) checked`); } await main();