@chappibunny/repolens 1.15.3 → 1.15.5

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,7 @@
1
1
  // Build structured AI context from deterministic scan results
2
2
 
3
- import { groupModulesByDomain, pathHasKeyword } from "./domain-inference.js";
3
+ import { groupModulesByDomain, pathHasKeyword, stripContainerSegments, hasContainerSegment } from "./domain-inference.js";
4
+ import { isTestPath } from "../utils/path-roles.js";
4
5
 
5
6
  /**
6
7
  * Compute per-module dependency metrics from the dep graph.
@@ -28,56 +29,92 @@ export function computeModuleDepMetrics(depGraph) {
28
29
  }
29
30
 
30
31
  /**
31
- * Build a module-level dep metrics map (aggregating file-level metrics).
32
+ * Find the module a file belongs to: the module with the longest key that is a path
33
+ * prefix of the file. Files at the repository root belong to `root`.
34
+ */
35
+ function buildModuleLookup(modules) {
36
+ const keys = modules.map((m) => m.key).sort((x, y) => y.length - x.length);
37
+ const cache = new Map();
38
+ return (file) => {
39
+ const norm = String(file || "").replace(/\\/g, "/");
40
+ if (cache.has(norm)) return cache.get(norm);
41
+ let found = null;
42
+ for (const key of keys) {
43
+ if (key === "root") continue;
44
+ if (norm === key || norm.startsWith(`${key}/`)) { found = key; break; }
45
+ }
46
+ if (!found && !norm.includes("/") && keys.includes("root")) found = "root";
47
+ cache.set(norm, found);
48
+ return found;
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Build a module-level dep metrics map from file-level edges.
32
54
  * moduleKey → { fanIn, fanOut, isHub, isOrphan, isLeaf, isOrchestrator }
55
+ * fanIn and fanOut count distinct other modules, not file-to-file edges, so a folder
56
+ * of 77 test files that imports one helper 77 times still has a fan-out of 1.
33
57
  */
34
58
  export function computeModuleLevelMetrics(depGraph, modules) {
35
- const fileMetrics = computeModuleDepMetrics(depGraph);
36
- if (fileMetrics.size === 0 || !modules) return new Map();
59
+ if (!depGraph?.nodes || depGraph.nodes.length === 0 || !modules) return new Map();
37
60
 
38
- const moduleLevelMap = new Map();
61
+ const moduleOf = buildModuleLookup(modules);
62
+ const fileToModule = new Map();
63
+ for (const node of depGraph.nodes) {
64
+ fileToModule.set(node.key, moduleOf(node.file || node.key));
65
+ }
39
66
 
40
- for (const mod of modules) {
41
- const moduleKey = mod.key;
42
- const lowerKey = moduleKey.toLowerCase();
43
-
44
- // Find all file-level nodes that belong to this module
45
- let totalFanIn = 0;
46
- let totalFanOut = 0;
47
- let fileCount = 0;
48
-
49
- for (const [nodeKey, m] of fileMetrics) {
50
- if (nodeKey === lowerKey || nodeKey.startsWith(lowerKey + "/") || nodeKey === moduleKey || nodeKey.startsWith(moduleKey + "/")) {
51
- // Count only external edges (crossing module boundary)
52
- if (depGraph?.nodes) {
53
- const node = depGraph.nodes.find(n => n.key === nodeKey);
54
- if (node) {
55
- const externalIn = node.importedBy.filter(imp => !imp.startsWith(moduleKey + "/") && imp !== moduleKey).length;
56
- const externalOut = node.imports.filter(imp => !imp.startsWith(moduleKey + "/") && imp !== moduleKey).length;
57
- totalFanIn += externalIn;
58
- totalFanOut += externalOut;
59
- fileCount++;
60
- }
61
- }
62
- }
67
+ const importedFrom = new Map(); // module -> Set of modules it imports
68
+ const importersOf = new Map(); // module -> Set of modules importing it
69
+ const fileCounts = new Map();
70
+ for (const node of depGraph.nodes) {
71
+ const from = fileToModule.get(node.key);
72
+ if (!from) continue;
73
+ fileCounts.set(from, (fileCounts.get(from) || 0) + 1);
74
+ for (const target of node.imports) {
75
+ const to = fileToModule.get(target);
76
+ if (!to || to === from) continue;
77
+ if (!importedFrom.has(from)) importedFrom.set(from, new Set());
78
+ if (!importersOf.has(to)) importersOf.set(to, new Set());
79
+ importedFrom.get(from).add(to);
80
+ importersOf.get(to).add(from);
63
81
  }
82
+ }
64
83
 
65
- if (fileCount > 0) {
66
- const hubThreshold = Math.max(5, Math.floor(modules.length * 0.15));
67
- moduleLevelMap.set(moduleKey, {
68
- fanIn: totalFanIn,
69
- fanOut: totalFanOut,
70
- isHub: totalFanIn >= hubThreshold,
71
- isOrphan: totalFanIn === 0 && totalFanOut === 0,
72
- isLeaf: totalFanOut === 0 && totalFanIn > 0,
73
- isOrchestrator: totalFanOut >= 5 && totalFanIn < totalFanOut,
74
- });
75
- }
84
+ const moduleLevelMap = new Map();
85
+ const hubThreshold = Math.max(3, Math.floor(modules.length * 0.15));
86
+ for (const mod of modules) {
87
+ if (!fileCounts.has(mod.key)) continue;
88
+ const fanIn = importersOf.get(mod.key)?.size || 0;
89
+ const fanOut = importedFrom.get(mod.key)?.size || 0;
90
+ moduleLevelMap.set(mod.key, {
91
+ fanIn,
92
+ fanOut,
93
+ isHub: fanIn >= hubThreshold,
94
+ isOrphan: fanIn === 0 && fanOut === 0,
95
+ isLeaf: fanOut === 0 && fanIn > 0,
96
+ isOrchestrator: fanOut >= 5 && fanIn < fanOut,
97
+ });
76
98
  }
77
99
 
78
100
  return moduleLevelMap;
79
101
  }
80
102
 
103
+ /**
104
+ * Is this project a command-line tool? Package metadata decides when it is known
105
+ * (a library with a build helper in `bin/` is a library). Otherwise a CLI pattern does.
106
+ */
107
+ export function isCliProject(context) {
108
+ const kind = context?.project?.kind;
109
+ if (kind === "cli") return true;
110
+ if (kind === "library") return false;
111
+ return (context?.patterns || []).some((p) => String(p).toLowerCase().startsWith("cli tool"));
112
+ }
113
+
114
+ export function isLibraryProject(context) {
115
+ return context?.project?.kind === "library";
116
+ }
117
+
81
118
  export function buildAIContext(scanResult, config, depGraph = null) {
82
119
  const { filesCount, modules, api, pages, metadata } = scanResult;
83
120
 
@@ -129,12 +166,13 @@ export function buildAIContext(scanResult, config, depGraph = null) {
129
166
  };
130
167
 
131
168
  // Identify key architectural patterns (verified against dep graph when available)
132
- const patterns = inferArchitecturalPatterns(modules, depGraph);
169
+ const patterns = inferArchitecturalPatterns(modules, depGraph, metadata?.projectKind);
133
170
 
134
171
  // Build compact context object
135
172
  return {
136
173
  project: {
137
174
  name: config.project.name,
175
+ kind: metadata?.projectKind || "unknown",
138
176
  filesScanned: filesCount,
139
177
  modulesDetected: modules.length,
140
178
  pagesDetected: pages?.length || 0,
@@ -159,6 +197,8 @@ export function buildAIContext(scanResult, config, depGraph = null) {
159
197
  techStack,
160
198
 
161
199
  patterns,
200
+
201
+ findings: inferFindings(depGraph),
162
202
 
163
203
  repoRoots: config.module_roots || [],
164
204
 
@@ -175,7 +215,15 @@ export function buildAIContext(scanResult, config, depGraph = null) {
175
215
  }
176
216
 
177
217
  function inferModuleType(modulePath) {
178
- const has = (keyword) => pathHasKeyword(modulePath, keyword);
218
+ // `src` and `lib` only say where code lives. `lib/ogg` is typed by `ogg`, and a bare
219
+ // `lib` is the project's source, not a "shared library" layer.
220
+ const typedPath = stripContainerSegments(modulePath);
221
+ const fromContainer = hasContainerSegment(modulePath);
222
+ const has = (keyword) => pathHasKeyword(typedPath, keyword);
223
+ // Page, route, and view describe a folder of pages. A build script called
224
+ // `build-pages.js` is not one.
225
+ const isFile = /\.[a-z0-9]+$/i.test(String(modulePath).split("/").pop() || "");
226
+ const hasDirectory = (keyword) => pathHasKeyword(directoryPath(typedPath), keyword);
179
227
 
180
228
  if (has("test") || has("spec")) return "test";
181
229
  if (has("api") || has("endpoint")) return "api";
@@ -183,7 +231,7 @@ function inferModuleType(modulePath) {
183
231
  if (has("lib") || has("util") || has("helper") || has("common") || has("shared")) return "library";
184
232
  if (has("hook")) return "hooks";
185
233
  if (has("store") || has("state") || has("redux") || has("zustand")) return "state";
186
- if (has("page") || has("route") || has("view")) return "route";
234
+ if (isFile ? (hasDirectory("page") || hasDirectory("route") || hasDirectory("view")) : (has("page") || has("route") || has("view"))) return "route";
187
235
  if (has("config") || has("setting") || has("env")) return "config";
188
236
  if (has("core") || has("kernel") || has("foundation")) return "core";
189
237
  if (has("render") || has("template") || has("format")) return "renderer";
@@ -201,19 +249,10 @@ function inferModuleType(modulePath) {
201
249
  if (has("service")) return "service";
202
250
  if (has("app")) return "app";
203
251
 
204
- return "other";
252
+ return fromContainer ? "source" : "other";
205
253
  }
206
254
 
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
- }
255
+ const isTestEvidence = isTestPath;
217
256
 
218
257
  const API_DIRECTORY_SEGMENTS = new Set([
219
258
  "api", "apis", "route", "routes", "router", "routers",
@@ -233,14 +272,15 @@ function directoryPath(modulePath) {
233
272
  return parts.join("/");
234
273
  }
235
274
 
236
- function inferArchitecturalPatterns(modules, depGraph = null) {
275
+ function inferArchitecturalPatterns(modules, depGraph = null, projectKind = null) {
237
276
  const patterns = [];
238
277
  const production = modules.filter((module) => !isTestEvidence(module.key));
239
278
  const tests = modules.filter((module) => isTestEvidence(module.key));
240
279
  const keys = production.map(m => m.key.toLowerCase());
241
280
 
281
+ // `lib` and `src` only say where code lives, so they never count as evidence for a pattern.
242
282
  const has = (keyword) => keys.some((key) => (
243
- keyword.includes("/") ? key.includes(keyword) : pathHasKeyword(key, keyword)
283
+ keyword.includes("/") ? key.includes(keyword) : pathHasKeyword(stripContainerSegments(key), keyword)
244
284
  ));
245
285
  const matchingKeys = (keywords, source = production) => {
246
286
  const found = [];
@@ -249,7 +289,7 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
249
289
  const key = module.key;
250
290
  const matched = keyword.includes("/")
251
291
  ? key.toLowerCase().includes(keyword)
252
- : pathHasKeyword(key, keyword);
292
+ : pathHasKeyword(stripContainerSegments(key), keyword);
253
293
  if (matched && !found.includes(key)) found.push(key);
254
294
  }
255
295
  }
@@ -271,7 +311,6 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
271
311
 
272
312
  // Compute module-level metrics for verification
273
313
  const moduleMetrics = depGraph ? computeModuleLevelMetrics(depGraph, modules) : new Map();
274
- const hasCycles = depGraph?.cycles?.length > 0;
275
314
 
276
315
  // Verify pattern: a module matching `keyword` has high fan-in (truly central)
277
316
  const verifyHub = (keyword) => {
@@ -309,11 +348,18 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
309
348
  patterns.push(`API route pattern — ${sample}${more}`);
310
349
  }
311
350
 
312
- if (has("core") || has("kernel")) {
351
+ // A directory named core/ or kernel/ is evidence. A file called core.ts is not:
352
+ // it is as likely a cross-platform entry point as a kernel.
353
+ const coreDirs = production
354
+ .filter((module) => directoryPath(module.key).split("/").some((segment) => ["core", "kernel"].includes(segment.toLowerCase())))
355
+ .map((module) => module.key);
356
+ if (coreDirs.length > 0) {
313
357
  const note = moduleMetrics.size > 0 && verifyHub("core")
314
358
  ? "high fan-in"
315
359
  : "";
316
- cite("Core/kernel architecture", ["core", "kernel"], note);
360
+ const sample = coreDirs.slice(0, 3).map((key) => `\`${key}\``).join(", ");
361
+ const more = coreDirs.length > 3 ? ` (+${coreDirs.length - 3} more)` : "";
362
+ patterns.push(`Core/kernel architecture — ${[`${sample}${more}`, note].filter(Boolean).join("; ")}`);
317
363
  }
318
364
 
319
365
  if (has("plugin") || has("extension")) {
@@ -334,7 +380,8 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
334
380
  }
335
381
 
336
382
  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"]);
383
+ // A library's `bin/` folder is build tooling. Only a CLI package gets this pattern.
384
+ if (projectKind !== "library" && (has("cli") || has("command") || has("bin"))) cite("CLI tool architecture", ["cli", "command", "bin"]);
338
385
 
339
386
  if (has("util") || has("helper") || has("lib")) {
340
387
  const verified = moduleMetrics.size > 0 && verifyHub("util") || verifyHub("helper") || verifyHub("lib");
@@ -354,14 +401,24 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
354
401
  if (depGraph.stats.cycles === 0 && depGraph.stats.totalEdges > 10) {
355
402
  patterns.push("Acyclic dependency graph — clean layering");
356
403
  }
357
- if (hasCycles) {
358
- patterns.push(`⚠️ ${depGraph.stats.cycles} circular dependency cycle(s) detected`);
359
- }
360
404
  }
361
405
 
362
406
  return patterns;
363
407
  }
364
408
 
409
+ /**
410
+ * Warnings about the code that are not architectural patterns, for example
411
+ * circular dependencies. Docs list these as findings, never as a pattern.
412
+ */
413
+ export function inferFindings(depGraph = null) {
414
+ const findings = [];
415
+ const cycles = depGraph?.stats?.cycles;
416
+ if (cycles > 0) {
417
+ findings.push(`⚠️ ${cycles} circular dependency cycle${cycles === 1 ? "" : "s"} detected`);
418
+ }
419
+ return findings;
420
+ }
421
+
365
422
  /**
366
423
  * Generate a dependency-aware role description for a module.
367
424
  */
@@ -16,6 +16,7 @@ import {
16
16
  isGoStdlib,
17
17
  parseGoModule,
18
18
  } from "./import-parsers.js";
19
+ import { isNonProductPath } from "../utils/path-roles.js";
19
20
 
20
21
  const JS_EXTENSIONS = new Set([".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"]);
21
22
  const PY_EXTENSIONS = new Set([".py", ".pyw"]);
@@ -70,6 +71,8 @@ function emptyResult() {
70
71
  edges: [],
71
72
  cycles: [],
72
73
  externalDeps: [],
74
+ externalRuntime: [],
75
+ externalDev: [],
73
76
  stats: null,
74
77
  summary: null,
75
78
  };
@@ -89,15 +92,19 @@ function goPackageImportPath(file, modulePath) {
89
92
  return rel || ".";
90
93
  }
91
94
 
95
+ // Hubs describe the product. Test helpers, examples, and build scripts are neither hubs
96
+ // nor reasons for a product file to count as one.
92
97
  function aggregateHubs(nodes, fileKeyToFile, modulePath, hubThreshold) {
93
98
  const groups = new Map();
94
99
  for (const node of nodes) {
100
+ if (isNonProductPath(node.file || node.key)) continue;
95
101
  const pkg = goPackageImportPath(node.file, modulePath);
96
102
  const key = pkg || node.key;
97
103
  if (!groups.has(key)) groups.set(key, new Set());
98
104
  const importers = groups.get(key);
99
105
  for (const importer of node.importedBy || []) {
100
106
  const importerFile = fileKeyToFile.get(importer) || importer;
107
+ if (isNonProductPath(importerFile)) continue;
101
108
  if (pkg && goPackageImportPath(importerFile, modulePath) === pkg) continue;
102
109
  importers.add(importer);
103
110
  }
@@ -156,6 +163,40 @@ export async function analyzeDependencyGraph(files, repoRoot) {
156
163
 
157
164
  for (const file of codeFiles) register(file);
158
165
 
166
+ // A package importing its own name (examples, docs, tests written like user code)
167
+ // is not a dependency. Resolve the nearest package.json for each file.
168
+ const packageNameByDir = new Map();
169
+ async function ownPackageName(file) {
170
+ let dir = path.dirname(file);
171
+ const visited = [];
172
+ for (;;) {
173
+ if (packageNameByDir.has(dir)) {
174
+ const cached = packageNameByDir.get(dir);
175
+ for (const d of visited) packageNameByDir.set(d, cached);
176
+ return cached;
177
+ }
178
+ visited.push(dir);
179
+ const raw = await readFileSafe(path.join(repoRoot, dir, "package.json"));
180
+ if (raw) {
181
+ let name = null;
182
+ try {
183
+ const parsed = JSON.parse(raw);
184
+ if (typeof parsed?.name === "string") name = parsed.name;
185
+ } catch {
186
+ // invalid package.json: keep walking up
187
+ }
188
+ if (name) {
189
+ for (const d of visited) packageNameByDir.set(d, name);
190
+ return name;
191
+ }
192
+ }
193
+ if (dir === "." || dir === "" || dir === path.dirname(dir)) break;
194
+ dir = path.dirname(dir);
195
+ }
196
+ for (const d of visited) packageNameByDir.set(d, null);
197
+ return null;
198
+ }
199
+
159
200
  const pyIndex = indexPythonModules(pyFiles);
160
201
  const goModulePath = goFiles.length > 0 ? await loadGoModule(repoRoot) : null;
161
202
 
@@ -163,12 +204,15 @@ export async function analyzeDependencyGraph(files, repoRoot) {
163
204
  const content = await readFileSafe(path.join(repoRoot, file));
164
205
  if (!content) continue;
165
206
  const fromKey = normalizeFileKey(file);
207
+ const selfName = await ownPackageName(file);
166
208
  for (const imported of extractJsImports(content)) {
167
209
  if (imported.typeOnly) continue;
168
210
  if (isRelativeImport(imported.specifier)) {
169
211
  addEdge(fromKey, resolveImportPath(file, imported.specifier));
170
212
  } else {
171
- addExternal(fromKey, externalPackageName(imported.specifier));
213
+ const name = externalPackageName(imported.specifier);
214
+ if (name && name === selfName) continue;
215
+ addExternal(fromKey, name);
172
216
  }
173
217
  }
174
218
  }
@@ -234,13 +278,22 @@ export async function analyzeDependencyGraph(files, repoRoot) {
234
278
  // Stats
235
279
  const totalImports = result.edges.length;
236
280
  const orphans = result.nodes.filter(n => n.imports.length === 0 && n.importedBy.length === 0);
237
- const hubThreshold = Math.max(5, Math.floor(result.nodes.length * 0.1));
281
+ const sourceNodeCount = result.nodes.filter((n) => !isNonProductPath(n.file || n.key)).length;
282
+ const hubThreshold = Math.max(5, Math.round(sourceNodeCount * 0.07));
238
283
  const hubs = aggregateHubs(result.nodes, fileKeyToFile, goModulePath, hubThreshold);
239
284
 
285
+ // Runtime vs development packages, from the nearest package.json.
286
+ const { runtime: externalRuntime, dev: externalDev } = await splitExternalDeps(result, repoRoot);
287
+ result.externalRuntime = externalRuntime;
288
+ result.externalDev = externalDev;
289
+
240
290
  result.stats = {
241
291
  totalFiles: codeFiles.length,
292
+ sourceFiles: codeFiles.filter((file) => !isNonProductPath(file)).length,
242
293
  totalEdges: totalImports,
243
294
  externalDeps: result.externalDeps.length,
295
+ externalRuntime: externalRuntime.length,
296
+ externalDev: externalDev.length,
244
297
  cycles: result.cycles.length,
245
298
  orphanFiles: orphans.length,
246
299
  hubs,
@@ -257,6 +310,48 @@ export async function analyzeDependencyGraph(files, repoRoot) {
257
310
  return result;
258
311
  }
259
312
 
313
+ /**
314
+ * Split external packages into runtime dependencies and development tooling.
315
+ * A package declared in `devDependencies` is development tooling. One declared in
316
+ * `dependencies`, `optionalDependencies`, or `peerDependencies` is runtime. An undeclared
317
+ * package is runtime when a product file (not a test, example, or build script) imports it.
318
+ * Without a root package.json (Python, Go) every package counts as runtime.
319
+ */
320
+ async function splitExternalDeps(result, repoRoot) {
321
+ const raw = await readFileSafe(path.join(repoRoot, "package.json"));
322
+ let pkg = null;
323
+ try { pkg = raw ? JSON.parse(raw) : null; } catch { pkg = null; }
324
+ if (!pkg) return { runtime: [...result.externalDeps], dev: [] };
325
+
326
+ const declaredRuntime = new Set([
327
+ ...Object.keys(pkg.dependencies || {}),
328
+ ...Object.keys(pkg.optionalDependencies || {}),
329
+ ...Object.keys(pkg.peerDependencies || {}),
330
+ ]);
331
+ const declaredDev = new Set(Object.keys(pkg.devDependencies || {}));
332
+
333
+ const productImports = new Set();
334
+ for (const node of result.nodes) {
335
+ if (isNonProductPath(node.file || node.key) || isBuildScript(node.file || node.key)) continue;
336
+ for (const name of node.externalImports || []) productImports.add(name);
337
+ }
338
+
339
+ const runtime = [];
340
+ const dev = [];
341
+ for (const name of result.externalDeps) {
342
+ if (declaredRuntime.has(name)) runtime.push(name);
343
+ else if (declaredDev.has(name)) dev.push(name);
344
+ else if (productImports.has(name)) runtime.push(name);
345
+ else dev.push(name);
346
+ }
347
+ return { runtime, dev };
348
+ }
349
+
350
+ function isBuildScript(file) {
351
+ const base = String(file || "").replace(/\\/g, "/").split("/").pop() || "";
352
+ return /^build([-_.]|$)/i.test(base) || /\.config\.[cm]?[jt]s$/i.test(base);
353
+ }
354
+
260
355
  /**
261
356
  * Detect all cycles in a directed graph using DFS with 3-color marking.
262
357
  * Returns an array of cycle paths (each path ends where it started).
@@ -17,7 +17,7 @@ const DEFAULT_DOMAIN_HINTS = [
17
17
  description: "Content publishing, management, and delivery"
18
18
  },
19
19
  {
20
- match: ["search", "filter", "query", "index", "catalog", "browse"],
20
+ match: ["search", "query", "index", "catalog", "browse"],
21
21
  domain: "Search & Discovery",
22
22
  description: "Search functionality, filtering, and content discovery"
23
23
  },
@@ -62,7 +62,7 @@ const DEFAULT_DOMAIN_HINTS = [
62
62
  description: "Database access, models, and data persistence"
63
63
  },
64
64
  {
65
- match: ["config", "setting", "env", "constant"],
65
+ match: ["config", "setting", "env"],
66
66
  domain: "Configuration",
67
67
  description: "Application configuration and environment settings"
68
68
  },
@@ -92,7 +92,7 @@ const DEFAULT_DOMAIN_HINTS = [
92
92
  description: "Content and artifact publishing, deployment, and distribution"
93
93
  },
94
94
  {
95
- match: ["analyz", "inspect", "detect", "lint", "scan", "parse"],
95
+ match: ["analyz", "inspect", "detect", "lint", "scan"],
96
96
  domain: "Analysis & Detection",
97
97
  description: "Code analysis, pattern detection, and static inspection"
98
98
  },
@@ -171,13 +171,54 @@ function domainEvidenceText(hint, modulePath, keyword, evidence) {
171
171
  return bits.join("; ");
172
172
  }
173
173
 
174
+ // Entry and barrel file names say nothing about what a module does. `lib/index.ts`
175
+ // is not "Search & Discovery" because it is called index.
176
+ const ENTRY_FILE_STEMS = new Set(["index", "main", "mod", "__init__", "__main__"]);
177
+
178
+ /** Drop a trailing entry/barrel file name (`lib/index.ts` → `lib`). Directories named `index` stay. */
179
+ export function stripEntryFileName(modulePath) {
180
+ const parts = String(modulePath).replace(/\\/g, "/").split("/");
181
+ const last = parts[parts.length - 1] || "";
182
+ const dot = last.lastIndexOf(".");
183
+ if (dot > 0 && ENTRY_FILE_STEMS.has(last.slice(0, dot).toLowerCase())) {
184
+ parts.pop();
185
+ return parts.join("/");
186
+ }
187
+ return parts.join("/");
188
+ }
189
+
190
+ // Folder names that only say "code lives here". `lib/ogg` is about ogg, not about being a lib.
191
+ export const GENERIC_CONTAINER_SEGMENTS = new Set(["src", "lib", "libs", "source", "sources", "packages", "pkg", "internal"]);
192
+
193
+ /**
194
+ * Drop generic container folders (`src`, `lib`, ...) from a path so they do not pick a
195
+ * domain on their own. Returns "" when nothing else is left (`lib`, `src/lib`).
196
+ */
197
+ export function stripContainerSegments(modulePath) {
198
+ return String(modulePath)
199
+ .replace(/\\/g, "/")
200
+ .split("/")
201
+ .filter((segment) => segment && !GENERIC_CONTAINER_SEGMENTS.has(segment.toLowerCase()))
202
+ .join("/");
203
+ }
204
+
205
+ /** True when the path has a generic container folder (`lib/ogg`, `src`). */
206
+ export function hasContainerSegment(modulePath) {
207
+ return String(modulePath).replace(/\\/g, "/").split("/").some((segment) => GENERIC_CONTAINER_SEGMENTS.has(segment.toLowerCase()));
208
+ }
209
+
174
210
  export function inferDomain(modulePath, customHints = [], evidence = {}) {
211
+ const customSet = new Set(customHints);
175
212
  const hints = [...customHints, ...DEFAULT_DOMAIN_HINTS];
213
+ const entryStripped = stripEntryFileName(modulePath);
214
+ const defaultPath = stripContainerSegments(entryStripped);
176
215
 
177
216
  for (const hint of hints) {
217
+ // User-defined hints see the full path. Built-in guesses ignore entry file names.
218
+ const searchPath = customSet.has(hint) ? modulePath : defaultPath;
178
219
  for (const keyword of hint.match) {
179
220
  if (REACT_HOOK_KEYWORDS.has(keyword) && !evidence.react) continue;
180
- if (pathHasKeyword(modulePath, keyword)) {
221
+ if (pathHasKeyword(searchPath, keyword)) {
181
222
  return {
182
223
  domain: hint.domain,
183
224
  description: domainEvidenceText(hint, modulePath, keyword, evidence),
@@ -188,6 +229,16 @@ export function inferDomain(modulePath, customHints = [], evidence = {}) {
188
229
  }
189
230
  }
190
231
 
232
+ // A source folder with no keyword of its own is core code, not "uncategorized".
233
+ if (hasContainerSegment(entryStripped)) {
234
+ return {
235
+ domain: "Core Source",
236
+ description: "The project's own source code, grouped by folder",
237
+ confidence: "none",
238
+ matchedKeyword: null
239
+ };
240
+ }
241
+
191
242
  return {
192
243
  domain: "Other",
193
244
  description: "Uncategorized module",
@@ -1,9 +1,33 @@
1
+ import { isToolingPath, isTestPath } from "../utils/path-roles.js";
2
+
1
3
  // Infer data flows through the application using import graph analysis + heuristics
2
4
 
3
5
  /**
4
6
  * Primary flow inference: uses dep graph when available for real import-chain flows,
5
7
  * falls back to keyword heuristics for repos without dep graph data.
6
8
  */
9
+ // File names too generic to name a flow ("gen Request Flow", "util Integration Flow").
10
+ const GENERIC_FLOW_NAMES = new Set([
11
+ "gen", "generate", "util", "utils", "helper", "helpers", "misc", "common", "types", "constants",
12
+ ]);
13
+
14
+ /**
15
+ * Drop flows that are noise: no steps, nothing beyond the starting file, a name that is only
16
+ * a generic helper word, or a chain that starts in tests, tooling, examples, or docs.
17
+ */
18
+ export function filterNoiseFlows(flows) {
19
+ return (flows || []).filter((flow) => {
20
+ if (!flow) return false;
21
+ const steps = Array.isArray(flow.steps) ? flow.steps : [];
22
+ if (steps.length < 2) return false;
23
+ const origin = flow.modules?.[0];
24
+ if (origin && (isToolingPath(origin) || isTestPath(origin))) return false;
25
+ const stem = String(flow.name || "").split(" ")[0].toLowerCase();
26
+ if (GENERIC_FLOW_NAMES.has(stem)) return false;
27
+ return true;
28
+ });
29
+ }
30
+
7
31
  export function inferDataFlows(scanResult, config, depGraph = null) {
8
32
  const flows = [];
9
33
 
@@ -28,7 +52,12 @@ export function inferDataFlows(scanResult, config, depGraph = null) {
28
52
  const apiFlow = inferApiIntegrationFlow(scanResult);
29
53
  if (apiFlow && !existingNames.has(apiFlow.name.toLowerCase())) flows.push(apiFlow);
30
54
 
31
- return flows;
55
+ return filterNoiseFlows(flows);
56
+ }
57
+
58
+ /** Same key the dependency graph gives a file: no extension, no trailing /index. */
59
+ function fileKey(file) {
60
+ return String(file).replace(/\\/g, "/").replace(/\.(js|ts|jsx|tsx|mjs|cjs)$/, "").replace(/\/index$/, "");
32
61
  }
33
62
 
34
63
  /**
@@ -39,12 +68,22 @@ function inferFlowsFromEntryPoints(scanResult, depGraph) {
39
68
  const flows = [];
40
69
  if (!depGraph?.nodes) return flows;
41
70
 
71
+ const isLibrary = scanResult?.metadata?.projectKind === "library";
72
+ const packageEntryKeys = new Set(
73
+ (scanResult?.metadata?.packageEntryFiles || []).map((file) => fileKey(file)),
74
+ );
75
+
42
76
  // Find entry points (files with high fan-out and low/zero fan-in)
43
77
  const entryPoints = depGraph.nodes.filter(n => {
44
78
  const key = n.key.toLowerCase();
79
+ // Build configs (`webpack.config`) wire up tooling. They are not how data moves.
80
+ if (/\.config$/.test(key)) return false;
81
+ // In a library, `bin/` holds build helpers. The package entry is what callers use.
82
+ if (isLibrary && /(^|\/)bin\//.test(key)) return false;
45
83
  const isEntry = (
46
- key.includes("bin/") ||
47
- key.includes("cli") ||
84
+ packageEntryKeys.has(n.key) ||
85
+ (!isLibrary && key.includes("bin/")) ||
86
+ (!isLibrary && key.includes("cli")) ||
48
87
  key.endsWith("/index") ||
49
88
  key.includes("pages/") ||
50
89
  key.includes("app/") ||
@@ -53,15 +92,15 @@ function inferFlowsFromEntryPoints(scanResult, depGraph) {
53
92
  return isEntry && n.imports.length >= 2;
54
93
  }).sort((a, b) => {
55
94
  // Prioritize src/ entry points over tests/
56
- const aIsTest = a.key.toLowerCase().includes("test");
57
- const bIsTest = b.key.toLowerCase().includes("test");
95
+ const aIsTest = isTestPath(a.key);
96
+ const bIsTest = isTestPath(b.key);
58
97
  if (aIsTest !== bIsTest) return aIsTest ? 1 : -1;
59
98
  return b.imports.length - a.imports.length;
60
99
  });
61
100
 
62
101
  // Limit: all src entries + max 2 test entries
63
- const srcEntries = entryPoints.filter(n => !n.key.toLowerCase().includes("test"));
64
- const testEntries = entryPoints.filter(n => n.key.toLowerCase().includes("test")).slice(0, 2);
102
+ const srcEntries = entryPoints.filter(n => !isTestPath(n.key));
103
+ const testEntries = entryPoints.filter(n => isTestPath(n.key)).slice(0, 2);
65
104
  const selectedEntries = [...srcEntries, ...testEntries].slice(0, 5);
66
105
 
67
106
  for (const entry of selectedEntries) {
@@ -69,8 +108,8 @@ function inferFlowsFromEntryPoints(scanResult, depGraph) {
69
108
  if (chain.length < 2) continue;
70
109
 
71
110
  const shortName = entry.key.split("/").pop();
72
- const isCliEntry = entry.key.toLowerCase().includes("cli") || entry.key.toLowerCase().includes("bin/");
73
- const flowType = isCliEntry ? "Command" : "Request";
111
+ const isCliEntry = !isLibrary && (entry.key.toLowerCase().includes("cli") || entry.key.toLowerCase().includes("bin/"));
112
+ const flowType = isCliEntry ? "Command" : isLibrary && packageEntryKeys.has(entry.key) ? "Library" : "Request";
74
113
 
75
114
  // Classify each step in the chain
76
115
  const steps = chain.map((nodeKey, i) => {