@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/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
- languages.add(extensionMap[ext]);
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
- if (allDeps["typescript"]) metadata.languages.add("TypeScript");
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;
@@ -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 = patterns.some(p => p.toLowerCase().includes("cli"));
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
- lines.push(`| External packages | ${s.externalDeps} |`);
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 in the codebase. Changes to hub modules have the widest blast radius and should be reviewed carefully:");
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
- // Render as a compact table for cleaner output
388
- const sorted = [...depResult.externalDeps].sort();
389
- lines.push("| Package |");
390
- lines.push("|---------|");
391
- for (const dep of sorted) {
392
- lines.push(`| \`${dep}\` |`);
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
+ }