// 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 }