dep-brain 1.2.0 → 1.4.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.
@@ -9,7 +9,7 @@ import { buildDependencyGraph } from "./graph-builder.js";
9
9
  import { PluginManager } from "./plugin-manager.js";
10
10
  import { calculateHealthScore, calculateScoreDeductions } from "./scorer.js";
11
11
  import { buildAnalysisContext } from "./context.js";
12
- export const OUTPUT_VERSION = "1.4";
12
+ export const OUTPUT_VERSION = "1.5";
13
13
  export async function analyzeProject(options = {}) {
14
14
  const rootDir = path.resolve(options.rootDir ?? process.cwd());
15
15
  const loadedConfig = await loadDepBrainConfig(rootDir, options.configPath);
@@ -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 = [];
@@ -391,6 +396,10 @@ function mapRiskIssues(issues) {
391
396
  explanation: normalizeStringArray(issue.explanation),
392
397
  trustScore: normalizeTrustScore(issue.meta?.trustScore),
393
398
  riskFactors: normalizeRiskFactors(issue.meta?.riskFactors),
399
+ transitiveRiskScore: typeof issue.meta?.transitiveRiskScore === "number"
400
+ ? issue.meta.transitiveRiskScore
401
+ : 0,
402
+ riskyTransitiveDeps: normalizeRiskTransitiveDependencies(issue.meta?.riskyTransitiveDeps),
394
403
  recommendation: buildRiskRecommendation(issue)
395
404
  }));
396
405
  }
@@ -454,13 +463,18 @@ function buildRiskRecommendation(issue) {
454
463
  const reasons = normalizeStringArray(issue.explanation);
455
464
  const confidence = normalizeConfidence(issue.confidence);
456
465
  const trustScore = normalizeTrustScore(issue.meta?.trustScore);
466
+ const riskyTransitiveDeps = normalizeRiskTransitiveDependencies(issue.meta?.riskyTransitiveDeps);
457
467
  return {
458
468
  action: "review",
459
- priority: trustScore === "low" || confidence >= 0.79 ? "high" : "medium",
469
+ priority: trustScore === "low" || confidence >= 0.79 || riskyTransitiveDeps.length >= 2
470
+ ? "high"
471
+ : "medium",
460
472
  safety: "caution",
461
- summary: trustScore === "low"
462
- ? "Low trust package; review whether to replace, pin, or monitor it closely."
463
- : "Review package trust signals and decide whether to keep, replace, or monitor it.",
473
+ summary: riskyTransitiveDeps.length > 0
474
+ ? "Review this direct dependency and its transitive chain before upgrading or keeping it."
475
+ : trustScore === "low"
476
+ ? "Low trust package; review whether to replace, pin, or monitor it closely."
477
+ : "Review package trust signals and decide whether to keep, replace, or monitor it.",
464
478
  reasons
465
479
  };
466
480
  }
@@ -599,7 +613,9 @@ function normalizeRiskFactors(value) {
599
613
  versionCount: null,
600
614
  recentReleaseCount: null,
601
615
  hasRepository: false,
602
- dependencyType: "unknown"
616
+ dependencyType: "unknown",
617
+ transitiveDependencyCount: 0,
618
+ riskyTransitiveCount: 0
603
619
  };
604
620
  }
605
621
  const factors = value;
@@ -612,9 +628,38 @@ function normalizeRiskFactors(value) {
612
628
  hasRepository: factors.hasRepository === true,
613
629
  dependencyType: factors.dependencyType === "dependencies" || factors.dependencyType === "devDependencies"
614
630
  ? factors.dependencyType
615
- : "unknown"
631
+ : "unknown",
632
+ transitiveDependencyCount: typeof factors.transitiveDependencyCount === "number" ? factors.transitiveDependencyCount : 0,
633
+ riskyTransitiveCount: typeof factors.riskyTransitiveCount === "number" ? factors.riskyTransitiveCount : 0
616
634
  };
617
635
  }
636
+ function normalizeRiskTransitiveDependencies(value) {
637
+ if (!Array.isArray(value)) {
638
+ return [];
639
+ }
640
+ return value
641
+ .map((entry) => {
642
+ if (!entry || typeof entry !== "object") {
643
+ return null;
644
+ }
645
+ const item = entry;
646
+ if (typeof item.name !== "string" ||
647
+ (item.trustScore !== "high" && item.trustScore !== "medium" && item.trustScore !== "low") ||
648
+ typeof item.confidence !== "number" ||
649
+ !Array.isArray(item.reasons) ||
650
+ !Array.isArray(item.introducedByPaths)) {
651
+ return null;
652
+ }
653
+ return {
654
+ name: item.name,
655
+ trustScore: item.trustScore,
656
+ confidence: normalizeConfidence(item.confidence),
657
+ reasons: item.reasons.filter((reason) => typeof reason === "string"),
658
+ introducedByPaths: item.introducedByPaths.filter((trace) => typeof trace === "string")
659
+ };
660
+ })
661
+ .filter((entry) => entry !== null);
662
+ }
618
663
  function normalizeWorkspaceUsage(value) {
619
664
  if (!Array.isArray(value)) {
620
665
  return [];
@@ -11,5 +11,6 @@ export interface DependencyGraph {
11
11
  overrides: Record<string, unknown>;
12
12
  scripts: Record<string, string>;
13
13
  lockPackages: Record<string, LockPackageInstance[]>;
14
+ lockDependencies: Record<string, string[]>;
14
15
  }
15
16
  export declare function buildDependencyGraph(rootDir: string): Promise<DependencyGraph>;
@@ -7,32 +7,29 @@ export async function buildDependencyGraph(rootDir) {
7
7
  const pnpmLockfilePath = path.join(rootDir, "pnpm-lock.yaml");
8
8
  const yarnLockfilePath = path.join(rootDir, "yarn.lock");
9
9
  const packageJson = await readJsonFile(packageJsonPath);
10
- const lockPackages = new Map();
11
10
  try {
12
11
  const packageLock = await readJsonFile(lockfilePath);
13
- for (const [packagePath, details] of Object.entries(packageLock.packages ?? {})) {
14
- const name = extractPackageName(packagePath);
15
- const version = details.version;
16
- if (!name || !version) {
17
- continue;
18
- }
19
- const instances = lockPackages.get(name) ?? new Map();
20
- const normalizedPath = packagePath || "node_modules/" + name;
21
- instances.set(normalizedPath, { path: normalizedPath, version });
22
- lockPackages.set(name, instances);
23
- }
24
- for (const [name, details] of Object.entries(packageLock.dependencies ?? {})) {
25
- if (!details.version) {
26
- continue;
27
- }
28
- const instances = lockPackages.get(name) ?? new Map();
29
- const normalizedPath = `node_modules/${name}`;
30
- instances.set(normalizedPath, { path: normalizedPath, version: details.version });
31
- lockPackages.set(name, instances);
32
- }
12
+ const parsed = parseNpmLockfile(packageLock, {
13
+ ...packageJson.dependencies,
14
+ ...packageJson.devDependencies
15
+ });
16
+ return {
17
+ rootDir,
18
+ packageJsonPath,
19
+ lockfilePath,
20
+ dependencies: packageJson.dependencies ?? {},
21
+ devDependencies: packageJson.devDependencies ?? {},
22
+ overrides: packageJson.overrides ?? {},
23
+ scripts: packageJson.scripts ?? {},
24
+ lockPackages: parsed.lockPackages,
25
+ lockDependencies: parsed.lockDependencies
26
+ };
33
27
  }
34
28
  catch {
35
- const fallbackLockfile = await readAlternativeLockfile(pnpmLockfilePath, yarnLockfilePath);
29
+ const fallbackLockfile = await readAlternativeLockfile(pnpmLockfilePath, yarnLockfilePath, {
30
+ ...packageJson.dependencies,
31
+ ...packageJson.devDependencies
32
+ });
36
33
  return {
37
34
  rootDir,
38
35
  packageJsonPath,
@@ -41,29 +38,19 @@ export async function buildDependencyGraph(rootDir) {
41
38
  devDependencies: packageJson.devDependencies ?? {},
42
39
  overrides: packageJson.overrides ?? {},
43
40
  scripts: packageJson.scripts ?? {},
44
- lockPackages: fallbackLockfile.lockPackages
41
+ lockPackages: fallbackLockfile.lockPackages,
42
+ lockDependencies: fallbackLockfile.lockDependencies
45
43
  };
46
44
  }
47
- return {
48
- rootDir,
49
- packageJsonPath,
50
- lockfilePath,
51
- dependencies: packageJson.dependencies ?? {},
52
- devDependencies: packageJson.devDependencies ?? {},
53
- overrides: packageJson.overrides ?? {},
54
- scripts: packageJson.scripts ?? {},
55
- lockPackages: Object.fromEntries(Array.from(lockPackages.entries()).map(([name, instances]) => [
56
- name,
57
- Array.from(instances.values()).sort((left, right) => left.path.localeCompare(right.path))
58
- ]))
59
- };
60
45
  }
61
- async function readAlternativeLockfile(pnpmLockfilePath, yarnLockfilePath) {
46
+ async function readAlternativeLockfile(pnpmLockfilePath, yarnLockfilePath, rootDependencies) {
62
47
  try {
63
48
  const content = await fs.readFile(pnpmLockfilePath, "utf8");
49
+ const parsed = parsePnpmLockfile(content, rootDependencies);
64
50
  return {
65
51
  lockfilePath: pnpmLockfilePath,
66
- lockPackages: parsePnpmLockfile(content)
52
+ lockPackages: parsed.lockPackages,
53
+ lockDependencies: parsed.lockDependencies
67
54
  };
68
55
  }
69
56
  catch {
@@ -71,58 +58,150 @@ async function readAlternativeLockfile(pnpmLockfilePath, yarnLockfilePath) {
71
58
  }
72
59
  try {
73
60
  const content = await fs.readFile(yarnLockfilePath, "utf8");
61
+ const parsed = parseYarnLockfile(content, rootDependencies);
74
62
  return {
75
63
  lockfilePath: yarnLockfilePath,
76
- lockPackages: parseYarnLockfile(content)
64
+ lockPackages: parsed.lockPackages,
65
+ lockDependencies: parsed.lockDependencies
77
66
  };
78
67
  }
79
68
  catch {
80
69
  return {
81
- lockPackages: {}
70
+ lockPackages: {},
71
+ lockDependencies: {}
82
72
  };
83
73
  }
84
74
  }
85
- function extractPackageName(packagePath) {
86
- if (!packagePath) {
87
- return null;
75
+ function parseNpmLockfile(packageLock, rootDependencies) {
76
+ const lockPackages = new Map();
77
+ const lockDependencies = new Map();
78
+ for (const [packagePath, details] of Object.entries(packageLock.packages ?? {})) {
79
+ const name = details.name ?? extractPackageName(packagePath);
80
+ const version = details.version;
81
+ if (name && version) {
82
+ const instances = lockPackages.get(name) ?? new Map();
83
+ const normalizedPath = packagePath || "node_modules/" + name;
84
+ instances.set(normalizedPath, { path: normalizedPath, version });
85
+ lockPackages.set(name, instances);
86
+ }
87
+ if (name) {
88
+ addDependencyNames(lockDependencies, name, Object.keys(details.dependencies ?? {}));
89
+ }
88
90
  }
89
- const match = packagePath.match(/(?:^|\/)node_modules\/(.+)$/);
90
- if (!match) {
91
- return null;
91
+ for (const [name, details] of Object.entries(packageLock.dependencies ?? {})) {
92
+ if (details.version) {
93
+ const instances = lockPackages.get(name) ?? new Map();
94
+ const normalizedPath = `node_modules/${name}`;
95
+ instances.set(normalizedPath, { path: normalizedPath, version: details.version });
96
+ lockPackages.set(name, instances);
97
+ }
98
+ addDependencyNames(lockDependencies, name, Object.keys(details.requires ?? {}));
92
99
  }
93
- return match[1];
100
+ addDependencyNames(lockDependencies, "__root__", Object.keys(rootDependencies));
101
+ return {
102
+ lockPackages: toLockPackageRecord(lockPackages),
103
+ lockDependencies: toDependencyRecord(lockDependencies)
104
+ };
94
105
  }
95
- function parsePnpmLockfile(content) {
106
+ function parsePnpmLockfile(content, rootDependencies) {
96
107
  const lockPackages = new Map();
97
- for (const line of content.split(/\r?\n/)) {
98
- const match = line.match(/^\s{2}(?:'|")?\/((?:@[^/]+\/)?[^/@'"]+)@([^('":]+)[^:]*:(?:'|")?\s*$/);
99
- if (!match) {
108
+ const lockDependencies = new Map();
109
+ const lines = content.split(/\r?\n/);
110
+ let currentName = null;
111
+ let currentVersion = null;
112
+ let inDependenciesBlock = false;
113
+ for (const line of lines) {
114
+ const packageMatch = line.match(/^\s{2}(?:'|")?\/((?:@[^/]+\/)?[^/@'"]+)@([^('":]+)[^:]*:(?:'|")?\s*$/);
115
+ if (packageMatch) {
116
+ currentName = packageMatch[1];
117
+ currentVersion = packageMatch[2];
118
+ inDependenciesBlock = false;
119
+ addLockPackage(lockPackages, currentName, `pnpm:${currentName}@${currentVersion}`, currentVersion);
120
+ continue;
121
+ }
122
+ if (!currentName) {
123
+ continue;
124
+ }
125
+ if (/^\s{4}(?:dependencies|optionalDependencies):\s*$/.test(line)) {
126
+ inDependenciesBlock = true;
127
+ continue;
128
+ }
129
+ if (/^\s{4}\S/.test(line) && !/^\s{4}(?:dependencies|optionalDependencies):\s*$/.test(line)) {
130
+ inDependenciesBlock = false;
131
+ }
132
+ if (!inDependenciesBlock) {
100
133
  continue;
101
134
  }
102
- addLockPackage(lockPackages, match[1], `pnpm-lock:${match[0].trim()}`, match[2]);
135
+ const dependencyMatch = line.match(/^\s{6}((?:@[^/]+\/)?[^:\s]+):\s*(.+)?$/);
136
+ if (!dependencyMatch) {
137
+ continue;
138
+ }
139
+ addDependencyNames(lockDependencies, currentName, [dependencyMatch[1]]);
103
140
  }
104
- return toLockPackageRecord(lockPackages);
141
+ addDependencyNames(lockDependencies, "__root__", Object.keys(rootDependencies));
142
+ return {
143
+ lockPackages: toLockPackageRecord(lockPackages),
144
+ lockDependencies: toDependencyRecord(lockDependencies)
145
+ };
105
146
  }
106
- function parseYarnLockfile(content) {
147
+ function parseYarnLockfile(content, rootDependencies) {
107
148
  const lockPackages = new Map();
149
+ const lockDependencies = new Map();
150
+ const lines = content.split(/\r?\n/);
108
151
  let currentNames = [];
109
- for (const line of content.split(/\r?\n/)) {
152
+ let currentVersion = null;
153
+ let inDependenciesBlock = false;
154
+ for (const line of lines) {
110
155
  if (line.trim().length === 0 || line.startsWith("#")) {
111
156
  continue;
112
157
  }
113
158
  if (!line.startsWith(" ") && line.endsWith(":")) {
114
159
  currentNames = extractYarnEntryNames(line.slice(0, -1));
160
+ currentVersion = null;
161
+ inDependenciesBlock = false;
115
162
  continue;
116
163
  }
117
164
  const versionMatch = line.match(/^\s+version\s+"?([^"\s]+)"?\s*$/);
118
- if (!versionMatch) {
165
+ if (versionMatch) {
166
+ currentVersion = versionMatch[1];
167
+ for (const name of currentNames) {
168
+ addLockPackage(lockPackages, name, `yarn:${name}@${currentVersion}`, currentVersion);
169
+ }
170
+ continue;
171
+ }
172
+ if (/^\s{2}dependencies:\s*$/.test(line)) {
173
+ inDependenciesBlock = true;
174
+ continue;
175
+ }
176
+ if (/^\s{2}\S/.test(line) && !/^\s{2}dependencies:\s*$/.test(line)) {
177
+ inDependenciesBlock = false;
178
+ }
179
+ if (!inDependenciesBlock) {
180
+ continue;
181
+ }
182
+ const dependencyMatch = line.match(/^\s{4}((?:@[^/]+\/)?[^"\s]+)\s+/);
183
+ if (!dependencyMatch) {
119
184
  continue;
120
185
  }
121
186
  for (const name of currentNames) {
122
- addLockPackage(lockPackages, name, `yarn-lock:${name}@${versionMatch[1]}`, versionMatch[1]);
187
+ addDependencyNames(lockDependencies, name, [dependencyMatch[1]]);
123
188
  }
124
189
  }
125
- return toLockPackageRecord(lockPackages);
190
+ addDependencyNames(lockDependencies, "__root__", Object.keys(rootDependencies));
191
+ return {
192
+ lockPackages: toLockPackageRecord(lockPackages),
193
+ lockDependencies: toDependencyRecord(lockDependencies)
194
+ };
195
+ }
196
+ function extractPackageName(packagePath) {
197
+ if (!packagePath) {
198
+ return null;
199
+ }
200
+ const match = packagePath.match(/(?:^|\/)node_modules\/(.+)$/);
201
+ if (!match) {
202
+ return null;
203
+ }
204
+ return match[1];
126
205
  }
127
206
  function extractYarnEntryNames(entry) {
128
207
  const names = new Set();
@@ -149,9 +228,25 @@ function addLockPackage(lockPackages, name, packagePath, version) {
149
228
  instances.set(packagePath, { path: packagePath, version });
150
229
  lockPackages.set(name, instances);
151
230
  }
231
+ function addDependencyNames(lockDependencies, name, dependencies) {
232
+ if (dependencies.length === 0) {
233
+ return;
234
+ }
235
+ const entry = lockDependencies.get(name) ?? new Set();
236
+ for (const dependency of dependencies) {
237
+ entry.add(dependency);
238
+ }
239
+ lockDependencies.set(name, entry);
240
+ }
152
241
  function toLockPackageRecord(lockPackages) {
153
242
  return Object.fromEntries(Array.from(lockPackages.entries()).map(([name, instances]) => [
154
243
  name,
155
244
  Array.from(instances.values()).sort((left, right) => left.path.localeCompare(right.path))
156
245
  ]));
157
246
  }
247
+ function toDependencyRecord(lockDependencies) {
248
+ return Object.fromEntries(Array.from(lockDependencies.entries()).map(([name, dependencies]) => [
249
+ name,
250
+ Array.from(dependencies).sort((left, right) => left.localeCompare(right))
251
+ ]));
252
+ }
@@ -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
- constructor(plugins) {
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 plugin = await loadPlugin(rootDir, spec);
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
- await plugin.preScan?.(context);
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
- const next = await plugin.postScan?.(current);
31
- if (next) {
32
- current = next;
42
+ try {
43
+ const next = await plugin.postScan?.(current);
44
+ if (next) {
45
+ current = next;
46
+ }
33
47
  }
34
- const reportSection = await plugin.reportHook?.(current);
35
- if (reportSection) {
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
- return current;
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
- return isPlugin(candidate) ? candidate : null;
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
@@ -1,8 +1,8 @@
1
1
  export { analyzeProject } from "./core/analyzer.js";
2
- export type { AnalysisOptions, AnalysisFocus, AnalysisResult, DepBrainBaseline, DuplicateDependency, OutdatedDependency, PolicyResult, PackageAnalysisResult, Recommendation, RiskFactors, ScoreBreakdown, RiskDependency, TopIssue, TrustScore, UnusedDependency, WorkspaceDependencyUsage, WorkspaceOwnershipSummary } from "./core/analyzer.js";
2
+ export type { AnalysisOptions, AnalysisFocus, AnalysisResult, DepBrainBaseline, DuplicateDependency, OutdatedDependency, PolicyResult, PackageAnalysisResult, Recommendation, RiskFactors, RiskTransitiveDependency, 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";