package main import ( "bufio" "bytes" "encoding/json" "path" "regexp" "strings" ) // What the forge's repositories say they depend on (novox/hq ADR 0251 ยง4): every range a package // manifest names, and every version a lockfile pins. Read from file contents alone, so it is tested // without a forge. // Wanted is one range a package manifest names, and where. type Wanted struct { Package string Range string Where string // repository:path } // Pinned is one version a lockfile names, and where. type Pinned struct { Package string Version string Where string } // Kinds of file this reads, by base name. var ( manifestNames = map[string]bool{"package.json": true} lockNames = map[string]bool{"package-lock.json": true, "npm-shrinkwrap.json": true, "yarn.lock": true, "pnpm-lock.yaml": true} ) // interesting is whether a path is a file this reads; nothing under node_modules, which is what a // lockfile already says and is not the repository's own. func interesting(p string) bool { for _, part := range strings.Split(p, "/") { if part == "node_modules" { return false } } base := path.Base(p) return manifestNames[base] || lockNames[base] } var dependencyFields = []string{"dependencies", "devDependencies", "peerDependencies", "optionalDependencies"} // ReadManifest is every range a package.json names for a registry package. An `npm:` alias names the // package it stands for. A range that is not the registry's (a path, a URL, git, a workspace) is not // a range on this registry, and is left out. func ReadManifest(content []byte, where string, known map[string]bool) ([]Wanted, error) { var m map[string]json.RawMessage if err := json.Unmarshal(content, &m); err != nil { return nil, err } var out []Wanted for _, field := range dependencyFields { var deps map[string]string if raw, has := m[field]; !has || json.Unmarshal(raw, &deps) != nil { continue } for name, spec := range deps { if alias, ok := strings.CutPrefix(spec, "npm:"); ok { at := strings.LastIndex(alias, "@") if at <= 0 { name, spec = alias, "*" } else { name, spec = alias[:at], alias[at+1:] } } if !known[name] || notRegistry(spec) { continue } out = append(out, Wanted{Package: name, Range: strings.TrimSpace(spec), Where: where}) } } return out, nil } func notRegistry(spec string) bool { for _, p := range []string{"file:", "link:", "workspace:", "git+", "git:", "github:", "http:", "https:", "portal:", "patch:"} { if strings.HasPrefix(spec, p) { return true } } return strings.Contains(spec, "/") && !strings.HasPrefix(spec, "npm:") } // ReadLock is every registry package version a lockfile names. func ReadLock(base string, content []byte, where string, known map[string]bool) ([]Pinned, error) { switch base { case "package-lock.json", "npm-shrinkwrap.json": return readNpmLock(content, where, known) case "yarn.lock": return readYarnLock(content, where, known), nil case "pnpm-lock.yaml": return readTextLock(content, where, known), nil } return nil, nil } type npmLockEntry struct { Name string `json:"name"` Version string `json:"version"` Dependencies map[string]npmLockEntry `json:"dependencies"` } func readNpmLock(content []byte, where string, known map[string]bool) ([]Pinned, error) { var lock struct { Packages map[string]npmLockEntry `json:"packages"` Dependencies map[string]npmLockEntry `json:"dependencies"` } if err := json.Unmarshal(content, &lock); err != nil { return nil, err } var out []Pinned add := func(name, version string) { if known[name] && version != "" { out = append(out, Pinned{Package: name, Version: version, Where: where}) } } // Version 2 and 3: keyed by the path it is installed at. An alias carries the real name. for key, e := range lock.Packages { _, name, found := cutLast(key, "node_modules/") if !found { continue } if e.Name != "" { name = e.Name } add(name, e.Version) } // Version 1 (and 2's copy): nested by name. An alias's version is `npm:@`. var walk func(map[string]npmLockEntry) walk = func(deps map[string]npmLockEntry) { for name, e := range deps { if alias, ok := strings.CutPrefix(e.Version, "npm:"); ok { if at := strings.LastIndex(alias, "@"); at > 0 { add(alias[:at], alias[at+1:]) } } else { add(name, e.Version) } walk(e.Dependencies) } } walk(lock.Dependencies) return out, nil } func cutLast(s, sep string) (string, string, bool) { i := strings.LastIndex(s, sep) if i < 0 { return s, "", false } return s[:i], s[i+len(sep):], true } // readYarnLock reads both yarn formats: a header of the specs it resolves (`"@a/b@^1.0.0", "@a/b@^1.1.0":`) // and an indented `version "1.2.3"` (classic) or `version: 1.2.3` (berry) under it. func readYarnLock(content []byte, where string, known map[string]bool) []Pinned { var out []Pinned var names []string scanner := bufio.NewScanner(bytes.NewReader(content)) scanner.Buffer(make([]byte, 1<<20), 1<<20) for scanner.Scan() { line := scanner.Text() if line == "" || strings.HasPrefix(line, "#") { continue } if !strings.HasPrefix(line, " ") && strings.HasSuffix(line, ":") { names = names[:0] for _, spec := range strings.Split(strings.TrimSuffix(line, ":"), ",") { spec = strings.Trim(strings.TrimSpace(spec), `"`) // `name@range`, `name@npm:range`; the name may itself start with @. at := strings.Index(spec[min(1, len(spec)):], "@") if at < 0 { continue } names = append(names, spec[:at+1]) } continue } trimmed := strings.TrimSpace(line) if v, ok := strings.CutPrefix(trimmed, "version "); ok && strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " ") { addYarn(&out, names, strings.Trim(v, `"`), where, known) } else if v, ok := strings.CutPrefix(trimmed, "version: "); ok && strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " ") { addYarn(&out, names, strings.Trim(v, `"`), where, known) } } return out } func addYarn(out *[]Pinned, names []string, version, where string, known map[string]bool) { seen := map[string]bool{} for _, n := range names { if known[n] && !seen[n] { seen[n] = true *out = append(*out, Pinned{Package: n, Version: version, Where: where}) } } } var versionText = regexp.MustCompile(`^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?`) // readTextLock reads a lockfile whose format changes between versions (pnpm's) by what every format // has in common: each resolved package written as `@` or `/`. Only names // the registry holds are looked for, so another package whose name merely contains one is told apart by // the character before it. func readTextLock(content []byte, where string, known map[string]bool) []Pinned { text := string(content) var out []Pinned seen := map[string]bool{} for name := range known { for start := 0; ; { i := strings.Index(text[start:], name) if i < 0 { break } i += start start = i + len(name) if i > 0 && !strings.ContainsRune(" '\"/\n\t:", rune(text[i-1])) { continue } rest := text[start:] if len(rest) == 0 || (rest[0] != '@' && rest[0] != '/') { continue } v := versionText.FindString(rest[1:]) if v == "" || seen[name+"@"+v] { continue } seen[name+"@"+v] = true out = append(out, Pinned{Package: name, Version: v, Where: where}) } } return out }