@chappibunny/repolens 1.15.5 → 1.16.1

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,8 @@
1
1
  // Dependency graph analysis with cycle detection.
2
2
  // JavaScript, TypeScript, Python, and Go imports become edges.
3
- // Comments and strings are ignored. Type-only TypeScript imports are not edges.
3
+ // Comments and strings are ignored. Type-only TypeScript imports are not runtime edges.
4
+ // Cycle priority is in cycle-priority.js: mutual recursion and type-only cycles are
5
+ // not "Priority: High refactor". Large multi-module cycles stay high.
4
6
 
5
7
  import fs from "node:fs/promises";
6
8
  import path from "node:path";
@@ -17,6 +19,11 @@ import {
17
19
  parseGoModule,
18
20
  } from "./import-parsers.js";
19
21
  import { isNonProductPath } from "../utils/path-roles.js";
22
+ import {
23
+ classifyRuntimeCycles,
24
+ priorityCounts,
25
+ typeOnlyReport,
26
+ } from "./cycle-priority.js";
20
27
 
21
28
  const JS_EXTENSIONS = new Set([".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"]);
22
29
  const PY_EXTENSIONS = new Set([".py", ".pyw"]);
@@ -70,6 +77,8 @@ function emptyResult() {
70
77
  nodes: [],
71
78
  edges: [],
72
79
  cycles: [],
80
+ cycleReports: [],
81
+ typeOnlyReports: [],
73
82
  externalDeps: [],
74
83
  externalRuntime: [],
75
84
  externalDev: [],
@@ -78,6 +87,23 @@ function emptyResult() {
78
87
  };
79
88
  }
80
89
 
90
+ /** How many times imported bindings are called. `foo(`, `foo.bar(`, `ns?.method(`. */
91
+ export function countBindingCalls(source, names) {
92
+ if (!names || names.length === 0) return 0;
93
+ const stripped = String(source)
94
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
95
+ .replace(/(^|[^:\\])\/\/.*$/gm, "$1");
96
+ let total = 0;
97
+ for (const name of names) {
98
+ if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue;
99
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
100
+ const direct = stripped.match(new RegExp(`\\b${escaped}\\s*(?:\\?\\.\\s*)?\\(`, "g"));
101
+ const member = stripped.match(new RegExp(`\\b${escaped}\\s*(?:\\?\\.|\\.)\\s*[A-Za-z_$][\\w$]*\\s*\\(`, "g"));
102
+ total += (direct ? direct.length : 0) + (member ? member.length : 0);
103
+ }
104
+ return total;
105
+ }
106
+
81
107
  async function loadGoModule(repoRoot) {
82
108
  const text = await readFileSafe(path.join(repoRoot, "go.mod"));
83
109
  return parseGoModule(text);
@@ -200,21 +226,66 @@ export async function analyzeDependencyGraph(files, repoRoot) {
200
226
  const pyIndex = indexPythonModules(pyFiles);
201
227
  const goModulePath = goFiles.length > 0 ? await loadGoModule(repoRoot) : null;
202
228
 
229
+ // Runtime edges carry call counts. Type-only edges stay off the runtime graph
230
+ // and are classified on their own so they are never "Priority: High refactor".
231
+ const edgeRecords = new Map();
232
+
233
+ function rememberEdge(fromKey, toKey, fromFile, typeOnly, calls) {
234
+ if (!toKey || toKey === fromKey || !allFileKeys.has(toKey)) return;
235
+ const id = `${fromKey}->${toKey}`;
236
+ let record = edgeRecords.get(id);
237
+ if (!record) {
238
+ record = {
239
+ from: fromKey,
240
+ to: toKey,
241
+ fromFile,
242
+ toFile: fileKeyToFile.get(toKey) || toKey,
243
+ typeOnly: true,
244
+ calls: 0,
245
+ };
246
+ edgeRecords.set(id, record);
247
+ }
248
+ if (!typeOnly) {
249
+ record.typeOnly = false;
250
+ record.calls += calls;
251
+ }
252
+ }
253
+
203
254
  for (const file of jsFiles) {
204
255
  const content = await readFileSafe(path.join(repoRoot, file));
205
256
  if (!content) continue;
206
257
  const fromKey = normalizeFileKey(file);
207
258
  const selfName = await ownPackageName(file);
259
+ const grouped = new Map();
208
260
  for (const imported of extractJsImports(content)) {
209
- if (imported.typeOnly) continue;
210
- if (isRelativeImport(imported.specifier)) {
211
- addEdge(fromKey, resolveImportPath(file, imported.specifier));
212
- } else {
261
+ if (!isRelativeImport(imported.specifier)) {
262
+ if (imported.typeOnly) continue;
213
263
  const name = externalPackageName(imported.specifier);
214
264
  if (name && name === selfName) continue;
215
265
  addExternal(fromKey, name);
266
+ continue;
267
+ }
268
+ const toKey = resolveImportPath(file, imported.specifier);
269
+ if (!toKey) continue;
270
+ const id = `${fromKey}->${toKey}`;
271
+ let slot = grouped.get(id);
272
+ if (!slot) {
273
+ slot = { toKey, typeOnly: true, names: new Set() };
274
+ grouped.set(id, slot);
275
+ }
276
+ if (!imported.typeOnly) {
277
+ slot.typeOnly = false;
278
+ for (const binding of imported.names || []) slot.names.add(binding);
216
279
  }
217
280
  }
281
+ for (const slot of grouped.values()) {
282
+ const calls = slot.typeOnly ? 0 : countBindingCalls(content, [...slot.names]);
283
+ rememberEdge(fromKey, slot.toKey, file, slot.typeOnly, calls);
284
+ }
285
+ }
286
+
287
+ for (const record of edgeRecords.values()) {
288
+ if (!record.typeOnly) addEdge(record.from, record.to);
218
289
  }
219
290
 
220
291
  for (const file of pyFiles) {
@@ -275,6 +346,21 @@ export async function analyzeDependencyGraph(files, repoRoot) {
275
346
  // Detect cycles using iterative DFS (Tarjan-lite / coloring)
276
347
  result.cycles = detectCycles(adjacency);
277
348
 
349
+ const fileForKey = (key) => fileKeyToFile.get(key) || key;
350
+ const runtimeEdgeList = [...edgeRecords.values()].filter((edge) => !edge.typeOnly);
351
+ result.cycleReports = classifyRuntimeCycles(result.cycles, runtimeEdgeList, fileForKey);
352
+
353
+ const typeOnlyAdj = new Map();
354
+ for (const edge of edgeRecords.values()) {
355
+ if (!edge.typeOnly) continue;
356
+ if (!typeOnlyAdj.has(edge.from)) typeOnlyAdj.set(edge.from, new Set());
357
+ if (!typeOnlyAdj.has(edge.to)) typeOnlyAdj.set(edge.to, new Set());
358
+ typeOnlyAdj.get(edge.from).add(edge.to);
359
+ }
360
+ result.typeOnlyReports = detectCycles(typeOnlyAdj).map((path) => typeOnlyReport(path));
361
+
362
+ const priorities = priorityCounts(result.cycleReports);
363
+
278
364
  // Stats
279
365
  const totalImports = result.edges.length;
280
366
  const orphans = result.nodes.filter(n => n.imports.length === 0 && n.importedBy.length === 0);
@@ -295,6 +381,9 @@ export async function analyzeDependencyGraph(files, repoRoot) {
295
381
  externalRuntime: externalRuntime.length,
296
382
  externalDev: externalDev.length,
297
383
  cycles: result.cycles.length,
384
+ highCycles: priorities.high,
385
+ mediumCycles: priorities.medium,
386
+ lowCycles: priorities.low,
298
387
  orphanFiles: orphans.length,
299
388
  hubs,
300
389
  };
@@ -314,21 +403,21 @@ export async function analyzeDependencyGraph(files, repoRoot) {
314
403
  * Split external packages into runtime dependencies and development tooling.
315
404
  * A package declared in `devDependencies` is development tooling. One declared in
316
405
  * `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.
406
+ * package is runtime when a product file (not a test, example, or build script) imports it,
407
+ * and development tooling when only tests or examples import it. Python and Go have no
408
+ * package.json, so that import split is the whole decision.
319
409
  */
320
410
  async function splitExternalDeps(result, repoRoot) {
321
411
  const raw = await readFileSafe(path.join(repoRoot, "package.json"));
322
412
  let pkg = null;
323
413
  try { pkg = raw ? JSON.parse(raw) : null; } catch { pkg = null; }
324
- if (!pkg) return { runtime: [...result.externalDeps], dev: [] };
325
414
 
326
- const declaredRuntime = new Set([
415
+ const declaredRuntime = new Set(pkg ? [
327
416
  ...Object.keys(pkg.dependencies || {}),
328
417
  ...Object.keys(pkg.optionalDependencies || {}),
329
418
  ...Object.keys(pkg.peerDependencies || {}),
330
- ]);
331
- const declaredDev = new Set(Object.keys(pkg.devDependencies || {}));
419
+ ] : []);
420
+ const declaredDev = new Set(pkg ? Object.keys(pkg.devDependencies || {}) : []);
332
421
 
333
422
  const productImports = new Set();
334
423
  for (const node of result.nodes) {
@@ -147,6 +147,33 @@ function braceIsTypeOnly(inner) {
147
147
  return parts.every((part) => /^type\s+/.test(part) && !/^type\s+as\b/.test(part));
148
148
  }
149
149
 
150
+ /** Local bindings in an import brace. `type Foo` is skipped. `foo as bar` yields `bar`. */
151
+ function localNames(inner) {
152
+ const names = [];
153
+ for (const part of splitTopCommas(inner)) {
154
+ const trimmed = part.trim().replace(/\s+/g, " ");
155
+ if (!trimmed || /^type\s+/.test(trimmed)) continue;
156
+ const asMatch = trimmed.match(/^[A-Za-z_$][\w$]*\s+as\s+([A-Za-z_$][\w$]*)$/);
157
+ if (asMatch) {
158
+ names.push(asMatch[1]);
159
+ continue;
160
+ }
161
+ const id = trimmed.match(/^([A-Za-z_$][\w$]*)$/);
162
+ if (id) names.push(id[1]);
163
+ }
164
+ return names;
165
+ }
166
+
167
+ function readAlias(source, i) {
168
+ let j = skipWs(source, i);
169
+ const word = readWord(source, j);
170
+ if (word.word !== "as") return { name: null, next: i };
171
+ j = skipWs(source, word.next);
172
+ const name = readWord(source, j);
173
+ if (!name.word) return { name: null, next: j };
174
+ return { name: name.word, next: name.next };
175
+ }
176
+
150
177
  function findMatchingBrace(source, openIndex) {
151
178
  let depth = 0;
152
179
  for (let i = openIndex; i < source.length; i += 1) {
@@ -179,16 +206,25 @@ function readFromClause(source, i) {
179
206
  }
180
207
 
181
208
  /**
182
- * @returns {{ specifier: string, typeOnly: boolean }[]}
209
+ * @returns {{ specifier: string, typeOnly: boolean, names: string[] }[]}
210
+ * `names` are the local bindings (`import { a as b }` → `b`) so callers can
211
+ * count `b(` as a use of this import. Type-only imports still list names.
183
212
  */
184
213
  export function extractJsImports(source) {
185
214
  const imports = [];
186
215
  const n = source.length;
187
216
  let i = 0;
188
217
 
189
- function add(specifier, typeOnly) {
218
+ function add(specifier, typeOnly, names = []) {
190
219
  if (typeof specifier !== "string") return;
191
- imports.push({ specifier, typeOnly: Boolean(typeOnly) });
220
+ const locals = [];
221
+ const seen = new Set();
222
+ for (const name of names) {
223
+ if (typeof name !== "string" || !name || seen.has(name)) continue;
224
+ seen.add(name);
225
+ locals.push(name);
226
+ }
227
+ imports.push({ specifier, typeOnly: Boolean(typeOnly), names: locals });
192
228
  }
193
229
 
194
230
  function parseRequire(at) {
@@ -228,7 +264,7 @@ export function extractJsImports(source) {
228
264
  const clause = readFromClause(source, close + 1);
229
265
  if (!clause) return close + 1;
230
266
  const onlyTypes = typeOnly || braceIsTypeOnly(inner);
231
- add(clause.value, onlyTypes);
267
+ add(clause.value, onlyTypes, typeOnly ? [] : localNames(inner));
232
268
  return clause.next;
233
269
  }
234
270
 
@@ -240,8 +276,10 @@ export function extractJsImports(source) {
240
276
  }
241
277
  // default binding, namespace, or `name, { ... }`
242
278
  let sawValue = !typeOnly;
279
+ const names = [];
243
280
  if (source[j] !== "*") {
244
281
  const binding = readWord(source, j);
282
+ if (binding.word && !typeOnly) names.push(binding.word);
245
283
  j = skipWs(source, binding.next);
246
284
  if (source[j] === ",") {
247
285
  sawValue = !typeOnly;
@@ -249,8 +287,9 @@ export function extractJsImports(source) {
249
287
  }
250
288
  } else {
251
289
  j = skipWs(source, j + 1);
252
- j = skipAsBinding(source, j);
253
- j = skipWs(source, j);
290
+ const alias = readAlias(source, j);
291
+ if (alias.name && !typeOnly) names.push(alias.name);
292
+ j = skipWs(source, alias.next);
254
293
  }
255
294
  if (source[j] === "{") {
256
295
  const close = findMatchingBrace(source, j);
@@ -259,12 +298,13 @@ export function extractJsImports(source) {
259
298
  const clause = readFromClause(source, close + 1);
260
299
  if (!clause) return close + 1;
261
300
  const onlyTypes = typeOnly || (!sawValue && braceIsTypeOnly(inner));
262
- add(clause.value, onlyTypes);
301
+ if (!onlyTypes) names.push(...localNames(inner));
302
+ add(clause.value, onlyTypes, names);
263
303
  return clause.next;
264
304
  }
265
305
  const clause = readFromClause(source, j);
266
306
  if (!clause) return at;
267
- add(clause.value, typeOnly);
307
+ add(clause.value, typeOnly, typeOnly ? [] : names);
268
308
  return clause.next;
269
309
  }
270
310
 
package/src/badge.js CHANGED
@@ -10,6 +10,8 @@ const SOURCE_EXT = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py",
10
10
  const SKIP_DIR = new Set([
11
11
  "node_modules", ".git", "dist", "build", "vendor", "target", "coverage",
12
12
  ".repolens", "__pycache__", ".cache",
13
+ "__snapshots__", "samples", "sample", "examples", "example",
14
+ "fixtures", "fixture", "__fixtures__", "e2e-fixtures", "e2e-fixture", "generated",
13
15
  ]);
14
16
 
15
17
  const COLOR_HEX = {
@@ -143,6 +145,7 @@ export async function listScoreFiles(root, { maxFiles = 2500 } = {}) {
143
145
  continue;
144
146
  }
145
147
  if (!entry.isFile()) continue;
148
+ if (entry.name.endsWith(".snap")) continue;
146
149
  if (!SOURCE_EXT.has(path.extname(entry.name))) continue;
147
150
  if (out.length >= maxFiles) {
148
151
  capped = true;
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");
@@ -107,6 +107,10 @@ export function validateConfig(config) {
107
107
  if (!config.scan.ignore || !Array.isArray(config.scan.ignore)) {
108
108
  errors.push("scan.ignore is required and must be an array");
109
109
  }
110
+
111
+ if (config.scan.include_dev_artifacts !== undefined && typeof config.scan.include_dev_artifacts !== "boolean") {
112
+ errors.push("scan.include_dev_artifacts must be a boolean");
113
+ }
110
114
  }
111
115
 
112
116
  // Validate module_roots (optional but must be array if present)
@@ -0,0 +1,36 @@
1
+ // Directories the scan skips unless scan.include_dev_artifacts is true.
2
+ // Snapshots, samples, fixtures, examples, and generated output are dev tooling.
3
+ // A project's own scan.ignore is still applied. The flag turns off only this list.
4
+
5
+ export const DEV_ARTIFACT_IGNORE = [
6
+ "**/__snapshots__/**",
7
+ "**/*.snap",
8
+ "**/samples/**",
9
+ "**/sample/**",
10
+ "**/examples/**",
11
+ "**/example/**",
12
+ "**/fixtures/**",
13
+ "**/fixture/**",
14
+ "**/__fixtures__/**",
15
+ "**/e2e-fixtures/**",
16
+ "**/e2e-fixture/**",
17
+ "**/generated/**",
18
+ ];
19
+
20
+ /**
21
+ * Ignore globs for one scan.
22
+ * `include_dev_artifacts: true` keeps the caller's list and does not add the defaults,
23
+ * so a config can scan samples or snapshots on purpose. Other ignore entries always apply.
24
+ */
25
+ export function resolveScanIgnore(scan = {}) {
26
+ const user = Array.isArray(scan.ignore) ? scan.ignore.filter((pattern) => typeof pattern === "string") : [];
27
+ if (scan.include_dev_artifacts === true) return user;
28
+ const seen = new Set(user);
29
+ const merged = [...user];
30
+ for (const pattern of DEV_ARTIFACT_IGNORE) {
31
+ if (seen.has(pattern)) continue;
32
+ seen.add(pattern);
33
+ merged.push(pattern);
34
+ }
35
+ return merged;
36
+ }
package/src/core/scan.js CHANGED
@@ -5,7 +5,9 @@ import { info, warn } from "../utils/logger.js";
5
5
  import { trackScan } from "../utils/telemetry.js";
6
6
  import { detectMonorepo } from "../analyzers/monorepo-detector.js";
7
7
  import { isInsideRoot } from "../utils/safe-path.js";
8
- import { isTestPath } from "../utils/path-roles.js";
8
+ import { isNonProductPath, isTestPath } from "../utils/path-roles.js";
9
+ import { resolveScanIgnore } from "./scan-defaults.js";
10
+ import { replaceBracketParams } from "../utils/route-params.js";
9
11
 
10
12
  const norm = (p) => p.replace(/\\/g, "/");
11
13
 
@@ -93,7 +95,13 @@ async function readFileSafe(file) {
93
95
  }
94
96
  }
95
97
 
96
- function moduleKeyForFile(file, moduleRoots) {
98
+ function rootModuleLabel(repoRoot) {
99
+ const base = path.basename(path.resolve(repoRoot || ""));
100
+ if (!base || base === "." || base === "..") return "root";
101
+ return base;
102
+ }
103
+
104
+ function moduleKeyForFile(file, moduleRoots, rootLabel = "root") {
97
105
  const normalized = norm(file);
98
106
 
99
107
  const sortedRoots = [...moduleRoots].sort((a, b) => b.length - a.length);
@@ -111,7 +119,9 @@ function moduleKeyForFile(file, moduleRoots) {
111
119
  }
112
120
 
113
121
  const parts = normalized.split("/");
114
- return parts.length > 1 ? parts[0] : "root";
122
+ // A file next to go.mod or package.json is one module, named for the directory.
123
+ // Calling it "root" hid that package on the system map.
124
+ return parts.length > 1 ? parts[0] : rootLabel;
115
125
  }
116
126
 
117
127
  function routePathFromFile(file) {
@@ -121,10 +131,9 @@ function routePathFromFile(file) {
121
131
  const appIndex = f.indexOf("/app/");
122
132
  const relative = f.slice(appIndex + 5);
123
133
 
124
- const cleaned = relative
134
+ const cleaned = replaceBracketParams(relative
125
135
  .replace(/\/page\.(ts|tsx|js|jsx)$/, "")
126
- .replace(/\/route\.(ts|tsx|js|jsx)$/, "")
127
- .replace(/\[(.*?)\]/g, ":$1")
136
+ .replace(/\/route\.(ts|tsx|js|jsx)$/, ""))
128
137
  .replace(/\/$/, "");
129
138
 
130
139
  return cleaned ? `/${cleaned}` : "/";
@@ -141,10 +150,9 @@ function routePathFromFile(file) {
141
150
  const pagesIndex = f.indexOf("/pages/");
142
151
  const relative = f.slice(pagesIndex + 7);
143
152
 
144
- const cleaned = relative
153
+ const cleaned = replaceBracketParams(relative
145
154
  .replace(/\.(ts|tsx|js|jsx)$/, "")
146
- .replace(/\/index$/, "")
147
- .replace(/\[(.*?)\]/g, ":$1")
155
+ .replace(/\/index$/, ""))
148
156
  .replace(/\/$/, "");
149
157
 
150
158
  return cleaned ? `/${cleaned}` : "/";
@@ -316,11 +324,13 @@ async function extractRepoMetadata(repoRoot, files = []) {
316
324
  const hasLibraryEntry = Boolean(pkg.main || pkg.module || pkg.exports || pkg.types || pkg.typings || pkg.browser);
317
325
  const isShippedApp = Boolean(allDeps["electron"] || allDeps["next"]);
318
326
  metadata.packageEntryFiles = packageEntryFiles(pkg);
319
- if (hasLibraryEntry && !isShippedApp) {
320
- const description = String(pkg.description || "");
321
- const keywords = Array.isArray(pkg.keywords) ? pkg.keywords.join(" ") : "";
322
- const saysLibrary = /\b(library|sdk|parser|toolkit|framework)\b/i.test(description);
323
- const saysCli = /\bcli\b|command[- ]line/i.test(`${description} ${keywords}`);
327
+ const description = String(pkg.description || "");
328
+ const keywords = Array.isArray(pkg.keywords) ? pkg.keywords.join(" ") : "";
329
+ const saysLibrary = /\b(library|sdk|parser|toolkit|framework)\b/i.test(`${description} ${keywords}`);
330
+ const saysCli = /\bcli\b|command[- ]line/i.test(`${description} ${keywords}`);
331
+ // Express 5 publishes index.js with no `main` field. A description that says
332
+ // library or framework is enough; Node's default entry is that index.js.
333
+ if ((hasLibraryEntry || saysLibrary) && !isShippedApp) {
324
334
  metadata.projectKind = hasBin && !saysLibrary && saysCli ? "cli" : "library";
325
335
  } else if (hasBin && !hasLibraryEntry) {
326
336
  metadata.projectKind = "cli";
@@ -408,16 +418,37 @@ async function extractRepoMetadata(repoRoot, files = []) {
408
418
  return metadata;
409
419
  }
410
420
 
421
+ function isCommentLine(line) {
422
+ const trimmed = String(line || "").trim();
423
+ return trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*");
424
+ }
425
+
426
+ function lineAt(content, index) {
427
+ const start = content.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
428
+ const end = content.indexOf("\n", index);
429
+ return content.slice(start, end === -1 ? content.length : end);
430
+ }
431
+
432
+ /** Express route paths start with `/`. `app.get('query parser fn')` is a setting. */
433
+ function isExpressPath(routePath) {
434
+ return typeof routePath === "string"
435
+ && routePath.length > 0
436
+ && routePath.length <= 200
437
+ && (routePath.startsWith("/") || routePath === "*");
438
+ }
439
+
411
440
  function extractExpressRoutes(content) {
412
441
  const routes = [];
413
442
  const seenRoutes = new Set();
414
443
 
415
- // Match app.METHOD(path) or router.METHOD(path)
444
+ // Match app.METHOD(path) or router.METHOD(path). Skip comments and setting lookups.
416
445
  const methodPattern = /(?:app|router)\.(get|post|put|patch|delete|all)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
417
446
  let match;
418
447
 
419
448
  while ((match = methodPattern.exec(content)) !== null) {
449
+ if (isCommentLine(lineAt(content, match.index))) continue;
420
450
  const [, method, path] = match;
451
+ if (!isExpressPath(path)) continue;
421
452
  const routeKey = `${method.toUpperCase()}:${path}`;
422
453
 
423
454
  if (!seenRoutes.has(routeKey)) {
@@ -440,7 +471,9 @@ function extractExpressRoutes(content) {
440
471
  // Match app.route(path).METHOD()
441
472
  const routePattern = /(?:app|router)\.route\s*\(\s*['"`]([^'"`]+)['"`]\s*\)([.\s\w()]*)/gi;
442
473
  while ((match = routePattern.exec(content)) !== null) {
474
+ if (isCommentLine(lineAt(content, match.index))) continue;
443
475
  const [, path, chain] = match;
476
+ if (!isExpressPath(path)) continue;
444
477
  const methods = [];
445
478
 
446
479
  if (chain.includes(".get(")) methods.push("GET");
@@ -527,7 +560,7 @@ export async function listMatchedFiles(cfg) {
527
560
  const rootResolved = path.resolve(repoRoot);
528
561
  const files = await fg(cfg.scan.include, {
529
562
  cwd: rootResolved,
530
- ignore: cfg.scan?.ignore || [],
563
+ ignore: resolveScanIgnore(cfg.scan),
531
564
  onlyFiles: true,
532
565
  followSymbolicLinks: false,
533
566
  absolute: false
@@ -556,21 +589,28 @@ export async function scanRepo(cfg) {
556
589
  }
557
590
 
558
591
  const moduleCounts = new Map();
592
+ const flatKeys = new Set();
593
+ const rootLabel = rootModuleLabel(repoRoot);
559
594
 
560
595
  for (const file of files) {
561
596
  // Go test files belong to the package they exercise. Counting each
562
597
  // *_test.go as its own module (the filename under module_roots) splits
563
598
  // one package into a source module and a test module.
564
599
  if (/_test\.go$/.test(norm(file))) continue;
565
- const key = moduleKeyForFile(file, cfg.module_roots || []);
600
+ const key = moduleKeyForFile(file, cfg.module_roots || [], rootLabel);
601
+ if (!norm(file).includes("/")) flatKeys.add(key);
566
602
  moduleCounts.set(key, (moduleCounts.get(key) || 0) + 1);
567
603
  }
568
604
 
569
605
  const modules = [...moduleCounts.entries()]
570
- .map(([key, fileCount]) => ({ key, fileCount }))
606
+ .map(([key, fileCount]) => ({
607
+ key,
608
+ fileCount,
609
+ ...(flatKeys.has(key) ? { flat: true } : {}),
610
+ }))
571
611
  .sort((a, b) => b.fileCount - a.fileCount);
572
612
 
573
- const apiFiles = files.filter(isNextRoute);
613
+ const apiFiles = files.filter((file) => isNextRoute(file) && !isNonProductPath(file));
574
614
  const api = [];
575
615
 
576
616
  // Extract Next.js API routes
@@ -593,8 +633,9 @@ export async function scanRepo(cfg) {
593
633
  });
594
634
  }
595
635
 
596
- // Extract Express.js routes
636
+ // Extract Express.js routes. Tests and examples are not the product API.
597
637
  for (const file of files) {
638
+ if (isNonProductPath(file)) continue;
598
639
  if (file.endsWith(".ts") || file.endsWith(".js")) {
599
640
  const absoluteFile = path.join(repoRoot, file);
600
641
  const content = await readFileSafe(absoluteFile);
@@ -613,7 +654,7 @@ export async function scanRepo(cfg) {
613
654
  }
614
655
  }
615
656
 
616
- const pageFiles = files.filter(isNextPage);
657
+ const pageFiles = files.filter((file) => isNextPage(file) && !isNonProductPath(file));
617
658
  const pages = pageFiles.map((file) => ({
618
659
  file,
619
660
  path: routePathFromFile(file),
@@ -622,6 +663,7 @@ export async function scanRepo(cfg) {
622
663
 
623
664
  // Extract React Router routes
624
665
  for (const file of files) {
666
+ if (isNonProductPath(file)) continue;
625
667
  if (file.endsWith(".tsx") || file.endsWith(".jsx")) {
626
668
  const absoluteFile = path.join(repoRoot, file);
627
669
  const content = await readFileSafe(absoluteFile);
@@ -635,6 +677,7 @@ export async function scanRepo(cfg) {
635
677
 
636
678
  // Extract Vue Router routes
637
679
  for (const file of files) {
680
+ if (isNonProductPath(file)) continue;
638
681
  if (file.endsWith(".ts") || file.endsWith(".js") || file.endsWith(".vue")) {
639
682
  const absoluteFile = path.join(repoRoot, file);
640
683
  const content = await readFileSafe(absoluteFile);
@@ -34,6 +34,7 @@ RepoLens is the npm package \`@chappibunny/repolens\` (Node.js 18+). It is a CLI
34
34
  - [publish](${REPO_URL}/blob/main/docs/INSTALLATION.md): \`npx @chappibunny/repolens publish\` scans and publishes
35
35
  - [badge](${REPO_URL}/blob/main/README.md): \`npx @chappibunny/repolens badge\` prints shields.io code-health JSON, SVG, or markdown
36
36
  - [comment](${REPO_URL}/blob/main/README.md): \`npx @chappibunny/repolens comment --base <ref> --head <ref>\` prints an architecture diff
37
+ - [app](${REPO_URL}/blob/main/docs/GITHUB_APP.md): \`npx @chappibunny/repolens app\` self-hosts the GitHub App webhook. \`--manifest\` prints the manifest. No credentials are required to print it
37
38
  - [doctor](${REPO_URL}/blob/main/docs/INSTALLATION.md): \`npx @chappibunny/repolens doctor\` validates setup
38
39
  - [mcp](${REPO_URL}/blob/main/docs/MCP.md): \`npx -y @chappibunny/repolens mcp\` starts the stdio server
39
40
 
@@ -95,6 +96,7 @@ Agent-oriented install steps, including the Cline MCP snippet, are in llms-insta
95
96
  - \`repolens watch\` regenerates docs on change and exits 2 when nothing matches.
96
97
  - \`repolens badge\` prints a shields.io endpoint JSON document for the code-health score (0–100). \`--format svg\` prints a square SVG. \`--format markdown --url https://host/repolens-badge.json\` prints a README snippet. \`--format all --out repolens-badge.json\` writes JSON and SVG. Exit 2 when no JavaScript, TypeScript, Python, or Go files were scored. The score is not reported as 100 when zero files match.
97
98
  - \`repolens comment --base <ref> --head <ref>\` compares those commits. It prints markdown locally. In GitHub Actions, with \`GITHUB_TOKEN\`, \`GITHUB_REPOSITORY\`, and a pull_request event, it creates or updates one comment.
99
+ - \`repolens app\` self-hosts a GitHub App webhook. It verifies \`X-Hub-Signature-256\`, posts a Check Run named RepoLens, and updates the sticky architecture comment. \`repolens app --manifest\` prints the manifest and needs no secrets. Listening needs \`REPOLENS_APP_ID\`, \`REPOLENS_WEBHOOK_SECRET\`, and a private key. See docs/GITHUB_APP.md.
98
100
  - \`repolens mcp\` starts the stdio MCP server. \`repolens-mcp\` is the same entry.
99
101
 
100
102
  Exit codes: 0 success, 1 runtime error or unknown command, 2 validation failure (bad flags, missing config, empty scan, and the other cases in the README).