/**
* Гвард границы «движок / игра». Проверяет импорты исходников:
*
* - packages/engine/src — не импортирует ничего из игры (apps/game) и
* не выходит за пределы пакета относительными импортами;
* - apps/game/src — импортирует движок только через публичное API
* (`@rpg/engine`, `@rpg/engine/assets/*`) и не выходит за пределы пакета.
*
* Запуск: node apps/game/tools/guards/boundary.mjs (npm run guard)
* Нарушения печатаются JSON-ом, exit 1 при наличии error-нарушений.
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
/** Корень репозитория (гвард живёт в apps/game/tools/guards — 4 уровня). */
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
const ENGINE = join(ROOT, 'packages', 'engine');
const GAME = join(ROOT, 'apps', 'game');
/** @returns {import('node:fs').Dirent[]} файлы .ts/.tsx рекурсивно. */
export function listSources(dir) {
const out = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...listSources(p));
else if (e.isFile() && /\.tsx?$/.test(e.name)) out.push(p);
}
return out;
}
/** Спецификаторы всех статических и динамических импортов файла. */
export function importSpecs(source) {
const specs = [];
// Тело импорта без ';', чтобы не переползти в следующий import (side-effect
// `import './b'` — без from: группа пропускается, кавычка берётся сразу).
const re = /(?:\bimport\s+(?:[^;'"]*?\sfrom\s*)?|\bexport\s+[^;'"]*?\sfrom\s+|import\s*\(\s*)(['"])([^'"]+)\1/g;
for (const m of source.matchAll(re)) specs.push(m[2]);
return specs;
}
/**
* Чистая проверка: пары «путь файла → содержимое» (относительно корня репо,
* с системными разделителями). Возвращает нарушения:
* {severity, file, spec, rule, message}.
*/
export function checkBoundary(files) {
const out = [];
for (const [rel, source] of files) {
const pkg = rel.startsWith('packages' + sep) ? 'engine' : 'game';
for (const spec of importSpecs(source)) {
// Движок не должен знать ничего об игре.
if (pkg === 'engine') {
if (spec.includes('apps/game') || /(^|[\\/])game([\\/]|$)/.test(spec)) {
out.push({ severity: 'error', file: rel, spec, rule: 'engine-no-game', message: 'движок импортирует игру' });
continue;
}
if (spec.startsWith('.')) {
const target = resolve(dirname(join(ROOT, rel)), spec);
if (!target.startsWith(resolve(ENGINE) + sep)) {
out.push({ severity: 'error', file: rel, spec, rule: 'engine-self-contained', message: 'относительный импорт выходит из packages/engine' });
}
}
continue;
}
// Игра: движок только через публичное API.
if (spec.startsWith('@rpg/engine')) {
if (spec !== '@rpg/engine' && !spec.startsWith('@rpg/engine/assets/')) {
out.push({ severity: 'error', file: rel, spec, rule: 'game-engine-public-api', message: 'под-путь движка мимо index.ts' });
}
continue;
}
if (spec.startsWith('.')) {
const target = resolve(dirname(join(ROOT, rel)), spec);
if (!target.startsWith(resolve(GAME) + sep)) {
out.push({ severity: 'error', file: rel, spec, rule: 'game-self-contained', message: 'относительный импорт выходит из apps/game' });
}
}
}
}
return out;
}
/** Собрать исходники обоих пакетов: путь (относительно корня) → содержимое. */
export function collectSources() {
const files = new Map();
for (const dir of [join(ENGINE, 'src'), join(GAME, 'src')]) {
for (const abs of listSources(dir)) {
files.set(relative(ROOT, abs), readFileSync(abs, 'utf8'));
}
}
return files;
}
function main() {
const violations = checkBoundary(collectSources());
console.log(JSON.stringify({ ok: violations.length === 0, violations }, null, 2));
process.exit(violations.length ? 1 : 0);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();