@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
package/src/core/scan.js
CHANGED
|
@@ -5,6 +5,7 @@ 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
9
|
|
|
9
10
|
const norm = (p) => p.replace(/\\/g, "/");
|
|
10
11
|
|
|
@@ -101,6 +102,9 @@ function moduleKeyForFile(file, moduleRoots) {
|
|
|
101
102
|
if (normalized === root) return root;
|
|
102
103
|
if (normalized.startsWith(`${root}/`)) {
|
|
103
104
|
const remainder = normalized.slice(root.length + 1);
|
|
105
|
+
// A file directly under the root belongs to the root itself. Only a sub-directory
|
|
106
|
+
// is its own module, so a flat `src/` is one module and not one module per file.
|
|
107
|
+
if (!remainder.includes("/")) return root;
|
|
104
108
|
const nextSegment = remainder.split("/")[0];
|
|
105
109
|
return nextSegment ? `${root}/${nextSegment}` : root;
|
|
106
110
|
}
|
|
@@ -149,6 +153,26 @@ function routePathFromFile(file) {
|
|
|
149
153
|
return file;
|
|
150
154
|
}
|
|
151
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Source files a package.json names as its public entry: `main`, `module`, `types`, and every
|
|
158
|
+
* string under `exports`. Paths are repo-relative with no leading `./`.
|
|
159
|
+
*/
|
|
160
|
+
function packageEntryFiles(pkg) {
|
|
161
|
+
const found = new Set();
|
|
162
|
+
const add = (value) => {
|
|
163
|
+
if (typeof value !== "string" || !/\.(c|m)?[jt]sx?$/.test(value) || value.endsWith(".d.ts")) return;
|
|
164
|
+
found.add(value.replace(/^\.\//, ""));
|
|
165
|
+
};
|
|
166
|
+
const walk = (value) => {
|
|
167
|
+
if (typeof value === "string") add(value);
|
|
168
|
+
else if (value && typeof value === "object") Object.values(value).forEach(walk);
|
|
169
|
+
};
|
|
170
|
+
add(pkg.main);
|
|
171
|
+
add(pkg.module);
|
|
172
|
+
walk(pkg.exports);
|
|
173
|
+
return [...found];
|
|
174
|
+
}
|
|
175
|
+
|
|
152
176
|
/**
|
|
153
177
|
* Detect languages from file extensions
|
|
154
178
|
*/
|
|
@@ -204,9 +228,12 @@ function detectLanguagesFromFiles(files) {
|
|
|
204
228
|
|
|
205
229
|
for (const file of files) {
|
|
206
230
|
const ext = path.extname(file).toLowerCase();
|
|
207
|
-
if (extensionMap[ext])
|
|
208
|
-
|
|
209
|
-
|
|
231
|
+
if (!extensionMap[ext]) continue;
|
|
232
|
+
// Type declarations (`index.d.ts`) and type tests describe a JavaScript library's
|
|
233
|
+
// types. They do not make the library a TypeScript project.
|
|
234
|
+
if (ext === ".ts" && /\.d\.ts$/i.test(file)) continue;
|
|
235
|
+
if (extensionMap[ext] === "TypeScript" && isTestPath(file)) continue;
|
|
236
|
+
languages.add(extensionMap[ext]);
|
|
210
237
|
}
|
|
211
238
|
|
|
212
239
|
return languages;
|
|
@@ -268,7 +295,9 @@ async function extractRepoMetadata(repoRoot, files = []) {
|
|
|
268
295
|
if (allDeps["parcel"]) metadata.buildTools.push("Parcel");
|
|
269
296
|
|
|
270
297
|
// Detect TypeScript from dependencies (supplements file detection)
|
|
271
|
-
|
|
298
|
+
// A `typescript` devDependency alone does not make the source TypeScript: plain JavaScript
|
|
299
|
+
// libraries use it to check their `.d.ts` files. Trust it only when no files were scanned.
|
|
300
|
+
if (allDeps["typescript"] && files.length === 0) metadata.languages.add("TypeScript");
|
|
272
301
|
|
|
273
302
|
// Detect Node.js runtime indicators
|
|
274
303
|
const hasNodeEngines = pkg.engines && pkg.engines.node;
|
|
@@ -279,6 +308,23 @@ async function extractRepoMetadata(repoRoot, files = []) {
|
|
|
279
308
|
if (hasNodeEngines || hasBin || hasNodeDeps || pkg.type === "module") {
|
|
280
309
|
metadata.languages.add("Node.js");
|
|
281
310
|
}
|
|
311
|
+
|
|
312
|
+
// A package with a library entry point (main, module, exports, types) is a library even
|
|
313
|
+
// when it also ships a `bin/` helper. Only a package with a bin and no library entry is a CLI.
|
|
314
|
+
// When it has both, the description decides: "Library that parses..." is a library,
|
|
315
|
+
// while a package that calls itself a CLI keeps that label.
|
|
316
|
+
const hasLibraryEntry = Boolean(pkg.main || pkg.module || pkg.exports || pkg.types || pkg.typings || pkg.browser);
|
|
317
|
+
const isShippedApp = Boolean(allDeps["electron"] || allDeps["next"]);
|
|
318
|
+
metadata.packageEntryFiles = packageEntryFiles(pkg);
|
|
319
|
+
if (hasLibraryEntry && !isShippedApp) {
|
|
320
|
+
const description = String(pkg.description || "");
|
|
321
|
+
const keywords = Array.isArray(pkg.keywords) ? pkg.keywords.join(" ") : "";
|
|
322
|
+
const saysLibrary = /\b(library|sdk|parser|toolkit|framework)\b/i.test(description);
|
|
323
|
+
const saysCli = /\bcli\b|command[- ]line/i.test(`${description} ${keywords}`);
|
|
324
|
+
metadata.projectKind = hasBin && !saysLibrary && saysCli ? "cli" : "library";
|
|
325
|
+
} else if (hasBin && !hasLibraryEntry) {
|
|
326
|
+
metadata.projectKind = "cli";
|
|
327
|
+
}
|
|
282
328
|
} catch {
|
|
283
329
|
// No package.json or invalid JSON
|
|
284
330
|
}
|
|
@@ -130,6 +130,11 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
|
|
|
130
130
|
|
|
131
131
|
for (const docPlan of activeDocuments) {
|
|
132
132
|
docIndex++;
|
|
133
|
+
const emptyReason = emptyDocumentReason(docPlan, { config, scanResult, graphqlResult });
|
|
134
|
+
if (emptyReason) {
|
|
135
|
+
info(`[${docIndex}/${totalDocs}] Skipping ${docPlan.key}: ${emptyReason}`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
133
138
|
info(`[${docIndex}/${totalDocs}] Generating ${docPlan.key}...`);
|
|
134
139
|
let content = null;
|
|
135
140
|
|
|
@@ -237,6 +242,22 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
|
|
|
237
242
|
};
|
|
238
243
|
}
|
|
239
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Documents that would only say "nothing found" are not written. A library has no GraphQL
|
|
247
|
+
* schema and no routes, so those two pages would be noise. A document the config lists
|
|
248
|
+
* explicitly in `documentation.sections` is always written.
|
|
249
|
+
*/
|
|
250
|
+
export function emptyDocumentReason(docPlan, { config, scanResult, graphqlResult }) {
|
|
251
|
+
if (config?.documentation?.sections?.includes(docPlan.key)) return null;
|
|
252
|
+
if (docPlan.key === "graphql_schema" && !graphqlResult?.detected) {
|
|
253
|
+
return "no GraphQL schema detected";
|
|
254
|
+
}
|
|
255
|
+
if (docPlan.key === "route_map" && !(scanResult?.pages?.length) && !(scanResult?.api?.length)) {
|
|
256
|
+
return "no pages or API routes detected";
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
240
261
|
async function generateDocument(docPlan, context) {
|
|
241
262
|
const { key } = docPlan;
|
|
242
263
|
const { scanResult, config, aiContext, moduleContext, flows, diffData, graphqlResult, tsResult, jsdocResult, depGraph, driftResult, securityResult, codeHealthResult, ownershipMap, pluginManager } = context;
|
package/src/renderers/render.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computeModuleLevelMetrics, describeModuleDepRole } from "../analyzers/context-builder.js";
|
|
1
|
+
import { computeModuleLevelMetrics, describeModuleDepRole, isCliProject, isLibraryProject } from "../analyzers/context-builder.js";
|
|
2
2
|
import { moduleEvidence, pathHasKeyword } from "../analyzers/domain-inference.js";
|
|
3
3
|
|
|
4
4
|
export function renderSystemOverview(cfg, scan, depGraph = null) {
|
|
@@ -447,8 +447,8 @@ export function renderRouteMap(cfg, scan, aiContext = null) {
|
|
|
447
447
|
if (!scan.pages?.length && !scan.api?.length) {
|
|
448
448
|
// Determine project type for context-aware messaging
|
|
449
449
|
const patterns = aiContext?.patterns || [];
|
|
450
|
-
const isCLI =
|
|
451
|
-
const isLibrary = patterns.some(p => p.toLowerCase().includes("library") || p.toLowerCase().includes("shared"));
|
|
450
|
+
const isCLI = isCliProject(aiContext);
|
|
451
|
+
const isLibrary = isLibraryProject(aiContext) || patterns.some(p => p.toLowerCase().includes("library") || p.toLowerCase().includes("shared"));
|
|
452
452
|
const techStack = aiContext?.techStack || {};
|
|
453
453
|
const hasWebFramework = (techStack.frameworks || []).some(f =>
|
|
454
454
|
/next|react|vue|angular|express|fastify|hono|koa|django|flask|rails|spring/i.test(f)
|
|
@@ -343,7 +343,11 @@ export function renderDependencyGraph(depResult) {
|
|
|
343
343
|
lines.push(`|--------|-------|`);
|
|
344
344
|
lines.push(`| Source files | ${s.totalFiles} |`);
|
|
345
345
|
lines.push(`| Import edges | ${s.totalEdges} |`);
|
|
346
|
-
|
|
346
|
+
if (Array.isArray(depResult.externalDev) && depResult.externalDev.length > 0) {
|
|
347
|
+
lines.push(`| External packages | ${s.externalDeps} (${depResult.externalRuntime.length} runtime, ${depResult.externalDev.length} dev/test tooling) |`);
|
|
348
|
+
} else {
|
|
349
|
+
lines.push(`| External packages | ${s.externalDeps} |`);
|
|
350
|
+
}
|
|
347
351
|
lines.push(`| Circular dependencies | ${s.cycles} |`);
|
|
348
352
|
lines.push(`| Orphan files (no imports or importers) | ${s.orphanFiles} |`);
|
|
349
353
|
lines.push("");
|
|
@@ -352,7 +356,7 @@ export function renderDependencyGraph(depResult) {
|
|
|
352
356
|
if (s.hubs.length > 0) {
|
|
353
357
|
lines.push("## Hub Modules");
|
|
354
358
|
lines.push("");
|
|
355
|
-
lines.push("These are the most-imported modules
|
|
359
|
+
lines.push("These are the most-imported product modules (tests, examples, and build scripts are not counted). Changes to hub modules have the widest blast radius and should be reviewed carefully:");
|
|
356
360
|
lines.push("");
|
|
357
361
|
lines.push("| Module | Imported By |");
|
|
358
362
|
lines.push("|--------|-------------|");
|
|
@@ -384,14 +388,29 @@ export function renderDependencyGraph(depResult) {
|
|
|
384
388
|
lines.push("");
|
|
385
389
|
lines.push(`The codebase imports **${depResult.externalDeps.length}** external package${depResult.externalDeps.length === 1 ? "" : "s"}. These are third-party modules (not the language standard library):`);
|
|
386
390
|
lines.push("");
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
391
|
+
const split = Array.isArray(depResult.externalDev) && depResult.externalDev.length > 0;
|
|
392
|
+
if (split) {
|
|
393
|
+
const renderTable = (title, names) => {
|
|
394
|
+
if (names.length === 0) return;
|
|
395
|
+
lines.push(`### ${title}`);
|
|
396
|
+
lines.push("");
|
|
397
|
+
lines.push("| Package |");
|
|
398
|
+
lines.push("|---------|");
|
|
399
|
+
for (const dep of [...names].sort()) lines.push(`| \`${dep}\` |`);
|
|
400
|
+
lines.push("");
|
|
401
|
+
};
|
|
402
|
+
renderTable(`Runtime (${depResult.externalRuntime.length})`, depResult.externalRuntime);
|
|
403
|
+
renderTable(`Development and test tooling (${depResult.externalDev.length})`, depResult.externalDev);
|
|
404
|
+
} else {
|
|
405
|
+
// Render as a compact table for cleaner output
|
|
406
|
+
const sorted = [...depResult.externalDeps].sort();
|
|
407
|
+
lines.push("| Package |");
|
|
408
|
+
lines.push("|---------|");
|
|
409
|
+
for (const dep of sorted) {
|
|
410
|
+
lines.push(`| \`${dep}\` |`);
|
|
411
|
+
}
|
|
412
|
+
lines.push("");
|
|
393
413
|
}
|
|
394
|
-
lines.push("");
|
|
395
414
|
}
|
|
396
415
|
|
|
397
416
|
lines.push("---");
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Path roles: is a file test code, or tooling/example code rather than the product?
|
|
2
|
+
// Shared by the dependency graph, flow inference, pattern inference, and security scan
|
|
3
|
+
// so tests and examples never drive hub lists, coupling advice, or severity.
|
|
4
|
+
|
|
5
|
+
const TEST_DIRECTORIES = new Set([
|
|
6
|
+
"test", "tests", "spec", "specs", "__tests__", "__mocks__", "fixtures", "testdata", "e2e", "cypress",
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
// Directories holding tooling, samples, and docs. Code in here describes a build script
|
|
10
|
+
// or an example, not how the product works.
|
|
11
|
+
export const TOOLING_DIRECTORIES = new Set([
|
|
12
|
+
"doc", "docs", "doc-gen", "docgen", "scripts", "script", "tools", "tooling",
|
|
13
|
+
"example", "examples", "demo", "demos", "sample", "samples",
|
|
14
|
+
"benchmark", "benchmarks", "bench", "fixtures", "tasks",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function segments(filePath) {
|
|
18
|
+
return String(filePath || "").replace(/\\/g, "/").toLowerCase().split("/");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Directory part of a path. A last segment without an extension (`test`, `examples/amd`)
|
|
22
|
+
// is a directory key such as a module, so it counts too.
|
|
23
|
+
function directorySegments(filePath) {
|
|
24
|
+
const parts = segments(filePath);
|
|
25
|
+
const last = parts[parts.length - 1] || "";
|
|
26
|
+
return /\.[a-z0-9]+$/.test(last) ? parts.slice(0, -1) : parts;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True for files in a test directory or named like a test (`a.test.js`, `a_test.go`, `test_a.py`). */
|
|
30
|
+
export function isTestPath(filePath) {
|
|
31
|
+
const parts = segments(filePath);
|
|
32
|
+
if (directorySegments(filePath).some((segment) => TEST_DIRECTORIES.has(segment))) return true;
|
|
33
|
+
const base = parts[parts.length - 1] || "";
|
|
34
|
+
if (/^(test_|spec_)/.test(base)) return true;
|
|
35
|
+
if (/(_test|_spec)\.[a-z0-9]+$/.test(base)) return true;
|
|
36
|
+
if (/\.(test|spec)\.[a-z0-9]+$/.test(base)) return true;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True when a directory above the file is a tooling, example, or docs directory. */
|
|
41
|
+
export function isToolingPath(filePath) {
|
|
42
|
+
return directorySegments(filePath).some((segment) => TOOLING_DIRECTORIES.has(segment));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build-time or example code: runs on a maintainer's machine or in a browser demo, not in
|
|
47
|
+
* the shipped library. Includes `build*.js` scripts and anything under a tooling directory.
|
|
48
|
+
*/
|
|
49
|
+
export function isBuildOrExamplePath(filePath) {
|
|
50
|
+
if (isToolingPath(filePath)) return true;
|
|
51
|
+
const base = segments(filePath).pop() || "";
|
|
52
|
+
return /^build([-_.]|$)/.test(base);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Test code or tooling/example code: not part of the product's own architecture. */
|
|
56
|
+
export function isNonProductPath(filePath) {
|
|
57
|
+
return isTestPath(filePath) || isToolingPath(filePath);
|
|
58
|
+
}
|