Every publish added a version and nothing removed one. A Go bundle beside the TypeScript one keeps what a lockfile or a range on the forge names, what a dist-tag names and the newest five; a dry run unless asked with a why, and nothing deleted while any repository is unread.
242 lines
7.3 KiB
Go
242 lines
7.3 KiB
Go
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:<name>@<version>`.
|
|
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 `<name>@<version>` or `<name>/<version>`. 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
|
|
}
|