@chappibunny/repolens 0.4.3 → 0.6.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.
@@ -80,6 +80,38 @@ export function shouldPublishToNotion(config, currentBranch = getCurrentBranch()
80
80
  });
81
81
  }
82
82
 
83
+ /**
84
+ * Check if current branch should publish to Confluence
85
+ * Based on config.confluence.branches setting
86
+ */
87
+ export function shouldPublishToConfluence(config, currentBranch = getCurrentBranch()) {
88
+ // If no confluence config, allow all branches (backward compatible)
89
+ if (!config.confluence) {
90
+ return true;
91
+ }
92
+
93
+ // If branches not specified, allow all
94
+ if (!config.confluence.branches || config.confluence.branches.length === 0) {
95
+ return true;
96
+ }
97
+
98
+ // Check if current branch matches any allowed pattern
99
+ return config.confluence.branches.some(pattern => {
100
+ // Exact match
101
+ if (pattern === currentBranch) {
102
+ return true;
103
+ }
104
+
105
+ // Wildcard pattern (simple glob)
106
+ if (pattern.includes("*")) {
107
+ const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
108
+ return regex.test(currentBranch);
109
+ }
110
+
111
+ return false;
112
+ });
113
+ }
114
+
83
115
  /**
84
116
  * Get branch-qualified page title
85
117
  */
@@ -1,26 +1,43 @@
1
+ import { sanitizeSecrets } from "./secrets.js";
2
+
1
3
  const isVerbose = process.argv.includes("--verbose");
2
4
  const isTest = process.env.NODE_ENV === "test";
3
5
 
6
+ /**
7
+ * Sanitize arguments for logging
8
+ */
9
+ function sanitizeArgs(args) {
10
+ return args.map(arg => {
11
+ if (typeof arg === "string") {
12
+ return sanitizeSecrets(arg);
13
+ }
14
+ if (typeof arg === "object" && arg !== null) {
15
+ return JSON.parse(sanitizeSecrets(JSON.stringify(arg)));
16
+ }
17
+ return arg;
18
+ });
19
+ }
20
+
4
21
  export function log(...args) {
5
22
  if (!isTest && isVerbose) {
6
- console.log("[RepoLens]", ...args);
23
+ console.log("[RepoLens]", ...sanitizeArgs(args));
7
24
  }
8
25
  }
9
26
 
10
27
  export function info(...args) {
11
28
  if (!isTest) {
12
- console.log("[RepoLens]", ...args);
29
+ console.log("[RepoLens]", ...sanitizeArgs(args));
13
30
  }
14
31
  }
15
32
 
16
33
  export function warn(...args) {
17
34
  if (!isTest) {
18
- console.warn("[RepoLens]", ...args);
35
+ console.warn("[RepoLens]", ...sanitizeArgs(args));
19
36
  }
20
37
  }
21
38
 
22
39
  export function error(...args) {
23
40
  if (!isTest) {
24
- console.error("[RepoLens]", ...args);
41
+ console.error("[RepoLens]", ...sanitizeArgs(args));
25
42
  }
26
43
  }
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Metrics Collection for RepoLens Dashboard
3
+ * Calculates coverage, health scores, staleness, and quality issues
4
+ */
5
+
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { info, warn } from "./logger.js";
9
+
10
+ /**
11
+ * Calculate documentation coverage
12
+ * @param {object} scanResult - Repository scan result
13
+ * @param {object} docs - Generated documentation
14
+ * @returns {object} - Coverage metrics
15
+ */
16
+ export function calculateCoverage(scanResult, docs) {
17
+ const { modules = [], api = [], pages = [], filesCount = 0 } = scanResult;
18
+
19
+ // Module coverage: modules with descriptions vs total
20
+ const modulesWithDocs = modules.filter((m) => m.description || m.files?.length > 0).length;
21
+ const moduleCoverage = modules.length > 0 ? (modulesWithDocs / modules.length) * 100 : 0;
22
+
23
+ // API coverage: endpoints with descriptions vs total
24
+ const apisWithDocs = api.filter((a) => a.description || a.method).length;
25
+ const apiCoverage = api.length > 0 ? (apisWithDocs / api.length) * 100 : 100;
26
+
27
+ // Page coverage: pages with descriptions vs total
28
+ const pagesWithDocs = pages.filter((p) => p.description || p.component).length;
29
+ const pageCoverage = pages.length > 0 ? (pagesWithDocs / pages.length) * 100 : 100;
30
+
31
+ // Overall coverage (weighted average)
32
+ const weights = {
33
+ modules: 0.5,
34
+ api: 0.3,
35
+ pages: 0.2,
36
+ };
37
+
38
+ const overallCoverage =
39
+ moduleCoverage * weights.modules +
40
+ apiCoverage * weights.api +
41
+ pageCoverage * weights.pages;
42
+
43
+ return {
44
+ overall: overallCoverage,
45
+ modules: moduleCoverage,
46
+ api: apiCoverage,
47
+ pages: pageCoverage,
48
+ counts: {
49
+ modules: modules.length,
50
+ modulesDocumented: modulesWithDocs,
51
+ api: api.length,
52
+ apiDocumented: apisWithDocs,
53
+ pages: pages.length,
54
+ pagesDocumented: pagesWithDocs,
55
+ files: filesCount,
56
+ },
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Calculate health score (0-100)
62
+ * @param {object} metrics - All metrics data
63
+ * @returns {number} - Health score
64
+ */
65
+ export function calculateHealthScore(metrics) {
66
+ const { coverage, freshness, quality } = metrics;
67
+
68
+ // Coverage weight: 40%
69
+ const coverageScore = coverage.overall * 0.4;
70
+
71
+ // Freshness weight: 30%
72
+ const freshnessScore = freshness.score * 0.3;
73
+
74
+ // Quality weight: 30%
75
+ const qualityScore = quality.score * 0.3;
76
+
77
+ const healthScore = coverageScore + freshnessScore + qualityScore;
78
+
79
+ return Math.round(Math.min(100, Math.max(0, healthScore)));
80
+ }
81
+
82
+ /**
83
+ * Detect stale documentation
84
+ * @param {string} docsPath - Path to documentation directory
85
+ * @param {number} staleThreshold - Days before considering stale (default: 90)
86
+ * @returns {Promise<object>} - Freshness metrics
87
+ */
88
+ export async function detectStaleness(docsPath, staleThreshold = 90) {
89
+ try {
90
+ const now = Date.now();
91
+ const staleMs = staleThreshold * 24 * 60 * 60 * 1000;
92
+
93
+ // Check if docs directory exists
94
+ try {
95
+ await fs.access(docsPath);
96
+ } catch {
97
+ // No docs yet
98
+ return {
99
+ lastUpdated: null,
100
+ daysSinceUpdate: null,
101
+ isStale: true,
102
+ score: 0,
103
+ files: [],
104
+ };
105
+ }
106
+
107
+ // Read all markdown files
108
+ const files = await fs.readdir(docsPath);
109
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
110
+
111
+ if (mdFiles.length === 0) {
112
+ return {
113
+ lastUpdated: null,
114
+ daysSinceUpdate: null,
115
+ isStale: true,
116
+ score: 0,
117
+ files: [],
118
+ };
119
+ }
120
+
121
+ // Get modification times
122
+ const fileStats = await Promise.all(
123
+ mdFiles.map(async (file) => {
124
+ const filePath = path.join(docsPath, file);
125
+ const stats = await fs.stat(filePath);
126
+ return {
127
+ file,
128
+ mtime: stats.mtime.getTime(),
129
+ daysSince: Math.floor((now - stats.mtime.getTime()) / (24 * 60 * 60 * 1000)),
130
+ };
131
+ })
132
+ );
133
+
134
+ // Find most recently updated file
135
+ const newest = fileStats.reduce((a, b) => (a.mtime > b.mtime ? a : b));
136
+ const daysSinceUpdate = newest.daysSince;
137
+ const isStale = daysSinceUpdate > staleThreshold;
138
+
139
+ // Calculate freshness score (100 at 0 days, 0 at staleThreshold days)
140
+ const score = Math.max(0, 100 - (daysSinceUpdate / staleThreshold) * 100);
141
+
142
+ // Find stale files
143
+ const staleFiles = fileStats.filter((f) => f.daysSince > staleThreshold);
144
+
145
+ return {
146
+ lastUpdated: new Date(newest.mtime),
147
+ daysSinceUpdate,
148
+ isStale,
149
+ score: Math.round(score),
150
+ staleFiles: staleFiles.map((f) => ({
151
+ file: f.file,
152
+ daysSince: f.daysSince,
153
+ })),
154
+ };
155
+ } catch (err) {
156
+ warn(`Failed to detect staleness: ${err.message}`);
157
+ return {
158
+ lastUpdated: null,
159
+ daysSinceUpdate: null,
160
+ isStale: true,
161
+ score: 0,
162
+ staleFiles: [],
163
+ };
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Find quality issues
169
+ * @param {object} scanResult - Repository scan result
170
+ * @param {object} docs - Generated documentation
171
+ * @returns {object} - Quality analysis
172
+ */
173
+ export function analyzeQuality(scanResult, docs) {
174
+ const issues = [];
175
+ const { modules = [], api = [], pages = [] } = scanResult;
176
+
177
+ // Check for undocumented modules
178
+ const undocumentedModules = modules.filter((m) => !m.description && m.files?.length > 0);
179
+ if (undocumentedModules.length > 0) {
180
+ issues.push({
181
+ type: "undocumented_modules",
182
+ severity: "medium",
183
+ count: undocumentedModules.length,
184
+ message: `${undocumentedModules.length} modules lack descriptions`,
185
+ items: undocumentedModules.slice(0, 5).map((m) => m.name || m.path),
186
+ });
187
+ }
188
+
189
+ // Check for undocumented APIs
190
+ const undocumentedApis = api.filter((a) => !a.description && !a.handler);
191
+ if (undocumentedApis.length > 0) {
192
+ issues.push({
193
+ type: "undocumented_apis",
194
+ severity: "high",
195
+ count: undocumentedApis.length,
196
+ message: `${undocumentedApis.length} API endpoints lack documentation`,
197
+ items: undocumentedApis.slice(0, 5).map((a) => `${a.method || "GET"} ${a.path}`),
198
+ });
199
+ }
200
+
201
+ // Check for undocumented pages
202
+ const undocumentedPages = pages.filter((p) => !p.description && !p.component);
203
+ if (undocumentedPages.length > 0) {
204
+ issues.push({
205
+ type: "undocumented_pages",
206
+ severity: "low",
207
+ count: undocumentedPages.length,
208
+ message: `${undocumentedPages.length} pages lack descriptions`,
209
+ items: undocumentedPages.slice(0, 5).map((p) => p.path),
210
+ });
211
+ }
212
+
213
+ // Check for empty modules
214
+ const emptyModules = modules.filter((m) => !m.files || m.files.length === 0);
215
+ if (emptyModules.length > 0) {
216
+ issues.push({
217
+ type: "empty_modules",
218
+ severity: "low",
219
+ count: emptyModules.length,
220
+ message: `${emptyModules.length} modules contain no files`,
221
+ items: emptyModules.slice(0, 5).map((m) => m.name || m.path),
222
+ });
223
+ }
224
+
225
+ // Calculate quality score (100 - penalty for issues)
226
+ const penalties = {
227
+ high: 10,
228
+ medium: 5,
229
+ low: 2,
230
+ };
231
+
232
+ const totalPenalty = issues.reduce((sum, issue) => {
233
+ return sum + penalties[issue.severity] * Math.min(issue.count, 5);
234
+ }, 0);
235
+
236
+ const score = Math.max(0, 100 - totalPenalty);
237
+
238
+ return {
239
+ score: Math.round(score),
240
+ issues,
241
+ summary: {
242
+ total: issues.length,
243
+ high: issues.filter((i) => i.severity === "high").length,
244
+ medium: issues.filter((i) => i.severity === "medium").length,
245
+ low: issues.filter((i) => i.severity === "low").length,
246
+ },
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Track metrics over time
252
+ * @param {object} metrics - Current metrics
253
+ * @param {string} historyPath - Path to metrics history file
254
+ * @returns {Promise<object>} - Updated history with trends
255
+ */
256
+ export async function trackMetrics(metrics, historyPath) {
257
+ try {
258
+ // Load existing history
259
+ let history = [];
260
+ try {
261
+ const data = await fs.readFile(historyPath, "utf-8");
262
+ history = JSON.parse(data);
263
+ } catch {
264
+ // No history yet, start fresh
265
+ history = [];
266
+ }
267
+
268
+ // Add current metrics to history
269
+ const timestamp = new Date().toISOString();
270
+ history.push({
271
+ timestamp,
272
+ coverage: metrics.coverage.overall,
273
+ healthScore: metrics.healthScore,
274
+ filesCount: metrics.coverage.counts.files,
275
+ modulesCount: metrics.coverage.counts.modules,
276
+ });
277
+
278
+ // Keep last 90 days (assuming daily runs)
279
+ if (history.length > 90) {
280
+ history = history.slice(-90);
281
+ }
282
+
283
+ // Save updated history
284
+ await fs.mkdir(path.dirname(historyPath), { recursive: true });
285
+ await fs.writeFile(historyPath, JSON.stringify(history, null, 2));
286
+
287
+ // Calculate trends
288
+ const trends = calculateTrends(history);
289
+
290
+ return { history, trends };
291
+ } catch (err) {
292
+ warn(`Failed to track metrics: ${err.message}`);
293
+ return { history: [], trends: {} };
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Calculate trends from history
299
+ * @param {array} history - Metrics history
300
+ * @returns {object} - Trend analysis
301
+ */
302
+ function calculateTrends(history) {
303
+ if (history.length < 2) {
304
+ return {
305
+ coverage: { direction: "stable", change: 0 },
306
+ healthScore: { direction: "stable", change: 0 },
307
+ };
308
+ }
309
+
310
+ const latest = history[history.length - 1];
311
+ const previous = history[history.length - 2];
312
+
313
+ const coverageChange = latest.coverage - previous.coverage;
314
+ const healthChange = latest.healthScore - previous.healthScore;
315
+
316
+ return {
317
+ coverage: {
318
+ direction: coverageChange > 1 ? "up" : coverageChange < -1 ? "down" : "stable",
319
+ change: coverageChange.toFixed(1),
320
+ },
321
+ healthScore: {
322
+ direction: healthChange > 2 ? "up" : healthChange < -2 ? "down" : "stable",
323
+ change: healthChange.toFixed(0),
324
+ },
325
+ };
326
+ }
327
+
328
+ /**
329
+ * Collect all metrics
330
+ * @param {object} scanResult - Repository scan result
331
+ * @param {object} docs - Generated documentation
332
+ * @param {string} docsPath - Path to documentation directory
333
+ * @param {string} historyPath - Path to metrics history file
334
+ * @returns {Promise<object>} - Complete metrics data
335
+ */
336
+ export async function collectMetrics(scanResult, docs, docsPath, historyPath) {
337
+ info("Collecting metrics...");
338
+
339
+ const coverage = calculateCoverage(scanResult, docs);
340
+ const freshness = await detectStaleness(docsPath);
341
+ const quality = analyzeQuality(scanResult, docs);
342
+
343
+ const metrics = {
344
+ coverage,
345
+ freshness,
346
+ quality,
347
+ timestamp: new Date().toISOString(),
348
+ };
349
+
350
+ metrics.healthScore = calculateHealthScore(metrics);
351
+
352
+ // Track over time
353
+ const { history, trends } = await trackMetrics(metrics, historyPath);
354
+ metrics.history = history;
355
+ metrics.trends = trends;
356
+
357
+ info(`✓ Health Score: ${metrics.healthScore}/100`);
358
+ info(`✓ Coverage: ${metrics.coverage.overall.toFixed(1)}%`);
359
+
360
+ return metrics;
361
+ }