dep-brain 0.8.0 → 1.0.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 +13 -1
- package/README.md +57 -4
- package/action.yml +116 -0
- package/depbrain.output.schema.json +27 -2
- package/dist/checks/duplicate.js +2 -0
- package/dist/checks/outdated.js +16 -2
- package/dist/checks/risk.js +16 -2
- package/dist/cli.js +28 -4
- package/dist/core/analyzer.d.ts +23 -1
- package/dist/core/analyzer.js +166 -43
- package/dist/core/graph-builder.js +93 -1
- package/dist/index.d.ts +1 -1
- package/dist/reporters/console.js +2 -2
- package/dist/reporters/markdown.js +2 -2
- package/dist/reporters/sarif.d.ts +2 -0
- package/dist/reporters/sarif.js +126 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 1.0.0
|
|
6
|
+
|
|
7
|
+
- Stable v1 CLI and library release for explainable dependency intelligence.
|
|
8
|
+
- Added baseline mode with `--baseline <file>` to ignore existing dependency debt in CI.
|
|
9
|
+
- Added reusable GitHub Action metadata through `action.yml`.
|
|
10
|
+
- Added SARIF export support for code scanning workflows.
|
|
11
|
+
- Added stable JSON output fields for confidence, reason codes, explanations, recommendations, top issues, score breakdown, and workspace ownership summaries.
|
|
12
|
+
- Added bounded registry request parallelism for outdated and risk checks.
|
|
13
|
+
- Documented v1 readiness, release validation, and CI usage.
|
|
14
|
+
|
|
15
|
+
## 0.9.0
|
|
6
16
|
|
|
7
17
|
- Workspace-aware analysis for npm workspaces.
|
|
8
18
|
- Config loading and CI policy controls.
|
|
@@ -11,3 +21,5 @@ All notable changes to this project will be documented in this file.
|
|
|
11
21
|
- Ranked top-issues summary output with `--top`.
|
|
12
22
|
- Supply-chain trust scoring for risk findings.
|
|
13
23
|
- Structured risk factors in JSON output.
|
|
24
|
+
- Monorepo ownership summaries for workspace packages.
|
|
25
|
+
- Workspace-level duplicate attribution and root-cause tracing.
|
package/README.md
CHANGED
|
@@ -17,13 +17,15 @@
|
|
|
17
17
|
|
|
18
18
|
## What It Does
|
|
19
19
|
|
|
20
|
-
- Detect duplicate dependencies from
|
|
20
|
+
- Detect duplicate dependencies from npm, pnpm, and yarn lockfiles
|
|
21
21
|
- Detect likely unused dependencies from source imports and scripts
|
|
22
22
|
- Detect outdated packages
|
|
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
|
|
26
|
+
- Output reports in console, JSON, Markdown, SARIF, and top-issues formats
|
|
27
|
+
- Gate CI with score and finding policies
|
|
28
|
+
- Compare new findings against a baseline report
|
|
27
29
|
|
|
28
30
|
The long-term goal is not just to list problems, but to answer:
|
|
29
31
|
|
|
@@ -31,18 +33,24 @@ The long-term goal is not just to list problems, but to answer:
|
|
|
31
33
|
- Can I remove it safely?
|
|
32
34
|
- What should I fix first?
|
|
33
35
|
|
|
34
|
-
##
|
|
36
|
+
## v1 Features
|
|
35
37
|
|
|
36
38
|
- Duplicate dependency detection with lockfile instance tracking
|
|
37
39
|
- Unused dependency detection with runtime vs dev-tool heuristics
|
|
38
40
|
- Outdated dependency reporting with `major`, `minor`, and `patch` classification
|
|
39
41
|
- Risk analysis based on npm package metadata
|
|
42
|
+
- Confidence scores, reason codes, explanations, and recommendations for findings
|
|
40
43
|
- Config loading from `depbrain.config.json`
|
|
41
44
|
- Ignore rules for noisy dependencies and checks
|
|
42
45
|
- CI-friendly policy evaluation with non-zero exit codes
|
|
43
46
|
- Workspace-aware analysis for npm workspaces
|
|
44
47
|
- Console reporting
|
|
45
48
|
- JSON output via `--json`
|
|
49
|
+
- Markdown output via `--md`
|
|
50
|
+
- SARIF output via `--sarif`
|
|
51
|
+
- Ranked top issues via `--top`
|
|
52
|
+
- Baseline mode via `--baseline`
|
|
53
|
+
- Reusable GitHub Action via `action.yml`
|
|
46
54
|
- Library entrypoint for programmatic use
|
|
47
55
|
|
|
48
56
|
## CLI Usage
|
|
@@ -61,6 +69,9 @@ npx dep-brain analyze --min-score 90 --fail-on-risks
|
|
|
61
69
|
npx dep-brain analyze ./path-to-project --fail-on-unused --json
|
|
62
70
|
npx dep-brain analyze --md > depbrain.md
|
|
63
71
|
npx dep-brain analyze --json --out depbrain.json
|
|
72
|
+
npx dep-brain analyze --sarif --out depbrain.sarif
|
|
73
|
+
npx dep-brain analyze --baseline depbrain-baseline.json
|
|
74
|
+
npx dep-brain analyze --baseline depbrain-baseline.json --min-score 90 --fail-on-risks
|
|
64
75
|
npx dep-brain report --from depbrain.json --md --out depbrain.md
|
|
65
76
|
|
|
66
77
|
dep-brain config
|
|
@@ -75,6 +86,12 @@ dep-brain --version
|
|
|
75
86
|
|
|
76
87
|
If the root `package.json` defines `workspaces`, `dep-brain` analyzes each workspace package and reports per-package results. Aggregated counts are still shown at the top-level summary.
|
|
77
88
|
|
|
89
|
+
Workspace analysis now includes:
|
|
90
|
+
|
|
91
|
+
- per-workspace ownership summaries
|
|
92
|
+
- root-level duplicate attribution back to contributing workspaces
|
|
93
|
+
- top issues that stay tagged to the workspace that should act
|
|
94
|
+
|
|
78
95
|
## Example Output
|
|
79
96
|
|
|
80
97
|
```text
|
|
@@ -132,6 +149,41 @@ dep-brain analyze --json --out depbrain.json
|
|
|
132
149
|
dep-brain report --from depbrain.json --md --out depbrain.md
|
|
133
150
|
```
|
|
134
151
|
|
|
152
|
+
## Baseline Mode
|
|
153
|
+
|
|
154
|
+
Baseline mode lets teams adopt `dep-brain` in existing repositories without failing CI for known dependency debt.
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
dep-brain analyze --json --out depbrain-baseline.json
|
|
158
|
+
dep-brain analyze --baseline depbrain-baseline.json --min-score 90 --fail-on-risks
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
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.
|
|
162
|
+
|
|
163
|
+
## GitHub Action
|
|
164
|
+
|
|
165
|
+
```yaml
|
|
166
|
+
name: Dependency Brain
|
|
167
|
+
|
|
168
|
+
on:
|
|
169
|
+
pull_request:
|
|
170
|
+
|
|
171
|
+
jobs:
|
|
172
|
+
dep-brain:
|
|
173
|
+
runs-on: ubuntu-latest
|
|
174
|
+
steps:
|
|
175
|
+
- uses: actions/checkout@v4
|
|
176
|
+
- uses: actions/setup-node@v4
|
|
177
|
+
with:
|
|
178
|
+
node-version: 22
|
|
179
|
+
- uses: prakashu51/dep-brain@v1
|
|
180
|
+
with:
|
|
181
|
+
format: sarif
|
|
182
|
+
out: depbrain.sarif
|
|
183
|
+
min-score: 85
|
|
184
|
+
fail-on-risks: "true"
|
|
185
|
+
```
|
|
186
|
+
|
|
135
187
|
## Config File
|
|
136
188
|
|
|
137
189
|
Create a `depbrain.config.json` file in the project root:
|
|
@@ -202,6 +254,7 @@ Examples:
|
|
|
202
254
|
dep-brain analyze --fail-on-unused
|
|
203
255
|
dep-brain analyze --min-score 85 --fail-on-risks
|
|
204
256
|
dep-brain analyze --config depbrain.config.json
|
|
257
|
+
dep-brain analyze --baseline depbrain-baseline.json --fail-on-unused
|
|
205
258
|
```
|
|
206
259
|
|
|
207
260
|
## Config Debugging
|
|
@@ -248,7 +301,7 @@ src/
|
|
|
248
301
|
|
|
249
302
|
## Product Direction
|
|
250
303
|
|
|
251
|
-
`dep-brain` is
|
|
304
|
+
`dep-brain` is in its `v1.0.0` production-ready CLI stage. The roadmap delivered through v1:
|
|
252
305
|
|
|
253
306
|
- `v0.6`: explainability and confidence scoring
|
|
254
307
|
- `v0.7`: safe removal guidance and actionable recommendations
|
package/action.yml
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
name: Dependency Brain
|
|
2
|
+
description: Run dep-brain dependency intelligence checks in GitHub Actions.
|
|
3
|
+
author: dep-brain
|
|
4
|
+
|
|
5
|
+
inputs:
|
|
6
|
+
path:
|
|
7
|
+
description: Project path to analyze.
|
|
8
|
+
required: false
|
|
9
|
+
default: "."
|
|
10
|
+
format:
|
|
11
|
+
description: Output format. Use console, json, md, sarif, or top.
|
|
12
|
+
required: false
|
|
13
|
+
default: "console"
|
|
14
|
+
out:
|
|
15
|
+
description: Optional path to write the report.
|
|
16
|
+
required: false
|
|
17
|
+
default: ""
|
|
18
|
+
config:
|
|
19
|
+
description: Optional path to depbrain.config.json.
|
|
20
|
+
required: false
|
|
21
|
+
default: ""
|
|
22
|
+
baseline:
|
|
23
|
+
description: Optional baseline JSON report used to ignore existing findings.
|
|
24
|
+
required: false
|
|
25
|
+
default: ""
|
|
26
|
+
min-score:
|
|
27
|
+
description: Minimum project health score required to pass.
|
|
28
|
+
required: false
|
|
29
|
+
default: ""
|
|
30
|
+
fail-on-unused:
|
|
31
|
+
description: Fail when unused dependencies are found.
|
|
32
|
+
required: false
|
|
33
|
+
default: "false"
|
|
34
|
+
fail-on-outdated:
|
|
35
|
+
description: Fail when outdated dependencies are found.
|
|
36
|
+
required: false
|
|
37
|
+
default: "false"
|
|
38
|
+
fail-on-duplicates:
|
|
39
|
+
description: Fail when duplicate dependencies are found.
|
|
40
|
+
required: false
|
|
41
|
+
default: "false"
|
|
42
|
+
fail-on-risks:
|
|
43
|
+
description: Fail when risky dependencies are found.
|
|
44
|
+
required: false
|
|
45
|
+
default: "false"
|
|
46
|
+
|
|
47
|
+
runs:
|
|
48
|
+
using: composite
|
|
49
|
+
steps:
|
|
50
|
+
- name: Run dep-brain
|
|
51
|
+
shell: bash
|
|
52
|
+
env:
|
|
53
|
+
INPUT_PATH: ${{ inputs.path }}
|
|
54
|
+
INPUT_FORMAT: ${{ inputs.format }}
|
|
55
|
+
INPUT_OUT: ${{ inputs.out }}
|
|
56
|
+
INPUT_CONFIG: ${{ inputs.config }}
|
|
57
|
+
INPUT_BASELINE: ${{ inputs.baseline }}
|
|
58
|
+
INPUT_MIN_SCORE: ${{ inputs.min-score }}
|
|
59
|
+
INPUT_FAIL_ON_UNUSED: ${{ inputs.fail-on-unused }}
|
|
60
|
+
INPUT_FAIL_ON_OUTDATED: ${{ inputs.fail-on-outdated }}
|
|
61
|
+
INPUT_FAIL_ON_DUPLICATES: ${{ inputs.fail-on-duplicates }}
|
|
62
|
+
INPUT_FAIL_ON_RISKS: ${{ inputs.fail-on-risks }}
|
|
63
|
+
run: |
|
|
64
|
+
set -euo pipefail
|
|
65
|
+
|
|
66
|
+
args=("analyze" "$INPUT_PATH")
|
|
67
|
+
|
|
68
|
+
case "$INPUT_FORMAT" in
|
|
69
|
+
json) args+=("--json") ;;
|
|
70
|
+
md) args+=("--md") ;;
|
|
71
|
+
sarif) args+=("--sarif") ;;
|
|
72
|
+
top) args+=("--top") ;;
|
|
73
|
+
console) ;;
|
|
74
|
+
*)
|
|
75
|
+
echo "Unsupported format: $INPUT_FORMAT" >&2
|
|
76
|
+
exit 1
|
|
77
|
+
;;
|
|
78
|
+
esac
|
|
79
|
+
|
|
80
|
+
if [ -n "$INPUT_OUT" ]; then
|
|
81
|
+
args+=("--out" "$INPUT_OUT")
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
if [ -n "$INPUT_CONFIG" ]; then
|
|
85
|
+
args+=("--config" "$INPUT_CONFIG")
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
if [ -n "$INPUT_BASELINE" ]; then
|
|
89
|
+
args+=("--baseline" "$INPUT_BASELINE")
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
if [ -n "$INPUT_MIN_SCORE" ]; then
|
|
93
|
+
args+=("--min-score" "$INPUT_MIN_SCORE")
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
if [ "$INPUT_FAIL_ON_UNUSED" = "true" ]; then
|
|
97
|
+
args+=("--fail-on-unused")
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
if [ "$INPUT_FAIL_ON_OUTDATED" = "true" ]; then
|
|
101
|
+
args+=("--fail-on-outdated")
|
|
102
|
+
fi
|
|
103
|
+
|
|
104
|
+
if [ "$INPUT_FAIL_ON_DUPLICATES" = "true" ]; then
|
|
105
|
+
args+=("--fail-on-duplicates")
|
|
106
|
+
fi
|
|
107
|
+
|
|
108
|
+
if [ "$INPUT_FAIL_ON_RISKS" = "true" ]; then
|
|
109
|
+
args+=("--fail-on-risks")
|
|
110
|
+
fi
|
|
111
|
+
|
|
112
|
+
node "$GITHUB_ACTION_PATH/dist/cli.js" "${args[@]}"
|
|
113
|
+
|
|
114
|
+
branding:
|
|
115
|
+
icon: activity
|
|
116
|
+
color: blue
|
|
@@ -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", "duplicates", "unused", "outdated", "risks", "suggestions", "topIssues", "config"],
|
|
5
|
+
"required": ["outputVersion", "rootDir", "score", "scoreBreakdown", "policy", "ownershipSummary", "duplicates", "unused", "outdated", "risks", "suggestions", "topIssues", "config"],
|
|
6
6
|
"additionalProperties": false,
|
|
7
7
|
"properties": {
|
|
8
8
|
"recommendation": {
|
|
@@ -31,6 +31,17 @@
|
|
|
31
31
|
"dependencyType": { "type": "string", "enum": ["dependencies", "devDependencies", "unknown"] }
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
|
+
"ownershipSummary": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"required": ["duplicates", "unused", "outdated", "risks"],
|
|
37
|
+
"additionalProperties": false,
|
|
38
|
+
"properties": {
|
|
39
|
+
"duplicates": { "type": "number" },
|
|
40
|
+
"unused": { "type": "number" },
|
|
41
|
+
"outdated": { "type": "number" },
|
|
42
|
+
"risks": { "type": "number" }
|
|
43
|
+
}
|
|
44
|
+
},
|
|
34
45
|
"outputVersion": { "type": "string" },
|
|
35
46
|
"rootDir": { "type": "string" },
|
|
36
47
|
"score": { "type": "number" },
|
|
@@ -70,11 +81,25 @@
|
|
|
70
81
|
"type": "array",
|
|
71
82
|
"items": {
|
|
72
83
|
"type": "object",
|
|
73
|
-
"required": ["name", "versions", "instances", "confidence", "reasonCodes", "explanation", "recommendation"],
|
|
84
|
+
"required": ["name", "versions", "instances", "workspaceUsage", "rootCause", "confidence", "reasonCodes", "explanation", "recommendation"],
|
|
74
85
|
"additionalProperties": false,
|
|
75
86
|
"properties": {
|
|
76
87
|
"name": { "type": "string" },
|
|
77
88
|
"versions": { "type": "array", "items": { "type": "string" } },
|
|
89
|
+
"workspaceUsage": {
|
|
90
|
+
"type": "array",
|
|
91
|
+
"items": {
|
|
92
|
+
"type": "object",
|
|
93
|
+
"required": ["workspace", "section", "declaredVersion"],
|
|
94
|
+
"additionalProperties": false,
|
|
95
|
+
"properties": {
|
|
96
|
+
"workspace": { "type": "string" },
|
|
97
|
+
"section": { "type": "string", "enum": ["dependencies", "devDependencies"] },
|
|
98
|
+
"declaredVersion": { "type": "string" }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"rootCause": { "type": "array", "items": { "type": "string" } },
|
|
78
103
|
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
79
104
|
"reasonCodes": { "type": "array", "items": { "type": "string" } },
|
|
80
105
|
"explanation": { "type": "array", "items": { "type": "string" } },
|
package/dist/checks/duplicate.js
CHANGED
|
@@ -4,6 +4,8 @@ export async function findDuplicateDependencies(graph) {
|
|
|
4
4
|
name,
|
|
5
5
|
versions: Array.from(new Set(instances.map((instance) => instance.version))).sort(),
|
|
6
6
|
instances,
|
|
7
|
+
workspaceUsage: [],
|
|
8
|
+
rootCause: [],
|
|
7
9
|
confidence: 0.98,
|
|
8
10
|
reasonCodes: [
|
|
9
11
|
"multiple_lockfile_versions",
|
package/dist/checks/outdated.js
CHANGED
|
@@ -5,7 +5,7 @@ export async function findOutdatedDependencies(graph, options = {}) {
|
|
|
5
5
|
...graph.dependencies,
|
|
6
6
|
...graph.devDependencies
|
|
7
7
|
};
|
|
8
|
-
const results = await
|
|
8
|
+
const results = await mapWithConcurrency(Object.entries(combined), 8, async ([name, current]) => {
|
|
9
9
|
const normalized = normalizeVersion(current);
|
|
10
10
|
const latest = await resolveLatestVersion(name);
|
|
11
11
|
if (!latest || latest === normalized) {
|
|
@@ -28,11 +28,25 @@ export async function findOutdatedDependencies(graph, options = {}) {
|
|
|
28
28
|
],
|
|
29
29
|
recommendation: buildOutdatedRecommendation(updateType)
|
|
30
30
|
};
|
|
31
|
-
})
|
|
31
|
+
});
|
|
32
32
|
return results
|
|
33
33
|
.filter((item) => item !== null)
|
|
34
34
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
35
35
|
}
|
|
36
|
+
async function mapWithConcurrency(items, limit, mapper) {
|
|
37
|
+
const results = new Array(items.length);
|
|
38
|
+
let nextIndex = 0;
|
|
39
|
+
async function worker() {
|
|
40
|
+
while (nextIndex < items.length) {
|
|
41
|
+
const currentIndex = nextIndex;
|
|
42
|
+
nextIndex += 1;
|
|
43
|
+
results[currentIndex] = await mapper(items[currentIndex]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
|
|
47
|
+
await Promise.all(workers);
|
|
48
|
+
return results;
|
|
49
|
+
}
|
|
36
50
|
function buildOutdatedRecommendation(updateType) {
|
|
37
51
|
return {
|
|
38
52
|
action: "upgrade",
|
package/dist/checks/risk.js
CHANGED
|
@@ -7,7 +7,7 @@ export async function findRiskDependencies(graph, options = {}) {
|
|
|
7
7
|
...graph.dependencies,
|
|
8
8
|
...graph.devDependencies
|
|
9
9
|
});
|
|
10
|
-
const results = await
|
|
10
|
+
const results = await mapWithConcurrency(names, 8, async (name) => {
|
|
11
11
|
const metadata = await resolvePackageMetadata(name);
|
|
12
12
|
if (!metadata) {
|
|
13
13
|
return null;
|
|
@@ -31,11 +31,25 @@ export async function findRiskDependencies(graph, options = {}) {
|
|
|
31
31
|
riskFactors: assessment.riskFactors,
|
|
32
32
|
recommendation: buildRiskRecommendation(assessment.reasons, assessment.confidence, assessment.trustScore)
|
|
33
33
|
};
|
|
34
|
-
})
|
|
34
|
+
});
|
|
35
35
|
return results
|
|
36
36
|
.filter((item) => item !== null)
|
|
37
37
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
38
38
|
}
|
|
39
|
+
async function mapWithConcurrency(items, limit, mapper) {
|
|
40
|
+
const results = new Array(items.length);
|
|
41
|
+
let nextIndex = 0;
|
|
42
|
+
async function worker() {
|
|
43
|
+
while (nextIndex < items.length) {
|
|
44
|
+
const currentIndex = nextIndex;
|
|
45
|
+
nextIndex += 1;
|
|
46
|
+
results[currentIndex] = await mapper(items[currentIndex]);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
|
|
50
|
+
await Promise.all(workers);
|
|
51
|
+
return results;
|
|
52
|
+
}
|
|
39
53
|
export async function runRiskCheck(graph) {
|
|
40
54
|
const risks = await findRiskDependencies(graph);
|
|
41
55
|
return {
|
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { analyzeProject } from "./core/analyzer.js";
|
|
|
3
3
|
import { renderConsoleReport } from "./reporters/console.js";
|
|
4
4
|
import { renderJsonReport } from "./reporters/json.js";
|
|
5
5
|
import { renderMarkdownReport } from "./reporters/markdown.js";
|
|
6
|
+
import { renderSarifReport } from "./reporters/sarif.js";
|
|
6
7
|
import { promises as fs } from "node:fs";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
async function main() {
|
|
@@ -55,7 +56,9 @@ async function main() {
|
|
|
55
56
|
? renderTopIssuesReport(reportData)
|
|
56
57
|
: flags.has("--json")
|
|
57
58
|
? JSON.stringify(reportData, null, 2)
|
|
58
|
-
:
|
|
59
|
+
: flags.has("--sarif")
|
|
60
|
+
? renderSarifReport(reportData)
|
|
61
|
+
: renderMarkdownReport(reportData);
|
|
59
62
|
await writeOutput(output, optionValues.get("--out"));
|
|
60
63
|
return;
|
|
61
64
|
}
|
|
@@ -100,15 +103,20 @@ async function main() {
|
|
|
100
103
|
}
|
|
101
104
|
try {
|
|
102
105
|
const cliConfig = buildCliConfig(flags, optionValues);
|
|
106
|
+
const baseline = await loadBaseline(optionValues.get("--baseline"));
|
|
103
107
|
const result = await analyzeProject({
|
|
104
108
|
rootDir: targetPath,
|
|
105
109
|
configPath: optionValues.get("--config"),
|
|
106
|
-
config: cliConfig
|
|
110
|
+
config: cliConfig,
|
|
111
|
+
baseline
|
|
107
112
|
});
|
|
108
113
|
let output;
|
|
109
114
|
if (flags.has("--json")) {
|
|
110
115
|
output = renderJsonReport(result);
|
|
111
116
|
}
|
|
117
|
+
else if (flags.has("--sarif")) {
|
|
118
|
+
output = renderSarifReport(result);
|
|
119
|
+
}
|
|
112
120
|
else if (flags.has("--top")) {
|
|
113
121
|
output = renderTopIssuesReport(result);
|
|
114
122
|
}
|
|
@@ -178,8 +186,8 @@ function printHelp() {
|
|
|
178
186
|
console.log("Dependency Brain");
|
|
179
187
|
console.log("");
|
|
180
188
|
console.log("Usage:");
|
|
181
|
-
console.log(" dep-brain analyze [path] [--json] [--md] [--top] [--out path] [--config path] [--
|
|
182
|
-
console.log(" dep-brain report --from <file> [--md] [--json] [--top] [--out path]");
|
|
189
|
+
console.log(" dep-brain analyze [path] [--json] [--md] [--sarif] [--top] [--out path] [--config path] [--baseline path] [--min-score n] [--fail-on-risks]");
|
|
190
|
+
console.log(" dep-brain report --from <file> [--md] [--json] [--sarif] [--top] [--out path]");
|
|
183
191
|
console.log(" dep-brain config [path] [--config path]");
|
|
184
192
|
console.log(" dep-brain help");
|
|
185
193
|
console.log(" dep-brain --version");
|
|
@@ -187,8 +195,10 @@ function printHelp() {
|
|
|
187
195
|
console.log("Options:");
|
|
188
196
|
console.log(" --json Output JSON for analysis");
|
|
189
197
|
console.log(" --md Output Markdown report");
|
|
198
|
+
console.log(" --sarif Output SARIF format for Code Scanning");
|
|
190
199
|
console.log(" --top Output the ranked top issues only");
|
|
191
200
|
console.log(" --config <path> Path to depbrain.config.json");
|
|
201
|
+
console.log(" --baseline <path> Ignore findings already present in a baseline JSON report");
|
|
192
202
|
console.log(" --from <file> Read analysis JSON from file");
|
|
193
203
|
console.log(" --out <path> Write output to a file");
|
|
194
204
|
console.log(" --min-score <n> Minimum score required to pass");
|
|
@@ -210,6 +220,20 @@ async function loadPackageVersion() {
|
|
|
210
220
|
return null;
|
|
211
221
|
}
|
|
212
222
|
}
|
|
223
|
+
async function loadBaseline(baselinePath) {
|
|
224
|
+
if (!baselinePath) {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
const resolved = resolveUserPath(baselinePath);
|
|
228
|
+
const raw = await fs.readFile(resolved, "utf8");
|
|
229
|
+
const parsed = JSON.parse(raw);
|
|
230
|
+
return {
|
|
231
|
+
duplicates: Array.isArray(parsed.duplicates) ? parsed.duplicates : [],
|
|
232
|
+
unused: Array.isArray(parsed.unused) ? parsed.unused : [],
|
|
233
|
+
outdated: Array.isArray(parsed.outdated) ? parsed.outdated : [],
|
|
234
|
+
risks: Array.isArray(parsed.risks) ? parsed.risks : []
|
|
235
|
+
};
|
|
236
|
+
}
|
|
213
237
|
async function writeOutput(output, outPath) {
|
|
214
238
|
if (outPath) {
|
|
215
239
|
const resolved = resolveUserPath(outPath);
|
package/dist/core/analyzer.d.ts
CHANGED
|
@@ -3,11 +3,29 @@ export interface AnalysisOptions {
|
|
|
3
3
|
rootDir?: string;
|
|
4
4
|
configPath?: string;
|
|
5
5
|
config?: DepBrainConfigOverrides;
|
|
6
|
+
baseline?: DepBrainBaseline;
|
|
7
|
+
}
|
|
8
|
+
export interface DepBrainBaseline {
|
|
9
|
+
duplicates?: Array<Partial<Pick<DuplicateDependency, "name">>>;
|
|
10
|
+
unused?: Array<Partial<Pick<UnusedDependency, "name" | "section" | "package">>>;
|
|
11
|
+
outdated?: Array<Partial<Pick<OutdatedDependency, "name" | "current" | "latest" | "package">>>;
|
|
12
|
+
risks?: Array<Partial<Pick<RiskDependency, "name" | "package">>>;
|
|
6
13
|
}
|
|
7
14
|
export interface DuplicateInstance {
|
|
8
15
|
path: string;
|
|
9
16
|
version: string;
|
|
10
17
|
}
|
|
18
|
+
export interface WorkspaceDependencyUsage {
|
|
19
|
+
workspace: string;
|
|
20
|
+
section: "dependencies" | "devDependencies";
|
|
21
|
+
declaredVersion: string;
|
|
22
|
+
}
|
|
23
|
+
export interface WorkspaceOwnershipSummary {
|
|
24
|
+
duplicates: number;
|
|
25
|
+
unused: number;
|
|
26
|
+
outdated: number;
|
|
27
|
+
risks: number;
|
|
28
|
+
}
|
|
11
29
|
export interface Recommendation {
|
|
12
30
|
action: "remove" | "consolidate" | "upgrade" | "review";
|
|
13
31
|
priority: "high" | "medium" | "low";
|
|
@@ -29,6 +47,8 @@ export interface DuplicateDependency {
|
|
|
29
47
|
name: string;
|
|
30
48
|
versions: string[];
|
|
31
49
|
instances: DuplicateInstance[];
|
|
50
|
+
workspaceUsage: WorkspaceDependencyUsage[];
|
|
51
|
+
rootCause: string[];
|
|
32
52
|
confidence: number;
|
|
33
53
|
reasonCodes: string[];
|
|
34
54
|
explanation: string[];
|
|
@@ -81,6 +101,7 @@ export interface AnalysisResult {
|
|
|
81
101
|
score: number;
|
|
82
102
|
scoreBreakdown: ScoreBreakdown;
|
|
83
103
|
policy: PolicyResult;
|
|
104
|
+
ownershipSummary: WorkspaceOwnershipSummary;
|
|
84
105
|
duplicates: DuplicateDependency[];
|
|
85
106
|
unused: UnusedDependency[];
|
|
86
107
|
outdated: OutdatedDependency[];
|
|
@@ -100,6 +121,7 @@ export interface PackageAnalysisResult {
|
|
|
100
121
|
score: number;
|
|
101
122
|
scoreBreakdown: ScoreBreakdown;
|
|
102
123
|
policy: PolicyResult;
|
|
124
|
+
ownershipSummary: WorkspaceOwnershipSummary;
|
|
103
125
|
duplicates: DuplicateDependency[];
|
|
104
126
|
unused: UnusedDependency[];
|
|
105
127
|
outdated: OutdatedDependency[];
|
|
@@ -107,7 +129,7 @@ export interface PackageAnalysisResult {
|
|
|
107
129
|
suggestions: string[];
|
|
108
130
|
topIssues: TopIssue[];
|
|
109
131
|
}
|
|
110
|
-
export declare const OUTPUT_VERSION = "1.
|
|
132
|
+
export declare const OUTPUT_VERSION = "1.4";
|
|
111
133
|
export interface ScoreBreakdown {
|
|
112
134
|
baseScore: number;
|
|
113
135
|
duplicates: number;
|
package/dist/core/analyzer.js
CHANGED
|
@@ -8,14 +8,16 @@ import { findWorkspacePackages } from "../utils/workspaces.js";
|
|
|
8
8
|
import { buildDependencyGraph } from "./graph-builder.js";
|
|
9
9
|
import { calculateHealthScore } from "./scorer.js";
|
|
10
10
|
import { buildAnalysisContext } from "./context.js";
|
|
11
|
-
export const OUTPUT_VERSION = "1.
|
|
11
|
+
export const OUTPUT_VERSION = "1.4";
|
|
12
12
|
export async function analyzeProject(options = {}) {
|
|
13
13
|
const rootDir = path.resolve(options.rootDir ?? process.cwd());
|
|
14
14
|
const loadedConfig = await loadDepBrainConfig(rootDir, options.configPath);
|
|
15
15
|
const config = mergeConfig(loadedConfig, options.config);
|
|
16
16
|
const workspaces = await findWorkspacePackages(rootDir);
|
|
17
17
|
if (workspaces.length === 0) {
|
|
18
|
-
return analyzeSingleProject(rootDir, config
|
|
18
|
+
return analyzeSingleProject(rootDir, config, {
|
|
19
|
+
baseline: options.baseline
|
|
20
|
+
});
|
|
19
21
|
}
|
|
20
22
|
const rootGraph = await buildDependencyGraph(rootDir);
|
|
21
23
|
const duplicateCheck = await runDuplicateCheck(rootGraph);
|
|
@@ -28,11 +30,26 @@ export async function analyzeProject(options = {}) {
|
|
|
28
30
|
});
|
|
29
31
|
packages.push({ ...result, name: workspace.name });
|
|
30
32
|
}
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const workspaceGraphs = await Promise.all(workspaces.map(async (workspace) => ({
|
|
34
|
+
name: workspace.name,
|
|
35
|
+
graph: await buildDependencyGraph(workspace.rootDir)
|
|
36
|
+
})));
|
|
37
|
+
const attributedDuplicates = addWorkspaceAttribution(duplicates, workspaceGraphs);
|
|
38
|
+
const rawUnused = packages.flatMap((pkg) => pkg.unused.map((item) => ({ ...item, package: pkg.name })));
|
|
39
|
+
const rawOutdated = packages.flatMap((pkg) => pkg.outdated.map((item) => ({ ...item, package: pkg.name })));
|
|
40
|
+
const rawRisks = packages.flatMap((pkg) => pkg.risks.map((item) => ({ ...item, package: pkg.name })));
|
|
41
|
+
const baselineFiltered = filterBaselineFindings({
|
|
42
|
+
duplicates: attributedDuplicates,
|
|
43
|
+
unused: rawUnused,
|
|
44
|
+
outdated: rawOutdated,
|
|
45
|
+
risks: rawRisks
|
|
46
|
+
}, options.baseline);
|
|
47
|
+
const activeDuplicates = baselineFiltered.duplicates;
|
|
48
|
+
const unused = baselineFiltered.unused;
|
|
49
|
+
const outdated = baselineFiltered.outdated;
|
|
50
|
+
const risks = baselineFiltered.risks;
|
|
34
51
|
const score = calculateHealthScore({
|
|
35
|
-
duplicates:
|
|
52
|
+
duplicates: activeDuplicates.length,
|
|
36
53
|
unused: unused.length,
|
|
37
54
|
outdated: outdated.length,
|
|
38
55
|
risks: risks.length,
|
|
@@ -42,17 +59,19 @@ export async function analyzeProject(options = {}) {
|
|
|
42
59
|
riskWeight: config.scoring.riskWeight
|
|
43
60
|
});
|
|
44
61
|
const scoreBreakdown = buildScoreBreakdown({
|
|
45
|
-
duplicates:
|
|
62
|
+
duplicates: activeDuplicates.length,
|
|
46
63
|
unused: unused.length,
|
|
47
64
|
outdated: outdated.length,
|
|
48
65
|
risks: risks.length
|
|
49
66
|
}, config);
|
|
50
67
|
const suggestions = [
|
|
51
|
-
...
|
|
68
|
+
...unused.map((item) => `Remove ${item.name} from ${item.section}`),
|
|
69
|
+
...activeDuplicates.map((item) => `Consider consolidating ${item.name} to one version`),
|
|
70
|
+
...outdated.map((item) => `Review ${item.name}: ${item.current} -> ${item.latest} (${item.updateType})`)
|
|
52
71
|
].slice(0, config.report.maxSuggestions);
|
|
53
72
|
const policy = evaluatePolicy({
|
|
54
73
|
score,
|
|
55
|
-
duplicates:
|
|
74
|
+
duplicates: activeDuplicates.length,
|
|
56
75
|
unused: unused.length,
|
|
57
76
|
outdated: outdated.length,
|
|
58
77
|
risks: risks.length
|
|
@@ -63,12 +82,23 @@ export async function analyzeProject(options = {}) {
|
|
|
63
82
|
score,
|
|
64
83
|
scoreBreakdown,
|
|
65
84
|
policy,
|
|
66
|
-
|
|
85
|
+
ownershipSummary: buildOwnershipSummary({
|
|
86
|
+
duplicates: activeDuplicates,
|
|
87
|
+
unused,
|
|
88
|
+
outdated,
|
|
89
|
+
risks
|
|
90
|
+
}),
|
|
91
|
+
duplicates: activeDuplicates,
|
|
67
92
|
unused,
|
|
68
93
|
outdated,
|
|
69
94
|
risks,
|
|
70
95
|
suggestions,
|
|
71
|
-
topIssues: buildTopIssues({
|
|
96
|
+
topIssues: buildTopIssues({
|
|
97
|
+
duplicates: activeDuplicates,
|
|
98
|
+
unused,
|
|
99
|
+
outdated,
|
|
100
|
+
risks
|
|
101
|
+
}),
|
|
72
102
|
config,
|
|
73
103
|
packages
|
|
74
104
|
};
|
|
@@ -139,59 +169,71 @@ async function analyzeSingleProject(rootDir, config, options = {}) {
|
|
|
139
169
|
const unused = mapUnusedIssues(issueGroups.unused);
|
|
140
170
|
const outdated = mapOutdatedIssues(issueGroups.outdated);
|
|
141
171
|
const risks = mapRiskIssues(issueGroups.risks);
|
|
172
|
+
const scopedUnused = options.packageName && options.packageName.trim().length > 0
|
|
173
|
+
? unused.map((item) => ({ ...item, package: options.packageName }))
|
|
174
|
+
: unused;
|
|
175
|
+
const scopedOutdated = options.packageName && options.packageName.trim().length > 0
|
|
176
|
+
? outdated.map((item) => ({ ...item, package: options.packageName }))
|
|
177
|
+
: outdated;
|
|
178
|
+
const scopedRisks = options.packageName && options.packageName.trim().length > 0
|
|
179
|
+
? risks.map((item) => ({ ...item, package: options.packageName }))
|
|
180
|
+
: risks;
|
|
181
|
+
const baselineFiltered = filterBaselineFindings({
|
|
182
|
+
duplicates,
|
|
183
|
+
unused: scopedUnused,
|
|
184
|
+
outdated: scopedOutdated,
|
|
185
|
+
risks: scopedRisks
|
|
186
|
+
}, options.baseline);
|
|
142
187
|
const score = calculateHealthScore({
|
|
143
|
-
duplicates: duplicates.length,
|
|
144
|
-
unused: unused.length,
|
|
145
|
-
outdated: outdated.length,
|
|
146
|
-
risks: risks.length,
|
|
188
|
+
duplicates: baselineFiltered.duplicates.length,
|
|
189
|
+
unused: baselineFiltered.unused.length,
|
|
190
|
+
outdated: baselineFiltered.outdated.length,
|
|
191
|
+
risks: baselineFiltered.risks.length,
|
|
147
192
|
duplicateWeight: config.scoring.duplicateWeight,
|
|
148
193
|
outdatedWeight: config.scoring.outdatedWeight,
|
|
149
194
|
unusedWeight: config.scoring.unusedWeight,
|
|
150
195
|
riskWeight: config.scoring.riskWeight
|
|
151
196
|
});
|
|
152
197
|
const scoreBreakdown = buildScoreBreakdown({
|
|
153
|
-
duplicates: duplicates.length,
|
|
154
|
-
unused: unused.length,
|
|
155
|
-
outdated: outdated.length,
|
|
156
|
-
risks: risks.length
|
|
198
|
+
duplicates: baselineFiltered.duplicates.length,
|
|
199
|
+
unused: baselineFiltered.unused.length,
|
|
200
|
+
outdated: baselineFiltered.outdated.length,
|
|
201
|
+
risks: baselineFiltered.risks.length
|
|
157
202
|
}, config);
|
|
158
203
|
const suggestions = [
|
|
159
|
-
...unused.map((item) => `Remove ${item.name} from ${item.section}`),
|
|
160
|
-
...duplicates.map((item) => `Consider consolidating ${item.name} to one version`),
|
|
161
|
-
...outdated.map((item) => `Review ${item.name}: ${item.current} -> ${item.latest} (${item.updateType})`)
|
|
204
|
+
...baselineFiltered.unused.map((item) => `Remove ${item.name} from ${item.section}`),
|
|
205
|
+
...baselineFiltered.duplicates.map((item) => `Consider consolidating ${item.name} to one version`),
|
|
206
|
+
...baselineFiltered.outdated.map((item) => `Review ${item.name}: ${item.current} -> ${item.latest} (${item.updateType})`)
|
|
162
207
|
].slice(0, config.report.maxSuggestions);
|
|
163
208
|
const policy = evaluatePolicy({
|
|
164
209
|
score,
|
|
165
|
-
duplicates: duplicates.length,
|
|
166
|
-
unused: unused.length,
|
|
167
|
-
outdated: outdated.length,
|
|
168
|
-
risks: risks.length
|
|
210
|
+
duplicates: baselineFiltered.duplicates.length,
|
|
211
|
+
unused: baselineFiltered.unused.length,
|
|
212
|
+
outdated: baselineFiltered.outdated.length,
|
|
213
|
+
risks: baselineFiltered.risks.length
|
|
169
214
|
}, config);
|
|
170
|
-
const scopedUnused = options.packageName && options.packageName.trim().length > 0
|
|
171
|
-
? unused.map((item) => ({ ...item, package: options.packageName }))
|
|
172
|
-
: unused;
|
|
173
|
-
const scopedOutdated = options.packageName && options.packageName.trim().length > 0
|
|
174
|
-
? outdated.map((item) => ({ ...item, package: options.packageName }))
|
|
175
|
-
: outdated;
|
|
176
|
-
const scopedRisks = options.packageName && options.packageName.trim().length > 0
|
|
177
|
-
? risks.map((item) => ({ ...item, package: options.packageName }))
|
|
178
|
-
: risks;
|
|
179
215
|
return {
|
|
180
216
|
outputVersion: OUTPUT_VERSION,
|
|
181
217
|
rootDir,
|
|
182
218
|
score,
|
|
183
219
|
scoreBreakdown,
|
|
184
220
|
policy,
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
221
|
+
ownershipSummary: buildOwnershipSummary({
|
|
222
|
+
duplicates: baselineFiltered.duplicates,
|
|
223
|
+
unused: baselineFiltered.unused,
|
|
224
|
+
outdated: baselineFiltered.outdated,
|
|
225
|
+
risks: baselineFiltered.risks
|
|
226
|
+
}),
|
|
227
|
+
duplicates: baselineFiltered.duplicates,
|
|
228
|
+
unused: baselineFiltered.unused,
|
|
229
|
+
outdated: baselineFiltered.outdated,
|
|
230
|
+
risks: baselineFiltered.risks,
|
|
189
231
|
suggestions,
|
|
190
232
|
topIssues: buildTopIssues({
|
|
191
|
-
duplicates,
|
|
192
|
-
unused:
|
|
193
|
-
outdated:
|
|
194
|
-
risks:
|
|
233
|
+
duplicates: baselineFiltered.duplicates,
|
|
234
|
+
unused: baselineFiltered.unused,
|
|
235
|
+
outdated: baselineFiltered.outdated,
|
|
236
|
+
risks: baselineFiltered.risks
|
|
195
237
|
}),
|
|
196
238
|
config
|
|
197
239
|
};
|
|
@@ -272,6 +314,8 @@ function mapDuplicateIssues(issues) {
|
|
|
272
314
|
instances: Array.isArray(issue.meta?.instances)
|
|
273
315
|
? issue.meta?.instances
|
|
274
316
|
: [],
|
|
317
|
+
workspaceUsage: normalizeWorkspaceUsage(issue.meta?.workspaceUsage),
|
|
318
|
+
rootCause: normalizeStringArray(issue.meta?.rootCause),
|
|
275
319
|
confidence: normalizeConfidence(issue.confidence),
|
|
276
320
|
reasonCodes: normalizeStringArray(issue.reasonCodes),
|
|
277
321
|
explanation: normalizeStringArray(issue.explanation),
|
|
@@ -437,6 +481,62 @@ function compareTrustScore(left, right) {
|
|
|
437
481
|
const rank = { low: 3, medium: 2, high: 1, undefined: 0 };
|
|
438
482
|
return rank[left ?? "undefined"] - rank[right ?? "undefined"];
|
|
439
483
|
}
|
|
484
|
+
function addWorkspaceAttribution(duplicates, workspaceGraphs) {
|
|
485
|
+
return duplicates.map((item) => {
|
|
486
|
+
const usage = [];
|
|
487
|
+
for (const workspace of workspaceGraphs) {
|
|
488
|
+
const runtimeVersion = workspace.graph.dependencies[item.name];
|
|
489
|
+
if (runtimeVersion) {
|
|
490
|
+
usage.push({
|
|
491
|
+
workspace: workspace.name,
|
|
492
|
+
section: "dependencies",
|
|
493
|
+
declaredVersion: runtimeVersion
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
const devVersion = workspace.graph.devDependencies[item.name];
|
|
497
|
+
if (devVersion) {
|
|
498
|
+
usage.push({
|
|
499
|
+
workspace: workspace.name,
|
|
500
|
+
section: "devDependencies",
|
|
501
|
+
declaredVersion: devVersion
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
...item,
|
|
507
|
+
workspaceUsage: usage,
|
|
508
|
+
rootCause: usage.map((entry) => `${entry.workspace} -> ${item.name}@${entry.declaredVersion}`)
|
|
509
|
+
};
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
function buildOwnershipSummary(input) {
|
|
513
|
+
return {
|
|
514
|
+
duplicates: input.duplicates.length,
|
|
515
|
+
unused: input.unused.length,
|
|
516
|
+
outdated: input.outdated.length,
|
|
517
|
+
risks: input.risks.length
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
function filterBaselineFindings(findings, baseline) {
|
|
521
|
+
if (!baseline) {
|
|
522
|
+
return findings;
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
duplicates: findings.duplicates.filter((item) => !(baseline.duplicates ?? []).some((entry) => entry.name === item.name)),
|
|
526
|
+
unused: findings.unused.filter((item) => !(baseline.unused ?? []).some((entry) => entry.name === item.name &&
|
|
527
|
+
optionalMatches(entry.section, item.section) &&
|
|
528
|
+
optionalMatches(entry.package, item.package))),
|
|
529
|
+
outdated: findings.outdated.filter((item) => !(baseline.outdated ?? []).some((entry) => entry.name === item.name &&
|
|
530
|
+
optionalMatches(entry.current, item.current) &&
|
|
531
|
+
optionalMatches(entry.latest, item.latest) &&
|
|
532
|
+
optionalMatches(entry.package, item.package))),
|
|
533
|
+
risks: findings.risks.filter((item) => !(baseline.risks ?? []).some((entry) => entry.name === item.name &&
|
|
534
|
+
optionalMatches(entry.package, item.package)))
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
function optionalMatches(expected, actual) {
|
|
538
|
+
return expected === undefined || expected === actual;
|
|
539
|
+
}
|
|
440
540
|
function normalizeConfidence(value) {
|
|
441
541
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
442
542
|
return 0.5;
|
|
@@ -479,6 +579,29 @@ function normalizeRiskFactors(value) {
|
|
|
479
579
|
: "unknown"
|
|
480
580
|
};
|
|
481
581
|
}
|
|
582
|
+
function normalizeWorkspaceUsage(value) {
|
|
583
|
+
if (!Array.isArray(value)) {
|
|
584
|
+
return [];
|
|
585
|
+
}
|
|
586
|
+
return value
|
|
587
|
+
.map((entry) => {
|
|
588
|
+
if (!entry || typeof entry !== "object") {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
const usage = entry;
|
|
592
|
+
if (typeof usage.workspace !== "string" ||
|
|
593
|
+
typeof usage.declaredVersion !== "string" ||
|
|
594
|
+
(usage.section !== "dependencies" && usage.section !== "devDependencies")) {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
workspace: usage.workspace,
|
|
599
|
+
section: usage.section,
|
|
600
|
+
declaredVersion: usage.declaredVersion
|
|
601
|
+
};
|
|
602
|
+
})
|
|
603
|
+
.filter((entry) => entry !== null);
|
|
604
|
+
}
|
|
482
605
|
function buildScoreBreakdown(counts, config) {
|
|
483
606
|
return {
|
|
484
607
|
baseScore: 100,
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
2
3
|
import { readJsonFile } from "../utils/file-parser.js";
|
|
3
4
|
export async function buildDependencyGraph(rootDir) {
|
|
4
5
|
const packageJsonPath = path.join(rootDir, "package.json");
|
|
5
6
|
const lockfilePath = path.join(rootDir, "package-lock.json");
|
|
7
|
+
const pnpmLockfilePath = path.join(rootDir, "pnpm-lock.yaml");
|
|
8
|
+
const yarnLockfilePath = path.join(rootDir, "yarn.lock");
|
|
6
9
|
const packageJson = await readJsonFile(packageJsonPath);
|
|
7
10
|
const lockPackages = new Map();
|
|
8
11
|
try {
|
|
@@ -29,13 +32,15 @@ export async function buildDependencyGraph(rootDir) {
|
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
34
|
catch {
|
|
35
|
+
const fallbackLockfile = await readAlternativeLockfile(pnpmLockfilePath, yarnLockfilePath);
|
|
32
36
|
return {
|
|
33
37
|
rootDir,
|
|
34
38
|
packageJsonPath,
|
|
39
|
+
lockfilePath: fallbackLockfile.lockfilePath,
|
|
35
40
|
dependencies: packageJson.dependencies ?? {},
|
|
36
41
|
devDependencies: packageJson.devDependencies ?? {},
|
|
37
42
|
scripts: packageJson.scripts ?? {},
|
|
38
|
-
lockPackages:
|
|
43
|
+
lockPackages: fallbackLockfile.lockPackages
|
|
39
44
|
};
|
|
40
45
|
}
|
|
41
46
|
return {
|
|
@@ -51,6 +56,30 @@ export async function buildDependencyGraph(rootDir) {
|
|
|
51
56
|
]))
|
|
52
57
|
};
|
|
53
58
|
}
|
|
59
|
+
async function readAlternativeLockfile(pnpmLockfilePath, yarnLockfilePath) {
|
|
60
|
+
try {
|
|
61
|
+
const content = await fs.readFile(pnpmLockfilePath, "utf8");
|
|
62
|
+
return {
|
|
63
|
+
lockfilePath: pnpmLockfilePath,
|
|
64
|
+
lockPackages: parsePnpmLockfile(content)
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Try yarn.lock below.
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const content = await fs.readFile(yarnLockfilePath, "utf8");
|
|
72
|
+
return {
|
|
73
|
+
lockfilePath: yarnLockfilePath,
|
|
74
|
+
lockPackages: parseYarnLockfile(content)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return {
|
|
79
|
+
lockPackages: {}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
54
83
|
function extractPackageName(packagePath) {
|
|
55
84
|
if (!packagePath) {
|
|
56
85
|
return null;
|
|
@@ -61,3 +90,66 @@ function extractPackageName(packagePath) {
|
|
|
61
90
|
}
|
|
62
91
|
return match[1];
|
|
63
92
|
}
|
|
93
|
+
function parsePnpmLockfile(content) {
|
|
94
|
+
const lockPackages = new Map();
|
|
95
|
+
for (const line of content.split(/\r?\n/)) {
|
|
96
|
+
const match = line.match(/^\s{2}(?:'|")?\/((?:@[^/]+\/)?[^/@'"]+)@([^('":]+)[^:]*:(?:'|")?\s*$/);
|
|
97
|
+
if (!match) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
addLockPackage(lockPackages, match[1], `pnpm-lock:${match[0].trim()}`, match[2]);
|
|
101
|
+
}
|
|
102
|
+
return toLockPackageRecord(lockPackages);
|
|
103
|
+
}
|
|
104
|
+
function parseYarnLockfile(content) {
|
|
105
|
+
const lockPackages = new Map();
|
|
106
|
+
let currentNames = [];
|
|
107
|
+
for (const line of content.split(/\r?\n/)) {
|
|
108
|
+
if (line.trim().length === 0 || line.startsWith("#")) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!line.startsWith(" ") && line.endsWith(":")) {
|
|
112
|
+
currentNames = extractYarnEntryNames(line.slice(0, -1));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const versionMatch = line.match(/^\s+version\s+"?([^"\s]+)"?\s*$/);
|
|
116
|
+
if (!versionMatch) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
for (const name of currentNames) {
|
|
120
|
+
addLockPackage(lockPackages, name, `yarn-lock:${name}@${versionMatch[1]}`, versionMatch[1]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return toLockPackageRecord(lockPackages);
|
|
124
|
+
}
|
|
125
|
+
function extractYarnEntryNames(entry) {
|
|
126
|
+
const names = new Set();
|
|
127
|
+
const unquoted = entry.replace(/^["']|["']$/g, "");
|
|
128
|
+
for (const selector of unquoted.split(/,\s*/)) {
|
|
129
|
+
const normalized = selector.replace(/^["']|["']$/g, "");
|
|
130
|
+
const withoutProtocol = normalized.replace(/@npm:/, "@");
|
|
131
|
+
if (withoutProtocol.startsWith("@")) {
|
|
132
|
+
const scoped = withoutProtocol.match(/^(@[^/]+\/[^@]+)/);
|
|
133
|
+
if (scoped) {
|
|
134
|
+
names.add(scoped[1]);
|
|
135
|
+
}
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const unscoped = withoutProtocol.match(/^([^@]+)/);
|
|
139
|
+
if (unscoped?.[1]) {
|
|
140
|
+
names.add(unscoped[1]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return Array.from(names);
|
|
144
|
+
}
|
|
145
|
+
function addLockPackage(lockPackages, name, packagePath, version) {
|
|
146
|
+
const instances = lockPackages.get(name) ?? new Map();
|
|
147
|
+
instances.set(packagePath, { path: packagePath, version });
|
|
148
|
+
lockPackages.set(name, instances);
|
|
149
|
+
}
|
|
150
|
+
function toLockPackageRecord(lockPackages) {
|
|
151
|
+
return Object.fromEntries(Array.from(lockPackages.entries()).map(([name, instances]) => [
|
|
152
|
+
name,
|
|
153
|
+
Array.from(instances.values()).sort((left, right) => left.path.localeCompare(right.path))
|
|
154
|
+
]));
|
|
155
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { analyzeProject } from "./core/analyzer.js";
|
|
2
|
-
export type { AnalysisOptions, AnalysisResult, DuplicateDependency, OutdatedDependency, PolicyResult, PackageAnalysisResult, Recommendation, ScoreBreakdown, RiskDependency, TopIssue, UnusedDependency } from "./core/analyzer.js";
|
|
2
|
+
export type { AnalysisOptions, AnalysisResult, DepBrainBaseline, DuplicateDependency, OutdatedDependency, PolicyResult, PackageAnalysisResult, Recommendation, RiskFactors, ScoreBreakdown, RiskDependency, TopIssue, TrustScore, UnusedDependency, WorkspaceDependencyUsage, WorkspaceOwnershipSummary } from "./core/analyzer.js";
|
|
3
3
|
export { OUTPUT_VERSION } from "./core/analyzer.js";
|
|
4
4
|
export type { AnalysisContext, CheckResult, Issue } from "./core/types.js";
|
|
5
5
|
export type { DepBrainConfig, DepBrainConfigOverrides } from "./utils/config.js";
|
|
@@ -20,10 +20,10 @@ export function renderConsoleReport(result) {
|
|
|
20
20
|
lines.push("");
|
|
21
21
|
lines.push("Packages:");
|
|
22
22
|
for (const pkg of result.packages) {
|
|
23
|
-
lines.push(`- ${pkg.name}: ${pkg.score}/100, D:${pkg.duplicates
|
|
23
|
+
lines.push(`- ${pkg.name}: ${pkg.score}/100, D:${pkg.ownershipSummary.duplicates} U:${pkg.ownershipSummary.unused} O:${pkg.ownershipSummary.outdated} R:${pkg.ownershipSummary.risks}`);
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
-
appendSection(lines, "Duplicate dependencies", result.duplicates.map((item) => formatEntry(`${item.name}: ${item.versions.join(", ")}`, item.confidence, item.explanation, item.recommendation)));
|
|
26
|
+
appendSection(lines, "Duplicate dependencies", result.duplicates.map((item) => formatEntry(`${item.name}: ${item.versions.join(", ")}${item.rootCause.length > 0 ? ` | via ${item.rootCause.join("; ")}` : ""}`, item.confidence, item.explanation, item.recommendation)));
|
|
27
27
|
appendSection(lines, "Unused dependencies", result.unused.map((item) => formatEntry(item.package
|
|
28
28
|
? `${item.name} (${item.section}) [${item.package}]`
|
|
29
29
|
: `${item.name} (${item.section})`, item.confidence, item.explanation, item.recommendation)));
|
|
@@ -17,7 +17,7 @@ export function renderMarkdownReport(result) {
|
|
|
17
17
|
if (result.packages && result.packages.length > 0) {
|
|
18
18
|
lines.push("## Packages");
|
|
19
19
|
for (const pkg of result.packages) {
|
|
20
|
-
lines.push(`- ${pkg.name}: ${pkg.score}/100 (D:${pkg.duplicates
|
|
20
|
+
lines.push(`- ${pkg.name}: ${pkg.score}/100 (D:${pkg.ownershipSummary.duplicates} U:${pkg.ownershipSummary.unused} O:${pkg.ownershipSummary.outdated} R:${pkg.ownershipSummary.risks})`);
|
|
21
21
|
}
|
|
22
22
|
lines.push("");
|
|
23
23
|
}
|
|
@@ -27,7 +27,7 @@ export function renderMarkdownReport(result) {
|
|
|
27
27
|
lines.push(`- Outdated: ${result.outdated.length}`);
|
|
28
28
|
lines.push(`- Risks: ${result.risks.length}`);
|
|
29
29
|
lines.push("");
|
|
30
|
-
appendSection(lines, "Duplicate dependencies", result.duplicates.map((item) => formatEntry(`${item.name}: ${item.versions.join(", ")}`, item.confidence, item.explanation, item.recommendation)));
|
|
30
|
+
appendSection(lines, "Duplicate dependencies", result.duplicates.map((item) => formatEntry(`${item.name}: ${item.versions.join(", ")}${item.rootCause.length > 0 ? ` | via ${item.rootCause.join("; ")}` : ""}`, item.confidence, item.explanation, item.recommendation)));
|
|
31
31
|
appendSection(lines, "Unused dependencies", result.unused.map((item) => formatEntry(item.package
|
|
32
32
|
? `${item.name} (${item.section}) [${item.package}]`
|
|
33
33
|
: `${item.name} (${item.section})`, item.confidence, item.explanation, item.recommendation)));
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export function renderSarifReport(result) {
|
|
2
|
+
const rules = [
|
|
3
|
+
{
|
|
4
|
+
id: "dep-brain-unused",
|
|
5
|
+
shortDescription: { text: "Unused Dependency" },
|
|
6
|
+
fullDescription: { text: "A dependency was detected but does not appear to be used in the source code." },
|
|
7
|
+
helpUri: "https://github.com/prakashu51/dep-brain"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
id: "dep-brain-duplicate",
|
|
11
|
+
shortDescription: { text: "Duplicate Dependency" },
|
|
12
|
+
fullDescription: { text: "Multiple versions of the same dependency exist in the lockfile." },
|
|
13
|
+
helpUri: "https://github.com/prakashu51/dep-brain"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "dep-brain-outdated",
|
|
17
|
+
shortDescription: { text: "Outdated Dependency" },
|
|
18
|
+
fullDescription: { text: "A newer version of the dependency is available." },
|
|
19
|
+
helpUri: "https://github.com/prakashu51/dep-brain"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "dep-brain-risk",
|
|
23
|
+
shortDescription: { text: "Risky Dependency" },
|
|
24
|
+
fullDescription: { text: "A dependency exhibits supply-chain risk signals." },
|
|
25
|
+
helpUri: "https://github.com/prakashu51/dep-brain"
|
|
26
|
+
}
|
|
27
|
+
];
|
|
28
|
+
const results = [];
|
|
29
|
+
const mapLevel = (priority) => {
|
|
30
|
+
switch (priority) {
|
|
31
|
+
case "high": return "error";
|
|
32
|
+
case "medium": return "warning";
|
|
33
|
+
case "low": return "note";
|
|
34
|
+
default: return "none";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
for (const item of result.unused) {
|
|
38
|
+
results.push({
|
|
39
|
+
ruleId: "dep-brain-unused",
|
|
40
|
+
level: mapLevel(item.recommendation.priority),
|
|
41
|
+
message: {
|
|
42
|
+
text: `[Unused] ${item.name}\n\n${item.recommendation.summary}\nReasons:\n- ${item.recommendation.reasons.join('\n- ')}`
|
|
43
|
+
},
|
|
44
|
+
locations: [
|
|
45
|
+
{
|
|
46
|
+
physicalLocation: {
|
|
47
|
+
artifactLocation: {
|
|
48
|
+
uri: item.package ? `packages/${item.package}/package.json` : "package.json"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
for (const item of result.duplicates) {
|
|
56
|
+
results.push({
|
|
57
|
+
ruleId: "dep-brain-duplicate",
|
|
58
|
+
level: mapLevel(item.recommendation.priority),
|
|
59
|
+
message: {
|
|
60
|
+
text: `[Duplicate] ${item.name} (${item.versions.join(", ")})\n\n${item.recommendation.summary}`
|
|
61
|
+
},
|
|
62
|
+
locations: [
|
|
63
|
+
{
|
|
64
|
+
physicalLocation: {
|
|
65
|
+
artifactLocation: {
|
|
66
|
+
uri: "package-lock.json"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
for (const item of result.outdated) {
|
|
74
|
+
results.push({
|
|
75
|
+
ruleId: "dep-brain-outdated",
|
|
76
|
+
level: mapLevel(item.recommendation.priority),
|
|
77
|
+
message: {
|
|
78
|
+
text: `[Outdated] ${item.name}: ${item.current} -> ${item.latest} (${item.updateType})\n\n${item.recommendation.summary}`
|
|
79
|
+
},
|
|
80
|
+
locations: [
|
|
81
|
+
{
|
|
82
|
+
physicalLocation: {
|
|
83
|
+
artifactLocation: {
|
|
84
|
+
uri: item.package ? `packages/${item.package}/package.json` : "package.json"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
for (const item of result.risks) {
|
|
92
|
+
results.push({
|
|
93
|
+
ruleId: "dep-brain-risk",
|
|
94
|
+
level: mapLevel(item.recommendation.priority),
|
|
95
|
+
message: {
|
|
96
|
+
text: `[Risk] ${item.name} (Trust: ${item.trustScore})\n\n${item.recommendation.summary}\nReasons:\n- ${item.recommendation.reasons.join('\n- ')}`
|
|
97
|
+
},
|
|
98
|
+
locations: [
|
|
99
|
+
{
|
|
100
|
+
physicalLocation: {
|
|
101
|
+
artifactLocation: {
|
|
102
|
+
uri: item.package ? `packages/${item.package}/package.json` : "package.json"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
]
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const sarif = {
|
|
110
|
+
version: "2.1.0",
|
|
111
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
112
|
+
runs: [
|
|
113
|
+
{
|
|
114
|
+
tool: {
|
|
115
|
+
driver: {
|
|
116
|
+
name: "Dependency Brain",
|
|
117
|
+
informationUri: "https://github.com/prakashu51/dep-brain",
|
|
118
|
+
rules
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
results
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
};
|
|
125
|
+
return JSON.stringify(sarif, null, 2);
|
|
126
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dep-brain",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI and library for dependency
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI and library for explainable dependency intelligence",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"README.md",
|
|
14
14
|
"LICENSE",
|
|
15
15
|
"CHANGELOG.md",
|
|
16
|
+
"action.yml",
|
|
16
17
|
"depbrain.config.json",
|
|
17
18
|
"depbrain.config.schema.json",
|
|
18
19
|
"depbrain.output.schema.json"
|