mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 01:21:36 +00:00
Add reusable TypeScript topology analyzer for public surface usage
This commit is contained in:
@@ -1104,6 +1104,7 @@
|
||||
"plugin-sdk:facades:check": "node scripts/generate-plugin-sdk-facades.mjs --check",
|
||||
"plugin-sdk:facades:gen": "node scripts/generate-plugin-sdk-facades.mjs --write",
|
||||
"plugin-sdk:sync-exports": "node scripts/sync-plugin-sdk-exports.mjs",
|
||||
"plugin-sdk:usage": "node --import tsx scripts/analyze-plugin-sdk-usage.ts",
|
||||
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
|
||||
"prepack": "pnpm build && pnpm ui:build",
|
||||
"prepare": "command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1 && git config core.hooksPath git-hooks || exit 0",
|
||||
@@ -1176,6 +1177,7 @@
|
||||
"test:ui": "pnpm lint:ui:no-raw-window-open && pnpm --dir ui test",
|
||||
"test:voicecall:closedloop": "vitest run extensions/voice-call/src/manager.test.ts extensions/voice-call/src/media-stream.test.ts src/plugins/voice-call.plugin.test.ts --maxWorkers=1",
|
||||
"test:watch": "vitest",
|
||||
"ts-topology": "node --import tsx scripts/ts-topology.ts",
|
||||
"tui": "node scripts/run-node.mjs tui",
|
||||
"tui:dev": "OPENCLAW_PROFILE=dev node scripts/run-node.mjs --dev tui",
|
||||
"ui:build": "node scripts/ui.js build",
|
||||
|
||||
9
scripts/analyze-plugin-sdk-usage.ts
Normal file
9
scripts/analyze-plugin-sdk-usage.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
import { main } from "./ts-topology.ts";
|
||||
|
||||
const forwardedArgs = process.argv.slice(2);
|
||||
const normalizedArgs = forwardedArgs[0] === "--" ? forwardedArgs.slice(1) : forwardedArgs;
|
||||
const exitCode = await main(["--scope=plugin-sdk", ...normalizedArgs]);
|
||||
if (exitCode !== 0) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
416
scripts/lib/ts-topology/analyze.ts
Normal file
416
scripts/lib/ts-topology/analyze.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import path from "node:path";
|
||||
import ts from "typescript";
|
||||
import {
|
||||
canonicalSymbolInfo,
|
||||
countIdentifierUsages,
|
||||
countNamespacePropertyUsages,
|
||||
createProgramContext,
|
||||
getRepoRevision,
|
||||
} from "./context.js";
|
||||
import type {
|
||||
ProgramContext,
|
||||
PublicEntrypoint,
|
||||
RankedCandidates,
|
||||
ReferenceEvent,
|
||||
TopologyEnvelope,
|
||||
TopologyRecord,
|
||||
TopologyReportName,
|
||||
TopologyScope,
|
||||
} from "./types.js";
|
||||
|
||||
function pushUnique(values: string[], next: string | null | undefined) {
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
if (!values.includes(next)) {
|
||||
values.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
function sortUnique(values: string[]) {
|
||||
values.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function clampScore(value: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function isTypeOnlyCandidate(record: Pick<TopologyRecord, "kind">): boolean {
|
||||
return record.kind === "interface" || record.kind === "type";
|
||||
}
|
||||
|
||||
function computeSharednessScore(record: TopologyRecord): number {
|
||||
const extensionWeight = record.productionExtensions.length * 30;
|
||||
const packageWeight = record.productionPackages.length * 20;
|
||||
const internalWeight = record.internalRefCount > 0 ? 10 : 0;
|
||||
const publicSpecifierWeight = Math.min(record.publicSpecifiers.length, 4) * 5;
|
||||
const typeWeight = record.isTypeOnlyCandidate ? 10 : 0;
|
||||
const testOnlyPenalty = record.productionRefCount === 0 && record.testRefCount > 0 ? 25 : 0;
|
||||
return clampScore(
|
||||
extensionWeight +
|
||||
packageWeight +
|
||||
internalWeight +
|
||||
publicSpecifierWeight +
|
||||
typeWeight -
|
||||
testOnlyPenalty,
|
||||
);
|
||||
}
|
||||
|
||||
function computeMoveBackToOwnerScore(record: TopologyRecord): number {
|
||||
const singleExtensionWeight = record.productionExtensions.length === 1 ? 45 : 0;
|
||||
const noNonExtensionOwnersWeight = record.productionPackages.length === 0 ? 20 : 0;
|
||||
const runtimeWeight = record.isTypeOnlyCandidate ? 0 : 10;
|
||||
const usedWeight = record.productionRefCount > 0 ? 10 : 0;
|
||||
const publicSpecifierWeight = record.publicSpecifiers.length > 1 ? 5 : 0;
|
||||
const multiOwnerPenalty = record.productionOwners.length > 1 ? 35 : 0;
|
||||
const packagePenalty = record.productionPackages.length > 0 ? 25 : 0;
|
||||
return clampScore(
|
||||
singleExtensionWeight +
|
||||
noNonExtensionOwnersWeight +
|
||||
runtimeWeight +
|
||||
usedWeight +
|
||||
publicSpecifierWeight -
|
||||
multiOwnerPenalty -
|
||||
packagePenalty,
|
||||
);
|
||||
}
|
||||
|
||||
function createRecord(info: ReturnType<typeof canonicalSymbolInfo>): TopologyRecord {
|
||||
return {
|
||||
canonicalKey: info.canonicalKey,
|
||||
declarationPath: info.declarationPath,
|
||||
declarationLine: info.declarationLine,
|
||||
kind: info.kind,
|
||||
aliasName: info.aliasName,
|
||||
entrypoints: [],
|
||||
exportNames: [],
|
||||
publicSpecifiers: [],
|
||||
internalRefCount: 0,
|
||||
productionRefCount: 0,
|
||||
testRefCount: 0,
|
||||
internalImportCount: 0,
|
||||
productionImportCount: 0,
|
||||
testImportCount: 0,
|
||||
internalConsumers: [],
|
||||
productionConsumers: [],
|
||||
testConsumers: [],
|
||||
productionExtensions: [],
|
||||
productionPackages: [],
|
||||
productionOwners: [],
|
||||
isTypeOnlyCandidate: info.kind === "interface" || info.kind === "type",
|
||||
sharednessScore: 0,
|
||||
moveBackToOwnerScore: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function bucketConsumer(record: TopologyRecord, event: ReferenceEvent) {
|
||||
if (event.bucket === "internal") {
|
||||
record.internalImportCount += event.importCount;
|
||||
record.internalRefCount += event.usageCount;
|
||||
pushUnique(record.internalConsumers, event.consumerPath);
|
||||
return;
|
||||
}
|
||||
if (event.bucket === "test") {
|
||||
record.testImportCount += event.importCount;
|
||||
record.testRefCount += event.usageCount;
|
||||
pushUnique(record.testConsumers, event.consumerPath);
|
||||
return;
|
||||
}
|
||||
record.productionImportCount += event.importCount;
|
||||
record.productionRefCount += event.usageCount;
|
||||
pushUnique(record.productionConsumers, event.consumerPath);
|
||||
pushUnique(record.productionExtensions, event.extensionId);
|
||||
pushUnique(record.productionPackages, event.packageOwner);
|
||||
pushUnique(record.productionOwners, event.owner);
|
||||
}
|
||||
|
||||
function addEntrypointMetadata(
|
||||
record: TopologyRecord,
|
||||
entrypoint: PublicEntrypoint,
|
||||
exportName: string,
|
||||
aliasName?: string,
|
||||
) {
|
||||
pushUnique(record.entrypoints, entrypoint.entrypoint);
|
||||
pushUnique(record.exportNames, exportName);
|
||||
pushUnique(record.publicSpecifiers, entrypoint.importSpecifier);
|
||||
if (aliasName) {
|
||||
pushUnique(record.exportNames, aliasName);
|
||||
}
|
||||
}
|
||||
|
||||
function buildScopeMaps(context: ProgramContext, scope: TopologyScope) {
|
||||
const recordByCanonicalKey = new Map<string, TopologyRecord>();
|
||||
const recordBySpecifierAndExportName = new Map<string, Map<string, TopologyRecord>>();
|
||||
|
||||
for (const entrypoint of scope.entrypoints) {
|
||||
const absolutePath = path.join(context.repoRoot, entrypoint.sourcePath);
|
||||
const sourceFile = context.program.getSourceFile(absolutePath);
|
||||
if (!sourceFile) {
|
||||
continue;
|
||||
}
|
||||
const moduleSymbol = context.checker.getSymbolAtLocation(sourceFile);
|
||||
if (!moduleSymbol) {
|
||||
continue;
|
||||
}
|
||||
const exportMap = new Map<string, TopologyRecord>();
|
||||
for (const exportedSymbol of context.checker.getExportsOfModule(moduleSymbol)) {
|
||||
const info = canonicalSymbolInfo(context, exportedSymbol);
|
||||
let record = recordByCanonicalKey.get(info.canonicalKey);
|
||||
if (!record) {
|
||||
record = createRecord(info);
|
||||
recordByCanonicalKey.set(info.canonicalKey, record);
|
||||
}
|
||||
addEntrypointMetadata(record, entrypoint, exportedSymbol.getName(), info.aliasName);
|
||||
exportMap.set(exportedSymbol.getName(), record);
|
||||
}
|
||||
recordBySpecifierAndExportName.set(entrypoint.importSpecifier, exportMap);
|
||||
}
|
||||
|
||||
return { recordByCanonicalKey, recordBySpecifierAndExportName };
|
||||
}
|
||||
|
||||
function collectReferenceEvents(
|
||||
context: ProgramContext,
|
||||
scope: TopologyScope,
|
||||
recordBySpecifierAndExportName: Map<string, Map<string, TopologyRecord>>,
|
||||
includeTests: boolean,
|
||||
): ReferenceEvent[] {
|
||||
const events: ReferenceEvent[] = [];
|
||||
for (const sourceFile of context.program.getSourceFiles()) {
|
||||
if (sourceFile.isDeclarationFile) {
|
||||
continue;
|
||||
}
|
||||
const normalizedFileName = context.normalizePath(sourceFile.fileName);
|
||||
if (!normalizedFileName.startsWith(context.normalizePath(context.repoRoot))) {
|
||||
continue;
|
||||
}
|
||||
const relPath = context.relativeToRepo(sourceFile.fileName);
|
||||
const bucket = scope.classifyUsageBucket(relPath);
|
||||
if (!includeTests && bucket === "test") {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
|
||||
continue;
|
||||
}
|
||||
const importSpecifier = statement.moduleSpecifier.text.trim();
|
||||
if (!scope.importFilter(importSpecifier)) {
|
||||
continue;
|
||||
}
|
||||
const recordMap = recordBySpecifierAndExportName.get(importSpecifier);
|
||||
if (!recordMap) {
|
||||
continue;
|
||||
}
|
||||
const clause = statement.importClause;
|
||||
if (!clause?.namedBindings) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ts.isNamedImports(clause.namedBindings)) {
|
||||
for (const element of clause.namedBindings.elements) {
|
||||
const importedName = element.propertyName?.text ?? element.name.text;
|
||||
const record = recordMap.get(importedName);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
const localSymbol = context.checker.getSymbolAtLocation(element.name);
|
||||
if (!localSymbol) {
|
||||
continue;
|
||||
}
|
||||
events.push({
|
||||
canonicalKey: record.canonicalKey,
|
||||
bucket,
|
||||
consumerPath: relPath,
|
||||
usageCount: countIdentifierUsages(context, sourceFile, localSymbol, element.name.text),
|
||||
importCount: 1,
|
||||
importSpecifier,
|
||||
owner: bucket === "production" ? scope.ownerForPath(relPath) : null,
|
||||
extensionId: bucket === "production" ? scope.extensionForPath(relPath) : null,
|
||||
packageOwner: bucket === "production" ? scope.packageOwnerForPath(relPath) : null,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ts.isNamespaceImport(clause.namedBindings)) {
|
||||
const namespaceSymbol = context.checker.getSymbolAtLocation(clause.namedBindings.name);
|
||||
if (!namespaceSymbol) {
|
||||
continue;
|
||||
}
|
||||
for (const [exportedName, record] of recordMap.entries()) {
|
||||
const usageCount = countNamespacePropertyUsages(
|
||||
context,
|
||||
sourceFile,
|
||||
namespaceSymbol,
|
||||
exportedName,
|
||||
);
|
||||
if (usageCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
events.push({
|
||||
canonicalKey: record.canonicalKey,
|
||||
bucket,
|
||||
consumerPath: relPath,
|
||||
usageCount,
|
||||
importCount: 1,
|
||||
importSpecifier,
|
||||
owner: bucket === "production" ? scope.ownerForPath(relPath) : null,
|
||||
extensionId: bucket === "production" ? scope.extensionForPath(relPath) : null,
|
||||
packageOwner: bucket === "production" ? scope.packageOwnerForPath(relPath) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function finalizeRecords(records: TopologyRecord[]) {
|
||||
for (const record of records) {
|
||||
sortUnique(record.entrypoints);
|
||||
sortUnique(record.exportNames);
|
||||
sortUnique(record.publicSpecifiers);
|
||||
sortUnique(record.internalConsumers);
|
||||
sortUnique(record.productionConsumers);
|
||||
sortUnique(record.testConsumers);
|
||||
sortUnique(record.productionExtensions);
|
||||
sortUnique(record.productionPackages);
|
||||
sortUnique(record.productionOwners);
|
||||
record.isTypeOnlyCandidate = isTypeOnlyCandidate(record);
|
||||
record.sharednessScore = computeSharednessScore(record);
|
||||
record.moveBackToOwnerScore = computeMoveBackToOwnerScore(record);
|
||||
}
|
||||
return records.toSorted((left, right) => {
|
||||
const byRefs =
|
||||
right.productionRefCount +
|
||||
right.testRefCount +
|
||||
right.internalRefCount -
|
||||
(left.productionRefCount + left.testRefCount + left.internalRefCount);
|
||||
if (byRefs !== 0) {
|
||||
return byRefs;
|
||||
}
|
||||
return (
|
||||
left.publicSpecifiers[0]!.localeCompare(right.publicSpecifiers[0]) ||
|
||||
left.exportNames[0]!.localeCompare(right.exportNames[0])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function buildRankedCandidates(records: TopologyRecord[], limit: number): RankedCandidates {
|
||||
return {
|
||||
candidateToMove: records
|
||||
.filter(
|
||||
(record) =>
|
||||
record.productionOwners.length === 1 &&
|
||||
record.productionExtensions.length === 1 &&
|
||||
record.productionRefCount > 0,
|
||||
)
|
||||
.toSorted((left, right) => right.moveBackToOwnerScore - left.moveBackToOwnerScore)
|
||||
.slice(0, limit),
|
||||
duplicatedPublicExports: records
|
||||
.filter((record) => record.publicSpecifiers.length > 1)
|
||||
.toSorted((left, right) => right.publicSpecifiers.length - left.publicSpecifiers.length)
|
||||
.slice(0, limit),
|
||||
singleOwnerShared: records
|
||||
.filter((record) => record.productionOwners.length === 1 && record.productionImportCount > 0)
|
||||
.toSorted((left, right) => right.productionRefCount - left.productionRefCount)
|
||||
.slice(0, limit),
|
||||
};
|
||||
}
|
||||
|
||||
export function analyzeTopology(options: {
|
||||
repoRoot: string;
|
||||
scope: TopologyScope;
|
||||
report: TopologyReportName;
|
||||
includeTests?: boolean;
|
||||
limit?: number;
|
||||
tsconfigName?: string;
|
||||
}): TopologyEnvelope {
|
||||
const includeTests = options.includeTests ?? true;
|
||||
const limit = options.limit ?? 25;
|
||||
const context = createProgramContext(options.repoRoot, options.tsconfigName);
|
||||
const { recordByCanonicalKey, recordBySpecifierAndExportName } = buildScopeMaps(
|
||||
context,
|
||||
options.scope,
|
||||
);
|
||||
const events = collectReferenceEvents(
|
||||
context,
|
||||
options.scope,
|
||||
recordBySpecifierAndExportName,
|
||||
includeTests,
|
||||
);
|
||||
for (const event of events) {
|
||||
const record = recordByCanonicalKey.get(event.canonicalKey);
|
||||
if (record) {
|
||||
bucketConsumer(record, event);
|
||||
}
|
||||
}
|
||||
const allRecords = finalizeRecords([...recordByCanonicalKey.values()]);
|
||||
const filteredRecords = filterRecordsForReport(allRecords, options.report);
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
tool: "ts-topology",
|
||||
version: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
repoRevision: getRepoRevision(options.repoRoot),
|
||||
tsconfigPath: context.tsconfigPath,
|
||||
},
|
||||
scope: {
|
||||
id: options.scope.id,
|
||||
description: options.scope.description,
|
||||
repoRoot: options.repoRoot,
|
||||
entrypoints: options.scope.entrypoints,
|
||||
includeTests,
|
||||
},
|
||||
report: options.report,
|
||||
totals: {
|
||||
exports: allRecords.length,
|
||||
usedByProduction: allRecords.filter((record) => record.productionImportCount > 0).length,
|
||||
usedByTests: allRecords.filter((record) => record.testImportCount > 0).length,
|
||||
usedInternally: allRecords.filter((record) => record.internalImportCount > 0).length,
|
||||
singleOwnerShared: allRecords.filter(
|
||||
(record) => record.productionOwners.length === 1 && record.productionImportCount > 0,
|
||||
).length,
|
||||
unused: allRecords.filter(
|
||||
(record) =>
|
||||
record.productionImportCount === 0 &&
|
||||
record.testImportCount === 0 &&
|
||||
record.internalImportCount === 0,
|
||||
).length,
|
||||
},
|
||||
rankedCandidates: buildRankedCandidates(allRecords, limit),
|
||||
records: filteredRecords,
|
||||
};
|
||||
}
|
||||
|
||||
export function filterRecordsForReport(
|
||||
records: TopologyRecord[],
|
||||
report: TopologyReportName,
|
||||
): TopologyRecord[] {
|
||||
switch (report) {
|
||||
case "owner-map":
|
||||
return records.filter((record) => record.productionImportCount > 0);
|
||||
case "single-owner-shared":
|
||||
return records.filter(
|
||||
(record) => record.productionOwners.length === 1 && record.productionImportCount > 0,
|
||||
);
|
||||
case "unused-public-surface":
|
||||
return records.filter(
|
||||
(record) =>
|
||||
record.productionImportCount === 0 &&
|
||||
record.testImportCount === 0 &&
|
||||
record.internalImportCount === 0,
|
||||
);
|
||||
case "consumer-topology":
|
||||
return records.filter(
|
||||
(record) =>
|
||||
record.productionImportCount > 0 ||
|
||||
record.testImportCount > 0 ||
|
||||
record.internalImportCount > 0,
|
||||
);
|
||||
case "public-surface-usage":
|
||||
return records;
|
||||
}
|
||||
}
|
||||
175
scripts/lib/ts-topology/context.ts
Normal file
175
scripts/lib/ts-topology/context.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import ts from "typescript";
|
||||
import type { CanonicalSymbol, ProgramContext, SymbolKind } from "./types.js";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePath(filePath: string): string {
|
||||
return filePath.split(path.sep).join(path.posix.sep);
|
||||
}
|
||||
|
||||
export function createProgramContext(
|
||||
repoRoot: string,
|
||||
tsconfigName = "tsconfig.json",
|
||||
): ProgramContext {
|
||||
const configPath = ts.findConfigFile(
|
||||
repoRoot,
|
||||
(candidate) => ts.sys.fileExists(candidate),
|
||||
tsconfigName,
|
||||
);
|
||||
assert(configPath, `Could not find ${tsconfigName}`);
|
||||
const configFile = ts.readConfigFile(configPath, (candidate) => ts.sys.readFile(candidate));
|
||||
if (configFile.error) {
|
||||
throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n"));
|
||||
}
|
||||
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, repoRoot);
|
||||
const program = ts.createProgram(parsed.fileNames, parsed.options);
|
||||
return {
|
||||
repoRoot,
|
||||
tsconfigPath: normalizePath(path.relative(repoRoot, configPath)),
|
||||
program,
|
||||
checker: program.getTypeChecker(),
|
||||
normalizePath,
|
||||
relativeToRepo(filePath: string) {
|
||||
return normalizePath(path.relative(repoRoot, filePath));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function comparableSymbol(
|
||||
checker: ts.TypeChecker,
|
||||
symbol: ts.Symbol | undefined,
|
||||
): ts.Symbol | undefined {
|
||||
if (!symbol) {
|
||||
return undefined;
|
||||
}
|
||||
return symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
|
||||
}
|
||||
|
||||
function symbolKind(symbol: ts.Symbol, declaration: ts.Declaration | undefined): SymbolKind {
|
||||
if (declaration) {
|
||||
switch (declaration.kind) {
|
||||
case ts.SyntaxKind.FunctionDeclaration:
|
||||
return "function";
|
||||
case ts.SyntaxKind.ClassDeclaration:
|
||||
return "class";
|
||||
case ts.SyntaxKind.InterfaceDeclaration:
|
||||
return "interface";
|
||||
case ts.SyntaxKind.TypeAliasDeclaration:
|
||||
return "type";
|
||||
case ts.SyntaxKind.EnumDeclaration:
|
||||
return "enum";
|
||||
case ts.SyntaxKind.VariableDeclaration:
|
||||
return "variable";
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (symbol.flags & ts.SymbolFlags.Function) {
|
||||
return "function";
|
||||
}
|
||||
if (symbol.flags & ts.SymbolFlags.Class) {
|
||||
return "class";
|
||||
}
|
||||
if (symbol.flags & ts.SymbolFlags.Interface) {
|
||||
return "interface";
|
||||
}
|
||||
if (symbol.flags & ts.SymbolFlags.TypeAlias) {
|
||||
return "type";
|
||||
}
|
||||
if (symbol.flags & ts.SymbolFlags.Enum) {
|
||||
return "enum";
|
||||
}
|
||||
if (symbol.flags & ts.SymbolFlags.Variable) {
|
||||
return "variable";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function canonicalSymbolInfo(context: ProgramContext, symbol: ts.Symbol): CanonicalSymbol {
|
||||
const resolved = comparableSymbol(context.checker, symbol) ?? symbol;
|
||||
const declaration =
|
||||
resolved.getDeclarations()?.find((candidate) => candidate.kind !== ts.SyntaxKind.SourceFile) ??
|
||||
symbol.getDeclarations()?.find((candidate) => candidate.kind !== ts.SyntaxKind.SourceFile);
|
||||
assert(declaration, `Missing declaration for symbol ${symbol.getName()}`);
|
||||
const sourceFile = declaration.getSourceFile();
|
||||
const declarationPath = context.relativeToRepo(sourceFile.fileName);
|
||||
const declarationLine = sourceFile.getLineAndCharacterOfPosition(declaration.getStart()).line + 1;
|
||||
return {
|
||||
canonicalKey: `${declarationPath}:${declarationLine}:${resolved.getName()}`,
|
||||
declarationPath,
|
||||
declarationLine,
|
||||
kind: symbolKind(resolved, declaration),
|
||||
aliasName: symbol.getName() !== resolved.getName() ? symbol.getName() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function countIdentifierUsages(
|
||||
context: ProgramContext,
|
||||
sourceFile: ts.SourceFile,
|
||||
importedSymbol: ts.Symbol,
|
||||
localName: string,
|
||||
): number {
|
||||
const targetSymbol = comparableSymbol(context.checker, importedSymbol);
|
||||
let count = 0;
|
||||
const visit = (node: ts.Node) => {
|
||||
if (ts.isIdentifier(node) && node.text === localName) {
|
||||
const symbol = comparableSymbol(context.checker, context.checker.getSymbolAtLocation(node));
|
||||
if (
|
||||
symbol === targetSymbol &&
|
||||
!ts.isImportClause(node.parent) &&
|
||||
!ts.isImportSpecifier(node.parent)
|
||||
) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
ts.forEachChild(sourceFile, visit);
|
||||
return count;
|
||||
}
|
||||
|
||||
export function countNamespacePropertyUsages(
|
||||
context: ProgramContext,
|
||||
sourceFile: ts.SourceFile,
|
||||
namespaceSymbol: ts.Symbol,
|
||||
exportedName: string,
|
||||
): number {
|
||||
const targetSymbol = comparableSymbol(context.checker, namespaceSymbol);
|
||||
let count = 0;
|
||||
const visit = (node: ts.Node) => {
|
||||
if (
|
||||
ts.isPropertyAccessExpression(node) &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
node.name.text === exportedName
|
||||
) {
|
||||
const symbol = comparableSymbol(
|
||||
context.checker,
|
||||
context.checker.getSymbolAtLocation(node.expression),
|
||||
);
|
||||
if (symbol === targetSymbol) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
ts.forEachChild(sourceFile, visit);
|
||||
return count;
|
||||
}
|
||||
|
||||
export function getRepoRevision(repoRoot: string): string | null {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
114
scripts/lib/ts-topology/reports.ts
Normal file
114
scripts/lib/ts-topology/reports.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { ReportModule, TopologyEnvelope, TopologyRecord } from "./types.js";
|
||||
|
||||
function canonicalExportName(record: TopologyRecord): string {
|
||||
const finalColon = record.canonicalKey.lastIndexOf(":");
|
||||
return finalColon >= 0
|
||||
? record.canonicalKey.slice(finalColon + 1)
|
||||
: (record.exportNames[0] ?? "<unknown>");
|
||||
}
|
||||
|
||||
function primarySymbol(record: TopologyRecord): string {
|
||||
return `${record.publicSpecifiers[0] ?? "<unknown>"}:${canonicalExportName(record)}`;
|
||||
}
|
||||
|
||||
function formatRecordLine(record: TopologyRecord): string {
|
||||
return (
|
||||
`- ${primarySymbol(record)} -> ${record.declarationPath}:${record.declarationLine} ` +
|
||||
`(prodRefs=${record.productionRefCount}, owners=${record.productionOwners.join(",") || "-"}, ` +
|
||||
`sharedness=${record.sharednessScore}, move=${record.moveBackToOwnerScore})`
|
||||
);
|
||||
}
|
||||
|
||||
const reportModules: Record<ReportModule["name"], ReportModule> = {
|
||||
"public-surface-usage": {
|
||||
name: "public-surface-usage",
|
||||
describe(envelope, limit) {
|
||||
const candidates = envelope.rankedCandidates?.candidateToMove ?? [];
|
||||
const duplicateExports = envelope.rankedCandidates?.duplicatedPublicExports ?? [];
|
||||
return [
|
||||
`Scope: ${envelope.scope.id}`,
|
||||
`Public exports analyzed: ${envelope.totals.exports}`,
|
||||
`Production-used exports: ${envelope.totals.usedByProduction}`,
|
||||
`Single-owner shared exports: ${envelope.totals.singleOwnerShared}`,
|
||||
`Unused public exports: ${envelope.totals.unused}`,
|
||||
"",
|
||||
`Top ${Math.min(limit, candidates.length)} candidate-to-move exports:`,
|
||||
...candidates.slice(0, limit).map(formatRecordLine),
|
||||
"",
|
||||
`Top ${Math.min(limit, duplicateExports.length)} duplicated public exports:`,
|
||||
...duplicateExports
|
||||
.slice(0, limit)
|
||||
.map(
|
||||
(record) =>
|
||||
`- ${primarySymbol(record)} via ${record.publicSpecifiers.join(", ")} ` +
|
||||
`(${record.declarationPath}:${record.declarationLine})`,
|
||||
),
|
||||
].join("\n");
|
||||
},
|
||||
},
|
||||
"owner-map": {
|
||||
name: "owner-map",
|
||||
describe(envelope, limit) {
|
||||
return [
|
||||
`Scope: ${envelope.scope.id}`,
|
||||
`Production-owned records: ${envelope.records.length}`,
|
||||
"",
|
||||
`Top ${Math.min(limit, envelope.records.length)} owner-map records:`,
|
||||
...envelope.records
|
||||
.slice(0, limit)
|
||||
.map(
|
||||
(record) =>
|
||||
`- ${primarySymbol(record)} owners=${record.productionOwners.join(",")} ` +
|
||||
`extensions=${record.productionExtensions.join(",") || "-"} ` +
|
||||
`packages=${record.productionPackages.join(",") || "-"}`,
|
||||
),
|
||||
].join("\n");
|
||||
},
|
||||
},
|
||||
"single-owner-shared": {
|
||||
name: "single-owner-shared",
|
||||
describe(envelope, limit) {
|
||||
return [
|
||||
`Scope: ${envelope.scope.id}`,
|
||||
`Single-owner shared exports: ${envelope.records.length}`,
|
||||
"",
|
||||
`Top ${Math.min(limit, envelope.records.length)} single-owner shared exports:`,
|
||||
...envelope.records.slice(0, limit).map(formatRecordLine),
|
||||
].join("\n");
|
||||
},
|
||||
},
|
||||
"unused-public-surface": {
|
||||
name: "unused-public-surface",
|
||||
describe(envelope, limit) {
|
||||
return [
|
||||
`Scope: ${envelope.scope.id}`,
|
||||
`Unused public exports: ${envelope.records.length}`,
|
||||
"",
|
||||
`Top ${Math.min(limit, envelope.records.length)} unused exports:`,
|
||||
...envelope.records.slice(0, limit).map(formatRecordLine),
|
||||
].join("\n");
|
||||
},
|
||||
},
|
||||
"consumer-topology": {
|
||||
name: "consumer-topology",
|
||||
describe(envelope, limit) {
|
||||
return [
|
||||
`Scope: ${envelope.scope.id}`,
|
||||
`Records with consumers: ${envelope.records.length}`,
|
||||
"",
|
||||
`Top ${Math.min(limit, envelope.records.length)} consumer-topology records:`,
|
||||
...envelope.records
|
||||
.slice(0, limit)
|
||||
.map(
|
||||
(record) =>
|
||||
`- ${primarySymbol(record)} prod=${record.productionConsumers.length} ` +
|
||||
`test=${record.testConsumers.length} internal=${record.internalConsumers.length}`,
|
||||
),
|
||||
].join("\n");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function renderTextReport(envelope: TopologyEnvelope, limit: number): string {
|
||||
return reportModules[envelope.report].describe(envelope, limit);
|
||||
}
|
||||
160
scripts/lib/ts-topology/scope.ts
Normal file
160
scripts/lib/ts-topology/scope.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pluginSdkEntrypoints } from "../plugin-sdk-entries.mjs";
|
||||
import type { ConsumerScope, PublicEntrypoint, TopologyScope, UsageBucket } from "./types.js";
|
||||
|
||||
function isTestFile(relPath: string): boolean {
|
||||
return (
|
||||
relPath.startsWith("test/") ||
|
||||
relPath.includes("/__tests__/") ||
|
||||
relPath.includes(".test.") ||
|
||||
relPath.includes(".spec.") ||
|
||||
relPath.includes(".e2e.") ||
|
||||
relPath.includes(".suite.") ||
|
||||
relPath.includes("test-harness") ||
|
||||
relPath.includes("test-support") ||
|
||||
relPath.includes("test-helper") ||
|
||||
relPath.includes("test-utils")
|
||||
);
|
||||
}
|
||||
|
||||
function classifyScope(relPath: string): ConsumerScope {
|
||||
if (relPath.startsWith("extensions/")) {
|
||||
return "extension";
|
||||
}
|
||||
if (relPath.startsWith("packages/")) {
|
||||
return "package";
|
||||
}
|
||||
if (relPath.startsWith("apps/")) {
|
||||
return "app";
|
||||
}
|
||||
if (relPath.startsWith("ui/")) {
|
||||
return "ui";
|
||||
}
|
||||
if (relPath.startsWith("scripts/")) {
|
||||
return "script";
|
||||
}
|
||||
if (relPath.startsWith("src/")) {
|
||||
return "src";
|
||||
}
|
||||
if (relPath.startsWith("test/")) {
|
||||
return "test";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function classifyUsageBucketForRoots(internalRoots: string[], relPath: string): UsageBucket {
|
||||
if (internalRoots.some((root) => relPath === root || relPath.startsWith(`${root}/`))) {
|
||||
return "internal";
|
||||
}
|
||||
return isTestFile(relPath) ? "test" : "production";
|
||||
}
|
||||
|
||||
function extractOwner(relPath: string): string | null {
|
||||
const scope = classifyScope(relPath);
|
||||
const parts = relPath.split("/");
|
||||
switch (scope) {
|
||||
case "extension":
|
||||
return parts[1] ? `extension:${parts[1]}` : "extension";
|
||||
case "package":
|
||||
return parts[1] ? `package:${parts[1]}` : "package";
|
||||
case "app":
|
||||
return parts[1] ? `app:${parts[1]}` : "app";
|
||||
case "src":
|
||||
return "src";
|
||||
case "ui":
|
||||
return "ui";
|
||||
case "script":
|
||||
return "scripts";
|
||||
case "other":
|
||||
return parts[0] || "other";
|
||||
case "test":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractExtensionId(relPath: string): string | null {
|
||||
if (!relPath.startsWith("extensions/")) {
|
||||
return null;
|
||||
}
|
||||
const parts = relPath.split("/");
|
||||
return parts[1] ?? null;
|
||||
}
|
||||
|
||||
function extractPackageOwner(relPath: string): string | null {
|
||||
const owner = extractOwner(relPath);
|
||||
return owner?.startsWith("extension:") ? null : owner;
|
||||
}
|
||||
|
||||
function buildScopeFromEntrypoints(
|
||||
id: string,
|
||||
description: string,
|
||||
entrypoints: PublicEntrypoint[],
|
||||
): TopologyScope {
|
||||
const internalRoots = [
|
||||
...new Set(entrypoints.map((entrypoint) => path.posix.dirname(entrypoint.sourcePath))),
|
||||
];
|
||||
const publicSpecifiers = new Set(entrypoints.map((entrypoint) => entrypoint.importSpecifier));
|
||||
return {
|
||||
id,
|
||||
description,
|
||||
entrypoints,
|
||||
importFilter(specifier: string) {
|
||||
return publicSpecifiers.has(specifier);
|
||||
},
|
||||
classifyUsageBucket(relPath: string) {
|
||||
return classifyUsageBucketForRoots(internalRoots, relPath);
|
||||
},
|
||||
classifyScope,
|
||||
ownerForPath(relPath: string) {
|
||||
return extractOwner(relPath);
|
||||
},
|
||||
extensionForPath(relPath: string) {
|
||||
return extractExtensionId(relPath);
|
||||
},
|
||||
packageOwnerForPath(relPath: string) {
|
||||
return extractPackageOwner(relPath);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginSdkScope(_repoRoot: string): TopologyScope {
|
||||
const entrypoints = pluginSdkEntrypoints.map((entrypoint) => ({
|
||||
entrypoint,
|
||||
sourcePath: `src/plugin-sdk/${entrypoint}.ts`,
|
||||
importSpecifier:
|
||||
entrypoint === "index" ? "openclaw/plugin-sdk" : `openclaw/plugin-sdk/${entrypoint}`,
|
||||
}));
|
||||
return buildScopeFromEntrypoints("plugin-sdk", "OpenClaw plugin-sdk public surface", entrypoints);
|
||||
}
|
||||
|
||||
export function createFilesystemPublicSurfaceScope(
|
||||
repoRoot: string,
|
||||
options: {
|
||||
id: string;
|
||||
description?: string;
|
||||
entrypointRoot: string;
|
||||
importPrefix: string;
|
||||
},
|
||||
): TopologyScope {
|
||||
const absoluteRoot = path.join(repoRoot, options.entrypointRoot);
|
||||
const entries = fs
|
||||
.readdirSync(absoluteRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
|
||||
.map((entry) => entry.name)
|
||||
.toSorted();
|
||||
const publicEntrypoints = entries.map((fileName) => {
|
||||
const entrypoint = fileName.replace(/\.ts$/, "");
|
||||
return {
|
||||
entrypoint,
|
||||
sourcePath: path.posix.join(options.entrypointRoot, fileName),
|
||||
importSpecifier:
|
||||
entrypoint === "index" ? options.importPrefix : `${options.importPrefix}/${entrypoint}`,
|
||||
};
|
||||
});
|
||||
return buildScopeFromEntrypoints(
|
||||
options.id,
|
||||
options.description ?? `Public surface rooted at ${options.entrypointRoot}`,
|
||||
publicEntrypoints,
|
||||
);
|
||||
}
|
||||
137
scripts/lib/ts-topology/types.ts
Normal file
137
scripts/lib/ts-topology/types.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type ts from "typescript";
|
||||
|
||||
export type UsageBucket = "internal" | "production" | "test";
|
||||
|
||||
export type ConsumerScope =
|
||||
| "src"
|
||||
| "extension"
|
||||
| "package"
|
||||
| "app"
|
||||
| "ui"
|
||||
| "script"
|
||||
| "test"
|
||||
| "other";
|
||||
|
||||
export type TopologyReportName =
|
||||
| "public-surface-usage"
|
||||
| "owner-map"
|
||||
| "single-owner-shared"
|
||||
| "unused-public-surface"
|
||||
| "consumer-topology";
|
||||
|
||||
export type SymbolKind =
|
||||
| "function"
|
||||
| "class"
|
||||
| "interface"
|
||||
| "type"
|
||||
| "enum"
|
||||
| "variable"
|
||||
| "unknown";
|
||||
|
||||
export type ProgramContext = {
|
||||
repoRoot: string;
|
||||
tsconfigPath: string;
|
||||
program: ts.Program;
|
||||
checker: ts.TypeChecker;
|
||||
normalizePath: (filePath: string) => string;
|
||||
relativeToRepo: (filePath: string) => string;
|
||||
};
|
||||
|
||||
export type CanonicalSymbol = {
|
||||
canonicalKey: string;
|
||||
declarationPath: string;
|
||||
declarationLine: number;
|
||||
kind: SymbolKind;
|
||||
aliasName?: string;
|
||||
};
|
||||
|
||||
export type PublicEntrypoint = {
|
||||
entrypoint: string;
|
||||
sourcePath: string;
|
||||
importSpecifier: string;
|
||||
};
|
||||
|
||||
export type ReferenceEvent = {
|
||||
canonicalKey: string;
|
||||
bucket: UsageBucket;
|
||||
consumerPath: string;
|
||||
usageCount: number;
|
||||
importCount: number;
|
||||
importSpecifier: string;
|
||||
owner: string | null;
|
||||
extensionId: string | null;
|
||||
packageOwner: string | null;
|
||||
};
|
||||
|
||||
export type TopologyRecord = CanonicalSymbol & {
|
||||
entrypoints: string[];
|
||||
exportNames: string[];
|
||||
publicSpecifiers: string[];
|
||||
internalRefCount: number;
|
||||
productionRefCount: number;
|
||||
testRefCount: number;
|
||||
internalImportCount: number;
|
||||
productionImportCount: number;
|
||||
testImportCount: number;
|
||||
internalConsumers: string[];
|
||||
productionConsumers: string[];
|
||||
testConsumers: string[];
|
||||
productionExtensions: string[];
|
||||
productionPackages: string[];
|
||||
productionOwners: string[];
|
||||
isTypeOnlyCandidate: boolean;
|
||||
sharednessScore: number;
|
||||
moveBackToOwnerScore: number;
|
||||
};
|
||||
|
||||
export type TopologyScope = {
|
||||
id: string;
|
||||
description: string;
|
||||
entrypoints: PublicEntrypoint[];
|
||||
importFilter: (specifier: string) => boolean;
|
||||
classifyUsageBucket: (relPath: string) => UsageBucket;
|
||||
classifyScope: (relPath: string) => ConsumerScope;
|
||||
ownerForPath: (relPath: string) => string | null;
|
||||
extensionForPath: (relPath: string) => string | null;
|
||||
packageOwnerForPath: (relPath: string) => string | null;
|
||||
};
|
||||
|
||||
export type RankedCandidates = {
|
||||
candidateToMove: TopologyRecord[];
|
||||
duplicatedPublicExports: TopologyRecord[];
|
||||
singleOwnerShared: TopologyRecord[];
|
||||
};
|
||||
|
||||
export type TopologyEnvelope = {
|
||||
metadata: {
|
||||
tool: "ts-topology";
|
||||
version: 1;
|
||||
generatedAt: string;
|
||||
repoRevision: string | null;
|
||||
tsconfigPath: string;
|
||||
};
|
||||
scope: {
|
||||
id: string;
|
||||
description: string;
|
||||
repoRoot: string;
|
||||
entrypoints: PublicEntrypoint[];
|
||||
includeTests: boolean;
|
||||
};
|
||||
report: TopologyReportName;
|
||||
totals: {
|
||||
exports: number;
|
||||
usedByProduction: number;
|
||||
usedByTests: number;
|
||||
usedInternally: number;
|
||||
singleOwnerShared: number;
|
||||
unused: number;
|
||||
};
|
||||
rankedCandidates?: RankedCandidates;
|
||||
records: TopologyRecord[];
|
||||
};
|
||||
|
||||
export type ReportModule = {
|
||||
name: TopologyReportName;
|
||||
describe: (envelope: TopologyEnvelope, limit: number) => string;
|
||||
filterRecords?: (record: TopologyRecord) => boolean;
|
||||
};
|
||||
171
scripts/ts-topology.ts
Normal file
171
scripts/ts-topology.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
#!/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<TopologyReportName>([
|
||||
"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=<plugin-sdk|custom> Built-in or custom scope",
|
||||
" --entrypoint-root=<path> Required for --scope=custom",
|
||||
" --import-prefix=<specifier> Required for --scope=custom",
|
||||
" --report=<name> public-surface-usage | owner-map | single-owner-shared | unused-public-surface | consumer-topology",
|
||||
" --json Emit JSON",
|
||||
" --limit=<n> Limit ranked/text output (default: 25)",
|
||||
" --exclude-tests Ignore test consumers",
|
||||
" --repo-root=<path> Override repo root",
|
||||
" --tsconfig=<name> 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<number> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
13
test/fixtures/ts-topology/basic/extensions/alpha/src/use.ts
vendored
Normal file
13
test/fixtures/ts-topology/basic/extensions/alpha/src/use.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import { aliasedThing as renamedThing, sharedThing, singleOwnerHelper } from "fixture-sdk";
|
||||
import type { SharedType } from "fixture-sdk";
|
||||
import * as extra from "fixture-sdk/extra";
|
||||
|
||||
export function alphaUse(input: SharedType) {
|
||||
return [
|
||||
sharedThing(),
|
||||
singleOwnerHelper(),
|
||||
renamedThing(),
|
||||
extra.sharedThing(),
|
||||
input.value,
|
||||
].join(":");
|
||||
}
|
||||
6
test/fixtures/ts-topology/basic/extensions/beta/src/use.ts
vendored
Normal file
6
test/fixtures/ts-topology/basic/extensions/beta/src/use.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import { sharedThing } from "fixture-sdk";
|
||||
import type { SharedType } from "fixture-sdk";
|
||||
|
||||
export function betaUse(input: SharedType) {
|
||||
return `${sharedThing()}:${input.value}`;
|
||||
}
|
||||
5
test/fixtures/ts-topology/basic/src/internal/use.ts
vendored
Normal file
5
test/fixtures/ts-topology/basic/src/internal/use.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
import { sharedThing } from "fixture-sdk";
|
||||
|
||||
export function internalUse() {
|
||||
return sharedThing();
|
||||
}
|
||||
23
test/fixtures/ts-topology/basic/src/lib/shared.ts
vendored
Normal file
23
test/fixtures/ts-topology/basic/src/lib/shared.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
export function sharedThing() {
|
||||
return "shared";
|
||||
}
|
||||
|
||||
export function singleOwnerHelper() {
|
||||
return "single-owner";
|
||||
}
|
||||
|
||||
export function aliasedThing() {
|
||||
return "aliased";
|
||||
}
|
||||
|
||||
export function testOnlyThing() {
|
||||
return "test-only";
|
||||
}
|
||||
|
||||
export function unusedThing() {
|
||||
return "unused";
|
||||
}
|
||||
|
||||
export type SharedType = {
|
||||
value: string;
|
||||
};
|
||||
1
test/fixtures/ts-topology/basic/src/public/extra.ts
vendored
Normal file
1
test/fixtures/ts-topology/basic/src/public/extra.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export { sharedThing } from "../lib/shared.js";
|
||||
9
test/fixtures/ts-topology/basic/src/public/index.ts
vendored
Normal file
9
test/fixtures/ts-topology/basic/src/public/index.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
aliasedThing,
|
||||
sharedThing,
|
||||
singleOwnerHelper,
|
||||
testOnlyThing,
|
||||
unusedThing,
|
||||
} from "../lib/shared.js";
|
||||
export { sharedThing as aliasedSharedThing } from "../lib/shared.js";
|
||||
export type { SharedType } from "../lib/shared.js";
|
||||
5
test/fixtures/ts-topology/basic/tests/public.test.ts
vendored
Normal file
5
test/fixtures/ts-topology/basic/tests/public.test.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
import { testOnlyThing } from "fixture-sdk";
|
||||
|
||||
export function testUse() {
|
||||
return testOnlyThing();
|
||||
}
|
||||
14
test/fixtures/ts-topology/basic/tsconfig.json
vendored
Normal file
14
test/fixtures/ts-topology/basic/tsconfig.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"paths": {
|
||||
"fixture-sdk": ["./src/public/index.ts"],
|
||||
"fixture-sdk/*": ["./src/public/*"]
|
||||
},
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "extensions/**/*.ts", "packages/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
154
test/scripts/ts-topology.test.ts
Normal file
154
test/scripts/ts-topology.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { analyzeTopology } from "../../scripts/lib/ts-topology/analyze.js";
|
||||
import { renderTextReport } from "../../scripts/lib/ts-topology/reports.js";
|
||||
import { createFilesystemPublicSurfaceScope } from "../../scripts/lib/ts-topology/scope.js";
|
||||
import { main } from "../../scripts/ts-topology.ts";
|
||||
import { createCapturedIo } from "../helpers/captured-io.js";
|
||||
|
||||
const repoRoot = path.join(process.cwd(), "test", "fixtures", "ts-topology", "basic");
|
||||
|
||||
function buildFixtureScope() {
|
||||
return createFilesystemPublicSurfaceScope(repoRoot, {
|
||||
id: "custom",
|
||||
entrypointRoot: "src/public",
|
||||
importPrefix: "fixture-sdk",
|
||||
});
|
||||
}
|
||||
|
||||
describe("ts-topology", () => {
|
||||
it("collapses canonical symbols exported by multiple public subpaths", () => {
|
||||
const envelope = analyzeTopology({
|
||||
repoRoot,
|
||||
scope: buildFixtureScope(),
|
||||
report: "public-surface-usage",
|
||||
});
|
||||
const sharedThing = envelope.records.find((record) =>
|
||||
record.exportNames.includes("sharedThing"),
|
||||
);
|
||||
|
||||
expect(sharedThing).toMatchObject({
|
||||
declarationPath: "src/lib/shared.ts",
|
||||
declarationLine: 1,
|
||||
productionExtensions: ["alpha", "beta"],
|
||||
productionPackages: ["package:core", "src"],
|
||||
productionOwners: ["extension:alpha", "extension:beta", "package:core", "src"],
|
||||
});
|
||||
expect(sharedThing?.publicSpecifiers).toEqual(["fixture-sdk", "fixture-sdk/extra"]);
|
||||
});
|
||||
|
||||
it("counts renamed imports, namespace imports, type-only imports, and test-only consumers", () => {
|
||||
const envelope = analyzeTopology({
|
||||
repoRoot,
|
||||
scope: buildFixtureScope(),
|
||||
report: "public-surface-usage",
|
||||
});
|
||||
const aliasedThing = envelope.records.find((record) =>
|
||||
record.exportNames.includes("aliasedThing"),
|
||||
);
|
||||
const sharedType = envelope.records.find((record) => record.exportNames.includes("SharedType"));
|
||||
const testOnlyThing = envelope.records.find((record) =>
|
||||
record.exportNames.includes("testOnlyThing"),
|
||||
);
|
||||
|
||||
expect(aliasedThing?.productionRefCount).toBe(1);
|
||||
expect(sharedType).toMatchObject({
|
||||
isTypeOnlyCandidate: true,
|
||||
productionExtensions: ["alpha", "beta"],
|
||||
productionRefCount: 2,
|
||||
});
|
||||
expect(testOnlyThing).toMatchObject({
|
||||
productionRefCount: 0,
|
||||
testRefCount: 1,
|
||||
testConsumers: ["tests/public.test.ts"],
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces single-owner shared and unused reports correctly", () => {
|
||||
const singleOwner = analyzeTopology({
|
||||
repoRoot,
|
||||
scope: buildFixtureScope(),
|
||||
report: "single-owner-shared",
|
||||
});
|
||||
const unused = analyzeTopology({
|
||||
repoRoot,
|
||||
scope: buildFixtureScope(),
|
||||
report: "unused-public-surface",
|
||||
});
|
||||
|
||||
expect(singleOwner.records.map((record) => record.exportNames[0])).toContain(
|
||||
"singleOwnerHelper",
|
||||
);
|
||||
expect(singleOwner.records.map((record) => record.exportNames[0])).not.toContain("sharedThing");
|
||||
expect(unused.records.map((record) => record.exportNames[0])).toEqual(["unusedThing"]);
|
||||
});
|
||||
|
||||
it("renders stable text summaries for the public-surface report", () => {
|
||||
const envelope = analyzeTopology({
|
||||
repoRoot,
|
||||
scope: buildFixtureScope(),
|
||||
report: "public-surface-usage",
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
expect(renderTextReport(envelope, 3)).toMatchInlineSnapshot(`
|
||||
"Scope: custom
|
||||
Public exports analyzed: 6
|
||||
Production-used exports: 4
|
||||
Single-owner shared exports: 2
|
||||
Unused public exports: 1
|
||||
|
||||
Top 2 candidate-to-move exports:
|
||||
- fixture-sdk:aliasedThing -> src/lib/shared.ts:9 (prodRefs=1, owners=extension:alpha, sharedness=35, move=85)
|
||||
- fixture-sdk:singleOwnerHelper -> src/lib/shared.ts:5 (prodRefs=1, owners=extension:alpha, sharedness=35, move=85)
|
||||
|
||||
Top 1 duplicated public exports:
|
||||
- fixture-sdk:sharedThing via fixture-sdk, fixture-sdk/extra (src/lib/shared.ts:1)"
|
||||
`);
|
||||
});
|
||||
|
||||
it("emits stable JSON and filtered report output through the CLI", async () => {
|
||||
const captured = createCapturedIo();
|
||||
const jsonExit = await main(
|
||||
[
|
||||
"--scope=custom",
|
||||
"--entrypoint-root=src/public",
|
||||
"--import-prefix=fixture-sdk",
|
||||
"--repo-root=test/fixtures/ts-topology/basic",
|
||||
"--report=single-owner-shared",
|
||||
"--json",
|
||||
],
|
||||
captured.io,
|
||||
);
|
||||
|
||||
expect(jsonExit).toBe(0);
|
||||
const payload = JSON.parse(captured.readStdout());
|
||||
expect(payload.report).toBe("single-owner-shared");
|
||||
expect(
|
||||
payload.records.map((record: { exportNames: string[] }) => record.exportNames[0]),
|
||||
).toEqual(["aliasedThing", "singleOwnerHelper"]);
|
||||
|
||||
const textCaptured = createCapturedIo();
|
||||
const textExit = await main(
|
||||
[
|
||||
"--scope=custom",
|
||||
"--entrypoint-root=src/public",
|
||||
"--import-prefix=fixture-sdk",
|
||||
"--repo-root=test/fixtures/ts-topology/basic",
|
||||
"--report=consumer-topology",
|
||||
"--limit=2",
|
||||
],
|
||||
textCaptured.io,
|
||||
);
|
||||
expect(textExit).toBe(0);
|
||||
expect(textCaptured.readStdout()).toMatchInlineSnapshot(`
|
||||
"Scope: custom
|
||||
Records with consumers: 5
|
||||
|
||||
Top 2 consumer-topology records:
|
||||
- fixture-sdk:sharedThing prod=4 test=0 internal=0
|
||||
- fixture-sdk:SharedType prod=2 test=0 internal=0
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user