mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gK4jZbBjL8toMAnrfJ4mh
This commit is contained in:
parent
8685b4fdd8
commit
e4b55544dd
|
|
@ -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",
|
||||
|
|
|
|||
42
scripts/call-graph-depth/README.md
Normal file
42
scripts/call-graph-depth/README.md
Normal file
|
|
@ -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 <file-or-dir>… [--tsconfig <path>] [--max <n>] [--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
|
||||
```
|
||||
362
scripts/call-graph-depth/call-graph-depth.mjs
Normal file
362
scripts/call-graph-depth/call-graph-depth.mjs
Normal file
|
|
@ -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 <file-or-dir>… [--tsconfig <path>] [--max <n>] [--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<node, Set<node>>`, 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<node, { depth, chain, recursive }>`.
|
||||
*/
|
||||
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 ?? '<anonymous>'}`;
|
||||
}
|
||||
|
||||
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<label, Set<label>>`. */
|
||||
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));
|
||||
}
|
||||
160
scripts/call-graph-depth/call-graph-depth.test.mjs
Normal file
160
scripts/call-graph-depth/call-graph-depth.test.mjs
Normal file
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user