feat: add make-deps script for function dependency graph generation
Some checks failed
build / build (push) Failing after 15s

This commit is contained in:
2026-03-07 10:01:04 +01:00
parent 2a0b49effe
commit d39fdb3e33
3 changed files with 305 additions and 9 deletions

View 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);
}