Newer
Older
rpg / apps / game / tools / guards / boundary.mjs
/**
 * Гвард границы «движок / игра». Проверяет импорты исходников:
 *
 *  - 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();