@chappibunny/repolens 1.16.2 → 1.16.4

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.
@@ -0,0 +1,105 @@
1
+ // Workspace members declared by the repository: root package.json `workspaces`
2
+ // (npm / yarn / bun), pnpm-workspace.yaml `packages`, and lerna.json `packages`.
3
+ // Only declared members are internal packages for the dependency graph. A stray
4
+ // package.json (a test stub named `express`, a fixture, a vendored copy) is not.
5
+
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import fg from "fast-glob";
9
+ import { loadYaml } from "../utils/yaml.js";
10
+
11
+ async function readJson(file) {
12
+ try {
13
+ return JSON.parse(await fs.readFile(file, "utf8"));
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ function cleanPattern(raw) {
20
+ if (typeof raw !== "string") return null;
21
+ let p = raw.trim();
22
+ if (!p || p.includes("\0")) return null;
23
+ const negated = p.startsWith("!");
24
+ if (negated) p = p.slice(1).trim();
25
+ p = p.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
26
+ if (!p || p.startsWith("/") || /^[A-Za-z]:/.test(p)) return null;
27
+ if (p.split("/").includes("..")) return null;
28
+ return { pattern: p, negated };
29
+ }
30
+
31
+ async function declaredPatterns(repoRoot) {
32
+ const sources = [];
33
+ const patterns = [];
34
+
35
+ const rootPkg = await readJson(path.join(repoRoot, "package.json"));
36
+ if (rootPkg?.workspaces) {
37
+ const list = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : rootPkg.workspaces.packages;
38
+ if (Array.isArray(list) && list.length > 0) {
39
+ sources.push("package.json workspaces");
40
+ patterns.push(...list);
41
+ }
42
+ }
43
+
44
+ try {
45
+ const doc = loadYaml(await fs.readFile(path.join(repoRoot, "pnpm-workspace.yaml"), "utf8"));
46
+ if (Array.isArray(doc?.packages) && doc.packages.length > 0) {
47
+ sources.push("pnpm-workspace.yaml");
48
+ patterns.push(...doc.packages);
49
+ }
50
+ } catch {
51
+ // no pnpm workspace
52
+ }
53
+
54
+ const lerna = await readJson(path.join(repoRoot, "lerna.json"));
55
+ if (lerna) {
56
+ const list = Array.isArray(lerna.packages) && lerna.packages.length > 0 ? lerna.packages : ["packages/*"];
57
+ sources.push("lerna.json");
58
+ patterns.push(...list);
59
+ }
60
+
61
+ return { sources, patterns };
62
+ }
63
+
64
+ /**
65
+ * @returns {Promise<{ sources: string[], packages: { name: string, dir: string, manifest: object }[] }>}
66
+ * `dir` is repository-relative with forward slashes ("" never occurs: the root is not a member).
67
+ */
68
+ export async function listWorkspacePackages(repoRoot) {
69
+ const { sources, patterns } = await declaredPatterns(repoRoot);
70
+ const positive = [];
71
+ const negative = [];
72
+ for (const raw of patterns) {
73
+ const cleaned = cleanPattern(raw);
74
+ if (!cleaned) continue;
75
+ (cleaned.negated ? negative : positive).push(cleaned.pattern);
76
+ }
77
+ if (positive.length === 0) return { sources, packages: [] };
78
+
79
+ let manifests = [];
80
+ try {
81
+ manifests = await fg(positive.map((p) => `${p}/package.json`), {
82
+ cwd: repoRoot,
83
+ dot: true,
84
+ onlyFiles: true,
85
+ followSymbolicLinks: false,
86
+ unique: true,
87
+ ignore: ["**/node_modules/**", ...negative.map((p) => `${p}/package.json`), ...negative.map((p) => `${p}/**`)],
88
+ });
89
+ } catch {
90
+ manifests = [];
91
+ }
92
+
93
+ const byName = new Map();
94
+ for (const rel of manifests.sort()) {
95
+ const dir = path.posix.dirname(rel.replace(/\\/g, "/"));
96
+ if (!dir || dir === ".") continue;
97
+ const manifest = await readJson(path.join(repoRoot, rel));
98
+ if (!manifest || typeof manifest.name !== "string" || !manifest.name) continue;
99
+ const existing = byName.get(manifest.name);
100
+ if (!existing || dir.split("/").length < existing.dir.split("/").length) {
101
+ byName.set(manifest.name, { name: manifest.name, dir, manifest });
102
+ }
103
+ }
104
+ return { sources, packages: [...byName.values()] };
105
+ }
package/src/badge.js CHANGED
@@ -2,11 +2,17 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { analyzeComplexity, computeCodeHealth } from "./analyzers/complexity-analyzer.js";
4
4
  import { analyzeDependencyGraph } from "./analyzers/dependency-graph.js";
5
+ import { badgeCoverageOk, coverageLine } from "./analyzers/language-coverage.js";
5
6
  import { analyzeJSDoc } from "./analyzers/jsdoc-analyzer.js";
6
7
  import { analyzeSecurityPatterns } from "./analyzers/security-patterns.js";
7
8
  import { GALLERY_URL, REPO_URL } from "./delivery/comment-constants.js";
8
9
 
9
10
  const SOURCE_EXT = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py", ".pyw", ".go"]);
11
+ const OTHER_SOURCE_EXT = new Set([
12
+ ".rs", ".java", ".kt", ".kts", ".scala", ".rb", ".php", ".cs", ".swift",
13
+ ".c", ".cc", ".cpp", ".h", ".hpp", ".vue", ".svelte",
14
+ ]);
15
+ const MAX_SOURCE_FILES = 20000;
10
16
  const SKIP_DIR = new Set([
11
17
  "node_modules", ".git", "dist", "build", "vendor", "target", "coverage",
12
18
  ".repolens", "__pycache__", ".cache",
@@ -119,15 +125,19 @@ export function compactHealth(health) {
119
125
  };
120
126
  }
121
127
 
122
- export async function listScoreFiles(root, { maxFiles = 2500 } = {}) {
123
- const out = [];
128
+ /**
129
+ * Walk the tree once. Up to `maxFiles` scored files are analyzed, but every source file
130
+ * (scored languages and others alike) is counted up to `maxSourceFiles`, with the same
131
+ * skip rules, so the language-coverage decision never compares a capped sample of one
132
+ * language against an uncapped count of another. `coverageTruncated` is true when that
133
+ * shared limit was hit, and the ratio is then an estimate.
134
+ */
135
+ export async function listScoreFiles(root, { maxFiles = 2500, maxSourceFiles = MAX_SOURCE_FILES } = {}) {
136
+ const scored = [];
137
+ const others = [];
124
138
  const stack = [root];
125
- let capped = false;
126
- while (stack.length) {
127
- if (out.length >= maxFiles) {
128
- capped = true;
129
- break;
130
- }
139
+ let coverageTruncated = false;
140
+ walk: while (stack.length) {
131
141
  const dir = stack.pop();
132
142
  let entries;
133
143
  try {
@@ -146,33 +156,47 @@ export async function listScoreFiles(root, { maxFiles = 2500 } = {}) {
146
156
  }
147
157
  if (!entry.isFile()) continue;
148
158
  if (entry.name.endsWith(".snap")) continue;
149
- if (!SOURCE_EXT.has(path.extname(entry.name))) continue;
150
- if (out.length >= maxFiles) {
151
- capped = true;
152
- break;
159
+ const ext = path.extname(entry.name);
160
+ const isScored = SOURCE_EXT.has(ext);
161
+ if (!isScored && !OTHER_SOURCE_EXT.has(ext.toLowerCase())) continue;
162
+ if (scored.length + others.length >= maxSourceFiles) {
163
+ coverageTruncated = true;
164
+ break walk;
153
165
  }
154
166
  const rel = path.relative(root, full).replace(/\\/g, "/");
155
- out.push(rel);
167
+ (isScored ? scored : others).push(rel);
156
168
  }
157
169
  }
158
- out.sort();
159
- return { files: out, capped };
170
+ const capped = scored.length > maxFiles || coverageTruncated;
171
+ const files = scored.slice(0, maxFiles).sort();
172
+ return {
173
+ files,
174
+ capped,
175
+ others: others.sort(),
176
+ coverageFiles: [...scored, ...others],
177
+ coverageTruncated,
178
+ };
160
179
  }
161
180
 
162
181
  export async function scoreRepository(root, { files, maxFiles = 2500 } = {}) {
163
182
  let list = files;
164
183
  let capped = false;
184
+ let coverageFiles = files;
185
+ let coverageTruncated = false;
165
186
  if (!list) {
166
187
  const walked = await listScoreFiles(root, { maxFiles });
167
188
  list = walked.files;
168
189
  capped = walked.capped;
190
+ coverageFiles = walked.coverageFiles;
191
+ coverageTruncated = walked.coverageTruncated;
169
192
  }
170
- const complexity = await analyzeComplexity(list, root);
171
- const depGraph = await analyzeDependencyGraph(list, root);
193
+ // Coverage is judged on every counted source file; only `list` is analyzed.
194
+ const complexity = await analyzeComplexity(list, root, { coverageFiles });
195
+ const depGraph = await analyzeDependencyGraph(list, root, { coverageFiles });
172
196
  const jsdoc = await analyzeJSDoc(list, root);
173
197
  const security = await analyzeSecurityPatterns(list, root);
174
198
  const health = computeCodeHealth(complexity, depGraph, jsdoc, security);
175
- return { health, depGraph, files: list, capped };
199
+ return { health, depGraph, files: list, capped, coverageTruncated };
176
200
  }
177
201
 
178
202
  function resolveOutPaths(outPath) {
@@ -228,6 +252,23 @@ export async function runBadgeCli({
228
252
  return 1;
229
253
  }
230
254
  const total = scored.health?.stats?.totalFiles || 0;
255
+ const coverage = scored.health?.coverage;
256
+ if (scored.coverageTruncated) {
257
+ console.error(`Note: language coverage was estimated from the first ${MAX_SOURCE_FILES} source files.`);
258
+ }
259
+ if (coverage && !badgeCoverageOk(coverage)) {
260
+ if (coverage.status === "unsupported") {
261
+ console.error(`Not scored: ${coverageLine(coverage, "code health")}`);
262
+ } else {
263
+ const pct = Math.round((coverage.share || 0) * 100);
264
+ console.error(`Not scored: only ${coverage.analyzedBase} of ${coverage.totalBase} product source files (${pct}%) are ${"JavaScript/TypeScript, Python, or Go"}; ${coverageLine(coverage, "code health")}`);
265
+ }
266
+ console.error("No badge was printed: one score would not stand for this repository.");
267
+ return 2;
268
+ }
269
+ if (coverage?.status === "partial") {
270
+ console.error(`Partial coverage: ${coverageLine(coverage, "code health")}`);
271
+ }
231
272
  if (!total) {
232
273
  console.error("No source files analyzed.");
233
274
  console.error("Code health covers JavaScript, TypeScript, Python, and Go. Empty documentation was not scored.");
package/src/core/scan.js CHANGED
@@ -186,6 +186,7 @@ function packageEntryFiles(pkg) {
186
186
  */
187
187
  function detectLanguagesFromFiles(files) {
188
188
  const languages = new Set();
189
+ const counts = new Map();
189
190
 
190
191
  // Extension to language mapping
191
192
  const extensionMap = {
@@ -241,9 +242,12 @@ function detectLanguagesFromFiles(files) {
241
242
  // types. They do not make the library a TypeScript project.
242
243
  if (ext === ".ts" && /\.d\.ts$/i.test(file)) continue;
243
244
  if (extensionMap[ext] === "TypeScript" && isTestPath(file)) continue;
244
- languages.add(extensionMap[ext]);
245
+ const label = extensionMap[ext];
246
+ counts.set(label, (counts.get(label) || 0) + 1);
245
247
  }
246
248
 
249
+ // Most files first, so a C# repository with one docs script reads "C#, JavaScript".
250
+ for (const [label] of [...counts.entries()].sort((a, b) => b[1] - a[1])) languages.add(label);
247
251
  return languages;
248
252
  }
249
253
 
@@ -406,9 +410,8 @@ async function extractRepoMetadata(repoRoot, files = []) {
406
410
 
407
411
  // Merge languages detected from file extensions
408
412
  const fileLanguages = detectLanguagesFromFiles(files);
409
- for (const lang of fileLanguages) {
410
- metadata.languages.add(lang);
411
- }
413
+ const manifestLanguages = [...metadata.languages];
414
+ metadata.languages = new Set([...fileLanguages, ...manifestLanguages]);
412
415
 
413
416
  // De-duplicate frameworks (in case detected from multiple sources)
414
417
  metadata.frameworks = [...new Set(metadata.frameworks)];
@@ -589,6 +592,7 @@ export async function scanRepo(cfg) {
589
592
  }
590
593
 
591
594
  const moduleCounts = new Map();
595
+ const moduleTestCounts = new Map();
592
596
  const flatKeys = new Set();
593
597
  const rootLabel = rootModuleLabel(repoRoot);
594
598
 
@@ -600,12 +604,14 @@ export async function scanRepo(cfg) {
600
604
  const key = moduleKeyForFile(file, cfg.module_roots || [], rootLabel);
601
605
  if (!norm(file).includes("/")) flatKeys.add(key);
602
606
  moduleCounts.set(key, (moduleCounts.get(key) || 0) + 1);
607
+ if (isTestPath(file)) moduleTestCounts.set(key, (moduleTestCounts.get(key) || 0) + 1);
603
608
  }
604
609
 
605
610
  const modules = [...moduleCounts.entries()]
606
611
  .map(([key, fileCount]) => ({
607
612
  key,
608
613
  fileCount,
614
+ ...(moduleTestCounts.get(key) ? { testFileCount: moduleTestCounts.get(key) } : {}),
609
615
  ...(flatKeys.has(key) ? { flat: true } : {}),
610
616
  }))
611
617
  .sort((a, b) => b.fileCount - a.fileCount);
@@ -130,7 +130,7 @@ 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 });
133
+ const emptyReason = emptyDocumentReason(docPlan, { config, scanResult, graphqlResult, jsdocResult });
134
134
  if (emptyReason) {
135
135
  info(`[${docIndex}/${totalDocs}] Skipping ${docPlan.key}: ${emptyReason}`);
136
136
  continue;
@@ -244,10 +244,11 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
244
244
 
245
245
  /**
246
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
247
+ * schema and no routes, so those two pages would be noise, and a CLI or library with no
248
+ * routes, no external API clients, and no documented exports has no API Surface to show. A document the config lists
248
249
  * explicitly in `documentation.sections` is always written.
249
250
  */
250
- export function emptyDocumentReason(docPlan, { config, scanResult, graphqlResult }) {
251
+ export function emptyDocumentReason(docPlan, { config, scanResult, graphqlResult, jsdocResult = null }) {
251
252
  if (config?.documentation?.sections?.includes(docPlan.key)) return null;
252
253
  if (docPlan.key === "graphql_schema" && !graphqlResult?.detected) {
253
254
  return "no GraphQL schema detected";
@@ -255,9 +256,16 @@ export function emptyDocumentReason(docPlan, { config, scanResult, graphqlResult
255
256
  if (docPlan.key === "route_map" && !(scanResult?.pages?.length) && !(scanResult?.api?.length)) {
256
257
  return "no pages or API routes detected";
257
258
  }
259
+ if (docPlan.key === "api_surface" && !(scanResult?.api?.length) && !(scanResult?.externalApis?.length) && !hasDocumentedExports(jsdocResult)) {
260
+ return "no API routes, external API integrations, or documented exports detected";
261
+ }
258
262
  return null;
259
263
  }
260
264
 
265
+ function hasDocumentedExports(jsdocResult) {
266
+ return Boolean(jsdocResult?.detected && Array.isArray(jsdocResult.exports) && jsdocResult.exports.length > 0);
267
+ }
268
+
261
269
  async function generateDocument(docPlan, context) {
262
270
  const { key } = docPlan;
263
271
  const { scanResult, config, aiContext, moduleContext, flows, diffData, graphqlResult, tsResult, jsdocResult, depGraph, driftResult, securityResult, codeHealthResult, ownershipMap, pluginManager } = context;
package/src/help.js CHANGED
@@ -32,6 +32,8 @@ It detects Python, Go, Rust, Java/Kotlin, JavaScript/TypeScript, Ruby, PHP,
32
32
  C#, Swift, and C/C++ (and combinations) and sets scan.include plus
33
33
  module_roots from the files it finds. A repository with no recognized
34
34
  language gets multi-language globs, not JavaScript-only defaults.
35
+ Detection is not full analysis: the dependency graph and code health read
36
+ JavaScript/TypeScript, Python, and Go only.
35
37
 
36
38
  The target directory must already exist.
37
39
 
@@ -129,9 +131,12 @@ Options:
129
131
  --help, -h Show this help
130
132
 
131
133
  With no config, demo detects the repository language and scans matching
132
- files (Python, Go, Rust, Java, JavaScript, and others). It does not publish
133
- to Notion or Confluence. A scan that matches 0 files exits 2 and does not
134
- write empty docs.
134
+ files (Python, Go, Rust, Java, JavaScript, and others) for modules and file
135
+ structure. The dependency graph and code health cover JavaScript/TypeScript,
136
+ Python, and Go only; for other languages (for example Java, Kotlin, or C#)
137
+ those pages say the language is not supported yet instead of showing a
138
+ score. It does not publish to Notion or Confluence. A scan that matches
139
+ 0 files exits 2 and does not write empty docs.
135
140
 
136
141
  The target directory must already exist.
137
142
 
@@ -151,6 +151,18 @@ function describeModule(key, depRole = null, evidence = {}) {
151
151
  return base;
152
152
  }
153
153
 
154
+ // A module whose files are mostly tests and fixtures is not "Application module".
155
+ // The file count stays the same; the role says how much of it is test code.
156
+ export function describeModuleWithTests(module, desc) {
157
+ const total = Number(module?.fileCount) || 0;
158
+ const tests = Number(module?.testFileCount) || 0;
159
+ if (!total || tests * 2 < total) return desc;
160
+ if (/^Test suites and fixtures/.test(desc)) return desc;
161
+ const role = desc.replace(/^Application module( · )?/, "");
162
+ const base = `Mostly tests and fixtures (${tests} of ${total} files)`;
163
+ return role ? `${base} · ${role}` : base;
164
+ }
165
+
154
166
  export function renderModuleCatalog(cfg, scan, ownershipMap = {}, depGraph = null) {
155
167
  const hasOwnership = Object.keys(ownershipMap).length > 0;
156
168
  const moduleMetrics = computeModuleLevelMetrics(depGraph, scan.modules);
@@ -192,7 +204,7 @@ export function renderModuleCatalog(cfg, scan, ownershipMap = {}, depGraph = nul
192
204
 
193
205
  for (const module of scan.modules.slice(0, 100)) {
194
206
  const depRole = describeModuleDepRole(module.key, moduleMetrics);
195
- const desc = describeModule(module.key, depRole, moduleEvidence(module.key, depGraph));
207
+ const desc = describeModuleWithTests(module, describeModule(module.key, depRole, moduleEvidence(module.key, depGraph)));
196
208
  const owners = ownershipMap[module.key];
197
209
  if (hasOwnership) {
198
210
  lines.push(`| \`${module.key}\` | ${module.fileCount} | ${desc} | ${owners ? owners.join(", ") : "—"} |`);
@@ -236,7 +248,7 @@ export function renderApiSurface(cfg, scan, jsdocResult = null) {
236
248
 
237
249
  if (!scan.api.length) {
238
250
  lines.push(
239
- `No API routes were detected in this scan. If your project uses a routing framework (Express, Fastify, NestJS, etc.), ensure the relevant directories are included in your \`scan.include\` patterns.`,
251
+ `No HTTP API routes were detected. RepoLens reads Next.js API routes and Express-style \`app.get(...)\` / \`router.post(...)\` handlers in JavaScript and TypeScript; libraries and CLIs usually have none.`,
240
252
  ``
241
253
  );
242
254
  } else {
@@ -4,6 +4,8 @@
4
4
  // - Dependency Graph
5
5
  // - Architecture Drift
6
6
 
7
+ import { coverageNotice } from "../analyzers/language-coverage.js";
8
+
7
9
  export function renderGraphQLSchema(graphqlResult) {
8
10
  if (!graphqlResult?.detected) {
9
11
  return [
@@ -316,6 +318,10 @@ export function renderTypeGraph(tsResult) {
316
318
  }
317
319
 
318
320
  export function renderDependencyGraph(depResult) {
321
+ const coverageLines = coverageNotice(depResult?.coverage, "import analysis");
322
+ if (!depResult?.nodes?.length && depResult?.coverage?.status === "unsupported") {
323
+ return ["# Dependency Graph", "", ...coverageLines].join("\n");
324
+ }
319
325
  if (!depResult?.nodes?.length) {
320
326
  return [
321
327
  "# Dependency Graph",
@@ -332,6 +338,7 @@ export function renderDependencyGraph(depResult) {
332
338
  lines.push("");
333
339
  lines.push(`> ${depResult.summary}`);
334
340
  lines.push("");
341
+ lines.push(...coverageLines);
335
342
  lines.push("This document maps every import relationship in the codebase, identifies the most-connected modules, and flags circular dependencies that may complicate refactoring or increase build times.");
336
343
  lines.push("");
337
344
 
@@ -349,6 +356,8 @@ export function renderDependencyGraph(depResult) {
349
356
  lines.push(`| External packages | ${s.externalDeps} |`);
350
357
  }
351
358
  lines.push(`| Circular dependencies | ${s.cycles} |`);
359
+ if (s.typeOnlyCycles > 0) lines.push(`| Type-only cycles (not runtime, not counted) | ${s.typeOnlyCycles} |`);
360
+ if (s.typeOnlyEdges > 0) lines.push(`| Type-only imports (not import edges) | ${s.typeOnlyEdges} |`);
352
361
  lines.push(`| Orphan files (no imports or importers) | ${s.orphanFiles} |`);
353
362
  lines.push("");
354
363
 
@@ -388,9 +397,27 @@ export function renderDependencyGraph(depResult) {
388
397
  }
389
398
 
390
399
  if (Array.isArray(depResult.typeOnlyReports) && depResult.typeOnlyReports.length > 0) {
400
+ const reports = depResult.typeOnlyReports;
391
401
  lines.push("## Type-only cycles");
392
402
  lines.push("");
393
- lines.push(`**${depResult.typeOnlyReports.length}** cycle${depResult.typeOnlyReports.length === 1 ? " exists" : "s exist"} only through \`import type\` / \`export type\`. They are not runtime dependencies. Priority: Low — type-only imports; verify.`);
403
+ lines.push(`**${reports.length}** cycle${reports.length === 1 ? " closes" : "s close"} only through type-only imports. The compiler removes those imports, so there is no runtime cycle. They are not counted as circular dependencies and do not lower the health score. Priority: Low — type-only imports; verify.`);
404
+ lines.push("");
405
+ lines.push("A type-only import is `import type`, `export type`, or an import list whose names are all marked `type` (*syntax*). In TypeScript files it is also an import whose names are interfaces or type aliases, or are used only in type positions (*inferred*). Inference is off under `verbatimModuleSyntax`, `preserveValueImports`, `importsNotUsedAsValues: \"preserve\"`, or `erasableSyntaxOnly`, which keep the import at runtime.");
406
+ lines.push("");
407
+ const limit = Math.min(reports.length, 20);
408
+ for (let i = 0; i < limit; i++) {
409
+ const report = reports[i];
410
+ const pathText = Array.isArray(report.path) ? report.path.join("` → `") : String(report.path);
411
+ lines.push(`${i + 1}. \`${pathText}\``);
412
+ const links = Array.isArray(report.typeOnlyLinks) ? report.typeOnlyLinks : [];
413
+ if (links.length > 0) {
414
+ const text = links.map((link) => `\`${link.from}\` → \`${link.to}\` (${link.reason === "inferred" ? "inferred" : "syntax"})`).join("; ");
415
+ lines.push(` Type-only link${links.length === 1 ? "" : "s"}: ${text}`);
416
+ }
417
+ }
418
+ if (reports.length > 20) {
419
+ lines.push(`\n*...and ${reports.length - 20} more type-only cycles*`);
420
+ }
394
421
  lines.push("");
395
422
  }
396
423
 
@@ -427,7 +454,7 @@ export function renderDependencyGraph(depResult) {
427
454
 
428
455
  lines.push("---");
429
456
  lines.push("");
430
- lines.push("*Generated by RepoLens import analysis. Dependency detection covers JavaScript and TypeScript (ES modules, dynamic import, CommonJS, re-exports; comments, strings, type-only imports, and Node.js built-ins are skipped), Python (`import` / `from ... import`, including relative imports; `TYPE_CHECKING` blocks are skipped), and Go (`go.mod` module paths, hubs grouped by package). Go `*_test.go` files are not part of the graph.*");
457
+ lines.push("*Generated by RepoLens import analysis. Dependency detection covers JavaScript and TypeScript (ES modules, dynamic import, CommonJS, re-exports; comments, strings, and Node.js built-ins are skipped; type-only imports, including TypeScript imports whose names are used only as types, are not import edges), Python (`import` / `from ... import`, including relative imports; `TYPE_CHECKING` blocks are skipped), and Go (`go.mod` module paths, hubs grouped by package). Go `*_test.go` files are not part of the graph.*");
431
458
  lines.push("");
432
459
 
433
460
  return lines.join("\n");
@@ -526,6 +553,10 @@ export function renderArchitectureDrift(driftResult) {
526
553
  }
527
554
 
528
555
  export function renderCodeHealth(healthResult) {
556
+ const coverageLines = coverageNotice(healthResult?.coverage, "code health");
557
+ if (!healthResult?.modules?.length && healthResult?.coverage?.status === "unsupported") {
558
+ return ["# Code Health Report", "", ...coverageLines].join("\n");
559
+ }
529
560
  if (!healthResult?.modules?.length) {
530
561
  return [
531
562
  "# Code Health Report",
@@ -544,6 +575,7 @@ export function renderCodeHealth(healthResult) {
544
575
  lines.push("");
545
576
  lines.push(`> ${healthResult.summary}`);
546
577
  lines.push("");
578
+ lines.push(...coverageLines);
547
579
  lines.push("This report synthesizes complexity, coupling, documentation, and security signals into a single health score per module. Lower scores indicate higher risk and maintenance burden.");
548
580
  lines.push("");
549
581
 
@@ -0,0 +1,146 @@
1
+ // Remove JavaScript/TypeScript comments while keeping every string, template literal
2
+ // (including `${...}` expressions, which may hold their own comments and strings),
3
+ // and regular-expression literal intact. Comment text becomes spaces; newlines stay,
4
+ // so offsets and line numbers still match the source.
5
+
6
+ const REGEX_PREFIX_WORDS = new Set([
7
+ "return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "throw",
8
+ "case", "do", "else", "yield", "await",
9
+ ]);
10
+
11
+ function blank(text) {
12
+ return text.replace(/[^\n]/g, " ");
13
+ }
14
+
15
+ function isIdentPart(ch) {
16
+ return /[A-Za-z0-9_$]/.test(ch || "");
17
+ }
18
+
19
+ export function stripJsComments(source) {
20
+ const src = String(source ?? "");
21
+ const n = src.length;
22
+ let out = "";
23
+ let i = 0;
24
+ // Stack of contexts: { type: "code", depth, inTemplate } or { type: "template" }.
25
+ const stack = [{ type: "code", depth: 0, inTemplate: false }];
26
+ // Last significant code token, to tell a regex literal from division.
27
+ let lastChar = "";
28
+ let lastWord = "";
29
+
30
+ while (i < n) {
31
+ const ctx = stack[stack.length - 1];
32
+ const ch = src[i];
33
+
34
+ if (ctx.type === "template") {
35
+ if (ch === "\\") {
36
+ out += src.slice(i, i + 2);
37
+ i += 2;
38
+ continue;
39
+ }
40
+ if (ch === "`") {
41
+ out += ch;
42
+ i += 1;
43
+ stack.pop();
44
+ lastChar = "`";
45
+ lastWord = "";
46
+ continue;
47
+ }
48
+ if (ch === "$" && src[i + 1] === "{") {
49
+ out += "${";
50
+ i += 2;
51
+ stack.push({ type: "code", depth: 0, inTemplate: true });
52
+ lastChar = "{";
53
+ lastWord = "";
54
+ continue;
55
+ }
56
+ out += ch;
57
+ i += 1;
58
+ continue;
59
+ }
60
+
61
+ // code context
62
+ const next = src[i + 1];
63
+ if (ch === "/" && next === "/") {
64
+ const nl = src.indexOf("\n", i);
65
+ const end = nl === -1 ? n : nl;
66
+ out += blank(src.slice(i, end));
67
+ i = end;
68
+ continue;
69
+ }
70
+ if (ch === "/" && next === "*") {
71
+ const close = src.indexOf("*/", i + 2);
72
+ const end = close === -1 ? n : close + 2;
73
+ out += blank(src.slice(i, end));
74
+ i = end;
75
+ continue;
76
+ }
77
+ if (ch === "'" || ch === '"') {
78
+ let j = i + 1;
79
+ while (j < n) {
80
+ if (src[j] === "\\") { j += 2; continue; }
81
+ if (src[j] === ch || src[j] === "\n") { j += 1; break; }
82
+ j += 1;
83
+ }
84
+ out += src.slice(i, Math.min(j, n));
85
+ i = Math.min(j, n);
86
+ lastChar = ch;
87
+ lastWord = "";
88
+ continue;
89
+ }
90
+ if (ch === "`") {
91
+ out += ch;
92
+ i += 1;
93
+ stack.push({ type: "template" });
94
+ continue;
95
+ }
96
+ if (ch === "/") {
97
+ const regexAllowed = lastChar === "" || "([{,;:!?&|~^=<>+-*%".includes(lastChar) || REGEX_PREFIX_WORDS.has(lastWord);
98
+ if (regexAllowed) {
99
+ let j = i + 1;
100
+ let inClass = false;
101
+ while (j < n) {
102
+ const c = src[j];
103
+ if (c === "\\") { j += 2; continue; }
104
+ if (c === "\n") break;
105
+ if (c === "[") inClass = true;
106
+ else if (c === "]") inClass = false;
107
+ else if (c === "/" && !inClass) { j += 1; break; }
108
+ j += 1;
109
+ }
110
+ while (j < n && /[a-z]/i.test(src[j])) j += 1;
111
+ out += src.slice(i, j);
112
+ i = j;
113
+ lastChar = ")";
114
+ lastWord = "";
115
+ continue;
116
+ }
117
+ }
118
+ if (ch === "{") ctx.depth += 1;
119
+ if (ch === "}") {
120
+ if (ctx.inTemplate && ctx.depth === 0) {
121
+ out += ch;
122
+ i += 1;
123
+ stack.pop();
124
+ continue;
125
+ }
126
+ ctx.depth -= 1;
127
+ }
128
+ if (isIdentPart(ch)) {
129
+ let j = i;
130
+ while (j < n && isIdentPart(src[j])) j += 1;
131
+ const word = src.slice(i, j);
132
+ out += word;
133
+ i = j;
134
+ lastWord = word;
135
+ lastChar = word[word.length - 1];
136
+ continue;
137
+ }
138
+ out += ch;
139
+ i += 1;
140
+ if (!/\s/.test(ch)) {
141
+ lastChar = ch;
142
+ lastWord = "";
143
+ }
144
+ }
145
+ return out;
146
+ }