211 lines
5.9 KiB
JavaScript
Executable File
211 lines
5.9 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { parseArgs } from "node:util";
|
|
import { Node, Project, SyntaxKind } from "ts-morph";
|
|
|
|
const projectRoot = process.cwd();
|
|
const MAX_EDGES = Number(process.env.MAX_GRAPH_EDGES ?? "450");
|
|
|
|
const {
|
|
values: { source: sourceArgsRaw, output: outputArg },
|
|
} = parseArgs({
|
|
options: {
|
|
source: {
|
|
type: "string",
|
|
multiple: true,
|
|
},
|
|
output: {
|
|
type: "string",
|
|
},
|
|
},
|
|
});
|
|
|
|
function collectFunctionEntries(sourceFile) {
|
|
const entries = [];
|
|
const namesSeen = new Set();
|
|
|
|
for (const fn of sourceFile.getFunctions()) {
|
|
const name = fn.getName();
|
|
if (!name || namesSeen.has(name)) {
|
|
continue;
|
|
}
|
|
namesSeen.add(name);
|
|
entries.push({ name, node: fn });
|
|
}
|
|
|
|
for (const declaration of sourceFile.getVariableDeclarations()) {
|
|
const name = declaration.getName();
|
|
if (namesSeen.has(name)) {
|
|
continue;
|
|
}
|
|
|
|
const initializer = declaration.getInitializer();
|
|
if (!initializer) {
|
|
continue;
|
|
}
|
|
if (!Node.isArrowFunction(initializer) && !Node.isFunctionExpression(initializer)) {
|
|
continue;
|
|
}
|
|
|
|
namesSeen.add(name);
|
|
entries.push({ name, node: initializer });
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function nodeId(filePath, functionName) {
|
|
return `${filePath}__${functionName}`.replace(/[^A-Za-z0-9_]/g, "_");
|
|
}
|
|
|
|
function normalizeRelativePath(filePath) {
|
|
return filePath.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
}
|
|
|
|
function normalizeSourceArg(sourceArg) {
|
|
const projectRelative = path.isAbsolute(sourceArg)
|
|
? path.relative(projectRoot, sourceArg)
|
|
: sourceArg;
|
|
return normalizeRelativePath(projectRelative).replace(/\/+$/, "");
|
|
}
|
|
|
|
const functionsByFile = new Map();
|
|
const allFunctionNames = new Set();
|
|
const functionEntriesByFile = new Map();
|
|
|
|
const project = new Project({
|
|
tsConfigFilePath: path.join(projectRoot, "tsconfig.json"),
|
|
});
|
|
const sourceFiles = project
|
|
.getSourceFiles()
|
|
.filter((sourceFile) => !sourceFile.isDeclarationFile())
|
|
.sort((a, b) => a.getFilePath().localeCompare(b.getFilePath()));
|
|
|
|
const sourceContext = sourceFiles.map((sourceFile) => ({
|
|
sourceFile,
|
|
relativePath: normalizeRelativePath(path.relative(projectRoot, sourceFile.getFilePath())),
|
|
}));
|
|
|
|
const filteredSourceFiles = (() => {
|
|
if (!sourceArgsRaw || sourceArgsRaw.length === 0) {
|
|
return sourceContext.map((item) => item.sourceFile);
|
|
}
|
|
|
|
const sourceArgs = sourceArgsRaw.map((sourceArg) => normalizeSourceArg(sourceArg));
|
|
const matchedSourceFiles = new Set();
|
|
|
|
for (const sourceArg of sourceArgs) {
|
|
const hasMatch = sourceContext.some((item) => (
|
|
item.relativePath === sourceArg
|
|
|| item.relativePath.startsWith(`${sourceArg}/`)
|
|
));
|
|
|
|
if (!hasMatch) {
|
|
throw new Error(`No source file matched --source=${sourceArg}`);
|
|
}
|
|
|
|
for (const item of sourceContext) {
|
|
if (item.relativePath === sourceArg || item.relativePath.startsWith(`${sourceArg}/`)) {
|
|
matchedSourceFiles.add(item.sourceFile);
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...matchedSourceFiles];
|
|
})();
|
|
|
|
for (const sourceFile of filteredSourceFiles) {
|
|
const absolutePath = sourceFile.getFilePath();
|
|
|
|
const relativePath = path.relative(projectRoot, absolutePath).replaceAll("\\", "/");
|
|
const functionEntries = collectFunctionEntries(sourceFile);
|
|
const functionNames = functionEntries.map((entry) => entry.name);
|
|
|
|
functionsByFile.set(relativePath, functionNames);
|
|
functionEntriesByFile.set(relativePath, functionEntries);
|
|
for (const functionName of functionNames) {
|
|
allFunctionNames.add(functionName);
|
|
}
|
|
}
|
|
|
|
const firstNodeForFunction = new Map();
|
|
for (const [relativePath, functionNames] of functionsByFile.entries()) {
|
|
for (const functionName of functionNames) {
|
|
if (!firstNodeForFunction.has(functionName)) {
|
|
firstNodeForFunction.set(functionName, nodeId(relativePath, functionName));
|
|
}
|
|
}
|
|
}
|
|
|
|
const edgeSet = new Set();
|
|
for (const functionEntries of functionEntriesByFile.values()) {
|
|
for (const { name: sourceName, node } of functionEntries) {
|
|
const body = node.getBody();
|
|
if (!body) {
|
|
continue;
|
|
}
|
|
|
|
const sourceNode = firstNodeForFunction.get(sourceName);
|
|
if (!sourceNode) {
|
|
continue;
|
|
}
|
|
|
|
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
const expression = call.getExpression();
|
|
let targetName;
|
|
|
|
if (Node.isIdentifier(expression)) {
|
|
targetName = expression.getText();
|
|
} else if (Node.isPropertyAccessExpression(expression)) {
|
|
targetName = expression.getName();
|
|
}
|
|
|
|
if (!targetName || targetName === sourceName || !allFunctionNames.has(targetName)) {
|
|
continue;
|
|
}
|
|
|
|
const targetNode = firstNodeForFunction.get(targetName);
|
|
if (targetNode) {
|
|
edgeSet.add(`${sourceNode} --> ${targetNode}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let diagram = "%% Function Dependency Graph (`src/**/*.ts`)\n";
|
|
diagram += "%% Generated from current package source files.\n";
|
|
diagram += "flowchart LR\n";
|
|
|
|
for (const [relativePath, functionNames] of functionsByFile.entries()) {
|
|
if (functionNames.length === 0) {
|
|
continue;
|
|
}
|
|
const subgraphId = relativePath.replace(/[^A-Za-z0-9_]/g, "_");
|
|
diagram += ` subgraph ${subgraphId}[\"${relativePath}\"]\n`;
|
|
for (const functionName of functionNames) {
|
|
diagram += ` ${nodeId(relativePath, functionName)}[\"${functionName}\"]\n`;
|
|
}
|
|
diagram += " end\n";
|
|
}
|
|
|
|
const sortedEdges = [...edgeSet].sort();
|
|
const emittedEdges = sortedEdges.slice(0, MAX_EDGES);
|
|
for (const edge of emittedEdges) {
|
|
diagram += ` ${edge}\n`;
|
|
}
|
|
|
|
if (sortedEdges.length > MAX_EDGES) {
|
|
diagram += `%% Note: showing ${MAX_EDGES} of ${sortedEdges.length} detected edges to stay within Mermaid default edge limits.\n`;
|
|
}
|
|
|
|
if (outputArg) {
|
|
const outputPath = path.resolve(projectRoot, outputArg);
|
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
fs.writeFileSync(outputPath, diagram, "utf8");
|
|
} else {
|
|
process.stdout.write(diagram);
|
|
}
|