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