feat: add make-deps script for function dependency graph generation
Some checks failed
build / build (push) Failing after 15s
Some checks failed
build / build (push) Failing after 15s
This commit is contained in:
210
scripts/make-mermaid-func-deps.mjs
Normal file
210
scripts/make-mermaid-func-deps.mjs
Normal file
@@ -0,0 +1,210 @@
|
||||
#!/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);
|
||||
}
|
||||
Reference in New Issue
Block a user