Gateway 화면을 route 단위로 지연 로딩하고 common runtime import를 실제 소유 leaf export로 분리한다.\n\npackage boundary와 route loading 회귀 테스트로 root barrel 재유입을 차단한다.
233 lines
9.4 KiB
JavaScript
233 lines
9.4 KiB
JavaScript
import fs from 'node:fs/promises';
|
|
import { builtinModules } from 'node:module';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
const zones = [
|
|
{ name: 'common', root: 'packages/common/src', allowed: [] },
|
|
{ name: 'logic', root: 'packages/logic/src', allowed: ['@sammo-ts/common'] },
|
|
{ name: 'infra', root: 'packages/infra/src', allowed: ['@sammo-ts/common', '@sammo-ts/logic'] },
|
|
{
|
|
name: 'game-engine',
|
|
root: 'app/game-engine/src',
|
|
allowed: ['@sammo-ts/common', '@sammo-ts/infra', '@sammo-ts/logic'],
|
|
},
|
|
{
|
|
name: 'game-api',
|
|
root: 'app/game-api/src',
|
|
allowed: ['@sammo-ts/common', '@sammo-ts/infra', '@sammo-ts/logic', '@sammo-ts/game-engine'],
|
|
},
|
|
{
|
|
name: 'gateway-api',
|
|
root: 'app/gateway-api/src',
|
|
allowed: ['@sammo-ts/common', '@sammo-ts/infra', '@sammo-ts/logic', '@sammo-ts/game-engine'],
|
|
},
|
|
{
|
|
name: 'release-controller',
|
|
root: 'app/release-controller/src',
|
|
allowed: ['@sammo-ts/gateway-api', '@sammo-ts/infra'],
|
|
},
|
|
{
|
|
name: 'game-frontend',
|
|
root: 'app/game-frontend/src',
|
|
allowed: ['@sammo-ts/common', '@sammo-ts/logic', '@sammo-ts/game-api', '@sammo-ts/gateway-api'],
|
|
frontend: true,
|
|
},
|
|
{
|
|
name: 'gateway-frontend',
|
|
root: 'app/gateway-frontend/src',
|
|
allowed: ['@sammo-ts/common', '@sammo-ts/game-api', '@sammo-ts/gateway-api'],
|
|
frontend: true,
|
|
},
|
|
];
|
|
|
|
const persistenceModules = new Set([
|
|
'@sammo-ts/infra',
|
|
'@prisma/client',
|
|
'pg',
|
|
'redis',
|
|
'fs',
|
|
'fs/promises',
|
|
'http',
|
|
'https',
|
|
'net',
|
|
'child_process',
|
|
'node:fs',
|
|
'node:fs/promises',
|
|
'node:http',
|
|
'node:https',
|
|
'node:net',
|
|
'node:dgram',
|
|
'node:dns',
|
|
'node:tls',
|
|
'node:child_process',
|
|
'node:worker_threads',
|
|
]);
|
|
|
|
const backendPackages = new Set(['@sammo-ts/game-api', '@sammo-ts/gateway-api']);
|
|
const frontendRuntimeRootBarrels = new Set(['@sammo-ts/common', '@sammo-ts/logic']);
|
|
const frontendRuntimeRootAllowlist = new Map([
|
|
['app/game-frontend/src/workers/battleSimulator.worker.ts', new Set(['@sammo-ts/logic'])],
|
|
]);
|
|
const nodeBuiltins = new Set(builtinModules.flatMap((name) => [name, `node:${name}`]));
|
|
|
|
const isPersistenceModule = (specifier) =>
|
|
persistenceModules.has(specifier) ||
|
|
['@sammo-ts/infra/', '@prisma/client/', 'pg/', 'redis/'].some((prefix) => specifier.startsWith(prefix));
|
|
|
|
const listSources = async (root) => {
|
|
const result = [];
|
|
const visit = async (directory) => {
|
|
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
|
const absolute = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await visit(absolute);
|
|
} else if (/\.(?:ts|tsx|vue)$/.test(entry.name)) {
|
|
result.push(absolute);
|
|
}
|
|
}
|
|
};
|
|
await visit(root);
|
|
return result.sort();
|
|
};
|
|
|
|
const internalPackageName = (specifier) => {
|
|
const match = /^(@sammo-ts\/[^/]+)/.exec(specifier);
|
|
return match?.[1] ?? null;
|
|
};
|
|
|
|
export const extractImports = (source) => {
|
|
const imports = [];
|
|
const fromPattern = /\b(?:import|export)\s+(type\s+)?([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g;
|
|
const sideEffectPattern = /\bimport\s+['"]([^'"]+)['"]/g;
|
|
const dynamicPattern = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
let match;
|
|
while ((match = fromPattern.exec(source))) {
|
|
const clause = match[2].trim();
|
|
const namedElements =
|
|
clause.startsWith('{') && clause.endsWith('}')
|
|
? clause
|
|
.slice(1, -1)
|
|
.split(',')
|
|
.map((element) => element.trim())
|
|
.filter(Boolean)
|
|
: [];
|
|
const typeOnly =
|
|
Boolean(match[1]) ||
|
|
(namedElements.length > 0 && namedElements.every((element) => element.startsWith('type ')));
|
|
imports.push({ specifier: match[3], typeOnly, clause: match[2] });
|
|
}
|
|
while ((match = sideEffectPattern.exec(source))) {
|
|
imports.push({ specifier: match[1], typeOnly: false, clause: '' });
|
|
}
|
|
while ((match = dynamicPattern.exec(source))) {
|
|
imports.push({ specifier: match[1], typeOnly: false, clause: '' });
|
|
}
|
|
return imports;
|
|
};
|
|
|
|
export const checkSource = ({ source, relativePath, zone }) => {
|
|
const violations = [];
|
|
for (const imported of extractImports(source)) {
|
|
const packageName = internalPackageName(imported.specifier);
|
|
if (packageName && packageName !== `@sammo-ts/${zone.name}` && !zone.allowed.includes(packageName)) {
|
|
violations.push(`${relativePath}: ${zone.name} may not depend on ${packageName}`);
|
|
}
|
|
if ((zone.name === 'common' || zone.name === 'logic') && isPersistenceModule(imported.specifier)) {
|
|
violations.push(
|
|
`${relativePath}: ${zone.name} must access I/O through an injected port (${imported.specifier})`
|
|
);
|
|
}
|
|
if (zone.frontend && nodeBuiltins.has(imported.specifier)) {
|
|
violations.push(`${relativePath}: frontend production code may not import ${imported.specifier}`);
|
|
}
|
|
if (zone.frontend && packageName && backendPackages.has(packageName) && !imported.typeOnly) {
|
|
violations.push(`${relativePath}: frontend may reference ${packageName} only through import type`);
|
|
}
|
|
if (
|
|
zone.frontend &&
|
|
frontendRuntimeRootBarrels.has(imported.specifier) &&
|
|
!imported.typeOnly &&
|
|
!frontendRuntimeRootAllowlist.get(relativePath)?.has(imported.specifier)
|
|
) {
|
|
violations.push(`${relativePath}: frontend runtime must import a leaf subpath from ${imported.specifier}`);
|
|
}
|
|
if (
|
|
(zone.name === 'game-api' || zone.name === 'gateway-api') &&
|
|
imported.specifier === '@sammo-ts/game-engine'
|
|
) {
|
|
violations.push(
|
|
`${relativePath}: import a side-effect-free @sammo-ts/game-engine subpath instead of its process entrypoint`
|
|
);
|
|
}
|
|
if (imported.specifier === '@sammo-ts/infra' && /\b(?:LogCategory|LogScope)\b/.test(imported.clause)) {
|
|
violations.push(`${relativePath}: LogCategory and LogScope are owned by @sammo-ts/logic`);
|
|
}
|
|
}
|
|
if ((zone.name === 'common' || zone.name === 'logic') && /\bprocess\.(?:env|stdout|stderr)\b/.test(source)) {
|
|
violations.push(`${relativePath}: ${zone.name} runtime diagnostics must use an injected port`);
|
|
}
|
|
if ((zone.name === 'common' || zone.name === 'logic') && /\bfetch\s*\(/.test(source)) {
|
|
violations.push(`${relativePath}: ${zone.name} network access must use an injected port`);
|
|
}
|
|
return violations;
|
|
};
|
|
|
|
export const checkWorkspace = async () => {
|
|
const violations = [];
|
|
for (const zone of zones) {
|
|
const absoluteRoot = path.join(workspaceRoot, zone.root);
|
|
const packageRoot = path.dirname(absoluteRoot);
|
|
const manifest = JSON.parse(await fs.readFile(path.join(packageRoot, 'package.json'), 'utf8'));
|
|
const importedPackages = new Map();
|
|
for (const filename of await listSources(absoluteRoot)) {
|
|
const source = await fs.readFile(filename, 'utf8');
|
|
const relativePath = path.relative(workspaceRoot, filename);
|
|
violations.push(...checkSource({ source, relativePath, zone }));
|
|
for (const imported of extractImports(source)) {
|
|
const packageName = internalPackageName(imported.specifier);
|
|
if (!packageName || packageName === manifest.name) {
|
|
continue;
|
|
}
|
|
const usage = importedPackages.get(packageName) ?? { runtime: false, typeOnly: false };
|
|
usage.typeOnly ||= imported.typeOnly;
|
|
usage.runtime ||= !imported.typeOnly;
|
|
importedPackages.set(packageName, usage);
|
|
}
|
|
}
|
|
for (const [packageName, usage] of importedPackages) {
|
|
const inDependencies = Object.hasOwn(manifest.dependencies ?? {}, packageName);
|
|
const inDevDependencies = Object.hasOwn(manifest.devDependencies ?? {}, packageName);
|
|
if (!inDependencies && !inDevDependencies) {
|
|
violations.push(`${path.relative(workspaceRoot, packageRoot)}/package.json: missing ${packageName}`);
|
|
}
|
|
if (usage.runtime && !inDependencies) {
|
|
violations.push(
|
|
`${path.relative(workspaceRoot, packageRoot)}/package.json: runtime dependency ${packageName} must be in dependencies`
|
|
);
|
|
}
|
|
if (zone.frontend && backendPackages.has(packageName) && inDependencies) {
|
|
violations.push(
|
|
`${path.relative(workspaceRoot, packageRoot)}/package.json: type-only backend ${packageName} belongs in devDependencies`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return violations;
|
|
};
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
const violations = await checkWorkspace();
|
|
if (violations.length > 0) {
|
|
console.error(`Package boundary check failed (${violations.length})`);
|
|
for (const violation of violations) {
|
|
console.error(`- ${violation}`);
|
|
}
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.info('Package boundary check passed.');
|
|
}
|
|
}
|