Merge pull request 'Bring node-tools' lock back in step with package.json, and fail the check when it falls behind (issue 624)' (#33) from fix/624-node-tools-lock-in-step into main

This commit was merged in pull request #33.
This commit is contained in:
2026-10-13 13:19:40 +00:00
3 changed files with 1948 additions and 2 deletions
+102
View File
@@ -0,0 +1,102 @@
// Package npmlock says whether a package's lock file still describes its package.json (novox/hq issue 624).
//
// `npm ci` installs only from a lock file in step with package.json, and refuses the whole install when it
// is not. node-tools' lock fell behind twice — the SDK's range moved and the toolchain's development
// dependencies were added to package.json alone — and nothing here noticed, because the image's build runs
// `npm install` and never reads the lock; the lab's runs, which run `npm ci`, stopped at setup instead.
// The check is offline and needs no node: it reads the two files the way `npm ci` compares them first.
package npmlock
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
)
type manifest struct {
Dependencies map[string]string `json:"dependencies"`
DevDependencies map[string]string `json:"devDependencies"`
OptionalDependencies map[string]string `json:"optionalDependencies"`
PeerDependencies map[string]string `json:"peerDependencies"`
}
type lock struct {
LockfileVersion int `json:"lockfileVersion"`
Packages map[string]manifest `json:"packages"`
}
// Check reads dir's package.json and package-lock.json and names every way the lock is out of step: a
// declared range the lock's root entry records differently or not at all, a range the lock records that
// package.json no longer declares, and a declared dependency the lock resolved no package for. None means
// in step.
func Check(dir string) ([]string, error) {
var pkg manifest
if err := readJSON(filepath.Join(dir, "package.json"), &pkg); err != nil {
return nil, err
}
var lk lock
if err := readJSON(filepath.Join(dir, "package-lock.json"), &lk); err != nil {
return nil, err
}
if lk.LockfileVersion < 2 {
return []string{fmt.Sprintf("package-lock.json is lockfileVersion %d; npm ci needs 2 or later here", lk.LockfileVersion)}, nil
}
root, ok := lk.Packages[""]
if !ok {
return []string{`package-lock.json has no root entry (packages[""])`}, nil
}
var out []string
for _, kind := range []struct {
name string
want, have map[string]string
}{
{"dependencies", pkg.Dependencies, root.Dependencies},
{"devDependencies", pkg.DevDependencies, root.DevDependencies},
{"optionalDependencies", pkg.OptionalDependencies, root.OptionalDependencies},
{"peerDependencies", pkg.PeerDependencies, root.PeerDependencies},
} {
for _, name := range sortedKeys(kind.want) {
have, ok := kind.have[name]
switch {
case !ok:
out = append(out, fmt.Sprintf("%s %s %q is in package.json and not in the lock", kind.name, name, kind.want[name]))
case have != kind.want[name]:
out = append(out, fmt.Sprintf("%s %s is %q in package.json and %q in the lock", kind.name, name, kind.want[name], have))
}
if kind.name == "peerDependencies" {
continue
}
if _, ok := lk.Packages["node_modules/"+name]; !ok {
out = append(out, fmt.Sprintf("%s %s has no resolved package in the lock", kind.name, name))
}
}
for _, name := range sortedKeys(kind.have) {
if _, ok := kind.want[name]; !ok {
out = append(out, fmt.Sprintf("%s %s %q is in the lock and no longer in package.json", kind.name, name, kind.have[name]))
}
}
}
return out, nil
}
func readJSON(path string, v any) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
if err := json.Unmarshal(b, v); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
return nil
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
@@ -0,0 +1,75 @@
package npmlock
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestNodeToolsLockIsInStepWithItsPackage: the lab installs node-tools with `npm ci`, which refuses a lock
// out of step with package.json, so a change to package.json without its lock fails here and not at a
// lab run's setup (novox/hq issue 624).
func TestNodeToolsLockIsInStepWithItsPackage(t *testing.T) {
out, err := Check(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
if len(out) > 0 {
t.Fatalf("node-tools/package-lock.json is out of step with package.json; run `npm install --package-lock-only` in node-tools:\n%s", strings.Join(out, "\n"))
}
}
func TestALockBehindItsPackageIsNamed(t *testing.T) {
dir := t.TempDir()
write(t, dir, "package.json", `{"dependencies":{"@novox/mesh-sdk":"^0.1.6","nats":"^2.29.0"},"devDependencies":{"pg":"^8"}}`)
write(t, dir, "package-lock.json", `{"lockfileVersion":3,"packages":{
"":{"dependencies":{"@novox/mesh-sdk":"^0.1.0","nats":"^2.29.0","left":"^1"}},
"node_modules/@novox/mesh-sdk":{"version":"0.1.1"},
"node_modules/nats":{"version":"2.29.3"}}}`)
out, err := Check(dir)
if err != nil {
t.Fatal(err)
}
got := strings.Join(out, "\n")
for _, want := range []string{
`dependencies @novox/mesh-sdk is "^0.1.6" in package.json and "^0.1.0" in the lock`,
`dependencies left "^1" is in the lock and no longer in package.json`,
`devDependencies pg "^8" is in package.json and not in the lock`,
`devDependencies pg has no resolved package in the lock`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
if len(out) != 4 {
t.Errorf("want 4 findings, got %d:\n%s", len(out), got)
}
}
func TestALockInStepIsQuiet(t *testing.T) {
dir := t.TempDir()
write(t, dir, "package.json", `{"dependencies":{"nats":"^2.29.0"}}`)
write(t, dir, "package-lock.json", `{"lockfileVersion":3,"packages":{"":{"dependencies":{"nats":"^2.29.0"}},"node_modules/nats":{"version":"2.29.3"}}}`)
out, err := Check(dir)
if err != nil || len(out) != 0 {
t.Fatalf("want quiet, got %v %v", out, err)
}
}
func TestAnOldLockfileVersionIsNamed(t *testing.T) {
dir := t.TempDir()
write(t, dir, "package.json", `{}`)
write(t, dir, "package-lock.json", `{"lockfileVersion":1,"dependencies":{}}`)
out, err := Check(dir)
if err != nil || len(out) != 1 {
t.Fatalf("want one finding, got %v %v", out, err)
}
}
func write(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
+1771 -2
View File
File diff suppressed because it is too large Load Diff