diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json index f4926fa1284a..68491cab5df3 100644 --- a/frontend/.oxlintrc.json +++ b/frontend/.oxlintrc.json @@ -11,7 +11,7 @@ }, "overrides": [ { - "files": ["src/**/*.ts"], + "files": ["**/*.ts"], "rules": { // } diff --git a/frontend/package.json b/frontend/package.json index e93a1ac5b9b4..bf880bad3116 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,8 @@ "dev-test": "concurrently --kill-others \"vite dev\" \"vitest\"", "tsc": "tsc", "docker": "docker compose -f docker/compose.dev.yml up", - "storybook": "cd storybook && pnpm run storybook" + "storybook": "cd storybook && pnpm run storybook", + "import-tree": "tsx ./scripts/import-tree.ts" }, "dependencies": { "@date-fns/utc": "1.2.0", @@ -89,6 +90,7 @@ "@types/throttle-debounce": "5.0.2", "@vitest/coverage-v8": "4.0.15", "autoprefixer": "10.4.27", + "caniuse-lite": "1.0.30001778", "concurrently": "8.2.2", "eslint": "9.39.1", "eslint-plugin-compat": "7.0.0", diff --git a/frontend/scripts/import-tree.ts b/frontend/scripts/import-tree.ts new file mode 100644 index 000000000000..bfced5702b35 --- /dev/null +++ b/frontend/scripts/import-tree.ts @@ -0,0 +1,284 @@ +import fs from "node:fs"; +import path from "node:path"; + +const ROOT = path.resolve(import.meta.dirname, ".."); + +// --- Argument handling --- + +const args = process.argv.slice(2); +const maxDepthFlagIdx = args.indexOf("--depth"); +let maxDepthLimit = Infinity; +if (maxDepthFlagIdx !== -1) { + maxDepthLimit = Number(args[maxDepthFlagIdx + 1]); + args.splice(maxDepthFlagIdx, 2); +} + +const target = args[0]; +if (target === undefined || target === "") { + console.log("Usage: pnpm import-tree [--depth ]"); + process.exit(1); +} + +const resolved = path.resolve(target); + +function collectTsFiles(dir: string): string[] { + const results: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...collectTsFiles(full)); + } else if (/\.tsx?$/.test(entry.name)) { + results.push(full); + } + } + return results; +} + +let entryPoints: string[]; +if (fs.statSync(resolved).isDirectory()) { + entryPoints = collectTsFiles(resolved); +} else { + entryPoints = [resolved]; +} + +if (entryPoints.length === 0) { + console.log("No .ts/.tsx files found."); + process.exit(1); +} + +// --- Import extraction --- + +const IMPORT_RE = + /(?:import|export)\s+(?:type\s+)?(?:(?:\{[^}]*\}|[\w*]+(?:\s*,\s*\{[^}]*\})?)\s+from\s+)?["']([^"']+)["']/g; + +function extractImports(filePath: string): string[] { + const content = fs.readFileSync(filePath, "utf-8"); + const specifiers: string[] = []; + for (const match of content.matchAll(IMPORT_RE)) { + const spec = match[1]; + if (spec !== undefined) specifiers.push(spec); + } + return specifiers; +} + +// --- Resolution --- + +const EXTENSIONS = [".ts", ".tsx", "/index.ts", "/index.tsx"]; + +function resolveSpecifier( + specifier: string, + importerDir: string, +): string | null { + if (specifier.startsWith("./") || specifier.startsWith("../")) { + const base = path.resolve(importerDir, specifier); + // exact match + if (fs.existsSync(base) && fs.statSync(base).isFile()) return base; + for (const ext of EXTENSIONS) { + const candidate = base + ext; + if (fs.existsSync(candidate)) return candidate; + } + return null; + } + + // @monkeytype packages are treated as leaf nodes (no recursion into them) + if (specifier.startsWith("@monkeytype/")) return specifier; + + return null; // third-party / virtual +} + +const printed = new Set(); + +// --- Graph traversal --- + +type NodeInfo = { + directImports: string[]; + totalReachable: number; + maxDepth: number; +}; + +const cache = new Map(); + +function walk( + filePath: string, + ancestors: Set, +): { reachable: Set; maxDepth: number } { + const cached = cache.get(filePath); + if (cached !== undefined) { + return { + reachable: new Set(getAllReachable(filePath, new Set())), + maxDepth: cached.maxDepth, + }; + } + + const importerDir = path.dirname(filePath); + const specifiers = extractImports(filePath); + const directImports: string[] = []; + + const reachable = new Set(); + let maxDepth = 0; + + for (const spec of specifiers) { + const resolved = resolveSpecifier(spec, importerDir); + if (resolved === null) continue; + if (directImports.includes(resolved)) continue; + directImports.push(resolved); + + if (ancestors.has(resolved)) continue; // circular + + reachable.add(resolved); + + // @monkeytype packages are leaf nodes — don't recurse + if (resolved.startsWith("@monkeytype/")) { + maxDepth = Math.max(maxDepth, 1); + continue; + } + + ancestors.add(resolved); + const sub = walk(resolved, ancestors); + ancestors.delete(resolved); + + for (const r of sub.reachable) reachable.add(r); + maxDepth = Math.max(maxDepth, 1 + sub.maxDepth); + } + + if (directImports.length > 0 && maxDepth === 0) { + maxDepth = 1; + } + + cache.set(filePath, { + directImports, + totalReachable: reachable.size, + maxDepth, + }); + + return { reachable, maxDepth }; +} + +function getAllReachable(filePath: string, visited: Set): string[] { + const info = cache.get(filePath); + if (!info) return []; + const result: string[] = []; + for (const dep of info.directImports) { + if (visited.has(dep)) continue; + visited.add(dep); + result.push(dep); + result.push(...getAllReachable(dep, visited)); + } + return result; +} + +// --- Colors --- + +const c = { + reset: "\x1b[0m", + dim: "\x1b[2m", + bold: "\x1b[1m", + cyan: "\x1b[36m", + green: "\x1b[32m", + yellow: "\x1b[33m", + magenta: "\x1b[35m", + red: "\x1b[31m", + blue: "\x1b[34m", + white: "\x1b[37m", +}; + +const DEPTH_COLORS = [c.cyan, c.green, c.yellow, c.blue, c.magenta, c.white]; + +function depthColor(depth: number): string { + return DEPTH_COLORS[depth % DEPTH_COLORS.length] ?? c.cyan; +} + +// --- Display --- + +function displayPath(filePath: string): string { + if (filePath.startsWith(ROOT + "/")) { + return path.relative(ROOT, filePath); + } + return filePath; +} + +function printTree( + filePath: string, + ancestors: Set, + prefix: string, + isLast: boolean, + isRoot: boolean, + depth: number = 0, +): void { + const info = cache.get(filePath); + const dp = displayPath(filePath); + const connector = isRoot ? "" : isLast ? "└── " : "├── "; + const dc = depthColor(depth); + + if (!info) { + // leaf node (e.g. @monkeytype package) + console.log(`${c.dim}${prefix}${connector}${dp}${c.reset}`); + return; + } + + const stats = + info.directImports.length > 0 + ? ` ${c.dim}(direct: ${info.directImports.length}, total: ${info.totalReachable}, depth: ${info.maxDepth})${c.reset}` + : ""; + + const nameStyle = isRoot ? c.bold + dc : dc; + const seen = !isRoot && printed.has(filePath); + const seenTag = seen ? ` ${c.dim}[seen above]${c.reset}` : ""; + console.log( + `${c.dim}${prefix}${connector}${c.reset}${nameStyle}${dp}${c.reset}${stats}${seenTag}`, + ); + + if (seen || depth >= maxDepthLimit) return; + printed.add(filePath); + + const childPrefix = isRoot ? "" : prefix + (isLast ? " " : "│ "); + + const deps = [...info.directImports]; + if (depth === 0) { + deps.sort((a, b) => { + const ta = cache.get(a)?.totalReachable ?? 0; + const tb = cache.get(b)?.totalReachable ?? 0; + return tb - ta; + }); + } + + for (let i = 0; i < deps.length; i++) { + const dep = deps[i]; + if (dep === undefined) continue; + const last = i === deps.length - 1; + + if (ancestors.has(dep)) { + const cc = last ? "└── " : "├── "; + console.log( + `${c.dim}${childPrefix}${cc}${c.reset}${c.red}[circular] ${displayPath(dep)}${c.reset}`, + ); + continue; + } + + ancestors.add(dep); + printTree(dep, ancestors, childPrefix, last, false, depth + 1); + ancestors.delete(dep); + } +} + +// --- Main --- + +for (const entry of entryPoints) { + if (!fs.existsSync(entry)) { + console.log(`File not found: ${entry}`); + continue; + } + walk(entry, new Set([entry])); +} + +entryPoints.sort((a, b) => { + const ta = cache.get(a)?.totalReachable ?? 0; + const tb = cache.get(b)?.totalReachable ?? 0; + return tb - ta; +}); + +for (const entry of entryPoints) { + if (!cache.has(entry)) continue; + printTree(entry, new Set([entry]), "", true, true); + if (entryPoints.length > 1) console.log(); +} diff --git a/package.json b/package.json index 9599a1cce80a..cfc9087ef38f 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@commitlint/config-conventional": "19.2.2", "@monkeytype/release": "workspace:*", "@vitest/coverage-v8": "4.0.15", + "caniuse-lite": "1.0.30001778", "conventional-changelog": "6.0.0", "husky": "8.0.1", "knip": "2.19.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9ae33837db0..7045c6431da8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@vitest/coverage-v8': specifier: 4.0.15 version: 4.0.15(vitest@4.0.15(@types/node@20.5.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)) + caniuse-lite: + specifier: 1.0.30001778 + version: 1.0.30001778 conventional-changelog: specifier: 6.0.0 version: 6.0.0(conventional-commits-filter@5.0.0) @@ -224,7 +227,7 @@ importers: version: 10.0.0 '@vitest/coverage-v8': specifier: 4.0.15 - version: 4.0.15(vitest@4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 4.0.15(vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)) concurrently: specifier: 8.2.2 version: 8.2.2 @@ -447,10 +450,13 @@ importers: version: 5.0.2 '@vitest/coverage-v8': specifier: 4.0.15 - version: 4.0.15(vitest@4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 4.0.15(vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)) autoprefixer: specifier: 10.4.27 version: 10.4.27(postcss@8.5.6) + caniuse-lite: + specifier: 1.0.30001778 + version: 1.0.30001778 concurrently: specifier: 8.2.2 version: 8.2.2 @@ -4477,8 +4483,8 @@ packages: camelize@1.0.1: resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} - caniuse-lite@1.0.30001774: - resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==} + caniuse-lite@1.0.30001778: + resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==} canvas-confetti@1.5.1: resolution: {integrity: sha512-Ncz+oZJP6OvY7ti4E1slxVlyAV/3g7H7oQtcCDXgwGgARxPnwYY9PW5Oe+I8uvspYNtuHviAdgA0LfcKFWJfpg==} @@ -13608,7 +13614,7 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-v8@4.0.15(vitest@4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))': + '@vitest/coverage-v8@4.0.15(vitest@4.0.15(@types/node@20.5.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.15 @@ -13621,11 +13627,11 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1) + vitest: 4.0.15(@types/node@20.5.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.15(vitest@4.0.15(@types/node@20.5.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))': + '@vitest/coverage-v8@4.0.15(vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.15 @@ -13638,7 +13644,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.15(@types/node@20.5.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1) + vitest: 4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -13692,14 +13698,6 @@ snapshots: optionalDependencies: vite: 7.1.12(@types/node@20.5.1)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1) - '@vitest/mocker@4.0.15(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))': - dependencies: - '@vitest/spy': 4.0.15 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1) - '@vitest/mocker@4.0.18(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.0.18 @@ -14046,7 +14044,7 @@ snapshots: autoprefixer@10.4.27(postcss@8.5.6): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001774 + caniuse-lite: 1.0.30001778 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.6 @@ -14274,7 +14272,7 @@ snapshots: browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.11 - caniuse-lite: 1.0.30001774 + caniuse-lite: 1.0.30001778 electron-to-chromium: 1.5.302 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -14390,7 +14388,7 @@ snapshots: camelize@1.0.1: {} - caniuse-lite@1.0.30001774: {} + caniuse-lite@1.0.30001778: {} canvas-confetti@1.5.1: {} @@ -20735,7 +20733,7 @@ snapshots: vitest@4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.15 - '@vitest/mocker': 4.0.15(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)) + '@vitest/mocker': 4.0.15(vite@7.1.12(@types/node@20.5.1)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.70.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.15 '@vitest/runner': 4.0.15 '@vitest/snapshot': 4.0.15