From d39fdb3e33b83633e5aca7d6a5f6d0c164ae0ae3 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sat, 7 Mar 2026 10:01:04 +0100 Subject: [PATCH] feat: add make-deps script for function dependency graph generation --- package-lock.json | 90 ++++++++++++- package.json | 14 +- scripts/make-mermaid-func-deps.mjs | 210 +++++++++++++++++++++++++++++ 3 files changed, 305 insertions(+), 9 deletions(-) create mode 100644 scripts/make-mermaid-func-deps.mjs diff --git a/package-lock.json b/package-lock.json index 182724c..0dab210 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,8 +22,9 @@ "sk-az-tools": "dist/cli.js" }, "devDependencies": { - "@types/node": "^24.0.0", - "typescript": "^5.8.2" + "@types/node": ">=24.0.0", + "ts-morph": ">=27.0.0", + "typescript": ">=5.8.2" }, "engines": { "node": ">=24.0.0" @@ -281,6 +282,18 @@ "sk-tools": "dist/cli.js" } }, + "node_modules/@ts-morph/common": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.28.1.tgz", + "integrity": "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" + } + }, "node_modules/@types/node": { "version": "24.11.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", @@ -459,6 +472,13 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -664,6 +684,24 @@ "node": ">=6" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -1113,6 +1151,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -1413,6 +1471,34 @@ "node": ">=6" } }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-morph": { + "version": "27.0.2", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-27.0.2.tgz", + "integrity": "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.28.1", + "code-block-writer": "^13.0.3" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index a802206..55f5b6d 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,10 @@ "LICENSE" ], "scripts": { - "clean": "rm -rf dist", - "build": "npm run clean && tsc && chmod +x dist/cli.js", - "build:watch": "tsc --watch", - "prepublishOnly": "npm run build", - "create-pca": "node dist/create-pca.js" + "build": "rm -rf dist && tsc && chmod +x dist/cli.js", + "create-pca": "node dist/create-pca.js", + "make-deps": "node scripts/make-mermaid-func-deps.mjs", + "clean": "rm -rf dist" }, "engines": { "node": ">=24.0.0" @@ -29,8 +28,9 @@ "open": "^10.1.0" }, "devDependencies": { - "@types/node": "^24.0.0", - "typescript": "^5.8.2" + "@types/node": ">=24.0.0", + "ts-morph": ">=27.0.0", + "typescript": ">=5.8.2" }, "author": { "name": "Sławomir Koszewski", diff --git a/scripts/make-mermaid-func-deps.mjs b/scripts/make-mermaid-func-deps.mjs new file mode 100644 index 0000000..ecf8b7d --- /dev/null +++ b/scripts/make-mermaid-func-deps.mjs @@ -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); +}