@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.
- package/CHANGELOG.md +57 -0
- package/README.md +8 -8
- package/package.json +1 -1
- package/src/ai/generate-sections.js +23 -4
- package/src/analyzers/complexity-analyzer.js +17 -4
- package/src/analyzers/context-builder.js +2 -1
- package/src/analyzers/cycle-priority.js +3 -0
- package/src/analyzers/dependency-graph.js +265 -34
- package/src/analyzers/flow-inference.js +10 -1
- package/src/analyzers/graphql-analyzer.js +80 -32
- package/src/analyzers/import-parsers.js +47 -14
- package/src/analyzers/language-coverage.js +222 -0
- package/src/analyzers/ts-elision.js +921 -0
- package/src/analyzers/workspace-packages.js +105 -0
- package/src/badge.js +59 -18
- package/src/core/scan.js +10 -4
- package/src/docs/generate-doc-set.js +11 -3
- package/src/help.js +8 -3
- package/src/renderers/render.js +14 -2
- package/src/renderers/renderAnalysis.js +34 -2
- package/src/utils/js-lexer.js +146 -0
|
@@ -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
|
-
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
167
|
+
(isScored ? scored : others).push(rel);
|
|
156
168
|
}
|
|
157
169
|
}
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
171
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
|
|
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
|
|
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)
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
package/src/renderers/render.js
CHANGED
|
@@ -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
|
|
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(`**${
|
|
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,
|
|
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
|
+
}
|