@chappibunny/repolens 1.15.1 → 1.15.2

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/badge.js ADDED
@@ -0,0 +1,274 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { analyzeComplexity, computeCodeHealth } from "./analyzers/complexity-analyzer.js";
4
+ import { analyzeDependencyGraph } from "./analyzers/dependency-graph.js";
5
+ import { analyzeJSDoc } from "./analyzers/jsdoc-analyzer.js";
6
+ import { analyzeSecurityPatterns } from "./analyzers/security-patterns.js";
7
+ import { GALLERY_URL, REPO_URL } from "./delivery/comment-constants.js";
8
+
9
+ const SOURCE_EXT = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py", ".pyw", ".go"]);
10
+ const SKIP_DIR = new Set([
11
+ "node_modules", ".git", "dist", "build", "vendor", "target", "coverage",
12
+ ".repolens", "__pycache__", ".cache",
13
+ ]);
14
+
15
+ const COLOR_HEX = {
16
+ brightgreen: "#4c1",
17
+ green: "#97ca00",
18
+ yellow: "#dfb317",
19
+ orange: "#fe7d37",
20
+ red: "#e05d44",
21
+ lightgrey: "#9f9f9f",
22
+ };
23
+
24
+ export function healthColor(score) {
25
+ if (score == null || Number.isNaN(Number(score))) return "lightgrey";
26
+ const value = Number(score);
27
+ if (value >= 80) return "brightgreen";
28
+ if (value >= 60) return "green";
29
+ if (value >= 40) return "yellow";
30
+ if (value >= 20) return "orange";
31
+ return "red";
32
+ }
33
+
34
+ export function shieldsEndpoint({ score, label = "code health" } = {}) {
35
+ const message = score == null || Number.isNaN(Number(score)) ? "n/a" : String(Math.round(Number(score)));
36
+ return {
37
+ schemaVersion: 1,
38
+ label,
39
+ message,
40
+ color: healthColor(score),
41
+ };
42
+ }
43
+
44
+ function escapeXml(value) {
45
+ return String(value)
46
+ .replace(/&/g, "&")
47
+ .replace(/</g, "&lt;")
48
+ .replace(/>/g, "&gt;")
49
+ .replace(/"/g, "&quot;");
50
+ }
51
+
52
+ /**
53
+ * Flat square SVG badge. No rounded corners.
54
+ * The shields.io endpoint JSON is the portable form; this SVG is a direct embed.
55
+ */
56
+ export function badgeSvg({ label = "code health", message = "n/a", color = "lightgrey" } = {}) {
57
+ const labelText = String(label).slice(0, 40);
58
+ const valueText = String(message).slice(0, 24);
59
+ const char = 7;
60
+ const pad = 8;
61
+ const labelWidth = pad * 2 + labelText.length * char;
62
+ const valueWidth = pad * 2 + Math.max(valueText.length, 1) * char;
63
+ const height = 20;
64
+ const fill = COLOR_HEX[color] || COLOR_HEX.lightgrey;
65
+ const width = labelWidth + valueWidth;
66
+ return [
67
+ `<?xml version="1.0" encoding="UTF-8"?>`,
68
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" role="img" aria-label="${escapeXml(labelText)}: ${escapeXml(valueText)}">`,
69
+ ` <title>${escapeXml(labelText)}: ${escapeXml(valueText)}</title>`,
70
+ ` <rect width="${labelWidth}" height="${height}" fill="#1565C0"/>`,
71
+ ` <rect x="${labelWidth}" width="${valueWidth}" height="${height}" fill="${fill}"/>`,
72
+ ` <g fill="#ffffff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">`,
73
+ ` <text x="${labelWidth / 2}" y="14">${escapeXml(labelText)}</text>`,
74
+ ` <text x="${labelWidth + valueWidth / 2}" y="14">${escapeXml(valueText)}</text>`,
75
+ ` </g>`,
76
+ `</svg>`,
77
+ ``,
78
+ ].join("\n");
79
+ }
80
+
81
+ export function badgeMarkdown({
82
+ endpointUrl,
83
+ linkUrl = REPO_URL,
84
+ label = "code health",
85
+ name,
86
+ } = {}) {
87
+ const alt = name ? `${name} ${label}` : label;
88
+ const href = linkUrl || REPO_URL;
89
+ if (!endpointUrl) {
90
+ return `[![${alt}](https://img.shields.io/endpoint?url=BADGE_JSON_URL&style=flat-square)](${href})`;
91
+ }
92
+ const src = `https://img.shields.io/endpoint?url=${encodeURIComponent(endpointUrl)}&style=flat-square`;
93
+ return `[![${alt}](${src})](${href})`;
94
+ }
95
+
96
+ export function compactHealth(health) {
97
+ const files = health?.stats?.totalFiles || 0;
98
+ if (!files) {
99
+ return {
100
+ score: null,
101
+ files: 0,
102
+ summary: health?.summary || "No source files analyzed.",
103
+ grades: health?.stats?.gradeDistribution || null,
104
+ hotspots: [],
105
+ };
106
+ }
107
+ return {
108
+ score: health.stats.avgScore,
109
+ files,
110
+ summary: health.summary,
111
+ grades: health.stats.gradeDistribution,
112
+ hotspots: (health.hotspots || []).slice(0, 12).map((item) => ({
113
+ file: item.file,
114
+ score: item.score,
115
+ grade: item.grade,
116
+ })),
117
+ };
118
+ }
119
+
120
+ export async function listScoreFiles(root, { maxFiles = 2500 } = {}) {
121
+ const out = [];
122
+ const stack = [root];
123
+ let capped = false;
124
+ while (stack.length) {
125
+ if (out.length >= maxFiles) {
126
+ capped = true;
127
+ break;
128
+ }
129
+ const dir = stack.pop();
130
+ let entries;
131
+ try {
132
+ entries = await fs.readdir(dir, { withFileTypes: true });
133
+ } catch {
134
+ continue;
135
+ }
136
+ entries.sort((a, b) => a.name.localeCompare(b.name));
137
+ for (const entry of entries) {
138
+ if (entry.isSymbolicLink()) continue;
139
+ const full = path.join(dir, entry.name);
140
+ if (entry.isDirectory()) {
141
+ if (SKIP_DIR.has(entry.name)) continue;
142
+ stack.push(full);
143
+ continue;
144
+ }
145
+ if (!entry.isFile()) continue;
146
+ if (!SOURCE_EXT.has(path.extname(entry.name))) continue;
147
+ if (out.length >= maxFiles) {
148
+ capped = true;
149
+ break;
150
+ }
151
+ const rel = path.relative(root, full).replace(/\\/g, "/");
152
+ out.push(rel);
153
+ }
154
+ }
155
+ out.sort();
156
+ return { files: out, capped };
157
+ }
158
+
159
+ export async function scoreRepository(root, { files, maxFiles = 2500 } = {}) {
160
+ let list = files;
161
+ let capped = false;
162
+ if (!list) {
163
+ const walked = await listScoreFiles(root, { maxFiles });
164
+ list = walked.files;
165
+ capped = walked.capped;
166
+ }
167
+ const complexity = await analyzeComplexity(list, root);
168
+ const depGraph = await analyzeDependencyGraph(list, root);
169
+ const jsdoc = await analyzeJSDoc(list, root);
170
+ const security = await analyzeSecurityPatterns(list, root);
171
+ const health = computeCodeHealth(complexity, depGraph, jsdoc, security);
172
+ return { health, depGraph, files: list, capped };
173
+ }
174
+
175
+ function resolveOutPaths(outPath) {
176
+ const target = outPath || "repolens-badge.json";
177
+ if (target.endsWith("/") || target.endsWith(path.sep)) {
178
+ return {
179
+ jsonPath: path.join(target, "repolens-badge.json"),
180
+ svgPath: path.join(target, "repolens-badge.svg"),
181
+ };
182
+ }
183
+ if (target.endsWith(".svg")) {
184
+ return {
185
+ jsonPath: target.replace(/\.svg$/, ".json"),
186
+ svgPath: target,
187
+ };
188
+ }
189
+ if (target.endsWith(".json")) {
190
+ return {
191
+ jsonPath: target,
192
+ svgPath: target.replace(/\.json$/, ".svg"),
193
+ };
194
+ }
195
+ return {
196
+ jsonPath: path.join(target, "repolens-badge.json"),
197
+ svgPath: path.join(target, "repolens-badge.svg"),
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Print or write the code-health badge.
203
+ * Returns a process exit code. JSON goes to stdout unless --out is set.
204
+ */
205
+ export async function runBadgeCli({
206
+ target = process.cwd(),
207
+ format = "json",
208
+ out,
209
+ url = "",
210
+ link = "",
211
+ label = "code health",
212
+ name,
213
+ } = {}) {
214
+ process.env.REPOLENS_LOG_STDERR = "1";
215
+ const allowed = new Set(["json", "svg", "markdown", "all"]);
216
+ if (!allowed.has(format)) {
217
+ console.error("format must be json, svg, markdown, or all");
218
+ return 2;
219
+ }
220
+ let scored;
221
+ try {
222
+ scored = await scoreRepository(target);
223
+ } catch (error) {
224
+ console.error(error.message || String(error));
225
+ return 1;
226
+ }
227
+ const total = scored.health?.stats?.totalFiles || 0;
228
+ if (!total) {
229
+ console.error("No source files analyzed.");
230
+ console.error("Code health covers JavaScript, TypeScript, Python, and Go. Empty documentation was not scored.");
231
+ return 2;
232
+ }
233
+ const endpoint = shieldsEndpoint({ score: scored.health.stats.avgScore, label });
234
+ const markdown = badgeMarkdown({
235
+ endpointUrl: url || "",
236
+ linkUrl: link || GALLERY_URL,
237
+ label,
238
+ name,
239
+ });
240
+ const svg = badgeSvg(endpoint);
241
+ const json = `${JSON.stringify(endpoint, null, 2)}\n`;
242
+
243
+ if (format === "markdown") {
244
+ console.log(markdown);
245
+ return 0;
246
+ }
247
+ if (format === "json" && !out) {
248
+ process.stdout.write(json);
249
+ return 0;
250
+ }
251
+ if (format === "svg" && !out) {
252
+ process.stdout.write(svg);
253
+ return 0;
254
+ }
255
+
256
+ const paths = resolveOutPaths(out || "repolens-badge.json");
257
+ await fs.mkdir(path.dirname(path.resolve(paths.jsonPath)), { recursive: true });
258
+ if (format === "json" || format === "all") {
259
+ await fs.writeFile(paths.jsonPath, json);
260
+ }
261
+ if (format === "svg" || format === "all") {
262
+ await fs.writeFile(paths.svgPath, svg);
263
+ }
264
+ if (format === "all" || format === "json" || format === "svg") {
265
+ console.error(`Wrote ${format === "svg" ? paths.svgPath : paths.jsonPath}${format === "all" ? ` and ${paths.svgPath}` : ""}`);
266
+ }
267
+ if (format === "all") {
268
+ console.log(markdown);
269
+ if (!url) {
270
+ console.error("Pass --url (or the badge-url action input) with the public HTTPS URL of the JSON file before embedding the snippet. shields.io fetches that URL.");
271
+ }
272
+ }
273
+ return 0;
274
+ }
package/src/cli-args.js CHANGED
@@ -1,4 +1,14 @@
1
- const FLAGS_WITH_VALUE = new Set(["--config", "--target"]);
1
+ const FLAGS_WITH_VALUE = new Set([
2
+ "--config",
3
+ "--target",
4
+ "--format",
5
+ "--out",
6
+ "--base",
7
+ "--head",
8
+ "--url",
9
+ "--link",
10
+ "--label",
11
+ ]);
2
12
  const BOOLEAN_FLAGS = new Set([
3
13
  "--quick",
4
14
  "--non-interactive",
package/src/cli.js CHANGED
@@ -284,6 +284,29 @@ async function main() {
284
284
 
285
285
  // MCP stdio must start before telemetry and the npm update check.
286
286
  // Those both can open the network, and logs must stay off stdout.
287
+ if (command === "badge") {
288
+ const { runBadgeCli } = await import("./badge.js");
289
+ const code = await runBadgeCli({
290
+ target: parsed.flags["--target"] || process.cwd(),
291
+ format: parsed.flags["--format"] || "json",
292
+ out: parsed.flags["--out"],
293
+ url: parsed.flags["--url"] || "",
294
+ link: parsed.flags["--link"] || "",
295
+ label: parsed.flags["--label"] || "code health",
296
+ });
297
+ process.exit(code);
298
+ }
299
+
300
+ if (command === "comment") {
301
+ const { runPullRequestComment } = await import("./delivery/architecture-comment.js");
302
+ const code = await runPullRequestComment({
303
+ repo: parsed.flags["--target"] || process.cwd(),
304
+ base: parsed.flags["--base"],
305
+ head: parsed.flags["--head"],
306
+ });
307
+ process.exit(code);
308
+ }
309
+
287
310
  if (command === "mcp") {
288
311
  process.env.REPOLENS_LOG_STDERR = "1";
289
312
  const { startMcpServer } = await import("./mcp/server.js");
@@ -799,7 +822,7 @@ async function main() {
799
822
  }
800
823
 
801
824
  error(`Unknown command: ${command}`);
802
- error("Available commands: init, doctor, migrate, publish, demo, watch, uninstall, feedback, mcp, version, help");
825
+ error("Available commands: init, doctor, migrate, publish, demo, watch, uninstall, feedback, mcp, badge, comment, version, help");
803
826
  process.exit(EXIT_ERROR);
804
827
  }
805
828
 
package/src/core/scan.js CHANGED
@@ -512,6 +512,10 @@ export async function scanRepo(cfg) {
512
512
  const moduleCounts = new Map();
513
513
 
514
514
  for (const file of files) {
515
+ // Go test files belong to the package they exercise. Counting each
516
+ // *_test.go as its own module (the filename under module_roots) splits
517
+ // one package into a source module and a test module.
518
+ if (/_test\.go$/.test(norm(file))) continue;
515
519
  const key = moduleKeyForFile(file, cfg.module_roots || []);
516
520
  moduleCounts.set(key, (moduleCounts.get(key) || 0) + 1);
517
521
  }