@chappibunny/repolens 1.15.4 → 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,
@@ -177,7 +215,15 @@ export function buildAIContext(scanResult, config, depGraph = null) {
177
215
  }
178
216
 
179
217
  function inferModuleType(modulePath) {
180
- 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);
181
227
 
182
228
  if (has("test") || has("spec")) return "test";
183
229
  if (has("api") || has("endpoint")) return "api";
@@ -185,7 +231,7 @@ function inferModuleType(modulePath) {
185
231
  if (has("lib") || has("util") || has("helper") || has("common") || has("shared")) return "library";
186
232
  if (has("hook")) return "hooks";
187
233
  if (has("store") || has("state") || has("redux") || has("zustand")) return "state";
188
- 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";
189
235
  if (has("config") || has("setting") || has("env")) return "config";
190
236
  if (has("core") || has("kernel") || has("foundation")) return "core";
191
237
  if (has("render") || has("template") || has("format")) return "renderer";
@@ -203,19 +249,10 @@ function inferModuleType(modulePath) {
203
249
  if (has("service")) return "service";
204
250
  if (has("app")) return "app";
205
251
 
206
- return "other";
252
+ return fromContainer ? "source" : "other";
207
253
  }
208
254
 
209
- function isTestEvidence(modulePath) {
210
- const norm = String(modulePath || "").replace(/\\/g, "/");
211
- if (/(^|\/)__(tests|mocks)__(\/|$)/.test(norm)) return true;
212
- if (/(^|\/)(test|tests|spec|specs|fixtures|testdata)(\/|$)/.test(norm)) return true;
213
- const base = norm.split("/").pop() || "";
214
- if (/^(test_|spec_)/.test(base)) return true;
215
- if (/(_test|_spec)\.[a-z0-9]+$/i.test(base)) return true;
216
- if (/\.(test|spec)\.[a-z0-9]+$/i.test(base)) return true;
217
- return false;
218
- }
255
+ const isTestEvidence = isTestPath;
219
256
 
220
257
  const API_DIRECTORY_SEGMENTS = new Set([
221
258
  "api", "apis", "route", "routes", "router", "routers",
@@ -235,14 +272,15 @@ function directoryPath(modulePath) {
235
272
  return parts.join("/");
236
273
  }
237
274
 
238
- function inferArchitecturalPatterns(modules, depGraph = null) {
275
+ function inferArchitecturalPatterns(modules, depGraph = null, projectKind = null) {
239
276
  const patterns = [];
240
277
  const production = modules.filter((module) => !isTestEvidence(module.key));
241
278
  const tests = modules.filter((module) => isTestEvidence(module.key));
242
279
  const keys = production.map(m => m.key.toLowerCase());
243
280
 
281
+ // `lib` and `src` only say where code lives, so they never count as evidence for a pattern.
244
282
  const has = (keyword) => keys.some((key) => (
245
- keyword.includes("/") ? key.includes(keyword) : pathHasKeyword(key, keyword)
283
+ keyword.includes("/") ? key.includes(keyword) : pathHasKeyword(stripContainerSegments(key), keyword)
246
284
  ));
247
285
  const matchingKeys = (keywords, source = production) => {
248
286
  const found = [];
@@ -251,7 +289,7 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
251
289
  const key = module.key;
252
290
  const matched = keyword.includes("/")
253
291
  ? key.toLowerCase().includes(keyword)
254
- : pathHasKeyword(key, keyword);
292
+ : pathHasKeyword(stripContainerSegments(key), keyword);
255
293
  if (matched && !found.includes(key)) found.push(key);
256
294
  }
257
295
  }
@@ -342,7 +380,8 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
342
380
  }
343
381
 
344
382
  if (has("analyz") || has("detect") || has("inspect")) cite("Analysis pipeline", ["analyz", "detect", "inspect"]);
345
- 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"]);
346
385
 
347
386
  if (has("util") || has("helper") || has("lib")) {
348
387
  const verified = moduleMetrics.size > 0 && verifyHub("util") || verifyHub("helper") || verifyHub("lib");
@@ -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
  }
@@ -271,13 +278,22 @@ export async function analyzeDependencyGraph(files, repoRoot) {
271
278
  // Stats
272
279
  const totalImports = result.edges.length;
273
280
  const orphans = result.nodes.filter(n => n.imports.length === 0 && n.importedBy.length === 0);
274
- 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));
275
283
  const hubs = aggregateHubs(result.nodes, fileKeyToFile, goModulePath, hubThreshold);
276
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
+
277
290
  result.stats = {
278
291
  totalFiles: codeFiles.length,
292
+ sourceFiles: codeFiles.filter((file) => !isNonProductPath(file)).length,
279
293
  totalEdges: totalImports,
280
294
  externalDeps: result.externalDeps.length,
295
+ externalRuntime: externalRuntime.length,
296
+ externalDev: externalDev.length,
281
297
  cycles: result.cycles.length,
282
298
  orphanFiles: orphans.length,
283
299
  hubs,
@@ -294,6 +310,48 @@ export async function analyzeDependencyGraph(files, repoRoot) {
294
310
  return result;
295
311
  }
296
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
+
297
355
  /**
298
356
  * Detect all cycles in a directed graph using DFS with 3-color marking.
299
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
  },
@@ -187,10 +187,31 @@ export function stripEntryFileName(modulePath) {
187
187
  return parts.join("/");
188
188
  }
189
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
+
190
210
  export function inferDomain(modulePath, customHints = [], evidence = {}) {
191
211
  const customSet = new Set(customHints);
192
212
  const hints = [...customHints, ...DEFAULT_DOMAIN_HINTS];
193
- const defaultPath = stripEntryFileName(modulePath);
213
+ const entryStripped = stripEntryFileName(modulePath);
214
+ const defaultPath = stripContainerSegments(entryStripped);
194
215
 
195
216
  for (const hint of hints) {
196
217
  // User-defined hints see the full path. Built-in guesses ignore entry file names.
@@ -208,6 +229,16 @@ export function inferDomain(modulePath, customHints = [], evidence = {}) {
208
229
  }
209
230
  }
210
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
+
211
242
  return {
212
243
  domain: "Other",
213
244
  description: "Uncategorized module",
@@ -1,30 +1,19 @@
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
  */
7
- // Directories holding tooling, samples, and docs. An import chain from a file in
8
- // here describes a build script or an example, not how data moves through the product.
9
- const TOOLING_DIRECTORIES = new Set([
10
- "doc", "docs", "doc-gen", "docgen", "scripts", "script", "tools", "tooling",
11
- "example", "examples", "demo", "demos", "sample", "samples",
12
- "benchmark", "benchmarks", "bench", "fixtures", "tasks",
13
- ]);
14
-
15
9
  // File names too generic to name a flow ("gen Request Flow", "util Integration Flow").
16
10
  const GENERIC_FLOW_NAMES = new Set([
17
11
  "gen", "generate", "util", "utils", "helper", "helpers", "misc", "common", "types", "constants",
18
12
  ]);
19
13
 
20
- function isToolingPath(key) {
21
- const parts = String(key || "").replace(/\\/g, "/").toLowerCase().split("/");
22
- return parts.slice(0, -1).some((segment) => TOOLING_DIRECTORIES.has(segment));
23
- }
24
-
25
14
  /**
26
15
  * Drop flows that are noise: no steps, nothing beyond the starting file, a name that is only
27
- * a generic helper word, or a chain that starts in tooling, examples, or docs.
16
+ * a generic helper word, or a chain that starts in tests, tooling, examples, or docs.
28
17
  */
29
18
  export function filterNoiseFlows(flows) {
30
19
  return (flows || []).filter((flow) => {
@@ -32,7 +21,7 @@ export function filterNoiseFlows(flows) {
32
21
  const steps = Array.isArray(flow.steps) ? flow.steps : [];
33
22
  if (steps.length < 2) return false;
34
23
  const origin = flow.modules?.[0];
35
- if (origin && isToolingPath(origin)) return false;
24
+ if (origin && (isToolingPath(origin) || isTestPath(origin))) return false;
36
25
  const stem = String(flow.name || "").split(" ")[0].toLowerCase();
37
26
  if (GENERIC_FLOW_NAMES.has(stem)) return false;
38
27
  return true;
@@ -66,6 +55,11 @@ export function inferDataFlows(scanResult, config, depGraph = null) {
66
55
  return filterNoiseFlows(flows);
67
56
  }
68
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$/, "");
61
+ }
62
+
69
63
  /**
70
64
  * Trace real import chains from entry points through the dep graph.
71
65
  * Entry points: CLI/bin files, page components, API route handlers, index files.
@@ -74,12 +68,22 @@ function inferFlowsFromEntryPoints(scanResult, depGraph) {
74
68
  const flows = [];
75
69
  if (!depGraph?.nodes) return flows;
76
70
 
71
+ const isLibrary = scanResult?.metadata?.projectKind === "library";
72
+ const packageEntryKeys = new Set(
73
+ (scanResult?.metadata?.packageEntryFiles || []).map((file) => fileKey(file)),
74
+ );
75
+
77
76
  // Find entry points (files with high fan-out and low/zero fan-in)
78
77
  const entryPoints = depGraph.nodes.filter(n => {
79
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;
80
83
  const isEntry = (
81
- key.includes("bin/") ||
82
- key.includes("cli") ||
84
+ packageEntryKeys.has(n.key) ||
85
+ (!isLibrary && key.includes("bin/")) ||
86
+ (!isLibrary && key.includes("cli")) ||
83
87
  key.endsWith("/index") ||
84
88
  key.includes("pages/") ||
85
89
  key.includes("app/") ||
@@ -88,15 +92,15 @@ function inferFlowsFromEntryPoints(scanResult, depGraph) {
88
92
  return isEntry && n.imports.length >= 2;
89
93
  }).sort((a, b) => {
90
94
  // Prioritize src/ entry points over tests/
91
- const aIsTest = a.key.toLowerCase().includes("test");
92
- const bIsTest = b.key.toLowerCase().includes("test");
95
+ const aIsTest = isTestPath(a.key);
96
+ const bIsTest = isTestPath(b.key);
93
97
  if (aIsTest !== bIsTest) return aIsTest ? 1 : -1;
94
98
  return b.imports.length - a.imports.length;
95
99
  });
96
100
 
97
101
  // Limit: all src entries + max 2 test entries
98
- const srcEntries = entryPoints.filter(n => !n.key.toLowerCase().includes("test"));
99
- 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);
100
104
  const selectedEntries = [...srcEntries, ...testEntries].slice(0, 5);
101
105
 
102
106
  for (const entry of selectedEntries) {
@@ -104,8 +108,8 @@ function inferFlowsFromEntryPoints(scanResult, depGraph) {
104
108
  if (chain.length < 2) continue;
105
109
 
106
110
  const shortName = entry.key.split("/").pop();
107
- const isCliEntry = entry.key.toLowerCase().includes("cli") || entry.key.toLowerCase().includes("bin/");
108
- 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";
109
113
 
110
114
  // Classify each step in the chain
111
115
  const steps = chain.map((nodeKey, i) => {
@@ -5,6 +5,7 @@
5
5
  import fs from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { info, warn } from "../utils/logger.js";
8
+ import { isBuildOrExamplePath } from "../utils/path-roles.js";
8
9
 
9
10
  const JS_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"];
10
11
 
@@ -205,6 +206,27 @@ const SKIP_PATTERNS = [
205
206
  /\.repolens\//,
206
207
  ];
207
208
 
209
+ const HTML_ASSIGNMENT_IDS = new Set(["innerhtml-assignment", "outerhtml-assignment"]);
210
+
211
+ /**
212
+ * Is the right-hand side of an innerHTML/outerHTML assignment a fixed string?
213
+ * `el.innerHTML = ''` clears an element and `el.innerHTML = '<b>ok</b>'` writes a constant.
214
+ * Neither can carry injected markup, so neither is an XSS finding. A template literal
215
+ * with `${...}` or any concatenation still is.
216
+ */
217
+ export function isStaticHtmlAssignment(restOfSource) {
218
+ const rest = String(restOfSource || "");
219
+ const quoted = rest.match(/^\s*(['"])((?:(?!\1)[^\\\n]|\\.)*)\1\s*(?=[;,)}\n]|$)/);
220
+ if (quoted) return true;
221
+ const template = rest.match(/^\s*`([^`]*)`\s*(?=[;,)}\n]|$)/);
222
+ if (template && !template[1].includes("${")) return true;
223
+ return false;
224
+ }
225
+
226
+ // Build scripts and example pages run on a maintainer's machine or in a demo page. They are
227
+ // listed, but never as high severity.
228
+ const SEVERITY_CAPPED_IDS_EXEMPT = new Set(["hardcoded-password"]);
229
+
208
230
  function shouldSkipFile(filePath) {
209
231
  return SKIP_PATTERNS.some(pattern => pattern.test(filePath));
210
232
  }
@@ -255,7 +277,11 @@ export async function analyzeSecurityPatterns(files, repoRoot) {
255
277
 
256
278
  while ((match = regex.exec(stripped)) !== null) {
257
279
  // Get line number
280
+ if (HTML_ASSIGNMENT_IDS.has(pattern.id) && isStaticHtmlAssignment(stripped.slice(match.index + match[0].length))) continue;
281
+
258
282
  const lineNumber = stripped.slice(0, match.index).split("\n").length;
283
+ const capped = isBuildOrExamplePath(file) && !SEVERITY_CAPPED_IDS_EXEMPT.has(pattern.id);
284
+ const severity = capped ? "low" : pattern.severity;
259
285
 
260
286
  // Get the surrounding code snippet
261
287
  const lines = stripped.split("\n");
@@ -267,8 +293,8 @@ export async function analyzeSecurityPatterns(files, repoRoot) {
267
293
  result.findings.push({
268
294
  id: pattern.id,
269
295
  category: pattern.category,
270
- name: pattern.name,
271
- severity: pattern.severity,
296
+ name: capped ? `${pattern.name} (build/example code)` : pattern.name,
297
+ severity,
272
298
  file,
273
299
  line: lineNumber,
274
300
  snippet: snippet.length > 200 ? snippet.slice(0, 200) + "..." : snippet,
@@ -276,7 +302,7 @@ export async function analyzeSecurityPatterns(files, repoRoot) {
276
302
  cwe: pattern.cwe,
277
303
  });
278
304
 
279
- result.bySeverity[pattern.severity]++;
305
+ result.bySeverity[severity]++;
280
306
  result.filesWithFindings.add(file);
281
307
 
282
308
  if (!result.byCategory[pattern.category]) {