dep-brain 1.1.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 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:
@@ -238,6 +240,26 @@ Create a `depbrain.config.json` file in the project root:
238
240
  "report": {
239
241
  "maxSuggestions": 3
240
242
  },
243
+ "plugins": {
244
+ "enabled": ["license"],
245
+ "paths": []
246
+ },
247
+ "risk": {
248
+ "transitiveBloatThreshold": 50,
249
+ "typosquattingDistanceThreshold": 2,
250
+ "staleReleaseDays": 730,
251
+ "agingReleaseDays": 365,
252
+ "lowDownloadThreshold": 1000,
253
+ "lowTrustWeightThreshold": 6,
254
+ "mediumTrustWeightThreshold": 3
255
+ },
256
+ "dashboard": {
257
+ "outputPath": "depbrain-dashboard.html"
258
+ },
259
+ "notifications": {
260
+ "slackWebhookEnv": "DEPBRAIN_SLACK_WEBHOOK_URL",
261
+ "discordWebhookEnv": "DEPBRAIN_DISCORD_WEBHOOK_URL"
262
+ },
241
263
  "scoring": {
242
264
  "duplicateWeight": 5,
243
265
  "outdatedWeight": 1,
@@ -264,6 +286,18 @@ Supported sections:
264
286
  - `policy.failOnOutdated`
265
287
  - `policy.failOnRisks`
266
288
  - `report.maxSuggestions`
289
+ - `plugins.enabled`
290
+ - `plugins.paths`
291
+ - `risk.transitiveBloatThreshold`
292
+ - `risk.typosquattingDistanceThreshold`
293
+ - `risk.staleReleaseDays`
294
+ - `risk.agingReleaseDays`
295
+ - `risk.lowDownloadThreshold`
296
+ - `risk.lowTrustWeightThreshold`
297
+ - `risk.mediumTrustWeightThreshold`
298
+ - `dashboard.outputPath`
299
+ - `notifications.slackWebhookEnv`
300
+ - `notifications.discordWebhookEnv`
267
301
  - `scoring.duplicateWeight`
268
302
  - `scoring.outdatedWeight`
269
303
  - `scoring.unusedWeight`
@@ -19,6 +19,26 @@
19
19
  "report": {
20
20
  "maxSuggestions": 5
21
21
  },
22
+ "plugins": {
23
+ "enabled": [],
24
+ "paths": []
25
+ },
26
+ "risk": {
27
+ "transitiveBloatThreshold": 50,
28
+ "typosquattingDistanceThreshold": 2,
29
+ "staleReleaseDays": 730,
30
+ "agingReleaseDays": 365,
31
+ "lowDownloadThreshold": 1000,
32
+ "lowTrustWeightThreshold": 6,
33
+ "mediumTrustWeightThreshold": 3
34
+ },
35
+ "dashboard": {
36
+ "outputPath": "depbrain-dashboard.html"
37
+ },
38
+ "notifications": {
39
+ "slackWebhookEnv": "DEPBRAIN_SLACK_WEBHOOK_URL",
40
+ "discordWebhookEnv": "DEPBRAIN_DISCORD_WEBHOOK_URL"
41
+ },
22
42
  "scoring": {
23
43
  "duplicateWeight": 5,
24
44
  "outdatedWeight": 3,
@@ -36,6 +36,42 @@
36
36
  "maxSuggestions": { "type": "number" }
37
37
  }
38
38
  },
39
+ "plugins": {
40
+ "type": "object",
41
+ "additionalProperties": false,
42
+ "properties": {
43
+ "enabled": { "type": "array", "items": { "type": "string" } },
44
+ "paths": { "type": "array", "items": { "type": "string" } }
45
+ }
46
+ },
47
+ "risk": {
48
+ "type": "object",
49
+ "additionalProperties": false,
50
+ "properties": {
51
+ "transitiveBloatThreshold": { "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" }
58
+ }
59
+ },
60
+ "dashboard": {
61
+ "type": "object",
62
+ "additionalProperties": false,
63
+ "properties": {
64
+ "outputPath": { "type": "string" }
65
+ }
66
+ },
67
+ "notifications": {
68
+ "type": "object",
69
+ "additionalProperties": false,
70
+ "properties": {
71
+ "slackWebhookEnv": { "type": "string" },
72
+ "discordWebhookEnv": { "type": "string" }
73
+ }
74
+ },
39
75
  "scoring": {
40
76
  "type": "object",
41
77
  "additionalProperties": false,
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "title": "Dependency Brain Analysis Output",
4
4
  "type": "object",
5
- "required": ["outputVersion", "rootDir", "score", "scoreBreakdown", "policy", "ownershipSummary", "duplicates", "unused", "outdated", "risks", "suggestions", "topIssues", "config"],
5
+ "required": ["outputVersion", "rootDir", "score", "scoreBreakdown", "policy", "ownershipSummary", "duplicates", "unused", "outdated", "risks", "suggestions", "topIssues", "extensions", "config"],
6
6
  "additionalProperties": false,
7
7
  "properties": {
8
8
  "recommendation": {
@@ -175,6 +175,10 @@
175
175
  }
176
176
  },
177
177
  "suggestions": { "type": "array", "items": { "type": "string" } },
178
+ "extensions": {
179
+ "type": "object",
180
+ "additionalProperties": true
181
+ },
178
182
  "topIssues": {
179
183
  "type": "array",
180
184
  "items": {
@@ -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>;
@@ -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
- if (metadata.daysSincePublish !== null && metadata.daysSincePublish > TWO_YEARS_IN_DAYS) {
83
- reasons.push("No release in over 2 years");
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 > ONE_YEAR_IN_DAYS) {
89
- reasons.push("No release in over 12 months");
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 < 1000) {
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 >= 6 ? "low" : weight >= 3 ? "medium" : "high";
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
- : renderMarkdownReport(reportData);
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");
@@ -110,6 +110,7 @@ export interface AnalysisResult {
110
110
  risks: RiskDependency[];
111
111
  suggestions: string[];
112
112
  topIssues: TopIssue[];
113
+ extensions: Record<string, unknown>;
113
114
  config: DepBrainConfig;
114
115
  packages?: PackageAnalysisResult[];
115
116
  }
@@ -130,6 +131,7 @@ export interface PackageAnalysisResult {
130
131
  risks: RiskDependency[];
131
132
  suggestions: string[];
132
133
  topIssues: TopIssue[];
134
+ extensions: Record<string, unknown>;
133
135
  }
134
136
  export declare const OUTPUT_VERSION = "1.4";
135
137
  export interface ScoreBreakdown {
@@ -6,6 +6,7 @@ import { runUnusedCheck } from "../checks/unused.js";
6
6
  import { loadDepBrainConfig } from "../utils/config.js";
7
7
  import { findWorkspacePackages } from "../utils/workspaces.js";
8
8
  import { buildDependencyGraph } from "./graph-builder.js";
9
+ import { PluginManager } from "./plugin-manager.js";
9
10
  import { calculateHealthScore, calculateScoreDeductions } from "./scorer.js";
10
11
  import { buildAnalysisContext } from "./context.js";
11
12
  export const OUTPUT_VERSION = "1.4";
@@ -14,12 +15,15 @@ export async function analyzeProject(options = {}) {
14
15
  const loadedConfig = await loadDepBrainConfig(rootDir, options.configPath);
15
16
  const config = mergeConfig(loadedConfig, options.config);
16
17
  const focus = options.focus ?? "all";
18
+ const plugins = await PluginManager.load(rootDir, config);
19
+ await plugins.runPreScan({ rootDir, config });
17
20
  const workspaces = await findWorkspacePackages(rootDir);
18
21
  if (workspaces.length === 0) {
19
- return analyzeSingleProject(rootDir, config, {
22
+ const result = await analyzeSingleProject(rootDir, config, {
20
23
  baseline: options.baseline,
21
24
  focus
22
25
  });
26
+ return plugins.runPostScan(result);
23
27
  }
24
28
  const rootGraph = await buildDependencyGraph(rootDir);
25
29
  const duplicates = shouldRunCheck("duplicate", focus)
@@ -79,7 +83,7 @@ export async function analyzeProject(options = {}) {
79
83
  outdated: outdated.length,
80
84
  risks: risks.length
81
85
  }, config);
82
- return {
86
+ const result = {
83
87
  outputVersion: OUTPUT_VERSION,
84
88
  rootDir,
85
89
  score,
@@ -102,9 +106,11 @@ export async function analyzeProject(options = {}) {
102
106
  outdated,
103
107
  risks
104
108
  }),
109
+ extensions: {},
105
110
  config,
106
111
  packages
107
112
  };
113
+ return plugins.runPostScan(result);
108
114
  }
109
115
  function mergeConfig(base, overrides) {
110
116
  if (!overrides) {
@@ -131,6 +137,26 @@ function mergeConfig(base, overrides) {
131
137
  report: {
132
138
  maxSuggestions: overrides.report?.maxSuggestions ?? base.report.maxSuggestions
133
139
  },
140
+ plugins: {
141
+ enabled: overrides.plugins?.enabled ?? base.plugins.enabled,
142
+ paths: overrides.plugins?.paths ?? base.plugins.paths
143
+ },
144
+ risk: {
145
+ transitiveBloatThreshold: overrides.risk?.transitiveBloatThreshold ?? base.risk.transitiveBloatThreshold,
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
152
+ },
153
+ dashboard: {
154
+ outputPath: overrides.dashboard?.outputPath ?? base.dashboard.outputPath
155
+ },
156
+ notifications: {
157
+ slackWebhookEnv: overrides.notifications?.slackWebhookEnv ?? base.notifications.slackWebhookEnv,
158
+ discordWebhookEnv: overrides.notifications?.discordWebhookEnv ?? base.notifications.discordWebhookEnv
159
+ },
134
160
  scoring: {
135
161
  duplicateWeight: overrides.scoring?.duplicateWeight ?? base.scoring.duplicateWeight,
136
162
  outdatedWeight: overrides.scoring?.outdatedWeight ?? base.scoring.outdatedWeight,
@@ -166,7 +192,7 @@ function evaluatePolicy(summary, config) {
166
192
  }
167
193
  async function analyzeSingleProject(rootDir, config, options = {}) {
168
194
  const context = await buildAnalysisContext(rootDir, config);
169
- const results = await runChecks(context, options.focus ?? "all");
195
+ const results = await runChecks(context, options.focus ?? "all", config);
170
196
  const issueGroups = normalizeIssues(results, config);
171
197
  const duplicates = mapDuplicateIssues(issueGroups.duplicates);
172
198
  const unused = mapUnusedIssues(issueGroups.unused);
@@ -238,6 +264,7 @@ async function analyzeSingleProject(rootDir, config, options = {}) {
238
264
  outdated: baselineFiltered.outdated,
239
265
  risks: baselineFiltered.risks
240
266
  }),
267
+ extensions: {},
241
268
  config
242
269
  };
243
270
  }
@@ -258,7 +285,7 @@ function shouldIgnorePackage(name, bucket, config) {
258
285
  }
259
286
  });
260
287
  }
261
- async function runChecks(context, focus) {
288
+ async function runChecks(context, focus, config) {
262
289
  const checks = [
263
290
  {
264
291
  name: "duplicate",
@@ -274,7 +301,7 @@ async function runChecks(context, focus) {
274
301
  },
275
302
  {
276
303
  name: "risk",
277
- run: () => runRiskCheck(context.graph)
304
+ run: () => runRiskCheck(context.graph, { thresholds: config.risk })
278
305
  }
279
306
  ];
280
307
  const results = [];
@@ -0,0 +1,29 @@
1
+ import type { AnalysisResult } from "./analyzer.js";
2
+ import type { DepBrainConfig } from "../utils/config.js";
3
+ export interface ProjectContext {
4
+ rootDir: string;
5
+ config: DepBrainConfig;
6
+ }
7
+ export interface DepBrainPlugin {
8
+ name: string;
9
+ preScan?: (context: ProjectContext) => Promise<void> | void;
10
+ postScan?: (result: AnalysisResult) => Promise<AnalysisResult | void> | AnalysisResult | void;
11
+ reportHook?: (result: AnalysisResult) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
12
+ cliCommands?: (cli: unknown) => void;
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
+ }
21
+ export declare class PluginManager {
22
+ private readonly plugins;
23
+ private readonly diagnostics;
24
+ private constructor();
25
+ static load(rootDir: string, config: DepBrainConfig): Promise<PluginManager>;
26
+ runPreScan(context: ProjectContext): Promise<void>;
27
+ runPostScan(result: AnalysisResult): Promise<AnalysisResult>;
28
+ private attachDiagnostics;
29
+ }
@@ -0,0 +1,193 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ export class PluginManager {
5
+ plugins;
6
+ diagnostics;
7
+ constructor(plugins, diagnostics) {
8
+ this.plugins = plugins;
9
+ this.diagnostics = diagnostics;
10
+ }
11
+ static async load(rootDir, config) {
12
+ const specs = [
13
+ ...config.plugins.enabled.map((name) => `dep-brain-plugin-${name}`),
14
+ ...config.plugins.paths
15
+ ];
16
+ const plugins = [];
17
+ const diagnostics = [];
18
+ for (const spec of specs) {
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);
25
+ }
26
+ }
27
+ return new PluginManager(plugins, diagnostics);
28
+ }
29
+ async runPreScan(context) {
30
+ for (const plugin of this.plugins) {
31
+ try {
32
+ await plugin.preScan?.(context);
33
+ }
34
+ catch (error) {
35
+ this.diagnostics.push(buildHookDiagnostic(plugin.name, "preScan", error));
36
+ }
37
+ }
38
+ }
39
+ async runPostScan(result) {
40
+ let current = result;
41
+ for (const plugin of this.plugins) {
42
+ try {
43
+ const next = await plugin.postScan?.(current);
44
+ if (next) {
45
+ current = next;
46
+ }
47
+ }
48
+ catch (error) {
49
+ this.diagnostics.push(buildHookDiagnostic(plugin.name, "postScan", error));
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;
69
+ }
70
+ result.extensions.depBrain = {
71
+ ...(asRecord(result.extensions.depBrain) ?? {}),
72
+ plugins: this.diagnostics
73
+ };
74
+ return result;
75
+ }
76
+ }
77
+ async function loadPlugin(rootDir, spec) {
78
+ const builtIn = getBuiltInPlugin(spec);
79
+ if (builtIn) {
80
+ return { plugin: builtIn };
81
+ }
82
+ try {
83
+ const resolved = spec.startsWith(".") || path.isAbsolute(spec)
84
+ ? path.resolve(rootDir, spec)
85
+ : spec;
86
+ const moduleUrl = path.isAbsolute(resolved) ? pathToFileURL(resolved).href : resolved;
87
+ const mod = await import(moduleUrl);
88
+ const exported = mod.default ?? mod.plugin ?? mod;
89
+ const candidate = typeof exported === "function" ? new exported() : exported;
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
+ };
101
+ }
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") {
115
+ return null;
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
+ };
183
+ }
184
+ function isPlugin(value) {
185
+ return Boolean(value &&
186
+ typeof value === "object" &&
187
+ typeof value.name === "string");
188
+ }
189
+ function asRecord(value) {
190
+ return value && typeof value === "object" && !Array.isArray(value)
191
+ ? value
192
+ : null;
193
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  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
+ export { PluginManager } from "./core/plugin-manager.js";
5
+ export type { DepBrainPlugin, PluginDiagnostic, ProjectContext } from "./core/plugin-manager.js";
4
6
  export type { AnalysisContext, CheckResult, Issue } from "./core/types.js";
5
7
  export type { DepBrainConfig, DepBrainConfigOverrides } from "./utils/config.js";
6
8
  export type { WorkspacePackage } from "./utils/workspaces.js";
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { analyzeProject } from "./core/analyzer.js";
2
2
  export { OUTPUT_VERSION } from "./core/analyzer.js";
3
+ export { PluginManager } from "./core/plugin-manager.js";
@@ -0,0 +1,2 @@
1
+ import type { AnalysisResult } from "../core/analyzer.js";
2
+ export declare function renderDashboardReport(result: AnalysisResult): string;
@@ -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, "&amp;")
80
+ .replace(/</g, "&lt;")
81
+ .replace(/>/g, "&gt;")
82
+ .replace(/"/g, "&quot;")
83
+ .replace(/'/g, "&#39;");
84
+ }
@@ -19,6 +19,26 @@ export interface DepBrainConfig {
19
19
  report: {
20
20
  maxSuggestions: number;
21
21
  };
22
+ plugins: {
23
+ enabled: string[];
24
+ paths: string[];
25
+ };
26
+ risk: {
27
+ transitiveBloatThreshold: number;
28
+ typosquattingDistanceThreshold: number;
29
+ staleReleaseDays: number;
30
+ agingReleaseDays: number;
31
+ lowDownloadThreshold: number;
32
+ lowTrustWeightThreshold: number;
33
+ mediumTrustWeightThreshold: number;
34
+ };
35
+ dashboard: {
36
+ outputPath: string;
37
+ };
38
+ notifications: {
39
+ slackWebhookEnv: string;
40
+ discordWebhookEnv: string;
41
+ };
22
42
  scoring: {
23
43
  duplicateWeight: number;
24
44
  outdatedWeight: number;
@@ -33,6 +53,10 @@ export interface DepBrainConfigOverrides {
33
53
  ignore?: Partial<DepBrainConfig["ignore"]>;
34
54
  policy?: Partial<DepBrainConfig["policy"]>;
35
55
  report?: Partial<DepBrainConfig["report"]>;
56
+ plugins?: Partial<DepBrainConfig["plugins"]>;
57
+ risk?: Partial<DepBrainConfig["risk"]>;
58
+ dashboard?: Partial<DepBrainConfig["dashboard"]>;
59
+ notifications?: Partial<DepBrainConfig["notifications"]>;
36
60
  scoring?: Partial<DepBrainConfig["scoring"]>;
37
61
  scan?: Partial<DepBrainConfig["scan"]>;
38
62
  }
@@ -21,6 +21,26 @@ export const defaultConfig = {
21
21
  report: {
22
22
  maxSuggestions: 5
23
23
  },
24
+ plugins: {
25
+ enabled: [],
26
+ paths: []
27
+ },
28
+ risk: {
29
+ transitiveBloatThreshold: 50,
30
+ typosquattingDistanceThreshold: 2,
31
+ staleReleaseDays: 730,
32
+ agingReleaseDays: 365,
33
+ lowDownloadThreshold: 1000,
34
+ lowTrustWeightThreshold: 6,
35
+ mediumTrustWeightThreshold: 3
36
+ },
37
+ dashboard: {
38
+ outputPath: "depbrain-dashboard.html"
39
+ },
40
+ notifications: {
41
+ slackWebhookEnv: "DEPBRAIN_SLACK_WEBHOOK_URL",
42
+ discordWebhookEnv: "DEPBRAIN_DISCORD_WEBHOOK_URL"
43
+ },
24
44
  scoring: {
25
45
  duplicateWeight: 5,
26
46
  outdatedWeight: 3,
@@ -63,6 +83,26 @@ function normalizeConfig(loaded) {
63
83
  report: {
64
84
  maxSuggestions: normalizeNumber(loaded.report?.maxSuggestions, defaultConfig.report.maxSuggestions)
65
85
  },
86
+ plugins: {
87
+ enabled: normalizeStringArray(loaded.plugins?.enabled, defaultConfig.plugins.enabled),
88
+ paths: normalizeStringArray(loaded.plugins?.paths, defaultConfig.plugins.paths)
89
+ },
90
+ risk: {
91
+ transitiveBloatThreshold: normalizeNumber(loaded.risk?.transitiveBloatThreshold, defaultConfig.risk.transitiveBloatThreshold),
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)
98
+ },
99
+ dashboard: {
100
+ outputPath: normalizeString(loaded.dashboard?.outputPath, defaultConfig.dashboard.outputPath)
101
+ },
102
+ notifications: {
103
+ slackWebhookEnv: normalizeString(loaded.notifications?.slackWebhookEnv, defaultConfig.notifications.slackWebhookEnv),
104
+ discordWebhookEnv: normalizeString(loaded.notifications?.discordWebhookEnv, defaultConfig.notifications.discordWebhookEnv)
105
+ },
66
106
  scoring: {
67
107
  duplicateWeight: normalizeNumber(loaded.scoring?.duplicateWeight, defaultConfig.scoring.duplicateWeight),
68
108
  outdatedWeight: normalizeNumber(loaded.scoring?.outdatedWeight, defaultConfig.scoring.outdatedWeight),
@@ -86,3 +126,6 @@ function normalizeBoolean(value, fallback) {
86
126
  function normalizeNumber(value, fallback) {
87
127
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
88
128
  }
129
+ function normalizeString(value, fallback) {
130
+ return typeof value === "string" && value.trim().length > 0 ? value : fallback;
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dep-brain",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "CLI and library for explainable dependency intelligence",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",