#!/usr/bin/env node import path from "node:path"; import { analyzeTopology } from "./lib/ts-topology/analyze.js"; import { renderTextReport } from "./lib/ts-topology/reports.js"; import { createFilesystemPublicSurfaceScope, createPluginSdkScope, } from "./lib/ts-topology/scope.js"; import type { TopologyReportName, TopologyScope } from "./lib/ts-topology/types.js"; const VALID_REPORTS = new Set([ "public-surface-usage", "owner-map", "single-owner-shared", "unused-public-surface", "consumer-topology", ]); type IoLike = { stdout: { write: (chunk: string) => void }; stderr: { write: (chunk: string) => void }; }; type CliOptions = { repoRoot: string; scopeId: string; report: TopologyReportName; json: boolean; includeTests: boolean; limit: number; tsconfigName?: string; customEntrypointRoot?: string; customImportPrefix?: string; }; function usage() { return [ "Usage: ts-topology [analyze] [options]", "", "Options:", " --scope= Built-in or custom scope", " --entrypoint-root= Required for --scope=custom", " --import-prefix= Required for --scope=custom", " --report= public-surface-usage | owner-map | single-owner-shared | unused-public-surface | consumer-topology", " --json Emit JSON", " --limit= Limit ranked/text output (default: 25)", " --exclude-tests Ignore test consumers", " --repo-root= Override repo root", " --tsconfig= Override tsconfig filename", ].join("\n"); } function parseArgs(argv: string[]): CliOptions { const args = [...argv]; if (args[0] === "analyze") { args.shift(); } const options: CliOptions = { repoRoot: process.cwd(), scopeId: "plugin-sdk", report: "public-surface-usage", json: false, includeTests: true, limit: 25, }; for (const arg of args) { if (arg === "--json") { options.json = true; continue; } if (arg === "--exclude-tests") { options.includeTests = false; continue; } if (arg === "--help" || arg === "-h") { throw new Error(usage()); } const [flag, value] = arg.split("=", 2); switch (flag) { case "--scope": options.scopeId = value ?? options.scopeId; break; case "--report": options.report = (value as TopologyReportName | undefined) ?? options.report; break; case "--limit": options.limit = Math.max(1, Number.parseInt(value ?? "25", 10)); break; case "--repo-root": options.repoRoot = path.resolve(value ?? options.repoRoot); break; case "--entrypoint-root": options.customEntrypointRoot = value; break; case "--import-prefix": options.customImportPrefix = value; break; case "--tsconfig": options.tsconfigName = value; break; default: throw new Error(`Unknown argument: ${arg}\n\n${usage()}`); } } return options; } function resolveScope(options: CliOptions): TopologyScope { if (options.scopeId === "plugin-sdk") { return createPluginSdkScope(options.repoRoot); } if (options.scopeId === "custom") { if (!options.customEntrypointRoot || !options.customImportPrefix) { throw new Error("--scope=custom requires --entrypoint-root and --import-prefix"); } return createFilesystemPublicSurfaceScope(options.repoRoot, { id: "custom", entrypointRoot: options.customEntrypointRoot, importPrefix: options.customImportPrefix, }); } throw new Error(`Unsupported scope: ${options.scopeId}`); } function assertValidReport(report: string): asserts report is TopologyReportName { if (!VALID_REPORTS.has(report as TopologyReportName)) { throw new Error( `Unsupported report: ${report}\nValid reports: ${[...VALID_REPORTS].join(", ")}`, ); } } export async function main(argv: string[], io: IoLike = process): Promise { let options: CliOptions; try { options = parseArgs(argv); } catch (error) { io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); return 1; } try { assertValidReport(options.report); const scope = resolveScope(options); const envelope = analyzeTopology({ repoRoot: options.repoRoot, scope, report: options.report, includeTests: options.includeTests, limit: options.limit, tsconfigName: options.tsconfigName, }); if (options.json) { io.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); return 0; } io.stdout.write(`${renderTextReport(envelope, options.limit)}\n`); return 0; } catch (error) { io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); return 1; } } if (import.meta.url === `file://${process.argv[1]}`) { const exitCode = await main(process.argv.slice(2)); if (exitCode !== 0) { process.exit(exitCode); } }