dep-brain 1.3.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 1.4.0
6
+
7
+ - Added lockfile dependency-edge parsing so transitive relationships are available from npm, pnpm, and yarn lockfiles.
8
+ - Changed risk analysis from direct-package-only output to direct-owner risk summaries with `transitiveRiskScore` and `riskyTransitiveDeps`.
9
+ - Added transitive dependency counts and risky transitive counts to `riskFactors`.
10
+ - Updated console, markdown, and dashboard reports to highlight transitive risk hotspots.
11
+ - Bumped analysis output contract to `1.5` and added regression coverage for transitive risk propagation.
12
+
5
13
  ## 1.3.0
6
14
 
7
15
  - Added plugin diagnostics under `extensions.depBrain.plugins` for failed plugin loads and hook errors.
package/README.md CHANGED
@@ -21,6 +21,7 @@
21
21
  - Detect likely unused dependencies from source imports and scripts
22
22
  - Detect outdated packages
23
23
  - Highlight dependency risk signals
24
+ - Show which direct dependency introduces risky transitive packages
24
25
  - Score package trust using supply-chain metadata
25
26
  - Generate a simple project health score
26
27
  - Output reports in console, JSON, Markdown, SARIF, dashboard, and top-issues formats
@@ -39,6 +40,7 @@ The long-term goal is not just to list problems, but to answer:
39
40
  - Unused dependency detection with runtime vs dev-tool heuristics
40
41
  - Outdated dependency reporting with `major`, `minor`, and `patch` classification
41
42
  - Risk analysis based on npm package metadata
43
+ - Transitive risk ownership and path tracing for direct dependencies
42
44
  - Confidence scores, reason codes, explanations, and recommendations for findings
43
45
  - Config loading from `depbrain.config.json`
44
46
  - Ignore rules for noisy dependencies and checks
@@ -379,7 +381,7 @@ src/
379
381
 
380
382
  The project should optimize for trust, clarity, and actionability over flashy UI, generic graphs, or simply adding more checks.
381
383
 
382
- Risk findings now include a `trustScore` plus structured `riskFactors` such as publish recency, maintainer count, and repository presence.
384
+ Risk findings now include a `trustScore`, structured `riskFactors`, `transitiveRiskScore`, and `riskyTransitiveDeps` path traces so teams can see which direct package introduces supply-chain risk.
383
385
 
384
386
  ## Repository Notes
385
387
 
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "riskFactors": {
21
21
  "type": "object",
22
- "required": ["daysSincePublish", "downloads", "maintainersCount", "versionCount", "recentReleaseCount", "hasRepository", "dependencyType"],
22
+ "required": ["daysSincePublish", "downloads", "maintainersCount", "versionCount", "recentReleaseCount", "hasRepository", "dependencyType", "transitiveDependencyCount", "riskyTransitiveCount"],
23
23
  "additionalProperties": false,
24
24
  "properties": {
25
25
  "daysSincePublish": { "type": ["number", "null"] },
@@ -28,7 +28,21 @@
28
28
  "versionCount": { "type": ["number", "null"] },
29
29
  "recentReleaseCount": { "type": ["number", "null"] },
30
30
  "hasRepository": { "type": "boolean" },
31
- "dependencyType": { "type": "string", "enum": ["dependencies", "devDependencies", "unknown"] }
31
+ "dependencyType": { "type": "string", "enum": ["dependencies", "devDependencies", "unknown"] },
32
+ "transitiveDependencyCount": { "type": "number" },
33
+ "riskyTransitiveCount": { "type": "number" }
34
+ }
35
+ },
36
+ "riskTransitiveDependency": {
37
+ "type": "object",
38
+ "required": ["name", "trustScore", "confidence", "reasons", "introducedByPaths"],
39
+ "additionalProperties": false,
40
+ "properties": {
41
+ "name": { "type": "string" },
42
+ "trustScore": { "type": "string", "enum": ["high", "medium", "low"] },
43
+ "confidence": { "type": "number", "minimum": 0, "maximum": 1 },
44
+ "reasons": { "type": "array", "items": { "type": "string" } },
45
+ "introducedByPaths": { "type": "array", "items": { "type": "string" } }
32
46
  }
33
47
  },
34
48
  "ownershipSummary": {
@@ -159,7 +173,7 @@
159
173
  "type": "array",
160
174
  "items": {
161
175
  "type": "object",
162
- "required": ["name", "reasons", "confidence", "reasonCodes", "explanation", "trustScore", "riskFactors", "recommendation"],
176
+ "required": ["name", "reasons", "confidence", "reasonCodes", "explanation", "trustScore", "riskFactors", "transitiveRiskScore", "riskyTransitiveDeps", "recommendation"],
163
177
  "additionalProperties": false,
164
178
  "properties": {
165
179
  "name": { "type": "string" },
@@ -170,6 +184,11 @@
170
184
  "explanation": { "type": "array", "items": { "type": "string" } },
171
185
  "trustScore": { "type": "string", "enum": ["high", "medium", "low"] },
172
186
  "riskFactors": { "$ref": "#/properties/riskFactors" },
187
+ "transitiveRiskScore": { "type": "number" },
188
+ "riskyTransitiveDeps": {
189
+ "type": "array",
190
+ "items": { "$ref": "#/properties/riskTransitiveDependency" }
191
+ },
173
192
  "recommendation": { "$ref": "#/properties/recommendation" }
174
193
  }
175
194
  }
@@ -1,11 +1,14 @@
1
1
  import { getPackageMetadata } from "../utils/npm-api.js";
2
2
  export async function findRiskDependencies(graph, options = {}) {
3
3
  const resolvePackageMetadata = options.resolvePackageMetadata ?? getPackageMetadata;
4
- const names = Object.keys({
4
+ const thresholds = options.thresholds;
5
+ const allNames = Object.keys({
5
6
  ...graph.dependencies,
6
- ...graph.devDependencies
7
+ ...graph.devDependencies,
8
+ ...(graph.lockPackages ?? {})
7
9
  });
8
- const results = await mapWithConcurrency(names, 8, async (name) => {
10
+ const assessments = new Map();
11
+ const results = await mapWithConcurrency(allNames, 8, async (name) => {
9
12
  const metadata = await resolvePackageMetadata(name);
10
13
  if (!metadata) {
11
14
  return null;
@@ -15,24 +18,25 @@ export async function findRiskDependencies(graph, options = {}) {
15
18
  : graph.devDependencies[name]
16
19
  ? "devDependencies"
17
20
  : "unknown";
18
- const assessment = assessRisk(metadata, dependencyType, options.thresholds);
19
- if (!shouldReportRisk(assessment.trustScore, dependencyType)) {
20
- return null;
21
- }
21
+ const assessment = assessRisk(metadata, dependencyType, thresholds, 0);
22
22
  return {
23
23
  name,
24
- reasons: assessment.reasons,
25
- confidence: assessment.confidence,
26
- reasonCodes: assessment.reasonCodes,
27
- explanation: assessment.reasons,
28
- trustScore: assessment.trustScore,
29
- riskFactors: assessment.riskFactors,
30
- recommendation: buildRiskRecommendation(assessment.reasons, assessment.confidence, assessment.trustScore)
24
+ assessment
31
25
  };
32
26
  });
33
- return results
34
- .filter((item) => item !== null)
35
- .sort((left, right) => left.name.localeCompare(right.name));
27
+ for (const result of results) {
28
+ if (result) {
29
+ assessments.set(result.name, result.assessment);
30
+ }
31
+ }
32
+ const directNames = Object.keys({
33
+ ...graph.dependencies,
34
+ ...graph.devDependencies
35
+ }).sort((left, right) => left.localeCompare(right));
36
+ const risks = directNames
37
+ .map((name) => buildDirectRiskEntry(name, graph, assessments, thresholds))
38
+ .filter((item) => item !== null);
39
+ return risks.sort((left, right) => left.name.localeCompare(right.name));
36
40
  }
37
41
  async function mapWithConcurrency(items, limit, mapper) {
38
42
  const results = new Array(items.length);
@@ -68,12 +72,120 @@ export async function runRiskCheck(graph, options = {}) {
68
72
  name: item.name,
69
73
  reasons: item.reasons,
70
74
  trustScore: item.trustScore,
71
- riskFactors: item.riskFactors
75
+ riskFactors: item.riskFactors,
76
+ transitiveRiskScore: item.transitiveRiskScore,
77
+ riskyTransitiveDeps: item.riskyTransitiveDeps
72
78
  }
73
79
  }))
74
80
  };
75
81
  }
76
- function assessRisk(metadata, dependencyType, thresholds) {
82
+ function buildDirectRiskEntry(name, graph, assessments, thresholds) {
83
+ const dependencyType = graph.dependencies[name]
84
+ ? "dependencies"
85
+ : graph.devDependencies[name]
86
+ ? "devDependencies"
87
+ : "unknown";
88
+ const selfAssessment = assessments.get(name) ?? buildUnknownAssessment(name, dependencyType);
89
+ const transitive = collectTransitiveRisks(name, graph, assessments);
90
+ const reasonCodes = [...selfAssessment.reasonCodes];
91
+ const reasons = [...selfAssessment.reasons];
92
+ const explanation = [...selfAssessment.reasons];
93
+ if (transitive.riskyTransitiveDeps.length > 0) {
94
+ reasons.push(`Introduces ${transitive.riskyTransitiveDeps.length} risky transitive dependenc${transitive.riskyTransitiveDeps.length === 1 ? "y" : "ies"}`);
95
+ reasonCodes.push("risky_transitive_dependencies");
96
+ explanation.push(`Transitive paths: ${transitive.riskyTransitiveDeps
97
+ .flatMap((item) => item.introducedByPaths)
98
+ .slice(0, 3)
99
+ .join("; ")}`);
100
+ }
101
+ if (transitive.transitiveDependencyCount > (thresholds?.transitiveBloatThreshold ?? 50)) {
102
+ reasons.push("Large transitive dependency tree");
103
+ reasonCodes.push("dependency_bloat");
104
+ explanation.push(`${name} introduces ${transitive.transitiveDependencyCount} transitive dependencies.`);
105
+ }
106
+ const transitiveRiskScore = transitive.riskyTransitiveDeps.reduce((total, item) => total + trustScoreWeight(item.trustScore), 0);
107
+ const combinedConfidence = Math.min(0.99, Math.max(selfAssessment.confidence, transitive.riskyTransitiveDeps.reduce((maxConfidence, item) => Math.max(maxConfidence, item.confidence), 0.5)));
108
+ const trustScore = combineTrustScores(selfAssessment.trustScore, transitive.highestTrustScore);
109
+ const shouldReport = shouldReportRisk(selfAssessment.trustScore, dependencyType) ||
110
+ transitive.riskyTransitiveDeps.length > 0 ||
111
+ transitive.transitiveDependencyCount > (thresholds?.transitiveBloatThreshold ?? 50);
112
+ if (!shouldReport || reasons.length === 0) {
113
+ return null;
114
+ }
115
+ return {
116
+ name,
117
+ reasons,
118
+ confidence: combinedConfidence,
119
+ reasonCodes: dedupeStrings(reasonCodes),
120
+ explanation: dedupeStrings(explanation),
121
+ trustScore,
122
+ riskFactors: {
123
+ ...selfAssessment.riskFactors,
124
+ dependencyType,
125
+ transitiveDependencyCount: transitive.transitiveDependencyCount,
126
+ riskyTransitiveCount: transitive.riskyTransitiveDeps.length
127
+ },
128
+ transitiveRiskScore,
129
+ riskyTransitiveDeps: transitive.riskyTransitiveDeps,
130
+ recommendation: buildRiskRecommendation(reasons, combinedConfidence, trustScore, transitive.riskyTransitiveDeps.length)
131
+ };
132
+ }
133
+ function collectTransitiveRisks(directName, graph, assessments) {
134
+ const visited = new Set();
135
+ const queue = (graph.lockDependencies?.[directName] ?? []).map((name) => ({
136
+ name,
137
+ path: [directName, name]
138
+ }));
139
+ const riskyByName = new Map();
140
+ let highestTrustScore = "high";
141
+ while (queue.length > 0) {
142
+ const current = queue.shift();
143
+ if (!current || visited.has(current.name)) {
144
+ continue;
145
+ }
146
+ visited.add(current.name);
147
+ const assessment = assessments.get(current.name);
148
+ if (assessment && shouldReportRisk(assessment.trustScore, assessment.riskFactors.dependencyType)) {
149
+ highestTrustScore = combineTrustScores(highestTrustScore, assessment.trustScore);
150
+ const existing = riskyByName.get(current.name);
151
+ const pathTrace = current.path.join(" -> ");
152
+ if (existing) {
153
+ existing.introducedByPaths.push(pathTrace);
154
+ }
155
+ else {
156
+ riskyByName.set(current.name, {
157
+ name: current.name,
158
+ trustScore: assessment.trustScore,
159
+ confidence: assessment.confidence,
160
+ reasons: assessment.reasons,
161
+ introducedByPaths: [pathTrace]
162
+ });
163
+ }
164
+ }
165
+ const nextDependencies = graph.lockDependencies?.[current.name] ?? [];
166
+ for (const dependency of nextDependencies) {
167
+ if (!visited.has(dependency)) {
168
+ queue.push({
169
+ name: dependency,
170
+ path: [...current.path, dependency]
171
+ });
172
+ }
173
+ }
174
+ }
175
+ return {
176
+ transitiveDependencyCount: visited.size,
177
+ riskyTransitiveDeps: Array.from(riskyByName.values())
178
+ .map((item) => ({
179
+ ...item,
180
+ introducedByPaths: dedupeStrings(item.introducedByPaths).slice(0, 3)
181
+ }))
182
+ .sort((left, right) => trustScoreWeight(right.trustScore) - trustScoreWeight(left.trustScore) ||
183
+ right.confidence - left.confidence ||
184
+ left.name.localeCompare(right.name)),
185
+ highestTrustScore
186
+ };
187
+ }
188
+ function assessRisk(metadata, dependencyType, thresholds, transitiveDependencyCount) {
77
189
  const reasons = [];
78
190
  const reasonCodes = [];
79
191
  let weight = 0;
@@ -127,6 +239,7 @@ function assessRisk(metadata, dependencyType, thresholds) {
127
239
  ? "medium"
128
240
  : "high";
129
241
  return {
242
+ name: "",
130
243
  confidence,
131
244
  trustScore,
132
245
  reasons,
@@ -138,7 +251,29 @@ function assessRisk(metadata, dependencyType, thresholds) {
138
251
  versionCount: metadata.versionCount,
139
252
  recentReleaseCount: metadata.recentReleaseCount,
140
253
  hasRepository: Boolean(metadata.repository),
141
- dependencyType
254
+ dependencyType,
255
+ transitiveDependencyCount,
256
+ riskyTransitiveCount: 0
257
+ }
258
+ };
259
+ }
260
+ function buildUnknownAssessment(name, dependencyType) {
261
+ return {
262
+ name,
263
+ confidence: 0.5,
264
+ trustScore: "high",
265
+ reasons: [],
266
+ reasonCodes: [],
267
+ riskFactors: {
268
+ daysSincePublish: null,
269
+ downloads: null,
270
+ maintainersCount: null,
271
+ versionCount: null,
272
+ recentReleaseCount: null,
273
+ hasRepository: false,
274
+ dependencyType,
275
+ transitiveDependencyCount: 0,
276
+ riskyTransitiveCount: 0
142
277
  }
143
278
  };
144
279
  }
@@ -160,14 +295,33 @@ function shouldReportRisk(trustScore, dependencyType) {
160
295
  }
161
296
  return true;
162
297
  }
163
- function buildRiskRecommendation(reasons, confidence, trustScore) {
298
+ function buildRiskRecommendation(reasons, confidence, trustScore, riskyTransitiveCount) {
164
299
  return {
165
300
  action: "review",
166
- priority: trustScore === "low" || confidence >= 0.8 ? "high" : "medium",
301
+ priority: trustScore === "low" || confidence >= 0.8 || riskyTransitiveCount >= 2
302
+ ? "high"
303
+ : "medium",
167
304
  safety: "caution",
168
- summary: trustScore === "low"
169
- ? "Low trust package; review whether to replace, pin, or monitor it closely."
170
- : "Review package trust signals and decide whether to keep, replace, or monitor it.",
305
+ summary: riskyTransitiveCount > 0
306
+ ? `Review this direct dependency and its transitive chain before upgrading or keeping it.`
307
+ : trustScore === "low"
308
+ ? "Low trust package; review whether to replace, pin, or monitor it closely."
309
+ : "Review package trust signals and decide whether to keep, replace, or monitor it.",
171
310
  reasons
172
311
  };
173
312
  }
313
+ function trustScoreWeight(value) {
314
+ if (value === "low") {
315
+ return 3;
316
+ }
317
+ if (value === "medium") {
318
+ return 2;
319
+ }
320
+ return 1;
321
+ }
322
+ function combineTrustScores(left, right) {
323
+ return trustScoreWeight(left) >= trustScoreWeight(right) ? left : right;
324
+ }
325
+ function dedupeStrings(values) {
326
+ return Array.from(new Set(values));
327
+ }
@@ -43,8 +43,17 @@ export interface RiskFactors {
43
43
  recentReleaseCount: number | null;
44
44
  hasRepository: boolean;
45
45
  dependencyType: "dependencies" | "devDependencies" | "unknown";
46
+ transitiveDependencyCount: number;
47
+ riskyTransitiveCount: number;
46
48
  }
47
49
  export type TrustScore = "high" | "medium" | "low";
50
+ export interface RiskTransitiveDependency {
51
+ name: string;
52
+ trustScore: TrustScore;
53
+ confidence: number;
54
+ reasons: string[];
55
+ introducedByPaths: string[];
56
+ }
48
57
  export interface DuplicateDependency {
49
58
  name: string;
50
59
  versions: string[];
@@ -85,6 +94,8 @@ export interface RiskDependency {
85
94
  explanation: string[];
86
95
  trustScore: TrustScore;
87
96
  riskFactors: RiskFactors;
97
+ transitiveRiskScore: number;
98
+ riskyTransitiveDeps: RiskTransitiveDependency[];
88
99
  recommendation: Recommendation;
89
100
  }
90
101
  export interface TopIssue {
@@ -133,7 +144,7 @@ export interface PackageAnalysisResult {
133
144
  topIssues: TopIssue[];
134
145
  extensions: Record<string, unknown>;
135
146
  }
136
- export declare const OUTPUT_VERSION = "1.4";
147
+ export declare const OUTPUT_VERSION = "1.5";
137
148
  export interface ScoreBreakdown {
138
149
  baseScore: number;
139
150
  duplicates: number;
@@ -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);
@@ -396,6 +396,10 @@ function mapRiskIssues(issues) {
396
396
  explanation: normalizeStringArray(issue.explanation),
397
397
  trustScore: normalizeTrustScore(issue.meta?.trustScore),
398
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),
399
403
  recommendation: buildRiskRecommendation(issue)
400
404
  }));
401
405
  }
@@ -459,13 +463,18 @@ function buildRiskRecommendation(issue) {
459
463
  const reasons = normalizeStringArray(issue.explanation);
460
464
  const confidence = normalizeConfidence(issue.confidence);
461
465
  const trustScore = normalizeTrustScore(issue.meta?.trustScore);
466
+ const riskyTransitiveDeps = normalizeRiskTransitiveDependencies(issue.meta?.riskyTransitiveDeps);
462
467
  return {
463
468
  action: "review",
464
- priority: trustScore === "low" || confidence >= 0.79 ? "high" : "medium",
469
+ priority: trustScore === "low" || confidence >= 0.79 || riskyTransitiveDeps.length >= 2
470
+ ? "high"
471
+ : "medium",
465
472
  safety: "caution",
466
- summary: trustScore === "low"
467
- ? "Low trust package; review whether to replace, pin, or monitor it closely."
468
- : "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.",
469
478
  reasons
470
479
  };
471
480
  }
@@ -604,7 +613,9 @@ function normalizeRiskFactors(value) {
604
613
  versionCount: null,
605
614
  recentReleaseCount: null,
606
615
  hasRepository: false,
607
- dependencyType: "unknown"
616
+ dependencyType: "unknown",
617
+ transitiveDependencyCount: 0,
618
+ riskyTransitiveCount: 0
608
619
  };
609
620
  }
610
621
  const factors = value;
@@ -617,9 +628,38 @@ function normalizeRiskFactors(value) {
617
628
  hasRepository: factors.hasRepository === true,
618
629
  dependencyType: factors.dependencyType === "dependencies" || factors.dependencyType === "devDependencies"
619
630
  ? factors.dependencyType
620
- : "unknown"
631
+ : "unknown",
632
+ transitiveDependencyCount: typeof factors.transitiveDependencyCount === "number" ? factors.transitiveDependencyCount : 0,
633
+ riskyTransitiveCount: typeof factors.riskyTransitiveCount === "number" ? factors.riskyTransitiveCount : 0
621
634
  };
622
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
+ }
623
663
  function normalizeWorkspaceUsage(value) {
624
664
  if (!Array.isArray(value)) {
625
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
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
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
5
  export type { DepBrainPlugin, PluginDiagnostic, ProjectContext } from "./core/plugin-manager.js";
@@ -31,8 +31,8 @@ export function renderConsoleReport(result) {
31
31
  ? `${item.name}: ${item.current} -> ${item.latest} [${item.updateType}] [${item.package}]`
32
32
  : `${item.name}: ${item.current} -> ${item.latest} [${item.updateType}]`, item.confidence, item.explanation, item.recommendation)));
33
33
  appendSection(lines, "Risky dependencies", result.risks.map((item) => formatEntry(item.package
34
- ? `${item.name}: ${item.reasons.join("; ")} [${item.package}] [trust ${item.trustScore.toUpperCase()}]`
35
- : `${item.name}: ${item.reasons.join("; ")} [trust ${item.trustScore.toUpperCase()}]`, item.confidence, item.explanation, item.recommendation)));
34
+ ? `${item.name}: ${item.reasons.join("; ")} [${item.package}] [trust ${item.trustScore.toUpperCase()}]${formatTransitiveRiskSuffix(item)}`
35
+ : `${item.name}: ${item.reasons.join("; ")} [trust ${item.trustScore.toUpperCase()}]${formatTransitiveRiskSuffix(item)}`, item.confidence, item.explanation, item.recommendation)));
36
36
  appendSection(lines, "Policy reasons", result.policy.reasons);
37
37
  if (result.suggestions.length > 0) {
38
38
  lines.push("");
@@ -43,6 +43,13 @@ export function renderConsoleReport(result) {
43
43
  }
44
44
  return lines.join("\n");
45
45
  }
46
+ function formatTransitiveRiskSuffix(item) {
47
+ if (item.riskyTransitiveDeps.length === 0) {
48
+ return "";
49
+ }
50
+ const names = item.riskyTransitiveDeps.slice(0, 3).map((entry) => entry.name).join(", ");
51
+ return ` [transitive score ${item.transitiveRiskScore}] [via ${names}]`;
52
+ }
46
53
  function summaryLine(label, count) {
47
54
  const indicator = count === 0 ? "OK" : "WARN";
48
55
  return `${indicator} ${label}: ${count}`;
@@ -1,6 +1,11 @@
1
1
  export function renderDashboardReport(result) {
2
2
  const topIssues = result.topIssues.map(renderTopIssue).join("");
3
3
  const suggestions = result.suggestions.map((item) => `<li>${escapeHtml(item)}</li>`).join("");
4
+ const transitiveHotspots = result.risks
5
+ .filter((item) => item.riskyTransitiveDeps.length > 0)
6
+ .sort((left, right) => right.transitiveRiskScore - left.transitiveRiskScore)
7
+ .map(renderTransitiveHotspot)
8
+ .join("");
4
9
  return [
5
10
  "<!doctype html>",
6
11
  '<html lang="en">',
@@ -14,9 +19,11 @@ export function renderDashboardReport(result) {
14
19
  "header{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;margin-bottom:24px}",
15
20
  "h1{font-size:28px;margin:0 0 8px}",
16
21
  "h2{font-size:18px;margin:0 0 12px}",
22
+ "h3{font-size:15px;margin:0 0 8px}",
17
23
  ".muted{color:#637083;font-size:13px}",
18
24
  ".score{font-size:48px;font-weight:700}",
19
25
  ".grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:20px}",
26
+ ".split{display:grid;grid-template-columns:1.2fr .8fr;gap:16px;margin-top:16px}",
20
27
  ".panel{background:#fff;border:1px solid #dce3ee;border-radius:8px;padding:16px}",
21
28
  ".metric{font-size:28px;font-weight:700;margin-top:4px}",
22
29
  ".pass{color:#167a43}.fail{color:#b42318}",
@@ -24,7 +31,9 @@ export function renderDashboardReport(result) {
24
31
  "li{margin:8px 0}",
25
32
  ".issue{margin-bottom:10px}",
26
33
  ".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}}",
34
+ ".hotspot{border-top:1px solid #e7ecf4;padding-top:12px;margin-top:12px}",
35
+ ".path{font-family:Consolas,monospace;font-size:12px;background:#f3f6fb;border-radius:6px;padding:6px 8px;margin:6px 0}",
36
+ "@media(max-width:760px){main{padding:20px}.grid{grid-template-columns:repeat(2,minmax(0,1fr))}.split{grid-template-columns:1fr}header{display:block}.score{font-size:40px}}",
28
37
  "</style>",
29
38
  "</head>",
30
39
  "<body>",
@@ -42,20 +51,36 @@ export function renderDashboardReport(result) {
42
51
  renderMetric("Outdated", result.outdated.length),
43
52
  renderMetric("Risks", result.risks.length),
44
53
  "</section>",
45
- '<section class="panel">',
54
+ '<section class="split">',
55
+ '<div class="panel">',
46
56
  "<h2>Policy</h2>",
47
57
  `<p class="${result.policy.passed ? "pass" : "fail"}">${result.policy.passed ? "Passed" : "Failed"}</p>`,
48
58
  result.policy.reasons.length > 0
49
59
  ? `<ul>${result.policy.reasons.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>`
50
60
  : '<p class="muted">No policy failures.</p>',
61
+ "</div>",
62
+ '<div class="panel">',
63
+ "<h2>Risk Snapshot</h2>",
64
+ `<div class="muted">${result.risks.filter((item) => item.riskyTransitiveDeps.length > 0).length} direct dependencies carry transitive risk.</div>`,
65
+ `<div class="metric">${result.risks.reduce((total, item) => total + item.transitiveRiskScore, 0)}</div>`,
66
+ '<div class="muted">Total transitive risk score</div>',
67
+ "</div>",
51
68
  "</section>",
52
- '<section class="panel">',
69
+ '<section class="split">',
70
+ '<div class="panel">',
53
71
  "<h2>Top Issues</h2>",
54
72
  topIssues.length > 0 ? `<ol>${topIssues}</ol>` : '<p class="muted">No actionable issues found.</p>',
55
- "</section>",
56
- '<section class="panel">',
73
+ "</div>",
74
+ '<div class="panel">',
57
75
  "<h2>Suggestions</h2>",
58
76
  suggestions.length > 0 ? `<ul>${suggestions}</ul>` : '<p class="muted">No suggestions.</p>',
77
+ "</div>",
78
+ "</section>",
79
+ '<section class="panel">',
80
+ "<h2>Transitive Risk Hotspots</h2>",
81
+ transitiveHotspots.length > 0
82
+ ? transitiveHotspots
83
+ : '<p class="muted">No transitive risk hotspots found.</p>',
59
84
  "</section>",
60
85
  "</main>",
61
86
  "</body>",
@@ -74,6 +99,20 @@ function renderTopIssue(item) {
74
99
  "</li>"
75
100
  ].join("");
76
101
  }
102
+ function renderTransitiveHotspot(item) {
103
+ return [
104
+ '<div class="hotspot">',
105
+ `<h3>${escapeHtml(item.name)} <span class="muted">score ${item.transitiveRiskScore}</span></h3>`,
106
+ `<div class="muted">${item.riskFactors.transitiveDependencyCount} transitive dependencies, ${item.riskyTransitiveDeps.length} risky transitive dependencies</div>`,
107
+ "<ul>",
108
+ item.riskyTransitiveDeps
109
+ .slice(0, 4)
110
+ .map((entry) => `<li><strong>${escapeHtml(entry.name)}</strong> [${escapeHtml(entry.trustScore.toUpperCase())}]<div>${escapeHtml(entry.reasons.join("; "))}</div>${entry.introducedByPaths.map((trace) => `<div class="path">${escapeHtml(trace)}</div>`).join("")}</li>`)
111
+ .join(""),
112
+ "</ul>",
113
+ "</div>"
114
+ ].join("");
115
+ }
77
116
  function escapeHtml(value) {
78
117
  return value
79
118
  .replace(/&/g, "&amp;")
@@ -35,8 +35,8 @@ export function renderMarkdownReport(result) {
35
35
  ? `${item.name}: ${item.current} -> ${item.latest} [${item.updateType}] [${item.package}]`
36
36
  : `${item.name}: ${item.current} -> ${item.latest} [${item.updateType}]`, item.confidence, item.explanation, item.recommendation)));
37
37
  appendSection(lines, "Risky dependencies", result.risks.map((item) => formatEntry(item.package
38
- ? `${item.name}: ${item.reasons.join("; ")} [${item.package}] [trust ${item.trustScore.toUpperCase()}]`
39
- : `${item.name}: ${item.reasons.join("; ")} [trust ${item.trustScore.toUpperCase()}]`, item.confidence, item.explanation, item.recommendation)));
38
+ ? `${item.name}: ${item.reasons.join("; ")} [${item.package}] [trust ${item.trustScore.toUpperCase()}]${formatTransitiveRiskSuffix(item)}`
39
+ : `${item.name}: ${item.reasons.join("; ")} [trust ${item.trustScore.toUpperCase()}]${formatTransitiveRiskSuffix(item)}`, item.confidence, item.explanation, item.recommendation)));
40
40
  appendSection(lines, "Policy reasons", result.policy.reasons);
41
41
  if (result.suggestions.length > 0) {
42
42
  lines.push("## Suggestions");
@@ -47,6 +47,13 @@ export function renderMarkdownReport(result) {
47
47
  }
48
48
  return lines.join("\n");
49
49
  }
50
+ function formatTransitiveRiskSuffix(item) {
51
+ if (item.riskyTransitiveDeps.length === 0) {
52
+ return "";
53
+ }
54
+ const names = item.riskyTransitiveDeps.slice(0, 3).map((entry) => entry.name).join(", ");
55
+ return ` [transitive score ${item.transitiveRiskScore}] [via ${names}]`;
56
+ }
50
57
  function appendSection(lines, title, items) {
51
58
  if (items.length === 0) {
52
59
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dep-brain",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "CLI and library for explainable dependency intelligence",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",