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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Generated
+1771
-2
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user