Files
core2026/tools/check-package-boundaries.mjs
T

209 lines
8.3 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/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 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))) {
imports.push({ specifier: match[3], typeOnly: Boolean(match[1]), 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.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.');
}
}