Newer
Older
rpg / packages / engine / tools / boundary.mjs
/**
 * Гвард границы «ядро / приложение» — правила проверки импортов:
 *
 *  - 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/`, `${enginePkg}/tools/`] } = {}) {
    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;
}