Bring node-tools' lock back in step with package.json, and fail the check when it falls behind (hq issue 624)
mesh/merge-gate head 52e275ad2f3a merged into trunk 954e1a53eef9 — pass, with a note: builds mesh-tools (+39 dependent(s)) → ace, g14, novox, shanks; no bu…
mesh/repo-check head 52e275ad2f3a merged into trunk 954e1a53eef9 — pass: its merge-check.sh passed
mesh/delivery delivered

The lab installs node-tools with npm ci, which refuses a lock that no longer
describes package.json; the SDK's range and the toolchain's development
dependencies had moved in package.json alone, so every lab run stopped at
setup. The image's build runs npm install and never read the lock, so nothing
noticed.
This commit is contained in:
jochen
2026-10-13 15:12:10 +02:00
parent 954e1a53ee
commit 52e275ad2f
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