From e4b55544dd215da9bbf920ffa8728ebb465cb2a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 17:59:01 +0000 Subject: [PATCH] chore: Add a script that measures static call depth (no-changelog) Add pnpm call-depth. It builds one TypeScript program, resolves calls with the type checker, and reports the longest call chain per function in the target files. Use --max to fail when a chain is deeper than the limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gK4jZbBjL8toMAnrfJ4mh --- package.json | 2 + scripts/call-graph-depth/README.md | 42 ++ scripts/call-graph-depth/call-graph-depth.mjs | 362 ++++++++++++++++++ .../call-graph-depth.test.mjs | 160 ++++++++ 4 files changed, 566 insertions(+) create mode 100644 scripts/call-graph-depth/README.md create mode 100644 scripts/call-graph-depth/call-graph-depth.mjs create mode 100644 scripts/call-graph-depth/call-graph-depth.test.mjs diff --git a/package.json b/package.json index 45721106900..cd9003e1997 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,8 @@ "setup-backend-module": "node scripts/ensure-zx.mjs && zx scripts/backend-module/setup.mjs", "start": "node scripts/os-normalize.mjs --dir packages/cli/bin n8n", "mutate": "node scripts/mutation-health/mutate.mjs", + "call-depth": "node scripts/call-graph-depth/call-graph-depth.mjs", + "call-depth:test": "node --test scripts/call-graph-depth/*.test.mjs", "mutate:diff": "node scripts/mutation-health/mutate.mjs --diff", "test": "turbo run test", "test:ci": "turbo run test --continue --concurrency=1", diff --git a/scripts/call-graph-depth/README.md b/scripts/call-graph-depth/README.md new file mode 100644 index 00000000000..0e78d0eafe3 --- /dev/null +++ b/scripts/call-graph-depth/README.md @@ -0,0 +1,42 @@ +# `scripts/call-graph-depth/` + +Measure how deep the call chains in a set of TypeScript files go. + +A deep chain makes code hard to read, test and change: to understand one +function, you must keep every function below it in mind. This script gives a +number for that cost, so a review can set a limit and check it. + +## Use + +```bash +pnpm call-depth … [--tsconfig ] [--max ] [--json] +pnpm call-depth packages/cli/src/modules/linked-instances --tsconfig packages/cli/tsconfig.json --max 6 +``` + +- `--tsconfig`: the project file that resolves imports. The default is the + nearest `tsconfig.json` above the first file. +- `--max`: the limit. The exit code is 1 when a function is deeper. +- `--json`: print a machine-readable summary. + +## How it measures + +1. The script builds one TypeScript program and uses the type checker to + resolve each call and `new` expression in the target files. +2. A node is a function-like declaration in a target file: a function, a + method, a constructor, an accessor, or an arrow function. +3. An edge goes to a callee only when the callee is also in a target file. + Calls into other parts of the repository or into dependencies end the + chain, so the number describes the code under review. +4. The depth of a function is the number of functions on its longest chain, + itself included. A leaf has depth 1. +5. Mutual recursion collapses into one step. The report lists recursive + functions. + +Dynamic dispatch through interfaces, callbacks passed as values and calls +through `any` are not resolved. The number is a lower bound. + +## Tests + +```bash +pnpm call-depth:test +``` diff --git a/scripts/call-graph-depth/call-graph-depth.mjs b/scripts/call-graph-depth/call-graph-depth.mjs new file mode 100644 index 00000000000..0a91bbe9279 --- /dev/null +++ b/scripts/call-graph-depth/call-graph-depth.mjs @@ -0,0 +1,362 @@ +#!/usr/bin/env node +/** + * Measure the static call depth of TypeScript source files. Run it as + * `pnpm call-depth` from the repo root. + * + * pnpm call-depth … [--tsconfig ] [--max ] [--json] + * + * The script builds one TypeScript program and resolves every call inside the + * target files with the type checker. A graph node is a function-like + * declaration in a target file. An edge goes from a function to each target + * function that it calls or constructs. Calls into other files of the + * repository or into dependencies end the chain, so the result is the depth of + * the code under review, not of the whole repository. + * + * The depth of a function is the number of functions on its longest call + * chain, the function itself included. A leaf has depth 1. Mutual recursion + * collapses into one strongly connected component, and the report names it. + * + * Exit codes: + * 0 — every function is at or below --max (or no --max given). + * 1 — at least one function is deeper than --max. + * 2 — usage error. + */ + +import { existsSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const SOURCE_EXTENSIONS = ['.ts', '.mts', '.cts', '.tsx']; +const IGNORED_SEGMENTS = ['node_modules', 'dist', '__tests__', '__mocks__']; +const TEST_FILE = /\.(test|spec)\.[cm]?tsx?$/; + +/** Parse the command line into options. Throws a usage error as an Error. */ +export function parseArgs(argv) { + const options = { targets: [], tsconfig: undefined, max: undefined, json: false }; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === '--json') options.json = true; + else if (arg === '--tsconfig') options.tsconfig = requireValue(argv, ++index, arg); + else if (arg === '--max') options.max = parseMax(requireValue(argv, ++index, arg)); + else if (arg.startsWith('--')) throw new Error(`Unknown option: ${arg}`); + else options.targets.push(arg); + } + if (options.targets.length === 0) throw new Error('Give at least one file or directory'); + return options; +} + +function requireValue(argv, index, flag) { + const value = argv[index]; + if (value === undefined || value.startsWith('--')) throw new Error(`${flag} needs a value`); + return value; +} + +function parseMax(value) { + const max = Number(value); + if (!Number.isInteger(max) || max < 1) throw new Error('--max must be a positive integer'); + return max; +} + +/** True when a path is a source file that the report should include. */ +export function isTargetSource(filePath) { + if (!SOURCE_EXTENSIONS.some((ext) => filePath.endsWith(ext))) return false; + if (filePath.endsWith('.d.ts') || TEST_FILE.test(filePath)) return false; + return !filePath.split(path.sep).some((segment) => IGNORED_SEGMENTS.includes(segment)); +} + +/** Expand files and directories into absolute source file paths. */ +export function collectFiles(targets) { + const files = new Set(); + const visit = (entry) => { + const absolute = path.resolve(entry); + if (!existsSync(absolute)) throw new Error(`Not found: ${entry}`); + if (statSync(absolute).isDirectory()) { + for (const child of readdirSync(absolute)) { + if (!IGNORED_SEGMENTS.includes(child)) visit(path.join(absolute, child)); + } + } else if (isTargetSource(absolute)) { + files.add(absolute); + } + }; + targets.forEach(visit); + return [...files].sort(); +} + +/** + * Tarjan's algorithm. Returns the strongly connected components of a graph + * given as `Map>`, in reverse topological order. + */ +export function stronglyConnectedComponents(graph) { + let counter = 0; + const index = new Map(); + const lowLink = new Map(); + const onStack = new Set(); + const stack = []; + const components = []; + + const connect = (node) => { + index.set(node, counter); + lowLink.set(node, counter); + counter++; + stack.push(node); + onStack.add(node); + for (const next of graph.get(node) ?? []) { + if (!index.has(next)) { + connect(next); + lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(next))); + } else if (onStack.has(next)) { + lowLink.set(node, Math.min(lowLink.get(node), index.get(next))); + } + } + if (lowLink.get(node) === index.get(node)) { + const component = []; + let member; + do { + member = stack.pop(); + onStack.delete(member); + component.push(member); + } while (member !== node); + components.push(component); + } + }; + + for (const node of graph.keys()) if (!index.has(node)) connect(node); + return components; +} + +/** + * Longest call chain from every node. A strongly connected component counts + * as one step, because recursion has no static bound. Returns + * `Map`. + */ +export function longestChains(graph) { + const components = stronglyConnectedComponents(graph); + const componentOf = new Map(); + components.forEach((component, componentIndex) => { + for (const node of component) componentOf.set(node, componentIndex); + }); + + // Tarjan emits components callee-first, so one pass in order is enough. + const best = new Map(); + components.forEach((component, componentIndex) => { + let bestNext; + for (const node of component) { + for (const next of graph.get(node) ?? []) { + const nextComponent = componentOf.get(next); + if (nextComponent === componentIndex) continue; + const candidate = best.get(nextComponent); + if (!bestNext || candidate.depth > bestNext.depth) bestNext = candidate; + } + } + const head = [...component].sort()[0]; + best.set(componentIndex, { + depth: 1 + (bestNext?.depth ?? 0), + chain: [head, ...(bestNext?.chain ?? [])], + recursive: component.length > 1 || (graph.get(head) ?? new Set()).has(head), + members: component, + }); + }); + + const result = new Map(); + for (const node of graph.keys()) { + const entry = best.get(componentOf.get(node)); + result.set(node, { + depth: entry.depth, + chain: [node, ...entry.chain.slice(1)], + recursive: entry.recursive, + }); + } + return result; +} + +function loadTypeScript() { + const require = createRequire(import.meta.url); + return require('typescript'); +} + +function readCompilerOptions(ts, tsconfigPath, files) { + const configPath = + tsconfigPath ?? ts.findConfigFile(path.dirname(files[0]), ts.sys.fileExists, 'tsconfig.json'); + if (!configPath) + return { options: { allowJs: false, skipLibCheck: true }, configPath: undefined }; + const read = ts.readConfigFile(configPath, ts.sys.readFile); + if (read.error) throw new Error(ts.flattenDiagnosticMessageText(read.error.messageText, '\n')); + const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath)); + return { options: { ...parsed.options, noEmit: true }, configPath }; +} + +function isFunctionLike(ts, node) { + return ( + ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node) || + ts.isConstructorDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) || + ts.isArrowFunction(node) || + ts.isFunctionExpression(node) + ); +} + +function functionLabel(ts, node, sourceFile) { + const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; + const file = path.relative(process.cwd(), sourceFile.fileName); + let name = node.name && ts.isIdentifier(node.name) ? node.name.text : undefined; + if (ts.isConstructorDeclaration(node)) name = 'constructor'; + if (!name && node.parent) { + const parent = node.parent; + if ( + (ts.isVariableDeclaration(parent) || + ts.isPropertyAssignment(parent) || + ts.isPropertyDeclaration(parent)) && + parent.name && + ts.isIdentifier(parent.name) + ) { + name = parent.name.text; + } + } + const owner = ts.isClassLike(node.parent) && node.parent.name ? `${node.parent.name.text}.` : ''; + return `${file}:${line} ${owner}${name ?? ''}`; +} + +function calleeDeclarations(ts, checker, call) { + const target = ts.isNewExpression(call) ? call.expression : call.expression; + const location = ts.isPropertyAccessExpression(target) ? target.name : target; + let symbol = checker.getSymbolAtLocation(location); + if (!symbol) return []; + if (symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol); + const declarations = []; + for (const declaration of symbol.declarations ?? []) { + if (isFunctionLike(ts, declaration)) declarations.push(declaration); + else if ( + ts.isVariableDeclaration(declaration) && + declaration.initializer && + isFunctionLike(ts, declaration.initializer) + ) + declarations.push(declaration.initializer); + else if ( + ts.isPropertyDeclaration(declaration) && + declaration.initializer && + isFunctionLike(ts, declaration.initializer) + ) + declarations.push(declaration.initializer); + else if (ts.isClassDeclaration(declaration) && ts.isNewExpression(call)) { + const constructor = declaration.members.find((member) => ts.isConstructorDeclaration(member)); + if (constructor) declarations.push(constructor); + } + } + return declarations; +} + +/** Build the call graph of the target files. Returns `Map>`. */ +export function buildCallGraph(files, { tsconfig } = {}) { + const ts = loadTypeScript(); + const { options } = readCompilerOptions(ts, tsconfig, files); + const program = ts.createProgram({ rootNames: files, options }); + const checker = program.getTypeChecker(); + const targetSet = new Set(files.map((file) => path.resolve(file))); + const labels = new Map(); + const graph = new Map(); + + const labelOf = (node) => { + if (!labels.has(node)) { + const label = functionLabel(ts, node, node.getSourceFile()); + labels.set(node, label); + if (!graph.has(label)) graph.set(label, new Set()); + } + return labels.get(node); + }; + + const enclosingFunction = (node) => { + let current = node.parent; + while (current && !isFunctionLike(ts, current)) current = current.parent; + return current; + }; + + for (const sourceFile of program.getSourceFiles()) { + if (!targetSet.has(path.resolve(sourceFile.fileName))) continue; + const visit = (node) => { + if (isFunctionLike(ts, node)) labelOf(node); + if (ts.isCallExpression(node) || ts.isNewExpression(node)) { + const caller = enclosingFunction(node); + if (caller) { + for (const declaration of calleeDeclarations(ts, checker, node)) { + if (!targetSet.has(path.resolve(declaration.getSourceFile().fileName))) continue; + graph.get(labelOf(caller)).add(labelOf(declaration)); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + } + return graph; +} + +/** Summarise chains per file and overall. */ +export function summarise(chains, max) { + const byFile = new Map(); + let deepest; + for (const [node, entry] of chains) { + const file = node.split(':')[0]; + const current = byFile.get(file); + if (!current || entry.depth > current.depth) + byFile.set(file, { depth: entry.depth, chain: entry.chain }); + if (!deepest || entry.depth > deepest.depth) deepest = { node, ...entry }; + } + const over = + max === undefined + ? [] + : [...chains] + .filter(([, entry]) => entry.depth > max) + .map(([node, entry]) => ({ node, depth: entry.depth, chain: entry.chain })); + const recursive = [...chains].filter(([, entry]) => entry.recursive).map(([node]) => node); + return { + functions: chains.size, + maxDepth: deepest?.depth ?? 0, + deepestChain: deepest?.chain ?? [], + files: [...byFile] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([file, entry]) => ({ file, ...entry })), + overLimit: over, + recursive, + }; +} + +function printText(summary, max) { + console.log(`Functions analysed: ${summary.functions}`); + console.log(`Maximum call depth: ${summary.maxDepth}${max ? ` (limit ${max})` : ''}`); + if (summary.deepestChain.length) + console.log(`Deepest chain:\n ${summary.deepestChain.join('\n → ')}`); + console.log('\nPer file (max depth):'); + for (const row of summary.files) console.log(` ${String(row.depth).padStart(2)} ${row.file}`); + if (summary.recursive.length) console.log(`\nRecursive functions: ${summary.recursive.length}`); + if (summary.overLimit.length) { + console.log(`\nOver the limit (${summary.overLimit.length}):`); + for (const row of summary.overLimit) console.log(` ${row.depth} ${row.node}`); + } +} + +export function main(argv) { + let options; + try { + options = parseArgs(argv); + } catch (error) { + console.error(`call-depth: ${error.message}`); + return 2; + } + const files = collectFiles(options.targets); + if (files.length === 0) { + console.error('call-depth: no source files found'); + return 2; + } + const chains = longestChains(buildCallGraph(files, options)); + const summary = summarise(chains, options.max); + if (options.json) console.log(JSON.stringify(summary, null, 2)); + else printText(summary, options.max); + return summary.overLimit.length > 0 ? 1 : 0; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/scripts/call-graph-depth/call-graph-depth.test.mjs b/scripts/call-graph-depth/call-graph-depth.test.mjs new file mode 100644 index 00000000000..495472affe2 --- /dev/null +++ b/scripts/call-graph-depth/call-graph-depth.test.mjs @@ -0,0 +1,160 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { + buildCallGraph, + collectFiles, + isTargetSource, + longestChains, + main, + parseArgs, + stronglyConnectedComponents, + summarise, +} from './call-graph-depth.mjs'; + +const graphOf = (edges) => { + const graph = new Map(); + for (const [from, targets] of Object.entries(edges)) graph.set(from, new Set(targets)); + return graph; +}; + +describe('parseArgs', () => { + it('reads targets and options', () => { + assert.deepEqual(parseArgs(['src', '--max', '6', '--json', '--tsconfig', 't.json']), { + targets: ['src'], + tsconfig: 't.json', + max: 6, + json: true, + }); + }); + + it('rejects a missing target, an unknown flag and a bad limit', () => { + assert.throws(() => parseArgs([]), /at least one/); + assert.throws(() => parseArgs(['a', '--nope']), /Unknown option/); + assert.throws(() => parseArgs(['a', '--max', '0']), /positive integer/); + assert.throws(() => parseArgs(['a', '--max']), /needs a value/); + }); +}); + +describe('isTargetSource', () => { + it('keeps source files and skips tests, declarations and build output', () => { + assert.equal(isTargetSource('/x/src/a.ts'), true); + assert.equal(isTargetSource('/x/src/a.test.ts'), false); + assert.equal(isTargetSource('/x/src/a.d.ts'), false); + assert.equal(isTargetSource('/x/dist/a.ts'), false); + assert.equal(isTargetSource('/x/src/__tests__/a.ts'), false); + assert.equal(isTargetSource('/x/src/a.js'), false); + }); +}); + +describe('stronglyConnectedComponents', () => { + it('emits callees before callers', () => { + const components = stronglyConnectedComponents(graphOf({ a: ['b'], b: ['c'], c: [] })); + assert.deepEqual(components, [['c'], ['b'], ['a']]); + }); + + it('groups mutual recursion', () => { + const components = stronglyConnectedComponents(graphOf({ a: ['b'], b: ['a'] })); + assert.equal(components.length, 1); + assert.deepEqual([...components[0]].sort(), ['a', 'b']); + }); +}); + +describe('longestChains', () => { + it('gives a leaf depth 1 and follows the longest branch', () => { + const chains = longestChains(graphOf({ a: ['b', 'd'], b: ['c'], c: [], d: [] })); + assert.equal(chains.get('c').depth, 1); + assert.equal(chains.get('a').depth, 3); + assert.deepEqual(chains.get('a').chain, ['a', 'b', 'c']); + }); + + it('counts a recursive component as one step and flags it', () => { + const chains = longestChains(graphOf({ a: ['b'], b: ['c'], c: ['b', 'd'], d: [] })); + assert.equal(chains.get('a').depth, 3); + assert.equal(chains.get('b').recursive, true); + assert.equal(chains.get('d').recursive, false); + }); + + it('flags direct self-recursion', () => { + const chains = longestChains(graphOf({ a: ['a'] })); + assert.equal(chains.get('a').depth, 1); + assert.equal(chains.get('a').recursive, true); + }); + + it('never reports a depth larger than the number of nodes', () => { + const edges = {}; + for (let index = 0; index < 30; index++) + edges[`n${index}`] = index < 29 ? [`n${index + 1}`] : []; + const chains = longestChains(graphOf(edges)); + assert.equal(chains.get('n0').depth, 30); + for (const entry of chains.values()) assert.ok(entry.depth <= 30); + }); +}); + +describe('summarise', () => { + it('lists functions over the limit', () => { + const chains = longestChains(graphOf({ 'f.ts:1 a': ['f.ts:2 b'], 'f.ts:2 b': [] })); + const summary = summarise(chains, 1); + assert.equal(summary.maxDepth, 2); + assert.equal(summary.overLimit.length, 1); + assert.equal(summary.files[0].depth, 2); + }); +}); + +describe('buildCallGraph (fixture)', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'call-depth-')); + const src = path.join(dir, 'src'); + mkdirSync(src); + writeFileSync( + path.join(dir, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { target: 'es2022', module: 'esnext', strict: true } }), + ); + writeFileSync( + path.join(src, 'leaf.ts'), + 'export function leaf(): number { return 1; }\nexport class Box { constructor() { leaf(); } read() { return leaf(); } }\n', + ); + writeFileSync( + path.join(src, 'top.ts'), + [ + "import { leaf, Box } from './leaf';", + 'const helper = () => leaf();', + 'export function top() { const box = new Box(); return box.read() + helper(); }', + 'export function external() { return JSON.stringify({}); }', + ].join('\n'), + ); + writeFileSync(path.join(src, 'top.test.ts'), 'export const ignored = 1;\n'); + + it('resolves imports, methods, constructors and arrow functions', () => { + const files = collectFiles([src]); + assert.equal(files.length, 2); + const chains = longestChains( + buildCallGraph(files, { tsconfig: path.join(dir, 'tsconfig.json') }), + ); + const topEntry = [...chains].find(([label]) => label.endsWith(' top')); + assert.ok(topEntry, 'top is in the graph'); + assert.equal(topEntry[1].depth, 3); + const externalEntry = [...chains].find(([label]) => label.endsWith(' external')); + assert.equal(externalEntry[1].depth, 1); + }); + + it('returns exit code 1 above the limit and 0 at the limit', () => { + const originalLog = console.log; + console.log = () => {}; + try { + assert.equal(main([src, '--tsconfig', path.join(dir, 'tsconfig.json'), '--max', '2']), 1); + assert.equal( + main([src, '--tsconfig', path.join(dir, 'tsconfig.json'), '--max', '3', '--json']), + 0, + ); + } finally { + console.log = originalLog; + } + }); + + it('cleans up', () => { + rmSync(dir, { recursive: true, force: true }); + }); +});