CAT-G2: the Go seat keeps what the npm seat keeps; its retention reads every go.mod

go_packages and go_retention read the forge's Go registry as npm_* read npm's: a version a
go.mod on a default branch requires (or replaces to) is kept, with the newest keep; a go.mod
that cannot be read stops a real run before anything is deleted; each deletion says removed
on the Go seat, under the module path and without Go's leading v (design 51 *Retention*).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 13:59:03 +01:00
parent b665156b0f
commit 4fa457b9bb
7 changed files with 212 additions and 34 deletions
+2
View File
@@ -201,6 +201,8 @@ deletes the versions nothing keeps.
|---|---|---|
| `npm_packages` | r | every package of the registry's owner, each version newest first with its publish time, its size and why it is kept |
| `npm_retention` | a | the versions retention would delete, and the bytes. **A dry run unless `dry_run` is false**; a real run needs `why` |
| `go_packages` | r | every Go module of the registry's owner (the Go seat's), each version newest first with its publish time, its size and why it is kept: a `go.mod` on a default branch requires it, or it is among the newest `keep` |
| `go_retention` | a | the Go module versions retention would delete, and the bytes; as `npm_retention`, and a `go.mod` that could not be read stops a real run. Each deletion says `removed` on the Go seat |
**A version is kept when:**
+26 -3
View File
@@ -21,6 +21,29 @@ import (
type Forge struct {
forgehttp.Client
Owner string // the package registry's owner, as the seat serves it
// Type is the registry's package type retention reads: TypeNpm when empty, or TypeGo.
Type string
}
// The package types the retention reads: npm packages (the npm seat) and Go modules (the Go seat).
const (
TypeNpm = "npm"
TypeGo = "go"
)
// typ is the package type this forge's retention reads.
func (f *Forge) typ() string {
if f.Type == "" {
return TypeNpm
}
return f.Type
}
// As is the same forge, its retention reading the package type typ.
func (f *Forge) As(typ string) *Forge {
out := *f
out.Type = typ
return &out
}
// ForgeFromEnv is the forge as the manifest hands it to this bundle.
@@ -56,12 +79,12 @@ type PackageVersion struct {
const pageSize = 50
// Packages is every npm package version of the owner, all pages.
// Packages is every package version of the owner of this forge's type, all pages.
func (f *Forge) Packages(ctx context.Context) ([]PackageVersion, error) {
var all []PackageVersion
for page := 1; page <= 1000; page++ {
var batch []PackageVersion
path := fmt.Sprintf("/api/v1/packages/%s?type=npm&page=%d&limit=%d", url.PathEscape(f.Owner), page, pageSize)
path := fmt.Sprintf("/api/v1/packages/%s?type=%s&page=%d&limit=%d", url.PathEscape(f.Owner), f.typ(), page, pageSize)
if err := f.GetJSON(ctx, path, &batch); err != nil {
return nil, err
}
@@ -74,7 +97,7 @@ func (f *Forge) Packages(ctx context.Context) ([]PackageVersion, error) {
}
func (f *Forge) versionPath(name, version string) string {
return fmt.Sprintf("/api/v1/packages/%s/npm/%s/%s", url.PathEscape(f.Owner), url.PathEscape(name), url.PathEscape(version))
return f.packagePath(f.typ(), name, version)
}
// Size is the bytes of one version's files.
+38 -14
View File
@@ -21,7 +21,7 @@ import (
// ToolNames are the tools this bundle serves. The module's manifest lists no `tools`: its TypeScript
// bundle's tools are served without one, and a claim without `serves` would offer a `tools` list to both
// of the module's seats as their verbs. The README names these two, and a test holds the two together.
var ToolNames = []string{"npm_packages", "npm_retention"}
var ToolNames = []string{"go_packages", "go_retention", "npm_packages", "npm_retention"}
func main() {
forge := func() (*Forge, error) { return ForgeFromEnv() }
@@ -35,17 +35,45 @@ func main() {
// Tools are the bundle's tools over the forge the function gives; asked per call, so a manifest that
// does not set the environment is a refusal naming what is missing, not a bundle that will not start.
func Tools(forge func() (*Forge, error)) []stdio.Tool {
return append(typeTools(TypeGo, forge), typeTools(TypeNpm, forge)...)
}
// typeTools are one package type's two tools, <type>_packages and <type>_retention, over the forge reading that type.
func typeTools(typ string, forgeOf func() (*Forge, error)) []stdio.Tool {
forge := func() (*Forge, error) {
f, err := forgeOf()
if err != nil {
return nil, err
}
return f.As(typ), nil
}
pkg := map[string]any{"type": "string", "description": "one package, by its name (default every package)"}
keep := map[string]any{"type": "integer", "description": fmt.Sprintf("how many of each package's newest versions are kept whatever names them (default %d, at least 1)", DefaultKeep)}
listed := "Every npm package in the forge's package registry, each version newest first with when it was published, " +
"its size and what keeps it: a lockfile on a repository's default branch that names it, the highest version " +
"satisfying a range a package.json there names, a dist-tag, or being among the newest `keep` of its package " +
"(novox/hq ADR 0251). Repositories and files that could not be read are named. Changes nothing. Replaces " +
"npm view and npm dist-tag ls. (r)"
deletes := "What retention would delete from the forge's package registry: every version nothing keeps — no lockfile " +
"on a repository's default branch names it, it is not the highest version satisfying any range a package.json " +
"there names, no dist-tag names it, and it is not among the newest `keep` of its package — with the bytes. " +
"A dry run unless dry_run is false; a real run needs why, and deletes nothing at all when any repository or " +
"file could not be read (novox/hq ADR 0251). Each deletion says removed on the npm seat. Replaces npm unpublish. (a)"
if typ == TypeGo {
listed = "Every Go module in the forge's package registry (the Go seat's), each version newest first with when it " +
"was published, its size and what keeps it: a go.mod on a repository's default branch that requires it, or " +
"being among the newest `keep` of its module (design 51 *Retention*). Repositories and go.mod files that " +
"could not be read are named. Changes nothing. Replaces go list -m -versions. (r)"
deletes = "What retention would delete from the forge's Go registry: every module version no go.mod on a " +
"repository's default branch requires and not among the newest `keep` of its module, with the bytes. A dry " +
"run unless dry_run is false; a real run needs why, and deletes nothing at all when any repository or go.mod " +
"could not be read. Each deletion says removed on the Go seat. (a)"
}
return []stdio.Tool{
{
Name: "npm_packages",
Description: "Every npm package in the forge's package registry, each version newest first with when it was published, " +
"its size and what keeps it: a lockfile on a repository's default branch that names it, the highest version " +
"satisfying a range a package.json there names, a dist-tag, or being among the newest `keep` of its package " +
"(novox/hq ADR 0251). Repositories and files that could not be read are named. Changes nothing. Replaces " +
"npm view and npm dist-tag ls. (r)",
Input: map[string]any{"package": pkg, "keep": keep},
Name: typ + "_packages",
Description: listed,
Input: map[string]any{"package": pkg, "keep": keep},
Run: func(args map[string]any) (any, error) {
f, err := forge()
if err != nil {
@@ -75,12 +103,8 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool {
},
},
{
Name: "npm_retention",
Description: "What retention would delete from the forge's package registry: every version nothing keeps — no lockfile " +
"on a repository's default branch names it, it is not the highest version satisfying any range a package.json " +
"there names, no dist-tag names it, and it is not among the newest `keep` of its package — with the bytes. " +
"A dry run unless dry_run is false; a real run needs why, and deletes nothing at all when any repository or " +
"file could not be read (novox/hq ADR 0251). Replaces npm unpublish. (a)",
Name: typ + "_retention",
Description: deletes,
Input: map[string]any{
"package": pkg,
"keep": keep,
+64 -4
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"path"
"regexp"
"slices"
"sort"
"strings"
)
@@ -36,18 +37,77 @@ var (
"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 {
// goModName is the file a Go module's requirements are read from.
const goModName = "go.mod"
// interesting is whether a path is a file this reads for packages of the type typ: an npm manifest or lockfile, or
// a go.mod; nothing under node_modules or vendor, which is what a lockfile or go.mod already says and is not the
// repository's own.
func interesting(p, typ string) bool {
for _, part := range strings.Split(p, "/") {
if part == "node_modules" {
if part == "node_modules" || part == "vendor" {
return false
}
}
base := path.Base(p)
if typ == TypeGo {
return base == goModName
}
return manifestNames[base] || lockNames[base]
}
// ReadGoMod is every registry module version a go.mod requires, or names on the right of a replace (design 51
// *Retention*: a Go version is referenced when a go.mod on a repository's default branch requires it). A line it
// cannot read, or a block left open, is an error beside what was read: the file is then not read whole, and a real
// run stops before anything is deleted.
func ReadGoMod(content []byte, where string, known map[string]bool) ([]Pinned, error) {
var out []Pinned
pin := func(module, version string) {
if known[module] {
out = append(out, Pinned{Package: module, Version: version, Where: where})
}
}
block := ""
for n, line := range strings.Split(string(content), "\n") {
if i := strings.Index(line, "//"); i >= 0 {
line = line[:i]
}
f := strings.Fields(line)
if len(f) == 0 {
continue
}
if block != "" {
if f[0] == ")" {
block = ""
continue
}
f = append([]string{block}, f...)
} else if len(f) == 2 && f[1] == "(" {
block = f[0]
continue
}
switch f[0] {
case "require":
if len(f) != 3 {
return out, fmt.Errorf("line %d: a require is <module> <version>, not %q", n+1, strings.Join(f[1:], " "))
}
pin(f[1], f[2])
case "replace":
arrow := slices.Index(f, "=>")
if arrow < 0 {
return out, fmt.Errorf("line %d: a replace without =>", n+1)
}
if right := f[arrow+1:]; len(right) == 2 {
pin(right[0], right[1])
}
}
}
if block != "" {
return out, fmt.Errorf("its %s block is never closed", block)
}
return out, nil
}
var dependencyFields = []string{"dependencies", "devDependencies", "peerDependencies", "optionalDependencies"}
// ReadManifest is every range a package.json names for a registry package. An `npm:` alias names the
@@ -85,7 +85,7 @@ func TestAPnpmLockNamesWhatItPinsInEachFormat(t *testing.T) {
func TestOnlyTheRepositorysOwnManifestsAreRead(t *testing.T) {
for p, want := range map[string]bool{"package.json": true, "modules/x/package-lock.json": true, "yarn.lock": true,
"pnpm-lock.yaml": true, "node_modules/a/package.json": false, "README.md": false, "a/package.json.bak": false} {
if interesting(p) != want {
if interesting(p, TypeNpm) != want {
t.Errorf("%s: %v", p, !want)
}
}
@@ -50,6 +50,7 @@ type Package struct {
// Survey is the registry and what the forge's repositories say of it.
type Survey struct {
Owner string `json:"owner"`
Type string `json:"type"`
Keep int `json:"keep"`
Repositories int `json:"repositories_read"`
Unread []string `json:"repositories_unread,omitempty"`
@@ -73,7 +74,7 @@ func survey(ctx context.Context, f *Forge, only string, keep int, sizes func(Ite
}
if only != "" {
if _, has := byName[only]; !has {
return nil, fmt.Errorf("the registry of %s holds no npm package %q", f.Owner, only)
return nil, fmt.Errorf("the registry of %s holds no %s package %q", f.Owner, f.typ(), only)
}
}
known := map[string]bool{}
@@ -81,7 +82,7 @@ func survey(ctx context.Context, f *Forge, only string, keep int, sizes func(Ite
known[name] = true
}
s := &Survey{Owner: f.Owner, Keep: keep, Said: []string{}}
s := &Survey{Owner: f.Owner, Type: f.typ(), Keep: keep, Said: []string{}}
wanted, pinned, err := readRepositories(ctx, f, known, s)
if err != nil {
return nil, err
@@ -94,7 +95,10 @@ func survey(ctx context.Context, f *Forge, only string, keep int, sizes func(Ite
}
}
sort.Strings(names)
tags := distTags(ctx, f, names)
tags := map[string]tagAnswer{} // a Go module has no dist-tags
if f.typ() == TypeNpm {
tags = distTags(ctx, f, names)
}
for _, name := range names {
s.Packages = append(s.Packages, decide(name, byName[name], tags[name], wanted[name], pinned[name], keep))
}
@@ -169,7 +173,7 @@ func readRepositories(ctx context.Context, f *Forge, known map[string]bool, s *S
var ps []Pinned
var notRead, unread []string
for _, p := range files {
if !interesting(p) {
if !interesting(p, f.typ()) {
continue
}
where := r.FullName + ":" + p
@@ -179,6 +183,14 @@ func readRepositories(ctx context.Context, f *Forge, known map[string]bool, s *S
continue
}
base := path.Base(p)
if base == goModName {
got, err := ReadGoMod(content, where, known)
ps = append(ps, got...)
if err != nil {
notRead = append(notRead, fmt.Sprintf("%s: not readable (%v)", where, err))
}
continue
}
if manifestNames[base] {
got, err := ReadManifest(content, where, known)
if err != nil {
@@ -421,16 +433,26 @@ func retention(ctx context.Context, f *Forge, only string, keep int, dryRun bool
del, cancel := context.WithTimeout(context.Background(), budget)
defer cancel()
store := seatStore{forge: func() (*Forge, error) { return f, nil }}
seat := packageseats.NpmSeat
if f.typ() == TypeGo {
seat = packageseats.GoSeat
}
for _, c := range p.Candidates {
digest, _, _ := store.Held(packageseats.NpmSeat, c.Package, c.Version)
digest, _, _ := store.Held(seat, c.Package, c.Version)
if err := f.Delete(del, c.Package, c.Version); err != nil && !errors.Is(err, errNotFound) {
p.Refused = append(p.Refused, fmt.Sprintf("%s@%s: %v", c.Package, c.Version, err))
continue
}
p.Deleted = append(p.Deleted, c.Package+"@"+c.Version)
// The seat says every removal (design 51 *Retention*), so the builder's entry turns removed.
o := packageseats.Outcome{Name: c.Package, Version: c.Version, Kind: packageseats.KindPackage, Digest: digest, Reason: packageseats.ReasonRetention}
if err := say(packageseats.NpmSeat, packageseats.EventRemoved, o); err != nil {
// The seat's version is the builder's, without Go's leading v (forgeVersion adds it for the forge).
o := packageseats.Outcome{Name: c.Package, Version: strings.TrimPrefix(c.Version, "v"), Kind: packageseats.KindPackage, Digest: digest, Reason: packageseats.ReasonRetention}
if seat == packageseats.NpmSeat {
o.Version = c.Version
} else {
o.Module = c.Package
}
if err := say(seat, packageseats.EventRemoved, o); err != nil {
s.Said = append(s.Said, fmt.Sprintf("%s@%s is deleted but its removed could not be said: %v", c.Package, c.Version, err))
}
}
@@ -24,6 +24,7 @@ import (
type fakeForge struct {
t *testing.T
versions map[string][]string // package -> versions
modules map[string][]string // Go module -> versions, listed as type go
tags map[string]map[string]string // package -> dist-tags
repos map[string]map[string]string // repository -> path -> content
broken map[string]bool // repositories whose tree answers 500
@@ -45,7 +46,7 @@ func (f *fakeForge) serve() *Forge {
}
var (
versionFiles = regexp.MustCompile(`^/api/v1/packages/acme/npm/([^/]+)/([^/]+)(/files)?$`)
versionFiles = regexp.MustCompile(`^/api/v1/packages/acme/(?:npm|go)/([^/]+)/([^/]+)(/files)?$`)
treePath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/git/trees/`)
rawPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/raw/(.+)$`)
)
@@ -63,10 +64,14 @@ func (f *fakeForge) handle(w http.ResponseWriter, r *http.Request) {
switch {
case p == "/api/v1/packages/acme" && r.Method == http.MethodGet:
var all []map[string]any
names := keys(f.versions)
listed := f.versions
if q.Get("type") == TypeGo {
listed = f.modules
}
names := keys(listed)
for _, n := range names {
for i, v := range f.versions[n] {
all = append(all, map[string]any{"name": n, "version": v, "type": "npm",
for i, v := range listed[n] {
all = append(all, map[string]any{"name": n, "version": v, "type": q.Get("type"),
"created_at": time.Date(2026, 1, 1+i, 0, 0, 0, 0, time.UTC)})
}
}
@@ -299,6 +304,48 @@ func TestARealRunDeletesOnlyWhatNothingKeeps(t *testing.T) {
}
}
// TestAVersionAGoModRequiresSurvivesGoRetention: the Go seat keeps what the npm seat keeps (design 51 *Retention*): a
// version a go.mod on a default branch requires, or names on the right of a replace, and the newest keep; the others are
// deleted and said removed on the Go seat, without Go's leading v; a go.mod that cannot be read stops a real run.
func TestAVersionAGoModRequiresSurvivesGoRetention(t *testing.T) {
f := aRegistry(t)
f.modules = map[string][]string{"git.acme/sdk": {"v0.1.0", "v0.1.1", "v0.1.2", "v0.1.3", "v0.1.4"}}
f.repos["acme/svc"] = map[string]string{
"go.mod": "module git.acme/svc\n\ngo 1.26\n\nrequire (\n\tgit.acme/sdk v0.1.0 // indirect\n\tgolang.org/x/mod v0.20.0\n)\n",
"tools/go.mod": "module git.acme/svc/tools\nrequire git.acme/other v1.0.0\nreplace git.acme/x => git.acme/sdk v0.1.2\n",
"vendor/go.mod": "module broken\nrequire (\n",
}
var removed []string
say = func(seat, event string, o packageseats.Outcome) error {
removed = append(removed, seat+" "+event+" "+o.Module+"@"+o.Version+" "+o.Reason)
return nil
}
t.Cleanup(func() { say = sayOn })
p, err := retention(context.Background(), f.serve().As(TypeGo), "", 1, false, "tidy the Go registry")
if err != nil {
t.Fatal(err)
}
sort.Strings(f.deleted)
if strings.Join(f.deleted, " ") != "git.acme/sdk@v0.1.1 git.acme/sdk@v0.1.3" {
t.Errorf("deleted %v, want all but the required v0.1.0, the replaced-to v0.1.2 and the newest v0.1.4", f.deleted)
}
if sort.Strings(removed); strings.Join(removed, ",") != "go-package-registry removed git.acme/sdk@0.1.1 retention,go-package-registry removed git.acme/sdk@0.1.3 retention" {
t.Errorf("said %v", removed)
}
if p.Type != TypeGo || len(p.Packages) != 1 {
t.Errorf("surveyed %s %v, want the Go registry alone", p.Type, p.Packages)
}
// A go.mod outside vendor that cannot be read stops the real run before anything is deleted.
f.deleted = nil
f.repos["acme/svc"]["cmd/go.mod"] = "module git.acme/svc/cmd\nrequire (\n\tgit.acme/sdk v0.1.1\n"
if _, err := retention(context.Background(), f.serve().As(TypeGo), "", 1, false, "tidy"); err == nil || !strings.Contains(err.Error(), "not read whole") {
t.Errorf("an unreadable go.mod = %v, want the real run refused", err)
}
if len(f.deleted) != 0 {
t.Errorf("deleted %v while a go.mod was unread", f.deleted)
}
}
func TestAnUnreadRepositoryStopsARealRunBeforeAnythingIsDeleted(t *testing.T) {
f := aRegistry(t)
f.broken = map[string]bool{"acme/site": true}
@@ -359,7 +406,7 @@ func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) {
for _, tl := range tools {
served = append(served, tl.Name)
suffix := "(r)"
if tl.Name == "npm_retention" {
if strings.HasSuffix(tl.Name, "_retention") {
suffix = "(a)"
}
if !strings.HasSuffix(tl.Description, suffix) {