@chappibunny/repolens 1.15.4 → 1.15.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +9 -9
- package/llms-install.md +1 -0
- package/package.json +1 -1
- package/src/ai/generate-sections.js +130 -52
- package/src/analyzers/context-builder.js +96 -57
- package/src/analyzers/dependency-graph.js +59 -1
- package/src/analyzers/domain-inference.js +35 -4
- package/src/analyzers/flow-inference.js +27 -23
- package/src/analyzers/security-patterns.js +29 -3
- package/src/core/scan.js +50 -4
- package/src/docs/generate-doc-set.js +21 -0
- package/src/renderers/render.js +3 -3
- package/src/renderers/renderAnalysis.js +28 -9
- package/src/utils/path-roles.js +58 -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,
|
|
@@ -177,7 +215,15 @@ export function buildAIContext(scanResult, config, depGraph = null) {
|
|
|
177
215
|
}
|
|
178
216
|
|
|
179
217
|
function inferModuleType(modulePath) {
|
|
180
|
-
|
|
218
|
+
// `src` and `lib` only say where code lives. `lib/ogg` is typed by `ogg`, and a bare
|
|
219
|
+
// `lib` is the project's source, not a "shared library" layer.
|
|
220
|
+
const typedPath = stripContainerSegments(modulePath);
|
|
221
|
+
const fromContainer = hasContainerSegment(modulePath);
|
|
222
|
+
const has = (keyword) => pathHasKeyword(typedPath, keyword);
|
|
223
|
+
// Page, route, and view describe a folder of pages. A build script called
|
|
224
|
+
// `build-pages.js` is not one.
|
|
225
|
+
const isFile = /\.[a-z0-9]+$/i.test(String(modulePath).split("/").pop() || "");
|
|
226
|
+
const hasDirectory = (keyword) => pathHasKeyword(directoryPath(typedPath), keyword);
|
|
181
227
|
|
|
182
228
|
if (has("test") || has("spec")) return "test";
|
|
183
229
|
if (has("api") || has("endpoint")) return "api";
|
|
@@ -185,7 +231,7 @@ function inferModuleType(modulePath) {
|
|
|
185
231
|
if (has("lib") || has("util") || has("helper") || has("common") || has("shared")) return "library";
|
|
186
232
|
if (has("hook")) return "hooks";
|
|
187
233
|
if (has("store") || has("state") || has("redux") || has("zustand")) return "state";
|
|
188
|
-
if (has("page") || has("route") || has("view")) return "route";
|
|
234
|
+
if (isFile ? (hasDirectory("page") || hasDirectory("route") || hasDirectory("view")) : (has("page") || has("route") || has("view"))) return "route";
|
|
189
235
|
if (has("config") || has("setting") || has("env")) return "config";
|
|
190
236
|
if (has("core") || has("kernel") || has("foundation")) return "core";
|
|
191
237
|
if (has("render") || has("template") || has("format")) return "renderer";
|
|
@@ -203,19 +249,10 @@ function inferModuleType(modulePath) {
|
|
|
203
249
|
if (has("service")) return "service";
|
|
204
250
|
if (has("app")) return "app";
|
|
205
251
|
|
|
206
|
-
return "other";
|
|
252
|
+
return fromContainer ? "source" : "other";
|
|
207
253
|
}
|
|
208
254
|
|
|
209
|
-
|
|
210
|
-
const norm = String(modulePath || "").replace(/\\/g, "/");
|
|
211
|
-
if (/(^|\/)__(tests|mocks)__(\/|$)/.test(norm)) return true;
|
|
212
|
-
if (/(^|\/)(test|tests|spec|specs|fixtures|testdata)(\/|$)/.test(norm)) return true;
|
|
213
|
-
const base = norm.split("/").pop() || "";
|
|
214
|
-
if (/^(test_|spec_)/.test(base)) return true;
|
|
215
|
-
if (/(_test|_spec)\.[a-z0-9]+$/i.test(base)) return true;
|
|
216
|
-
if (/\.(test|spec)\.[a-z0-9]+$/i.test(base)) return true;
|
|
217
|
-
return false;
|
|
218
|
-
}
|
|
255
|
+
const isTestEvidence = isTestPath;
|
|
219
256
|
|
|
220
257
|
const API_DIRECTORY_SEGMENTS = new Set([
|
|
221
258
|
"api", "apis", "route", "routes", "router", "routers",
|
|
@@ -235,14 +272,15 @@ function directoryPath(modulePath) {
|
|
|
235
272
|
return parts.join("/");
|
|
236
273
|
}
|
|
237
274
|
|
|
238
|
-
function inferArchitecturalPatterns(modules, depGraph = null) {
|
|
275
|
+
function inferArchitecturalPatterns(modules, depGraph = null, projectKind = null) {
|
|
239
276
|
const patterns = [];
|
|
240
277
|
const production = modules.filter((module) => !isTestEvidence(module.key));
|
|
241
278
|
const tests = modules.filter((module) => isTestEvidence(module.key));
|
|
242
279
|
const keys = production.map(m => m.key.toLowerCase());
|
|
243
280
|
|
|
281
|
+
// `lib` and `src` only say where code lives, so they never count as evidence for a pattern.
|
|
244
282
|
const has = (keyword) => keys.some((key) => (
|
|
245
|
-
keyword.includes("/") ? key.includes(keyword) : pathHasKeyword(key, keyword)
|
|
283
|
+
keyword.includes("/") ? key.includes(keyword) : pathHasKeyword(stripContainerSegments(key), keyword)
|
|
246
284
|
));
|
|
247
285
|
const matchingKeys = (keywords, source = production) => {
|
|
248
286
|
const found = [];
|
|
@@ -251,7 +289,7 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
|
|
|
251
289
|
const key = module.key;
|
|
252
290
|
const matched = keyword.includes("/")
|
|
253
291
|
? key.toLowerCase().includes(keyword)
|
|
254
|
-
: pathHasKeyword(key, keyword);
|
|
292
|
+
: pathHasKeyword(stripContainerSegments(key), keyword);
|
|
255
293
|
if (matched && !found.includes(key)) found.push(key);
|
|
256
294
|
}
|
|
257
295
|
}
|
|
@@ -342,7 +380,8 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
|
|
|
342
380
|
}
|
|
343
381
|
|
|
344
382
|
if (has("analyz") || has("detect") || has("inspect")) cite("Analysis pipeline", ["analyz", "detect", "inspect"]);
|
|
345
|
-
|
|
383
|
+
// A library's `bin/` folder is build tooling. Only a CLI package gets this pattern.
|
|
384
|
+
if (projectKind !== "library" && (has("cli") || has("command") || has("bin"))) cite("CLI tool architecture", ["cli", "command", "bin"]);
|
|
346
385
|
|
|
347
386
|
if (has("util") || has("helper") || has("lib")) {
|
|
348
387
|
const verified = moduleMetrics.size > 0 && verifyHub("util") || verifyHub("helper") || verifyHub("lib");
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
isGoStdlib,
|
|
17
17
|
parseGoModule,
|
|
18
18
|
} from "./import-parsers.js";
|
|
19
|
+
import { isNonProductPath } from "../utils/path-roles.js";
|
|
19
20
|
|
|
20
21
|
const JS_EXTENSIONS = new Set([".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"]);
|
|
21
22
|
const PY_EXTENSIONS = new Set([".py", ".pyw"]);
|
|
@@ -70,6 +71,8 @@ function emptyResult() {
|
|
|
70
71
|
edges: [],
|
|
71
72
|
cycles: [],
|
|
72
73
|
externalDeps: [],
|
|
74
|
+
externalRuntime: [],
|
|
75
|
+
externalDev: [],
|
|
73
76
|
stats: null,
|
|
74
77
|
summary: null,
|
|
75
78
|
};
|
|
@@ -89,15 +92,19 @@ function goPackageImportPath(file, modulePath) {
|
|
|
89
92
|
return rel || ".";
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
// Hubs describe the product. Test helpers, examples, and build scripts are neither hubs
|
|
96
|
+
// nor reasons for a product file to count as one.
|
|
92
97
|
function aggregateHubs(nodes, fileKeyToFile, modulePath, hubThreshold) {
|
|
93
98
|
const groups = new Map();
|
|
94
99
|
for (const node of nodes) {
|
|
100
|
+
if (isNonProductPath(node.file || node.key)) continue;
|
|
95
101
|
const pkg = goPackageImportPath(node.file, modulePath);
|
|
96
102
|
const key = pkg || node.key;
|
|
97
103
|
if (!groups.has(key)) groups.set(key, new Set());
|
|
98
104
|
const importers = groups.get(key);
|
|
99
105
|
for (const importer of node.importedBy || []) {
|
|
100
106
|
const importerFile = fileKeyToFile.get(importer) || importer;
|
|
107
|
+
if (isNonProductPath(importerFile)) continue;
|
|
101
108
|
if (pkg && goPackageImportPath(importerFile, modulePath) === pkg) continue;
|
|
102
109
|
importers.add(importer);
|
|
103
110
|
}
|
|
@@ -271,13 +278,22 @@ export async function analyzeDependencyGraph(files, repoRoot) {
|
|
|
271
278
|
// Stats
|
|
272
279
|
const totalImports = result.edges.length;
|
|
273
280
|
const orphans = result.nodes.filter(n => n.imports.length === 0 && n.importedBy.length === 0);
|
|
274
|
-
const
|
|
281
|
+
const sourceNodeCount = result.nodes.filter((n) => !isNonProductPath(n.file || n.key)).length;
|
|
282
|
+
const hubThreshold = Math.max(5, Math.round(sourceNodeCount * 0.07));
|
|
275
283
|
const hubs = aggregateHubs(result.nodes, fileKeyToFile, goModulePath, hubThreshold);
|
|
276
284
|
|
|
285
|
+
// Runtime vs development packages, from the nearest package.json.
|
|
286
|
+
const { runtime: externalRuntime, dev: externalDev } = await splitExternalDeps(result, repoRoot);
|
|
287
|
+
result.externalRuntime = externalRuntime;
|
|
288
|
+
result.externalDev = externalDev;
|
|
289
|
+
|
|
277
290
|
result.stats = {
|
|
278
291
|
totalFiles: codeFiles.length,
|
|
292
|
+
sourceFiles: codeFiles.filter((file) => !isNonProductPath(file)).length,
|
|
279
293
|
totalEdges: totalImports,
|
|
280
294
|
externalDeps: result.externalDeps.length,
|
|
295
|
+
externalRuntime: externalRuntime.length,
|
|
296
|
+
externalDev: externalDev.length,
|
|
281
297
|
cycles: result.cycles.length,
|
|
282
298
|
orphanFiles: orphans.length,
|
|
283
299
|
hubs,
|
|
@@ -294,6 +310,48 @@ export async function analyzeDependencyGraph(files, repoRoot) {
|
|
|
294
310
|
return result;
|
|
295
311
|
}
|
|
296
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Split external packages into runtime dependencies and development tooling.
|
|
315
|
+
* A package declared in `devDependencies` is development tooling. One declared in
|
|
316
|
+
* `dependencies`, `optionalDependencies`, or `peerDependencies` is runtime. An undeclared
|
|
317
|
+
* package is runtime when a product file (not a test, example, or build script) imports it.
|
|
318
|
+
* Without a root package.json (Python, Go) every package counts as runtime.
|
|
319
|
+
*/
|
|
320
|
+
async function splitExternalDeps(result, repoRoot) {
|
|
321
|
+
const raw = await readFileSafe(path.join(repoRoot, "package.json"));
|
|
322
|
+
let pkg = null;
|
|
323
|
+
try { pkg = raw ? JSON.parse(raw) : null; } catch { pkg = null; }
|
|
324
|
+
if (!pkg) return { runtime: [...result.externalDeps], dev: [] };
|
|
325
|
+
|
|
326
|
+
const declaredRuntime = new Set([
|
|
327
|
+
...Object.keys(pkg.dependencies || {}),
|
|
328
|
+
...Object.keys(pkg.optionalDependencies || {}),
|
|
329
|
+
...Object.keys(pkg.peerDependencies || {}),
|
|
330
|
+
]);
|
|
331
|
+
const declaredDev = new Set(Object.keys(pkg.devDependencies || {}));
|
|
332
|
+
|
|
333
|
+
const productImports = new Set();
|
|
334
|
+
for (const node of result.nodes) {
|
|
335
|
+
if (isNonProductPath(node.file || node.key) || isBuildScript(node.file || node.key)) continue;
|
|
336
|
+
for (const name of node.externalImports || []) productImports.add(name);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const runtime = [];
|
|
340
|
+
const dev = [];
|
|
341
|
+
for (const name of result.externalDeps) {
|
|
342
|
+
if (declaredRuntime.has(name)) runtime.push(name);
|
|
343
|
+
else if (declaredDev.has(name)) dev.push(name);
|
|
344
|
+
else if (productImports.has(name)) runtime.push(name);
|
|
345
|
+
else dev.push(name);
|
|
346
|
+
}
|
|
347
|
+
return { runtime, dev };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function isBuildScript(file) {
|
|
351
|
+
const base = String(file || "").replace(/\\/g, "/").split("/").pop() || "";
|
|
352
|
+
return /^build([-_.]|$)/i.test(base) || /\.config\.[cm]?[jt]s$/i.test(base);
|
|
353
|
+
}
|
|
354
|
+
|
|
297
355
|
/**
|
|
298
356
|
* Detect all cycles in a directed graph using DFS with 3-color marking.
|
|
299
357
|
* Returns an array of cycle paths (each path ends where it started).
|
|
@@ -17,7 +17,7 @@ const DEFAULT_DOMAIN_HINTS = [
|
|
|
17
17
|
description: "Content publishing, management, and delivery"
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
|
-
match: ["search", "
|
|
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
|
},
|
|
@@ -187,10 +187,31 @@ export function stripEntryFileName(modulePath) {
|
|
|
187
187
|
return parts.join("/");
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
// Folder names that only say "code lives here". `lib/ogg` is about ogg, not about being a lib.
|
|
191
|
+
export const GENERIC_CONTAINER_SEGMENTS = new Set(["src", "lib", "libs", "source", "sources", "packages", "pkg", "internal"]);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Drop generic container folders (`src`, `lib`, ...) from a path so they do not pick a
|
|
195
|
+
* domain on their own. Returns "" when nothing else is left (`lib`, `src/lib`).
|
|
196
|
+
*/
|
|
197
|
+
export function stripContainerSegments(modulePath) {
|
|
198
|
+
return String(modulePath)
|
|
199
|
+
.replace(/\\/g, "/")
|
|
200
|
+
.split("/")
|
|
201
|
+
.filter((segment) => segment && !GENERIC_CONTAINER_SEGMENTS.has(segment.toLowerCase()))
|
|
202
|
+
.join("/");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** True when the path has a generic container folder (`lib/ogg`, `src`). */
|
|
206
|
+
export function hasContainerSegment(modulePath) {
|
|
207
|
+
return String(modulePath).replace(/\\/g, "/").split("/").some((segment) => GENERIC_CONTAINER_SEGMENTS.has(segment.toLowerCase()));
|
|
208
|
+
}
|
|
209
|
+
|
|
190
210
|
export function inferDomain(modulePath, customHints = [], evidence = {}) {
|
|
191
211
|
const customSet = new Set(customHints);
|
|
192
212
|
const hints = [...customHints, ...DEFAULT_DOMAIN_HINTS];
|
|
193
|
-
const
|
|
213
|
+
const entryStripped = stripEntryFileName(modulePath);
|
|
214
|
+
const defaultPath = stripContainerSegments(entryStripped);
|
|
194
215
|
|
|
195
216
|
for (const hint of hints) {
|
|
196
217
|
// User-defined hints see the full path. Built-in guesses ignore entry file names.
|
|
@@ -208,6 +229,16 @@ export function inferDomain(modulePath, customHints = [], evidence = {}) {
|
|
|
208
229
|
}
|
|
209
230
|
}
|
|
210
231
|
|
|
232
|
+
// A source folder with no keyword of its own is core code, not "uncategorized".
|
|
233
|
+
if (hasContainerSegment(entryStripped)) {
|
|
234
|
+
return {
|
|
235
|
+
domain: "Core Source",
|
|
236
|
+
description: "The project's own source code, grouped by folder",
|
|
237
|
+
confidence: "none",
|
|
238
|
+
matchedKeyword: null
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
211
242
|
return {
|
|
212
243
|
domain: "Other",
|
|
213
244
|
description: "Uncategorized module",
|
|
@@ -1,30 +1,19 @@
|
|
|
1
|
+
import { isToolingPath, isTestPath } from "../utils/path-roles.js";
|
|
2
|
+
|
|
1
3
|
// Infer data flows through the application using import graph analysis + heuristics
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Primary flow inference: uses dep graph when available for real import-chain flows,
|
|
5
7
|
* falls back to keyword heuristics for repos without dep graph data.
|
|
6
8
|
*/
|
|
7
|
-
// Directories holding tooling, samples, and docs. An import chain from a file in
|
|
8
|
-
// here describes a build script or an example, not how data moves through the product.
|
|
9
|
-
const TOOLING_DIRECTORIES = new Set([
|
|
10
|
-
"doc", "docs", "doc-gen", "docgen", "scripts", "script", "tools", "tooling",
|
|
11
|
-
"example", "examples", "demo", "demos", "sample", "samples",
|
|
12
|
-
"benchmark", "benchmarks", "bench", "fixtures", "tasks",
|
|
13
|
-
]);
|
|
14
|
-
|
|
15
9
|
// File names too generic to name a flow ("gen Request Flow", "util Integration Flow").
|
|
16
10
|
const GENERIC_FLOW_NAMES = new Set([
|
|
17
11
|
"gen", "generate", "util", "utils", "helper", "helpers", "misc", "common", "types", "constants",
|
|
18
12
|
]);
|
|
19
13
|
|
|
20
|
-
function isToolingPath(key) {
|
|
21
|
-
const parts = String(key || "").replace(/\\/g, "/").toLowerCase().split("/");
|
|
22
|
-
return parts.slice(0, -1).some((segment) => TOOLING_DIRECTORIES.has(segment));
|
|
23
|
-
}
|
|
24
|
-
|
|
25
14
|
/**
|
|
26
15
|
* Drop flows that are noise: no steps, nothing beyond the starting file, a name that is only
|
|
27
|
-
* a generic helper word, or a chain that starts in tooling, examples, or docs.
|
|
16
|
+
* a generic helper word, or a chain that starts in tests, tooling, examples, or docs.
|
|
28
17
|
*/
|
|
29
18
|
export function filterNoiseFlows(flows) {
|
|
30
19
|
return (flows || []).filter((flow) => {
|
|
@@ -32,7 +21,7 @@ export function filterNoiseFlows(flows) {
|
|
|
32
21
|
const steps = Array.isArray(flow.steps) ? flow.steps : [];
|
|
33
22
|
if (steps.length < 2) return false;
|
|
34
23
|
const origin = flow.modules?.[0];
|
|
35
|
-
if (origin && isToolingPath(origin)) return false;
|
|
24
|
+
if (origin && (isToolingPath(origin) || isTestPath(origin))) return false;
|
|
36
25
|
const stem = String(flow.name || "").split(" ")[0].toLowerCase();
|
|
37
26
|
if (GENERIC_FLOW_NAMES.has(stem)) return false;
|
|
38
27
|
return true;
|
|
@@ -66,6 +55,11 @@ export function inferDataFlows(scanResult, config, depGraph = null) {
|
|
|
66
55
|
return filterNoiseFlows(flows);
|
|
67
56
|
}
|
|
68
57
|
|
|
58
|
+
/** Same key the dependency graph gives a file: no extension, no trailing /index. */
|
|
59
|
+
function fileKey(file) {
|
|
60
|
+
return String(file).replace(/\\/g, "/").replace(/\.(js|ts|jsx|tsx|mjs|cjs)$/, "").replace(/\/index$/, "");
|
|
61
|
+
}
|
|
62
|
+
|
|
69
63
|
/**
|
|
70
64
|
* Trace real import chains from entry points through the dep graph.
|
|
71
65
|
* Entry points: CLI/bin files, page components, API route handlers, index files.
|
|
@@ -74,12 +68,22 @@ function inferFlowsFromEntryPoints(scanResult, depGraph) {
|
|
|
74
68
|
const flows = [];
|
|
75
69
|
if (!depGraph?.nodes) return flows;
|
|
76
70
|
|
|
71
|
+
const isLibrary = scanResult?.metadata?.projectKind === "library";
|
|
72
|
+
const packageEntryKeys = new Set(
|
|
73
|
+
(scanResult?.metadata?.packageEntryFiles || []).map((file) => fileKey(file)),
|
|
74
|
+
);
|
|
75
|
+
|
|
77
76
|
// Find entry points (files with high fan-out and low/zero fan-in)
|
|
78
77
|
const entryPoints = depGraph.nodes.filter(n => {
|
|
79
78
|
const key = n.key.toLowerCase();
|
|
79
|
+
// Build configs (`webpack.config`) wire up tooling. They are not how data moves.
|
|
80
|
+
if (/\.config$/.test(key)) return false;
|
|
81
|
+
// In a library, `bin/` holds build helpers. The package entry is what callers use.
|
|
82
|
+
if (isLibrary && /(^|\/)bin\//.test(key)) return false;
|
|
80
83
|
const isEntry = (
|
|
81
|
-
|
|
82
|
-
key.includes("
|
|
84
|
+
packageEntryKeys.has(n.key) ||
|
|
85
|
+
(!isLibrary && key.includes("bin/")) ||
|
|
86
|
+
(!isLibrary && key.includes("cli")) ||
|
|
83
87
|
key.endsWith("/index") ||
|
|
84
88
|
key.includes("pages/") ||
|
|
85
89
|
key.includes("app/") ||
|
|
@@ -88,15 +92,15 @@ function inferFlowsFromEntryPoints(scanResult, depGraph) {
|
|
|
88
92
|
return isEntry && n.imports.length >= 2;
|
|
89
93
|
}).sort((a, b) => {
|
|
90
94
|
// Prioritize src/ entry points over tests/
|
|
91
|
-
const aIsTest = a.key
|
|
92
|
-
const bIsTest = b.key
|
|
95
|
+
const aIsTest = isTestPath(a.key);
|
|
96
|
+
const bIsTest = isTestPath(b.key);
|
|
93
97
|
if (aIsTest !== bIsTest) return aIsTest ? 1 : -1;
|
|
94
98
|
return b.imports.length - a.imports.length;
|
|
95
99
|
});
|
|
96
100
|
|
|
97
101
|
// Limit: all src entries + max 2 test entries
|
|
98
|
-
const srcEntries = entryPoints.filter(n => !n.key
|
|
99
|
-
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);
|
|
100
104
|
const selectedEntries = [...srcEntries, ...testEntries].slice(0, 5);
|
|
101
105
|
|
|
102
106
|
for (const entry of selectedEntries) {
|
|
@@ -104,8 +108,8 @@ function inferFlowsFromEntryPoints(scanResult, depGraph) {
|
|
|
104
108
|
if (chain.length < 2) continue;
|
|
105
109
|
|
|
106
110
|
const shortName = entry.key.split("/").pop();
|
|
107
|
-
const isCliEntry = entry.key.toLowerCase().includes("cli") || entry.key.toLowerCase().includes("bin/");
|
|
108
|
-
const flowType = isCliEntry ? "Command" : "Request";
|
|
111
|
+
const isCliEntry = !isLibrary && (entry.key.toLowerCase().includes("cli") || entry.key.toLowerCase().includes("bin/"));
|
|
112
|
+
const flowType = isCliEntry ? "Command" : isLibrary && packageEntryKeys.has(entry.key) ? "Library" : "Request";
|
|
109
113
|
|
|
110
114
|
// Classify each step in the chain
|
|
111
115
|
const steps = chain.map((nodeKey, i) => {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import fs from "node:fs/promises";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { info, warn } from "../utils/logger.js";
|
|
8
|
+
import { isBuildOrExamplePath } from "../utils/path-roles.js";
|
|
8
9
|
|
|
9
10
|
const JS_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"];
|
|
10
11
|
|
|
@@ -205,6 +206,27 @@ const SKIP_PATTERNS = [
|
|
|
205
206
|
/\.repolens\//,
|
|
206
207
|
];
|
|
207
208
|
|
|
209
|
+
const HTML_ASSIGNMENT_IDS = new Set(["innerhtml-assignment", "outerhtml-assignment"]);
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Is the right-hand side of an innerHTML/outerHTML assignment a fixed string?
|
|
213
|
+
* `el.innerHTML = ''` clears an element and `el.innerHTML = '<b>ok</b>'` writes a constant.
|
|
214
|
+
* Neither can carry injected markup, so neither is an XSS finding. A template literal
|
|
215
|
+
* with `${...}` or any concatenation still is.
|
|
216
|
+
*/
|
|
217
|
+
export function isStaticHtmlAssignment(restOfSource) {
|
|
218
|
+
const rest = String(restOfSource || "");
|
|
219
|
+
const quoted = rest.match(/^\s*(['"])((?:(?!\1)[^\\\n]|\\.)*)\1\s*(?=[;,)}\n]|$)/);
|
|
220
|
+
if (quoted) return true;
|
|
221
|
+
const template = rest.match(/^\s*`([^`]*)`\s*(?=[;,)}\n]|$)/);
|
|
222
|
+
if (template && !template[1].includes("${")) return true;
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Build scripts and example pages run on a maintainer's machine or in a demo page. They are
|
|
227
|
+
// listed, but never as high severity.
|
|
228
|
+
const SEVERITY_CAPPED_IDS_EXEMPT = new Set(["hardcoded-password"]);
|
|
229
|
+
|
|
208
230
|
function shouldSkipFile(filePath) {
|
|
209
231
|
return SKIP_PATTERNS.some(pattern => pattern.test(filePath));
|
|
210
232
|
}
|
|
@@ -255,7 +277,11 @@ export async function analyzeSecurityPatterns(files, repoRoot) {
|
|
|
255
277
|
|
|
256
278
|
while ((match = regex.exec(stripped)) !== null) {
|
|
257
279
|
// Get line number
|
|
280
|
+
if (HTML_ASSIGNMENT_IDS.has(pattern.id) && isStaticHtmlAssignment(stripped.slice(match.index + match[0].length))) continue;
|
|
281
|
+
|
|
258
282
|
const lineNumber = stripped.slice(0, match.index).split("\n").length;
|
|
283
|
+
const capped = isBuildOrExamplePath(file) && !SEVERITY_CAPPED_IDS_EXEMPT.has(pattern.id);
|
|
284
|
+
const severity = capped ? "low" : pattern.severity;
|
|
259
285
|
|
|
260
286
|
// Get the surrounding code snippet
|
|
261
287
|
const lines = stripped.split("\n");
|
|
@@ -267,8 +293,8 @@ export async function analyzeSecurityPatterns(files, repoRoot) {
|
|
|
267
293
|
result.findings.push({
|
|
268
294
|
id: pattern.id,
|
|
269
295
|
category: pattern.category,
|
|
270
|
-
name: pattern.name,
|
|
271
|
-
severity
|
|
296
|
+
name: capped ? `${pattern.name} (build/example code)` : pattern.name,
|
|
297
|
+
severity,
|
|
272
298
|
file,
|
|
273
299
|
line: lineNumber,
|
|
274
300
|
snippet: snippet.length > 200 ? snippet.slice(0, 200) + "..." : snippet,
|
|
@@ -276,7 +302,7 @@ export async function analyzeSecurityPatterns(files, repoRoot) {
|
|
|
276
302
|
cwe: pattern.cwe,
|
|
277
303
|
});
|
|
278
304
|
|
|
279
|
-
result.bySeverity[
|
|
305
|
+
result.bySeverity[severity]++;
|
|
280
306
|
result.filesWithFindings.add(file);
|
|
281
307
|
|
|
282
308
|
if (!result.byCategory[pattern.category]) {
|