/**
* Гвард границы «ядро / приложение» — правила проверки импортов:
*
* - src ядра не импортирует ничего из приложения и не выходит за пределы
* пакета относительными импортами;
* - src приложения импортирует ядро только через публичное API (сам пакет
* и разрешённые под-пути) и не выходит за пределы пакета.
*
* Библиотека не знает конкретной раскладки: корни пакетов и публичное API
* передаются параметрами (с дефолтами под раскладку этого монорепо).
* Сценарий запуска для конкретного репо — у приложения.
*/
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
/** Дефолтный корень монорепо (тулза живёт в packages/engine/tools — 3 уровня). */
const DEFAULT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
/** @returns {string[]} файлы .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;
}
/**
* Опции границы: root — корень репо (ключи files — пути относительно него),
* engineDir/gameDir — корни пакетов, enginePkg — имя пакета ядра, publicApi —
* разрешённые префиксы импорта ядра из приложения.
*/
function resolveOpts({ root = DEFAULT_ROOT, engineDir = join(root, 'packages', 'engine'),
gameDir = join(root, 'apps', 'game'), enginePkg = '@rpg/engine',
publicApi = [enginePkg, `${enginePkg}/assets/`] } = {}) {
return { root, engineDir, gameDir, enginePkg, publicApi };
}
/**
* Чистая проверка: пары «путь файла → содержимое» (относительно root,
* с системными разделителями). Возвращает нарушения:
* {severity, file, spec, rule, message}.
*/
export function checkBoundary(files, opts = {}) {
const { root, engineDir, gameDir, enginePkg, publicApi } = resolveOpts(opts);
const engineRel = relative(root, engineDir);
const gameRel = relative(root, gameDir);
const out = [];
for (const [rel, source] of files) {
const pkg = rel.startsWith(engineRel + sep) ? 'engine' : rel.startsWith(gameRel + sep) ? 'game' : null;
if (!pkg) continue;
for (const spec of importSpecs(source)) {
// Ядро не должно знать ничего о приложении.
if (pkg === 'engine') {
if (spec.includes(gameRel) || /(^|[\\/])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(engineDir) + sep)) {
out.push({ severity: 'error', file: rel, spec, rule: 'engine-self-contained', message: 'относительный импорт выходит из пакета ядра' });
}
}
continue;
}
// Приложение: ядро только через публичное API (голое имя пакета —
// только само по себе, префиксы — по началу строки).
if (spec.startsWith(enginePkg)) {
const allowed = spec === enginePkg ||
publicApi.some((p) => p !== enginePkg && spec.startsWith(p));
if (!allowed) {
out.push({ severity: 'error', file: rel, spec, rule: 'game-engine-public-api', message: 'под-путь ядра мимо публичного API' });
}
continue;
}
if (spec.startsWith('.')) {
const target = resolve(dirname(join(root, rel)), spec);
if (!target.startsWith(resolve(gameDir) + sep)) {
out.push({ severity: 'error', file: rel, spec, rule: 'game-self-contained', message: 'относительный импорт выходит из пакета приложения' });
}
}
}
}
return out;
}
/** Собрать исходники обоих пакетов: путь (относительно root) → содержимое. */
export function collectSources(opts = {}) {
const { root, engineDir, gameDir } = resolveOpts(opts);
const files = new Map();
for (const dir of [join(engineDir, 'src'), join(gameDir, 'src')]) {
for (const abs of listSources(dir)) {
files.set(relative(root, abs), readFileSync(abs, 'utf8'));
}
}
return files;
}