@chappibunny/repolens 1.15.4 → 1.16.0

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,100 @@ 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 the flat module
34
+ * (the directory name, or the legacy key `root`).
35
+ */
36
+ function flatModuleKey(modules) {
37
+ const marked = modules.find((mod) => mod.flat);
38
+ if (marked) return marked.key;
39
+ return modules.some((mod) => mod.key === "root") ? "root" : null;
40
+ }
41
+
42
+ function buildModuleLookup(modules) {
43
+ const keys = modules.map((m) => m.key).sort((x, y) => y.length - x.length);
44
+ const flatKey = flatModuleKey(modules);
45
+ const cache = new Map();
46
+ return (file) => {
47
+ const norm = String(file || "").replace(/\\/g, "/");
48
+ if (cache.has(norm)) return cache.get(norm);
49
+ let found = null;
50
+ for (const key of keys) {
51
+ if (key === flatKey) continue;
52
+ if (norm === key || norm.startsWith(`${key}/`)) { found = key; break; }
53
+ }
54
+ if (!found && !norm.includes("/") && flatKey) found = flatKey;
55
+ cache.set(norm, found);
56
+ return found;
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Build a module-level dep metrics map from file-level edges.
32
62
  * moduleKey → { fanIn, fanOut, isHub, isOrphan, isLeaf, isOrchestrator }
63
+ * fanIn and fanOut count distinct other modules, not file-to-file edges, so a folder
64
+ * of 77 test files that imports one helper 77 times still has a fan-out of 1.
33
65
  */
34
66
  export function computeModuleLevelMetrics(depGraph, modules) {
35
- const fileMetrics = computeModuleDepMetrics(depGraph);
36
- if (fileMetrics.size === 0 || !modules) return new Map();
67
+ if (!depGraph?.nodes || depGraph.nodes.length === 0 || !modules) return new Map();
37
68
 
38
- const moduleLevelMap = new Map();
69
+ const moduleOf = buildModuleLookup(modules);
70
+ const fileToModule = new Map();
71
+ for (const node of depGraph.nodes) {
72
+ fileToModule.set(node.key, moduleOf(node.file || node.key));
73
+ }
39
74
 
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
- }
75
+ const importedFrom = new Map(); // module -> Set of modules it imports
76
+ const importersOf = new Map(); // module -> Set of modules importing it
77
+ const fileCounts = new Map();
78
+ for (const node of depGraph.nodes) {
79
+ const from = fileToModule.get(node.key);
80
+ if (!from) continue;
81
+ fileCounts.set(from, (fileCounts.get(from) || 0) + 1);
82
+ for (const target of node.imports) {
83
+ const to = fileToModule.get(target);
84
+ if (!to || to === from) continue;
85
+ if (!importedFrom.has(from)) importedFrom.set(from, new Set());
86
+ if (!importersOf.has(to)) importersOf.set(to, new Set());
87
+ importedFrom.get(from).add(to);
88
+ importersOf.get(to).add(from);
63
89
  }
90
+ }
64
91
 
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
- }
92
+ const moduleLevelMap = new Map();
93
+ const hubThreshold = Math.max(3, Math.floor(modules.length * 0.15));
94
+ for (const mod of modules) {
95
+ if (!fileCounts.has(mod.key)) continue;
96
+ const fanIn = importersOf.get(mod.key)?.size || 0;
97
+ const fanOut = importedFrom.get(mod.key)?.size || 0;
98
+ moduleLevelMap.set(mod.key, {
99
+ fanIn,
100
+ fanOut,
101
+ isHub: fanIn >= hubThreshold,
102
+ isOrphan: fanIn === 0 && fanOut === 0,
103
+ isLeaf: fanOut === 0 && fanIn > 0,
104
+ isOrchestrator: fanOut >= 5 && fanIn < fanOut,
105
+ });
76
106
  }
77
107
 
78
108
  return moduleLevelMap;
79
109
  }
80
110
 
111
+ /**
112
+ * Is this project a command-line tool? Package metadata decides when it is known
113
+ * (a library with a build helper in `bin/` is a library). Otherwise a CLI pattern does.
114
+ */
115
+ export function isCliProject(context) {
116
+ const kind = context?.project?.kind;
117
+ if (kind === "cli") return true;
118
+ if (kind === "library") return false;
119
+ return (context?.patterns || []).some((p) => String(p).toLowerCase().startsWith("cli tool"));
120
+ }
121
+
122
+ export function isLibraryProject(context) {
123
+ return context?.project?.kind === "library";
124
+ }
125
+
81
126
  export function buildAIContext(scanResult, config, depGraph = null) {
82
127
  const { filesCount, modules, api, pages, metadata } = scanResult;
83
128
 
@@ -129,12 +174,13 @@ export function buildAIContext(scanResult, config, depGraph = null) {
129
174
  };
130
175
 
131
176
  // Identify key architectural patterns (verified against dep graph when available)
132
- const patterns = inferArchitecturalPatterns(modules, depGraph);
177
+ const patterns = inferArchitecturalPatterns(modules, depGraph, metadata?.projectKind);
133
178
 
134
179
  // Build compact context object
135
180
  return {
136
181
  project: {
137
182
  name: config.project.name,
183
+ kind: metadata?.projectKind || "unknown",
138
184
  filesScanned: filesCount,
139
185
  modulesDetected: modules.length,
140
186
  pagesDetected: pages?.length || 0,
@@ -177,7 +223,15 @@ export function buildAIContext(scanResult, config, depGraph = null) {
177
223
  }
178
224
 
179
225
  function inferModuleType(modulePath) {
180
- const has = (keyword) => pathHasKeyword(modulePath, keyword);
226
+ // `src` and `lib` only say where code lives. `lib/ogg` is typed by `ogg`, and a bare
227
+ // `lib` is the project's source, not a "shared library" layer.
228
+ const typedPath = stripContainerSegments(modulePath);
229
+ const fromContainer = hasContainerSegment(modulePath);
230
+ const has = (keyword) => pathHasKeyword(typedPath, keyword);
231
+ // Page, route, and view describe a folder of pages. A build script called
232
+ // `build-pages.js` is not one.
233
+ const isFile = /\.[a-z0-9]+$/i.test(String(modulePath).split("/").pop() || "");
234
+ const hasDirectory = (keyword) => pathHasKeyword(directoryPath(typedPath), keyword);
181
235
 
182
236
  if (has("test") || has("spec")) return "test";
183
237
  if (has("api") || has("endpoint")) return "api";
@@ -185,7 +239,7 @@ function inferModuleType(modulePath) {
185
239
  if (has("lib") || has("util") || has("helper") || has("common") || has("shared")) return "library";
186
240
  if (has("hook")) return "hooks";
187
241
  if (has("store") || has("state") || has("redux") || has("zustand")) return "state";
188
- if (has("page") || has("route") || has("view")) return "route";
242
+ if (isFile ? (hasDirectory("page") || hasDirectory("route") || hasDirectory("view")) : (has("page") || has("route") || has("view"))) return "route";
189
243
  if (has("config") || has("setting") || has("env")) return "config";
190
244
  if (has("core") || has("kernel") || has("foundation")) return "core";
191
245
  if (has("render") || has("template") || has("format")) return "renderer";
@@ -203,19 +257,10 @@ function inferModuleType(modulePath) {
203
257
  if (has("service")) return "service";
204
258
  if (has("app")) return "app";
205
259
 
206
- return "other";
260
+ return fromContainer ? "source" : "other";
207
261
  }
208
262
 
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
- }
263
+ const isTestEvidence = isTestPath;
219
264
 
220
265
  const API_DIRECTORY_SEGMENTS = new Set([
221
266
  "api", "apis", "route", "routes", "router", "routers",
@@ -235,14 +280,15 @@ function directoryPath(modulePath) {
235
280
  return parts.join("/");
236
281
  }
237
282
 
238
- function inferArchitecturalPatterns(modules, depGraph = null) {
283
+ function inferArchitecturalPatterns(modules, depGraph = null, projectKind = null) {
239
284
  const patterns = [];
240
285
  const production = modules.filter((module) => !isTestEvidence(module.key));
241
286
  const tests = modules.filter((module) => isTestEvidence(module.key));
242
287
  const keys = production.map(m => m.key.toLowerCase());
243
288
 
289
+ // `lib` and `src` only say where code lives, so they never count as evidence for a pattern.
244
290
  const has = (keyword) => keys.some((key) => (
245
- keyword.includes("/") ? key.includes(keyword) : pathHasKeyword(key, keyword)
291
+ keyword.includes("/") ? key.includes(keyword) : pathHasKeyword(stripContainerSegments(key), keyword)
246
292
  ));
247
293
  const matchingKeys = (keywords, source = production) => {
248
294
  const found = [];
@@ -251,7 +297,7 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
251
297
  const key = module.key;
252
298
  const matched = keyword.includes("/")
253
299
  ? key.toLowerCase().includes(keyword)
254
- : pathHasKeyword(key, keyword);
300
+ : pathHasKeyword(stripContainerSegments(key), keyword);
255
301
  if (matched && !found.includes(key)) found.push(key);
256
302
  }
257
303
  }
@@ -342,7 +388,8 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
342
388
  }
343
389
 
344
390
  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"]);
391
+ // A library's `bin/` folder is build tooling. Only a CLI package gets this pattern.
392
+ if (projectKind !== "library" && (has("cli") || has("command") || has("bin"))) cite("CLI tool architecture", ["cli", "command", "bin"]);
346
393
 
347
394
  if (has("util") || has("helper") || has("lib")) {
348
395
  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
+ * and development tooling when only tests or examples import it. Python and Go have no
319
+ * package.json, so that import split is the whole decision.
320
+ */
321
+ async function splitExternalDeps(result, repoRoot) {
322
+ const raw = await readFileSafe(path.join(repoRoot, "package.json"));
323
+ let pkg = null;
324
+ try { pkg = raw ? JSON.parse(raw) : null; } catch { pkg = null; }
325
+
326
+ const declaredRuntime = new Set(pkg ? [
327
+ ...Object.keys(pkg.dependencies || {}),
328
+ ...Object.keys(pkg.optionalDependencies || {}),
329
+ ...Object.keys(pkg.peerDependencies || {}),
330
+ ] : []);
331
+ const declaredDev = new Set(pkg ? 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]) {
package/src/cli-args.js CHANGED
@@ -8,6 +8,9 @@ const FLAGS_WITH_VALUE = new Set([
8
8
  "--url",
9
9
  "--link",
10
10
  "--label",
11
+ "--port",
12
+ "--host",
13
+ "--webhook-path",
11
14
  ]);
12
15
  const BOOLEAN_FLAGS = new Set([
13
16
  "--quick",
@@ -16,6 +19,7 @@ const BOOLEAN_FLAGS = new Set([
16
19
  "--force",
17
20
  "--strict",
18
21
  "--verbose",
22
+ "--manifest",
19
23
  "--help",
20
24
  "-h",
21
25
  "--version",
package/src/cli.js CHANGED
@@ -311,6 +311,17 @@ async function main() {
311
311
  process.exit(code);
312
312
  }
313
313
 
314
+ if (command === "app") {
315
+ const { runGitHubApp } = await import("./github-app/run.js");
316
+ const code = await runGitHubApp({
317
+ manifest: Boolean(parsed.flags["--manifest"]),
318
+ port: parsed.flags["--port"],
319
+ host: parsed.flags["--host"],
320
+ webhookPath: parsed.flags["--webhook-path"],
321
+ });
322
+ process.exit(code);
323
+ }
324
+
314
325
  if (command === "mcp") {
315
326
  process.env.REPOLENS_LOG_STDERR = "1";
316
327
  const { startMcpServer } = await import("./mcp/server.js");