mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 11:13:21 +00:00
* test(architecture): ratchet top-level layer direction and mutual imports Declare a layer for every top-level src/ directory and root source file (domain, contracts, foundation, providers, application, composition, adapters) in scripts/lib/module-layers.mjs. verify:module-boundaries now also builds the production import graph between those units and compares it with a counted baseline: 45 unit pairs import a higher layer (248 imports) and 23 unit pairs import each other. A new upward pair, more imports on a recorded pair, a new mutual import, or an undeclared unit fails; shrinkage is reported so the baseline can be lowered. * fix(architecture): enforce layers for isolated units
206 lines
6.0 KiB
JavaScript
206 lines
6.0 KiB
JavaScript
/**
|
|
* Declared layer of every top-level `src/` unit: each directory, and each
|
|
* root-level source file. Dependencies may point to the same or a lower
|
|
* layer. An undeclared unit fails the check, so a new directory must choose
|
|
* its layer when it is added.
|
|
*
|
|
* The order follows docs/architecture/architecture.mermaid: domain and
|
|
* contracts at the centre, the process/filesystem foundation, provider
|
|
* adapters, shared application workflows, production composition, then the
|
|
* CLI and MCP entry points.
|
|
*/
|
|
export const LAYER_ORDER = Object.freeze([
|
|
"domain",
|
|
"contracts",
|
|
"foundation",
|
|
"providers",
|
|
"application",
|
|
"composition",
|
|
"adapters",
|
|
]);
|
|
|
|
const units = (layer, names) => names.map((name) => [name, layer]);
|
|
|
|
export const UNIT_LAYERS = Object.freeze(
|
|
Object.fromEntries([
|
|
...units("domain", ["domain"]),
|
|
...units("contracts", ["contracts"]),
|
|
...units("foundation", [
|
|
"compression",
|
|
"config",
|
|
"filesystem",
|
|
"process",
|
|
"windows",
|
|
"generatedPackageMetadata.ts",
|
|
"identity.ts",
|
|
"logger.ts",
|
|
]),
|
|
...units("providers", [
|
|
"adb",
|
|
"android",
|
|
"apkSignature",
|
|
"apktool",
|
|
"artifacts",
|
|
"browser",
|
|
"captures",
|
|
"cutter",
|
|
"desktop",
|
|
"dotnet",
|
|
"electron",
|
|
"erlang",
|
|
"evm",
|
|
"firmware",
|
|
"flutter",
|
|
"frida",
|
|
"gdb",
|
|
"ghidra",
|
|
"go",
|
|
"gpu",
|
|
"hopper",
|
|
"ida",
|
|
"il2cpp",
|
|
"inspector",
|
|
"javascript",
|
|
"jeb",
|
|
"kuna",
|
|
"model",
|
|
"native",
|
|
"objdump",
|
|
"pcap",
|
|
"providers",
|
|
"python",
|
|
"reference",
|
|
"rizin",
|
|
"rust",
|
|
"signals",
|
|
"sqlite",
|
|
"v8",
|
|
"wasm",
|
|
]),
|
|
...units("application", ["application"]),
|
|
...units("composition", ["composition"]),
|
|
...units("adapters", [
|
|
"cli",
|
|
"main",
|
|
"server",
|
|
"catalogDiscovery.ts",
|
|
"catalogIdentity.ts",
|
|
"cli.ts",
|
|
"cliBrowserScenarioCommands.ts",
|
|
"cliBuiltinArguments.ts",
|
|
"cliCommandNames.ts",
|
|
"cliConfiguration.ts",
|
|
"cliEvidenceCommands.ts",
|
|
"cliJsonInput.ts",
|
|
"cliLogging.ts",
|
|
"cliObservationOptions.ts",
|
|
"cliOneShot.ts",
|
|
"cliOutput.ts",
|
|
"doctorRuntime.ts",
|
|
"main.ts",
|
|
"mcpDoctor.ts",
|
|
"mcpDoctorOptions.ts",
|
|
"mcpStartupPolicy.ts",
|
|
"recordedCatalogDigests.ts",
|
|
"recordedCliCommandRegistrations.ts",
|
|
"recordedMcpCatalog.ts",
|
|
"serverIdentity.ts",
|
|
]),
|
|
]),
|
|
);
|
|
|
|
const PRODUCTION_SOURCE = /^src\/.+\.(?:ts|js)$/u;
|
|
const NON_PRODUCTION = /\.(?:test|spec|fixture)\.(?:ts|js)$|\.d\.ts$/u;
|
|
|
|
/** The top-level unit of a production `src/` file, or undefined. */
|
|
export const sourceUnit = (path) => {
|
|
if (!PRODUCTION_SOURCE.test(path) || NON_PRODUCTION.test(path))
|
|
return undefined;
|
|
// A root-level source file is its own unit; built .js specifiers name .ts files.
|
|
return path.split("/")[1].replace(/\.js$/u, ".ts");
|
|
};
|
|
|
|
const rank = (unit) => LAYER_ORDER.indexOf(UNIT_LAYERS[unit]);
|
|
|
|
/** Unit pairs that import each other, the shortest import cycles. */
|
|
const mutualPairs = (edges) =>
|
|
[...edges.keys()]
|
|
.map((key) => key.split(" -> "))
|
|
.filter(([from, to]) => from < to && edges.has(`${to} -> ${from}`))
|
|
.map(([from, to]) => `${from} <-> ${to}`)
|
|
.sort();
|
|
|
|
/**
|
|
* Collect unit-level facts from resolved production imports: undeclared
|
|
* units, imports that point to a higher layer (counted per unit pair), and
|
|
* unit pairs that import each other. Longer cycles through three or more
|
|
* units are not reported; every such cycle contains an upward import, which
|
|
* the layer count already ratchets.
|
|
*/
|
|
export const layerGraphFacts = (imports, files = []) => {
|
|
const edges = new Map();
|
|
const undeclared = new Set();
|
|
for (const file of files) {
|
|
const unit = sourceUnit(file);
|
|
if (unit !== undefined && UNIT_LAYERS[unit] === undefined)
|
|
undeclared.add(unit);
|
|
}
|
|
for (const { file, target } of imports) {
|
|
const from = sourceUnit(file);
|
|
const to = sourceUnit(target);
|
|
if (from === undefined || to === undefined || from === to) continue;
|
|
for (const unit of [from, to])
|
|
if (UNIT_LAYERS[unit] === undefined) undeclared.add(unit);
|
|
const key = `${from} -> ${to}`;
|
|
edges.set(key, (edges.get(key) ?? 0) + 1);
|
|
}
|
|
const upward = {};
|
|
for (const [key, count] of [...edges].sort(([a], [b]) =>
|
|
a < b ? -1 : a > b ? 1 : 0,
|
|
)) {
|
|
const [from, to] = key.split(" -> ");
|
|
if (undeclared.has(from) || undeclared.has(to)) continue;
|
|
if (rank(from) < rank(to)) upward[key] = count;
|
|
}
|
|
return {
|
|
undeclared: [...undeclared].sort(),
|
|
upward,
|
|
cycles: mutualPairs(edges),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Compare facts with the recorded baseline. Growth fails: a new upward pair,
|
|
* more imports on a recorded pair, or a cycle group not recorded. Shrinkage is
|
|
* returned separately so the baseline can be lowered.
|
|
*/
|
|
export const compareLayerBaseline = (facts, baseline) => {
|
|
const failures = [];
|
|
const lowerable = [];
|
|
for (const unit of facts.undeclared)
|
|
failures.push(
|
|
`undeclared unit src/${unit}: add it to UNIT_LAYERS in scripts/lib/module-layers.mjs`,
|
|
);
|
|
for (const [key, count] of Object.entries(facts.upward)) {
|
|
const allowed = baseline.upward[key] ?? 0;
|
|
if (count > allowed)
|
|
failures.push(
|
|
`upward import ${key}: ${count} imports, baseline ${allowed}`,
|
|
);
|
|
}
|
|
for (const [key, allowed] of Object.entries(baseline.upward)) {
|
|
const count = facts.upward[key] ?? 0;
|
|
if (count < allowed)
|
|
lowerable.push(`upward import ${key}: ${count}, baseline ${allowed}`);
|
|
}
|
|
const recordedCycles = new Set(baseline.cycles);
|
|
for (const group of facts.cycles)
|
|
if (!recordedCycles.has(group))
|
|
failures.push(`mutual import not in baseline: ${group}`);
|
|
const observedCycles = new Set(facts.cycles);
|
|
for (const group of baseline.cycles)
|
|
if (!observedCycles.has(group))
|
|
lowerable.push(`recorded mutual import no longer present: ${group}`);
|
|
return { failures, lowerable };
|
|
};
|