Files
elizaos--eliza/packages/scripts/audit-scripts-inventory.mjs
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

748 lines
25 KiB
JavaScript

#!/usr/bin/env node
/**
* Inventory + reachability classifier for build/dev/support scripts (issue
* #10194; packages/app surface added for #10200).
*
* Classifies every `packages/scripts/*.mjs` file, every root `package.json`
* script, and every `packages/app/package.json` script (the second dense script
* surface) as one of:
* - reachable-from-verify
* - reachable-from-test
* - reachable-from-build
* - reachable-from-ci-workflow
* - reachable-from-operator-script
* - reachable-from-package-script
* - reachable-from-docs
* - reachable-from-app-internal (packages/app scripts only)
* - orphan
*
* packages/app reachability also models Turbo task fan-out (`run-turbo run
* build|lint|typecheck` reaches app's same-named script), `--cwd packages/app
* <name>` invocations, `working-directory: packages/app` CI step blocks, app→app
* `bun run <name>`, and npm pre/post lifecycle pairs.
*
* Reachability model:
* 1. Root scripts form a call graph: a script body that runs `bun run X` /
* `npm run X` makes root script X reachable transitively.
* 2. The seed entrypoints are `verify` (+ its `check` alias), `test`, `build`,
* every root script name referenced from a `.github/` workflow, and every
* named root script as a lower-priority human/operator entrypoint.
* 3. A reachable script body that runs `node packages/scripts/X.mjs` (or
* otherwise names a packages/scripts file) makes that file reachable.
* 4. `.github/` workflows that directly name a packages/scripts file make it
* reachable-from-ci-workflow.
* 5. A reachable `.mjs` file that spawnSync/exec/imports/names another
* packages/scripts `.mjs` propagates reachability to it.
* 6. A packages/scripts file named from docs/source/test text is documented
* as an intentional standalone/support entrypoint, not a true orphan.
*
* Output:
* - machine-readable JSON to reports/scripts-inventory.json (gitignored).
* - a summary table to stdout (total files, total LOC, orphan count,
* root-script count).
*
* Usage:
* node packages/scripts/audit-scripts-inventory.mjs # write + print
* node packages/scripts/audit-scripts-inventory.mjs --json # print JSON
*/
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(SCRIPT_DIR, "..", "..");
const SCRIPTS_DIR = path.join(ROOT, "packages", "scripts");
const APP_PKG = path.join(ROOT, "packages", "app", "package.json");
const CATEGORIES = [
"reachable-from-verify",
"reachable-from-test",
"reachable-from-build",
"reachable-from-ci-workflow",
"reachable-from-operator-script",
"reachable-from-package-script",
"reachable-from-docs",
"orphan",
];
// packages/app scripts can also be reached app-internally (one app script runs
// another, or via an npm pre/post lifecycle pair) — a color the root/file graphs
// don't need. Priority: verify > test > build > ci-workflow > app-internal.
const APP_CATEGORIES = [
"reachable-from-verify",
"reachable-from-test",
"reachable-from-build",
"reachable-from-ci-workflow",
"reachable-from-app-internal",
"orphan",
];
const PACKAGE_JSON_PRUNE_DIRS = new Set([
".git",
".next",
".turbo",
"aesthetic-audit-output",
"benchmark_results",
"build",
"coverage",
"dist",
"node_modules",
"reports",
]);
const DOCUMENTATION_REFERENCE_EXTENSIONS = new Set([
".cjs",
".js",
".json",
".md",
".mdx",
".mjs",
".py",
".ts",
".tsx",
".txt",
".yaml",
".yml",
]);
function readJson(file) {
return JSON.parse(readFileSync(file, "utf8"));
}
function readTextIfReadable(file) {
try {
return readFileSync(file, "utf8");
} catch {
return "";
}
}
function walk(dir, visit) {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, visit);
else visit(full);
}
}
function walkPruned(dir, visit) {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!PACKAGE_JSON_PRUNE_DIRS.has(entry.name)) walkPruned(full, visit);
} else {
visit(full);
}
}
}
function loc(file) {
const text = readTextIfReadable(file);
if (!text) return 0;
return text.split("\n").length;
}
/** All packages/scripts/*.mjs basenames (the file universe we classify). */
function collectScriptFiles() {
return readdirSync(SCRIPTS_DIR)
.filter((name) => name.endsWith(".mjs"))
.sort();
}
/** Root-script names invoked from a script body via `bun|npm|pnpm|yarn run X`. */
function referencedRootScripts(body) {
const names = new Set();
const re =
/\b(?:bun|npm|pnpm|yarn)\s+(?:--silent\s+)?run\s+([a-z0-9][a-z0-9:_-]*)/gi;
for (const match of body.matchAll(re)) names.add(match[1]);
return names;
}
/** packages/scripts/*.mjs basenames named anywhere in a text body. */
function referencedScriptFiles(body, fileUniverse) {
const found = new Set();
for (const file of fileUniverse) {
const escaped = file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`(^|[^A-Za-z0-9_.-])${escaped}($|[^A-Za-z0-9_.-])`);
if (re.test(body)) found.add(file);
}
return found;
}
/** BFS the root-script call graph from a set of seed names. */
function reachableRootScripts(seeds, rootScripts) {
const reached = new Set();
const queue = [...seeds];
while (queue.length) {
const name = queue.shift();
if (reached.has(name)) continue;
if (!(name in rootScripts)) continue;
reached.add(name);
for (const next of referencedRootScripts(rootScripts[name])) {
if (!reached.has(next)) queue.push(next);
}
}
return reached;
}
/** File-graph adjacency: file -> set of packages/scripts files it references. */
function buildFileGraph(fileUniverse) {
const graph = new Map();
for (const file of fileUniverse) {
const body = readTextIfReadable(path.join(SCRIPTS_DIR, file));
const refs = referencedScriptFiles(body, fileUniverse);
refs.delete(file);
graph.set(file, refs);
}
return graph;
}
/** BFS the file graph from a set of seed files. */
function reachableFiles(seedFiles, graph) {
const reached = new Set();
const queue = [...seedFiles];
while (queue.length) {
const file = queue.shift();
if (reached.has(file)) continue;
reached.add(file);
for (const next of graph.get(file) ?? []) {
if (!reached.has(next)) queue.push(next);
}
}
return reached;
}
/** Seed files named directly in reachable root-script bodies. */
function filesFromRootScripts(reachedRoots, rootScripts, fileUniverse) {
const seeds = new Set();
for (const name of reachedRoots) {
for (const file of referencedScriptFiles(
rootScripts[name] ?? "",
fileUniverse,
)) {
seeds.add(file);
}
}
return seeds;
}
/** Root `package.json` script callers for packages/scripts/*.mjs files. */
function filesFromOperatorScripts(reachedRoots, rootScripts, fileUniverse) {
const callersByFile = new Map();
for (const name of reachedRoots) {
for (const scriptFile of referencedScriptFiles(
rootScripts[name] ?? "",
fileUniverse,
)) {
if (!callersByFile.has(scriptFile)) callersByFile.set(scriptFile, []);
callersByFile.get(scriptFile).push({
packageJson: "package.json",
script: name,
});
}
}
for (const callers of callersByFile.values()) {
callers.sort((a, b) =>
`${a.packageJson}:${a.script}`.localeCompare(
`${b.packageJson}:${b.script}`,
),
);
}
return callersByFile;
}
/**
* packages/scripts files invoked from package-local `package.json` scripts.
*
* Root reachability intentionally remains its own color; this catches the other
* public package script surface so helpers used by a package command are not
* reported as disposable just because root verify/build/CI do not call them.
*/
function filesFromPackageScripts(fileUniverse) {
const callersByFile = new Map();
walkPruned(ROOT, (file) => {
if (path.basename(file) !== "package.json") return;
if (path.resolve(file) === path.join(ROOT, "package.json")) return;
const pkg = readJson(file);
const scripts = pkg.scripts ?? {};
for (const [name, body] of Object.entries(scripts)) {
for (const scriptFile of referencedScriptFiles(body, fileUniverse)) {
if (!callersByFile.has(scriptFile)) callersByFile.set(scriptFile, []);
callersByFile.get(scriptFile).push({
packageJson: path.relative(ROOT, file),
script: name,
});
}
}
});
for (const callers of callersByFile.values()) {
callers.sort((a, b) =>
`${a.packageJson}:${a.script}`.localeCompare(
`${b.packageJson}:${b.script}`,
),
);
}
return callersByFile;
}
/**
* packages/scripts files referenced from docs/source/test text outside their own
* script file. This is intentionally low priority: package.json / CI /
* operator-script reachability wins, but a documented standalone support script
* is not a true zero-reference orphan.
*/
function filesFromDocumentation(fileUniverse) {
const referencesByFile = new Map();
walkPruned(ROOT, (file) => {
const rel = path.relative(ROOT, file);
if (rel === "package.json" || path.basename(file) === "package.json") {
return;
}
if (!DOCUMENTATION_REFERENCE_EXTENSIONS.has(path.extname(file))) return;
const body = readTextIfReadable(file);
if (!body) return;
for (const scriptFile of referencedScriptFiles(body, fileUniverse)) {
const ownScriptPath = path.join("packages", "scripts", scriptFile);
if (rel === ownScriptPath) continue;
if (!referencesByFile.has(scriptFile))
referencesByFile.set(scriptFile, []);
referencesByFile.get(scriptFile).push(rel);
}
});
for (const references of referencesByFile.values()) {
references.sort((a, b) => a.localeCompare(b));
}
return referencesByFile;
}
/**
* Turbo task names a body fans out across the workspace via
* `run-turbo.mjs run <task…>` / `turbo run <task…>`. Turbo runs each task on
* every workspace package, so a fan-out task reaches packages/app's same-named
* script — unless the invocation carries a positive `--filter=@elizaos/<pkg>`
* allowlist that omits app (e.g. `build:core`). Negative `--filter=!…` filters
* still include app, and Turbo's dependency selectors `<pkg>...` (with-deps) /
* `...<pkg>` (with-dependents) both include the named package, so the leading/
* trailing `...` is stripped before the equality check.
*/
function turboFanoutTasks(body) {
const positiveElizaFilters = [
...body.matchAll(
/--filter=['"]?(?:\.\.\.)?(@elizaos\/[a-z0-9-]+)(?:\.\.\.)?/g,
),
].map((m) => m[1]);
if (
positiveElizaFilters.length &&
!positiveElizaFilters.some((f) => f === "@elizaos/app")
) {
return new Set(); // restricted to a package set that excludes app
}
const tasks = new Set();
const re = /(?:run-turbo\.mjs|\bturbo)\s+run\s+([a-z0-9:_\- ]+)/g;
for (const match of body.matchAll(re)) {
for (const token of match[1].split(/\s+/)) {
if (/^[a-z0-9][a-z0-9:_-]*$/.test(token)) tasks.add(token);
else break; // first flag/operator ends the task list
}
}
return tasks;
}
/** App-script names a body invokes via `--cwd packages/app <name>`. */
function appScriptsViaCwd(body, appUniverse) {
const found = new Set();
const re = /--cwd\s+packages\/app\s+([a-z0-9][a-z0-9:_-]*)/gi;
for (const match of body.matchAll(re)) {
if (appUniverse.has(match[1])) found.add(match[1]);
}
return found;
}
/** App-script names a body invokes via a bare `bun|npm|pnpm|yarn run <name>`. */
function appScriptsViaRun(body, appUniverse) {
const found = new Set();
for (const name of referencedRootScripts(body)) {
if (appUniverse.has(name)) found.add(name);
}
return found;
}
/**
* App scripts referenced inside a `.github/` step whose `working-directory` is
* packages/app — there the `run:` body invokes app scripts without `--cwd`.
* Split each workflow into step blocks (list items under `steps:`) so a
* working-directory in one step never bleeds onto another step's commands.
*/
function appScriptsFromCiWorkdir(workflowChunks, appUniverse) {
const found = new Set();
for (const text of workflowChunks) {
for (const block of text.split(/\n(?=\s*- )/)) {
if (!/working-directory:\s*packages\/app(\s|$)/m.test(block)) continue;
for (const name of referencedRootScripts(block)) {
if (appUniverse.has(name)) found.add(name);
}
}
}
return found;
}
/** npm lifecycle pairs present in the app scripts: `pre<x>`/`post<x>` → `<x>`. */
function lifecycleEdges(appUniverse) {
const edges = new Map();
for (const name of appUniverse) {
const base = name.replace(/^(pre|post)/, "");
if (base !== name && appUniverse.has(base)) edges.set(name, base);
}
return edges;
}
/** BFS app scripts: seeds + app→app `run` edges + lifecycle pre/post pairs. */
function reachableAppScripts(seeds, appScripts, appUniverse) {
const lifecycle = lifecycleEdges(appUniverse);
// base -> [pre/post wrappers] so reaching a base reaches its lifecycle hooks.
const reverseLifecycle = new Map();
for (const [hook, base] of lifecycle) {
if (!reverseLifecycle.has(base)) reverseLifecycle.set(base, []);
reverseLifecycle.get(base).push(hook);
}
const reached = new Set();
const queue = [...seeds];
while (queue.length) {
const name = queue.shift();
if (reached.has(name) || !appUniverse.has(name)) continue;
reached.add(name);
for (const next of appScriptsViaRun(appScripts[name] ?? "", appUniverse)) {
if (!reached.has(next)) queue.push(next);
}
for (const hook of reverseLifecycle.get(name) ?? []) {
if (!reached.has(hook)) queue.push(hook);
}
}
return reached;
}
function buildInventory() {
const rootScripts = readJson(path.join(ROOT, "package.json")).scripts ?? {};
const fileUniverse = collectScriptFiles();
const fileGraph = buildFileGraph(fileUniverse);
const packageScriptCallersByFile = filesFromPackageScripts(fileUniverse);
const documentationReferencesByFile = filesFromDocumentation(fileUniverse);
// CI workflow corpus + the root-script names + script files it references.
const workflowChunks = [];
walk(path.join(ROOT, ".github"), (file) => {
if (/\.(ya?ml)$/.test(file)) workflowChunks.push(readTextIfReadable(file));
});
const ciText = workflowChunks.join("\n");
const ciRootSeeds = referencedRootScripts(ciText);
const ciFileSeeds = referencedScriptFiles(ciText, fileUniverse);
// Reachable root-script sets per seed entrypoint.
const verifyRoots = reachableRootScripts(["verify", "check"], rootScripts);
const testRoots = reachableRootScripts(["test"], rootScripts);
const buildRoots = reachableRootScripts(["build"], rootScripts);
const ciRoots = reachableRootScripts(ciRootSeeds, rootScripts);
const operatorRoots = reachableRootScripts(
Object.keys(rootScripts),
rootScripts,
);
const operatorScriptCallersByFile = filesFromOperatorScripts(
operatorRoots,
rootScripts,
fileUniverse,
);
// Reachable file sets, colored by entrypoint
// (priority verify > test > build > ci > operator > package-local script).
const verifyFiles = reachableFiles(
filesFromRootScripts(verifyRoots, rootScripts, fileUniverse),
fileGraph,
);
const testFiles = reachableFiles(
filesFromRootScripts(testRoots, rootScripts, fileUniverse),
fileGraph,
);
const buildFiles = reachableFiles(
filesFromRootScripts(buildRoots, rootScripts, fileUniverse),
fileGraph,
);
const ciFiles = reachableFiles(
new Set([
...filesFromRootScripts(ciRoots, rootScripts, fileUniverse),
...ciFileSeeds,
]),
fileGraph,
);
const operatorFiles = reachableFiles(
new Set(operatorScriptCallersByFile.keys()),
fileGraph,
);
const packageScriptFiles = reachableFiles(
new Set(packageScriptCallersByFile.keys()),
fileGraph,
);
const documentedFiles = reachableFiles(
new Set(documentationReferencesByFile.keys()),
fileGraph,
);
const classifyRoot = (name) => {
if (verifyRoots.has(name)) return "reachable-from-verify";
if (testRoots.has(name)) return "reachable-from-test";
if (buildRoots.has(name)) return "reachable-from-build";
if (ciRoots.has(name)) return "reachable-from-ci-workflow";
if (operatorRoots.has(name)) return "reachable-from-operator-script";
return "orphan";
};
const classifyFile = (file) => {
if (verifyFiles.has(file)) return "reachable-from-verify";
if (testFiles.has(file)) return "reachable-from-test";
if (buildFiles.has(file)) return "reachable-from-build";
if (ciFiles.has(file)) return "reachable-from-ci-workflow";
if (operatorFiles.has(file)) return "reachable-from-operator-script";
if (packageScriptFiles.has(file)) return "reachable-from-package-script";
if (documentedFiles.has(file)) return "reachable-from-docs";
return "orphan";
};
const files = fileUniverse.map((file) => ({
file,
loc: loc(path.join(SCRIPTS_DIR, file)),
category: classifyFile(file),
operatorScriptCallers: operatorScriptCallersByFile.get(file) ?? [],
packageScriptCallers: packageScriptCallersByFile.get(file) ?? [],
documentationReferences: documentationReferencesByFile.get(file) ?? [],
}));
const roots = Object.keys(rootScripts).map((name) => ({
name,
category: classifyRoot(name),
}));
const fileTotals = Object.fromEntries(CATEGORIES.map((c) => [c, 0]));
const fileLocTotals = Object.fromEntries(CATEGORIES.map((c) => [c, 0]));
for (const f of files) {
fileTotals[f.category] += 1;
fileLocTotals[f.category] += f.loc;
}
const rootTotals = Object.fromEntries(CATEGORIES.map((c) => [c, 0]));
for (const r of roots) rootTotals[r.category] += 1;
// packages/app — the second dense script surface (issue #10200, item 2). An app
// script is reachable when a reachable root script or a CI workflow invokes it
// (via `--cwd packages/app <name>` or a `working-directory: packages/app` step),
// or when another reachable app script / npm lifecycle hook chains to it.
const appScripts = existsSync(APP_PKG)
? (readJson(APP_PKG).scripts ?? {})
: {};
const appUniverse = new Set(Object.keys(appScripts));
const appSeedsByColor = {
"reachable-from-verify": new Set(),
"reachable-from-test": new Set(),
"reachable-from-build": new Set(),
"reachable-from-ci-workflow": new Set(),
};
for (const name of Object.keys(rootScripts)) {
const color = classifyRoot(name);
if (!(color in appSeedsByColor)) continue;
for (const app of appScriptsViaCwd(rootScripts[name], appUniverse)) {
appSeedsByColor[color].add(app);
}
// Turbo fan-out: `run-turbo run build|lint|typecheck|…` reaches app's
// same-named script across the whole workspace.
for (const task of turboFanoutTasks(rootScripts[name])) {
if (appUniverse.has(task)) appSeedsByColor[color].add(task);
}
}
for (const app of appScriptsViaCwd(ciText, appUniverse)) {
appSeedsByColor["reachable-from-ci-workflow"].add(app);
}
for (const app of appScriptsFromCiWorkdir(workflowChunks, appUniverse)) {
appSeedsByColor["reachable-from-ci-workflow"].add(app);
}
const verifyApp = reachableAppScripts(
appSeedsByColor["reachable-from-verify"],
appScripts,
appUniverse,
);
const testApp = reachableAppScripts(
appSeedsByColor["reachable-from-test"],
appScripts,
appUniverse,
);
const buildApp = reachableAppScripts(
appSeedsByColor["reachable-from-build"],
appScripts,
appUniverse,
);
const ciApp = reachableAppScripts(
appSeedsByColor["reachable-from-ci-workflow"],
appScripts,
appUniverse,
);
// App-internal: reachable through the app graph from any directly-seeded script.
const directlySeeded = new Set([
...verifyApp,
...testApp,
...buildApp,
...ciApp,
]);
const internalApp = reachableAppScripts(
directlySeeded,
appScripts,
appUniverse,
);
const classifyApp = (name) => {
if (verifyApp.has(name)) return "reachable-from-verify";
if (testApp.has(name)) return "reachable-from-test";
if (buildApp.has(name)) return "reachable-from-build";
if (ciApp.has(name)) return "reachable-from-ci-workflow";
if (internalApp.has(name)) return "reachable-from-app-internal";
return "orphan";
};
const appScriptList = Object.keys(appScripts).map((name) => ({
name,
category: classifyApp(name),
}));
const appTotals = Object.fromEntries(APP_CATEGORIES.map((c) => [c, 0]));
for (const a of appScriptList) appTotals[a.category] += 1;
return {
generatedAt: new Date().toISOString(),
summary: {
totalFiles: files.length,
totalLoc: files.reduce((sum, f) => sum + f.loc, 0),
orphanFiles: fileTotals.orphan,
orphanLoc: fileLocTotals.orphan,
totalRootScripts: roots.length,
orphanRootScripts: rootTotals.orphan,
filesByCategory: fileTotals,
locByCategory: fileLocTotals,
rootScriptsByCategory: rootTotals,
packageScriptFileReferences: [
...packageScriptCallersByFile.values(),
].reduce((sum, callers) => sum + callers.length, 0),
operatorScriptFileReferences: [
...operatorScriptCallersByFile.values(),
].reduce((sum, callers) => sum + callers.length, 0),
documentationFileReferences: [
...documentationReferencesByFile.values(),
].reduce((sum, references) => sum + references.length, 0),
totalAppScripts: appScriptList.length,
orphanAppScripts: appTotals.orphan,
appScriptsByCategory: appTotals,
},
files,
roots,
appScripts: appScriptList,
};
}
function printSummary(inv) {
const { summary } = inv;
const w = process.stdout.write.bind(process.stdout);
const categoryWidth = 31;
w("\n[audit-scripts-inventory] packages/scripts/*.mjs reachability\n\n");
w(` ${"category".padEnd(categoryWidth)} files loc roots\n`);
w(` ${"-".repeat(categoryWidth)} ------- ------- -------\n`);
for (const c of CATEGORIES) {
w(
` ${c.padEnd(categoryWidth)} ${String(summary.filesByCategory[c]).padStart(5)} ` +
`${String(summary.locByCategory[c]).padStart(7)} ` +
`${String(summary.rootScriptsByCategory[c]).padStart(7)}\n`,
);
}
w(` ${"-".repeat(categoryWidth)} ------- ------- -------\n`);
w(
` ${"TOTAL".padEnd(categoryWidth)} ${String(summary.totalFiles).padStart(5)} ` +
`${String(summary.totalLoc).padStart(7)} ` +
`${String(summary.totalRootScripts).padStart(7)}\n\n`,
);
w(
` total files: ${summary.totalFiles} total LOC: ${summary.totalLoc} ` +
`orphan files: ${summary.orphanFiles} (${summary.orphanLoc} LOC) ` +
`root scripts: ${summary.totalRootScripts} (${summary.orphanRootScripts} orphan)\n\n`,
);
const orphans = inv.files.filter((f) => f.category === "orphan");
if (orphans.length) {
w(" orphan files:\n");
for (const f of orphans) w(` - ${f.file} (${f.loc} LOC)\n`);
w(
"\n note: orphan here means no root/CI/package-script caller found, " +
'not "safe to delete". Root operator commands are tracked separately.\n\n',
);
}
// packages/app — the second dense script surface (issue #10200, item 2).
w("[audit-scripts-inventory] packages/app/package.json reachability\n\n");
w(" category scripts\n");
w(" ---------------------------- ---------\n");
for (const c of APP_CATEGORIES) {
w(
` ${c.padEnd(28)} ${String(summary.appScriptsByCategory[c]).padStart(7)}\n`,
);
}
w(" ---------------------------- ---------\n");
w(
` ${"TOTAL".padEnd(28)} ${String(summary.totalAppScripts).padStart(7)}\n\n`,
);
w(
` app scripts: ${summary.totalAppScripts} (${summary.orphanAppScripts} ` +
`with no detected automated caller)\n` +
' note: orphan here means "no root/CI/app-internal caller found", not ' +
'"safe to delete" — many are\n human/maintainer entrypoints ' +
"(build:ios:*, capture:*, preflight:*), the same as root DEV-ENTRY scripts.\n\n",
);
const appOrphans = inv.appScripts.filter((a) => a.category === "orphan");
if (appOrphans.length) {
w(" app scripts with no detected automated caller:\n");
for (const a of appOrphans) w(` - ${a.name}\n`);
w("\n");
}
}
function main() {
const args = process.argv.slice(2);
const inv = buildInventory();
if (args.includes("--json")) {
process.stdout.write(`${JSON.stringify(inv, null, 2)}\n`);
return;
}
const outDir = path.join(ROOT, "reports");
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
const outFile = path.join(outDir, "scripts-inventory.json");
writeFileSync(outFile, `${JSON.stringify(inv, null, 2)}\n`);
printSummary(inv);
process.stdout.write(` JSON written to ${path.relative(ROOT, outFile)}\n\n`);
}
export { buildInventory };
if (import.meta.url === `file://${process.argv[1]}`) main();