@chappibunny/repolens 1.15.1 → 1.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  // Build structured AI context from deterministic scan results
2
2
 
3
- import { groupModulesByDomain } from "./domain-inference.js";
3
+ import { groupModulesByDomain, pathHasKeyword } from "./domain-inference.js";
4
4
 
5
5
  /**
6
6
  * Compute per-module dependency metrics from the dep graph.
@@ -91,7 +91,7 @@ export function buildAIContext(scanResult, config, depGraph = null) {
91
91
  : [];
92
92
 
93
93
  // Group modules by business domain
94
- const domainGroups = groupModulesByDomain(modules, customHints);
94
+ const domainGroups = groupModulesByDomain(modules, customHints, depGraph);
95
95
 
96
96
  // Compute module-level dependency metrics
97
97
  const moduleMetrics = computeModuleLevelMetrics(depGraph, modules);
@@ -175,40 +175,99 @@ export function buildAIContext(scanResult, config, depGraph = null) {
175
175
  }
176
176
 
177
177
  function inferModuleType(modulePath) {
178
- const lower = modulePath.toLowerCase();
179
-
180
- if (lower.includes("test") || lower.includes("spec") || lower.includes("__test")) return "test";
181
- if (lower.includes("api") || lower.includes("endpoint")) return "api";
182
- if (lower.includes("component") || lower.includes("widget")) return "ui";
183
- if (lower.includes("lib") || lower.includes("util") || lower.includes("helper") || lower.includes("common") || lower.includes("shared")) return "library";
184
- if (lower.includes("hook")) return "hooks";
185
- if (lower.includes("store") || lower.includes("state") || lower.includes("redux") || lower.includes("zustand")) return "state";
186
- if (lower.includes("page") || lower.includes("route") || lower.includes("view")) return "route";
187
- if (lower.includes("config") || lower.includes("setting") || lower.includes("env")) return "config";
188
- if (lower.includes("core") || lower.includes("kernel") || lower.includes("foundation")) return "core";
189
- if (lower.includes("render") || lower.includes("template") || lower.includes("format")) return "renderer";
190
- if (lower.includes("publish") || lower.includes("output") || lower.includes("export")) return "publisher";
191
- if (lower.includes("analyz") || lower.includes("inspect") || lower.includes("detect")) return "analyzer";
192
- if (lower.includes("plugin") || lower.includes("extension") || lower.includes("addon")) return "plugin";
193
- if (lower.includes("deliver") || lower.includes("dispatch") || lower.includes("send")) return "delivery";
194
- if (lower.includes("doc") || lower.includes("generate")) return "documentation";
195
- if (lower.includes("integrat") || lower.includes("connect") || lower.includes("adapter")) return "integration";
196
- if (lower.includes("cli") || lower.includes("command") || lower.includes("bin")) return "cli";
197
- if (lower.includes("ai") || lower.includes("ml") || lower.includes("model") || lower.includes("prompt")) return "ai";
198
- if (lower.includes("auth") || lower.includes("login") || lower.includes("session")) return "auth";
199
- if (lower.includes("data") || lower.includes("db") || lower.includes("model") || lower.includes("schema")) return "data";
200
- if (lower.includes("middleware")) return "middleware";
201
- if (lower.includes("service")) return "service";
202
- if (lower.includes("app")) return "app";
203
-
178
+ const has = (keyword) => pathHasKeyword(modulePath, keyword);
179
+
180
+ if (has("test") || has("spec")) return "test";
181
+ if (has("api") || has("endpoint")) return "api";
182
+ if (has("component") || has("widget")) return "ui";
183
+ if (has("lib") || has("util") || has("helper") || has("common") || has("shared")) return "library";
184
+ if (has("hook")) return "hooks";
185
+ if (has("store") || has("state") || has("redux") || has("zustand")) return "state";
186
+ if (has("page") || has("route") || has("view")) return "route";
187
+ if (has("config") || has("setting") || has("env")) return "config";
188
+ if (has("core") || has("kernel") || has("foundation")) return "core";
189
+ if (has("render") || has("template") || has("format")) return "renderer";
190
+ if (has("publish") || has("output") || has("export")) return "publisher";
191
+ if (has("analyz") || has("inspect") || has("detect")) return "analyzer";
192
+ if (has("plugin") || has("extension") || has("addon")) return "plugin";
193
+ if (has("deliver") || has("dispatch") || has("send")) return "delivery";
194
+ if (has("doc") || has("generate")) return "documentation";
195
+ if (has("integrat") || has("connect") || has("adapter")) return "integration";
196
+ if (has("cli") || has("command") || has("bin")) return "cli";
197
+ if (has("ai") || has("llm") || has("openai") || has("prompt")) return "ai";
198
+ if (has("auth") || has("login") || has("session")) return "auth";
199
+ if (has("data") || has("db") || has("schema")) return "data";
200
+ if (has("middleware")) return "middleware";
201
+ if (has("service")) return "service";
202
+ if (has("app")) return "app";
203
+
204
204
  return "other";
205
205
  }
206
206
 
207
+ function isTestEvidence(modulePath) {
208
+ const norm = String(modulePath || "").replace(/\\/g, "/");
209
+ if (/(^|\/)__(tests|mocks)__(\/|$)/.test(norm)) return true;
210
+ if (/(^|\/)(test|tests|spec|specs|fixtures|testdata)(\/|$)/.test(norm)) return true;
211
+ const base = norm.split("/").pop() || "";
212
+ if (/^(test_|spec_)/.test(base)) return true;
213
+ if (/(_test|_spec)\.[a-z0-9]+$/i.test(base)) return true;
214
+ if (/\.(test|spec)\.[a-z0-9]+$/i.test(base)) return true;
215
+ return false;
216
+ }
217
+
218
+ const API_DIRECTORY_SEGMENTS = new Set([
219
+ "api", "apis", "route", "routes", "router", "routers",
220
+ "endpoint", "endpoints", "controller", "controllers", "handler", "handlers",
221
+ ]);
222
+
223
+ function hasApiDirectory(modulePath) {
224
+ return directoryPath(modulePath)
225
+ .split("/")
226
+ .some((segment) => API_DIRECTORY_SEGMENTS.has(segment.toLowerCase()));
227
+ }
228
+
229
+ function directoryPath(modulePath) {
230
+ const parts = String(modulePath || "").replace(/\\/g, "/").split("/");
231
+ const last = parts[parts.length - 1] || "";
232
+ if (/\.[a-z0-9]+$/i.test(last)) parts.pop();
233
+ return parts.join("/");
234
+ }
235
+
207
236
  function inferArchitecturalPatterns(modules, depGraph = null) {
208
237
  const patterns = [];
209
- const keys = modules.map(m => m.key.toLowerCase());
210
-
211
- const has = (keyword) => keys.some(k => k.includes(keyword));
238
+ const production = modules.filter((module) => !isTestEvidence(module.key));
239
+ const tests = modules.filter((module) => isTestEvidence(module.key));
240
+ const keys = production.map(m => m.key.toLowerCase());
241
+
242
+ const has = (keyword) => keys.some((key) => (
243
+ keyword.includes("/") ? key.includes(keyword) : pathHasKeyword(key, keyword)
244
+ ));
245
+ const matchingKeys = (keywords, source = production) => {
246
+ const found = [];
247
+ for (const keyword of keywords) {
248
+ for (const module of source) {
249
+ const key = module.key;
250
+ const matched = keyword.includes("/")
251
+ ? key.toLowerCase().includes(keyword)
252
+ : pathHasKeyword(key, keyword);
253
+ if (matched && !found.includes(key)) found.push(key);
254
+ }
255
+ }
256
+ return found;
257
+ };
258
+ const hasApiRouteEvidence = () => production.some((module) => hasApiDirectory(module.key));
259
+ const cite = (label, keywords, extra = "", source = production) => {
260
+ const found = matchingKeys(keywords, source);
261
+ if (found.length === 0 && !extra) return;
262
+ const sample = found.slice(0, 3).map((key) => `\`${key}\``).join(", ");
263
+ const more = found.length > 3 ? ` (+${found.length - 3} more)` : "";
264
+ const where = sample ? `${sample}${more}` : "";
265
+ const detail = [where, extra].filter(Boolean).join("; ");
266
+ patterns.push(detail ? `${label} — ${detail}` : label);
267
+ };
268
+ const externalNames = depGraph?.externalDeps || [];
269
+ const importsReact = externalNames.some((name) => name === "react" || name === "react-dom" || name.startsWith("react/"));
270
+ const importsAi = externalNames.some((name) => /(^|\/)(openai|anthropic|langchain|ollama)(\/|$)/i.test(name) || name.startsWith("@anthropic-ai/") || name.startsWith("@ai-sdk/"));
212
271
 
213
272
  // Compute module-level metrics for verification
214
273
  const moduleMetrics = depGraph ? computeModuleLevelMetrics(depGraph, modules) : new Map();
@@ -217,6 +276,7 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
217
276
  // Verify pattern: a module matching `keyword` has high fan-in (truly central)
218
277
  const verifyHub = (keyword) => {
219
278
  for (const [mKey, m] of moduleMetrics) {
279
+ if (isTestEvidence(mKey)) continue;
220
280
  if (mKey.toLowerCase().includes(keyword) && m.fanIn >= 3) return true;
221
281
  }
222
282
  return false;
@@ -225,74 +285,69 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
225
285
  // Verify pattern: modules matching keyword have mostly outbound deps (orchestrators)
226
286
  const verifyOrchestrator = (keyword) => {
227
287
  for (const [mKey, m] of moduleMetrics) {
288
+ if (isTestEvidence(mKey)) continue;
228
289
  if (mKey.toLowerCase().includes(keyword) && m.fanOut >= 3) return true;
229
290
  }
230
291
  return false;
231
292
  };
232
293
 
233
- // Web framework patterns
234
- if (has("app/")) patterns.push("Next.js App Router");
235
- if (has("pages/")) patterns.push("Next.js Pages Router");
236
- if (has("component") && has("lib")) patterns.push("Layered component architecture");
237
- if (has("hook")) patterns.push("React hooks pattern");
238
- if (has("store") || has("state") || has("redux") || has("zustand")) patterns.push("Centralized state management");
239
-
240
- // General patterns — verified against dep graph when available
241
- if (has("api") || has("endpoint")) patterns.push("API route pattern");
294
+ // Web framework patterns. Each line names the modules (or imports) that triggered it.
295
+ if (has("app/")) cite("Next.js App Router", ["app/"]);
296
+ if (has("pages/")) cite("Next.js Pages Router", ["pages/"]);
297
+ if (has("component") && has("lib")) cite("Layered component architecture", ["component", "lib"]);
298
+ if (has("hook") && importsReact) cite("React hooks pattern", ["hook"], "imports react");
299
+ if (has("store") || has("state") || has("redux") || has("zustand")) {
300
+ cite("Centralized state management", ["store", "state", "redux", "zustand"]);
301
+ }
302
+
303
+ if (hasApiRouteEvidence()) {
304
+ const found = production
305
+ .filter((module) => hasApiDirectory(module.key))
306
+ .map((module) => module.key);
307
+ const sample = found.slice(0, 3).map((key) => `\`${key}\``).join(", ");
308
+ const more = found.length > 3 ? ` (+${found.length - 3} more)` : "";
309
+ patterns.push(`API route pattern — ${sample}${more}`);
310
+ }
242
311
 
243
312
  if (has("core") || has("kernel")) {
244
- if (moduleMetrics.size > 0 && verifyHub("core")) {
245
- patterns.push("Core/kernel architecture (verified — high fan-in)");
246
- } else if (moduleMetrics.size > 0) {
247
- patterns.push("Core/kernel architecture (naming only)");
248
- } else {
249
- patterns.push("Core/kernel architecture");
250
- }
313
+ const note = moduleMetrics.size > 0 && verifyHub("core")
314
+ ? "high fan-in"
315
+ : "";
316
+ cite("Core/kernel architecture", ["core", "kernel"], note);
251
317
  }
252
318
 
253
319
  if (has("plugin") || has("extension")) {
254
- if (moduleMetrics.size > 0 && (verifyHub("plugin") || verifyOrchestrator("plugin") || verifyHub("loader"))) {
255
- patterns.push("Plugin system (verified — loader/registry detected)");
256
- } else if (moduleMetrics.size > 0) {
257
- patterns.push("Plugin system (naming only)");
258
- } else {
259
- patterns.push("Plugin system");
260
- }
320
+ const verified = moduleMetrics.size > 0 && (verifyHub("plugin") || verifyOrchestrator("plugin") || verifyHub("loader"));
321
+ cite("Plugin system", ["plugin", "extension"], verified ? "loader or registry fan-in" : "");
261
322
  }
262
323
 
263
- if (has("middleware")) patterns.push("Middleware pipeline");
324
+ if (has("middleware")) cite("Middleware pipeline", ["middleware"]);
264
325
 
265
326
  if (has("render") || has("template")) {
266
- if (moduleMetrics.size > 0 && verifyOrchestrator("render")) {
267
- patterns.push("Renderer pipeline (verified — multi-output)");
268
- } else {
269
- patterns.push("Renderer pipeline");
270
- }
327
+ const note = moduleMetrics.size > 0 && verifyOrchestrator("render") ? "multi-output fan-out" : "";
328
+ cite("Renderer pipeline", ["render", "template"], note);
271
329
  }
272
330
 
273
331
  if (has("publish") || has("output")) {
274
- if (moduleMetrics.size > 0 && verifyOrchestrator("publish")) {
275
- patterns.push("Multi-output publishing (verified — multiple targets)");
276
- } else {
277
- patterns.push("Multi-output publishing");
278
- }
332
+ const note = moduleMetrics.size > 0 && verifyOrchestrator("publish") ? "multiple publish targets" : "";
333
+ cite("Multi-output publishing", ["publish", "output"], note);
279
334
  }
280
335
 
281
- if (has("analyz") || has("detect") || has("inspect")) patterns.push("Analysis pipeline");
282
- if (has("cli") || has("command") || has("bin")) patterns.push("CLI tool architecture");
283
-
336
+ if (has("analyz") || has("detect") || has("inspect")) cite("Analysis pipeline", ["analyz", "detect", "inspect"]);
337
+ if (has("cli") || has("command") || has("bin")) cite("CLI tool architecture", ["cli", "command", "bin"]);
338
+
284
339
  if (has("util") || has("helper") || has("lib")) {
285
- if (moduleMetrics.size > 0 && verifyHub("util") || verifyHub("helper") || verifyHub("lib")) {
286
- patterns.push("Shared utility layer (verified — high fan-in)");
287
- } else {
288
- patterns.push("Shared utility layer");
289
- }
340
+ const verified = moduleMetrics.size > 0 && verifyHub("util") || verifyHub("helper") || verifyHub("lib");
341
+ cite("Shared utility layer", ["util", "helper", "lib"], verified ? "high fan-in" : "");
290
342
  }
291
-
292
- if (has("integrat") || has("adapter") || has("connect")) patterns.push("Integration adapters");
293
- if (has("ai") || has("prompt") || has("provider")) patterns.push("AI/LLM integration");
294
- if (has("deliver") || has("dispatch")) patterns.push("Delivery pipeline");
295
- if (has("test") || has("spec")) patterns.push("Dedicated test infrastructure");
343
+
344
+ if (has("integrat") || has("adapter") || has("connect")) cite("Integration adapters", ["integrat", "adapter", "connect"]);
345
+ if (has("ai") || has("llm") || has("openai") || has("anthropic") || importsAi) {
346
+ const aiPackages = externalNames.filter((name) => /(^|\/)(openai|anthropic|langchain|ollama)(\/|$)/i.test(name) || name.startsWith("@anthropic-ai/") || name.startsWith("@ai-sdk/"));
347
+ cite("AI/LLM integration", ["ai", "llm", "openai", "anthropic"], aiPackages.length ? `imports ${aiPackages.slice(0, 3).join(", ")}` : "");
348
+ }
349
+ if (has("deliver") || has("dispatch")) cite("Delivery pipeline", ["deliver", "dispatch"]);
350
+ if (tests.length) cite("Dedicated test infrastructure", ["test", "spec"], "", tests);
296
351
 
297
352
  // Structural patterns from dep graph (not keyword-based)
298
353
  if (depGraph?.stats) {
@@ -334,7 +389,7 @@ export function buildModuleContext(modules, config, depGraph = null) {
334
389
  }))
335
390
  : [];
336
391
 
337
- const domainGroups = groupModulesByDomain(modules, customHints);
392
+ const domainGroups = groupModulesByDomain(modules, customHints, depGraph);
338
393
  const moduleMetrics = computeModuleLevelMetrics(depGraph, modules);
339
394
 
340
395
  return modules.map(module => {
@@ -1,23 +1,25 @@
1
- // Dependency graph analysis with cycle detection
2
- // Parses import/require statements, builds a directed graph, detects cycles via DFS
1
+ // Dependency graph analysis with cycle detection.
2
+ // JavaScript, TypeScript, Python, and Go imports become edges.
3
+ // Comments and strings are ignored. Type-only TypeScript imports are not edges.
3
4
 
4
5
  import fs from "node:fs/promises";
5
6
  import path from "node:path";
6
7
  import { info, warn } from "../utils/logger.js";
7
-
8
- const CODE_EXTENSIONS = new Set([".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"]);
9
-
10
- // Patterns for extracting imports
11
- const IMPORT_PATTERNS = [
12
- // ES module: import ... from "..."
13
- /import\s+(?:[\w*{},\s]+\s+from\s+)?['"]([^'"]+)['"]/g,
14
- // Dynamic import: import("...")
15
- /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
16
- // CommonJS: require("...")
17
- /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
18
- // Re-export: export ... from "..."
19
- /export\s+(?:[\w*{},\s]+\s+from\s+)?['"]([^'"]+)['"]/g,
20
- ];
8
+ import {
9
+ extractJsImports,
10
+ externalPackageName,
11
+ extractPythonImports,
12
+ extractGoImports,
13
+ indexPythonModules,
14
+ resolvePythonImport,
15
+ goPackageDir,
16
+ isGoStdlib,
17
+ parseGoModule,
18
+ } from "./import-parsers.js";
19
+
20
+ const JS_EXTENSIONS = new Set([".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"]);
21
+ const PY_EXTENSIONS = new Set([".py", ".pyw"]);
22
+ const GO_EXTENSIONS = new Set([".go"]);
21
23
 
22
24
  async function readFileSafe(filePath) {
23
25
  try {
@@ -27,6 +29,10 @@ async function readFileSafe(filePath) {
27
29
  }
28
30
  }
29
31
 
32
+ function isGoTestFile(file) {
33
+ return String(file).replace(/\\/g, "/").endsWith("_test.go");
34
+ }
35
+
30
36
  function isRelativeImport(specifier) {
31
37
  return specifier.startsWith("./") || specifier.startsWith("../");
32
38
  }
@@ -58,63 +64,140 @@ function normalizeFileKey(file) {
58
64
  return key;
59
65
  }
60
66
 
61
- export async function analyzeDependencyGraph(files, repoRoot) {
62
- const result = {
63
- nodes: [], // { key, file, imports: string[], importedBy: string[] }
64
- edges: [], // { from, to }
65
- cycles: [], // [ [a, b, c, a], ... ]
66
- externalDeps: [],// sorted unique bare-specifier imports
67
+ function emptyResult() {
68
+ return {
69
+ nodes: [],
70
+ edges: [],
71
+ cycles: [],
72
+ externalDeps: [],
67
73
  stats: null,
68
74
  summary: null,
69
75
  };
76
+ }
77
+
78
+ async function loadGoModule(repoRoot) {
79
+ const text = await readFileSafe(path.join(repoRoot, "go.mod"));
80
+ return parseGoModule(text);
81
+ }
82
+
83
+ function goPackageImportPath(file, modulePath) {
84
+ const norm = String(file || "").replace(/\\/g, "/");
85
+ if (!norm.endsWith(".go")) return null;
86
+ const dir = path.posix.dirname(norm);
87
+ const rel = dir === "." ? "" : dir;
88
+ if (modulePath) return rel ? `${modulePath}/${rel}` : modulePath;
89
+ return rel || ".";
90
+ }
91
+
92
+ function aggregateHubs(nodes, fileKeyToFile, modulePath, hubThreshold) {
93
+ const groups = new Map();
94
+ for (const node of nodes) {
95
+ const pkg = goPackageImportPath(node.file, modulePath);
96
+ const key = pkg || node.key;
97
+ if (!groups.has(key)) groups.set(key, new Set());
98
+ const importers = groups.get(key);
99
+ for (const importer of node.importedBy || []) {
100
+ const importerFile = fileKeyToFile.get(importer) || importer;
101
+ if (pkg && goPackageImportPath(importerFile, modulePath) === pkg) continue;
102
+ importers.add(importer);
103
+ }
104
+ }
105
+ return [...groups.entries()]
106
+ .map(([key, importers]) => ({ key, importedBy: importers.size }))
107
+ .filter((hub) => hub.importedBy >= hubThreshold)
108
+ .sort((a, b) => b.importedBy - a.importedBy)
109
+ .slice(0, 10);
110
+ }
70
111
 
71
- const codeFiles = files.filter(f => {
72
- const ext = path.extname(f);
73
- return CODE_EXTENSIONS.has(ext);
112
+ function goFilesInDir(goFiles, dir) {
113
+ const target = dir || "";
114
+ return goFiles.filter((file) => {
115
+ const fileDir = path.posix.dirname(file.replace(/\\/g, "/"));
116
+ const normalized = fileDir === "." ? "" : fileDir;
117
+ return normalized === target;
74
118
  });
119
+ }
120
+
121
+ export async function analyzeDependencyGraph(files, repoRoot) {
122
+ const result = emptyResult();
123
+
124
+ const jsFiles = files.filter((file) => JS_EXTENSIONS.has(path.extname(file)));
125
+ const pyFiles = files.filter((file) => PY_EXTENSIONS.has(path.extname(file)));
126
+ const goFiles = files.filter((file) => GO_EXTENSIONS.has(path.extname(file)) && !isGoTestFile(file));
127
+ const codeFiles = [...jsFiles, ...pyFiles, ...goFiles];
75
128
 
76
129
  if (codeFiles.length === 0) return result;
77
130
 
78
- // Build adjacency: fileKey → Set<fileKey>
79
131
  const adjacency = new Map();
80
132
  const fileKeyToFile = new Map();
81
133
  const allFileKeys = new Set();
82
134
  const externalSet = new Set();
135
+ const externalByKey = new Map();
83
136
 
84
- // Register all known file keys
85
- for (const file of codeFiles) {
137
+ function register(file) {
86
138
  const key = normalizeFileKey(file);
87
139
  allFileKeys.add(key);
88
- fileKeyToFile.set(key, file);
140
+ if (!fileKeyToFile.has(key)) fileKeyToFile.set(key, file);
89
141
  if (!adjacency.has(key)) adjacency.set(key, new Set());
142
+ if (!externalByKey.has(key)) externalByKey.set(key, new Set());
143
+ return key;
90
144
  }
91
145
 
92
- // Parse imports and build edges
93
- for (const file of codeFiles) {
146
+ function addExternal(fromKey, name) {
147
+ if (!name) return;
148
+ externalSet.add(name);
149
+ externalByKey.get(fromKey).add(name);
150
+ }
151
+
152
+ function addEdge(fromKey, toKey) {
153
+ if (!toKey || toKey === fromKey || !allFileKeys.has(toKey)) return;
154
+ adjacency.get(fromKey).add(toKey);
155
+ }
156
+
157
+ for (const file of codeFiles) register(file);
158
+
159
+ const pyIndex = indexPythonModules(pyFiles);
160
+ const goModulePath = goFiles.length > 0 ? await loadGoModule(repoRoot) : null;
161
+
162
+ for (const file of jsFiles) {
94
163
  const content = await readFileSafe(path.join(repoRoot, file));
95
164
  if (!content) continue;
165
+ const fromKey = normalizeFileKey(file);
166
+ for (const imported of extractJsImports(content)) {
167
+ if (imported.typeOnly) continue;
168
+ if (isRelativeImport(imported.specifier)) {
169
+ addEdge(fromKey, resolveImportPath(file, imported.specifier));
170
+ } else {
171
+ addExternal(fromKey, externalPackageName(imported.specifier));
172
+ }
173
+ }
174
+ }
96
175
 
176
+ for (const file of pyFiles) {
177
+ const content = await readFileSafe(path.join(repoRoot, file));
178
+ if (!content) continue;
97
179
  const fromKey = normalizeFileKey(file);
180
+ for (const imported of extractPythonImports(content)) {
181
+ if (imported.typeOnly) continue;
182
+ const resolved = resolvePythonImport(file, imported, pyIndex);
183
+ if (resolved.kind === "internal") addEdge(fromKey, normalizeFileKey(resolved.file));
184
+ else if (resolved.kind === "external") addExternal(fromKey, resolved.name);
185
+ }
186
+ }
98
187
 
99
- for (const pattern of IMPORT_PATTERNS) {
100
- // Reset lastIndex since we reuse regex objects
101
- const regex = new RegExp(pattern.source, pattern.flags);
102
- let match;
103
- while ((match = regex.exec(content)) !== null) {
104
- const specifier = match[1];
105
-
106
- if (isRelativeImport(specifier)) {
107
- const resolved = resolveImportPath(file, specifier);
108
- if (resolved && allFileKeys.has(resolved)) {
109
- adjacency.get(fromKey).add(resolved);
110
- }
111
- } else if (!specifier.startsWith("node:")) {
112
- // Bare specifier → external dependency
113
- // Normalize scoped packages: @scope/pkg/path → @scope/pkg
114
- const parts = specifier.startsWith("@") ? specifier.split("/").slice(0, 2) : specifier.split("/").slice(0, 1);
115
- externalSet.add(parts.join("/"));
188
+ for (const file of goFiles) {
189
+ const content = await readFileSafe(path.join(repoRoot, file));
190
+ if (!content) continue;
191
+ const fromKey = normalizeFileKey(file);
192
+ for (const importPath of extractGoImports(content)) {
193
+ const dir = goPackageDir(importPath, goModulePath, "");
194
+ if (dir !== null) {
195
+ for (const target of goFilesInDir(goFiles, dir)) {
196
+ addEdge(fromKey, normalizeFileKey(target));
116
197
  }
198
+ continue;
117
199
  }
200
+ if (!isGoStdlib(importPath)) addExternal(fromKey, importPath);
118
201
  }
119
202
  }
120
203
 
@@ -139,6 +222,7 @@ export async function analyzeDependencyGraph(files, repoRoot) {
139
222
  file: fileKeyToFile.get(key) || key,
140
223
  imports: [...(adjacency.get(key) || [])],
141
224
  importedBy: importedByMap.get(key) || [],
225
+ externalImports: [...(externalByKey.get(key) || [])].sort(),
142
226
  });
143
227
  }
144
228
 
@@ -151,11 +235,7 @@ export async function analyzeDependencyGraph(files, repoRoot) {
151
235
  const totalImports = result.edges.length;
152
236
  const orphans = result.nodes.filter(n => n.imports.length === 0 && n.importedBy.length === 0);
153
237
  const hubThreshold = Math.max(5, Math.floor(result.nodes.length * 0.1));
154
- const hubs = result.nodes
155
- .filter(n => n.importedBy.length >= hubThreshold)
156
- .sort((a, b) => b.importedBy.length - a.importedBy.length)
157
- .slice(0, 10)
158
- .map(n => ({ key: n.key, importedBy: n.importedBy.length }));
238
+ const hubs = aggregateHubs(result.nodes, fileKeyToFile, goModulePath, hubThreshold);
159
239
 
160
240
  result.stats = {
161
241
  totalFiles: codeFiles.length,
@@ -133,16 +133,54 @@ const DEFAULT_DOMAIN_HINTS = [
133
133
  }
134
134
  ];
135
135
 
136
- export function inferDomain(modulePath, customHints = []) {
136
+ const REACT_HOOK_KEYWORDS = new Set(["hook", "hooks", "use"]);
137
+
138
+ export function pathTokens(modulePath) {
139
+ return String(modulePath).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
140
+ }
141
+
142
+ /** Match a path token, not a substring (`ai` does not match `_main` or `email`). */
143
+ export function pathHasKeyword(modulePath, keyword) {
144
+ const needle = String(keyword).toLowerCase();
145
+ if (!needle) return false;
146
+ return pathTokens(modulePath).some((token) => {
147
+ if (token === needle || token === `${needle}s`) return true;
148
+ if (needle.length >= 5 && token.startsWith(needle)) return true;
149
+ return false;
150
+ });
151
+ }
152
+
153
+ export function moduleEvidence(moduleKey, depGraph) {
154
+ const key = String(moduleKey).replace(/\\/g, "/");
155
+ const nodes = (depGraph?.nodes || []).filter((node) => {
156
+ const file = String(node.file || node.key || "").replace(/\\/g, "/");
157
+ return file === key || file.startsWith(`${key}/`) || node.key === key;
158
+ });
159
+ const imports = new Set(nodes.flatMap((node) => node.externalImports || []));
160
+ const react = [...imports].some((name) => name === "react" || name === "react-dom" || name.startsWith("react/"));
161
+ const ai = [...imports].some((name) => /(^|\/)(openai|anthropic|langchain|ollama)(\/|$)/i.test(name) || name.startsWith("@anthropic-ai/") || name.startsWith("@ai-sdk/"));
162
+ return { react, ai };
163
+ }
164
+
165
+ function domainEvidenceText(hint, modulePath, keyword, evidence) {
166
+ const isDefault = DEFAULT_DOMAIN_HINTS.includes(hint);
167
+ if (!isDefault && hint.description) return hint.description;
168
+ const bits = [`path \`${modulePath}\` matches \`${keyword}\``];
169
+ if (REACT_HOOK_KEYWORDS.has(keyword) && evidence.react) bits.push("imports react");
170
+ if (hint.domain === "AI & Machine Learning" && evidence.ai) bits.push("imports an AI SDK");
171
+ return bits.join("; ");
172
+ }
173
+
174
+ export function inferDomain(modulePath, customHints = [], evidence = {}) {
137
175
  const hints = [...customHints, ...DEFAULT_DOMAIN_HINTS];
138
- const lowerPath = modulePath.toLowerCase();
139
-
176
+
140
177
  for (const hint of hints) {
141
178
  for (const keyword of hint.match) {
142
- if (lowerPath.includes(keyword)) {
179
+ if (REACT_HOOK_KEYWORDS.has(keyword) && !evidence.react) continue;
180
+ if (pathHasKeyword(modulePath, keyword)) {
143
181
  return {
144
182
  domain: hint.domain,
145
- description: hint.description,
183
+ description: domainEvidenceText(hint, modulePath, keyword, evidence),
146
184
  confidence: "pattern_match",
147
185
  matchedKeyword: keyword
148
186
  };
@@ -158,11 +196,11 @@ export function inferDomain(modulePath, customHints = []) {
158
196
  };
159
197
  }
160
198
 
161
- export function groupModulesByDomain(modules, customHints = []) {
199
+ export function groupModulesByDomain(modules, customHints = [], depGraph = null) {
162
200
  const domainGroups = {};
163
201
 
164
202
  for (const module of modules) {
165
- const inference = inferDomain(module.key, customHints);
203
+ const inference = inferDomain(module.key, customHints, moduleEvidence(module.key, depGraph));
166
204
  const domainName = inference.domain;
167
205
 
168
206
  if (!domainGroups[domainName]) {