feat(F-001): scaffold modular monolith skeleton with boundary checker
- TypeScript + Fastify skeleton under project/ (src/modules, shared, infrastructure, app) - scripts/check-module-boundaries.mjs enforcing module public-API rules (tested with fixtures) - GET /health endpoint, error envelope without stack leakage - specs/F-001-scaffold (SPEC/DESIGN/TASKS/TESTS), spec/tech.md dependency justification - 30-ticket MercadoDeVida roadmap in backlog/features.json, spec/roadmap.md - All gates approved: reviewer, security, qa; verify.sh green
This commit is contained in:
112
project/scripts/check-module-boundaries.mjs
Normal file
112
project/scripts/check-module-boundaries.mjs
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Module boundary checker.
|
||||
*
|
||||
* Rules (see specs/F-001-scaffold/DESIGN.md):
|
||||
* R1: Files inside <root>/modules/<mod>/ may only import their own module
|
||||
* subtree, <root>/shared/, Node builtins, or npm packages.
|
||||
* R2: Files outside modules may import a module only through its index.ts.
|
||||
* Deep imports into <root>/modules/<mod>/... are violations.
|
||||
*
|
||||
* Usage: node scripts/check-module-boundaries.mjs <srcRoot>
|
||||
* 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 <srcRoot>');
|
||||
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();
|
||||
Reference in New Issue
Block a user