From 995139781095f0d03643e69244ab82fde46a9e6d Mon Sep 17 00:00:00 2001 From: Jeremy Levartovsky Date: Sun, 30 Aug 2026 09:38:50 +1000 Subject: [PATCH 1/2] feat(registry): Go module support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `go` as a registry: version discovery through proxy.golang.org, plus the recursive definition walk that Go module paths require. Three things differ from the existing fetchers, each measured against the live proxy rather than inferred: - Uppercase letters must be escaped as "!" + lowercase. github.com/BurntSushi/toml/@v/list returns 404; github.com/!burnt!sushi/toml returns 200. Slashes are path separators and must survive, so encodeURIComponent is the wrong tool. - The "v" prefix is stripped before the shared helpers see it. isPrerelease("v1.10.2") is true, so every Go version would be silently discarded, and compareSemver("v1.10.2", "v1.9.1") is NaN, so sorting breaks. Definitions restore the prefix with tag_pattern "v{version}", which keeps both shared functions untouched. - /@v/list carries no publish dates. Getting them costs one /@v/.info request per version, so --since filtering is unavailable for Go rather than merely slow. publishedAt is left undefined. listDefinitions only recursed into directories starting with "@", so registry/go/github.com/spf13/cobra.yaml was never loaded — exit 0, no warning. Every Go module path contains slashes, so this blocked the ecosystem rather than one package. The general recursive walk that replaces the special case is smaller than the case it removes, and scoped npm packages still resolve because loadDefinition already derives expectedName from the path relative to the manager directory. registry/go/github.com/spf13/cobra.yaml is included as the first definition. It was taken end to end before this PR: registry build produced 56 sections / 19631 tokens, context add installed it, and `context query 'github.com/spf13/cobra' 'persistent flags'` returned the right section. Tests: four new ones. Three cover the fetcher (prefix stripping, case escaping, a module with no tags) and one covers a definition nested several directories deep. Each was mutation-checked — dropping the escaping, keeping the v prefix, and removing the recursion each turn exactly the matching test red. 45/45 green, biome clean. One open question for a maintainer: `std` is not on the module proxy, so the Go standard library would need an unversioned definition like python/python.yaml rather than a `go` one. Not included here. --- packages/registry/src/definition.test.ts | 16 +++++ packages/registry/src/definition.ts | 32 +++++----- packages/registry/src/version-check.test.ts | 66 +++++++++++++++++++++ packages/registry/src/version-check.ts | 46 +++++++++++++- registry/go/github.com/spf13/cobra.yaml | 11 ++++ 5 files changed, 152 insertions(+), 19 deletions(-) create mode 100644 registry/go/github.com/spf13/cobra.yaml diff --git a/packages/registry/src/definition.test.ts b/packages/registry/src/definition.test.ts index 9f53de0..be7960f 100644 --- a/packages/registry/src/definition.test.ts +++ b/packages/registry/src/definition.test.ts @@ -220,6 +220,22 @@ describe("listDefinitions", () => { expect(defs[1].registry).toBe("pip"); }); + it("discovers a definition nested several directories deep", () => { + // Every Go module path contains slashes, so its definition file lands at + // go/github.com/spf13/cobra.yaml. Before the walk became recursive, this + // file was never loaded and the build exited 0 with no warning. + mkdirSync(join(tempDir, "go", "github.com", "spf13"), { recursive: true }); + writeFileSync( + join(tempDir, "go", "github.com", "spf13", "cobra.yaml"), + 'name: github.com/spf13/cobra\nversions:\n - min_version: "1.8.0"\n tag_pattern: "v{version}"\n source:\n type: git\n url: https://github.com/spf13/cobra\n', + ); + + const defs = listDefinitions(tempDir); + expect(defs).toHaveLength(1); + expect(defs[0].name).toBe("github.com/spf13/cobra"); + expect(defs[0].registry).toBe("go"); + }); + it("discovers scoped packages in @scope subdirectories", () => { mkdirSync(join(tempDir, "npm")); mkdirSync(join(tempDir, "npm", "@trpc"), { recursive: true }); diff --git a/packages/registry/src/definition.ts b/packages/registry/src/definition.ts index 370f1d5..1317829 100644 --- a/packages/registry/src/definition.ts +++ b/packages/registry/src/definition.ts @@ -187,31 +187,27 @@ export function loadDefinition( /** * Scan the registry/ directory and load all definitions. - * Supports scoped packages via @scope subdirectories (e.g., npm/@trpc/server.yaml). + * Recurses into subdirectories, so a package name may contain slashes: + * npm/@trpc/server.yaml and go/github.com/spf13/cobra.yaml both work. */ export function listDefinitions(registryDir: string): PackageDefinition[] { const definitions: PackageDefinition[] = []; + const walk = (dir: string, managerDir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, managerDir); + } else if (entry.isFile() && entry.name.endsWith(".yaml")) { + definitions.push(loadDefinition(full, managerDir)); + } + } + }; + for (const manager of readdirSync(registryDir, { withFileTypes: true })) { if (!manager.isDirectory()) continue; - const managerDir = join(registryDir, manager.name); - for (const entry of readdirSync(managerDir, { withFileTypes: true })) { - if (entry.isFile() && entry.name.endsWith(".yaml")) { - definitions.push( - loadDefinition(join(managerDir, entry.name), managerDir), - ); - } else if (entry.isDirectory() && entry.name.startsWith("@")) { - // Scoped package directory (e.g., @trpc/) - const scopeDir = join(managerDir, entry.name); - for (const file of readdirSync(scopeDir, { withFileTypes: true })) { - if (!file.isFile() || !file.name.endsWith(".yaml")) continue; - definitions.push( - loadDefinition(join(scopeDir, file.name), managerDir), - ); - } - } - } + walk(managerDir, managerDir); } return definitions.sort((a, b) => diff --git a/packages/registry/src/version-check.test.ts b/packages/registry/src/version-check.test.ts index 0312bd7..ebb445a 100644 --- a/packages/registry/src/version-check.test.ts +++ b/packages/registry/src/version-check.test.ts @@ -164,6 +164,72 @@ describe("discoverVersions", () => { ); }); + it("fetches go versions from the module proxy and strips the v prefix", async () => { + const goDef: VersionedDefinition = { + ...mockDefinition, + name: "github.com/spf13/cobra", + registry: "go", + }; + + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => "v1.10.2\nv1.9.1\nv1.8.1\n", + } as Response); + + const versions = await discoverVersions(goDef); + + // The shared isPrerelease() reads a leading "v" as a prerelease tag and + // discards the version, and compareSemver() returns NaN for it, so every + // Go version would vanish if the prefix survived this far. + expect(versions.map((v) => v.version)).toEqual([ + "1.10.2", + "1.9.1", + "1.8.1", + ]); + expect(mockFetch).toHaveBeenCalledWith( + "https://proxy.golang.org/github.com/spf13/cobra/@v/list", + ); + }); + + it("escapes uppercase letters in a go module path, and only those", async () => { + const goDef: VersionedDefinition = { + ...mockDefinition, + name: "github.com/BurntSushi/toml", + registry: "go", + }; + + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => "v1.5.0\n", + } as Response); + + await discoverVersions(goDef); + + // Measured against the live proxy: the unescaped path 404s, the escaped one + // returns 200. Slashes are path separators and must survive intact, which + // is why encodeURIComponent is the wrong tool here. + expect(mockFetch).toHaveBeenCalledWith( + "https://proxy.golang.org/github.com/!burnt!sushi/toml/@v/list", + ); + }); + + it("returns nothing for a go module with no tagged releases", async () => { + const goDef: VersionedDefinition = { + ...mockDefinition, + name: "github.com/test/untagged", + registry: "go", + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + text: async () => "", + } as Response); + + await expect(discoverVersions(goDef)).resolves.toEqual([]); + }); + it("throws for unsupported registry", async () => { const def = { ...mockDefinition, registry: "cargo" }; await expect(discoverVersions(def)).rejects.toThrow( diff --git a/packages/registry/src/version-check.ts b/packages/registry/src/version-check.ts index ace0b17..dd8d376 100644 --- a/packages/registry/src/version-check.ts +++ b/packages/registry/src/version-check.ts @@ -1,5 +1,5 @@ /** - * Version discovery from package registry APIs (npm, pip, maven, hex). + * Version discovery from package registry APIs (npm, pip, maven, hex, go). * * Queries public registry APIs to find available versions, * filters to defined ranges, and deduplicates to latest-patch-per-minor. @@ -33,6 +33,7 @@ const registryFetchers: Record = { pip: fetchPipVersions, maven: fetchMavenVersions, hex: fetchHexVersions, + go: fetchGoVersions, }; /** @@ -220,6 +221,49 @@ async function fetchHexVersions(packageName: string): Promise { })); } +/** + * Go modules, via the module proxy (https://proxy.golang.org). + * + * `packageName` is the full module path (e.g. "github.com/spf13/cobra"), which + * is also the definition's `name`. Three things differ from the other fetchers: + * + * - **Case escaping.** The proxy requires uppercase letters to be written as + * "!" + lowercase, so "github.com/BurntSushi/toml" is requested as + * "github.com/!burnt!sushi/toml". The unescaped path 404s. Slashes are path + * separators and must NOT be percent-encoded, so encodeURIComponent is wrong + * here. + * - **The "v" prefix is stripped.** Go tags are "v1.10.2"; this returns + * "1.10.2" so the shared `isPrerelease` and `compareSemver` keep working + * (both misread a leading "v" — isPrerelease sees a letter and discards the + * version, compareSemver returns NaN). Definitions restore it with the + * default tag_pattern "v{version}". + * - **No publish dates.** /@v/list returns bare versions. Dates need one + * /@v/.info request each, so `--since` filtering is unavailable + * rather than expensive. publishedAt is left undefined. + * + * The response is plain text, one version per line, in no particular order, + * and is empty for a module with no tagged releases. + */ +function escapeGoModulePath(modulePath: string): string { + return modulePath.replace(/[A-Z]/g, (c) => `!${c.toLowerCase()}`); +} + +async function fetchGoVersions(packageName: string): Promise { + const res = await fetchWithRetry( + `https://proxy.golang.org/${escapeGoModulePath(packageName)}/@v/list`, + `Go module proxy`, + packageName, + ); + + const body = await res.text(); + + return body + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("v")) + .map((version) => ({ version: version.slice(1) })); +} + /** * Public registries occasionally return 504/503 under load. Retry 5xx and * network errors with exponential backoff; 4xx aborts immediately. diff --git a/registry/go/github.com/spf13/cobra.yaml b/registry/go/github.com/spf13/cobra.yaml new file mode 100644 index 0000000..7d481eb --- /dev/null +++ b/registry/go/github.com/spf13/cobra.yaml @@ -0,0 +1,11 @@ +name: github.com/spf13/cobra +description: "A Commander for modern Go CLI interactions" +repository: https://github.com/spf13/cobra + +versions: + - min_version: "1.8.0" + tag_pattern: "v{version}" + source: + type: git + url: https://github.com/spf13/cobra + docs_path: site/content From cac38d90d6461e95469b51190739ee7b3970afc9 Mon Sep 17 00:00:00 2001 From: Jeremy Levartovsky Date: Sun, 30 Aug 2026 12:59:02 +1000 Subject: [PATCH 2/2] Add a changeset for Go module support --- .changeset/spotty-carrots-invent.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/spotty-carrots-invent.md diff --git a/.changeset/spotty-carrots-invent.md b/.changeset/spotty-carrots-invent.md new file mode 100644 index 0000000..af367f4 --- /dev/null +++ b/.changeset/spotty-carrots-invent.md @@ -0,0 +1,7 @@ +--- +"@neuledge/registry": minor +--- + +Add `go` as a registry: version discovery through `proxy.golang.org`, with the uppercase-to-`!` module path escaping the proxy requires and the `v` prefix stripped so the shared `isPrerelease` and `compareSemver` keep working. `/@v/list` carries no publish dates, so `--since` filtering is unavailable for Go rather than merely slow. + +Also make `listDefinitions` recurse into any subdirectory, not only `@scope` ones. Every Go module path contains slashes, so `registry/go/github.com/spf13/cobra.yaml` was never loaded — exit code 0, no warning. Scoped npm packages continue to resolve unchanged.