dep-brain 1.2.0 → 1.3.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 +16 -0
- package/README.md +39 -27
- package/depbrain.config.json +6 -1
- package/depbrain.config.schema.json +6 -1
- package/dist/checks/risk.d.ts +3 -1
- package/dist/checks/risk.js +28 -12
- package/dist/cli.js +11 -3
- package/dist/core/analyzer.js +9 -4
- package/dist/core/plugin-manager.d.ts +9 -0
- package/dist/core/plugin-manager.js +142 -18
- package/dist/index.d.ts +1 -1
- package/dist/reporters/dashboard.d.ts +2 -0
- package/dist/reporters/dashboard.js +84 -0
- package/dist/utils/config.d.ts +5 -0
- package/dist/utils/config.js +12 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 1.3.0
|
|
6
|
+
|
|
7
|
+
- Added plugin diagnostics under `extensions.depBrain.plugins` for failed plugin loads and hook errors.
|
|
8
|
+
- Added built-in `license` plugin through `plugins.enabled: ["license"]`.
|
|
9
|
+
- Added configurable risk thresholds for stale release days, aging release days, low downloads, and trust score weights.
|
|
10
|
+
- Added `--dashboard` and `--dashboard-out` for static HTML dashboard generation.
|
|
11
|
+
- Updated starter config, schemas, README, and tests for v1.3 behavior.
|
|
12
|
+
|
|
13
|
+
## 1.2.0
|
|
14
|
+
|
|
15
|
+
- Added `PluginManager` with `preScan`, `postScan`, and `reportHook` lifecycle support.
|
|
16
|
+
- Added disabled-by-default plugin config through `plugins.enabled` and `plugins.paths`.
|
|
17
|
+
- Added `extensions` to analysis output so plugins can enrich results without breaking schema.
|
|
18
|
+
- Added future config slots for risk thresholds, dashboard output, and notification webhook env names.
|
|
19
|
+
- Added regression coverage for plugin hooks enriching `extensions`.
|
|
20
|
+
|
|
5
21
|
## 1.1.0
|
|
6
22
|
|
|
7
23
|
- Added `--focus` modes for targeted duplicate, unused, outdated, risk, and health analysis.
|
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
- Highlight dependency risk signals
|
|
24
24
|
- Score package trust using supply-chain metadata
|
|
25
25
|
- Generate a simple project health score
|
|
26
|
-
- Output reports in console, JSON, Markdown, SARIF, and top-issues formats
|
|
26
|
+
- Output reports in console, JSON, Markdown, SARIF, dashboard, and top-issues formats
|
|
27
27
|
- Gate CI with score and finding policies
|
|
28
28
|
- Compare new findings against a baseline report
|
|
29
29
|
|
|
@@ -48,12 +48,14 @@ The long-term goal is not just to list problems, but to answer:
|
|
|
48
48
|
- JSON output via `--json`
|
|
49
49
|
- Markdown output via `--md`
|
|
50
50
|
- SARIF output via `--sarif`
|
|
51
|
+
- Static HTML dashboard via `--dashboard`
|
|
51
52
|
- Ranked top issues via `--top`
|
|
52
53
|
- Baseline mode via `--baseline`
|
|
53
54
|
- Focused analysis via `--focus`
|
|
54
55
|
- Low-noise CI defaults via `--ci`
|
|
55
56
|
- Starter config generation via `dep-brain init`
|
|
56
57
|
- Reusable GitHub Action via `action.yml`
|
|
58
|
+
- Built-in `license` plugin and plugin diagnostics
|
|
57
59
|
- Library entrypoint for programmatic use
|
|
58
60
|
|
|
59
61
|
## CLI Usage
|
|
@@ -73,6 +75,8 @@ npx dep-brain analyze ./path-to-project --fail-on-unused --json
|
|
|
73
75
|
npx dep-brain analyze --md > depbrain.md
|
|
74
76
|
npx dep-brain analyze --json --out depbrain.json
|
|
75
77
|
npx dep-brain analyze --sarif --out depbrain.sarif
|
|
78
|
+
npx dep-brain analyze --dashboard
|
|
79
|
+
npx dep-brain analyze --dashboard --dashboard-out reports/depbrain.html
|
|
76
80
|
npx dep-brain analyze --focus duplicates
|
|
77
81
|
npx dep-brain analyze --ci
|
|
78
82
|
npx dep-brain analyze --baseline depbrain-baseline.json
|
|
@@ -176,6 +180,28 @@ dep-brain analyze --top
|
|
|
176
180
|
|
|
177
181
|
Shows the highest-priority actionable findings first, including confidence and next-step guidance.
|
|
178
182
|
|
|
183
|
+
## Dashboard Output
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
dep-brain analyze --dashboard
|
|
187
|
+
dep-brain analyze --dashboard --dashboard-out reports/depbrain.html
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Writes a static HTML dashboard. Default path comes from `dashboard.outputPath`.
|
|
191
|
+
|
|
192
|
+
## Plugins
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
{
|
|
196
|
+
"plugins": {
|
|
197
|
+
"enabled": ["license"],
|
|
198
|
+
"paths": ["./depbrain-plugin.mjs"]
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Built-in `license` plugin adds license counts under `extensions.license`. Failed plugin loads and hook errors are reported under `extensions.depBrain.plugins`.
|
|
204
|
+
|
|
179
205
|
## Report From JSON
|
|
180
206
|
|
|
181
207
|
```bash
|
|
@@ -194,30 +220,6 @@ dep-brain analyze --baseline depbrain-baseline.json --min-score 90 --fail-on-ris
|
|
|
194
220
|
|
|
195
221
|
The baseline file is a normal JSON analysis report. Matching entries in `duplicates`, `unused`, `outdated`, and `risks` are ignored before score, policy, suggestions, and top issues are calculated.
|
|
196
222
|
|
|
197
|
-
## GitHub Action
|
|
198
|
-
|
|
199
|
-
```yaml
|
|
200
|
-
name: Dependency Brain
|
|
201
|
-
|
|
202
|
-
on:
|
|
203
|
-
pull_request:
|
|
204
|
-
|
|
205
|
-
jobs:
|
|
206
|
-
dep-brain:
|
|
207
|
-
runs-on: ubuntu-latest
|
|
208
|
-
steps:
|
|
209
|
-
- uses: actions/checkout@v4
|
|
210
|
-
- uses: actions/setup-node@v4
|
|
211
|
-
with:
|
|
212
|
-
node-version: 22
|
|
213
|
-
- uses: prakashu51/dep-brain@v1
|
|
214
|
-
with:
|
|
215
|
-
format: sarif
|
|
216
|
-
out: depbrain.sarif
|
|
217
|
-
min-score: 85
|
|
218
|
-
fail-on-risks: "true"
|
|
219
|
-
```
|
|
220
|
-
|
|
221
223
|
## Config File
|
|
222
224
|
|
|
223
225
|
Create a `depbrain.config.json` file in the project root:
|
|
@@ -239,12 +241,17 @@ Create a `depbrain.config.json` file in the project root:
|
|
|
239
241
|
"maxSuggestions": 3
|
|
240
242
|
},
|
|
241
243
|
"plugins": {
|
|
242
|
-
"enabled": [],
|
|
244
|
+
"enabled": ["license"],
|
|
243
245
|
"paths": []
|
|
244
246
|
},
|
|
245
247
|
"risk": {
|
|
246
248
|
"transitiveBloatThreshold": 50,
|
|
247
|
-
"typosquattingDistanceThreshold": 2
|
|
249
|
+
"typosquattingDistanceThreshold": 2,
|
|
250
|
+
"staleReleaseDays": 730,
|
|
251
|
+
"agingReleaseDays": 365,
|
|
252
|
+
"lowDownloadThreshold": 1000,
|
|
253
|
+
"lowTrustWeightThreshold": 6,
|
|
254
|
+
"mediumTrustWeightThreshold": 3
|
|
248
255
|
},
|
|
249
256
|
"dashboard": {
|
|
250
257
|
"outputPath": "depbrain-dashboard.html"
|
|
@@ -283,6 +290,11 @@ Supported sections:
|
|
|
283
290
|
- `plugins.paths`
|
|
284
291
|
- `risk.transitiveBloatThreshold`
|
|
285
292
|
- `risk.typosquattingDistanceThreshold`
|
|
293
|
+
- `risk.staleReleaseDays`
|
|
294
|
+
- `risk.agingReleaseDays`
|
|
295
|
+
- `risk.lowDownloadThreshold`
|
|
296
|
+
- `risk.lowTrustWeightThreshold`
|
|
297
|
+
- `risk.mediumTrustWeightThreshold`
|
|
286
298
|
- `dashboard.outputPath`
|
|
287
299
|
- `notifications.slackWebhookEnv`
|
|
288
300
|
- `notifications.discordWebhookEnv`
|
package/depbrain.config.json
CHANGED
|
@@ -25,7 +25,12 @@
|
|
|
25
25
|
},
|
|
26
26
|
"risk": {
|
|
27
27
|
"transitiveBloatThreshold": 50,
|
|
28
|
-
"typosquattingDistanceThreshold": 2
|
|
28
|
+
"typosquattingDistanceThreshold": 2,
|
|
29
|
+
"staleReleaseDays": 730,
|
|
30
|
+
"agingReleaseDays": 365,
|
|
31
|
+
"lowDownloadThreshold": 1000,
|
|
32
|
+
"lowTrustWeightThreshold": 6,
|
|
33
|
+
"mediumTrustWeightThreshold": 3
|
|
29
34
|
},
|
|
30
35
|
"dashboard": {
|
|
31
36
|
"outputPath": "depbrain-dashboard.html"
|
|
@@ -49,7 +49,12 @@
|
|
|
49
49
|
"additionalProperties": false,
|
|
50
50
|
"properties": {
|
|
51
51
|
"transitiveBloatThreshold": { "type": "number" },
|
|
52
|
-
"typosquattingDistanceThreshold": { "type": "number" }
|
|
52
|
+
"typosquattingDistanceThreshold": { "type": "number" },
|
|
53
|
+
"staleReleaseDays": { "type": "number" },
|
|
54
|
+
"agingReleaseDays": { "type": "number" },
|
|
55
|
+
"lowDownloadThreshold": { "type": "number" },
|
|
56
|
+
"lowTrustWeightThreshold": { "type": "number" },
|
|
57
|
+
"mediumTrustWeightThreshold": { "type": "number" }
|
|
53
58
|
}
|
|
54
59
|
},
|
|
55
60
|
"dashboard": {
|
package/dist/checks/risk.d.ts
CHANGED
|
@@ -2,8 +2,10 @@ import type { DependencyGraph } from "../core/graph-builder.js";
|
|
|
2
2
|
import type { RiskDependency } from "../core/analyzer.js";
|
|
3
3
|
import type { CheckResult } from "../core/types.js";
|
|
4
4
|
import { type PackageMetadata } from "../utils/npm-api.js";
|
|
5
|
+
import type { DepBrainConfig } from "../utils/config.js";
|
|
5
6
|
export interface RiskCheckOptions {
|
|
6
7
|
resolvePackageMetadata?: (name: string) => Promise<PackageMetadata | null>;
|
|
8
|
+
thresholds?: DepBrainConfig["risk"];
|
|
7
9
|
}
|
|
8
10
|
export declare function findRiskDependencies(graph: DependencyGraph, options?: RiskCheckOptions): Promise<RiskDependency[]>;
|
|
9
|
-
export declare function runRiskCheck(graph: DependencyGraph): Promise<CheckResult>;
|
|
11
|
+
export declare function runRiskCheck(graph: DependencyGraph, options?: RiskCheckOptions): Promise<CheckResult>;
|
package/dist/checks/risk.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { getPackageMetadata } from "../utils/npm-api.js";
|
|
2
|
-
const TWO_YEARS_IN_DAYS = 365 * 2;
|
|
3
|
-
const ONE_YEAR_IN_DAYS = 365;
|
|
4
2
|
export async function findRiskDependencies(graph, options = {}) {
|
|
5
3
|
const resolvePackageMetadata = options.resolvePackageMetadata ?? getPackageMetadata;
|
|
6
4
|
const names = Object.keys({
|
|
@@ -17,7 +15,7 @@ export async function findRiskDependencies(graph, options = {}) {
|
|
|
17
15
|
: graph.devDependencies[name]
|
|
18
16
|
? "devDependencies"
|
|
19
17
|
: "unknown";
|
|
20
|
-
const assessment = assessRisk(metadata, dependencyType);
|
|
18
|
+
const assessment = assessRisk(metadata, dependencyType, options.thresholds);
|
|
21
19
|
if (!shouldReportRisk(assessment.trustScore, dependencyType)) {
|
|
22
20
|
return null;
|
|
23
21
|
}
|
|
@@ -50,8 +48,8 @@ async function mapWithConcurrency(items, limit, mapper) {
|
|
|
50
48
|
await Promise.all(workers);
|
|
51
49
|
return results;
|
|
52
50
|
}
|
|
53
|
-
export async function runRiskCheck(graph) {
|
|
54
|
-
const risks = await findRiskDependencies(graph);
|
|
51
|
+
export async function runRiskCheck(graph, options = {}) {
|
|
52
|
+
const risks = await findRiskDependencies(graph, options);
|
|
55
53
|
return {
|
|
56
54
|
name: "risk",
|
|
57
55
|
summary: `${risks.length} risky dependencies found`,
|
|
@@ -75,22 +73,27 @@ export async function runRiskCheck(graph) {
|
|
|
75
73
|
}))
|
|
76
74
|
};
|
|
77
75
|
}
|
|
78
|
-
function assessRisk(metadata, dependencyType) {
|
|
76
|
+
function assessRisk(metadata, dependencyType, thresholds) {
|
|
79
77
|
const reasons = [];
|
|
80
78
|
const reasonCodes = [];
|
|
81
79
|
let weight = 0;
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
const staleReleaseDays = thresholds?.staleReleaseDays ?? 730;
|
|
81
|
+
const agingReleaseDays = thresholds?.agingReleaseDays ?? 365;
|
|
82
|
+
const lowDownloadThreshold = thresholds?.lowDownloadThreshold ?? 1000;
|
|
83
|
+
const lowTrustWeightThreshold = thresholds?.lowTrustWeightThreshold ?? 6;
|
|
84
|
+
const mediumTrustWeightThreshold = thresholds?.mediumTrustWeightThreshold ?? 3;
|
|
85
|
+
if (metadata.daysSincePublish !== null && metadata.daysSincePublish > staleReleaseDays) {
|
|
86
|
+
reasons.push(`No release in over ${formatDays(staleReleaseDays)}`);
|
|
84
87
|
reasonCodes.push("stale_release");
|
|
85
88
|
weight += 3;
|
|
86
89
|
}
|
|
87
90
|
else if (metadata.daysSincePublish !== null &&
|
|
88
|
-
metadata.daysSincePublish >
|
|
89
|
-
reasons.push(
|
|
91
|
+
metadata.daysSincePublish > agingReleaseDays) {
|
|
92
|
+
reasons.push(`No release in over ${formatDays(agingReleaseDays)}`);
|
|
90
93
|
reasonCodes.push("aging_release");
|
|
91
94
|
weight += 2;
|
|
92
95
|
}
|
|
93
|
-
if (metadata.downloads !== null && metadata.downloads <
|
|
96
|
+
if (metadata.downloads !== null && metadata.downloads < lowDownloadThreshold) {
|
|
94
97
|
reasons.push("Low weekly download volume");
|
|
95
98
|
reasonCodes.push("low_download_volume");
|
|
96
99
|
weight += 2;
|
|
@@ -118,7 +121,11 @@ function assessRisk(metadata, dependencyType) {
|
|
|
118
121
|
weight += 1;
|
|
119
122
|
}
|
|
120
123
|
const confidence = reasons.length === 0 ? 0.5 : Math.min(0.99, 0.52 + weight * 0.07);
|
|
121
|
-
const trustScore = weight >=
|
|
124
|
+
const trustScore = weight >= lowTrustWeightThreshold
|
|
125
|
+
? "low"
|
|
126
|
+
: weight >= mediumTrustWeightThreshold
|
|
127
|
+
? "medium"
|
|
128
|
+
: "high";
|
|
122
129
|
return {
|
|
123
130
|
confidence,
|
|
124
131
|
trustScore,
|
|
@@ -135,6 +142,15 @@ function assessRisk(metadata, dependencyType) {
|
|
|
135
142
|
}
|
|
136
143
|
};
|
|
137
144
|
}
|
|
145
|
+
function formatDays(days) {
|
|
146
|
+
if (days === 730) {
|
|
147
|
+
return "2 years";
|
|
148
|
+
}
|
|
149
|
+
if (days === 365) {
|
|
150
|
+
return "12 months";
|
|
151
|
+
}
|
|
152
|
+
return `${days} days`;
|
|
153
|
+
}
|
|
138
154
|
function shouldReportRisk(trustScore, dependencyType) {
|
|
139
155
|
if (trustScore === "high") {
|
|
140
156
|
return false;
|
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { renderConsoleReport } from "./reporters/console.js";
|
|
|
4
4
|
import { renderJsonReport } from "./reporters/json.js";
|
|
5
5
|
import { renderMarkdownReport } from "./reporters/markdown.js";
|
|
6
6
|
import { renderSarifReport } from "./reporters/sarif.js";
|
|
7
|
+
import { renderDashboardReport } from "./reporters/dashboard.js";
|
|
7
8
|
import { defaultConfig } from "./utils/config.js";
|
|
8
9
|
import { promises as fs } from "node:fs";
|
|
9
10
|
import path from "node:path";
|
|
@@ -59,7 +60,9 @@ async function main() {
|
|
|
59
60
|
? JSON.stringify(reportData, null, 2)
|
|
60
61
|
: flags.has("--sarif")
|
|
61
62
|
? renderSarifReport(reportData)
|
|
62
|
-
:
|
|
63
|
+
: flags.has("--dashboard")
|
|
64
|
+
? renderDashboardReport(reportData)
|
|
65
|
+
: renderMarkdownReport(reportData);
|
|
63
66
|
await writeOutput(output, optionValues.get("--out"));
|
|
64
67
|
return;
|
|
65
68
|
}
|
|
@@ -149,6 +152,9 @@ async function main() {
|
|
|
149
152
|
: consoleOutput;
|
|
150
153
|
}
|
|
151
154
|
await writeOutput(output, optionValues.get("--out"));
|
|
155
|
+
if (flags.has("--dashboard")) {
|
|
156
|
+
await writeOutput(renderDashboardReport(result), optionValues.get("--dashboard-out") ?? result.config.dashboard.outputPath);
|
|
157
|
+
}
|
|
152
158
|
if (!result.policy.passed) {
|
|
153
159
|
process.exitCode = 1;
|
|
154
160
|
}
|
|
@@ -209,8 +215,8 @@ function printHelp() {
|
|
|
209
215
|
console.log("Dependency Brain");
|
|
210
216
|
console.log("");
|
|
211
217
|
console.log("Usage:");
|
|
212
|
-
console.log(" dep-brain analyze [path] [--json] [--md] [--sarif] [--top] [--focus kind] [--ci] [--out path] [--config path] [--baseline path] [--min-score n] [--fail-on-risks]");
|
|
213
|
-
console.log(" dep-brain report --from <file> [--md] [--json] [--sarif] [--top] [--out path]");
|
|
218
|
+
console.log(" dep-brain analyze [path] [--json] [--md] [--sarif] [--top] [--dashboard] [--focus kind] [--ci] [--out path] [--config path] [--baseline path] [--min-score n] [--fail-on-risks]");
|
|
219
|
+
console.log(" dep-brain report --from <file> [--md] [--json] [--sarif] [--top] [--dashboard] [--out path]");
|
|
214
220
|
console.log(" dep-brain config [path] [--config path]");
|
|
215
221
|
console.log(" dep-brain init [--out depbrain.config.json]");
|
|
216
222
|
console.log(" dep-brain help");
|
|
@@ -221,6 +227,8 @@ function printHelp() {
|
|
|
221
227
|
console.log(" --md Output Markdown report");
|
|
222
228
|
console.log(" --sarif Output SARIF format for Code Scanning");
|
|
223
229
|
console.log(" --top Output the ranked top issues only");
|
|
230
|
+
console.log(" --dashboard Write an HTML dashboard");
|
|
231
|
+
console.log(" --dashboard-out <path> Write dashboard HTML to a custom path");
|
|
224
232
|
console.log(" --focus <kind> Run all, health, duplicates, unused, outdated, or risks");
|
|
225
233
|
console.log(" --ci Apply low-noise CI defaults");
|
|
226
234
|
console.log(" --config <path> Path to depbrain.config.json");
|
package/dist/core/analyzer.js
CHANGED
|
@@ -143,7 +143,12 @@ function mergeConfig(base, overrides) {
|
|
|
143
143
|
},
|
|
144
144
|
risk: {
|
|
145
145
|
transitiveBloatThreshold: overrides.risk?.transitiveBloatThreshold ?? base.risk.transitiveBloatThreshold,
|
|
146
|
-
typosquattingDistanceThreshold: overrides.risk?.typosquattingDistanceThreshold ?? base.risk.typosquattingDistanceThreshold
|
|
146
|
+
typosquattingDistanceThreshold: overrides.risk?.typosquattingDistanceThreshold ?? base.risk.typosquattingDistanceThreshold,
|
|
147
|
+
staleReleaseDays: overrides.risk?.staleReleaseDays ?? base.risk.staleReleaseDays,
|
|
148
|
+
agingReleaseDays: overrides.risk?.agingReleaseDays ?? base.risk.agingReleaseDays,
|
|
149
|
+
lowDownloadThreshold: overrides.risk?.lowDownloadThreshold ?? base.risk.lowDownloadThreshold,
|
|
150
|
+
lowTrustWeightThreshold: overrides.risk?.lowTrustWeightThreshold ?? base.risk.lowTrustWeightThreshold,
|
|
151
|
+
mediumTrustWeightThreshold: overrides.risk?.mediumTrustWeightThreshold ?? base.risk.mediumTrustWeightThreshold
|
|
147
152
|
},
|
|
148
153
|
dashboard: {
|
|
149
154
|
outputPath: overrides.dashboard?.outputPath ?? base.dashboard.outputPath
|
|
@@ -187,7 +192,7 @@ function evaluatePolicy(summary, config) {
|
|
|
187
192
|
}
|
|
188
193
|
async function analyzeSingleProject(rootDir, config, options = {}) {
|
|
189
194
|
const context = await buildAnalysisContext(rootDir, config);
|
|
190
|
-
const results = await runChecks(context, options.focus ?? "all");
|
|
195
|
+
const results = await runChecks(context, options.focus ?? "all", config);
|
|
191
196
|
const issueGroups = normalizeIssues(results, config);
|
|
192
197
|
const duplicates = mapDuplicateIssues(issueGroups.duplicates);
|
|
193
198
|
const unused = mapUnusedIssues(issueGroups.unused);
|
|
@@ -280,7 +285,7 @@ function shouldIgnorePackage(name, bucket, config) {
|
|
|
280
285
|
}
|
|
281
286
|
});
|
|
282
287
|
}
|
|
283
|
-
async function runChecks(context, focus) {
|
|
288
|
+
async function runChecks(context, focus, config) {
|
|
284
289
|
const checks = [
|
|
285
290
|
{
|
|
286
291
|
name: "duplicate",
|
|
@@ -296,7 +301,7 @@ async function runChecks(context, focus) {
|
|
|
296
301
|
},
|
|
297
302
|
{
|
|
298
303
|
name: "risk",
|
|
299
|
-
run: () => runRiskCheck(context.graph)
|
|
304
|
+
run: () => runRiskCheck(context.graph, { thresholds: config.risk })
|
|
300
305
|
}
|
|
301
306
|
];
|
|
302
307
|
const results = [];
|
|
@@ -11,10 +11,19 @@ export interface DepBrainPlugin {
|
|
|
11
11
|
reportHook?: (result: AnalysisResult) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
|
|
12
12
|
cliCommands?: (cli: unknown) => void;
|
|
13
13
|
}
|
|
14
|
+
export interface PluginDiagnostic {
|
|
15
|
+
spec: string;
|
|
16
|
+
code: "load_failed" | "invalid_plugin" | "hook_failed";
|
|
17
|
+
message: string;
|
|
18
|
+
plugin?: string;
|
|
19
|
+
hook?: "preScan" | "postScan" | "reportHook";
|
|
20
|
+
}
|
|
14
21
|
export declare class PluginManager {
|
|
15
22
|
private readonly plugins;
|
|
23
|
+
private readonly diagnostics;
|
|
16
24
|
private constructor();
|
|
17
25
|
static load(rootDir: string, config: DepBrainConfig): Promise<PluginManager>;
|
|
18
26
|
runPreScan(context: ProjectContext): Promise<void>;
|
|
19
27
|
runPostScan(result: AnalysisResult): Promise<AnalysisResult>;
|
|
28
|
+
private attachDiagnostics;
|
|
20
29
|
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { pathToFileURL } from "node:url";
|
|
3
4
|
export class PluginManager {
|
|
4
5
|
plugins;
|
|
5
|
-
|
|
6
|
+
diagnostics;
|
|
7
|
+
constructor(plugins, diagnostics) {
|
|
6
8
|
this.plugins = plugins;
|
|
9
|
+
this.diagnostics = diagnostics;
|
|
7
10
|
}
|
|
8
11
|
static async load(rootDir, config) {
|
|
9
12
|
const specs = [
|
|
@@ -11,38 +14,71 @@ export class PluginManager {
|
|
|
11
14
|
...config.plugins.paths
|
|
12
15
|
];
|
|
13
16
|
const plugins = [];
|
|
17
|
+
const diagnostics = [];
|
|
14
18
|
for (const spec of specs) {
|
|
15
|
-
const
|
|
16
|
-
if (plugin) {
|
|
17
|
-
plugins.push(plugin);
|
|
19
|
+
const result = await loadPlugin(rootDir, spec);
|
|
20
|
+
if (result.plugin) {
|
|
21
|
+
plugins.push(result.plugin);
|
|
22
|
+
}
|
|
23
|
+
if (result.diagnostic) {
|
|
24
|
+
diagnostics.push(result.diagnostic);
|
|
18
25
|
}
|
|
19
26
|
}
|
|
20
|
-
return new PluginManager(plugins);
|
|
27
|
+
return new PluginManager(plugins, diagnostics);
|
|
21
28
|
}
|
|
22
29
|
async runPreScan(context) {
|
|
23
30
|
for (const plugin of this.plugins) {
|
|
24
|
-
|
|
31
|
+
try {
|
|
32
|
+
await plugin.preScan?.(context);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
this.diagnostics.push(buildHookDiagnostic(plugin.name, "preScan", error));
|
|
36
|
+
}
|
|
25
37
|
}
|
|
26
38
|
}
|
|
27
39
|
async runPostScan(result) {
|
|
28
40
|
let current = result;
|
|
29
41
|
for (const plugin of this.plugins) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
42
|
+
try {
|
|
43
|
+
const next = await plugin.postScan?.(current);
|
|
44
|
+
if (next) {
|
|
45
|
+
current = next;
|
|
46
|
+
}
|
|
33
47
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
current.extensions[plugin.name] = {
|
|
37
|
-
...(asRecord(current.extensions[plugin.name]) ?? {}),
|
|
38
|
-
...reportSection
|
|
39
|
-
};
|
|
48
|
+
catch (error) {
|
|
49
|
+
this.diagnostics.push(buildHookDiagnostic(plugin.name, "postScan", error));
|
|
40
50
|
}
|
|
51
|
+
try {
|
|
52
|
+
const reportSection = await plugin.reportHook?.(current);
|
|
53
|
+
if (reportSection) {
|
|
54
|
+
current.extensions[plugin.name] = {
|
|
55
|
+
...(asRecord(current.extensions[plugin.name]) ?? {}),
|
|
56
|
+
...reportSection
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
this.diagnostics.push(buildHookDiagnostic(plugin.name, "reportHook", error));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return this.attachDiagnostics(current);
|
|
65
|
+
}
|
|
66
|
+
attachDiagnostics(result) {
|
|
67
|
+
if (this.diagnostics.length === 0) {
|
|
68
|
+
return result;
|
|
41
69
|
}
|
|
42
|
-
|
|
70
|
+
result.extensions.depBrain = {
|
|
71
|
+
...(asRecord(result.extensions.depBrain) ?? {}),
|
|
72
|
+
plugins: this.diagnostics
|
|
73
|
+
};
|
|
74
|
+
return result;
|
|
43
75
|
}
|
|
44
76
|
}
|
|
45
77
|
async function loadPlugin(rootDir, spec) {
|
|
78
|
+
const builtIn = getBuiltInPlugin(spec);
|
|
79
|
+
if (builtIn) {
|
|
80
|
+
return { plugin: builtIn };
|
|
81
|
+
}
|
|
46
82
|
try {
|
|
47
83
|
const resolved = spec.startsWith(".") || path.isAbsolute(spec)
|
|
48
84
|
? path.resolve(rootDir, spec)
|
|
@@ -51,11 +87,99 @@ async function loadPlugin(rootDir, spec) {
|
|
|
51
87
|
const mod = await import(moduleUrl);
|
|
52
88
|
const exported = mod.default ?? mod.plugin ?? mod;
|
|
53
89
|
const candidate = typeof exported === "function" ? new exported() : exported;
|
|
54
|
-
|
|
90
|
+
if (isPlugin(candidate)) {
|
|
91
|
+
return { plugin: candidate };
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
plugin: null,
|
|
95
|
+
diagnostic: {
|
|
96
|
+
spec,
|
|
97
|
+
code: "invalid_plugin",
|
|
98
|
+
message: "Plugin must export an object with a string name."
|
|
99
|
+
}
|
|
100
|
+
};
|
|
55
101
|
}
|
|
56
|
-
catch {
|
|
102
|
+
catch (error) {
|
|
103
|
+
return {
|
|
104
|
+
plugin: null,
|
|
105
|
+
diagnostic: {
|
|
106
|
+
spec,
|
|
107
|
+
code: "load_failed",
|
|
108
|
+
message: error instanceof Error ? error.message : String(error)
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function getBuiltInPlugin(spec) {
|
|
114
|
+
if (spec !== "license" && spec !== "dep-brain-plugin-license") {
|
|
57
115
|
return null;
|
|
58
116
|
}
|
|
117
|
+
return {
|
|
118
|
+
name: "license",
|
|
119
|
+
reportHook: async (result) => {
|
|
120
|
+
const packages = await collectLicensePackages(result.rootDir);
|
|
121
|
+
const licenses = packages.reduce((acc, item) => {
|
|
122
|
+
acc[item.license] = (acc[item.license] ?? 0) + 1;
|
|
123
|
+
return acc;
|
|
124
|
+
}, {});
|
|
125
|
+
return {
|
|
126
|
+
summary: {
|
|
127
|
+
total: packages.length,
|
|
128
|
+
unknown: packages.filter((item) => item.license === "UNKNOWN").length,
|
|
129
|
+
licenses
|
|
130
|
+
},
|
|
131
|
+
packages
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
async function collectLicensePackages(rootDir) {
|
|
137
|
+
const raw = await fs.readFile(path.join(rootDir, "package.json"), "utf8");
|
|
138
|
+
const pkg = JSON.parse(raw);
|
|
139
|
+
const names = Object.keys({
|
|
140
|
+
...(pkg.dependencies ?? {}),
|
|
141
|
+
...(pkg.devDependencies ?? {})
|
|
142
|
+
}).sort();
|
|
143
|
+
return Promise.all(names.map(async (name) => ({
|
|
144
|
+
name,
|
|
145
|
+
license: await readPackageLicense(rootDir, name)
|
|
146
|
+
})));
|
|
147
|
+
}
|
|
148
|
+
async function readPackageLicense(rootDir, name) {
|
|
149
|
+
try {
|
|
150
|
+
const raw = await fs.readFile(path.join(rootDir, "node_modules", name, "package.json"), "utf8");
|
|
151
|
+
const pkg = JSON.parse(raw);
|
|
152
|
+
if (typeof pkg.license === "string" && pkg.license.trim().length > 0) {
|
|
153
|
+
return pkg.license;
|
|
154
|
+
}
|
|
155
|
+
if (Array.isArray(pkg.licenses) && pkg.licenses.length > 0) {
|
|
156
|
+
const licenses = pkg.licenses
|
|
157
|
+
.map((item) => {
|
|
158
|
+
if (typeof item === "string") {
|
|
159
|
+
return item;
|
|
160
|
+
}
|
|
161
|
+
if (item && typeof item === "object" && typeof item.type === "string") {
|
|
162
|
+
return item.type;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
})
|
|
166
|
+
.filter((item) => Boolean(item));
|
|
167
|
+
return licenses.length > 0 ? licenses.join(", ") : "UNKNOWN";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return "UNKNOWN";
|
|
172
|
+
}
|
|
173
|
+
return "UNKNOWN";
|
|
174
|
+
}
|
|
175
|
+
function buildHookDiagnostic(plugin, hook, error) {
|
|
176
|
+
return {
|
|
177
|
+
spec: plugin,
|
|
178
|
+
plugin,
|
|
179
|
+
hook,
|
|
180
|
+
code: "hook_failed",
|
|
181
|
+
message: error instanceof Error ? error.message : String(error)
|
|
182
|
+
};
|
|
59
183
|
}
|
|
60
184
|
function isPlugin(value) {
|
|
61
185
|
return Boolean(value &&
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { analyzeProject } from "./core/analyzer.js";
|
|
|
2
2
|
export type { AnalysisOptions, AnalysisFocus, AnalysisResult, DepBrainBaseline, DuplicateDependency, OutdatedDependency, PolicyResult, PackageAnalysisResult, Recommendation, RiskFactors, ScoreBreakdown, RiskDependency, TopIssue, TrustScore, UnusedDependency, WorkspaceDependencyUsage, WorkspaceOwnershipSummary } from "./core/analyzer.js";
|
|
3
3
|
export { OUTPUT_VERSION } from "./core/analyzer.js";
|
|
4
4
|
export { PluginManager } from "./core/plugin-manager.js";
|
|
5
|
-
export type { DepBrainPlugin, ProjectContext } from "./core/plugin-manager.js";
|
|
5
|
+
export type { DepBrainPlugin, PluginDiagnostic, ProjectContext } from "./core/plugin-manager.js";
|
|
6
6
|
export type { AnalysisContext, CheckResult, Issue } from "./core/types.js";
|
|
7
7
|
export type { DepBrainConfig, DepBrainConfigOverrides } from "./utils/config.js";
|
|
8
8
|
export type { WorkspacePackage } from "./utils/workspaces.js";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export function renderDashboardReport(result) {
|
|
2
|
+
const topIssues = result.topIssues.map(renderTopIssue).join("");
|
|
3
|
+
const suggestions = result.suggestions.map((item) => `<li>${escapeHtml(item)}</li>`).join("");
|
|
4
|
+
return [
|
|
5
|
+
"<!doctype html>",
|
|
6
|
+
'<html lang="en">',
|
|
7
|
+
"<head>",
|
|
8
|
+
'<meta charset="utf-8">',
|
|
9
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
10
|
+
"<title>Dependency Brain Dashboard</title>",
|
|
11
|
+
"<style>",
|
|
12
|
+
"body{font-family:Arial,sans-serif;margin:0;color:#172033;background:#f6f8fb}",
|
|
13
|
+
"main{max-width:1120px;margin:0 auto;padding:32px}",
|
|
14
|
+
"header{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;margin-bottom:24px}",
|
|
15
|
+
"h1{font-size:28px;margin:0 0 8px}",
|
|
16
|
+
"h2{font-size:18px;margin:0 0 12px}",
|
|
17
|
+
".muted{color:#637083;font-size:13px}",
|
|
18
|
+
".score{font-size:48px;font-weight:700}",
|
|
19
|
+
".grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:20px}",
|
|
20
|
+
".panel{background:#fff;border:1px solid #dce3ee;border-radius:8px;padding:16px}",
|
|
21
|
+
".metric{font-size:28px;font-weight:700;margin-top:4px}",
|
|
22
|
+
".pass{color:#167a43}.fail{color:#b42318}",
|
|
23
|
+
"ol,ul{margin:0;padding-left:20px}",
|
|
24
|
+
"li{margin:8px 0}",
|
|
25
|
+
".issue{margin-bottom:10px}",
|
|
26
|
+
".kind{font-size:12px;text-transform:uppercase;color:#637083}",
|
|
27
|
+
"@media(max-width:760px){main{padding:20px}.grid{grid-template-columns:repeat(2,minmax(0,1fr))}header{display:block}.score{font-size:40px}}",
|
|
28
|
+
"</style>",
|
|
29
|
+
"</head>",
|
|
30
|
+
"<body>",
|
|
31
|
+
"<main>",
|
|
32
|
+
"<header>",
|
|
33
|
+
"<div>",
|
|
34
|
+
"<h1>Dependency Brain Dashboard</h1>",
|
|
35
|
+
`<div class="muted">${escapeHtml(result.rootDir)}</div>`,
|
|
36
|
+
"</div>",
|
|
37
|
+
`<div class="${result.policy.passed ? "pass" : "fail"} score">${result.score}/100</div>`,
|
|
38
|
+
"</header>",
|
|
39
|
+
'<section class="grid">',
|
|
40
|
+
renderMetric("Duplicates", result.duplicates.length),
|
|
41
|
+
renderMetric("Unused", result.unused.length),
|
|
42
|
+
renderMetric("Outdated", result.outdated.length),
|
|
43
|
+
renderMetric("Risks", result.risks.length),
|
|
44
|
+
"</section>",
|
|
45
|
+
'<section class="panel">',
|
|
46
|
+
"<h2>Policy</h2>",
|
|
47
|
+
`<p class="${result.policy.passed ? "pass" : "fail"}">${result.policy.passed ? "Passed" : "Failed"}</p>`,
|
|
48
|
+
result.policy.reasons.length > 0
|
|
49
|
+
? `<ul>${result.policy.reasons.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>`
|
|
50
|
+
: '<p class="muted">No policy failures.</p>',
|
|
51
|
+
"</section>",
|
|
52
|
+
'<section class="panel">',
|
|
53
|
+
"<h2>Top Issues</h2>",
|
|
54
|
+
topIssues.length > 0 ? `<ol>${topIssues}</ol>` : '<p class="muted">No actionable issues found.</p>',
|
|
55
|
+
"</section>",
|
|
56
|
+
'<section class="panel">',
|
|
57
|
+
"<h2>Suggestions</h2>",
|
|
58
|
+
suggestions.length > 0 ? `<ul>${suggestions}</ul>` : '<p class="muted">No suggestions.</p>',
|
|
59
|
+
"</section>",
|
|
60
|
+
"</main>",
|
|
61
|
+
"</body>",
|
|
62
|
+
"</html>"
|
|
63
|
+
].join("\n");
|
|
64
|
+
}
|
|
65
|
+
function renderMetric(label, value) {
|
|
66
|
+
return `<div class="panel"><div class="muted">${escapeHtml(label)}</div><div class="metric">${value}</div></div>`;
|
|
67
|
+
}
|
|
68
|
+
function renderTopIssue(item) {
|
|
69
|
+
return [
|
|
70
|
+
'<li class="issue">',
|
|
71
|
+
`<div class="kind">${escapeHtml(item.kind)} ${escapeHtml(item.priority)}</div>`,
|
|
72
|
+
`<strong>${escapeHtml(item.name)}</strong>`,
|
|
73
|
+
`<div>${escapeHtml(item.recommendation.summary)}</div>`,
|
|
74
|
+
"</li>"
|
|
75
|
+
].join("");
|
|
76
|
+
}
|
|
77
|
+
function escapeHtml(value) {
|
|
78
|
+
return value
|
|
79
|
+
.replace(/&/g, "&")
|
|
80
|
+
.replace(/</g, "<")
|
|
81
|
+
.replace(/>/g, ">")
|
|
82
|
+
.replace(/"/g, """)
|
|
83
|
+
.replace(/'/g, "'");
|
|
84
|
+
}
|
package/dist/utils/config.d.ts
CHANGED
|
@@ -26,6 +26,11 @@ export interface DepBrainConfig {
|
|
|
26
26
|
risk: {
|
|
27
27
|
transitiveBloatThreshold: number;
|
|
28
28
|
typosquattingDistanceThreshold: number;
|
|
29
|
+
staleReleaseDays: number;
|
|
30
|
+
agingReleaseDays: number;
|
|
31
|
+
lowDownloadThreshold: number;
|
|
32
|
+
lowTrustWeightThreshold: number;
|
|
33
|
+
mediumTrustWeightThreshold: number;
|
|
29
34
|
};
|
|
30
35
|
dashboard: {
|
|
31
36
|
outputPath: string;
|
package/dist/utils/config.js
CHANGED
|
@@ -27,7 +27,12 @@ export const defaultConfig = {
|
|
|
27
27
|
},
|
|
28
28
|
risk: {
|
|
29
29
|
transitiveBloatThreshold: 50,
|
|
30
|
-
typosquattingDistanceThreshold: 2
|
|
30
|
+
typosquattingDistanceThreshold: 2,
|
|
31
|
+
staleReleaseDays: 730,
|
|
32
|
+
agingReleaseDays: 365,
|
|
33
|
+
lowDownloadThreshold: 1000,
|
|
34
|
+
lowTrustWeightThreshold: 6,
|
|
35
|
+
mediumTrustWeightThreshold: 3
|
|
31
36
|
},
|
|
32
37
|
dashboard: {
|
|
33
38
|
outputPath: "depbrain-dashboard.html"
|
|
@@ -84,7 +89,12 @@ function normalizeConfig(loaded) {
|
|
|
84
89
|
},
|
|
85
90
|
risk: {
|
|
86
91
|
transitiveBloatThreshold: normalizeNumber(loaded.risk?.transitiveBloatThreshold, defaultConfig.risk.transitiveBloatThreshold),
|
|
87
|
-
typosquattingDistanceThreshold: normalizeNumber(loaded.risk?.typosquattingDistanceThreshold, defaultConfig.risk.typosquattingDistanceThreshold)
|
|
92
|
+
typosquattingDistanceThreshold: normalizeNumber(loaded.risk?.typosquattingDistanceThreshold, defaultConfig.risk.typosquattingDistanceThreshold),
|
|
93
|
+
staleReleaseDays: normalizeNumber(loaded.risk?.staleReleaseDays, defaultConfig.risk.staleReleaseDays),
|
|
94
|
+
agingReleaseDays: normalizeNumber(loaded.risk?.agingReleaseDays, defaultConfig.risk.agingReleaseDays),
|
|
95
|
+
lowDownloadThreshold: normalizeNumber(loaded.risk?.lowDownloadThreshold, defaultConfig.risk.lowDownloadThreshold),
|
|
96
|
+
lowTrustWeightThreshold: normalizeNumber(loaded.risk?.lowTrustWeightThreshold, defaultConfig.risk.lowTrustWeightThreshold),
|
|
97
|
+
mediumTrustWeightThreshold: normalizeNumber(loaded.risk?.mediumTrustWeightThreshold, defaultConfig.risk.mediumTrustWeightThreshold)
|
|
88
98
|
},
|
|
89
99
|
dashboard: {
|
|
90
100
|
outputPath: normalizeString(loaded.dashboard?.outputPath, defaultConfig.dashboard.outputPath)
|