Skip to content

Commit 49589c4

Browse files
committed
chore: add script to visualise import tree
1 parent 02310c7 commit 49589c4

2 files changed

Lines changed: 286 additions & 1 deletion

File tree

frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
"dev-test": "concurrently --kill-others \"vite dev\" \"vitest\"",
2121
"tsc": "tsc",
2222
"docker": "docker compose -f docker/compose.dev.yml up",
23-
"storybook": "cd storybook && pnpm run storybook"
23+
"storybook": "cd storybook && pnpm run storybook",
24+
"import-tree": "tsx ./scripts/import-tree.ts"
2425
},
2526
"dependencies": {
2627
"@date-fns/utc": "1.2.0",

frontend/scripts/import-tree.ts

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
4+
const ROOT = path.resolve(import.meta.dirname, "..");
5+
6+
// --- Argument handling ---
7+
8+
const args = process.argv.slice(2);
9+
const maxDepthFlagIdx = args.indexOf("--depth");
10+
let maxDepthLimit = Infinity;
11+
if (maxDepthFlagIdx !== -1) {
12+
maxDepthLimit = Number(args[maxDepthFlagIdx + 1]);
13+
args.splice(maxDepthFlagIdx, 2);
14+
}
15+
16+
const target = args[0];
17+
if (target === undefined || target === "") {
18+
console.log("Usage: pnpm import-tree <file-or-directory> [--depth <n>]");
19+
process.exit(1);
20+
}
21+
22+
const resolved = path.resolve(target);
23+
24+
function collectTsFiles(dir: string): string[] {
25+
const results: string[] = [];
26+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
27+
const full = path.join(dir, entry.name);
28+
if (entry.isDirectory()) {
29+
results.push(...collectTsFiles(full));
30+
} else if (/\.tsx?$/.test(entry.name)) {
31+
results.push(full);
32+
}
33+
}
34+
return results;
35+
}
36+
37+
let entryPoints: string[];
38+
if (fs.statSync(resolved).isDirectory()) {
39+
entryPoints = collectTsFiles(resolved);
40+
} else {
41+
entryPoints = [resolved];
42+
}
43+
44+
if (entryPoints.length === 0) {
45+
console.log("No .ts/.tsx files found.");
46+
process.exit(1);
47+
}
48+
49+
// --- Import extraction ---
50+
51+
const IMPORT_RE =
52+
/(?:import|export)\s+(?:type\s+)?(?:(?:\{[^}]*\}|[\w*]+(?:\s*,\s*\{[^}]*\})?)\s+from\s+)?["']([^"']+)["']/g;
53+
54+
function extractImports(filePath: string): string[] {
55+
const content = fs.readFileSync(filePath, "utf-8");
56+
const specifiers: string[] = [];
57+
for (const match of content.matchAll(IMPORT_RE)) {
58+
const spec = match[1];
59+
if (spec !== undefined) specifiers.push(spec);
60+
}
61+
return specifiers;
62+
}
63+
64+
// --- Resolution ---
65+
66+
const EXTENSIONS = [".ts", ".tsx", "/index.ts", "/index.tsx"];
67+
68+
function resolveSpecifier(
69+
specifier: string,
70+
importerDir: string,
71+
): string | null {
72+
if (specifier.startsWith("./") || specifier.startsWith("../")) {
73+
const base = path.resolve(importerDir, specifier);
74+
// exact match
75+
if (fs.existsSync(base) && fs.statSync(base).isFile()) return base;
76+
for (const ext of EXTENSIONS) {
77+
const candidate = base + ext;
78+
if (fs.existsSync(candidate)) return candidate;
79+
}
80+
return null;
81+
}
82+
83+
// @monkeytype packages are treated as leaf nodes (no recursion into them)
84+
if (specifier.startsWith("@monkeytype/")) return specifier;
85+
86+
return null; // third-party / virtual
87+
}
88+
89+
const printed = new Set<string>();
90+
91+
// --- Graph traversal ---
92+
93+
type NodeInfo = {
94+
directImports: string[];
95+
totalReachable: number;
96+
maxDepth: number;
97+
};
98+
99+
const cache = new Map<string, NodeInfo>();
100+
101+
function walk(
102+
filePath: string,
103+
ancestors: Set<string>,
104+
): { reachable: Set<string>; maxDepth: number } {
105+
const cached = cache.get(filePath);
106+
if (cached !== undefined) {
107+
return {
108+
reachable: new Set(getAllReachable(filePath, new Set())),
109+
maxDepth: cached.maxDepth,
110+
};
111+
}
112+
113+
const importerDir = path.dirname(filePath);
114+
const specifiers = extractImports(filePath);
115+
const directImports: string[] = [];
116+
117+
const reachable = new Set<string>();
118+
let maxDepth = 0;
119+
120+
for (const spec of specifiers) {
121+
const resolved = resolveSpecifier(spec, importerDir);
122+
if (resolved === null) continue;
123+
if (directImports.includes(resolved)) continue;
124+
directImports.push(resolved);
125+
126+
if (ancestors.has(resolved)) continue; // circular
127+
128+
reachable.add(resolved);
129+
130+
// @monkeytype packages are leaf nodes — don't recurse
131+
if (resolved.startsWith("@monkeytype/")) {
132+
maxDepth = Math.max(maxDepth, 1);
133+
continue;
134+
}
135+
136+
ancestors.add(resolved);
137+
const sub = walk(resolved, ancestors);
138+
ancestors.delete(resolved);
139+
140+
for (const r of sub.reachable) reachable.add(r);
141+
maxDepth = Math.max(maxDepth, 1 + sub.maxDepth);
142+
}
143+
144+
if (directImports.length > 0 && maxDepth === 0) {
145+
maxDepth = 1;
146+
}
147+
148+
cache.set(filePath, {
149+
directImports,
150+
totalReachable: reachable.size,
151+
maxDepth,
152+
});
153+
154+
return { reachable, maxDepth };
155+
}
156+
157+
function getAllReachable(filePath: string, visited: Set<string>): string[] {
158+
const info = cache.get(filePath);
159+
if (!info) return [];
160+
const result: string[] = [];
161+
for (const dep of info.directImports) {
162+
if (visited.has(dep)) continue;
163+
visited.add(dep);
164+
result.push(dep);
165+
result.push(...getAllReachable(dep, visited));
166+
}
167+
return result;
168+
}
169+
170+
// --- Colors ---
171+
172+
const c = {
173+
reset: "\x1b[0m",
174+
dim: "\x1b[2m",
175+
bold: "\x1b[1m",
176+
cyan: "\x1b[36m",
177+
green: "\x1b[32m",
178+
yellow: "\x1b[33m",
179+
magenta: "\x1b[35m",
180+
red: "\x1b[31m",
181+
blue: "\x1b[34m",
182+
white: "\x1b[37m",
183+
};
184+
185+
const DEPTH_COLORS = [c.cyan, c.green, c.yellow, c.blue, c.magenta, c.white];
186+
187+
function depthColor(depth: number): string {
188+
return DEPTH_COLORS[depth % DEPTH_COLORS.length] ?? c.cyan;
189+
}
190+
191+
// --- Display ---
192+
193+
function displayPath(filePath: string): string {
194+
if (filePath.startsWith(ROOT + "/")) {
195+
return path.relative(ROOT, filePath);
196+
}
197+
return filePath;
198+
}
199+
200+
function printTree(
201+
filePath: string,
202+
ancestors: Set<string>,
203+
prefix: string,
204+
isLast: boolean,
205+
isRoot: boolean,
206+
depth: number = 0,
207+
): void {
208+
const info = cache.get(filePath);
209+
const dp = displayPath(filePath);
210+
const connector = isRoot ? "" : isLast ? "└── " : "├── ";
211+
const dc = depthColor(depth);
212+
213+
if (!info) {
214+
// leaf node (e.g. @monkeytype package)
215+
console.log(`${c.dim}${prefix}${connector}${dp}${c.reset}`);
216+
return;
217+
}
218+
219+
const stats =
220+
info.directImports.length > 0
221+
? ` ${c.dim}(direct: ${info.directImports.length}, total: ${info.totalReachable}, depth: ${info.maxDepth})${c.reset}`
222+
: "";
223+
224+
const nameStyle = isRoot ? c.bold + dc : dc;
225+
const seen = !isRoot && printed.has(filePath);
226+
const seenTag = seen ? ` ${c.dim}[seen above]${c.reset}` : "";
227+
console.log(
228+
`${c.dim}${prefix}${connector}${c.reset}${nameStyle}${dp}${c.reset}${stats}${seenTag}`,
229+
);
230+
231+
if (seen || depth >= maxDepthLimit) return;
232+
printed.add(filePath);
233+
234+
const childPrefix = isRoot ? "" : prefix + (isLast ? " " : "│ ");
235+
236+
const deps = [...info.directImports];
237+
if (depth === 0) {
238+
deps.sort((a, b) => {
239+
const ta = cache.get(a)?.totalReachable ?? 0;
240+
const tb = cache.get(b)?.totalReachable ?? 0;
241+
return tb - ta;
242+
});
243+
}
244+
245+
for (let i = 0; i < deps.length; i++) {
246+
const dep = deps[i];
247+
if (dep === undefined) continue;
248+
const last = i === deps.length - 1;
249+
250+
if (ancestors.has(dep)) {
251+
const cc = last ? "└── " : "├── ";
252+
console.log(
253+
`${c.dim}${childPrefix}${cc}${c.reset}${c.red}[circular] ${displayPath(dep)}${c.reset}`,
254+
);
255+
continue;
256+
}
257+
258+
ancestors.add(dep);
259+
printTree(dep, ancestors, childPrefix, last, false, depth + 1);
260+
ancestors.delete(dep);
261+
}
262+
}
263+
264+
// --- Main ---
265+
266+
for (const entry of entryPoints) {
267+
if (!fs.existsSync(entry)) {
268+
console.log(`File not found: ${entry}`);
269+
continue;
270+
}
271+
walk(entry, new Set([entry]));
272+
}
273+
274+
entryPoints.sort((a, b) => {
275+
const ta = cache.get(a)?.totalReachable ?? 0;
276+
const tb = cache.get(b)?.totalReachable ?? 0;
277+
return tb - ta;
278+
});
279+
280+
for (const entry of entryPoints) {
281+
if (!cache.has(entry)) continue;
282+
printTree(entry, new Set([entry]), "", true, true);
283+
if (entryPoints.length > 1) console.log();
284+
}

0 commit comments

Comments
 (0)