209 lines
8.3 KiB
JavaScript
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.');
|
|
}
|
|
}
|