pi-mono/scripts/check-entry-graphs.mjs
Mario Zechner 5507d76ee8 feat(agent,ai): add narrow subpath exports so clients skip the barrel
A barrel forces Node to evaluate its whole graph: importing reduceLaneSnapshot
(1 file, 6 KB) through pi-agent-core's index evaluated 106 files, and mini's
server loaded 112 files to enumerate JSONL headers. Types are free, so this only
ever affected value imports.

pi-ai gains ./utils/* alongside the existing ./providers/* and ./api/*
wildcards. pi-agent-core gains ./harness/context, ./harness/env/nodejs,
./harness/runtime/reducer and ./harness/session, and ./session/testing becomes
./harness/session/testing so every export mirrors its source path. The barrel
and ./node keep their batteries-included semantics.

The session layer takes uuidv7 from @earendil-works/pi-ai/utils/uuid rather than
the pi-ai barrel, which is what let ./harness/session drop from 42 files to 19.
Two imports there were written `import { type Usage } from` - not a type-only
statement, so it emitted a runtime import of the barrel.

scripts/check-entry-graphs.mjs walks each declared entry's value-import graph and
enforces per-entry budgets and forbidden paths, so a stray `export *` cannot
silently undo this. It caught the two `import { type ... }` regressions above.

Measured with mini: server 112 files and 98 MB to 28 files and 69 MB, presentation
204 files and 125 MB to 99 files and 110 MB. The worker still loads the provider
graph, correctly.
2026-08-26 22:02:31 +02:00

139 lines
5.2 KiB
JavaScript

#!/usr/bin/env node
/**
* Entry points are cost contracts.
*
* A package's `exports` map is the only place that says which modules are public, and one stray
* `export *` can silently make a narrow entry drag an entire barrel: importing a 1-file pure
* function through a barrel costs ~37 MB of evaluated module graph, and nothing fails until someone
* measures a process. This walks the value-import graph of every declared entry point and enforces a
* budget per entry, so that regression fails at commit time instead.
*
* Only value imports count. `import type` / `export type` are erased before Node sees them.
*/
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
/** Workspace package name -> its source root, so cross-package imports are followed. */
const WORKSPACE = {
"@earendil-works/pi-ai": "packages/ai/src",
"@earendil-works/pi-agent-core": "packages/agent/src",
"@earendil-works/pi-telemetry": "packages/telemetry/src",
"@earendil-works/pi-tui": "packages/tui/src",
};
/**
* Budgets are deliberate. `.` and `./node` are batteries-included entries and stay unbounded; every
* narrow entry states the graph it is allowed to reach.
*/
const BUDGETS = {
"packages/ai": {
"./utils/*": { maxFiles: 3, forbid: ["providers/", "api/", "index.ts"] },
},
"packages/agent": {
"./harness/runtime/reducer": { maxFiles: 1 },
"./harness/context": { maxFiles: 6, forbid: ["harness/runtime/", "harness/execution/", "packages/ai/"] },
"./harness/env/nodejs": { maxFiles: 3, forbid: ["packages/ai/", "harness/runtime/"] },
"./harness/session": { maxFiles: 25, forbid: ["harness/runtime/", "harness/execution/", "packages/ai/src/index.ts"] },
},
};
const SPEC = /(?:^|\n)\s*(?:import|export)\s+(?!type\s)([^;]*?\sfrom\s*)?["']([^"']+)["']/g;
function resolveSpec(spec, fromFile) {
if (spec.startsWith("node:")) return null;
if (spec.startsWith(".")) {
const base = resolve(dirname(fromFile), spec);
for (const candidate of [base, `${base}.ts`, `${base}/index.ts`]) {
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
}
return null;
}
for (const [name, src] of Object.entries(WORKSPACE)) {
if (spec === name) return resolve(ROOT, src, "index.ts");
if (!spec.startsWith(`${name}/`)) continue;
const tail = spec.slice(name.length + 1);
for (const candidate of [`${tail}.ts`, `${tail}/index.ts`, tail]) {
const file = resolve(ROOT, src, candidate);
if (existsSync(file) && statSync(file).isFile()) return file;
}
}
return null; // external dependency: not part of the workspace graph
}
function walk(entryFile) {
const seen = new Set();
const queue = [entryFile];
while (queue.length > 0) {
const file = queue.pop();
if (seen.has(file) || file.endsWith(".json")) continue;
seen.add(file);
for (const match of readFileSync(file, "utf8").matchAll(SPEC)) {
const target = resolveSpec(match[2], file);
if (target) queue.push(target);
}
}
return seen;
}
/** `./dist/harness/context.js` in the exports map is `src/harness/context.ts` on disk. */
function sourceFor(pkgDir, distPath) {
const rel = distPath.replace(/^\.\/dist\//, "").replace(/\.js$/, ".ts");
const file = resolve(ROOT, pkgDir, "src", rel);
return existsSync(file) ? file : undefined;
}
function expand(pkgDir, entry, target) {
if (!entry.includes("*")) return [[entry, target]];
const dir = resolve(ROOT, pkgDir, "src", dirname(target.replace(/^\.\/dist\//, "")));
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((name) => name.endsWith(".ts"))
.map((name) => [entry.replace("*", name.replace(/\.ts$/, "")), target.replace("*", name.replace(/\.ts$/, ""))]);
}
let failures = 0;
for (const [pkgDir, budgets] of Object.entries(BUDGETS)) {
const manifest = JSON.parse(readFileSync(resolve(ROOT, pkgDir, "package.json"), "utf8"));
for (const [entry, budget] of Object.entries(budgets)) {
const declared = manifest.exports?.[entry];
if (!declared) {
console.error(`${pkgDir} declares no export "${entry}" but a budget exists for it`);
failures += 1;
continue;
}
const target = typeof declared === "string" ? declared : declared.import;
for (const [name, distPath] of expand(pkgDir, entry, target)) {
const source = sourceFor(pkgDir, distPath);
if (!source) {
console.error(`${pkgDir} export "${name}" points at ${distPath}, which has no source file`);
failures += 1;
continue;
}
const graph = [...walk(source)].map((file) => relative(ROOT, file));
if (graph.length > budget.maxFiles) {
console.error(
`${pkgDir} export "${name}" reaches ${graph.length} files, budget ${budget.maxFiles}\n` +
graph.map((file) => ` ${file}`).join("\n"),
);
failures += 1;
}
for (const pattern of budget.forbid ?? []) {
const hit = graph.filter((file) => file.includes(pattern));
if (hit.length > 0) {
console.error(`${pkgDir} export "${name}" must not reach ${pattern}:\n${hit.map((f) => ` ${f}`).join("\n")}`);
failures += 1;
}
}
}
}
}
if (failures > 0) {
console.error(`\n${failures} entry-point budget violation(s).`);
process.exit(1);
}
console.log("Entry point graphs are within budget.");