@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.
- package/CHANGELOG.md +61 -0
- package/README.md +17 -14
- package/app.yml +23 -0
- package/github-app-manifest.json +22 -0
- package/llms-full.txt +1 -0
- package/llms-install.md +2 -0
- package/llms.txt +1 -0
- package/package.json +8 -6
- package/src/ai/generate-sections.js +11 -8
- package/src/analyzers/context-builder.js +11 -3
- package/src/analyzers/cycle-priority.js +169 -0
- package/src/analyzers/dependency-graph.js +100 -11
- package/src/analyzers/import-parsers.js +48 -8
- package/src/badge.js +3 -0
- package/src/cli-args.js +4 -0
- package/src/cli.js +11 -0
- package/src/core/config-schema.js +4 -0
- package/src/core/scan-defaults.js +36 -0
- package/src/core/scan.js +64 -21
- package/src/docs/llms-text.js +2 -0
- package/src/github-app/auth.js +130 -0
- package/src/github-app/checkout.js +70 -0
- package/src/github-app/handler.js +170 -0
- package/src/github-app/manifest.js +36 -0
- package/src/github-app/run.js +67 -0
- package/src/github-app/server.js +120 -0
- package/src/github-app/signature.js +22 -0
- package/src/help.js +44 -0
- package/src/renderers/renderAnalysis.js +16 -4
- package/src/renderers/renderDiff.js +5 -3
- package/src/renderers/renderMap.js +7 -5
- package/src/utils/path-roles.js +29 -1
- package/src/utils/route-params.js +50 -0
- package/src/utils/yaml.js +11 -6
|
@@ -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.
|
|
210
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
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]) => ({
|
|
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);
|
package/src/docs/llms-text.js
CHANGED
|
@@ -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).
|