Files
mesh-catalog/modules/gitea/cmd/npm-registry/refs.go
T
jochen a378abc758 gitea: say what keeps each npm version and delete only what nothing names (hq ADR 0251)
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.
2026-10-08 12:00:48 +02:00

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
}