refactor: enforce package boundaries
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { checkSource, extractImports } from './check-package-boundaries.mjs';
|
||||
|
||||
test('extracts multiline and type-only package imports', () => {
|
||||
assert.deepEqual(extractImports("import type { AppRouter } from '@sammo-ts/game-api';"), [
|
||||
{ specifier: '@sammo-ts/game-api', typeOnly: true, clause: '{ AppRouter }' },
|
||||
]);
|
||||
assert.equal(
|
||||
extractImports("import {\n GamePrisma,\n type DatabaseClient\n} from '@sammo-ts/infra';")[0]?.specifier,
|
||||
'@sammo-ts/infra'
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects infrastructure access and process diagnostics in logic', () => {
|
||||
const zone = { name: 'logic', allowed: ['@sammo-ts/common'] };
|
||||
const violations = checkSource({
|
||||
source: "import { PrismaClient } from '@prisma/client/runtime';\nprocess.stdout.write('trace');\nfetch('/x');",
|
||||
relativePath: 'packages/logic/src/example.ts',
|
||||
zone,
|
||||
});
|
||||
assert.equal(violations.length, 3);
|
||||
});
|
||||
|
||||
test('requires frontend backend references to be type-only', () => {
|
||||
const zone = {
|
||||
name: 'game-frontend',
|
||||
allowed: ['@sammo-ts/game-api'],
|
||||
frontend: true,
|
||||
};
|
||||
const violations = checkSource({
|
||||
source: "import { appRouter } from '@sammo-ts/game-api';",
|
||||
relativePath: 'app/game-frontend/src/trpc.ts',
|
||||
zone,
|
||||
});
|
||||
assert.deepEqual(violations, [
|
||||
'app/game-frontend/src/trpc.ts: frontend may reference @sammo-ts/game-api only through import type',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects prefixed and bare Node builtins in frontend source', () => {
|
||||
const violations = checkSource({
|
||||
source: "import path from 'path';\nimport fs from 'node:fs';",
|
||||
relativePath: 'app/game-frontend/src/example.ts',
|
||||
zone: { name: 'game-frontend', allowed: [], frontend: true },
|
||||
});
|
||||
|
||||
assert.deepEqual(violations, [
|
||||
'app/game-frontend/src/example.ts: frontend production code may not import path',
|
||||
'app/game-frontend/src/example.ts: frontend production code may not import node:fs',
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user