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