@chappibunny/repolens 1.10.0 โ 1.12.0
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 +93 -0
- package/README.md +5 -5
- package/package.json +1 -1
- package/src/ai/document-plan.js +16 -0
- package/src/ai/generate-sections.js +6 -6
- package/src/ai/provider.js +27 -3
- package/src/analyzers/complexity-analyzer.js +297 -0
- package/src/analyzers/jsdoc-analyzer.js +354 -0
- package/src/analyzers/security-patterns.js +329 -0
- package/src/docs/generate-doc-set.js +34 -4
- package/src/init.js +484 -41
- package/src/publishers/github-wiki.js +10 -1
- package/src/publishers/markdown.js +3 -1
- package/src/renderers/render.js +96 -1
- package/src/renderers/renderAnalysis.js +184 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,99 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to RepoLens will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 1.12.0
|
|
6
|
+
|
|
7
|
+
### ๐ก๏ธ Security Pattern Detection
|
|
8
|
+
|
|
9
|
+
New static analysis for security anti-patterns with CWE classification:
|
|
10
|
+
|
|
11
|
+
- **16 anti-patterns** across 8 categories: Code Injection (eval), XSS (innerHTML), SQL Injection, Command Injection, Path Traversal, Prototype Pollution, Hardcoded Credentials, Insecure Randomness, ReDoS
|
|
12
|
+
- Each finding includes severity (high/medium/low), file location, code snippet, and CWE ID
|
|
13
|
+
- New renderer: `renderSecurityHotspots()` with severity breakdown, category grouping, and remediation guidance
|
|
14
|
+
- New document type: **Security Hotspots** (`security_hotspots`)
|
|
15
|
+
- Skips test files, build outputs, configs, `.min.js`, `.d.ts` to reduce noise
|
|
16
|
+
- 30 new tests in `security-patterns.test.js`
|
|
17
|
+
|
|
18
|
+
### ๐ Complexity & Code Health Scoring
|
|
19
|
+
|
|
20
|
+
Cyclomatic complexity analysis with unified health scoring:
|
|
21
|
+
|
|
22
|
+
- **Per-file and per-function complexity**: Counts if/else/for/while/case/catch/&&/||/??/ternary with comment and string stripping
|
|
23
|
+
- **Unified health score** (0โ100, AโF grades) per module synthesizing:
|
|
24
|
+
- Cyclomatic complexity penalties
|
|
25
|
+
- Coupling metrics (fan-in ร fan-out from dependency graph)
|
|
26
|
+
- JSDoc documentation coverage
|
|
27
|
+
- Security findings severity
|
|
28
|
+
- New renderer: `renderCodeHealth()` with grade distribution, hotspot table, top complex functions, and scoring methodology
|
|
29
|
+
- New document type: **Code Health** (`code_health`)
|
|
30
|
+
- 41 new tests in `code-health.test.js`
|
|
31
|
+
|
|
32
|
+
### ๐ JSDoc/TSDoc Extraction
|
|
33
|
+
|
|
34
|
+
API Surface enrichment with documentation extraction:
|
|
35
|
+
|
|
36
|
+
- Parses `@param`, `@returns`, `@deprecated`, `@example`, `@throws`, `@since` tags
|
|
37
|
+
- Enriches API Surface document with parameter types, descriptions, and deprecation notices
|
|
38
|
+
- Coverage reporting (e.g., "89/167 exports documented (53%)")
|
|
39
|
+
- 16 new tests in `jsdoc-analyzer.test.js`
|
|
40
|
+
|
|
41
|
+
### ๐ Numbers
|
|
42
|
+
|
|
43
|
+
- **17 document types** (up from 15): added Security Hotspots + Code Health
|
|
44
|
+
- **480 tests** passing across 25 test files (up from 393 across 22)
|
|
45
|
+
- **12 analyzers** in the pipeline
|
|
46
|
+
|
|
47
|
+
## 1.11.0
|
|
48
|
+
|
|
49
|
+
### ๐ง Smart URL Parsing in Wizard
|
|
50
|
+
|
|
51
|
+
The init wizard now intelligently parses URLs you paste, automatically extracting the right values:
|
|
52
|
+
|
|
53
|
+
**Confluence:**
|
|
54
|
+
- Paste a full page URL โ wizard extracts base URL, space key, AND page ID
|
|
55
|
+
- No more confusion between base URL vs page URL
|
|
56
|
+
- Space key clearly explained (examples: `DOCS`, `ENG`, `~username` for personal)
|
|
57
|
+
- Validates credentials immediately by testing connection to your space
|
|
58
|
+
|
|
59
|
+
**Notion:**
|
|
60
|
+
- Paste a full Notion page URL โ wizard extracts the 32-char page ID automatically
|
|
61
|
+
- Tests connection immediately and confirms your integration has access
|
|
62
|
+
- Clear step-by-step instructions with browser auto-open for integrations page
|
|
63
|
+
|
|
64
|
+
### ๐ Multi-Language Scan Presets
|
|
65
|
+
|
|
66
|
+
The wizard now supports **8 language/framework presets**:
|
|
67
|
+
|
|
68
|
+
| # | Preset | Languages | Best For |
|
|
69
|
+
|---|--------|-----------|----------|
|
|
70
|
+
| 1 | **Universal** | All (JS, TS, Python, Go, Rust, Java, Ruby, PHP, C#, Swift, Kotlin, Scala, Vue, Svelte) | Polyglot projects |
|
|
71
|
+
| 2 | Next.js / React | TypeScript, JavaScript | React frontends |
|
|
72
|
+
| 3 | Express / Node.js | TypeScript, JavaScript | Node.js backends |
|
|
73
|
+
| 4 | **Python** | Python | Django, Flask, FastAPI |
|
|
74
|
+
| 5 | **Go** | Go | Standard Go layout |
|
|
75
|
+
| 6 | **Rust** | Rust | Cargo projects |
|
|
76
|
+
| 7 | **Java/Kotlin/Scala** | JVM languages | Maven/Gradle |
|
|
77
|
+
| 8 | JavaScript/TypeScript | JS/TS only | Legacy JS projects |
|
|
78
|
+
|
|
79
|
+
**Universal is now the default** โ no more "0 modules detected" because of language mismatch!
|
|
80
|
+
|
|
81
|
+
### ๐ Critical .env Reminder
|
|
82
|
+
|
|
83
|
+
After collecting credentials, the wizard now prominently displays:
|
|
84
|
+
```
|
|
85
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
86
|
+
โ IMPORTANT: Your credentials are in .env but not loaded yet โ
|
|
87
|
+
โ Run this BEFORE 'repolens publish': โ
|
|
88
|
+
โ โ
|
|
89
|
+
โ source .env โ
|
|
90
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### ๐งช New Tests
|
|
94
|
+
|
|
95
|
+
- 13 new tests for URL parsing functions (`parseConfluenceUrl`, `parseNotionInput`)
|
|
96
|
+
- Total: 393 tests passing
|
|
97
|
+
|
|
5
98
|
## 1.10.0
|
|
6
99
|
|
|
7
100
|
### โจ Interactive Init is Now Default
|
package/README.md
CHANGED
|
@@ -10,14 +10,14 @@
|
|
|
10
10
|
|
|
11
11
|
[](https://www.npmjs.com/package/@chappibunny/repolens)
|
|
12
12
|
[](https://marketplace.visualstudio.com/items?itemName=CHAPIBUNNY.repolens-architecture)
|
|
13
|
-
[](https://github.com/CHAPIBUNNY/repolens/actions)
|
|
14
14
|
[](LICENSE)
|
|
15
15
|
|
|
16
16
|
**Your architecture docs are already outdated.** RepoLens fixes that.
|
|
17
17
|
|
|
18
18
|
RepoLens scans your repository, generates living architecture documentation, and publishes it to Notion, Confluence, GitHub Wiki, or Markdown โ automatically on every push. Engineers get technical docs. Stakeholders get readable system overviews. Nobody writes a word.
|
|
19
19
|
|
|
20
|
-
> Stable as of v1.0 โ [API guarantees](docs/STABILITY.md) ยท [Security hardened](SECURITY.md) ยท v1.
|
|
20
|
+
> Stable as of v1.0 โ [API guarantees](docs/STABILITY.md) ยท [Security hardened](SECURITY.md) ยท v1.12.0
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
@@ -92,13 +92,13 @@ Run `npx @chappibunny/repolens migrate` to automatically update your workflow fi
|
|
|
92
92
|
|
|
93
93
|
## ๐ What It Generates
|
|
94
94
|
|
|
95
|
-
**
|
|
95
|
+
**17 document types** for three audiences โ no manual writing required:
|
|
96
96
|
|
|
97
97
|
| Audience | Documents |
|
|
98
98
|
|---|---|
|
|
99
99
|
| **Stakeholders** (founders, PMs, ops) | Executive Summary ยท Business Domains ยท Data Flows |
|
|
100
|
-
| **Everyone** | System Overview ยท Developer Onboarding ยท Change Impact ยท Architecture Drift |
|
|
101
|
-
| **Engineers** | Architecture Overview ยท Module Catalog ยท API Surface ยท Route Map ยท System Map ยท GraphQL Schema ยท TypeScript Type Graph ยท Dependency Graph |
|
|
100
|
+
| **Everyone** | System Overview ยท Developer Onboarding ยท Change Impact ยท Architecture Drift ยท Code Health |
|
|
101
|
+
| **Engineers** | Architecture Overview ยท Module Catalog ยท API Surface ยท Route Map ยท System Map ยท GraphQL Schema ยท TypeScript Type Graph ยท Dependency Graph ยท Security Hotspots |
|
|
102
102
|
|
|
103
103
|
**Two modes:** Deterministic (free, fast, always works) or AI-Enhanced (optional โ GitHub Models, OpenAI, Anthropic, Google, Azure, Ollama).
|
|
104
104
|
|
package/package.json
CHANGED
package/src/ai/document-plan.js
CHANGED
|
@@ -120,6 +120,22 @@ export const DOCUMENT_PLAN = [
|
|
|
120
120
|
audience: "mixed",
|
|
121
121
|
ai: false,
|
|
122
122
|
description: "Structural changes compared to baseline snapshot"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
key: "security_hotspots",
|
|
126
|
+
filename: "15-security-hotspots.md",
|
|
127
|
+
title: "Security Hotspots",
|
|
128
|
+
audience: "technical",
|
|
129
|
+
ai: false,
|
|
130
|
+
description: "Security anti-pattern detection with CWE classification"
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
key: "code_health",
|
|
134
|
+
filename: "16-code-health.md",
|
|
135
|
+
title: "Code Health Report",
|
|
136
|
+
audience: "mixed",
|
|
137
|
+
ai: false,
|
|
138
|
+
description: "Per-module health scores from complexity, coupling, docs, and security"
|
|
123
139
|
}
|
|
124
140
|
];
|
|
125
141
|
|
|
@@ -94,7 +94,7 @@ export async function generateExecutiveSummary(context, enrichment = {}, config)
|
|
|
94
94
|
return generateWithStructuredFallback(
|
|
95
95
|
"executive_summary",
|
|
96
96
|
createExecutiveSummaryPrompt(context),
|
|
97
|
-
|
|
97
|
+
3000,
|
|
98
98
|
() => getFallbackExecutiveSummary(context, enrichment),
|
|
99
99
|
config,
|
|
100
100
|
);
|
|
@@ -104,7 +104,7 @@ export async function generateSystemOverview(context, enrichment = {}, config) {
|
|
|
104
104
|
return generateWithStructuredFallback(
|
|
105
105
|
"system_overview",
|
|
106
106
|
createSystemOverviewPrompt(context),
|
|
107
|
-
|
|
107
|
+
2500,
|
|
108
108
|
() => getFallbackSystemOverview(context, enrichment),
|
|
109
109
|
config,
|
|
110
110
|
);
|
|
@@ -114,7 +114,7 @@ export async function generateBusinessDomains(context, enrichment = {}, config)
|
|
|
114
114
|
return generateWithStructuredFallback(
|
|
115
115
|
"business_domains",
|
|
116
116
|
createBusinessDomainsPrompt(context),
|
|
117
|
-
|
|
117
|
+
3500,
|
|
118
118
|
() => getFallbackBusinessDomains(context, enrichment),
|
|
119
119
|
config,
|
|
120
120
|
);
|
|
@@ -124,7 +124,7 @@ export async function generateArchitectureOverview(context, enrichment = {}, con
|
|
|
124
124
|
return generateWithStructuredFallback(
|
|
125
125
|
"architecture_overview",
|
|
126
126
|
createArchitectureOverviewPrompt(context),
|
|
127
|
-
|
|
127
|
+
3500,
|
|
128
128
|
() => getFallbackArchitectureOverview(context, enrichment),
|
|
129
129
|
config,
|
|
130
130
|
);
|
|
@@ -134,7 +134,7 @@ export async function generateDataFlows(flows, context, enrichment = {}, config)
|
|
|
134
134
|
return generateWithStructuredFallback(
|
|
135
135
|
"data_flows",
|
|
136
136
|
createDataFlowsPrompt(flows, context),
|
|
137
|
-
|
|
137
|
+
3500,
|
|
138
138
|
() => getFallbackDataFlows(flows, context, enrichment),
|
|
139
139
|
config,
|
|
140
140
|
);
|
|
@@ -144,7 +144,7 @@ export async function generateDeveloperOnboarding(context, enrichment = {}, conf
|
|
|
144
144
|
return generateWithStructuredFallback(
|
|
145
145
|
"developer_onboarding",
|
|
146
146
|
createDeveloperOnboardingPrompt(context),
|
|
147
|
-
|
|
147
|
+
4000,
|
|
148
148
|
() => getFallbackDeveloperOnboarding(context, enrichment),
|
|
149
149
|
config,
|
|
150
150
|
);
|
package/src/ai/provider.js
CHANGED
|
@@ -4,7 +4,7 @@ import { warn, info } from "../utils/logger.js";
|
|
|
4
4
|
import { executeAIRequest } from "../utils/rate-limit.js";
|
|
5
5
|
|
|
6
6
|
const DEFAULT_TIMEOUT_MS = 60000;
|
|
7
|
-
const DEFAULT_MAX_TOKENS =
|
|
7
|
+
const DEFAULT_MAX_TOKENS = 4000;
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* AI Provider Presets - one env var to configure common providers.
|
|
@@ -245,8 +245,16 @@ async function callOpenAICompatibleAPI({ baseUrl, apiKey, model, system, user, t
|
|
|
245
245
|
{ role: "system", content: system },
|
|
246
246
|
{ role: "user", content: user }
|
|
247
247
|
],
|
|
248
|
-
max_completion_tokens: maxTokens
|
|
249
248
|
};
|
|
249
|
+
|
|
250
|
+
// GPT-5+ and o-series models require max_completion_tokens; older models use max_tokens
|
|
251
|
+
const usesNewTokenParam = /^(gpt-5|gpt-4\.1|o[34]-|o3)/.test(model);
|
|
252
|
+
if (usesNewTokenParam) {
|
|
253
|
+
body.max_completion_tokens = maxTokens;
|
|
254
|
+
} else {
|
|
255
|
+
body.max_tokens = maxTokens;
|
|
256
|
+
}
|
|
257
|
+
|
|
250
258
|
// Only send temperature when explicitly configured โ some models
|
|
251
259
|
// (e.g. gpt-5-mini) reject any non-default value
|
|
252
260
|
if (temperature != null) {
|
|
@@ -279,7 +287,23 @@ async function callOpenAICompatibleAPI({ baseUrl, apiKey, model, system, user, t
|
|
|
279
287
|
throw new Error("No completion returned from API");
|
|
280
288
|
}
|
|
281
289
|
|
|
282
|
-
|
|
290
|
+
const choice = data.choices[0];
|
|
291
|
+
const content = choice.message?.content;
|
|
292
|
+
const finishReason = choice.finish_reason || "unknown";
|
|
293
|
+
|
|
294
|
+
// Handle truncation - warn with actionable advice
|
|
295
|
+
if (finishReason === "length") {
|
|
296
|
+
if (!content || content.trim().length === 0) {
|
|
297
|
+
warn(`AI response truncated (hit ${maxTokens} token limit). Increase REPOLENS_AI_MAX_TOKENS or the per-document limit.`);
|
|
298
|
+
} else {
|
|
299
|
+
warn(`AI response may be incomplete (finish_reason: length). Consider increasing token limits.`);
|
|
300
|
+
}
|
|
301
|
+
} else if (!content || content.trim().length === 0) {
|
|
302
|
+
warn(`AI response empty (finish_reason: ${finishReason})`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Handle null/undefined content (some models return null in certain cases)
|
|
306
|
+
return content ?? "";
|
|
283
307
|
|
|
284
308
|
} catch (error) {
|
|
285
309
|
clearTimeout(timeoutId);
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// Complexity & Code Health analyzer
|
|
2
|
+
// Computes per-file cyclomatic complexity, then synthesizes all analyzers
|
|
3
|
+
// (dep graph, JSDoc, security) into a per-module health score.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { info } from "../utils/logger.js";
|
|
8
|
+
|
|
9
|
+
const JS_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"];
|
|
10
|
+
|
|
11
|
+
// Skip test files, build outputs, etc.
|
|
12
|
+
const SKIP_PATTERNS = [
|
|
13
|
+
/node_modules\//,
|
|
14
|
+
/\.test\.[jt]sx?$/,
|
|
15
|
+
/\.spec\.[jt]sx?$/,
|
|
16
|
+
/\btest[s]?\//,
|
|
17
|
+
/\bdist\//,
|
|
18
|
+
/\bbuild\//,
|
|
19
|
+
/\.min\.[jt]s$/,
|
|
20
|
+
/\.d\.ts$/,
|
|
21
|
+
/\.repolens\//,
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function shouldSkip(filePath) {
|
|
25
|
+
return SKIP_PATTERNS.some(p => p.test(filePath));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Count cyclomatic complexity for a source string.
|
|
30
|
+
* Each decision point adds 1 to the baseline of 1.
|
|
31
|
+
*/
|
|
32
|
+
export function computeComplexity(source) {
|
|
33
|
+
// Strip comments to avoid false positives
|
|
34
|
+
let stripped = source.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, " "));
|
|
35
|
+
stripped = stripped.replace(/(?<!:)\/\/.*$/gm, "");
|
|
36
|
+
// Strip string literals to avoid matching keywords inside strings
|
|
37
|
+
stripped = stripped.replace(/`[^`]*`/g, '""');
|
|
38
|
+
stripped = stripped.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
|
39
|
+
stripped = stripped.replace(/'(?:[^'\\]|\\.)*'/g, "''");
|
|
40
|
+
|
|
41
|
+
let complexity = 1; // baseline
|
|
42
|
+
|
|
43
|
+
// Decision point patterns
|
|
44
|
+
const patterns = [
|
|
45
|
+
/\bif\s*\(/g,
|
|
46
|
+
/\belse\s+if\s*\(/g,
|
|
47
|
+
/\bfor\s*\(/g,
|
|
48
|
+
/\bwhile\s*\(/g,
|
|
49
|
+
/\bcase\s+/g,
|
|
50
|
+
/\bcatch\s*\(/g,
|
|
51
|
+
/(?<!\?)\?\s*(?![.?])/g, // ternary ? but not optional chaining ?. or nullish ??
|
|
52
|
+
/&&/g,
|
|
53
|
+
/\|\|/g,
|
|
54
|
+
/\?\?/g, // nullish coalescing
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
for (const pattern of patterns) {
|
|
58
|
+
const matches = stripped.match(pattern);
|
|
59
|
+
if (matches) complexity += matches.length;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Subtract double-counted else-if (already counted by if)
|
|
63
|
+
const elseIfMatches = stripped.match(/\belse\s+if\s*\(/g);
|
|
64
|
+
if (elseIfMatches) complexity -= elseIfMatches.length;
|
|
65
|
+
|
|
66
|
+
return complexity;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Extract function-level complexity from source code.
|
|
71
|
+
* Returns per-function complexity for the top-level functions.
|
|
72
|
+
*/
|
|
73
|
+
export function extractFunctions(source) {
|
|
74
|
+
const functions = [];
|
|
75
|
+
const lines = source.split("\n");
|
|
76
|
+
|
|
77
|
+
// Match exported/declared function signatures
|
|
78
|
+
const funcPattern = /(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/g;
|
|
79
|
+
const arrowPattern = /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|\w+)\s*=>/g;
|
|
80
|
+
|
|
81
|
+
let match;
|
|
82
|
+
|
|
83
|
+
while ((match = funcPattern.exec(source)) !== null) {
|
|
84
|
+
const startLine = source.slice(0, match.index).split("\n").length;
|
|
85
|
+
functions.push({ name: match[1], line: startLine, index: match.index });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
while ((match = arrowPattern.exec(source)) !== null) {
|
|
89
|
+
const startLine = source.slice(0, match.index).split("\n").length;
|
|
90
|
+
functions.push({ name: match[1], line: startLine, index: match.index });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Sort by position in file
|
|
94
|
+
functions.sort((a, b) => a.index - b.index);
|
|
95
|
+
|
|
96
|
+
// Compute complexity for each function's body (approximation: from start to next function)
|
|
97
|
+
const results = [];
|
|
98
|
+
for (let i = 0; i < functions.length; i++) {
|
|
99
|
+
const start = functions[i].index;
|
|
100
|
+
const end = i + 1 < functions.length ? functions[i + 1].index : source.length;
|
|
101
|
+
const body = source.slice(start, end);
|
|
102
|
+
const complexity = computeComplexity(body);
|
|
103
|
+
results.push({
|
|
104
|
+
name: functions[i].name,
|
|
105
|
+
line: functions[i].line,
|
|
106
|
+
complexity,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return results;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Analyze complexity for all JS/TS files in the project.
|
|
115
|
+
* @param {string[]} files - File paths relative to repoRoot
|
|
116
|
+
* @param {string} repoRoot - Absolute path to repo root
|
|
117
|
+
* @returns {Promise<Object>} Complexity analysis results
|
|
118
|
+
*/
|
|
119
|
+
export async function analyzeComplexity(files, repoRoot) {
|
|
120
|
+
const jsFiles = files
|
|
121
|
+
.filter(f => JS_EXTENSIONS.some(ext => f.endsWith(ext)))
|
|
122
|
+
.filter(f => !shouldSkip(f));
|
|
123
|
+
|
|
124
|
+
const result = {
|
|
125
|
+
files: [],
|
|
126
|
+
functions: [],
|
|
127
|
+
filesAnalyzed: jsFiles.length,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
for (const file of jsFiles) {
|
|
131
|
+
let content;
|
|
132
|
+
try {
|
|
133
|
+
content = await fs.readFile(path.join(repoRoot, file), "utf8");
|
|
134
|
+
} catch {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const fileComplexity = computeComplexity(content);
|
|
139
|
+
const lineCount = content.split("\n").length;
|
|
140
|
+
const funcs = extractFunctions(content);
|
|
141
|
+
|
|
142
|
+
result.files.push({
|
|
143
|
+
file,
|
|
144
|
+
complexity: fileComplexity,
|
|
145
|
+
lines: lineCount,
|
|
146
|
+
functions: funcs.length,
|
|
147
|
+
maxFunctionComplexity: funcs.length > 0 ? Math.max(...funcs.map(f => f.complexity)) : fileComplexity,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
for (const fn of funcs) {
|
|
151
|
+
result.functions.push({ ...fn, file });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Synthesize signals from all analyzers into per-module health scores.
|
|
160
|
+
*
|
|
161
|
+
* Health score (0โ100) is computed from:
|
|
162
|
+
* - Complexity penalty: high cyclomatic complexity
|
|
163
|
+
* - Coupling penalty: high fan-in * fan-out product
|
|
164
|
+
* - Documentation bonus: JSDoc coverage
|
|
165
|
+
* - Security penalty: findings in this file
|
|
166
|
+
*
|
|
167
|
+
* @param {Object} complexityResult - From analyzeComplexity()
|
|
168
|
+
* @param {Object} depGraph - From analyzeDependencyGraph()
|
|
169
|
+
* @param {Object} jsdocResult - From analyzeJSDoc()
|
|
170
|
+
* @param {Object} securityResult - From analyzeSecurityPatterns()
|
|
171
|
+
* @returns {Object} Code health report
|
|
172
|
+
*/
|
|
173
|
+
export function computeCodeHealth(complexityResult, depGraph, jsdocResult, securityResult) {
|
|
174
|
+
const modules = [];
|
|
175
|
+
|
|
176
|
+
// Build lookup maps
|
|
177
|
+
const depMap = new Map();
|
|
178
|
+
if (depGraph?.nodes) {
|
|
179
|
+
for (const node of depGraph.nodes) {
|
|
180
|
+
depMap.set(node.file, {
|
|
181
|
+
fanIn: node.importedBy?.length || 0,
|
|
182
|
+
fanOut: node.imports?.length || 0,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const securityByFile = new Map();
|
|
188
|
+
if (securityResult?.findings) {
|
|
189
|
+
for (const finding of securityResult.findings) {
|
|
190
|
+
if (!securityByFile.has(finding.file)) securityByFile.set(finding.file, []);
|
|
191
|
+
securityByFile.get(finding.file).push(finding);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const jsdocByFile = new Map();
|
|
196
|
+
if (jsdocResult?.byFile) {
|
|
197
|
+
for (const [file, exports] of Object.entries(jsdocResult.byFile)) {
|
|
198
|
+
const total = exports.length;
|
|
199
|
+
const documented = exports.filter(e => e.jsdoc != null).length;
|
|
200
|
+
jsdocByFile.set(file, { total, documented, coverage: total > 0 ? documented / total : 1 });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const fileInfo of complexityResult.files) {
|
|
205
|
+
const { file, complexity, lines, maxFunctionComplexity } = fileInfo;
|
|
206
|
+
|
|
207
|
+
// Coupling
|
|
208
|
+
const dep = depMap.get(file) || { fanIn: 0, fanOut: 0 };
|
|
209
|
+
const coupling = dep.fanIn * dep.fanOut;
|
|
210
|
+
|
|
211
|
+
// Documentation coverage (default 100% if not tracked)
|
|
212
|
+
const docInfo = jsdocByFile.get(file) || { total: 0, documented: 0, coverage: 1 };
|
|
213
|
+
|
|
214
|
+
// Security findings
|
|
215
|
+
const findings = securityByFile.get(file) || [];
|
|
216
|
+
const highFindings = findings.filter(f => f.severity === "high").length;
|
|
217
|
+
const mediumFindings = findings.filter(f => f.severity === "medium").length;
|
|
218
|
+
|
|
219
|
+
// Score calculation (0โ100, higher is healthier)
|
|
220
|
+
let score = 100;
|
|
221
|
+
|
|
222
|
+
// Complexity penalty: -1 per complexity point above 10, -2 above 30
|
|
223
|
+
if (complexity > 10) score -= Math.min(25, (complexity - 10) * 1);
|
|
224
|
+
if (complexity > 30) score -= Math.min(15, (complexity - 30) * 2);
|
|
225
|
+
|
|
226
|
+
// Max function complexity penalty: -2 per point above 8
|
|
227
|
+
if (maxFunctionComplexity > 8) score -= Math.min(15, (maxFunctionComplexity - 8) * 2);
|
|
228
|
+
|
|
229
|
+
// Coupling penalty: penalize files with high fan-in AND fan-out
|
|
230
|
+
if (coupling > 20) score -= Math.min(15, Math.floor((coupling - 20) / 5));
|
|
231
|
+
|
|
232
|
+
// Documentation bonus/penalty
|
|
233
|
+
if (docInfo.total > 0) {
|
|
234
|
+
if (docInfo.coverage < 0.5) score -= 10;
|
|
235
|
+
else if (docInfo.coverage < 0.8) score -= 5;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Security penalty
|
|
239
|
+
score -= highFindings * 10;
|
|
240
|
+
score -= mediumFindings * 5;
|
|
241
|
+
|
|
242
|
+
score = Math.max(0, Math.min(100, score));
|
|
243
|
+
|
|
244
|
+
const grade = score >= 80 ? "A" : score >= 60 ? "B" : score >= 40 ? "C" : score >= 20 ? "D" : "F";
|
|
245
|
+
|
|
246
|
+
modules.push({
|
|
247
|
+
file,
|
|
248
|
+
score,
|
|
249
|
+
grade,
|
|
250
|
+
lines,
|
|
251
|
+
complexity,
|
|
252
|
+
maxFunctionComplexity,
|
|
253
|
+
fanIn: dep.fanIn,
|
|
254
|
+
fanOut: dep.fanOut,
|
|
255
|
+
coupling,
|
|
256
|
+
docCoverage: docInfo.total > 0 ? Math.round(docInfo.coverage * 100) : null,
|
|
257
|
+
securityFindings: findings.length,
|
|
258
|
+
highSecurityFindings: highFindings,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Sort by score ascending (worst first)
|
|
263
|
+
modules.sort((a, b) => a.score - b.score);
|
|
264
|
+
|
|
265
|
+
// Aggregate stats
|
|
266
|
+
const totalFiles = modules.length;
|
|
267
|
+
const avgScore = totalFiles > 0 ? Math.round(modules.reduce((s, m) => s + m.score, 0) / totalFiles) : 100;
|
|
268
|
+
const avgComplexity = totalFiles > 0 ? Math.round(modules.reduce((s, m) => s + m.complexity, 0) / totalFiles) : 0;
|
|
269
|
+
|
|
270
|
+
const gradeDistribution = { A: 0, B: 0, C: 0, D: 0, F: 0 };
|
|
271
|
+
for (const m of modules) gradeDistribution[m.grade]++;
|
|
272
|
+
|
|
273
|
+
const topComplexFunctions = [...complexityResult.functions]
|
|
274
|
+
.sort((a, b) => b.complexity - a.complexity)
|
|
275
|
+
.slice(0, 15);
|
|
276
|
+
|
|
277
|
+
const hotspots = modules.filter(m => m.score < 60).slice(0, 20);
|
|
278
|
+
|
|
279
|
+
const summary = totalFiles > 0
|
|
280
|
+
? `${totalFiles} files analyzed ยท Average health: ${avgScore}/100 ยท ${gradeDistribution.A} A, ${gradeDistribution.B} B, ${gradeDistribution.C} C, ${gradeDistribution.D} D, ${gradeDistribution.F} F`
|
|
281
|
+
: "No source files analyzed.";
|
|
282
|
+
|
|
283
|
+
info(`Code health: avg ${avgScore}/100 across ${totalFiles} files (${hotspots.length} hotspots)`);
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
modules,
|
|
287
|
+
summary,
|
|
288
|
+
stats: {
|
|
289
|
+
totalFiles,
|
|
290
|
+
avgScore,
|
|
291
|
+
avgComplexity,
|
|
292
|
+
gradeDistribution,
|
|
293
|
+
},
|
|
294
|
+
hotspots,
|
|
295
|
+
topComplexFunctions,
|
|
296
|
+
};
|
|
297
|
+
}
|