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:
rikrdo
2026-08-14 21:46:54 +02:00
commit 1d4eebca54
76 changed files with 9430 additions and 0 deletions

View 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();

View File

@@ -0,0 +1,49 @@
import { execFile } from 'node:child_process';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const here = path.dirname(fileURLToPath(import.meta.url));
const scriptPath = path.resolve(here, '..', 'check-module-boundaries.mjs');
const fixturesDir = path.resolve(here, 'fixtures');
function runChecker(rootDir: string): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
execFile(
process.execPath,
[scriptPath, rootDir],
{ encoding: 'utf8' },
(error, stdout, stderr) => {
const code = error !== null && 'code' in error ? (error.code as number) : 0;
resolve({ code, output: `${stdout}\n${stderr}` });
},
);
});
}
describe('check-module-boundaries', () => {
it('passes on a clean tree', async () => {
const { code, output } = await runChecker(path.join(fixturesDir, 'ok'));
expect(output).toContain('Boundary check OK');
expect(code).toBe(0);
});
it('fails when a module imports another module internal file (R1)', async () => {
const { code, output } = await runChecker(path.join(fixturesDir, 'cross-module-internal'));
expect(output).toContain('R1 violation');
expect(code).toBe(1);
});
it('fails when outside code deep-imports a module internal file (R2)', async () => {
const { code, output } = await runChecker(path.join(fixturesDir, 'deep-from-app'));
expect(output).toContain('R2 violation');
expect(code).toBe(1);
});
it('allows outside code to import a module index (R2 ok)', async () => {
const { code, output } = await runChecker(path.join(fixturesDir, 'ok'));
expect(output).toContain('Boundary check OK');
expect(code).toBe(0);
});
});

View File

@@ -0,0 +1,2 @@
import { secret } from '../../beta/domain/secret.js';
export const routes = [secret];

View File

@@ -0,0 +1 @@
export const secret = 'secret';

View File

@@ -0,0 +1,2 @@
import { routes } from '../modules/alpha/api/routes.js';
export const main = [routes];

View File

@@ -0,0 +1 @@
export const routes = 'routes';

View File

@@ -0,0 +1,3 @@
import { alpha } from '../modules/alpha/index.js';
import { beta } from '../modules/beta/index.js';
export const main = [alpha, beta];

View File

@@ -0,0 +1,3 @@
import { alpha } from '../index.js';
import { sharedUtil } from '../../../shared/util.js';
export const routes = [alpha, sharedUtil];

View File

@@ -0,0 +1 @@
export const alpha = 'alpha';

View File

@@ -0,0 +1 @@
export const beta = 'beta';

View File

@@ -0,0 +1 @@
export const sharedUtil = 'shared';