devrites 3.2.0 → 3.2.1
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 +7 -0
- package/README.md +1 -1
- package/docs/engine/commands.md +7 -5
- package/engine/commands.go +2 -2
- package/engine/internal/doctor/doctor.go +21 -23
- package/engine/internal/doctor/doctor_test.go +31 -3
- package/engine/tests/doctor_cli_test.go +15 -0
- package/pack/.claude/skills/rite-doctor/SKILL.md +2 -2
- package/pack/.claude/skills/rite-upgrade/SKILL.md +1 -1
- package/pack/generated/claude/skills/rite-doctor/SKILL.md +2 -2
- package/pack/generated/claude/skills/rite-upgrade/SKILL.md +1 -1
- package/pack/generated/codex/skills/rite-doctor/SKILL.md +2 -2
- package/pack/generated/codex/skills/rite-upgrade/SKILL.md +1 -1
- package/package.json +2 -2
- package/scripts/check-npm-audit.mjs +110 -0
- package/scripts/npm-audit-exceptions.json +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
|
|
4
4
|
|
|
5
|
+
## [3.2.1](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.0...v3.2.1) (2026-07-24)
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
* **ci:** govern temporary npm audit exceptions ([c2219e0](https://github.com/ViktorsBaikers/DevRites/commit/c2219e0ff11f11ee3f2e2ffa7c5d35b480c5eb76))
|
|
10
|
+
* **rite:** repair doctor compatibility ([811f339](https://github.com/ViktorsBaikers/DevRites/commit/811f339d22d3ec079549a5d50838d7596c72c7da))
|
|
11
|
+
|
|
5
12
|
## [3.2.0](https://github.com/ViktorsBaikers/DevRites/compare/v3.1.0...v3.2.0) (2026-07-24)
|
|
6
13
|
|
|
7
14
|
### Added
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ final commit, push, and tag, and it requires a typed `GO` confirmation.
|
|
|
25
25
|
Unattended runs may create local WIP checkpoint commits along the way, but only
|
|
26
26
|
Ship collapses and pushes them.
|
|
27
27
|
|
|
28
|
-
**Status:** [`v3.2.
|
|
28
|
+
**Status:** [`v3.2.1`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.1): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
|
|
29
29
|
|
|
30
30
|
## Quick start
|
|
31
31
|
|
package/docs/engine/commands.md
CHANGED
|
@@ -77,11 +77,13 @@ hazards: ok
|
|
|
77
77
|
`3`): the binary won't silently mis-parse newer state.
|
|
78
78
|
- Additive schema changes (older state read by a newer binary) are always fine.
|
|
79
79
|
|
|
80
|
-
The pack version
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
The pack version comes from the installer-owned
|
|
81
|
+
`.claude/devrites.manifest`. The old `.claude/devrites.version` marker remains
|
|
82
|
+
readable for compatibility; without either source the pack is reported
|
|
83
|
+
`unknown` and no skew is asserted. The project's own `package.json` is never
|
|
84
|
+
treated as DevRites provenance. Doctor also reports linked-worktree/submodule
|
|
85
|
+
identity, merge/rebase state, host-artifact drift, and project extensions that
|
|
86
|
+
have artifacts but no optional `provenance.json`.
|
|
85
87
|
|
|
86
88
|
### Root safety at command dispatch
|
|
87
89
|
|
package/engine/commands.go
CHANGED
|
@@ -218,8 +218,8 @@ func gitOperation(projectDir, resolvedGitDir string) string {
|
|
|
218
218
|
// versions. It exits nonzero for an unsafe root or newer state schema. Missing
|
|
219
219
|
// optional data and warnings remain diagnostic.
|
|
220
220
|
func cmdDoctor(args []string, stdout, stderr io.Writer) int {
|
|
221
|
-
if len(args)
|
|
222
|
-
fmt.Fprintln(stderr, "usage: devrites-engine doctor")
|
|
221
|
+
if len(args) > 1 || len(args) == 1 && args[0] != "--verbose" {
|
|
222
|
+
fmt.Fprintln(stderr, "usage: devrites-engine doctor [--verbose]")
|
|
223
223
|
return exitUsage
|
|
224
224
|
}
|
|
225
225
|
facts, resolveErr := rootfacts.Resolve(os.Getenv("DEVRITES_ROOT"))
|
|
@@ -5,13 +5,13 @@ package doctor
|
|
|
5
5
|
|
|
6
6
|
import (
|
|
7
7
|
"crypto/sha256"
|
|
8
|
-
"encoding/json"
|
|
9
8
|
"fmt"
|
|
10
9
|
"io/fs"
|
|
11
10
|
"os"
|
|
12
11
|
"path/filepath"
|
|
13
12
|
"strings"
|
|
14
13
|
|
|
14
|
+
"github.com/devrites/devrites/internal/devritespaths"
|
|
15
15
|
"github.com/devrites/devrites/internal/rootfacts"
|
|
16
16
|
"github.com/devrites/devrites/internal/state"
|
|
17
17
|
"github.com/devrites/devrites/internal/version"
|
|
@@ -160,20 +160,32 @@ func (r *Report) Render() string {
|
|
|
160
160
|
return b.String()
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
// packVersion discovers the installed DevRites pack version
|
|
164
|
-
//
|
|
165
|
-
// package.json at the project root; either absent leaves the pack Unknown so no
|
|
166
|
-
// false skew is asserted.
|
|
163
|
+
// packVersion discovers the installed DevRites pack version from the install
|
|
164
|
+
// manifest. The old standalone marker remains readable for compatibility.
|
|
167
165
|
func packVersion(projectDir string) string {
|
|
168
|
-
if v :=
|
|
166
|
+
if v := manifestVersion(filepath.Join(projectDir, filepath.FromSlash(devritespaths.ManifestName))); v != "" {
|
|
169
167
|
return v
|
|
170
168
|
}
|
|
171
|
-
if v :=
|
|
169
|
+
if v := firstLine(filepath.Join(projectDir, ".claude", "devrites.version")); v != "" {
|
|
172
170
|
return v
|
|
173
171
|
}
|
|
174
172
|
return Unknown
|
|
175
173
|
}
|
|
176
174
|
|
|
175
|
+
func manifestVersion(path string) string {
|
|
176
|
+
raw, err := os.ReadFile(path)
|
|
177
|
+
if err != nil {
|
|
178
|
+
return ""
|
|
179
|
+
}
|
|
180
|
+
const prefix = "# devrites-version:"
|
|
181
|
+
for _, line := range strings.Split(string(raw), "\n") {
|
|
182
|
+
if strings.HasPrefix(line, prefix) {
|
|
183
|
+
return strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return ""
|
|
187
|
+
}
|
|
188
|
+
|
|
177
189
|
func firstLine(path string) string {
|
|
178
190
|
raw, err := os.ReadFile(path)
|
|
179
191
|
if err != nil {
|
|
@@ -200,8 +212,8 @@ func driftChecks(projectDir string) []string {
|
|
|
200
212
|
if isDir(filepath.Join(projectDir, ".claude", "agents")) && !isDir(filepath.Join(projectDir, ".codex", "agents")) {
|
|
201
213
|
checks = append(checks, "WARN: Claude agents installed but Codex agent mirror .codex/agents is missing")
|
|
202
214
|
}
|
|
203
|
-
if
|
|
204
|
-
checks = append(checks, "WARN: installed skills have no .claude/devrites.
|
|
215
|
+
if packVersion(projectDir) == Unknown && isDir(filepath.Join(projectDir, ".claude", "skills")) {
|
|
216
|
+
checks = append(checks, "WARN: installed skills have no versioned .claude/devrites.manifest provenance")
|
|
205
217
|
}
|
|
206
218
|
if generatedClaudeDrift(projectDir) {
|
|
207
219
|
checks = append(checks, "[DRV-GENERATED-DRIFT] WARN: pack/generated/claude differs from canonical pack/.claude; run `bash scripts/build-host-artifacts.sh`")
|
|
@@ -292,17 +304,3 @@ func isDir(path string) bool {
|
|
|
292
304
|
info, err := os.Stat(path)
|
|
293
305
|
return err == nil && info.IsDir()
|
|
294
306
|
}
|
|
295
|
-
|
|
296
|
-
func packageJSONVersion(path string) string {
|
|
297
|
-
raw, err := os.ReadFile(path)
|
|
298
|
-
if err != nil {
|
|
299
|
-
return ""
|
|
300
|
-
}
|
|
301
|
-
var pkg struct {
|
|
302
|
-
Version string `json:"version"`
|
|
303
|
-
}
|
|
304
|
-
if err := json.Unmarshal(raw, &pkg); err != nil {
|
|
305
|
-
return ""
|
|
306
|
-
}
|
|
307
|
-
return strings.TrimSpace(pkg.Version)
|
|
308
|
-
}
|
|
@@ -64,12 +64,13 @@ func TestDiagnoseOKWhenAligned(t *testing.T) {
|
|
|
64
64
|
func TestDiagnoseWarnsWhenBinaryOlderThanPack(t *testing.T) {
|
|
65
65
|
withBinaryVersion(t, "1.0.0")
|
|
66
66
|
projectDir := t.TempDir()
|
|
67
|
-
//
|
|
67
|
+
// Installed pack newer than the binary → warn, not refuse.
|
|
68
68
|
claude := filepath.Join(projectDir, ".claude")
|
|
69
69
|
if err := os.MkdirAll(claude, 0o755); err != nil {
|
|
70
70
|
t.Fatal(err)
|
|
71
71
|
}
|
|
72
|
-
|
|
72
|
+
manifest := "# devrites-version: 2.0.0\n.claude/skills/rite/SKILL.md\tabc\n"
|
|
73
|
+
if err := os.WriteFile(filepath.Join(claude, "devrites.manifest"), []byte(manifest), 0o644); err != nil {
|
|
73
74
|
t.Fatal(err)
|
|
74
75
|
}
|
|
75
76
|
root := t.TempDir()
|
|
@@ -90,6 +91,33 @@ func TestDiagnoseWarnsWhenBinaryOlderThanPack(t *testing.T) {
|
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
func TestPackVersionUsesManifestBeforeLegacyMarker(t *testing.T) {
|
|
95
|
+
projectDir := t.TempDir()
|
|
96
|
+
claude := filepath.Join(projectDir, ".claude")
|
|
97
|
+
if err := os.MkdirAll(claude, 0o755); err != nil {
|
|
98
|
+
t.Fatal(err)
|
|
99
|
+
}
|
|
100
|
+
if err := os.WriteFile(filepath.Join(claude, "devrites.manifest"), []byte("# devrites-version: 3.2.0\n"), 0o644); err != nil {
|
|
101
|
+
t.Fatal(err)
|
|
102
|
+
}
|
|
103
|
+
if err := os.WriteFile(filepath.Join(claude, "devrites.version"), []byte("2.9.0\n"), 0o644); err != nil {
|
|
104
|
+
t.Fatal(err)
|
|
105
|
+
}
|
|
106
|
+
if got := packVersion(projectDir); got != "3.2.0" {
|
|
107
|
+
t.Fatalf("packVersion() = %q, want installer manifest version 3.2.0", got)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func TestPackVersionIgnoresProjectPackageJSON(t *testing.T) {
|
|
112
|
+
projectDir := t.TempDir()
|
|
113
|
+
if err := os.WriteFile(filepath.Join(projectDir, "package.json"), []byte(`{"version":"99.0.0"}`), 0o644); err != nil {
|
|
114
|
+
t.Fatal(err)
|
|
115
|
+
}
|
|
116
|
+
if got := packVersion(projectDir); got != Unknown {
|
|
117
|
+
t.Fatalf("packVersion() = %q, want %q without DevRites install provenance", got, Unknown)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
93
121
|
func TestDiagnoseRefusesNewerStateSchema(t *testing.T) {
|
|
94
122
|
withBinaryVersion(t, "2.6.1")
|
|
95
123
|
root := t.TempDir()
|
|
@@ -143,7 +171,7 @@ func TestDiagnoseReportsHostArtifactDrift(t *testing.T) {
|
|
|
143
171
|
t.Fatal("expected drift checks")
|
|
144
172
|
}
|
|
145
173
|
out := r.Render()
|
|
146
|
-
for _, want := range []string{"devrites-engine hooks", "Codex skill mirror", "devrites.
|
|
174
|
+
for _, want := range []string{"devrites-engine hooks", "Codex skill mirror", "devrites.manifest provenance"} {
|
|
147
175
|
if !strings.Contains(out, want) {
|
|
148
176
|
t.Fatalf("doctor output missing %q:\n%s", want, out)
|
|
149
177
|
}
|
|
@@ -25,6 +25,21 @@ func TestDoctorPrintsTriangle(t *testing.T) {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
func TestDoctorVerboseMatchesPlainDoctor(t *testing.T) {
|
|
29
|
+
root := newWorkspace(t)
|
|
30
|
+
plainOut, plainErr, plainCode := runDevrites(t, root, "doctor")
|
|
31
|
+
verboseOut, verboseErr, verboseCode := runDevrites(t, root, "doctor", "--verbose")
|
|
32
|
+
if plainCode != 0 || verboseCode != 0 {
|
|
33
|
+
t.Fatalf("doctor exits: plain=%d verbose=%d", plainCode, verboseCode)
|
|
34
|
+
}
|
|
35
|
+
if verboseCode != plainCode || verboseOut != plainOut || verboseErr != plainErr {
|
|
36
|
+
t.Fatalf(
|
|
37
|
+
"doctor --verbose differs from doctor:\nplain: code=%d stdout=%q stderr=%q\nverbose: code=%d stdout=%q stderr=%q",
|
|
38
|
+
plainCode, plainOut, plainErr, verboseCode, verboseOut, verboseErr,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
func TestDoctorReportsStaleActiveWithPasteableRepair(t *testing.T) {
|
|
29
44
|
root := newWorkspace(t)
|
|
30
45
|
if err := os.WriteFile(filepath.Join(root, "ACTIVE"), []byte("ghost\n"), 0o644); err != nil {
|
|
@@ -19,7 +19,7 @@ It is **read-only**: it never edits the workspace, never advances a phase, never
|
|
|
19
19
|
## Workflow
|
|
20
20
|
1. Run the diagnose core verbosely (resolve across install layouts):
|
|
21
21
|
```bash
|
|
22
|
-
devrites-engine doctor
|
|
22
|
+
devrites-engine doctor; echo "doctor rc=$?"
|
|
23
23
|
```
|
|
24
24
|
1a. **Surface the learnings nudge**: point the user at `/rite-learn` when a pattern recurs across
|
|
25
25
|
shipped features (read-only; silent when there's nothing to say):
|
|
@@ -68,7 +68,7 @@ progress`, but it follows the compact labels and one-next-action rule from
|
|
|
68
68
|
```
|
|
69
69
|
Done: DevRites health checked; <OK | n issues>.
|
|
70
70
|
Changed: workspace only
|
|
71
|
-
Evidence: devrites-engine doctor
|
|
71
|
+
Evidence: devrites-engine doctor rc=<0|1>; health <skipped|PASS|WARN|FAIL>; reindex <skipped|result>; learnings nudge <summary|none>; extensions/overrides <ok|n issues>
|
|
72
72
|
Open: <none | issue count and top issue>
|
|
73
73
|
Next: <single command for the most urgent issue>
|
|
74
74
|
Record: not applicable
|
|
@@ -42,7 +42,7 @@ Plan, Vet, Converge, and Build phase contracts needed by the assessment.
|
|
|
42
42
|
|
|
43
43
|
0. **Orient and normalize structure.** Read the active slug or `$ARGUMENTS`. Run:
|
|
44
44
|
```bash
|
|
45
|
-
devrites-engine doctor
|
|
45
|
+
devrites-engine doctor; echo "doctor rc=$?"
|
|
46
46
|
```
|
|
47
47
|
A binary/pack integrity mismatch stops at `/rite-doctor`; do not run migration or
|
|
48
48
|
mutate semantic artifacts. Once healthy, orient without writing:
|
|
@@ -19,7 +19,7 @@ It is **read-only**: it never edits the workspace, never advances a phase, never
|
|
|
19
19
|
## Workflow
|
|
20
20
|
1. Run the diagnose core verbosely (resolve across install layouts):
|
|
21
21
|
```bash
|
|
22
|
-
devrites-engine doctor
|
|
22
|
+
devrites-engine doctor; echo "doctor rc=$?"
|
|
23
23
|
```
|
|
24
24
|
1a. **Surface the learnings nudge**: point the user at `/rite-learn` when a pattern recurs across
|
|
25
25
|
shipped features (read-only; silent when there's nothing to say):
|
|
@@ -68,7 +68,7 @@ progress`, but it follows the compact labels and one-next-action rule from
|
|
|
68
68
|
```
|
|
69
69
|
Done: DevRites health checked; <OK | n issues>.
|
|
70
70
|
Changed: workspace only
|
|
71
|
-
Evidence: devrites-engine doctor
|
|
71
|
+
Evidence: devrites-engine doctor rc=<0|1>; health <skipped|PASS|WARN|FAIL>; reindex <skipped|result>; learnings nudge <summary|none>; extensions/overrides <ok|n issues>
|
|
72
72
|
Open: <none | issue count and top issue>
|
|
73
73
|
Next: <single command for the most urgent issue>
|
|
74
74
|
Record: not applicable
|
|
@@ -42,7 +42,7 @@ Plan, Vet, Converge, and Build phase contracts needed by the assessment.
|
|
|
42
42
|
|
|
43
43
|
0. **Orient and normalize structure.** Read the active slug or `$ARGUMENTS`. Run:
|
|
44
44
|
```bash
|
|
45
|
-
devrites-engine doctor
|
|
45
|
+
devrites-engine doctor; echo "doctor rc=$?"
|
|
46
46
|
```
|
|
47
47
|
A binary/pack integrity mismatch stops at `/rite-doctor`; do not run migration or
|
|
48
48
|
mutate semantic artifacts. Once healthy, orient without writing:
|
|
@@ -35,7 +35,7 @@ It is **read-only**: it never edits the workspace, never advances a phase, never
|
|
|
35
35
|
## Workflow
|
|
36
36
|
1. Run the diagnose core verbosely (resolve across install layouts):
|
|
37
37
|
```bash
|
|
38
|
-
devrites-engine doctor
|
|
38
|
+
devrites-engine doctor; echo "doctor rc=$?"
|
|
39
39
|
```
|
|
40
40
|
1a. **Surface the learnings nudge**: point the user at `$rite-learn` when a pattern recurs across
|
|
41
41
|
shipped features (read-only; silent when there's nothing to say):
|
|
@@ -84,7 +84,7 @@ progress`, but it follows the compact labels and one-next-action rule from
|
|
|
84
84
|
```
|
|
85
85
|
Done: DevRites health checked; <OK | n issues>.
|
|
86
86
|
Changed: workspace only
|
|
87
|
-
Evidence: devrites-engine doctor
|
|
87
|
+
Evidence: devrites-engine doctor rc=<0|1>; health <skipped|PASS|WARN|FAIL>; reindex <skipped|result>; learnings nudge <summary|none>; extensions/overrides <ok|n issues>
|
|
88
88
|
Open: <none | issue count and top issue>
|
|
89
89
|
Next: <single command for the most urgent issue>
|
|
90
90
|
Record: not applicable
|
|
@@ -58,7 +58,7 @@ Plan, Vet, Converge, and Build phase contracts needed by the assessment.
|
|
|
58
58
|
|
|
59
59
|
0. **Orient and normalize structure.** Read the active slug or `$ARGUMENTS`. Run:
|
|
60
60
|
```bash
|
|
61
|
-
devrites-engine doctor
|
|
61
|
+
devrites-engine doctor; echo "doctor rc=$?"
|
|
62
62
|
```
|
|
63
63
|
A binary/pack integrity mismatch stops at `$rite-doctor`; do not run migration or
|
|
64
64
|
mutate semantic artifacts. Once healthy, orient without writing:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devrites",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.1",
|
|
4
4
|
"description": "DevRites: a disciplined senior-engineer workflow pack for Claude Code and Codex",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://github.com/ViktorsBaikers/DevRites#readme",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"test:fast": "node scripts/run-tests.mjs --fast",
|
|
55
55
|
"test:parallel": "node scripts/run-tests.mjs",
|
|
56
56
|
"test:serial": "node scripts/run-tests.mjs --serial",
|
|
57
|
-
"audit": "npm
|
|
57
|
+
"audit": "node scripts/check-npm-audit.mjs",
|
|
58
58
|
"size:baseline": "node scripts/check-instruction-size-baseline.mjs --write",
|
|
59
59
|
"release": "semantic-release",
|
|
60
60
|
"release:dry": "semantic-release --dry-run --no-ci"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
const option = (name, fallback) => {
|
|
10
|
+
const index = args.indexOf(name);
|
|
11
|
+
return index >= 0 ? args[index + 1] : fallback;
|
|
12
|
+
};
|
|
13
|
+
const input = option('--input', '');
|
|
14
|
+
const exceptionsPath = resolve(option('--exceptions', resolve(root, 'scripts', 'npm-audit-exceptions.json')));
|
|
15
|
+
const blocking = new Set(['moderate', 'high', 'critical']);
|
|
16
|
+
const failures = [];
|
|
17
|
+
const fail = (message) => failures.push(message);
|
|
18
|
+
const load = (path) => JSON.parse(readFileSync(path, 'utf8'));
|
|
19
|
+
|
|
20
|
+
let report;
|
|
21
|
+
if (input) {
|
|
22
|
+
report = load(resolve(input));
|
|
23
|
+
} else {
|
|
24
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
25
|
+
const result = spawnSync(npm, ['audit', '--json'], { cwd: root, encoding: 'utf8' });
|
|
26
|
+
if (result.error || ![0, 1].includes(result.status)) {
|
|
27
|
+
console.error(result.stderr || result.error?.message || `npm audit exited ${result.status}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
report = JSON.parse(result.stdout);
|
|
32
|
+
} catch {
|
|
33
|
+
console.error('FAIL: npm audit did not return valid JSON');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const concrete = new Map();
|
|
39
|
+
let affected = 0;
|
|
40
|
+
for (const [packageName, vulnerability] of Object.entries(report.vulnerabilities || {})) {
|
|
41
|
+
if (!blocking.has(vulnerability.severity)) continue;
|
|
42
|
+
affected++;
|
|
43
|
+
for (const via of vulnerability.via || []) {
|
|
44
|
+
if (!via || typeof via !== 'object' || !blocking.has(via.severity)) continue;
|
|
45
|
+
const id = via.url?.match(/GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}/i)?.[0];
|
|
46
|
+
if (!id) {
|
|
47
|
+
fail(`${packageName}: blocking advisory has no GHSA identifier`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const finding = concrete.get(id) || {
|
|
51
|
+
id,
|
|
52
|
+
package: via.name || packageName,
|
|
53
|
+
range: via.range || vulnerability.range,
|
|
54
|
+
source: via.url,
|
|
55
|
+
nodes: [],
|
|
56
|
+
};
|
|
57
|
+
finding.nodes = [...new Set([...finding.nodes, ...(vulnerability.nodes || [])])].sort();
|
|
58
|
+
concrete.set(id, finding);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (affected > 0 && concrete.size === 0) fail('blocking audit findings have no concrete advisory records');
|
|
62
|
+
|
|
63
|
+
const exceptions = load(exceptionsPath);
|
|
64
|
+
if (!Array.isArray(exceptions)) fail('npm audit exceptions must be a JSON array');
|
|
65
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
for (const exception of Array.isArray(exceptions) ? exceptions : []) {
|
|
68
|
+
const required = ['id', 'package', 'range', 'source', 'owner', 'reason', 'expires'];
|
|
69
|
+
const missing = required.filter((field) => !exception?.[field]);
|
|
70
|
+
if (!Array.isArray(exception?.nodes) || exception.nodes.length === 0) missing.push('nodes');
|
|
71
|
+
if (missing.length) {
|
|
72
|
+
fail(`${exception?.id || 'unknown exception'}: missing ${missing.join(', ')}`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!/^GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}$/i.test(exception.id) || !/^\d{4}-\d{2}-\d{2}$/.test(exception.expires)) {
|
|
76
|
+
fail(`${exception.id}: invalid id or expiry`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (seen.has(exception.id)) fail(`${exception.id}: duplicate exception`);
|
|
80
|
+
seen.add(exception.id);
|
|
81
|
+
if (exception.expires < today) fail(`${exception.id}: exception expired ${exception.expires}`);
|
|
82
|
+
|
|
83
|
+
const finding = concrete.get(exception.id);
|
|
84
|
+
if (!finding) {
|
|
85
|
+
fail(`${exception.id}: exception is stale; advisory is absent`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
for (const field of ['package', 'range', 'source']) {
|
|
89
|
+
if (exception[field] !== finding[field]) {
|
|
90
|
+
fail(`${exception.id}: ${field} mismatch (expected ${finding[field]})`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (JSON.stringify([...exception.nodes].sort()) !== JSON.stringify(finding.nodes)) {
|
|
94
|
+
fail(`${exception.id}: nodes mismatch (expected ${finding.nodes.join(', ')})`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const finding of concrete.values()) {
|
|
98
|
+
if (!seen.has(finding.id)) fail(`${finding.id}: blocking advisory is not excepted`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const message of failures) console.error(`FAIL: ${message}`);
|
|
102
|
+
if (failures.length) process.exit(1);
|
|
103
|
+
if (affected === 0) {
|
|
104
|
+
console.log('npm-audit: no moderate-or-higher advisories');
|
|
105
|
+
} else {
|
|
106
|
+
for (const exception of exceptions) {
|
|
107
|
+
console.log(`npm-audit: temporary ${exception.id} exception owned by ${exception.owner}, expires ${exception.expires}`);
|
|
108
|
+
}
|
|
109
|
+
console.log(`npm-audit: ${concrete.size} exact temporary exception(s), ${affected} affected package record(s)`);
|
|
110
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "GHSA-r292-9mhp-454m",
|
|
4
|
+
"package": "tar",
|
|
5
|
+
"range": "<=7.5.20",
|
|
6
|
+
"nodes": [
|
|
7
|
+
"node_modules/npm/node_modules/tar"
|
|
8
|
+
],
|
|
9
|
+
"source": "https://github.com/advisories/GHSA-r292-9mhp-454m",
|
|
10
|
+
"owner": "release-maintainers",
|
|
11
|
+
"reason": "npm@11.18.0 bundles tar@7.5.19 through @semantic-release/npm. The plugin invokes npm for authentication and publishing operations over trusted local source; it does not select or extract members from untrusted tar archives. Remove this exception as soon as npm bundles tar@7.5.21 or newer.",
|
|
12
|
+
"expires": "2026-08-07"
|
|
13
|
+
}
|
|
14
|
+
]
|