From 0eac5a9dd8e98ebaeb035b6806a7520a07b6a301 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Thu, 23 Jul 2026 14:22:29 -0500 Subject: [PATCH 01/71] Initial scaffold of zed-interfaces Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + Cargo.lock | 290 ++++++++++++++++++++++++++++++++ Cargo.toml | 15 ++ LICENSE | 21 +++ README.md | 85 ++++++++++ examples/generate_schemas.rs | 33 ++++ schemas/api-error.json | 19 +++ schemas/claim-org-request.json | 13 ++ schemas/claim-org-response.json | 18 ++ schemas/lockfile.json | 83 +++++++++ schemas/manifest.json | 154 +++++++++++++++++ schemas/package-metadata.json | 59 +++++++ schemas/publish-meta.json | 200 ++++++++++++++++++++++ schemas/publish-response.json | 25 +++ schemas/search-response.json | 49 ++++++ schemas/version-metadata.json | 69 ++++++++ src/artifact.rs | 36 ++++ src/excludes.rs | 55 ++++++ src/lib.rs | 20 +++ src/lockfile.rs | 94 +++++++++++ src/manifest.rs | 180 ++++++++++++++++++++ src/paths.rs | 44 +++++ src/registry.rs | 156 +++++++++++++++++ src/vcs.rs | 72 ++++++++ tests/roundtrip.rs | 108 ++++++++++++ 25 files changed, 1899 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/generate_schemas.rs create mode 100644 schemas/api-error.json create mode 100644 schemas/claim-org-request.json create mode 100644 schemas/claim-org-response.json create mode 100644 schemas/lockfile.json create mode 100644 schemas/manifest.json create mode 100644 schemas/package-metadata.json create mode 100644 schemas/publish-meta.json create mode 100644 schemas/publish-response.json create mode 100644 schemas/search-response.json create mode 100644 schemas/version-metadata.json create mode 100644 src/artifact.rs create mode 100644 src/excludes.rs create mode 100644 src/lib.rs create mode 100644 src/lockfile.rs create mode 100644 src/manifest.rs create mode 100644 src/paths.rs create mode 100644 src/registry.rs create mode 100644 src/vcs.rs create mode 100644 tests/roundtrip.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4037a86 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,290 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zed-interfaces" +version = "0.1.0" +dependencies = [ + "schemars", + "semver", + "serde", + "serde_json", + "thiserror", + "toml", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ab18f0a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "zed-interfaces" +version = "0.1.0" +edition = "2024" +description = "Core interface definitions for the zed-pkg universal package manager" +license = "MIT" +repository = "https://github.com/zed-pkg/zed-interfaces" + +[dependencies] +schemars = "1.2.1" +semver = { version = "1.0.28", features = ["serde"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +thiserror = "2.0.19" +toml = "1.1.3" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..613f14a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 zed-pkg contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b5f556 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# zed-interfaces + +Core interface definitions for [zed-pkg](https://github.com/zed-pkg), the +universal package manager backed by the VCS hosts you already use. + +This crate is the contract everything else builds against: + +- **`.zpkg.toml`** — the package manifest at the repo root, TOML only (`manifest` module) +- **`.zpkg.lock`** — the lockfile with artifact hashes and VCS provenance (`lockfile`) +- **Registry REST API** — URL scheme and JSON DTOs shared by `zed-api-server`, + `zed-cli`, `zed-web-server`, and the SDKs in `zed-clients` (`registry`) +- **Publish excludes** — the default rules that strip tests, CI config, + `.github/`, and READMEs from published artifacts (`excludes`) +- **Filesystem layout** — `$HOME/.zed-pkg` store, `zed_modules/` symlink dir, + archive structure (`paths`) +- **VCS + artifact enums** — `git`/`hg`, `tar.gz`/`zip` (`vcs`, `artifact`) + +## The model in one page + +A package is `/`. Its source of truth is a repository on any VCS +host — GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo/Gitea, Azure +DevOps, CodeCommit, Radicle, or a server you run — using git, hg, jj, sapling, +fossil, or pijul (jj and sapling verify through git tags since they push to +git remotes). The registry at zpkg.tech is the primary artifact host; the +declared backing repo doubles as mirror/backup. What gets installed is never +a clone: `zed publish` packs a pruned artifact (no tests, no +CI config, no README unless opted in; licenses always kept), verifies that a +VCS tag matching `publish.tag_format` (default `v{version}`) points at the +published commit, and uploads the archive to the registry, which stores it in +S3-compatible object storage (Cloudflare R2, S3, MinIO). + +`zed install` resolves semver requirements against registry metadata, downloads +each artifact once into the global content-addressed store +(`$HOME/.zed-pkg/store/v1///pkg`), verifies its sha256, and +symlinks it into the project's `zed_modules//` — pnpm-style, one +copy per machine no matter how many projects use it. In containers, +`--install-mode copy` materializes files instead of symlinking so image layers +stay self-contained across multi-stage builds. + +The lockfile pins `sha256`, `size`, `vcs_tag`, and `vcs_commit` per package: +installs are reproducible and every artifact traces back to source. + +## Registry API surface + +| Method | Path | Body / response | +| --- | --- | --- | +| GET | `/v1/packages/{org}/{name}` | `PackageMetadata` | +| GET | `/v1/packages/{org}/{name}/versions/{version}` | `VersionMetadata` | +| PUT | `/v1/packages/{org}/{name}/versions/{version}` | multipart `meta` (`PublishMeta` JSON) + `artifact` (bytes) → `PublishResponse` | +| GET | `/v1/artifacts/{sha256}` | artifact bytes or redirect to presigned URL | +| GET | `/v1/search?q=` | `SearchResponse` | +| POST | `/v1/orgs` | `ClaimOrgRequest` → `ClaimOrgResponse` | +| GET | `/healthz` | liveness | + +Errors use `ApiError { code, message }`. Authenticated routes take +`Authorization: Bearer `. + +## JSON Schemas + +`schemas/` holds generated JSON Schema files for every wire type, used by the +non-Rust SDKs in [zed-clients](https://github.com/zed-pkg/zed-clients). +Regenerate after changing any type: + +```sh +cargo run --example generate_schemas +``` + +## Development + +This repo is developed side by side with its siblings; the other Rust repos +depend on it via `zed-interfaces = { path = "../zed-interfaces" }`: + +```sh +git clone https://github.com/zed-pkg/zed-interfaces +git clone https://github.com/zed-pkg/zed-cli +# ... siblings in the same parent directory +``` + +```sh +cargo test +``` + +## License + +MIT diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs new file mode 100644 index 0000000..87cdbaf --- /dev/null +++ b/examples/generate_schemas.rs @@ -0,0 +1,33 @@ +//! Regenerates the JSON Schemas under `schemas/`, which the non-Rust +//! client libraries in `zed-clients` use for codegen and validation. +//! +//! Run with: `cargo run --example generate_schemas` + +use std::fs; +use std::path::Path; + +use schemars::{JsonSchema, schema_for}; + +fn write(dir: &Path, name: &str) { + let schema = schema_for!(T); + let json = serde_json::to_string_pretty(&schema).expect("schema serializes"); + let path = dir.join(format!("{name}.json")); + fs::write(&path, json + "\n").expect("schema file writes"); + println!("wrote {}", path.display()); +} + +fn main() { + let dir = Path::new("schemas"); + fs::create_dir_all(dir).expect("schemas dir"); + + write::(dir, "manifest"); + write::(dir, "lockfile"); + write::(dir, "package-metadata"); + write::(dir, "version-metadata"); + write::(dir, "publish-meta"); + write::(dir, "publish-response"); + write::(dir, "search-response"); + write::(dir, "claim-org-request"); + write::(dir, "claim-org-response"); + write::(dir, "api-error"); +} diff --git a/schemas/api-error.json b/schemas/api-error.json new file mode 100644 index 0000000..0c8f479 --- /dev/null +++ b/schemas/api-error.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ApiError", + "description": "Error body returned with any non-2xx status.", + "type": "object", + "properties": { + "code": { + "description": "Stable machine-readable code, e.g. `not_found`, `sha256_mismatch`,\n`tag_not_found`, `unauthorized`, `org_taken`.", + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ] +} diff --git a/schemas/claim-org-request.json b/schemas/claim-org-request.json new file mode 100644 index 0000000..cf6c1d6 --- /dev/null +++ b/schemas/claim-org-request.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ClaimOrgRequest", + "type": "object", + "properties": { + "slug": { + "type": "string" + } + }, + "required": [ + "slug" + ] +} diff --git a/schemas/claim-org-response.json b/schemas/claim-org-response.json new file mode 100644 index 0000000..a96c2b9 --- /dev/null +++ b/schemas/claim-org-response.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ClaimOrgResponse", + "type": "object", + "properties": { + "created": { + "description": "False when the caller already owned the org.", + "type": "boolean" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "slug", + "created" + ] +} diff --git a/schemas/lockfile.json b/schemas/lockfile.json new file mode 100644 index 0000000..c260ec9 --- /dev/null +++ b/schemas/lockfile.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Lockfile", + "description": "The `zed.lock` file written next to `zed.toml` after resolution.\n\nSerialized as TOML with one `[[package]]` table per locked package,\nCargo.lock-style. Every entry pins the exact artifact hash and the VCS\ntag it was published from, so installs are reproducible and every\nartifact is traceable back to source.", + "type": "object", + "properties": { + "package": { + "type": "array", + "items": { + "$ref": "#/$defs/LockedPackage" + } + }, + "version": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "version" + ], + "$defs": { + "ArtifactFormat": { + "description": "On-the-wire formats for published package artifacts.", + "type": "string", + "enum": [ + "tar.gz", + "zip" + ] + }, + "LockedPackage": { + "type": "object", + "properties": { + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "sha256": { + "description": "Hex sha256 of the artifact archive; also its store address.", + "type": "string" + }, + "size": { + "description": "Artifact size in bytes.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "source": { + "description": "Base URL of the registry the artifact was resolved from.", + "type": "string" + }, + "vcs_commit": { + "type": [ + "string", + "null" + ] + }, + "vcs_tag": { + "description": "VCS tag the version was published from, e.g. `v1.2.0`.", + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "org", + "name", + "version", + "sha256", + "size", + "vcs_tag", + "source" + ] + } + } +} diff --git a/schemas/manifest.json b/schemas/manifest.json new file mode 100644 index 0000000..4cbe51e --- /dev/null +++ b/schemas/manifest.json @@ -0,0 +1,154 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Manifest", + "description": "The `.zpkg.toml` manifest at the root of every package repository.\nTOML only — never YAML or JSON.\n\n```toml\n[package]\norg = \"acme\"\nname = \"http-kit\"\nversion = \"1.2.0\"\ndescription = \"Tiny HTTP helpers\"\nlicense = \"MIT\"\n\n[package.repository]\nvcs = \"git\"\nurl = \"https://github.com/acme/http-kit\"\n\n[dependencies]\n\"acme/logkit\" = \"^0.3\"\n\n[publish]\nexclude = [\"benches/**\"]\nsmoke_test = \"sh scripts/smoke.sh\"\n```", + "type": "object", + "properties": { + "dependencies": { + "description": "Dependencies keyed by `org/name`, valued by a semver requirement.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "package": { + "$ref": "#/$defs/PackageSection" + }, + "publish": { + "$ref": "#/$defs/PublishSection", + "default": { + "exclude": [], + "include_readme": false, + "smoke_test": null, + "tag_format": "v{version}" + } + }, + "scripts": { + "$ref": "#/$defs/ScriptsSection", + "default": { + "test": null + } + } + }, + "required": [ + "package" + ], + "$defs": { + "PackageSection": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "license": { + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Package name, unique within the org. Lowercase slug.", + "type": "string" + }, + "org": { + "description": "Namespace the package is published under. Lowercase slug.", + "type": "string" + }, + "repository": { + "$ref": "#/$defs/RepositorySection" + }, + "version": { + "description": "Semver version of this package.", + "type": "string" + } + }, + "required": [ + "org", + "name", + "version", + "repository" + ] + }, + "PublishSection": { + "type": "object", + "properties": { + "exclude": { + "description": "Extra glob patterns to exclude on top of the built-in defaults.", + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "include_readme": { + "description": "Keep README files in the published artifact (stripped by default).", + "type": "boolean", + "default": false + }, + "smoke_test": { + "description": "Command run by `zed test-local` inside a throwaway consumer project\nthat has this package installed the same way a real consumer would.", + "type": [ + "string", + "null" + ], + "default": null + }, + "tag_format": { + "description": "VCS tag template that must exist and point at the published commit.\n`{version}` is substituted with `package.version`.", + "type": "string", + "default": "v{version}" + } + } + }, + "RepositorySection": { + "description": "Where the package's source of truth lives. Any Git or Mercurial host\nworks: GitHub, GitLab, Bitbucket, or a self-hosted server.", + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "vcs": { + "$ref": "#/$defs/Vcs", + "default": "git" + } + }, + "required": [ + "url" + ] + }, + "ScriptsSection": { + "type": "object", + "properties": { + "test": { + "description": "Test command run from the repository (not from published artifacts;\ntests are stripped at publish time).", + "type": [ + "string", + "null" + ], + "default": null + } + } + }, + "Vcs": { + "description": "Version-control systems a package's source repository can live on.\n\nzed-pkg is VCS-agnostic by design: what gets installed is always a\nregistry artifact, and the VCS is where provenance (tags) is anchored.\nAuthors must create a matching tag on their declared backing repo\n(GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure\nDevOps, CodeCommit, Radicle, or self-hosted) before publishing.\n\n`jj` and Sapling are git-compatible and push to git remotes, so their\nprovenance is verified through git tags.", + "type": "string", + "enum": [ + "git", + "hg", + "jj", + "sapling", + "fossil", + "pijul" + ] + } + } +} diff --git a/schemas/package-metadata.json b/schemas/package-metadata.json new file mode 100644 index 0000000..5cd8bb2 --- /dev/null +++ b/schemas/package-metadata.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PackageMetadata", + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "latest": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "repo_url": { + "type": "string" + }, + "vcs": { + "$ref": "#/$defs/Vcs" + }, + "versions": { + "description": "All published, non-yanked versions, newest first.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "org", + "name", + "vcs", + "repo_url", + "versions" + ], + "$defs": { + "Vcs": { + "description": "Version-control systems a package's source repository can live on.\n\nzed-pkg is VCS-agnostic by design: what gets installed is always a\nregistry artifact, and the VCS is where provenance (tags) is anchored.\nAuthors must create a matching tag on their declared backing repo\n(GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure\nDevOps, CodeCommit, Radicle, or self-hosted) before publishing.\n\n`jj` and Sapling are git-compatible and push to git remotes, so their\nprovenance is verified through git tags.", + "type": "string", + "enum": [ + "git", + "hg", + "jj", + "sapling", + "fossil", + "pijul" + ] + } + } +} diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json new file mode 100644 index 0000000..1aad6af --- /dev/null +++ b/schemas/publish-meta.json @@ -0,0 +1,200 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PublishMeta", + "description": "JSON half of the multipart publish request; the artifact bytes travel in\nthe [`PUBLISH_ARTIFACT_FIELD`] part.", + "type": "object", + "properties": { + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "manifest": { + "$ref": "#/$defs/Manifest" + }, + "sha256": { + "description": "Client-computed sha256 of the uploaded archive; the server recomputes\nand rejects on mismatch.", + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "vcs_commit": { + "type": [ + "string", + "null" + ] + }, + "vcs_tag": { + "description": "Tag that exists in the source repository for this version.", + "type": "string" + } + }, + "required": [ + "manifest", + "vcs_tag", + "sha256", + "size" + ], + "$defs": { + "ArtifactFormat": { + "description": "On-the-wire formats for published package artifacts.", + "type": "string", + "enum": [ + "tar.gz", + "zip" + ] + }, + "Manifest": { + "description": "The `.zpkg.toml` manifest at the root of every package repository.\nTOML only — never YAML or JSON.\n\n```toml\n[package]\norg = \"acme\"\nname = \"http-kit\"\nversion = \"1.2.0\"\ndescription = \"Tiny HTTP helpers\"\nlicense = \"MIT\"\n\n[package.repository]\nvcs = \"git\"\nurl = \"https://github.com/acme/http-kit\"\n\n[dependencies]\n\"acme/logkit\" = \"^0.3\"\n\n[publish]\nexclude = [\"benches/**\"]\nsmoke_test = \"sh scripts/smoke.sh\"\n```", + "type": "object", + "properties": { + "dependencies": { + "description": "Dependencies keyed by `org/name`, valued by a semver requirement.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "package": { + "$ref": "#/$defs/PackageSection" + }, + "publish": { + "$ref": "#/$defs/PublishSection", + "default": { + "exclude": [], + "include_readme": false, + "smoke_test": null, + "tag_format": "v{version}" + } + }, + "scripts": { + "$ref": "#/$defs/ScriptsSection", + "default": { + "test": null + } + } + }, + "required": [ + "package" + ] + }, + "PackageSection": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "license": { + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Package name, unique within the org. Lowercase slug.", + "type": "string" + }, + "org": { + "description": "Namespace the package is published under. Lowercase slug.", + "type": "string" + }, + "repository": { + "$ref": "#/$defs/RepositorySection" + }, + "version": { + "description": "Semver version of this package.", + "type": "string" + } + }, + "required": [ + "org", + "name", + "version", + "repository" + ] + }, + "PublishSection": { + "type": "object", + "properties": { + "exclude": { + "description": "Extra glob patterns to exclude on top of the built-in defaults.", + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "include_readme": { + "description": "Keep README files in the published artifact (stripped by default).", + "type": "boolean", + "default": false + }, + "smoke_test": { + "description": "Command run by `zed test-local` inside a throwaway consumer project\nthat has this package installed the same way a real consumer would.", + "type": [ + "string", + "null" + ], + "default": null + }, + "tag_format": { + "description": "VCS tag template that must exist and point at the published commit.\n`{version}` is substituted with `package.version`.", + "type": "string", + "default": "v{version}" + } + } + }, + "RepositorySection": { + "description": "Where the package's source of truth lives. Any Git or Mercurial host\nworks: GitHub, GitLab, Bitbucket, or a self-hosted server.", + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "vcs": { + "$ref": "#/$defs/Vcs", + "default": "git" + } + }, + "required": [ + "url" + ] + }, + "ScriptsSection": { + "type": "object", + "properties": { + "test": { + "description": "Test command run from the repository (not from published artifacts;\ntests are stripped at publish time).", + "type": [ + "string", + "null" + ], + "default": null + } + } + }, + "Vcs": { + "description": "Version-control systems a package's source repository can live on.\n\nzed-pkg is VCS-agnostic by design: what gets installed is always a\nregistry artifact, and the VCS is where provenance (tags) is anchored.\nAuthors must create a matching tag on their declared backing repo\n(GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure\nDevOps, CodeCommit, Radicle, or self-hosted) before publishing.\n\n`jj` and Sapling are git-compatible and push to git remotes, so their\nprovenance is verified through git tags.", + "type": "string", + "enum": [ + "git", + "hg", + "jj", + "sapling", + "fossil", + "pijul" + ] + } + } +} diff --git a/schemas/publish-response.json b/schemas/publish-response.json new file mode 100644 index 0000000..30d0d41 --- /dev/null +++ b/schemas/publish-response.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PublishResponse", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "sha256": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "org", + "name", + "version", + "sha256" + ] +} diff --git a/schemas/search-response.json b/schemas/search-response.json new file mode 100644 index 0000000..38e6191 --- /dev/null +++ b/schemas/search-response.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SearchResponse", + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/PackageSummary" + } + }, + "query": { + "type": "string" + } + }, + "required": [ + "query", + "items" + ], + "$defs": { + "PackageSummary": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "latest": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + } + }, + "required": [ + "org", + "name" + ] + } + } +} diff --git a/schemas/version-metadata.json b/schemas/version-metadata.json new file mode 100644 index 0000000..f458cb7 --- /dev/null +++ b/schemas/version-metadata.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "VersionMetadata", + "type": "object", + "properties": { + "download_url": { + "description": "Absolute or registry-relative URL the artifact can be fetched from.", + "type": "string" + }, + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "published_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "sha256": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "vcs_commit": { + "type": [ + "string", + "null" + ] + }, + "vcs_tag": { + "type": "string" + }, + "version": { + "type": "string" + }, + "yanked": { + "type": "boolean", + "default": false + } + }, + "required": [ + "org", + "name", + "version", + "sha256", + "size", + "vcs_tag", + "download_url", + "published_at" + ], + "$defs": { + "ArtifactFormat": { + "description": "On-the-wire formats for published package artifacts.", + "type": "string", + "enum": [ + "tar.gz", + "zip" + ] + } + } +} diff --git a/src/artifact.rs b/src/artifact.rs new file mode 100644 index 0000000..180d0e7 --- /dev/null +++ b/src/artifact.rs @@ -0,0 +1,36 @@ +use std::fmt; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// On-the-wire formats for published package artifacts. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub enum ArtifactFormat { + #[default] + #[serde(rename = "tar.gz")] + TarGz, + #[serde(rename = "zip")] + Zip, +} + +impl ArtifactFormat { + pub fn extension(&self) -> &'static str { + match self { + ArtifactFormat::TarGz => "tar.gz", + ArtifactFormat::Zip => "zip", + } + } + + pub fn content_type(&self) -> &'static str { + match self { + ArtifactFormat::TarGz => "application/gzip", + ArtifactFormat::Zip => "application/zip", + } + } +} + +impl fmt::Display for ArtifactFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.extension()) + } +} diff --git a/src/excludes.rs b/src/excludes.rs new file mode 100644 index 0000000..31f900d --- /dev/null +++ b/src/excludes.rs @@ -0,0 +1,55 @@ +//! Publish-time exclusion rules. +//! +//! zed-pkg's core disk-space promise: published artifacts carry what runs, +//! not what develops. Tests, CI configuration, VCS metadata, and READMEs +//! are stripped by default; license files are always kept. + +/// Glob patterns excluded from every published artifact by default. +/// Matching is case-insensitive in the CLI. +pub const DEFAULT_EXCLUDES: &[&str] = &[ + ".git/**", + ".hg/**", + ".svn/**", + "tests/**", + "test/**", + "spec/**", + "src/test/**", + "**/__tests__/**", + "**/*.test.*", + "**/*.spec.*", + "**/*_test.go", + "**/*_test.py", + ".github/**", + ".gitlab/**", + ".gitlab-ci.yml", + "bitbucket-pipelines.yml", + ".circleci/**", + ".travis.yml", + "azure-pipelines.yml", + "README*", + "CHANGELOG*", + ".zedignore", + ".zed/**", + ".zpkg.lock", + "zed_modules/**", +]; + +/// Patterns that are always kept, even when an exclude matches them. +/// Shipping license texts with artifacts is non-negotiable. +pub const ALWAYS_INCLUDE: &[&str] = &["LICENSE*", "LICENCE*", "COPYING*", "NOTICE*", ".zpkg.toml"]; + +/// The effective exclusion list for a package: built-in defaults (minus +/// README patterns when `include_readme` is set), plus the manifest's own +/// `publish.exclude` globs. `.zedignore` lines are appended by the CLI on +/// top of this. +pub fn effective_excludes(extra: &[String], include_readme: bool) -> Vec { + let mut out: Vec = Vec::new(); + for pattern in DEFAULT_EXCLUDES { + if include_readme && pattern.starts_with("README") { + continue; + } + out.push((*pattern).to_string()); + } + out.extend(extra.iter().cloned()); + out +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..2c69d94 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,20 @@ +//! Core interface definitions for the zed-pkg universal package manager. +//! +//! This crate is the single source of truth for the on-disk formats +//! (`zed.toml`, `zed.lock`, store layout), the registry REST API DTOs, and +//! the publish-time exclusion rules. It is consumed by `zed-cli`, +//! `zed-api-server`, `zed-web-server`, and (via generated JSON Schemas in +//! `schemas/`) by the non-Rust client libraries in `zed-clients`. + +pub mod artifact; +pub mod excludes; +pub mod lockfile; +pub mod manifest; +pub mod paths; +pub mod registry; +pub mod vcs; + +pub use artifact::ArtifactFormat; +pub use lockfile::{LockedPackage, Lockfile}; +pub use manifest::{Manifest, ManifestError}; +pub use vcs::Vcs; diff --git a/src/lockfile.rs b/src/lockfile.rs new file mode 100644 index 0000000..2807de0 --- /dev/null +++ b/src/lockfile.rs @@ -0,0 +1,94 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::artifact::ArtifactFormat; + +/// The `zed.lock` file written next to `zed.toml` after resolution. +/// +/// Serialized as TOML with one `[[package]]` table per locked package, +/// Cargo.lock-style. Every entry pins the exact artifact hash and the VCS +/// tag it was published from, so installs are reproducible and every +/// artifact is traceable back to source. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Lockfile { + pub version: u32, + #[serde(default, rename = "package", skip_serializing_if = "Vec::is_empty")] + pub packages: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct LockedPackage { + pub org: String, + pub name: String, + pub version: String, + /// Hex sha256 of the artifact archive; also its store address. + pub sha256: String, + /// Artifact size in bytes. + pub size: u64, + #[serde(default)] + pub format: ArtifactFormat, + /// VCS tag the version was published from, e.g. `v1.2.0`. + pub vcs_tag: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vcs_commit: Option, + /// Base URL of the registry the artifact was resolved from. + pub source: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum LockfileError { + #[error("lockfile toml error: {0}")] + Toml(String), + #[error("unsupported lockfile version {0} (this build supports {1})")] + UnsupportedVersion(u32, u32), +} + +impl Default for Lockfile { + fn default() -> Self { + Self { + version: Self::CURRENT_VERSION, + packages: Vec::new(), + } + } +} + +impl Lockfile { + pub const CURRENT_VERSION: u32 = 1; + + pub fn parse(input: &str) -> Result { + let lockfile: Lockfile = + toml::from_str(input).map_err(|e| LockfileError::Toml(e.to_string()))?; + if lockfile.version > Self::CURRENT_VERSION { + return Err(LockfileError::UnsupportedVersion( + lockfile.version, + Self::CURRENT_VERSION, + )); + } + Ok(lockfile) + } + + pub fn to_toml_string(&self) -> Result { + toml::to_string_pretty(self).map_err(|e| LockfileError::Toml(e.to_string())) + } + + pub fn find(&self, org: &str, name: &str) -> Option<&LockedPackage> { + self.packages + .iter() + .find(|p| p.org == org && p.name == name) + } + + /// Insert or replace the entry for `org/name`, keeping entries sorted. + pub fn upsert(&mut self, package: LockedPackage) { + self.packages + .retain(|p| !(p.org == package.org && p.name == package.name)); + self.packages.push(package); + self.packages + .sort_by(|a, b| (&a.org, &a.name).cmp(&(&b.org, &b.name))); + } +} + +impl LockedPackage { + pub fn full_name(&self) -> String { + format!("{}/{}", self.org, self.name) + } +} diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..09423bf --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,180 @@ +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::vcs::Vcs; + +/// The `.zpkg.toml` manifest at the root of every package repository. +/// TOML only — never YAML or JSON. +/// +/// ```toml +/// [package] +/// org = "acme" +/// name = "http-kit" +/// version = "1.2.0" +/// description = "Tiny HTTP helpers" +/// license = "MIT" +/// +/// [package.repository] +/// vcs = "git" +/// url = "https://github.com/acme/http-kit" +/// +/// [dependencies] +/// "acme/logkit" = "^0.3" +/// +/// [publish] +/// exclude = ["benches/**"] +/// smoke_test = "sh scripts/smoke.sh" +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Manifest { + pub package: PackageSection, + /// Dependencies keyed by `org/name`, valued by a semver requirement. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub dependencies: BTreeMap, + #[serde(default)] + pub publish: PublishSection, + #[serde(default)] + pub scripts: ScriptsSection, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PackageSection { + /// Namespace the package is published under. Lowercase slug. + pub org: String, + /// Package name, unique within the org. Lowercase slug. + pub name: String, + /// Semver version of this package. + pub version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub license: Option, + pub repository: RepositorySection, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub keywords: Vec, +} + +/// Where the package's source of truth lives. Any Git or Mercurial host +/// works: GitHub, GitLab, Bitbucket, or a self-hosted server. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RepositorySection { + #[serde(default)] + pub vcs: Vcs, + pub url: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct PublishSection { + /// Extra glob patterns to exclude on top of the built-in defaults. + pub exclude: Vec, + /// Keep README files in the published artifact (stripped by default). + pub include_readme: bool, + /// Command run by `zed test-local` inside a throwaway consumer project + /// that has this package installed the same way a real consumer would. + pub smoke_test: Option, + /// VCS tag template that must exist and point at the published commit. + /// `{version}` is substituted with `package.version`. + pub tag_format: String, +} + +impl Default for PublishSection { + fn default() -> Self { + Self { + exclude: Vec::new(), + include_readme: false, + smoke_test: None, + tag_format: "v{version}".to_string(), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct ScriptsSection { + /// Test command run from the repository (not from published artifacts; + /// tests are stripped at publish time). + pub test: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum ManifestError { + #[error("invalid org slug `{0}`: must match [a-z0-9][a-z0-9-]*[a-z0-9]")] + InvalidOrg(String), + #[error("invalid package name `{0}`: must match [a-z0-9][a-z0-9-]*[a-z0-9]")] + InvalidName(String), + #[error("invalid version `{0}`: {1}")] + InvalidVersion(String, String), + #[error("invalid dependency key `{0}`: expected `org/name`")] + InvalidDependencyKey(String), + #[error("invalid requirement `{1}` for dependency `{0}`: {2}")] + InvalidDependencyReq(String, String, String), + #[error("manifest toml error: {0}")] + Toml(String), +} + +/// True for the lowercase slugs zed-pkg accepts as org and package names. +pub fn is_slug(s: &str) -> bool { + !s.is_empty() + && !s.starts_with('-') + && !s.ends_with('-') + && s.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +impl Manifest { + /// Parse and validate a `.zpkg.toml` document. + pub fn parse(input: &str) -> Result { + let manifest: Manifest = + toml::from_str(input).map_err(|e| ManifestError::Toml(e.to_string()))?; + manifest.validate()?; + Ok(manifest) + } + + pub fn to_toml_string(&self) -> Result { + toml::to_string_pretty(self).map_err(|e| ManifestError::Toml(e.to_string())) + } + + pub fn validate(&self) -> Result<(), ManifestError> { + if !is_slug(&self.package.org) { + return Err(ManifestError::InvalidOrg(self.package.org.clone())); + } + if !is_slug(&self.package.name) { + return Err(ManifestError::InvalidName(self.package.name.clone())); + } + semver::Version::parse(&self.package.version).map_err(|e| { + ManifestError::InvalidVersion(self.package.version.clone(), e.to_string()) + })?; + for (key, req) in &self.dependencies { + let mut parts = key.splitn(2, '/'); + let (org, name) = (parts.next().unwrap_or(""), parts.next().unwrap_or("")); + if !is_slug(org) || !is_slug(name) { + return Err(ManifestError::InvalidDependencyKey(key.clone())); + } + semver::VersionReq::parse(req).map_err(|e| { + ManifestError::InvalidDependencyReq(key.clone(), req.clone(), e.to_string()) + })?; + } + Ok(()) + } + + /// `org/name`, the canonical package identifier. + pub fn full_name(&self) -> String { + format!("{}/{}", self.package.org, self.package.name) + } + + /// Parsed semver version. Only call after `validate()` has passed. + pub fn version(&self) -> Result { + semver::Version::parse(&self.package.version) + .map_err(|e| ManifestError::InvalidVersion(self.package.version.clone(), e.to_string())) + } + + /// The VCS tag that must exist for this version, e.g. `v1.2.0`. + pub fn vcs_tag(&self) -> String { + self.publish + .tag_format + .replace("{version}", &self.package.version) + } +} diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..e1f80d1 --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,44 @@ +//! Filesystem layout conventions shared by the CLI, servers, and docs. +//! +//! The global store lives under `$HOME/.zed-pkg` (overridable with +//! `ZED_PKG_HOME`). Projects never copy packages by default: installed +//! versions are extracted once into the content-addressed store and +//! symlinked into the project's `zed_modules/` directory, pnpm-style. +//! In containers (`--install-mode copy`) the symlink step is replaced by a +//! copy so image layers stay self-contained. + +/// Name of the per-user global directory, resolved against `$HOME`. +pub const ZED_HOME_DIR_NAME: &str = ".zed-pkg"; + +/// Directory inside a project where installed packages appear +/// (`zed_modules//`). +pub const MODULES_DIR: &str = "zed_modules"; + +/// Package manifest file name, at the repository root. TOML only. +pub const MANIFEST_FILE: &str = ".zpkg.toml"; + +/// Lockfile name, written next to the manifest. +pub const LOCKFILE_FILE: &str = ".zpkg.lock"; + +/// Optional file with extra publish-exclusion globs, one per line. +pub const IGNORE_FILE: &str = ".zedignore"; + +/// Store schema version segment; bump when the on-disk layout changes. +pub const STORE_VERSION: &str = "v1"; + +/// Directory inside an extracted store entry that holds the package files. +pub const STORE_PKG_DIR: &str = "pkg"; + +/// Root directory inside every artifact archive under which package files +/// are placed (like npm's `package/` prefix). +pub const ARCHIVE_ROOT: &str = "pkg"; + +/// Where `zed pack` writes artifacts, relative to the project root. +pub const PACK_OUT_DIR: &str = ".zed/pack"; + +/// Store entry path for an artifact, relative to the zed home directory, +/// e.g. `store/v1/ab/abcdef.../`. +pub fn store_entry_rel(sha256: &str) -> String { + let prefix = sha256.get(..2).unwrap_or("xx"); + format!("store/{STORE_VERSION}/{prefix}/{sha256}") +} diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..bac6921 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,156 @@ +//! REST API contract between clients (CLI, language SDKs, web UI) and +//! `zed-api-server`. Paths are built by the helpers here so every consumer +//! agrees on the URL scheme; DTOs are the JSON bodies. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::artifact::ArtifactFormat; +use crate::manifest::Manifest; +use crate::vcs::Vcs; + +/// Default public registry (production host: zpkg.tech). Override with +/// `--registry` / `ZED_PKG_REGISTRY`; self-hosted deployments point this at +/// their own `zed-api-server`. +pub const DEFAULT_REGISTRY_URL: &str = "https://registry.zpkg.tech"; + +pub const API_V1: &str = "/v1"; + +/// Multipart field carrying the JSON-encoded [`PublishMeta`]. +pub const PUBLISH_META_FIELD: &str = "meta"; +/// Multipart field carrying the artifact archive bytes. +pub const PUBLISH_ARTIFACT_FIELD: &str = "artifact"; + +/// `GET` — package metadata and version list. +pub fn package_path(org: &str, name: &str) -> String { + format!("{API_V1}/packages/{org}/{name}") +} + +/// `GET` — metadata for one published version. +/// `PUT` (multipart, bearer token) — publish this version. +pub fn version_path(org: &str, name: &str, version: &str) -> String { + format!("{API_V1}/packages/{org}/{name}/versions/{version}") +} + +/// `GET` — download (or get redirected to) the artifact with this sha256. +pub fn artifact_path(sha256: &str) -> String { + format!("{API_V1}/artifacts/{sha256}") +} + +/// `GET ?q=` — search packages. +pub fn search_path() -> String { + format!("{API_V1}/search") +} + +/// `GET` — serve one file out of a published artifact, unpkg-style +/// (`/v1/files/acme/http-kit/1.2.0/dist/style.css`). Lets the web consume +/// package contents directly from the edge without installing. +pub fn file_path(org: &str, name: &str, version: &str, path: &str) -> String { + format!("{API_V1}/files/{org}/{name}/{version}/{path}") +} + +/// `POST` (bearer token) — claim an org namespace. +pub fn orgs_path() -> String { + format!("{API_V1}/orgs") +} + +/// `GET` — liveness probe. +pub fn healthz_path() -> String { + "/healthz".to_string() +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PackageSummary { + pub org: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PackageMetadata { + pub org: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub vcs: Vcs, + pub repo_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest: Option, + /// All published, non-yanked versions, newest first. + pub versions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct VersionMetadata { + pub org: String, + pub name: String, + pub version: String, + pub sha256: String, + pub size: u64, + #[serde(default)] + pub format: ArtifactFormat, + pub vcs_tag: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vcs_commit: Option, + /// Absolute or registry-relative URL the artifact can be fetched from. + pub download_url: String, + /// RFC 3339 timestamp. + pub published_at: String, + #[serde(default)] + pub yanked: bool, +} + +/// JSON half of the multipart publish request; the artifact bytes travel in +/// the [`PUBLISH_ARTIFACT_FIELD`] part. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PublishMeta { + pub manifest: Manifest, + /// Tag that exists in the source repository for this version. + pub vcs_tag: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vcs_commit: Option, + /// Client-computed sha256 of the uploaded archive; the server recomputes + /// and rejects on mismatch. + pub sha256: String, + pub size: u64, + #[serde(default)] + pub format: ArtifactFormat, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PublishResponse { + pub org: String, + pub name: String, + pub version: String, + pub sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ClaimOrgRequest { + pub slug: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ClaimOrgResponse { + pub slug: String, + /// False when the caller already owned the org. + pub created: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SearchResponse { + pub query: String, + pub items: Vec, +} + +/// Error body returned with any non-2xx status. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ApiError { + /// Stable machine-readable code, e.g. `not_found`, `sha256_mismatch`, + /// `tag_not_found`, `unauthorized`, `org_taken`. + pub code: String, + pub message: String, +} diff --git a/src/vcs.rs b/src/vcs.rs new file mode 100644 index 0000000..d7b337f --- /dev/null +++ b/src/vcs.rs @@ -0,0 +1,72 @@ +use std::fmt; +use std::str::FromStr; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Version-control systems a package's source repository can live on. +/// +/// zed-pkg is VCS-agnostic by design: what gets installed is always a +/// registry artifact, and the VCS is where provenance (tags) is anchored. +/// Authors must create a matching tag on their declared backing repo +/// (GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure +/// DevOps, CodeCommit, Radicle, or self-hosted) before publishing. +/// +/// `jj` and Sapling are git-compatible and push to git remotes, so their +/// provenance is verified through git tags. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum Vcs { + #[default] + Git, + Hg, + Jj, + Sapling, + Fossil, + Pijul, +} + +impl Vcs { + pub fn as_str(&self) -> &'static str { + match self { + Vcs::Git => "git", + Vcs::Hg => "hg", + Vcs::Jj => "jj", + Vcs::Sapling => "sapling", + Vcs::Fossil => "fossil", + Vcs::Pijul => "pijul", + } + } + + /// True for systems whose tags live in git ref namespaces (git itself + /// plus the git-compatible layers), so git-based tag verification works. + pub fn uses_git_tags(&self) -> bool { + matches!(self, Vcs::Git | Vcs::Jj | Vcs::Sapling) + } +} + +impl fmt::Display for Vcs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("unknown vcs `{0}` (expected git, hg, jj, sapling, fossil, or pijul)")] +pub struct ParseVcsError(pub String); + +impl FromStr for Vcs { + type Err = ParseVcsError; + + fn from_str(s: &str) -> Result { + match s { + "git" => Ok(Vcs::Git), + "hg" => Ok(Vcs::Hg), + "jj" => Ok(Vcs::Jj), + "sapling" => Ok(Vcs::Sapling), + "fossil" => Ok(Vcs::Fossil), + "pijul" => Ok(Vcs::Pijul), + other => Err(ParseVcsError(other.to_string())), + } + } +} diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs new file mode 100644 index 0000000..5360209 --- /dev/null +++ b/tests/roundtrip.rs @@ -0,0 +1,108 @@ +use zed_interfaces::ArtifactFormat; +use zed_interfaces::excludes::{ALWAYS_INCLUDE, DEFAULT_EXCLUDES, effective_excludes}; +use zed_interfaces::lockfile::{LockedPackage, Lockfile}; +use zed_interfaces::manifest::{Manifest, ManifestError}; +use zed_interfaces::paths::store_entry_rel; +use zed_interfaces::vcs::Vcs; + +const SAMPLE: &str = r#" +[package] +org = "acme" +name = "http-kit" +version = "1.2.0" +description = "Tiny HTTP helpers" +license = "MIT" + +[package.repository] +vcs = "git" +url = "https://github.com/acme/http-kit" + +[dependencies] +"acme/logkit" = "^0.3" + +[publish] +exclude = ["benches/**"] +smoke_test = "sh scripts/smoke.sh" + +[scripts] +test = "make test" +"#; + +#[test] +fn manifest_roundtrip() { + let m = Manifest::parse(SAMPLE).unwrap(); + assert_eq!(m.full_name(), "acme/http-kit"); + assert_eq!(m.package.repository.vcs, Vcs::Git); + assert_eq!(m.vcs_tag(), "v1.2.0"); + assert_eq!(m.publish.exclude, vec!["benches/**".to_string()]); + assert!(!m.publish.include_readme); + assert_eq!(m.scripts.test.as_deref(), Some("make test")); + + let serialized = m.to_toml_string().unwrap(); + let reparsed = Manifest::parse(&serialized).unwrap(); + assert_eq!(m, reparsed); +} + +#[test] +fn manifest_rejects_bad_input() { + let bad_org = SAMPLE.replace("org = \"acme\"", "org = \"Acme!\""); + assert!(matches!( + Manifest::parse(&bad_org), + Err(ManifestError::InvalidOrg(_)) + )); + + let bad_dep = SAMPLE.replace("\"acme/logkit\"", "\"logkit\""); + assert!(matches!( + Manifest::parse(&bad_dep), + Err(ManifestError::InvalidDependencyKey(_)) + )); + + let bad_version = SAMPLE.replace("version = \"1.2.0\"", "version = \"not-semver\""); + assert!(matches!( + Manifest::parse(&bad_version), + Err(ManifestError::InvalidVersion(_, _)) + )); +} + +#[test] +fn lockfile_roundtrip() { + let mut lock = Lockfile::default(); + lock.upsert(LockedPackage { + org: "acme".into(), + name: "http-kit".into(), + version: "1.2.0".into(), + sha256: "ab".repeat(32), + size: 4096, + format: ArtifactFormat::TarGz, + vcs_tag: "v1.2.0".into(), + vcs_commit: Some("deadbeef".into()), + source: "https://registry.zed-pkg.dev".into(), + }); + + let text = lock.to_toml_string().unwrap(); + assert!(text.contains("[[package]]")); + let reparsed = Lockfile::parse(&text).unwrap(); + assert_eq!(lock, reparsed); + assert!(reparsed.find("acme", "http-kit").is_some()); +} + +#[test] +fn excludes_respect_include_readme() { + let with_readme_stripped = effective_excludes(&[], false); + assert!(with_readme_stripped.iter().any(|p| p == "README*")); + assert!(with_readme_stripped.iter().any(|p| p == "tests/**")); + + let readme_kept = effective_excludes(&["extra/**".to_string()], true); + assert!(!readme_kept.iter().any(|p| p == "README*")); + assert!(readme_kept.iter().any(|p| p == "extra/**")); + + assert!(DEFAULT_EXCLUDES.contains(&".github/**")); + assert!(ALWAYS_INCLUDE.contains(&"LICENSE*")); + assert!(ALWAYS_INCLUDE.contains(&".zpkg.toml")); +} + +#[test] +fn store_paths_are_sharded() { + let sha = "abcdef0123".to_string() + &"0".repeat(54); + assert_eq!(store_entry_rel(&sha), format!("store/v1/ab/{sha}")); +} From ee50b45bdb70ff49d1350169d6775d5737561e61 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Fri, 24 Jul 2026 08:47:37 -0500 Subject: [PATCH 02/71] Calendar + semver versioning, zip artifacts, and zed-sync interop types - zed-interfaces: VersionScheme (semver/calver/opaque), tolerant version parsing (v-prefix, Go +incompatible, PEP 440), npm-style range parsing, shared sync contract types (SyncWriteMode/ErrorPolicy/ChangeEvent), regenerated schemas. - zed-cli: resolve via the shared calver-aware resolver; deterministic zip artifacts alongside tar.gz with magic-byte extraction. - zed-api-server: persist and serve version_scheme (entity + migration + handlers). - zed-clients: dependency-free sync wire types bridge. Co-Authored-By: Claude Fable 5 --- examples/generate_schemas.rs | 5 + schemas/manifest.json | 26 ++- schemas/package-metadata.json | 24 +++ schemas/publish-meta.json | 26 ++- schemas/sync-change-event.json | 80 ++++++++ schemas/sync-error-policy.json | 12 ++ schemas/sync-write-mode.json | 13 ++ src/lib.rs | 3 + src/manifest.rs | 31 ++- src/registry.rs | 6 + src/sync.rs | 104 ++++++++++ src/version.rs | 361 +++++++++++++++++++++++++++++++++ 12 files changed, 682 insertions(+), 9 deletions(-) create mode 100644 schemas/sync-change-event.json create mode 100644 schemas/sync-error-policy.json create mode 100644 schemas/sync-write-mode.json create mode 100644 src/sync.rs create mode 100644 src/version.rs diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index 87cdbaf..f81dbdd 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -30,4 +30,9 @@ fn main() { write::(dir, "claim-org-request"); write::(dir, "claim-org-response"); write::(dir, "api-error"); + + // Sync contract types shared with zed-sync + zed-clients. + write::(dir, "sync-change-event"); + write::(dir, "sync-write-mode"); + write::(dir, "sync-error-policy"); } diff --git a/schemas/manifest.json b/schemas/manifest.json index 4cbe51e..36342c3 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -67,8 +67,12 @@ "$ref": "#/$defs/RepositorySection" }, "version": { - "description": "Semver version of this package.", + "description": "Version of this package, interpreted according to `version_scheme`\n(semver by default).", "type": "string" + }, + "version_scheme": { + "description": "How `version` (and published tags) should be interpreted. Semver by\ndefault; `calver` for calendar versions, `opaque` for arbitrary tags.", + "$ref": "#/$defs/VersionScheme" } }, "required": [ @@ -149,6 +153,26 @@ "fossil", "pijul" ] + }, + "VersionScheme": { + "description": "How a package's `version` string (and its published tags) should be\ninterpreted. Defaults to [`VersionScheme::Semver`], which covers the vast\nmajority of modern ecosystems.", + "oneOf": [ + { + "description": "Semantic Versioning. `package.version` must be valid semver; ranges\n(`^1.2`, `>=0.2 <0.5`) resolve to the max satisfying stable version.", + "type": "string", + "const": "semver" + }, + { + "description": "Calendar Versioning (`2026.07.24`, `2026.07`). Normalized to a semver\ntotal order (leading zeros dropped, padded to major.minor.patch) so the\nsame range algebra applies. See [`normalize_calver`].", + "type": "string", + "const": "calver" + }, + { + "description": "Arbitrary tags (`release-candidate-1`, `legacy-api`). No range algebra:\na requirement must match a published version **exactly**.", + "type": "string", + "const": "opaque" + } + ] } } } diff --git a/schemas/package-metadata.json b/schemas/package-metadata.json index 5cd8bb2..578460a 100644 --- a/schemas/package-metadata.json +++ b/schemas/package-metadata.json @@ -27,6 +27,10 @@ "vcs": { "$ref": "#/$defs/Vcs" }, + "version_scheme": { + "description": "How this package's versions should be interpreted (semver by default).", + "$ref": "#/$defs/VersionScheme" + }, "versions": { "description": "All published, non-yanked versions, newest first.", "type": "array", @@ -54,6 +58,26 @@ "fossil", "pijul" ] + }, + "VersionScheme": { + "description": "How a package's `version` string (and its published tags) should be\ninterpreted. Defaults to [`VersionScheme::Semver`], which covers the vast\nmajority of modern ecosystems.", + "oneOf": [ + { + "description": "Semantic Versioning. `package.version` must be valid semver; ranges\n(`^1.2`, `>=0.2 <0.5`) resolve to the max satisfying stable version.", + "type": "string", + "const": "semver" + }, + { + "description": "Calendar Versioning (`2026.07.24`, `2026.07`). Normalized to a semver\ntotal order (leading zeros dropped, padded to major.minor.patch) so the\nsame range algebra applies. See [`normalize_calver`].", + "type": "string", + "const": "calver" + }, + { + "description": "Arbitrary tags (`release-candidate-1`, `legacy-api`). No range algebra:\na requirement must match a published version **exactly**.", + "type": "string", + "const": "opaque" + } + ] } } } diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index 1aad6af..5bfc03f 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -113,8 +113,12 @@ "$ref": "#/$defs/RepositorySection" }, "version": { - "description": "Semver version of this package.", + "description": "Version of this package, interpreted according to `version_scheme`\n(semver by default).", "type": "string" + }, + "version_scheme": { + "description": "How `version` (and published tags) should be interpreted. Semver by\ndefault; `calver` for calendar versions, `opaque` for arbitrary tags.", + "$ref": "#/$defs/VersionScheme" } }, "required": [ @@ -195,6 +199,26 @@ "fossil", "pijul" ] + }, + "VersionScheme": { + "description": "How a package's `version` string (and its published tags) should be\ninterpreted. Defaults to [`VersionScheme::Semver`], which covers the vast\nmajority of modern ecosystems.", + "oneOf": [ + { + "description": "Semantic Versioning. `package.version` must be valid semver; ranges\n(`^1.2`, `>=0.2 <0.5`) resolve to the max satisfying stable version.", + "type": "string", + "const": "semver" + }, + { + "description": "Calendar Versioning (`2026.07.24`, `2026.07`). Normalized to a semver\ntotal order (leading zeros dropped, padded to major.minor.patch) so the\nsame range algebra applies. See [`normalize_calver`].", + "type": "string", + "const": "calver" + }, + { + "description": "Arbitrary tags (`release-candidate-1`, `legacy-api`). No range algebra:\na requirement must match a published version **exactly**.", + "type": "string", + "const": "opaque" + } + ] } } } diff --git a/schemas/sync-change-event.json b/schemas/sync-change-event.json new file mode 100644 index 0000000..7c46c10 --- /dev/null +++ b/schemas/sync-change-event.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SyncChangeEvent", + "description": "The change envelope both transports decode to (mirrors zed-sync\n`ChangeEvent`). `version` is the ONLY reconciliation key; `sync_sequence`\nis the catch-up cursor and never feeds reconciliation.", + "type": "object", + "properties": { + "at_ms": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "id": { + "type": "string" + }, + "op": { + "$ref": "#/$defs/SyncOp" + }, + "row": true, + "sync_sequence": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "table": { + "type": "string" + }, + "version": { + "$ref": "#/$defs/Hlc" + }, + "write_key": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "table", + "op", + "id", + "version", + "at_ms" + ], + "$defs": { + "Hlc": { + "description": "Hybrid Logical Clock stamp — the reconciliation key. Total order:\n`wall_ms`, then `counter`, then `actor`.", + "type": "object", + "properties": { + "actor": { + "type": "string" + }, + "counter": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "wall_ms": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "wall_ms", + "counter", + "actor" + ] + }, + "SyncOp": { + "type": "string", + "enum": [ + "upsert", + "delete" + ] + } + } +} diff --git a/schemas/sync-error-policy.json b/schemas/sync-error-policy.json new file mode 100644 index 0000000..03afa98 --- /dev/null +++ b/schemas/sync-error-policy.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SyncErrorPolicy", + "description": "How write/flush errors are surfaced (mirrors zed-sync `ErrorPolicy`).", + "type": "string", + "enum": [ + "throw_only", + "emit_only", + "throw_and_emit", + "silent" + ] +} diff --git a/schemas/sync-write-mode.json b/schemas/sync-write-mode.json new file mode 100644 index 0000000..f2f2fe7 --- /dev/null +++ b/schemas/sync-write-mode.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SyncWriteMode", + "description": "Per-write optimism level (mirrors zed-sync `WriteMode`). Enum, not a\nboolean: a write names its exact behavior.", + "type": "string", + "enum": [ + "local_only", + "optimistic_queue", + "optimistic_await_ack", + "server_first", + "server_only" + ] +} diff --git a/src/lib.rs b/src/lib.rs index 2c69d94..b8b2246 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,9 +12,12 @@ pub mod lockfile; pub mod manifest; pub mod paths; pub mod registry; +pub mod sync; pub mod vcs; +pub mod version; pub use artifact::ArtifactFormat; pub use lockfile::{LockedPackage, Lockfile}; pub use manifest::{Manifest, ManifestError}; pub use vcs::Vcs; +pub use version::{Requirement, VersionScheme}; diff --git a/src/manifest.rs b/src/manifest.rs index 09423bf..425a0cd 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -4,6 +4,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::vcs::Vcs; +use crate::version::{Requirement, VersionScheme}; /// The `.zpkg.toml` manifest at the root of every package repository. /// TOML only — never YAML or JSON. @@ -45,8 +46,16 @@ pub struct PackageSection { pub org: String, /// Package name, unique within the org. Lowercase slug. pub name: String, - /// Semver version of this package. + /// Version of this package, interpreted according to `version_scheme` + /// (semver by default). pub version: String, + /// How `version` (and published tags) should be interpreted. Semver by + /// default; `calver` for calendar versions, `opaque` for arbitrary tags. + #[serde( + default, + skip_serializing_if = "crate::version::VersionScheme::is_default" + )] + pub version_scheme: VersionScheme, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -144,18 +153,26 @@ impl Manifest { if !is_slug(&self.package.name) { return Err(ManifestError::InvalidName(self.package.name.clone())); } - semver::Version::parse(&self.package.version).map_err(|e| { - ManifestError::InvalidVersion(self.package.version.clone(), e.to_string()) - })?; + self.package + .version_scheme + .validate_version(&self.package.version) + .map_err(|e| ManifestError::InvalidVersion(self.package.version.clone(), e))?; for (key, req) in &self.dependencies { let mut parts = key.splitn(2, '/'); let (org, name) = (parts.next().unwrap_or(""), parts.next().unwrap_or("")); if !is_slug(org) || !is_slug(name) { return Err(ManifestError::InvalidDependencyKey(key.clone())); } - semver::VersionReq::parse(req).map_err(|e| { - ManifestError::InvalidDependencyReq(key.clone(), req.clone(), e.to_string()) - })?; + // A requirement is either a semver range or an exact (opaque) tag; + // only an empty string is invalid. + if req.trim().is_empty() { + return Err(ManifestError::InvalidDependencyReq( + key.clone(), + req.clone(), + "requirement must not be empty".to_string(), + )); + } + let _ = Requirement::parse(req); } Ok(()) } diff --git a/src/registry.rs b/src/registry.rs index bac6921..44662b3 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -77,6 +77,12 @@ pub struct PackageMetadata { pub description: Option, pub vcs: Vcs, pub repo_url: String, + /// How this package's versions should be interpreted (semver by default). + #[serde( + default, + skip_serializing_if = "crate::version::VersionScheme::is_default" + )] + pub version_scheme: crate::version::VersionScheme, #[serde(default, skip_serializing_if = "Option::is_none")] pub latest: Option, /// All published, non-yanked versions, newest first. diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..fe31d71 --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,104 @@ +//! Shared sync contract types for cross-language consumers. +//! +//! [`zed-sync`](https://github.com/zed-pkg/zed-sync) is the behavioral source +//! of truth (its `protocol/*.schema.json` and `conformance.json` are +//! canonical). This module re-declares the *wire* enums and the change +//! envelope so Rust services and `zed-clients` share one set of types, and so +//! the generated JSON Schemas in `schemas/` stay aligned with zed-sync. Keep +//! these in lockstep with zed-sync's protocol. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Per-write optimism level (mirrors zed-sync `WriteMode`). Enum, not a +/// boolean: a write names its exact behavior. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SyncWriteMode { + LocalOnly, + #[default] + OptimisticQueue, + OptimisticAwaitAck, + ServerFirst, + ServerOnly, +} + +/// How write/flush errors are surfaced (mirrors zed-sync `ErrorPolicy`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SyncErrorPolicy { + ThrowOnly, + #[default] + EmitOnly, + ThrowAndEmit, + Silent, +} + +/// How a dirty-vs-newer-remote conflict resolves (mirrors zed-sync +/// `ConflictResolution`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SyncConflictResolution { + #[default] + ServerWins, + LastWriteWins, +} + +/// Hybrid Logical Clock stamp — the reconciliation key. Total order: +/// `wall_ms`, then `counter`, then `actor`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct Hlc { + pub wall_ms: u64, + pub counter: u32, + pub actor: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum SyncOp { + Upsert, + Delete, +} + +/// The change envelope both transports decode to (mirrors zed-sync +/// `ChangeEvent`). `version` is the ONLY reconciliation key; `sync_sequence` +/// is the catch-up cursor and never feeds reconciliation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SyncChangeEvent { + pub table: String, + pub op: SyncOp, + pub id: String, + pub version: Hlc, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub row: Option, + pub at_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub write_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sync_sequence: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_values_match_zed_sync_protocol() { + assert_eq!( + serde_json::to_string(&SyncWriteMode::OptimisticQueue).unwrap(), + "\"optimistic_queue\"" + ); + assert_eq!( + serde_json::to_string(&SyncErrorPolicy::EmitOnly).unwrap(), + "\"emit_only\"" + ); + assert_eq!( + serde_json::to_string(&SyncConflictResolution::ServerWins).unwrap(), + "\"server_wins\"" + ); + assert_eq!( + serde_json::to_string(&SyncOp::Upsert).unwrap(), + "\"upsert\"" + ); + } +} diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..aee5b26 --- /dev/null +++ b/src/version.rs @@ -0,0 +1,361 @@ +//! Polyglot version handling (zed-docs issue #3). +//! +//! zed-pkg standardizes on **semver** as its resolution algebra, but the +//! ecosystems it federates label releases inconsistently: Go tags `v1.2.3` +//! (and `+incompatible`), Python uses PEP 440 (`1.2.3rc1`, `.postN`), and many +//! tools use calendar versions (`2026.07.24`). A package declares its +//! [`VersionScheme`] so `package.version` is validated the right way, and the +//! resolver normalizes foreign tag spellings to a total order at the edges. +//! +//! The scheme is a property of the *published package*, not of the consumer's +//! requirement — a dependency on an opaque-versioned package is written as an +//! exact string, everything else as a semver range. + +use schemars::JsonSchema; +use semver::{Version, VersionReq}; +use serde::{Deserialize, Serialize}; + +/// How a package's `version` string (and its published tags) should be +/// interpreted. Defaults to [`VersionScheme::Semver`], which covers the vast +/// majority of modern ecosystems. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum VersionScheme { + /// Semantic Versioning. `package.version` must be valid semver; ranges + /// (`^1.2`, `>=0.2 <0.5`) resolve to the max satisfying stable version. + #[default] + Semver, + /// Calendar Versioning (`2026.07.24`, `2026.07`). Normalized to a semver + /// total order (leading zeros dropped, padded to major.minor.patch) so the + /// same range algebra applies. See [`normalize_calver`]. + Calver, + /// Arbitrary tags (`release-candidate-1`, `legacy-api`). No range algebra: + /// a requirement must match a published version **exactly**. + Opaque, +} + +impl VersionScheme { + pub fn as_str(&self) -> &'static str { + match self { + VersionScheme::Semver => "semver", + VersionScheme::Calver => "calver", + VersionScheme::Opaque => "opaque", + } + } + + /// True for the default scheme (semver); lets manifests omit the field. + pub fn is_default(&self) -> bool { + matches!(self, VersionScheme::Semver) + } + + /// Parse a scheme name, defaulting to semver for empty/unknown input so a + /// stored value can never wedge a read. + pub fn from_str_lenient(s: &str) -> VersionScheme { + match s { + "calver" => VersionScheme::Calver, + "opaque" => VersionScheme::Opaque, + _ => VersionScheme::Semver, + } + } + + /// Validate that `version` is a legal identity under this scheme. + pub fn validate_version(&self, version: &str) -> Result<(), String> { + match self { + VersionScheme::Semver => Version::parse(version) + .map(|_| ()) + .map_err(|e| e.to_string()), + VersionScheme::Calver => normalize_calver(version) + .map(|_| ()) + .ok_or_else(|| format!("`{version}` is not a calendar version (e.g. 2026.07.24)")), + VersionScheme::Opaque => { + if version.trim().is_empty() { + Err("opaque version must be non-empty".to_string()) + } else { + Ok(()) + } + } + } + } +} + +/// Normalize a calendar version to a semver string with a total order. +/// +/// `2026.07.24` → `2026.7.24`, `2026.07` → `2026.7.0`, `2026` → `2026.0.0`. +/// Leading zeros are stripped (semver forbids them); 1–3 dot-separated numeric +/// segments are accepted and padded to `major.minor.patch`. An optional +/// `-prerelease` suffix is preserved. Returns `None` if the shape isn't +/// calendar-like. +pub fn normalize_calver(raw: &str) -> Option { + let raw = raw.strip_prefix('v').unwrap_or(raw); + let (core, pre) = match raw.split_once('-') { + Some((core, pre)) if !pre.is_empty() => (core, Some(pre)), + _ => (raw, None), + }; + let mut parts: Vec = Vec::new(); + for seg in core.split('.') { + if seg.is_empty() || !seg.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + parts.push(seg.parse().ok()?); + } + if parts.is_empty() || parts.len() > 3 { + return None; + } + while parts.len() < 3 { + parts.push(0); + } + let core = format!("{}.{}.{}", parts[0], parts[1], parts[2]); + match pre { + Some(pre) => Some(format!("{core}-{pre}")), + None => Some(core), + } +} + +/// Drop Go's `+incompatible` (and any other build metadata) so a bare Go tag +/// like `v2.0.0+incompatible` parses. Build metadata is ignored by semver +/// precedence anyway, but we strip it for a clean identity. +fn normalize_go(raw: &str) -> Option { + let raw = raw.strip_prefix('v').unwrap_or(raw); + let core = raw.split('+').next().unwrap_or(raw); + Version::parse(core).ok().map(|v| v.to_string()) +} + +/// Map a PEP 440 pre/dev/post spelling onto a semver prerelease so ordering +/// stays sane: `1.2.3rc1` → `1.2.3-rc.1`, `1.2a1` → `1.2.0-a.1`, +/// `1.2.3.dev4` → `1.2.3-dev.4`. Post-releases (`1.2.3.post1`) become build +/// metadata (`1.2.3+post.1`) since semver has no "after the release" ordering. +/// Conservative by design — only the common shapes; returns `None` otherwise. +fn normalize_pep440(raw: &str) -> Option { + let raw = raw.strip_prefix('v').unwrap_or(raw).to_ascii_lowercase(); + // Split the numeric release from the first non-digit/non-dot marker. + let marker = raw.find(|c: char| c.is_ascii_alphabetic())?; + let (release, suffix) = raw.split_at(marker); + let release = release.trim_end_matches('.'); + let mut nums: Vec = Vec::new(); + for seg in release.split('.') { + if seg.is_empty() { + continue; + } + nums.push(seg.parse().ok()?); + } + while nums.len() < 3 { + nums.push(0); + } + if nums.len() > 3 { + return None; + } + let core = format!("{}.{}.{}", nums[0], nums[1], nums[2]); + + // suffix is e.g. "rc1", "a1", "b2", "dev4", "post1" (dots already gone). + let suffix = suffix.trim_start_matches('.'); + let split = suffix + .find(|c: char| c.is_ascii_digit()) + .unwrap_or(suffix.len()); + let (label, num) = suffix.split_at(split); + let num = if num.is_empty() { "0" } else { num }; + if num.bytes().any(|b| !b.is_ascii_digit()) { + return None; + } + let (label, kind) = match label { + "a" | "alpha" => ("alpha", '-'), + "b" | "beta" => ("beta", '-'), + "c" | "rc" | "pre" | "preview" => ("rc", '-'), + "dev" => ("dev", '-'), + "post" | "rev" | "r" => ("post", '+'), + _ => return None, + }; + Some(format!("{core}{kind}{label}.{num}")) +} + +/// Parse a version string to a comparable semver [`Version`], tolerating the +/// common foreign spellings (bare `v`, calendar versions, Go `+incompatible`, +/// a subset of PEP 440). Build metadata is dropped so `2.0.0+incompatible` and +/// `2.0.0` share one identity (semver precedence ignores build metadata). +/// Returns `None` for genuinely opaque strings. +pub fn parse_version(raw: &str) -> Option { + let mut version = parse_version_inner(raw)?; + version.build = semver::BuildMetadata::EMPTY; + Some(version) +} + +fn parse_version_inner(raw: &str) -> Option { + if let Ok(v) = Version::parse(raw) { + return Some(v); + } + if let Some(stripped) = raw.strip_prefix('v') { + if let Ok(v) = Version::parse(stripped) { + return Some(v); + } + } + if let Some(go) = normalize_go(raw) { + if let Ok(v) = Version::parse(&go) { + return Some(v); + } + } + if let Some(cal) = normalize_calver(raw) { + if let Ok(v) = Version::parse(&cal) { + return Some(v); + } + } + if let Some(pep) = normalize_pep440(raw) { + if let Ok(v) = Version::parse(&pep) { + return Some(v); + } + } + None +} + +/// A resolved dependency requirement. Semver and calendar requirements are +/// ranges; opaque requirements are exact strings. +#[derive(Debug, Clone, PartialEq)] +pub enum Requirement { + Range(VersionReq), + Exact(String), +} + +impl Requirement { + /// Interpret a manifest requirement string. A valid semver range becomes + /// [`Requirement::Range`]; anything else is an exact (opaque) match. + /// + /// npm-style whitespace-AND ranges (`>=1.2 <2`) are accepted by + /// normalizing the whitespace between comparators to the comma the semver + /// crate expects — but only as a fallback, so an opaque tag containing a + /// space is still treated as exact when the normalized form is not a valid + /// range. + pub fn parse(input: &str) -> Requirement { + if let Ok(req) = VersionReq::parse(input) { + return Requirement::Range(req); + } + if input.split_whitespace().count() > 1 { + let normalized = input.split_whitespace().collect::>().join(","); + if let Ok(req) = VersionReq::parse(&normalized) { + return Requirement::Range(req); + } + } + Requirement::Exact(input.to_string()) + } + + /// Does a published version string satisfy this requirement? + pub fn matches(&self, version: &str) -> bool { + match self { + Requirement::Range(req) => parse_version(version).is_some_and(|v| req.matches(&v)), + Requirement::Exact(want) => want == version, + } + } +} + +/// Pick the version string satisfying `req` from `versions`. +/// +/// For a range: the highest stable (non-prerelease) parseable version that +/// matches, returned in its **original** spelling so the store address and tag +/// stay faithful to what the publisher tagged. For an exact requirement: the +/// verbatim match if present. +pub fn resolve<'a>(req: &Requirement, versions: &'a [String]) -> Option<&'a str> { + match req { + Requirement::Exact(want) => versions + .iter() + .find(|v| v.as_str() == want) + .map(String::as_str), + Requirement::Range(range) => versions + .iter() + .filter_map(|v| parse_version(v).map(|parsed| (parsed, v))) + .filter(|(parsed, _)| parsed.pre.is_empty() && range.matches(parsed)) + .max_by(|a, b| a.0.cmp(&b.0)) + .map(|(_, original)| original.as_str()), + } +} + +/// Sort version strings newest-first, tolerant of foreign spellings. +/// Unparseable (opaque) versions sort after parseable ones, lexically. +pub fn sort_desc(versions: &mut [String]) { + versions.sort_by(|a, b| match (parse_version(a), parse_version(b)) { + (Some(va), Some(vb)) => vb.cmp(&va), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => b.cmp(a), + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn calver_normalizes_to_semver_order() { + assert_eq!(normalize_calver("2026.07.24").as_deref(), Some("2026.7.24")); + assert_eq!(normalize_calver("2026.07").as_deref(), Some("2026.7.0")); + assert_eq!(normalize_calver("2026").as_deref(), Some("2026.0.0")); + assert_eq!(normalize_calver("v2026.01.02").as_deref(), Some("2026.1.2")); + assert_eq!(normalize_calver("not-a-date"), None); + assert_eq!(normalize_calver("1.2.3.4"), None); + + // Total order holds across months and days. + let older = parse_version("2026.07.24").unwrap(); + let newer = parse_version("2026.08.01").unwrap(); + assert!(newer > older); + } + + #[test] + fn parse_version_tolerates_foreign_spellings() { + assert_eq!(parse_version("1.2.3").unwrap().to_string(), "1.2.3"); + assert_eq!(parse_version("v1.2.3").unwrap().to_string(), "1.2.3"); + // Go +incompatible is stripped to a clean identity. + assert_eq!( + parse_version("v2.0.0+incompatible").unwrap().to_string(), + "2.0.0" + ); + // PEP 440 pre-releases map onto semver prereleases (order preserved). + assert_eq!(parse_version("1.2.3rc1").unwrap().to_string(), "1.2.3-rc.1"); + assert_eq!(parse_version("1.2a1").unwrap().to_string(), "1.2.0-alpha.1"); + assert!(parse_version("1.2.3rc1").unwrap() < parse_version("1.2.3").unwrap()); + assert_eq!(parse_version("legacy-api"), None); + } + + #[test] + fn resolve_range_picks_max_satisfying_stable() { + let versions: Vec = ["1.0.0", "1.2.3", "1.3.0-alpha.1", "2.0.0"] + .iter() + .map(|s| s.to_string()) + .collect(); + let req = Requirement::parse("^1.2"); + assert_eq!(resolve(&req, &versions), Some("1.2.3")); + assert_eq!(resolve(&Requirement::parse("^3"), &versions), None); + } + + #[test] + fn resolve_calver_range() { + let versions: Vec = ["2025.12.01", "2026.07.24", "2026.08.01"] + .iter() + .map(|s| s.to_string()) + .collect(); + // A semver range over calendar versions: pick the newest in 2026. + let req = Requirement::parse(">=2026.0.0, <2027.0.0"); + assert!(matches!(req, Requirement::Range(_))); + assert_eq!(resolve(&req, &versions), Some("2026.08.01")); + } + + #[test] + fn opaque_requires_exact_match() { + let versions: Vec = ["release-candidate-1", "legacy-api", "beta"] + .iter() + .map(|s| s.to_string()) + .collect(); + let req = Requirement::parse("legacy-api"); + assert!(matches!(req, Requirement::Exact(_))); + assert_eq!(resolve(&req, &versions), Some("legacy-api")); + assert_eq!(resolve(&Requirement::parse("nope"), &versions), None); + } + + #[test] + fn scheme_validation() { + assert!(VersionScheme::Semver.validate_version("1.2.3").is_ok()); + assert!( + VersionScheme::Semver + .validate_version("2026.07.24") + .is_err() + ); + assert!(VersionScheme::Calver.validate_version("2026.07.24").is_ok()); + assert!(VersionScheme::Opaque.validate_version("legacy-api").is_ok()); + assert!(VersionScheme::Opaque.validate_version(" ").is_err()); + } +} From 56dc77a9937f8fb1214542102633fbcdb49eda92 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 10:32:52 -0500 Subject: [PATCH 03/71] Harden manifest + add build/bin/workspace/overrides, yank DTOs, sha/url validation - PackageSection gains bin, build ([build] command+outputs), build_dependencies, workspace ([workspace] members), and overrides.build sections for the polyglot build-cache, binary-hoisting, monorepo, and consumer-patch features. - Validate repository URL scheme, bin names/targets, and build outputs; reject range-shaped requirements that don't parse (no silent degrade to opaque tag). - Add is_sha256_hex + is_safe_relative_path guards for the CLI trust boundary. - Add build_entry_rel/current_platform (per-platform build cache) and BIN_DIR. - Yank REST DTOs (YankRequest/YankResponse) + yank_path helper. - Regenerate JSON schemas; fix stale zed.toml/zed.lock doc references. Co-Authored-By: Claude Fable 5 --- examples/generate_schemas.rs | 3 + schemas/lockfile.json | 2 +- schemas/manifest.json | 86 ++++++++++++++ schemas/publish-meta.json | 86 ++++++++++++++ schemas/sync-conflict-resolution.json | 10 ++ schemas/yank-request.json | 14 +++ schemas/yank-response.json | 25 ++++ src/lib.rs | 2 +- src/lockfile.rs | 2 +- src/manifest.rs | 161 +++++++++++++++++++++++++- src/paths.rs | 19 +++ src/registry.rs | 21 ++++ src/version.rs | 19 +++ 13 files changed, 443 insertions(+), 7 deletions(-) create mode 100644 schemas/sync-conflict-resolution.json create mode 100644 schemas/yank-request.json create mode 100644 schemas/yank-response.json diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index f81dbdd..0db10a7 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -29,10 +29,13 @@ fn main() { write::(dir, "search-response"); write::(dir, "claim-org-request"); write::(dir, "claim-org-response"); + write::(dir, "yank-request"); + write::(dir, "yank-response"); write::(dir, "api-error"); // Sync contract types shared with zed-sync + zed-clients. write::(dir, "sync-change-event"); write::(dir, "sync-write-mode"); write::(dir, "sync-error-policy"); + write::(dir, "sync-conflict-resolution"); } diff --git a/schemas/lockfile.json b/schemas/lockfile.json index c260ec9..b1b3d31 100644 --- a/schemas/lockfile.json +++ b/schemas/lockfile.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Lockfile", - "description": "The `zed.lock` file written next to `zed.toml` after resolution.\n\nSerialized as TOML with one `[[package]]` table per locked package,\nCargo.lock-style. Every entry pins the exact artifact hash and the VCS\ntag it was published from, so installs are reproducible and every\nartifact is traceable back to source.", + "description": "The `.zpkg.lock` file written next to `.zpkg.toml` after resolution.\n\nSerialized as TOML with one `[[package]]` table per locked package,\nCargo.lock-style. Every entry pins the exact artifact hash and the VCS\ntag it was published from, so installs are reproducible and every\nartifact is traceable back to source.", "type": "object", "properties": { "package": { diff --git a/schemas/manifest.json b/schemas/manifest.json index 36342c3..9da7bd4 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -4,6 +4,31 @@ "description": "The `.zpkg.toml` manifest at the root of every package repository.\nTOML only — never YAML or JSON.\n\n```toml\n[package]\norg = \"acme\"\nname = \"http-kit\"\nversion = \"1.2.0\"\ndescription = \"Tiny HTTP helpers\"\nlicense = \"MIT\"\n\n[package.repository]\nvcs = \"git\"\nurl = \"https://github.com/acme/http-kit\"\n\n[dependencies]\n\"acme/logkit\" = \"^0.3\"\n\n[publish]\nexclude = [\"benches/**\"]\nsmoke_test = \"sh scripts/smoke.sh\"\n```", "type": "object", "properties": { + "bin": { + "description": "Executables this package exposes, keyed by command name, valued by a\npath relative to the package root. Consumers get them hoisted into\n`zed_modules/.bin/` and runnable via `zed run `.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "build": { + "description": "Optional post-extract build step (compiled extensions, codegen).\nBuilds run in an isolated staging copy — never inside the immutable\nsource store — and results are cached per (sha256, platform).", + "anyOf": [ + { + "$ref": "#/$defs/BuildSection" + }, + { + "type": "null" + } + ] + }, + "build_dependencies": { + "description": "Dependencies needed only while running this package's `[build]`\ncommand. Never linked into a consumer's zed_modules/.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "dependencies": { "description": "Dependencies keyed by `org/name`, valued by a semver requirement.", "type": "object", @@ -11,6 +36,10 @@ "type": "string" } }, + "overrides": { + "description": "Consumer-side patches for dependencies (e.g. fixing a dependency's\nbroken or missing build command without waiting on upstream).", + "$ref": "#/$defs/OverridesSection" + }, "package": { "$ref": "#/$defs/PackageSection" }, @@ -28,12 +57,56 @@ "default": { "test": null } + }, + "workspace": { + "description": "Monorepo workspace configuration; only meaningful in a workspace\nroot manifest.", + "anyOf": [ + { + "$ref": "#/$defs/WorkspaceSection" + }, + { + "type": "null" + } + ] } }, "required": [ "package" ], "$defs": { + "BuildSection": { + "description": "A post-extract build step. `command` runs via `sh -c` inside a staging\ncopy of the package; `outputs` optionally narrows what is promoted into\nthe per-platform build cache (default: the whole staged tree).", + "type": "object", + "properties": { + "command": { + "description": "Command executed after extraction, e.g. `make` or `cargo build --release`.", + "type": "string" + }, + "outputs": { + "description": "Paths (relative to the package root) to keep from the staging build.\nEmpty means keep everything.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "command" + ] + }, + "OverridesSection": { + "description": "Consumer-side dependency patches, keyed by `org/name`.", + "type": "object", + "properties": { + "build": { + "description": "Replace or provide a dependency's `[build]` step.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/BuildSection" + } + } + } + }, "PackageSection": { "type": "object", "properties": { @@ -173,6 +246,19 @@ "const": "opaque" } ] + }, + "WorkspaceSection": { + "description": "Workspace configuration for monorepos: member globs relative to the\nworkspace root, e.g. `[\"packages/*\", \"apps/*\"]`. Dependencies that\nresolve to a member are symlinked straight to the member's source\ndirectory instead of going through the registry.", + "type": "object", + "properties": { + "members": { + "type": "array", + "default": [], + "items": { + "type": "string" + } + } + } } } } diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index 5bfc03f..ef0c909 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -46,10 +46,55 @@ "zip" ] }, + "BuildSection": { + "description": "A post-extract build step. `command` runs via `sh -c` inside a staging\ncopy of the package; `outputs` optionally narrows what is promoted into\nthe per-platform build cache (default: the whole staged tree).", + "type": "object", + "properties": { + "command": { + "description": "Command executed after extraction, e.g. `make` or `cargo build --release`.", + "type": "string" + }, + "outputs": { + "description": "Paths (relative to the package root) to keep from the staging build.\nEmpty means keep everything.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "command" + ] + }, "Manifest": { "description": "The `.zpkg.toml` manifest at the root of every package repository.\nTOML only — never YAML or JSON.\n\n```toml\n[package]\norg = \"acme\"\nname = \"http-kit\"\nversion = \"1.2.0\"\ndescription = \"Tiny HTTP helpers\"\nlicense = \"MIT\"\n\n[package.repository]\nvcs = \"git\"\nurl = \"https://github.com/acme/http-kit\"\n\n[dependencies]\n\"acme/logkit\" = \"^0.3\"\n\n[publish]\nexclude = [\"benches/**\"]\nsmoke_test = \"sh scripts/smoke.sh\"\n```", "type": "object", "properties": { + "bin": { + "description": "Executables this package exposes, keyed by command name, valued by a\npath relative to the package root. Consumers get them hoisted into\n`zed_modules/.bin/` and runnable via `zed run `.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "build": { + "description": "Optional post-extract build step (compiled extensions, codegen).\nBuilds run in an isolated staging copy — never inside the immutable\nsource store — and results are cached per (sha256, platform).", + "anyOf": [ + { + "$ref": "#/$defs/BuildSection" + }, + { + "type": "null" + } + ] + }, + "build_dependencies": { + "description": "Dependencies needed only while running this package's `[build]`\ncommand. Never linked into a consumer's zed_modules/.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "dependencies": { "description": "Dependencies keyed by `org/name`, valued by a semver requirement.", "type": "object", @@ -57,6 +102,10 @@ "type": "string" } }, + "overrides": { + "description": "Consumer-side patches for dependencies (e.g. fixing a dependency's\nbroken or missing build command without waiting on upstream).", + "$ref": "#/$defs/OverridesSection" + }, "package": { "$ref": "#/$defs/PackageSection" }, @@ -74,12 +123,36 @@ "default": { "test": null } + }, + "workspace": { + "description": "Monorepo workspace configuration; only meaningful in a workspace\nroot manifest.", + "anyOf": [ + { + "$ref": "#/$defs/WorkspaceSection" + }, + { + "type": "null" + } + ] } }, "required": [ "package" ] }, + "OverridesSection": { + "description": "Consumer-side dependency patches, keyed by `org/name`.", + "type": "object", + "properties": { + "build": { + "description": "Replace or provide a dependency's `[build]` step.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/BuildSection" + } + } + } + }, "PackageSection": { "type": "object", "properties": { @@ -219,6 +292,19 @@ "const": "opaque" } ] + }, + "WorkspaceSection": { + "description": "Workspace configuration for monorepos: member globs relative to the\nworkspace root, e.g. `[\"packages/*\", \"apps/*\"]`. Dependencies that\nresolve to a member are symlinked straight to the member's source\ndirectory instead of going through the registry.", + "type": "object", + "properties": { + "members": { + "type": "array", + "default": [], + "items": { + "type": "string" + } + } + } } } } diff --git a/schemas/sync-conflict-resolution.json b/schemas/sync-conflict-resolution.json new file mode 100644 index 0000000..620e053 --- /dev/null +++ b/schemas/sync-conflict-resolution.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SyncConflictResolution", + "description": "How a dirty-vs-newer-remote conflict resolves (mirrors zed-sync\n`ConflictResolution`).", + "type": "string", + "enum": [ + "server_wins", + "last_write_wins" + ] +} diff --git a/schemas/yank-request.json b/schemas/yank-request.json new file mode 100644 index 0000000..28324b5 --- /dev/null +++ b/schemas/yank-request.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "YankRequest", + "description": "Body for the yank route. `yanked: false` restores a yanked version.", + "type": "object", + "properties": { + "yanked": { + "type": "boolean" + } + }, + "required": [ + "yanked" + ] +} diff --git a/schemas/yank-response.json b/schemas/yank-response.json new file mode 100644 index 0000000..4dfb9f7 --- /dev/null +++ b/schemas/yank-response.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "YankResponse", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "version": { + "type": "string" + }, + "yanked": { + "type": "boolean" + } + }, + "required": [ + "org", + "name", + "version", + "yanked" + ] +} diff --git a/src/lib.rs b/src/lib.rs index b8b2246..dbd1146 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ //! Core interface definitions for the zed-pkg universal package manager. //! //! This crate is the single source of truth for the on-disk formats -//! (`zed.toml`, `zed.lock`, store layout), the registry REST API DTOs, and +//! (`.zpkg.toml`, `.zpkg.lock`, store layout), the registry REST API DTOs, and //! the publish-time exclusion rules. It is consumed by `zed-cli`, //! `zed-api-server`, `zed-web-server`, and (via generated JSON Schemas in //! `schemas/`) by the non-Rust client libraries in `zed-clients`. diff --git a/src/lockfile.rs b/src/lockfile.rs index 2807de0..23a01ae 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::artifact::ArtifactFormat; -/// The `zed.lock` file written next to `zed.toml` after resolution. +/// The `.zpkg.lock` file written next to `.zpkg.toml` after resolution. /// /// Serialized as TOML with one `[[package]]` table per locked package, /// Cargo.lock-style. Every entry pins the exact artifact hash and the VCS diff --git a/src/manifest.rs b/src/manifest.rs index 425a0cd..cf928a3 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -34,10 +34,32 @@ pub struct Manifest { /// Dependencies keyed by `org/name`, valued by a semver requirement. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub dependencies: BTreeMap, + /// Dependencies needed only while running this package's `[build]` + /// command. Never linked into a consumer's zed_modules/. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub build_dependencies: BTreeMap, #[serde(default)] pub publish: PublishSection, #[serde(default)] pub scripts: ScriptsSection, + /// Executables this package exposes, keyed by command name, valued by a + /// path relative to the package root. Consumers get them hoisted into + /// `zed_modules/.bin/` and runnable via `zed run `. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub bin: BTreeMap, + /// Optional post-extract build step (compiled extensions, codegen). + /// Builds run in an isolated staging copy — never inside the immutable + /// source store — and results are cached per (sha256, platform). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build: Option, + /// Monorepo workspace configuration; only meaningful in a workspace + /// root manifest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, + /// Consumer-side patches for dependencies (e.g. fixing a dependency's + /// broken or missing build command without waiting on upstream). + #[serde(default, skip_serializing_if = "OverridesSection::is_empty")] + pub overrides: OverridesSection, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -108,6 +130,44 @@ pub struct ScriptsSection { pub test: Option, } +/// A post-extract build step. `command` runs via `sh -c` inside a staging +/// copy of the package; `outputs` optionally narrows what is promoted into +/// the per-platform build cache (default: the whole staged tree). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct BuildSection { + /// Command executed after extraction, e.g. `make` or `cargo build --release`. + pub command: String, + /// Paths (relative to the package root) to keep from the staging build. + /// Empty means keep everything. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub outputs: Vec, +} + +/// Workspace configuration for monorepos: member globs relative to the +/// workspace root, e.g. `["packages/*", "apps/*"]`. Dependencies that +/// resolve to a member are symlinked straight to the member's source +/// directory instead of going through the registry. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct WorkspaceSection { + pub members: Vec, +} + +/// Consumer-side dependency patches, keyed by `org/name`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct OverridesSection { + /// Replace or provide a dependency's `[build]` step. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub build: BTreeMap, +} + +impl OverridesSection { + pub fn is_empty(&self) -> bool { + self.build.is_empty() + } +} + #[derive(Debug, thiserror::Error)] pub enum ManifestError { #[error("invalid org slug `{0}`: must match [a-z0-9][a-z0-9-]*[a-z0-9]")] @@ -120,6 +180,12 @@ pub enum ManifestError { InvalidDependencyKey(String), #[error("invalid requirement `{1}` for dependency `{0}`: {2}")] InvalidDependencyReq(String, String, String), + #[error("invalid repository url `{0}`: {1}")] + InvalidRepositoryUrl(String, String), + #[error("invalid bin entry `{0}`: {1}")] + InvalidBin(String, String), + #[error("invalid build section: {0}")] + InvalidBuild(String), #[error("manifest toml error: {0}")] Toml(String), } @@ -133,6 +199,39 @@ pub fn is_slug(s: &str) -> bool { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } +/// True for a 64-character lowercase-hex sha256 digest. Registry responses +/// and lockfiles feed digests into filesystem paths, so anything else is +/// rejected before it can reach the disk layer. +pub fn is_sha256_hex(s: &str) -> bool { + s.len() == 64 + && s.chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) +} + +/// True when `path` is a relative path with no `..` components — the only +/// shape allowed for manifest-declared paths (bin targets, build outputs). +pub fn is_safe_relative_path(path: &str) -> bool { + !path.is_empty() + && !path.starts_with('/') + && !path.starts_with('\\') + && !path.contains('\0') + // windows drive/UNC prefixes + && !(path.len() >= 2 && path.as_bytes()[1] == b':') + && !path + .split(['/', '\\']) + .any(|seg| seg == ".." || seg.is_empty()) +} + +fn is_allowed_repo_url(url: &str) -> bool { + // The repo URL renders as a link in registry UIs and is shelled to VCS + // tooling, so restrict it to the schemes those consumers expect. + ["https://", "http://", "ssh://", "git://", "git+ssh://"] + .iter() + .any(|scheme| url.starts_with(scheme)) + // scp-like git syntax: git@github.com:org/repo.git + || (url.contains('@') && url.contains(':') && !url.contains("://")) +} + impl Manifest { /// Parse and validate a `.zpkg.toml` document. pub fn parse(input: &str) -> Result { @@ -157,14 +256,19 @@ impl Manifest { .version_scheme .validate_version(&self.package.version) .map_err(|e| ManifestError::InvalidVersion(self.package.version.clone(), e))?; - for (key, req) in &self.dependencies { + if !is_allowed_repo_url(&self.package.repository.url) { + return Err(ManifestError::InvalidRepositoryUrl( + self.package.repository.url.clone(), + "expected an https/http/ssh/git URL or scp-like git syntax".to_string(), + )); + } + for (key, req) in self.dependencies.iter().chain(&self.build_dependencies) { let mut parts = key.splitn(2, '/'); let (org, name) = (parts.next().unwrap_or(""), parts.next().unwrap_or("")); if !is_slug(org) || !is_slug(name) { return Err(ManifestError::InvalidDependencyKey(key.clone())); } - // A requirement is either a semver range or an exact (opaque) tag; - // only an empty string is invalid. + // A requirement is either a semver range or an exact (opaque) tag. if req.trim().is_empty() { return Err(ManifestError::InvalidDependencyReq( key.clone(), @@ -172,7 +276,56 @@ impl Manifest { "requirement must not be empty".to_string(), )); } - let _ = Requirement::parse(req); + // Ranges that *look* like semver ranges but do not parse are + // rejected rather than silently degrading to an opaque tag. + if let Err(reason) = Requirement::validate(req) { + return Err(ManifestError::InvalidDependencyReq( + key.clone(), + req.clone(), + reason, + )); + } + } + for (bin_name, target) in &self.bin { + if bin_name.is_empty() + || bin_name.starts_with('.') + || bin_name + .chars() + .any(|c| !(c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')) + { + return Err(ManifestError::InvalidBin( + bin_name.clone(), + "names use [A-Za-z0-9._-] and cannot start with `.`".to_string(), + )); + } + if !is_safe_relative_path(target) { + return Err(ManifestError::InvalidBin( + bin_name.clone(), + format!("target `{target}` must be a relative path without `..`"), + )); + } + } + let overriding = self.overrides.build.values(); + for build in self.build.iter().chain(overriding) { + if build.command.trim().is_empty() { + return Err(ManifestError::InvalidBuild( + "command must not be empty".to_string(), + )); + } + for output in &build.outputs { + if !is_safe_relative_path(output) { + return Err(ManifestError::InvalidBuild(format!( + "output `{output}` must be a relative path without `..`" + ))); + } + } + } + for (key, _) in &self.overrides.build { + let mut parts = key.splitn(2, '/'); + let (org, name) = (parts.next().unwrap_or(""), parts.next().unwrap_or("")); + if !is_slug(org) || !is_slug(name) { + return Err(ManifestError::InvalidDependencyKey(key.clone())); + } } Ok(()) } diff --git a/src/paths.rs b/src/paths.rs index e1f80d1..f03a1e7 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -36,9 +36,28 @@ pub const ARCHIVE_ROOT: &str = "pkg"; /// Where `zed pack` writes artifacts, relative to the project root. pub const PACK_OUT_DIR: &str = ".zed/pack"; +/// Directory inside `zed_modules/` where package-declared executables are +/// hoisted (`zed_modules/.bin/`), runnable via `zed run `. +pub const BIN_DIR: &str = ".bin"; + /// Store entry path for an artifact, relative to the zed home directory, /// e.g. `store/v1/ab/abcdef.../`. pub fn store_entry_rel(sha256: &str) -> String { let prefix = sha256.get(..2).unwrap_or("xx"); format!("store/{STORE_VERSION}/{prefix}/{sha256}") } + +/// Build-cache entry for an artifact on one platform, relative to the zed +/// home directory, e.g. `builds/v1/macos-aarch64/ab/abcdef.../`. Source +/// extraction (`store/`) is platform-independent; compiled results are not, +/// so they cache separately per (sha256, platform) and the source store +/// stays immutable. +pub fn build_entry_rel(platform: &str, sha256: &str) -> String { + let prefix = sha256.get(..2).unwrap_or("xx"); + format!("builds/{STORE_VERSION}/{platform}/{prefix}/{sha256}") +} + +/// The `os-arch` pair used to key the build cache, e.g. `linux-x86_64`. +pub fn current_platform() -> String { + format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH) +} diff --git a/src/registry.rs b/src/registry.rs index 44662b3..d70c6f3 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -49,6 +49,13 @@ pub fn file_path(org: &str, name: &str, version: &str, path: &str) -> String { format!("{API_V1}/files/{org}/{name}/{version}/{path}") } +/// `POST` (bearer token, org-scoped) — mark a published version as yanked +/// (or restore it). Yanked versions stay downloadable for existing +/// lockfiles but are hidden from resolution and search. +pub fn yank_path(org: &str, name: &str, version: &str) -> String { + format!("{API_V1}/packages/{org}/{name}/versions/{version}/yank") +} + /// `POST` (bearer token) — claim an org namespace. pub fn orgs_path() -> String { format!("{API_V1}/orgs") @@ -134,6 +141,20 @@ pub struct PublishResponse { pub sha256: String, } +/// Body for the yank route. `yanked: false` restores a yanked version. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct YankRequest { + pub yanked: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct YankResponse { + pub org: String, + pub name: String, + pub version: String, + pub yanked: bool, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct ClaimOrgRequest { pub slug: String, diff --git a/src/version.rs b/src/version.rs index aee5b26..fbc6732 100644 --- a/src/version.rs +++ b/src/version.rs @@ -242,6 +242,25 @@ impl Requirement { Requirement::Exact(want) => want == version, } } + + /// Reject requirement strings that *look* like semver ranges but fail to + /// parse (e.g. `^1.x.y`, `>= banana`). Without this, a typo'd range would + /// silently degrade to an opaque exact-match tag and never resolve. + /// Strings with no range operators are legitimate opaque tags and pass. + pub fn validate(input: &str) -> Result<(), String> { + let looks_like_range = input.starts_with(['^', '~', '>', '<', '=']) + || input.contains(['*', ',']) + || input.split_whitespace().count() > 1; + if !looks_like_range { + return Ok(()); + } + match Self::parse(input) { + Requirement::Range(_) => Ok(()), + Requirement::Exact(_) => Err(format!( + "`{input}` looks like a version range but is not a valid one" + )), + } + } } /// Pick the version string satisfying `req` from `versions`. From 0bdc44e8e9c05b529043bc72cfd52ba626aa30e5 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 10:37:39 -0500 Subject: [PATCH 04/71] Add CI: fmt, test, and a schema-drift gate The generated schemas/ are the contract the non-Rust SDKs mirror; verify they stay in sync with the source types on every push. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9b13cec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: ci +on: + push: + branches: [main] + pull_request: + +jobs: + rust: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Format + run: cargo fmt --check + - name: Test + run: cargo test + # The generated JSON schemas are committed for the non-Rust SDKs to + # mirror; fail if they drift from the source types. + - name: Schemas are up to date + run: | + cargo run --example generate_schemas + git diff --exit-code schemas/ \ + || (echo "schemas/ is stale; run 'cargo run --example generate_schemas' and commit" && exit 1) From 3b1b8b5c65992ec4a65d432adeed1782d2e53317 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Fri, 24 Jul 2026 11:14:50 -0500 Subject: [PATCH 05/71] Manifest: workspace section for monorepo installs Co-Authored-By: Claude Fable 5 --- rust-toolchain.toml | 5 ++ src/manifest.rs | 143 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 rust-toolchain.toml diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..e140726 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +# Pin the toolchain so CI cache keys stay stable and every machine builds the +# same way (zed-docs issue #8). +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/src/manifest.rs b/src/manifest.rs index 425a0cd..54c4e5a 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -31,15 +31,62 @@ use crate::version::{Requirement, VersionScheme}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct Manifest { pub package: PackageSection, + /// Monorepo workspace declaration (zed-docs issue #7). When present, + /// `zed install` at this root resolves every member against one store and + /// writes one `.zpkg.lock`; member→member dependencies link by path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, /// Dependencies keyed by `org/name`, valued by a semver requirement. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub dependencies: BTreeMap, + /// Tools needed only to *build* this package (compilers, codegen). They + /// are made available in the build sandbox and never linked into a + /// consumer's `zed_modules/`. See [`BuildSection`] and zed-docs issue #5. + #[serde( + default, + rename = "build-dependencies", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub build_dependencies: BTreeMap, + /// This package's own build step, run after extraction on the consumer's + /// machine when the package ships source that needs compiling. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build: Option, + /// Consumer-side patches to a *dependency's* build step, keyed by + /// `org/name`. Lets a project fix a broken/missing upstream build locally. + #[serde( + default, + rename = "build-overrides", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub build_overrides: BTreeMap, + /// Executables this package exposes, `name -> path relative to the package + /// root`. On install they are hoisted into `zed_modules/.bin/` and run via + /// `zed run ` (zed-docs issue #7). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub bin: BTreeMap, #[serde(default)] pub publish: PublishSection, #[serde(default)] pub scripts: ScriptsSection, } +/// A build step: the command to run after extraction, and the artifacts to +/// expose. Because compiled output is OS/arch-specific, zed-pkg runs this in a +/// sandbox and caches the result in a build cache keyed by +/// `(source sha256, target triple, command)` — separate from the universal, +/// platform-independent source store (zed-docs issue #5). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct BuildSection { + /// Command executed with `sh -c` in the sandboxed copy of the source. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + /// Files (relative paths) to expose to consumers. When empty, the whole + /// built tree is exposed. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub outputs: Vec, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct PackageSection { /// Namespace the package is published under. Lowercase slug. @@ -108,6 +155,15 @@ pub struct ScriptsSection { pub test: Option, } +/// Monorepo workspace membership. `members` are glob patterns (relative to +/// the workspace root) selecting directories that each contain a `.zpkg.toml`, +/// e.g. `["packages/*", "apps/*"]`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct WorkspaceSection { + pub members: Vec, +} + #[derive(Debug, thiserror::Error)] pub enum ManifestError { #[error("invalid org slug `{0}`: must match [a-z0-9][a-z0-9-]*[a-z0-9]")] @@ -120,10 +176,37 @@ pub enum ManifestError { InvalidDependencyKey(String), #[error("invalid requirement `{1}` for dependency `{0}`: {2}")] InvalidDependencyReq(String, String, String), + #[error("invalid bin `{0}`: {1}")] + InvalidBin(String, String), + #[error("invalid workspace member pattern `{0}`")] + InvalidWorkspaceMember(String), #[error("manifest toml error: {0}")] Toml(String), } +/// True for a relative path that stays within the package (no absolute paths, +/// no `..` traversal). Used to keep hoisted bins and build outputs contained. +pub fn is_safe_relative_path(path: &str) -> bool { + let p = std::path::Path::new(path); + !path.is_empty() + && p.is_relative() + && p.components().all(|c| { + matches!( + c, + std::path::Component::Normal(_) | std::path::Component::CurDir + ) + }) +} + +/// True for a well-formed `org/name` dependency key. +pub fn is_dependency_key(key: &str) -> bool { + let mut parts = key.splitn(2, '/'); + match (parts.next(), parts.next()) { + (Some(org), Some(name)) => is_slug(org) && is_slug(name), + _ => false, + } +} + /// True for the lowercase slugs zed-pkg accepts as org and package names. pub fn is_slug(s: &str) -> bool { !s.is_empty() @@ -174,9 +257,69 @@ impl Manifest { } let _ = Requirement::parse(req); } + for (key, req) in &self.build_dependencies { + if !is_dependency_key(key) { + return Err(ManifestError::InvalidDependencyKey(key.clone())); + } + if req.trim().is_empty() { + return Err(ManifestError::InvalidDependencyReq( + key.clone(), + req.clone(), + "build-dependency requirement must not be empty".to_string(), + )); + } + } + for key in self.build_overrides.keys() { + if !is_dependency_key(key) { + return Err(ManifestError::InvalidDependencyKey(key.clone())); + } + } + for (name, path) in &self.bin { + if name.trim().is_empty() || name.contains('/') || name.contains('\\') { + return Err(ManifestError::InvalidBin( + name.clone(), + "bin name must be non-empty with no path separators".to_string(), + )); + } + if !is_safe_relative_path(path) { + return Err(ManifestError::InvalidBin( + name.clone(), + format!("bin path `{path}` must be relative and stay inside the package"), + )); + } + } + if let Some(ws) = &self.workspace { + for pat in &ws.members { + if pat.trim().is_empty() { + return Err(ManifestError::InvalidWorkspaceMember(pat.clone())); + } + } + } Ok(()) } + /// True when this manifest declares a non-empty monorepo workspace. + pub fn is_workspace_root(&self) -> bool { + self.workspace + .as_ref() + .is_some_and(|w| !w.members.is_empty()) + } + + /// The effective build step for a dependency `org/name`: this manifest's + /// `[build-overrides]` entry if present, else the dependency's own + /// `[build]`. Returns `None` when neither declares a build command. + pub fn effective_build( + &self, + dep_key: &str, + dep_build: Option<&BuildSection>, + ) -> Option { + self.build_overrides + .get(dep_key) + .or(dep_build) + .filter(|b| b.command.is_some()) + .cloned() + } + /// `org/name`, the canonical package identifier. pub fn full_name(&self) -> String { format!("{}/{}", self.package.org, self.package.name) From 92fbd60f3f45a42108888bb35fe36fffc34551eb Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 10:42:13 -0500 Subject: [PATCH 06/71] =?UTF-8?q?manifest:=20[install].dir=20=E2=80=94=20c?= =?UTF-8?q?onfigurable=20dependency=20location?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an [install] section with a `dir` (default `zed_modules`; e.g. `.vendor/.zed` or `.deps/.zed`) plus an optional `adapter`, and a Manifest::modules_dir() helper. The dir must be a safe relative path (InvalidInstallDir otherwise). This lets zed COMPLEMENT npm/maven rather than replace them: the few zed-sourced deps land in a project-relative tree the ecosystem adapter then wires into the toolchain. Co-Authored-By: Claude Fable 5 --- src/manifest.rs | 51 ++++++++++++++++++++++++++++++++++++++++++++++ tests/roundtrip.rs | 21 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index 8a5bd71..7c5a389 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -73,6 +73,14 @@ pub struct Manifest { pub publish: PublishSection, #[serde(default)] pub scripts: ScriptsSection, + /// Where zed materializes the (few, hand-picked) dependencies it sources — + /// zed complements npm/maven/etc. rather than replacing them, so this dir + /// sits alongside the native one and the ecosystem adapter wires it into + /// the toolchain (NODE_PATH / node_modules, the JVM classpath, …). `dir` + /// defaults to `zed_modules`; relocate it with e.g. `.vendor/.zed` or + /// `.deps/.zed`. + #[serde(default, skip_serializing_if = "InstallSection::is_empty")] + pub install: InstallSection, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -144,6 +152,28 @@ pub struct ScriptsSection { pub test: Option, } +/// Install-layout controls: where zed's dependency tree lands and which +/// ecosystem adapter to emit so those deps are visible to the native toolchain. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct InstallSection { + /// Project-relative directory for the installed tree (`//`). + /// Defaults to `zed_modules`. Common overrides: `.vendor/.zed`, `.deps/.zed`. + /// Must be a safe relative path (no leading `/`, no `..`). + #[serde(skip_serializing_if = "Option::is_none")] + pub dir: Option, + /// Force the ecosystem adapter: `node`, `java`, or `none`. Omitted = + /// auto-detect (or the CLI `--adapter`). Mirrors the CLI's values. + #[serde(skip_serializing_if = "Option::is_none")] + pub adapter: Option, +} + +impl InstallSection { + pub fn is_empty(&self) -> bool { + self.dir.is_none() && self.adapter.is_none() + } +} + /// A post-extract build step. Because compiled output is OS/arch-specific, /// zed-pkg runs `command` via `sh -c` inside a sandboxed staging copy of the /// source and caches the result in a build cache keyed by @@ -205,6 +235,8 @@ pub enum ManifestError { InvalidBuild(String), #[error("invalid workspace member pattern `{0}`")] InvalidWorkspaceMember(String), + #[error("invalid install dir `{0}`: {1}")] + InvalidInstallDir(String, String), #[error("manifest toml error: {0}")] Toml(String), } @@ -359,9 +391,28 @@ impl Manifest { } } } + if let Some(dir) = &self.install.dir + && !is_safe_relative_path(dir) + { + return Err(ManifestError::InvalidInstallDir( + dir.clone(), + "must be a relative path without `..` or a leading `/`".to_string(), + )); + } Ok(()) } + /// Project-relative directory dependencies install into. Honors + /// `[install].dir` (e.g. `.vendor/.zed`), else the default `zed_modules`. + pub fn modules_dir(&self) -> &str { + self.install + .dir + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(crate::paths::MODULES_DIR) + } + /// True when this manifest declares a non-empty monorepo workspace. pub fn is_workspace_root(&self) -> bool { self.workspace diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 5360209..7e3cf8d 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -43,6 +43,27 @@ fn manifest_roundtrip() { assert_eq!(m, reparsed); } +#[test] +fn install_dir_defaults_and_overrides() { + // No [install] section -> the default dep dir. + assert_eq!(Manifest::parse(SAMPLE).unwrap().modules_dir(), "zed_modules"); + + // A configured dir relocates the tree and round-trips. + let with_dir = format!("{SAMPLE}\n[install]\ndir = \".vendor/.zed\"\n"); + let m = Manifest::parse(&with_dir).unwrap(); + assert_eq!(m.modules_dir(), ".vendor/.zed"); + assert_eq!(Manifest::parse(&m.to_toml_string().unwrap()).unwrap(), m); + + // Unsafe dirs are rejected. + for bad in ["/abs/path", "../escape", "a/../../b"] { + let src = format!("{SAMPLE}\n[install]\ndir = \"{bad}\"\n"); + assert!( + matches!(Manifest::parse(&src), Err(ManifestError::InvalidInstallDir(_, _))), + "expected {bad} rejected" + ); + } +} + #[test] fn manifest_rejects_bad_input() { let bad_org = SAMPLE.replace("org = \"acme\"", "org = \"Acme!\""); From 478dad021ab67450aa98528ea579a34b6352aec0 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 10:42:37 -0500 Subject: [PATCH 07/71] fmt: format install_dir roundtrip test Co-Authored-By: Claude Fable 5 --- tests/roundtrip.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 7e3cf8d..f4aefcc 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -46,7 +46,10 @@ fn manifest_roundtrip() { #[test] fn install_dir_defaults_and_overrides() { // No [install] section -> the default dep dir. - assert_eq!(Manifest::parse(SAMPLE).unwrap().modules_dir(), "zed_modules"); + assert_eq!( + Manifest::parse(SAMPLE).unwrap().modules_dir(), + "zed_modules" + ); // A configured dir relocates the tree and round-trips. let with_dir = format!("{SAMPLE}\n[install]\ndir = \".vendor/.zed\"\n"); @@ -58,7 +61,10 @@ fn install_dir_defaults_and_overrides() { for bad in ["/abs/path", "../escape", "a/../../b"] { let src = format!("{SAMPLE}\n[install]\ndir = \"{bad}\"\n"); assert!( - matches!(Manifest::parse(&src), Err(ManifestError::InvalidInstallDir(_, _))), + matches!( + Manifest::parse(&src), + Err(ManifestError::InvalidInstallDir(_, _)) + ), "expected {bad} rejected" ); } From ad858f3b96eff195c0f04f43f5e4f49da2c76ef2 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 10:57:01 -0500 Subject: [PATCH 08/71] schemas: regenerate for [install] section The InstallSection addition (install.dir / install.adapter) landed in the Rust types but the committed JSON schemas were never regenerated, so the "Schemas are up to date" CI gate failed. Regenerated via `cargo run --example generate_schemas`. Co-Authored-By: Claude Opus 5 (1M context) --- schemas/manifest.json | 24 ++++++++++++++++++++++++ schemas/publish-meta.json | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/schemas/manifest.json b/schemas/manifest.json index 5428d7e..968e208 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -36,6 +36,10 @@ "type": "string" } }, + "install": { + "description": "Where zed materializes the (few, hand-picked) dependencies it sources —\nzed complements npm/maven/etc. rather than replacing them, so this dir\nsits alongside the native one and the ecosystem adapter wires it into\nthe toolchain (NODE_PATH / node_modules, the JVM classpath, …). `dir`\ndefaults to `zed_modules`; relocate it with e.g. `.vendor/.zed` or\n`.deps/.zed`.", + "$ref": "#/$defs/InstallSection" + }, "overrides": { "description": "Consumer-side patches for dependencies (e.g. fixing a dependency's\nbroken or missing `[build]` step without waiting on upstream).", "$ref": "#/$defs/OverridesSection" @@ -94,6 +98,26 @@ "command" ] }, + "InstallSection": { + "description": "Install-layout controls: where zed's dependency tree lands and which\necosystem adapter to emit so those deps are visible to the native toolchain.", + "type": "object", + "properties": { + "adapter": { + "description": "Force the ecosystem adapter: `node`, `java`, or `none`. Omitted =\nauto-detect (or the CLI `--adapter`). Mirrors the CLI's values.", + "type": [ + "string", + "null" + ] + }, + "dir": { + "description": "Project-relative directory for the installed tree (`//`).\nDefaults to `zed_modules`. Common overrides: `.vendor/.zed`, `.deps/.zed`.\nMust be a safe relative path (no leading `/`, no `..`).", + "type": [ + "string", + "null" + ] + } + } + }, "OverridesSection": { "description": "Consumer-side dependency patches, keyed by `org/name`.", "type": "object", diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index f63d3ed..52c037a 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -66,6 +66,26 @@ "command" ] }, + "InstallSection": { + "description": "Install-layout controls: where zed's dependency tree lands and which\necosystem adapter to emit so those deps are visible to the native toolchain.", + "type": "object", + "properties": { + "adapter": { + "description": "Force the ecosystem adapter: `node`, `java`, or `none`. Omitted =\nauto-detect (or the CLI `--adapter`). Mirrors the CLI's values.", + "type": [ + "string", + "null" + ] + }, + "dir": { + "description": "Project-relative directory for the installed tree (`//`).\nDefaults to `zed_modules`. Common overrides: `.vendor/.zed`, `.deps/.zed`.\nMust be a safe relative path (no leading `/`, no `..`).", + "type": [ + "string", + "null" + ] + } + } + }, "Manifest": { "description": "The `.zpkg.toml` manifest at the root of every package repository.\nTOML only — never YAML or JSON.\n\n```toml\n[package]\norg = \"acme\"\nname = \"http-kit\"\nversion = \"1.2.0\"\ndescription = \"Tiny HTTP helpers\"\nlicense = \"MIT\"\n\n[package.repository]\nvcs = \"git\"\nurl = \"https://github.com/acme/http-kit\"\n\n[dependencies]\n\"acme/logkit\" = \"^0.3\"\n\n[publish]\nexclude = [\"benches/**\"]\nsmoke_test = \"sh scripts/smoke.sh\"\n```", "type": "object", @@ -102,6 +122,10 @@ "type": "string" } }, + "install": { + "description": "Where zed materializes the (few, hand-picked) dependencies it sources —\nzed complements npm/maven/etc. rather than replacing them, so this dir\nsits alongside the native one and the ecosystem adapter wires it into\nthe toolchain (NODE_PATH / node_modules, the JVM classpath, …). `dir`\ndefaults to `zed_modules`; relocate it with e.g. `.vendor/.zed` or\n`.deps/.zed`.", + "$ref": "#/$defs/InstallSection" + }, "overrides": { "description": "Consumer-side patches for dependencies (e.g. fixing a dependency's\nbroken or missing `[build]` step without waiting on upstream).", "$ref": "#/$defs/OverridesSection" From 17ee671dc33c5e6eadd7936787918baaf3154dd8 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Sat, 25 Jul 2026 16:34:02 -0500 Subject: [PATCH 09/71] Audit-log contract: path helper + AuditAction/AuditEntry DTOs The shared contract for the org audit trail (zed-docs issue #7), so the server, CLI, and generated client schemas cannot disagree on it. `AuditEntry` carries the raw `action` string alongside a parsed `action_kind`, and `AuditAction::parse` returns Option: an unrecognized action from a newer server deserializes cleanly on an older client instead of failing the whole response. Co-Authored-By: Claude Opus 5 (1M context) --- src/registry.rs | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/registry.rs b/src/registry.rs index d70c6f3..837a67b 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -61,6 +61,12 @@ pub fn orgs_path() -> String { format!("{API_V1}/orgs") } +/// `GET ?limit=` (bearer token, org `owner` or admin) — the org's audit log: +/// who changed published state, what, and when (zed-docs issue #7 governance). +pub fn audit_path(org: &str) -> String { + format!("{API_V1}/orgs/{org}/audit") +} + /// `GET` — liveness probe. pub fn healthz_path() -> String { "/healthz".to_string() @@ -173,6 +179,76 @@ pub struct SearchResponse { pub items: Vec, } +/// A state-changing action recorded in an org's audit log. Reads are never +/// audited — only mutations of published state and of the namespace itself, so +/// the log answers "who changed what" without drowning in traffic. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AuditAction { + /// A version was published. + Publish, + /// A version was yanked (hidden from fresh resolution). + Yank, + /// A previously yanked version was restored. + Unyank, + /// The org namespace was claimed. + OrgClaim, +} + +impl AuditAction { + pub fn as_str(&self) -> &'static str { + match self { + AuditAction::Publish => "publish", + AuditAction::Yank => "yank", + AuditAction::Unyank => "unyank", + AuditAction::OrgClaim => "org_claim", + } + } + + /// Parse a stored action string; unknown values are preserved as `None` so + /// a newer server's rows never break an older client's read. + pub fn parse(s: &str) -> Option { + match s { + "publish" => Some(AuditAction::Publish), + "yank" => Some(AuditAction::Yank), + "unyank" => Some(AuditAction::Unyank), + "org_claim" => Some(AuditAction::OrgClaim), + _ => None, + } + } +} + +/// One audit-log record. The actor is identified by the *token* that acted — +/// its name and role, never its secret — which is the identity a registry +/// actually has (zed-docs issue #7 governance). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AuditEntry { + /// RFC 3339 timestamp of the action. + pub at: String, + /// Raw action string; `action_kind` is the parsed form when recognized. + pub action: String, + /// Parsed action, absent when this server build doesn't recognize it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action_kind: Option, + /// What was acted on, e.g. `acme/http-kit@1.2.0` or the org slug. + pub subject: String, + /// Human-readable name of the token that acted. + pub actor_token_name: String, + /// The acting token's role (`owner`/`publisher`/`reader`, or `admin` for + /// unscoped tokens). + pub actor_role: String, + /// Extra context, e.g. the artifact sha256 for a publish. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AuditLogResponse { + pub org: String, + /// Most recent first. + pub entries: Vec, +} + /// Error body returned with any non-2xx status. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct ApiError { From 5491be699ff31b00d9c1b50f3455900f236da47d Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 17:01:46 -0500 Subject: [PATCH 10/71] =?UTF-8?q?manifest:=20[targets]=20=E2=80=94=20polyg?= =?UTF-8?q?lot=20packages=20that=20fan=20out=20one=20package=20per=20langu?= =?UTF-8?q?age?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client repo (fiducia-clients, zed-clients, …) ships the same library for several ecosystems. Installing all of them into every consumer is wrong: a Java build should never download the Node sources. [targets.] declares one language subtree per ecosystem: [targets.nodejs] dir = "clients/ts" adapter = "node" [targets.java] dir = "clients/java" adapter = "java" [targets.golang] dir = "clients/go" Each target is its own PUBLISHED package, named - by default (overridable per target), so one repo and one version fan out to fiducia/fiducia-clients-{nodejs,java,golang}@1.1.2 — a consumer depends on the one it needs and downloads only those bytes. manifest_for_target() derives the standalone manifest that ships inside each artifact: same org/version/repo, the target's own name and adapter, and no [targets] (a slice is single-language by construction, so fan-out cannot recurse). [install].target + target_subdir() cover the consumer side for path/workspace deps that point at the polyglot root. Target names and dirs are validated (slug names, no absolute/.. dirs). Packages without [targets] are entirely unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- src/manifest.rs | 183 +++++++++++++++++++++++++++++++- tests/roundtrip.rs | 254 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 436 insertions(+), 1 deletion(-) diff --git a/src/manifest.rs b/src/manifest.rs index 7c5a389..3abbcf2 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -81,6 +81,15 @@ pub struct Manifest { /// `.deps/.zed`. #[serde(default, skip_serializing_if = "InstallSection::is_empty")] pub install: InstallSection, + /// Language subtrees for a **polyglot package** — one repo shipping the + /// same library for several ecosystems (e.g. `node/`, `python/`, `go/`). + /// Keyed by ecosystem name; the value says which subdirectory is that + /// ecosystem's package root. On install the consumer resolves one target + /// and only that subtree is materialized, so a Python project gets the + /// Python source at its import root rather than a tree it has to reach + /// into. Absent (the common case) = single-language package, whole tree. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub targets: BTreeMap, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -166,14 +175,56 @@ pub struct InstallSection { /// auto-detect (or the CLI `--adapter`). Mirrors the CLI's values. #[serde(skip_serializing_if = "Option::is_none")] pub adapter: Option, + /// Which language subtree to take from **polyglot** dependencies (see + /// [`TargetSection`]). Omitted = infer from the project (`package.json` → + /// `node`, `go.mod` → `go`, `pyproject.toml` → `python`, …). Naming a + /// target a dependency does not publish is an error rather than a silent + /// fallback: the consumer asked for something specific. + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, } impl InstallSection { pub fn is_empty(&self) -> bool { - self.dir.is_none() && self.adapter.is_none() + self.dir.is_none() && self.adapter.is_none() && self.target.is_none() } } +/// One ecosystem's slice of a polyglot package — and, on publish, its own +/// independently installable package. +/// +/// A repo like `fiducia-clients` carrying `clients/ts`, `clients/java`, and +/// `clients/go` declares one target each. `zed publish` then emits **one +/// artifact per target**, named `-` by default: +/// +/// ```text +/// fiducia/fiducia-clients-nodejs@1.1.2 <- clients/ts only +/// fiducia/fiducia-clients-java@1.1.2 <- clients/java only +/// fiducia/fiducia-clients-golang@1.1.2 <- clients/go only +/// ``` +/// +/// One source of truth and one version in the repo; N packages on the wire. +/// A Java consumer downloads only Java bytes — the decisive advantage over +/// shipping one fat artifact and slicing it at install time. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TargetSection { + /// Package-relative directory that is this ecosystem's package root, e.g. + /// `python` or `clients/go`. Must be a safe relative path (no leading `/`, + /// no `..`) so a target can never escape the package. + pub dir: String, + /// Published package name for this target. Defaults to + /// `-` (e.g. `fiducia-clients-java`). Set it to + /// break out of the suffix convention when an ecosystem expects a + /// different spelling. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Ecosystem adapter consumers of THIS target should use (`node`, `java`, + /// `none`). Recorded in the published per-target manifest so a consumer + /// gets the right wiring without configuring it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter: Option, +} + /// A post-extract build step. Because compiled output is OS/arch-specific, /// zed-pkg runs `command` via `sh -c` inside a sandboxed staging copy of the /// source and caches the result in a build cache keyed by @@ -237,6 +288,8 @@ pub enum ManifestError { InvalidWorkspaceMember(String), #[error("invalid install dir `{0}`: {1}")] InvalidInstallDir(String, String), + #[error("invalid target `{0}`: {1}")] + InvalidTarget(String, String), #[error("manifest toml error: {0}")] Toml(String), } @@ -250,6 +303,12 @@ pub fn is_slug(s: &str) -> bool { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } +/// True for a well-formed polyglot target name (`node`, `python`, `go`, …). +/// Same shape as a slug: these appear in manifests, CLI flags, and messages. +pub fn is_target_name(s: &str) -> bool { + is_slug(s) +} + /// True for a well-formed `org/name` dependency key. pub fn is_dependency_key(key: &str) -> bool { let mut parts = key.splitn(2, '/'); @@ -345,6 +404,30 @@ impl Manifest { )); } } + for (name, target) in &self.targets { + if !is_target_name(name) { + return Err(ManifestError::InvalidTarget( + name.clone(), + "target names use [a-z0-9][a-z0-9-]* (e.g. `node`, `python`, `go`)".to_string(), + )); + } + if !is_safe_relative_path(&target.dir) { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!("dir `{}` must be a relative path without `..`", target.dir), + )); + } + } + // A blank request means "no target", the same way a blank + // `[install].dir` falls back to the default rather than erroring. + if let Some(requested) = self.requested_target() + && !is_target_name(requested) + { + return Err(ManifestError::InvalidTarget( + requested.to_string(), + "target names use [a-z0-9][a-z0-9-]*".to_string(), + )); + } for (bin_name, target) in &self.bin { if bin_name.is_empty() || bin_name.starts_with('.') @@ -413,6 +496,104 @@ impl Manifest { .unwrap_or(crate::paths::MODULES_DIR) } + /// The consumer's requested polyglot target, if it named one explicitly. + pub fn requested_target(&self) -> Option<&str> { + self.install + .target + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + } + + /// True when this package ships per-ecosystem subtrees. + pub fn is_polyglot(&self) -> bool { + !self.targets.is_empty() + } + + /// The package name a target publishes under: its explicit `name`, else + /// the `-` convention (`fiducia-clients` + `java` → + /// `fiducia-clients-java`). + pub fn target_package_name(&self, target: &str) -> Option { + self.targets.get(target).map(|t| { + t.name + .clone() + .unwrap_or_else(|| format!("{}-{}", self.package.name, target)) + }) + } + + /// Every `(target, published name)` pair this manifest fans out to, sorted + /// by target for deterministic publish order and output. + pub fn target_package_names(&self) -> Vec<(String, String)> { + let mut names: Vec<(String, String)> = self + .targets + .keys() + .filter_map(|target| { + self.target_package_name(target) + .map(|name| (target.clone(), name)) + }) + .collect(); + names.sort(); + names + } + + /// Derive the per-target manifest that ships *inside* that target's + /// artifact: same org/version/repo, the target's own package name, its + /// adapter, and no `[targets]` (the slice is single-language by + /// construction). Dependencies are carried over so a target's own zed + /// deps still resolve. + pub fn manifest_for_target(&self, target: &str) -> Option { + let name = self.target_package_name(target)?; + let section = self.targets.get(target)?; + let mut derived = self.clone(); + derived.package.name = name; + derived.package.description = Some(match &self.package.description { + Some(base) => format!("{base} ({target})"), + None => format!("{} ({target} client)", self.package.name), + }); + derived.targets = BTreeMap::new(); + derived.workspace = None; + // The consumer-facing wiring for this ecosystem. + derived.install.adapter = section.adapter.clone().or(self.install.adapter.clone()); + derived.install.target = None; + Some(derived) + } + + /// Resolve which subdirectory of *this* (dependency) package a consumer + /// asking for `requested` should get. + /// + /// * Not polyglot → `Ok(None)`: the whole tree, exactly as before. + /// * Polyglot + a matching target → `Ok(Some(dir))`. + /// * Polyglot + `requested` names a target this package does not publish + /// → `Err` listing what it does publish. An explicit request that cannot + /// be honored is a mistake worth surfacing, not something to paper over + /// by installing a tree the consumer's toolchain cannot read. + /// * Polyglot + nothing requested → `Ok(None)` (whole tree), so a consumer + /// that has not opted in keeps working. + pub fn target_subdir(&self, requested: Option<&str>) -> Result, ManifestError> { + if self.targets.is_empty() { + return Ok(None); + } + let Some(requested) = requested else { + return Ok(None); + }; + match self.targets.get(requested) { + Some(target) => Ok(Some(target.dir.as_str())), + None => { + let mut available: Vec<&str> = self.targets.keys().map(String::as_str).collect(); + available.sort_unstable(); + Err(ManifestError::InvalidTarget( + requested.to_string(), + format!( + "package `{}/{}` publishes no such target; it provides: {}", + self.package.org, + self.package.name, + available.join(", ") + ), + )) + } + } + } + /// True when this manifest declares a non-empty monorepo workspace. pub fn is_workspace_root(&self) -> bool { self.workspace diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index f4aefcc..76cb7e5 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -133,3 +133,257 @@ fn store_paths_are_sharded() { let sha = "abcdef0123".to_string() + &"0".repeat(54); assert_eq!(store_entry_rel(&sha), format!("store/v1/ab/{sha}")); } + +/// A polyglot package declares one subtree per ecosystem; consumers pick one. +const POLYGLOT: &str = r#" +[package] +org = "zedtest" +name = "polyglot-lib" +version = "1.0.0" + +[package.repository] +vcs = "git" +url = "https://github.com/zed-pkg-test/polyglot-lib" + +[targets.node] +dir = "node" + +[targets.python] +dir = "python" + +[targets.go] +dir = "go" +"#; + +#[test] +fn polyglot_targets_roundtrip_and_resolve() { + let m = Manifest::parse(POLYGLOT).unwrap(); + assert!(m.is_polyglot()); + assert_eq!(m.targets.len(), 3); + assert_eq!(m.target_subdir(Some("python")).unwrap(), Some("python")); + assert_eq!(m.target_subdir(Some("node")).unwrap(), Some("node")); + // Round-trips through TOML unchanged. + assert_eq!(Manifest::parse(&m.to_toml_string().unwrap()).unwrap(), m); +} + +#[test] +fn a_single_language_package_ignores_target_selection() { + // No [targets] => always the whole tree, even if a consumer asks for one. + // Existing packages must keep installing exactly as before. + let m = Manifest::parse(SAMPLE).unwrap(); + assert!(!m.is_polyglot()); + assert_eq!(m.target_subdir(None).unwrap(), None); + assert_eq!(m.target_subdir(Some("python")).unwrap(), None); +} + +#[test] +fn a_polyglot_package_without_a_request_yields_the_whole_tree() { + // A consumer that has not opted into a target still installs fine. + let m = Manifest::parse(POLYGLOT).unwrap(); + assert_eq!(m.target_subdir(None).unwrap(), None); +} + +#[test] +fn requesting_an_unpublished_target_is_an_error_listing_what_exists() { + let m = Manifest::parse(POLYGLOT).unwrap(); + let err = m + .target_subdir(Some("ruby")) + .expect_err("a target the package does not publish must not silently fall back"); + let msg = err.to_string(); + assert!(msg.contains("ruby"), "{msg}"); + assert!(msg.contains("zedtest/polyglot-lib"), "{msg}"); + // The message enumerates the real targets so the fix is obvious. + for target in ["go", "node", "python"] { + assert!(msg.contains(target), "expected `{target}` listed in: {msg}"); + } +} + +#[test] +fn target_dirs_and_names_are_validated() { + // `..` in a target dir would escape the package on install. + let escaping = POLYGLOT.replace(r#"dir = "python""#, r#"dir = "../../etc""#); + assert!(matches!( + Manifest::parse(&escaping), + Err(ManifestError::InvalidTarget(_, _)) + )); + + // Absolute dirs likewise. + let absolute = POLYGLOT.replace(r#"dir = "python""#, r#"dir = "/etc/passwd""#); + assert!(matches!( + Manifest::parse(&absolute), + Err(ManifestError::InvalidTarget(_, _)) + )); + + // Target names are slugs. + let bad_name = POLYGLOT.replace("[targets.node]", "[targets.\"Node JS\"]"); + assert!(matches!( + Manifest::parse(&bad_name), + Err(ManifestError::InvalidTarget(_, _)) + )); + + // And so is a consumer's requested target. + let bad_request = format!("{SAMPLE}\n[install]\ntarget = \"Python 3\"\n"); + assert!(matches!( + Manifest::parse(&bad_request), + Err(ManifestError::InvalidTarget(_, _)) + )); +} + +#[test] +fn nested_target_dirs_are_allowed() { + // Real repos often nest, e.g. clients/go. + let nested = POLYGLOT.replace(r#"dir = "go""#, r#"dir = "clients/go""#); + let m = Manifest::parse(&nested).unwrap(); + assert_eq!(m.target_subdir(Some("go")).unwrap(), Some("clients/go")); +} + +#[test] +fn consumer_requested_target_is_read_from_the_install_section() { + let consumer = format!("{SAMPLE}\n[install]\ndir = \".vendor/.zed\"\ntarget = \"python\"\n"); + let m = Manifest::parse(&consumer).unwrap(); + assert_eq!(m.requested_target(), Some("python")); + assert_eq!(m.modules_dir(), ".vendor/.zed"); + assert_eq!(Manifest::parse(&m.to_toml_string().unwrap()).unwrap(), m); + + // Absent or blank = no request. + assert_eq!(Manifest::parse(SAMPLE).unwrap().requested_target(), None); + let blank = format!("{SAMPLE}\n[install]\ntarget = \" \"\n"); + assert_eq!(Manifest::parse(&blank).unwrap().requested_target(), None); +} + +/// The real shape: a client repo publishing one package per language. +const CLIENTS: &str = r#" +[package] +org = "fiducia" +name = "fiducia-clients" +version = "1.1.2" +description = "Fiducia API clients" + +[package.repository] +vcs = "git" +url = "https://github.com/fiducia-cloud/fiducia-clients" + +[targets.nodejs] +dir = "clients/ts" +adapter = "node" + +[targets.java] +dir = "clients/java" +adapter = "java" + +[targets.golang] +dir = "clients/go" +"#; + +#[test] +fn each_target_publishes_under_its_own_package_name() { + let m = Manifest::parse(CLIENTS).unwrap(); + assert_eq!( + m.target_package_name("nodejs").as_deref(), + Some("fiducia-clients-nodejs") + ); + assert_eq!( + m.target_package_name("java").as_deref(), + Some("fiducia-clients-java") + ); + assert_eq!( + m.target_package_name("golang").as_deref(), + Some("fiducia-clients-golang") + ); + assert_eq!(m.target_package_name("ruby"), None); + + // Deterministic, sorted fan-out list. + assert_eq!( + m.target_package_names(), + vec![ + ("golang".to_string(), "fiducia-clients-golang".to_string()), + ("java".to_string(), "fiducia-clients-java".to_string()), + ("nodejs".to_string(), "fiducia-clients-nodejs".to_string()), + ] + ); +} + +#[test] +fn an_explicit_target_name_overrides_the_suffix_convention() { + let custom = CLIENTS.replace( + "[targets.nodejs]\ndir = \"clients/ts\"", + "[targets.nodejs]\ndir = \"clients/ts\"\nname = \"fiducia-js-sdk\"", + ); + let m = Manifest::parse(&custom).unwrap(); + assert_eq!( + m.target_package_name("nodejs").as_deref(), + Some("fiducia-js-sdk") + ); + // Other targets keep the convention. + assert_eq!( + m.target_package_name("java").as_deref(), + Some("fiducia-clients-java") + ); +} + +#[test] +fn the_per_target_manifest_is_a_standalone_single_language_package() { + let base = Manifest::parse(CLIENTS).unwrap(); + let java = base + .manifest_for_target("java") + .expect("java target exists"); + + // It is its own package, sharing org/version/repo with the parent. + assert_eq!(java.package.name, "fiducia-clients-java"); + assert_eq!(java.package.org, "fiducia"); + assert_eq!(java.package.version, "1.1.2"); + assert_eq!(java.full_name(), "fiducia/fiducia-clients-java"); + assert_eq!( + java.package.repository.url, base.package.repository.url, + "the artifact still points back at the source repo" + ); + + // It is NOT itself polyglot — the slice is single-language by construction, + // so a consumer can never recurse into another fan-out. + assert!(!java.is_polyglot()); + assert!(java.targets.is_empty()); + + // It carries the ecosystem wiring its consumers need. + assert_eq!(java.install.adapter.as_deref(), Some("java")); + assert_eq!( + base.manifest_for_target("nodejs") + .unwrap() + .install + .adapter + .as_deref(), + Some("node") + ); + // A target with no adapter inherits the base (here: none set). + assert_eq!( + base.manifest_for_target("golang").unwrap().install.adapter, + None + ); + + // The derived manifest is valid on its own and round-trips. + java.validate().expect("derived manifest must be valid"); + assert_eq!( + Manifest::parse(&java.to_toml_string().unwrap()).unwrap(), + java + ); + + // The description makes the language obvious in registry listings. + assert_eq!( + java.package.description.as_deref(), + Some("Fiducia API clients (java)") + ); + + assert!(base.manifest_for_target("ruby").is_none()); +} + +#[test] +fn derived_target_names_must_still_be_valid_package_names() { + // The suffix convention has to produce a legal slug, or publish would + // emit an unusable package name. + let m = Manifest::parse(CLIENTS).unwrap(); + for (_, name) in m.target_package_names() { + assert!( + zed_interfaces::manifest::is_slug(&name), + "derived name `{name}` is not a valid package name" + ); + } +} From 1d815bb5609c102d409bc98ec45646865b9ea2ae Mon Sep 17 00:00:00 2001 From: alex-mills Date: Sat, 25 Jul 2026 17:05:12 -0500 Subject: [PATCH 11/71] schemas: regenerate for [targets] The schema-drift gate caught it: [targets] landed in the manifest without regenerating schemas/, which the non-Rust SDKs codegen from. Co-Authored-By: Claude Fable 5 --- schemas/manifest.json | 41 +++++++++++++++++++++++++++++++++++++++ schemas/publish-meta.json | 41 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/schemas/manifest.json b/schemas/manifest.json index 968e208..7026acd 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -62,6 +62,13 @@ "test": null } }, + "targets": { + "description": "Language subtrees for a **polyglot package** — one repo shipping the\nsame library for several ecosystems (e.g. `node/`, `python/`, `go/`).\nKeyed by ecosystem name; the value says which subdirectory is that\necosystem's package root. On install the consumer resolves one target\nand only that subtree is materialized, so a Python project gets the\nPython source at its import root rather than a tree it has to reach\ninto. Absent (the common case) = single-language package, whole tree.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/TargetSection" + } + }, "workspace": { "description": "Monorepo workspace declaration (zed-docs issue #7); only meaningful in\na workspace root manifest. When present, `zed install` at this root\nresolves every member against one store and writes one `.zpkg.lock`;\nmember→member dependencies link by path instead of going through the\nregistry.", "anyOf": [ @@ -115,6 +122,13 @@ "string", "null" ] + }, + "target": { + "description": "Which language subtree to take from **polyglot** dependencies (see\n[`TargetSection`]). Omitted = infer from the project (`package.json` →\n`node`, `go.mod` → `go`, `pyproject.toml` → `python`, …). Naming a\ntarget a dependency does not publish is an error rather than a silent\nfallback: the consumer asked for something specific.", + "type": [ + "string", + "null" + ] } } }, @@ -239,6 +253,33 @@ } } }, + "TargetSection": { + "description": "One ecosystem's slice of a polyglot package — and, on publish, its own\nindependently installable package.\n\nA repo like `fiducia-clients` carrying `clients/ts`, `clients/java`, and\n`clients/go` declares one target each. `zed publish` then emits **one\nartifact per target**, named `-` by default:\n\n```text\nfiducia/fiducia-clients-nodejs@1.1.2 <- clients/ts only\nfiducia/fiducia-clients-java@1.1.2 <- clients/java only\nfiducia/fiducia-clients-golang@1.1.2 <- clients/go only\n```\n\nOne source of truth and one version in the repo; N packages on the wire.\nA Java consumer downloads only Java bytes — the decisive advantage over\nshipping one fat artifact and slicing it at install time.", + "type": "object", + "properties": { + "adapter": { + "description": "Ecosystem adapter consumers of THIS target should use (`node`, `java`,\n`none`). Recorded in the published per-target manifest so a consumer\ngets the right wiring without configuring it.", + "type": [ + "string", + "null" + ] + }, + "dir": { + "description": "Package-relative directory that is this ecosystem's package root, e.g.\n`python` or `clients/go`. Must be a safe relative path (no leading `/`,\nno `..`) so a target can never escape the package.", + "type": "string" + }, + "name": { + "description": "Published package name for this target. Defaults to\n`-` (e.g. `fiducia-clients-java`). Set it to\nbreak out of the suffix convention when an ecosystem expects a\ndifferent spelling.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "dir" + ] + }, "Vcs": { "description": "Version-control systems a package's source repository can live on.\n\nzed-pkg is VCS-agnostic by design: what gets installed is always a\nregistry artifact, and the VCS is where provenance (tags) is anchored.\nAuthors must create a matching tag on their declared backing repo\n(GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure\nDevOps, CodeCommit, Radicle, or self-hosted) before publishing.\n\n`jj` and Sapling are git-compatible and push to git remotes, so their\nprovenance is verified through git tags.", "type": "string", diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index 52c037a..c30177e 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -83,6 +83,13 @@ "string", "null" ] + }, + "target": { + "description": "Which language subtree to take from **polyglot** dependencies (see\n[`TargetSection`]). Omitted = infer from the project (`package.json` →\n`node`, `go.mod` → `go`, `pyproject.toml` → `python`, …). Naming a\ntarget a dependency does not publish is an error rather than a silent\nfallback: the consumer asked for something specific.", + "type": [ + "string", + "null" + ] } } }, @@ -148,6 +155,13 @@ "test": null } }, + "targets": { + "description": "Language subtrees for a **polyglot package** — one repo shipping the\nsame library for several ecosystems (e.g. `node/`, `python/`, `go/`).\nKeyed by ecosystem name; the value says which subdirectory is that\necosystem's package root. On install the consumer resolves one target\nand only that subtree is materialized, so a Python project gets the\nPython source at its import root rather than a tree it has to reach\ninto. Absent (the common case) = single-language package, whole tree.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/TargetSection" + } + }, "workspace": { "description": "Monorepo workspace declaration (zed-docs issue #7); only meaningful in\na workspace root manifest. When present, `zed install` at this root\nresolves every member against one store and writes one `.zpkg.lock`;\nmember→member dependencies link by path instead of going through the\nregistry.", "anyOf": [ @@ -285,6 +299,33 @@ } } }, + "TargetSection": { + "description": "One ecosystem's slice of a polyglot package — and, on publish, its own\nindependently installable package.\n\nA repo like `fiducia-clients` carrying `clients/ts`, `clients/java`, and\n`clients/go` declares one target each. `zed publish` then emits **one\nartifact per target**, named `-` by default:\n\n```text\nfiducia/fiducia-clients-nodejs@1.1.2 <- clients/ts only\nfiducia/fiducia-clients-java@1.1.2 <- clients/java only\nfiducia/fiducia-clients-golang@1.1.2 <- clients/go only\n```\n\nOne source of truth and one version in the repo; N packages on the wire.\nA Java consumer downloads only Java bytes — the decisive advantage over\nshipping one fat artifact and slicing it at install time.", + "type": "object", + "properties": { + "adapter": { + "description": "Ecosystem adapter consumers of THIS target should use (`node`, `java`,\n`none`). Recorded in the published per-target manifest so a consumer\ngets the right wiring without configuring it.", + "type": [ + "string", + "null" + ] + }, + "dir": { + "description": "Package-relative directory that is this ecosystem's package root, e.g.\n`python` or `clients/go`. Must be a safe relative path (no leading `/`,\nno `..`) so a target can never escape the package.", + "type": "string" + }, + "name": { + "description": "Published package name for this target. Defaults to\n`-` (e.g. `fiducia-clients-java`). Set it to\nbreak out of the suffix convention when an ecosystem expects a\ndifferent spelling.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "dir" + ] + }, "Vcs": { "description": "Version-control systems a package's source repository can live on.\n\nzed-pkg is VCS-agnostic by design: what gets installed is always a\nregistry artifact, and the VCS is where provenance (tags) is anchored.\nAuthors must create a matching tag on their declared backing repo\n(GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure\nDevOps, CodeCommit, Radicle, or self-hosted) before publishing.\n\n`jj` and Sapling are git-compatible and push to git remotes, so their\nprovenance is verified through git tags.", "type": "string", From b676f193b625619b11e1048d01a1c0a1a124562f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 17:14:20 -0500 Subject: [PATCH 12/71] feat: publish isolated packages from polyglot targets --- schemas/manifest.json | 41 ++++++++++++++++++++++++++++++++ schemas/publish-meta.json | 41 ++++++++++++++++++++++++++++++++ src/excludes.rs | 18 ++++++++++++++ src/manifest.rs | 49 +++++++++++++++++++++++++++++++++++++++ tests/roundtrip.rs | 36 ++++++++++++++++++++++++++++ 5 files changed, 185 insertions(+) diff --git a/schemas/manifest.json b/schemas/manifest.json index 968e208..7026acd 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -62,6 +62,13 @@ "test": null } }, + "targets": { + "description": "Language subtrees for a **polyglot package** — one repo shipping the\nsame library for several ecosystems (e.g. `node/`, `python/`, `go/`).\nKeyed by ecosystem name; the value says which subdirectory is that\necosystem's package root. On install the consumer resolves one target\nand only that subtree is materialized, so a Python project gets the\nPython source at its import root rather than a tree it has to reach\ninto. Absent (the common case) = single-language package, whole tree.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/TargetSection" + } + }, "workspace": { "description": "Monorepo workspace declaration (zed-docs issue #7); only meaningful in\na workspace root manifest. When present, `zed install` at this root\nresolves every member against one store and writes one `.zpkg.lock`;\nmember→member dependencies link by path instead of going through the\nregistry.", "anyOf": [ @@ -115,6 +122,13 @@ "string", "null" ] + }, + "target": { + "description": "Which language subtree to take from **polyglot** dependencies (see\n[`TargetSection`]). Omitted = infer from the project (`package.json` →\n`node`, `go.mod` → `go`, `pyproject.toml` → `python`, …). Naming a\ntarget a dependency does not publish is an error rather than a silent\nfallback: the consumer asked for something specific.", + "type": [ + "string", + "null" + ] } } }, @@ -239,6 +253,33 @@ } } }, + "TargetSection": { + "description": "One ecosystem's slice of a polyglot package — and, on publish, its own\nindependently installable package.\n\nA repo like `fiducia-clients` carrying `clients/ts`, `clients/java`, and\n`clients/go` declares one target each. `zed publish` then emits **one\nartifact per target**, named `-` by default:\n\n```text\nfiducia/fiducia-clients-nodejs@1.1.2 <- clients/ts only\nfiducia/fiducia-clients-java@1.1.2 <- clients/java only\nfiducia/fiducia-clients-golang@1.1.2 <- clients/go only\n```\n\nOne source of truth and one version in the repo; N packages on the wire.\nA Java consumer downloads only Java bytes — the decisive advantage over\nshipping one fat artifact and slicing it at install time.", + "type": "object", + "properties": { + "adapter": { + "description": "Ecosystem adapter consumers of THIS target should use (`node`, `java`,\n`none`). Recorded in the published per-target manifest so a consumer\ngets the right wiring without configuring it.", + "type": [ + "string", + "null" + ] + }, + "dir": { + "description": "Package-relative directory that is this ecosystem's package root, e.g.\n`python` or `clients/go`. Must be a safe relative path (no leading `/`,\nno `..`) so a target can never escape the package.", + "type": "string" + }, + "name": { + "description": "Published package name for this target. Defaults to\n`-` (e.g. `fiducia-clients-java`). Set it to\nbreak out of the suffix convention when an ecosystem expects a\ndifferent spelling.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "dir" + ] + }, "Vcs": { "description": "Version-control systems a package's source repository can live on.\n\nzed-pkg is VCS-agnostic by design: what gets installed is always a\nregistry artifact, and the VCS is where provenance (tags) is anchored.\nAuthors must create a matching tag on their declared backing repo\n(GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure\nDevOps, CodeCommit, Radicle, or self-hosted) before publishing.\n\n`jj` and Sapling are git-compatible and push to git remotes, so their\nprovenance is verified through git tags.", "type": "string", diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index 52c037a..c30177e 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -83,6 +83,13 @@ "string", "null" ] + }, + "target": { + "description": "Which language subtree to take from **polyglot** dependencies (see\n[`TargetSection`]). Omitted = infer from the project (`package.json` →\n`node`, `go.mod` → `go`, `pyproject.toml` → `python`, …). Naming a\ntarget a dependency does not publish is an error rather than a silent\nfallback: the consumer asked for something specific.", + "type": [ + "string", + "null" + ] } } }, @@ -148,6 +155,13 @@ "test": null } }, + "targets": { + "description": "Language subtrees for a **polyglot package** — one repo shipping the\nsame library for several ecosystems (e.g. `node/`, `python/`, `go/`).\nKeyed by ecosystem name; the value says which subdirectory is that\necosystem's package root. On install the consumer resolves one target\nand only that subtree is materialized, so a Python project gets the\nPython source at its import root rather than a tree it has to reach\ninto. Absent (the common case) = single-language package, whole tree.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/TargetSection" + } + }, "workspace": { "description": "Monorepo workspace declaration (zed-docs issue #7); only meaningful in\na workspace root manifest. When present, `zed install` at this root\nresolves every member against one store and writes one `.zpkg.lock`;\nmember→member dependencies link by path instead of going through the\nregistry.", "anyOf": [ @@ -285,6 +299,33 @@ } } }, + "TargetSection": { + "description": "One ecosystem's slice of a polyglot package — and, on publish, its own\nindependently installable package.\n\nA repo like `fiducia-clients` carrying `clients/ts`, `clients/java`, and\n`clients/go` declares one target each. `zed publish` then emits **one\nartifact per target**, named `-` by default:\n\n```text\nfiducia/fiducia-clients-nodejs@1.1.2 <- clients/ts only\nfiducia/fiducia-clients-java@1.1.2 <- clients/java only\nfiducia/fiducia-clients-golang@1.1.2 <- clients/go only\n```\n\nOne source of truth and one version in the repo; N packages on the wire.\nA Java consumer downloads only Java bytes — the decisive advantage over\nshipping one fat artifact and slicing it at install time.", + "type": "object", + "properties": { + "adapter": { + "description": "Ecosystem adapter consumers of THIS target should use (`node`, `java`,\n`none`). Recorded in the published per-target manifest so a consumer\ngets the right wiring without configuring it.", + "type": [ + "string", + "null" + ] + }, + "dir": { + "description": "Package-relative directory that is this ecosystem's package root, e.g.\n`python` or `clients/go`. Must be a safe relative path (no leading `/`,\nno `..`) so a target can never escape the package.", + "type": "string" + }, + "name": { + "description": "Published package name for this target. Defaults to\n`-` (e.g. `fiducia-clients-java`). Set it to\nbreak out of the suffix convention when an ecosystem expects a\ndifferent spelling.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "dir" + ] + }, "Vcs": { "description": "Version-control systems a package's source repository can live on.\n\nzed-pkg is VCS-agnostic by design: what gets installed is always a\nregistry artifact, and the VCS is where provenance (tags) is anchored.\nAuthors must create a matching tag on their declared backing repo\n(GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure\nDevOps, CodeCommit, Radicle, or self-hosted) before publishing.\n\n`jj` and Sapling are git-compatible and push to git remotes, so their\nprovenance is verified through git tags.", "type": "string", diff --git a/src/excludes.rs b/src/excludes.rs index 31f900d..ae8d931 100644 --- a/src/excludes.rs +++ b/src/excludes.rs @@ -32,6 +32,24 @@ pub const DEFAULT_EXCLUDES: &[&str] = &[ ".zed/**", ".zpkg.lock", "zed_modules/**", + "node_modules/**", + "**/node_modules/**", + "target/**", + "**/target/**", + ".dart_tool/**", + "**/.dart_tool/**", + ".gradle/**", + "**/.gradle/**", + "build/**", + "**/build/**", + "__pycache__/**", + "**/__pycache__/**", + ".venv/**", + "**/.venv/**", + "_build/**", + "**/_build/**", + "deps/**", + "**/deps/**", ]; /// Patterns that are always kept, even when an exclude matches them. diff --git a/src/manifest.rs b/src/manifest.rs index 3abbcf2..9b94037 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -404,6 +404,8 @@ impl Manifest { )); } } + let mut target_dirs = BTreeMap::<&str, &str>::new(); + let mut published_names = BTreeMap::::new(); for (name, target) in &self.targets { if !is_target_name(name) { return Err(ManifestError::InvalidTarget( @@ -417,6 +419,53 @@ impl Manifest { format!("dir `{}` must be a relative path without `..`", target.dir), )); } + if let Some(previous) = target_dirs.insert(target.dir.as_str(), name.as_str()) { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "dir `{}` is already owned by target `{previous}`; every target must have an isolated source root", + target.dir + ), + )); + } + let published_name = target + .name + .clone() + .unwrap_or_else(|| format!("{}-{name}", self.package.name)); + if !is_slug(&published_name) { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "published name `{published_name}` must match [a-z0-9][a-z0-9-]*[a-z0-9]" + ), + )); + } + if published_name == self.package.name { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "published name `{published_name}` collides with the polyglot source package" + ), + )); + } + if let Some(previous) = published_names.insert(published_name.clone(), name.as_str()) { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "published name `{published_name}` is already used by target `{previous}`" + ), + )); + } + if let Some(adapter) = target.adapter.as_deref() + && !matches!(adapter, "node" | "java" | "none") + { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" + ), + )); + } } // A blank request means "no target", the same way a blank // `[install].dir` falls back to the default rather than erroring. diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 76cb7e5..0afac66 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -124,6 +124,9 @@ fn excludes_respect_include_readme() { assert!(readme_kept.iter().any(|p| p == "extra/**")); assert!(DEFAULT_EXCLUDES.contains(&".github/**")); + assert!(DEFAULT_EXCLUDES.contains(&"**/node_modules/**")); + assert!(DEFAULT_EXCLUDES.contains(&"**/.dart_tool/**")); + assert!(DEFAULT_EXCLUDES.contains(&"**/build/**")); assert!(ALWAYS_INCLUDE.contains(&"LICENSE*")); assert!(ALWAYS_INCLUDE.contains(&".zpkg.toml")); } @@ -227,6 +230,39 @@ fn target_dirs_and_names_are_validated() { Manifest::parse(&bad_request), Err(ManifestError::InvalidTarget(_, _)) )); + + let bad_published_name = POLYGLOT.replace( + "[targets.node]\ndir = \"node\"", + "[targets.node]\ndir = \"node\"\nname = \"Node SDK\"", + ); + assert!(matches!( + Manifest::parse(&bad_published_name), + Err(ManifestError::InvalidTarget(_, _)) + )); + + let duplicate_dir = POLYGLOT.replace(r#"dir = "python""#, r#"dir = "node""#); + assert!(matches!( + Manifest::parse(&duplicate_dir), + Err(ManifestError::InvalidTarget(_, _)) + )); + + let duplicate_name = POLYGLOT.replace( + "[targets.python]\ndir = \"python\"", + "[targets.python]\ndir = \"python\"\nname = \"polyglot-lib-node\"", + ); + assert!(matches!( + Manifest::parse(&duplicate_name), + Err(ManifestError::InvalidTarget(_, _)) + )); + + let bad_adapter = POLYGLOT.replace( + "[targets.node]\ndir = \"node\"", + "[targets.node]\ndir = \"node\"\nadapter = \"npm\"", + ); + assert!(matches!( + Manifest::parse(&bad_adapter), + Err(ManifestError::InvalidTarget(_, _)) + )); } #[test] From 940befe36227f3fd6a23ae718c9131caae0f3978 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Sun, 26 Jul 2026 10:17:30 -0500 Subject: [PATCH 13/71] registry: package tags + list/RAG DTOs (PackageListResponse, SemanticSearch*, EmbeddingUpsertRequest); regen schemas Companion to zed-api-server's package_embedding (pgvector 2050) + tags work. Co-Authored-By: Claude Fable 5 --- examples/generate_schemas.rs | 6 ++ schemas/embedding-upsert-request.json | 28 +++++++++ schemas/package-list-response.json | 60 +++++++++++++++++++ schemas/package-metadata.json | 7 +++ schemas/search-response.json | 7 +++ schemas/semantic-search-request.json | 37 ++++++++++++ schemas/semantic-search-response.json | 51 ++++++++++++++++ src/excludes.rs | 9 +++ src/registry.rs | 85 ++++++++++++++++++++++++++- 9 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 schemas/embedding-upsert-request.json create mode 100644 schemas/package-list-response.json create mode 100644 schemas/semantic-search-request.json create mode 100644 schemas/semantic-search-response.json diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index 0db10a7..0de25cb 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -38,4 +38,10 @@ fn main() { write::(dir, "sync-write-mode"); write::(dir, "sync-error-policy"); write::(dir, "sync-conflict-resolution"); + + // Registry list + RAG/embedding search DTOs. + write::(dir, "package-list-response"); + write::(dir, "semantic-search-request"); + write::(dir, "semantic-search-response"); + write::(dir, "embedding-upsert-request"); } diff --git a/schemas/embedding-upsert-request.json b/schemas/embedding-upsert-request.json new file mode 100644 index 0000000..f6c51c2 --- /dev/null +++ b/schemas/embedding-upsert-request.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "EmbeddingUpsertRequest", + "description": "Body of `PUT /v1/packages/{org}/{name}/embedding` — upsert a package's\nembedding for one model (re-embedding replaces in place).", + "type": "object", + "properties": { + "content": { + "description": "The text that was embedded (kept for provenance / re-embedding).", + "type": "string" + }, + "embedding": { + "description": "The embedding (native width; padded to 2050 server-side).", + "type": "array", + "items": { + "type": "number", + "format": "float" + } + }, + "model": { + "type": "string" + } + }, + "required": [ + "model", + "embedding", + "content" + ] +} diff --git a/schemas/package-list-response.json b/schemas/package-list-response.json new file mode 100644 index 0000000..78aecd9 --- /dev/null +++ b/schemas/package-list-response.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PackageListResponse", + "description": "Response for `GET /v1/packages` (list all).", + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/PackageSummary" + } + }, + "total": { + "description": "Total packages matching the filter (before limit/offset).", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "items", + "total" + ], + "$defs": { + "PackageSummary": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "latest": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "tags": { + "description": "Free-form tags for filtering/discovery.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "org", + "name" + ] + } + } +} diff --git a/schemas/package-metadata.json b/schemas/package-metadata.json index 578460a..5079cb0 100644 --- a/schemas/package-metadata.json +++ b/schemas/package-metadata.json @@ -24,6 +24,13 @@ "repo_url": { "type": "string" }, + "tags": { + "description": "Free-form tags for filtering/discovery (multi-tag lookup).", + "type": "array", + "items": { + "type": "string" + } + }, "vcs": { "$ref": "#/$defs/Vcs" }, diff --git a/schemas/search-response.json b/schemas/search-response.json index 38e6191..86d2a0c 100644 --- a/schemas/search-response.json +++ b/schemas/search-response.json @@ -38,6 +38,13 @@ }, "org": { "type": "string" + }, + "tags": { + "description": "Free-form tags for filtering/discovery.", + "type": "array", + "items": { + "type": "string" + } } }, "required": [ diff --git a/schemas/semantic-search-request.json b/schemas/semantic-search-request.json new file mode 100644 index 0000000..9682ee0 --- /dev/null +++ b/schemas/semantic-search-request.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SemanticSearchRequest", + "description": "Body of `POST /v1/search/semantic` (RAG). The caller computes the query\nembedding with its model; the server ranks stored package embeddings from\nthe SAME model by cosine distance. Vectors up to 2050 dims are accepted and\nzero-padded server-side.", + "type": "object", + "properties": { + "embedding": { + "description": "The query embedding (native width; padded to 2050 server-side).", + "type": "array", + "items": { + "type": "number", + "format": "float" + } + }, + "limit": { + "type": "integer", + "format": "uint32", + "default": 20, + "minimum": 0 + }, + "model": { + "description": "Embedding model id, e.g. `openai/text-embedding-3-small`. Only stored\nembeddings from this model are searched.", + "type": "string" + }, + "tags": { + "description": "Optional tag filter: results must carry all of these tags.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "model", + "embedding" + ] +} diff --git a/schemas/semantic-search-response.json b/schemas/semantic-search-response.json new file mode 100644 index 0000000..9a89e4c --- /dev/null +++ b/schemas/semantic-search-response.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SemanticSearchResponse", + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/SemanticHit" + } + } + }, + "required": [ + "items" + ], + "$defs": { + "SemanticHit": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "distance": { + "description": "Cosine distance (0 = identical direction, 2 = opposite). Lower is nearer.", + "type": "number", + "format": "double" + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "org", + "name", + "distance" + ] + } + } +} diff --git a/src/excludes.rs b/src/excludes.rs index ae8d931..7481e5a 100644 --- a/src/excludes.rs +++ b/src/excludes.rs @@ -18,7 +18,16 @@ pub const DEFAULT_EXCLUDES: &[&str] = &[ "**/*.test.*", "**/*.spec.*", "**/*_test.go", + // Python tests come in both spellings; `test_*.py` is what unittest + // discovers by default, so omitting it leaks tests into every published + // Python slice. "**/*_test.py", + "**/test_*.py", + "**/conftest.py", + // Ruby tests living outside `spec/` (minitest's `*_test.rb`, and RSpec + // files kept beside their subject). + "**/*_test.rb", + "**/*_spec.rb", ".github/**", ".gitlab/**", ".gitlab-ci.yml", diff --git a/src/registry.rs b/src/registry.rs index 837a67b..61ce4ed 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -37,11 +37,30 @@ pub fn artifact_path(sha256: &str) -> String { format!("{API_V1}/artifacts/{sha256}") } -/// `GET ?q=` — search packages. +/// `GET ?q=` — search packages by name/description. Also accepts repeatable +/// `?tag=` filters (a package must carry all given tags) and `?limit=`. pub fn search_path() -> String { format!("{API_V1}/search") } +/// `GET` — list all packages, newest first. Accepts `?tag=` (repeatable), +/// `?limit=`, and `?offset=` for pagination. +pub fn packages_list_path() -> String { + format!("{API_V1}/packages") +} + +/// `POST` — RAG / semantic search: nearest packages to a query embedding +/// within one model's space. Body is [`SemanticSearchRequest`]. +pub fn semantic_search_path() -> String { + format!("{API_V1}/search/semantic") +} + +/// `PUT` (bearer token) — upsert a package's embedding for one model. Body is +/// [`EmbeddingUpsertRequest`]. +pub fn embedding_path(org: &str, name: &str) -> String { + format!("{API_V1}/packages/{org}/{name}/embedding") +} + /// `GET` — serve one file out of a published artifact, unpkg-style /// (`/v1/files/acme/http-kit/1.2.0/dist/style.css`). Lets the web consume /// package contents directly from the edge without installing. @@ -80,6 +99,9 @@ pub struct PackageSummary { pub description: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub latest: Option, + /// Free-form tags for filtering/discovery. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -98,6 +120,9 @@ pub struct PackageMetadata { pub version_scheme: crate::version::VersionScheme, #[serde(default, skip_serializing_if = "Option::is_none")] pub latest: Option, + /// Free-form tags for filtering/discovery (multi-tag lookup). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, /// All published, non-yanked versions, newest first. pub versions: Vec, } @@ -179,6 +204,64 @@ pub struct SearchResponse { pub items: Vec, } +/// Response for `GET /v1/packages` (list all). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PackageListResponse { + pub items: Vec, + /// Total packages matching the filter (before limit/offset). + pub total: u64, +} + +/// Body of `POST /v1/search/semantic` (RAG). The caller computes the query +/// embedding with its model; the server ranks stored package embeddings from +/// the SAME model by cosine distance. Vectors up to 2050 dims are accepted and +/// zero-padded server-side. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SemanticSearchRequest { + /// Embedding model id, e.g. `openai/text-embedding-3-small`. Only stored + /// embeddings from this model are searched. + pub model: String, + /// The query embedding (native width; padded to 2050 server-side). + pub embedding: Vec, + #[serde(default = "default_semantic_limit")] + pub limit: u32, + /// Optional tag filter: results must carry all of these tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +fn default_semantic_limit() -> u32 { + 20 +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SemanticHit { + pub org: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Cosine distance (0 = identical direction, 2 = opposite). Lower is nearer. + pub distance: f64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SemanticSearchResponse { + pub items: Vec, +} + +/// Body of `PUT /v1/packages/{org}/{name}/embedding` — upsert a package's +/// embedding for one model (re-embedding replaces in place). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct EmbeddingUpsertRequest { + pub model: String, + /// The embedding (native width; padded to 2050 server-side). + pub embedding: Vec, + /// The text that was embedded (kept for provenance / re-embedding). + pub content: String, +} + /// A state-changing action recorded in an org's audit log. Reads are never /// audited — only mutations of published state and of the namespace itself, so /// the log answers "who changed what" without drowning in traffic. From 90534dd8468d98281356f2767f1a0b498598f868 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sun, 26 Jul 2026 12:11:03 -0500 Subject: [PATCH 14/71] fix: exclude zed pack output from artifacts --- src/excludes.rs | 1 + tests/roundtrip.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/excludes.rs b/src/excludes.rs index 7481e5a..d174841 100644 --- a/src/excludes.rs +++ b/src/excludes.rs @@ -39,6 +39,7 @@ pub const DEFAULT_EXCLUDES: &[&str] = &[ "CHANGELOG*", ".zedignore", ".zed/**", + ".zed-pack/**", ".zpkg.lock", "zed_modules/**", "node_modules/**", diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 0afac66..4372b28 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -124,6 +124,7 @@ fn excludes_respect_include_readme() { assert!(readme_kept.iter().any(|p| p == "extra/**")); assert!(DEFAULT_EXCLUDES.contains(&".github/**")); + assert!(DEFAULT_EXCLUDES.contains(&".zed-pack/**")); assert!(DEFAULT_EXCLUDES.contains(&"**/node_modules/**")); assert!(DEFAULT_EXCLUDES.contains(&"**/.dart_tool/**")); assert!(DEFAULT_EXCLUDES.contains(&"**/build/**")); From 415e871b1fb3dd97744c134351408a3224805dfb Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 00:01:34 -0500 Subject: [PATCH 15/71] Exclude generated zed pack output from artifacts Support whole-repository targets safely by excluding generated pack output and covering the boundary in tests. --- src/excludes.rs | 1 + tests/roundtrip.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/excludes.rs b/src/excludes.rs index 7481e5a..d174841 100644 --- a/src/excludes.rs +++ b/src/excludes.rs @@ -39,6 +39,7 @@ pub const DEFAULT_EXCLUDES: &[&str] = &[ "CHANGELOG*", ".zedignore", ".zed/**", + ".zed-pack/**", ".zpkg.lock", "zed_modules/**", "node_modules/**", diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 0afac66..4372b28 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -124,6 +124,7 @@ fn excludes_respect_include_readme() { assert!(readme_kept.iter().any(|p| p == "extra/**")); assert!(DEFAULT_EXCLUDES.contains(&".github/**")); + assert!(DEFAULT_EXCLUDES.contains(&".zed-pack/**")); assert!(DEFAULT_EXCLUDES.contains(&"**/node_modules/**")); assert!(DEFAULT_EXCLUDES.contains(&"**/.dart_tool/**")); assert!(DEFAULT_EXCLUDES.contains(&"**/build/**")); From ce84fde03fde66624b63d6a0b24df7a75abca1f1 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:21:48 -0500 Subject: [PATCH 16/71] ci: apply DEN-100 native release schema --- .github/workflows/apply-den-100-temporary.yml | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 .github/workflows/apply-den-100-temporary.yml diff --git a/.github/workflows/apply-den-100-temporary.yml b/.github/workflows/apply-den-100-temporary.yml new file mode 100644 index 0000000..ff34c9e --- /dev/null +++ b/.github/workflows/apply-den-100-temporary.yml @@ -0,0 +1,363 @@ +name: Apply DEN-100 native release schema (temporary) + +on: + push: + branches: + - feat/den-100-native-release-routing + workflow_dispatch: + +permissions: + contents: write + +jobs: + apply: + if: github.repository == 'zed-pkg/zed-interfaces' && github.actor == 'ORESoftware' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: feat/den-100-native-release-routing + fetch-depth: 0 + persist-credentials: true + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + components: rustfmt + - name: Apply typed native release routing + run: | + python3 - <<'PY' + from pathlib import Path + + path = Path("src/manifest.rs") + text = path.read_text(encoding="utf-8") + + target_old = ''' #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter: Option, + } + + /// A post-extract build step.'''.replace(" ", " ") + target_new = ''' #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter: Option, + /// Optional routing to the target's native ecosystem registry. This is + /// declarative metadata only: the native manifest remains authoritative, + /// and arbitrary commands are intentionally not representable here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native: Option, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct NativeReleaseSection { + pub registry: NativeRegistry, + pub package: String, + } + + #[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, + )] + #[serde(rename_all = "kebab-case")] + pub enum NativeRegistry { + Npm, + CratesIo, + } + + impl NativeRegistry { + pub fn as_str(self) -> &'static str { + match self { + Self::Npm => "npm", + Self::CratesIo => "crates-io", + } + } + + fn validate_package(self, package: &str) -> Result<(), String> { + let valid = match self { + Self::Npm => is_valid_npm_package(package), + Self::CratesIo => is_valid_crates_package(package), + }; + if valid { + Ok(()) + } else { + Err(format!( + "package `{package}` is not a valid {} package identity", + self.as_str() + )) + } + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct NativeReleaseRoute { + pub target: String, + pub dir: String, + pub registry: NativeRegistry, + pub package: String, + } + + fn is_valid_npm_component(value: &str) -> bool { + !value.is_empty() + && value.len() <= 214 + && !value.starts_with(['.', '_']) + && !value.contains("..") + && value.chars().all(|c| { + c.is_ascii_lowercase() + || c.is_ascii_digit() + || matches!(c, '-' | '_' | '.' | '~') + }) + } + + fn is_valid_npm_package(value: &str) -> bool { + if let Some(scoped) = value.strip_prefix('@') { + let Some((scope, package)) = scoped.split_once('/') else { + return false; + }; + !package.contains('/') + && is_valid_npm_component(scope) + && is_valid_npm_component(package) + } else { + !value.contains('/') && is_valid_npm_component(value) + } + } + + fn is_valid_crates_package(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && !value.starts_with(['-', '_']) + && !value.ends_with(['-', '_']) + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) + } + + /// A post-extract build step.'''.replace(" ", " ") + assert target_old in text, "TargetSection insertion point changed" + text = text.replace(target_old, target_new, 1) + + error_old = ''' #[error("invalid target `{0}`: {1}")] + InvalidTarget(String, String), + #[error("manifest toml error: {0}")]'''.replace(" ", " ") + error_new = ''' #[error("invalid target `{0}`: {1}")] + InvalidTarget(String, String), + #[error("invalid native release route for target `{0}`: {1}")] + InvalidNativeRoute(String, String), + #[error("manifest toml error: {0}")]'''.replace(" ", " ") + assert error_old in text, "ManifestError insertion point changed" + text = text.replace(error_old, error_new, 1) + + maps_old = ''' let mut target_dirs = BTreeMap::<&str, &str>::new(); + let mut published_names = BTreeMap::::new();'''.replace(" ", " ") + maps_new = ''' let mut target_dirs = BTreeMap::<&str, &str>::new(); + let mut published_names = BTreeMap::::new(); + let mut native_routes = BTreeMap::<(NativeRegistry, String), &str>::new();'''.replace(" ", " ") + assert maps_old in text, "target validation maps changed" + text = text.replace(maps_old, maps_new, 1) + + adapter_old = ''' if let Some(adapter) = target.adapter.as_deref() + && !matches!(adapter, "node" | "java" | "none") + { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" + ), + )); + } + }'''.replace(" ", " ") + adapter_new = ''' if let Some(adapter) = target.adapter.as_deref() + && !matches!(adapter, "node" | "java" | "none") + { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" + ), + )); + } + if let Some(native) = &target.native { + if target.dir == "." { + return Err(ManifestError::InvalidNativeRoute( + name.clone(), + "the whole-repository target cannot publish to a native registry" + .to_string(), + )); + } + native + .registry + .validate_package(&native.package) + .map_err(|reason| { + ManifestError::InvalidNativeRoute(name.clone(), reason) + })?; + let route = (native.registry, native.package.clone()); + if let Some(previous) = native_routes.insert(route, name.as_str()) { + return Err(ManifestError::InvalidNativeRoute( + name.clone(), + format!( + "{} package `{}` is already routed by target `{previous}`", + native.registry.as_str(), + native.package + ), + )); + } + } + }'''.replace(" ", " ") + assert adapter_old in text, "target adapter validation changed" + text = text.replace(adapter_old, adapter_new, 1) + + method_old = ''' /// Every `(target, published name)` pair this manifest fans out to, sorted + /// by target for deterministic publish order and output. + pub fn target_package_names(&self) -> Vec<(String, String)> {'''.replace(" ", " ") + method_new = ''' /// Native release routes sorted by target name, suitable for deterministic + /// credential-free planning before any registry adapter executes. + pub fn native_release_routes(&self) -> Vec { + self.targets + .iter() + .filter_map(|(target, section)| { + section.native.as_ref().map(|native| NativeReleaseRoute { + target: target.clone(), + dir: section.dir.clone(), + registry: native.registry, + package: native.package.clone(), + }) + }) + .collect() + } + + /// Every `(target, published name)` pair this manifest fans out to, sorted + /// by target for deterministic publish order and output. + pub fn target_package_names(&self) -> Vec<(String, String)> {'''.replace(" ", " ") + assert method_old in text, "native route method insertion point changed" + text = text.replace(method_old, method_new, 1) + + path.write_text(text, encoding="utf-8") + + tests = r'''use zed_interfaces::manifest::{Manifest, ManifestError, NativeRegistry}; + + fn manifest(targets: &str) -> String { + format!( + r#" + [package] + org = "acme" + name = "clients" + version = "1.2.3" + + [package.repository] + url = "https://github.com/acme/clients" + + {targets} + "# + ) + } + + #[test] + fn native_routes_parse_roundtrip_and_stay_sorted() { + let parsed = Manifest::parse(&manifest( + r#" + [targets.rust] + dir = "clients/rust" + + [targets.rust.native] + registry = "crates-io" + package = "acme-client" + + [targets.nodejs] + dir = "clients/typescript" + adapter = "node" + + [targets.nodejs.native] + registry = "npm" + package = "@acme/client" + "#, + )) + .unwrap(); + + let routes = parsed.native_release_routes(); + assert_eq!(routes.len(), 2); + assert_eq!(routes[0].target, "nodejs"); + assert_eq!(routes[0].registry, NativeRegistry::Npm); + assert_eq!(routes[0].package, "@acme/client"); + assert_eq!(routes[1].target, "rust"); + assert_eq!(routes[1].registry, NativeRegistry::CratesIo); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("[targets.nodejs.native]")); + assert!(encoded.contains("registry = \"npm\"")); + Manifest::parse(&encoded).unwrap(); + } + + #[test] + fn whole_repository_target_cannot_route_to_a_native_registry() { + let error = Manifest::parse(&manifest( + r#" + [targets.repository] + dir = "." + + [targets.repository.native] + registry = "npm" + package = "acme-repository" + "#, + )) + .unwrap_err(); + assert!(matches!(error, ManifestError::InvalidNativeRoute(_, _))); + } + + #[test] + fn native_package_identities_are_registry_specific() { + for targets in [ + r#" + [targets.nodejs] + dir = "clients/typescript" + [targets.nodejs.native] + registry = "npm" + package = "Bad Package" + "#, + r#" + [targets.rust] + dir = "clients/rust" + [targets.rust.native] + registry = "crates-io" + package = "acme/client" + "#, + ] { + assert!(matches!( + Manifest::parse(&manifest(targets)), + Err(ManifestError::InvalidNativeRoute(_, _)) + )); + } + } + + #[test] + fn duplicate_native_destinations_are_rejected() { + let error = Manifest::parse(&manifest( + r#" + [targets.nodejs] + dir = "clients/typescript" + [targets.nodejs.native] + registry = "npm" + package = "@acme/client" + + [targets.browser] + dir = "clients/browser" + [targets.browser.native] + registry = "npm" + package = "@acme/client" + "#, + )) + .unwrap_err(); + assert!(matches!(error, ManifestError::InvalidNativeRoute(_, _))); + } + ''' + Path("tests/native_release.rs").write_text( + "\n".join(line[10:] if line.startswith(" ") else line for line in tests.splitlines()) + "\n", + encoding="utf-8", + ) + PY + - name: Format and test + run: | + cargo fmt --all + cargo test --locked + - name: Commit schema and tests to the feature branch + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/manifest.rs tests/native_release.rs + git commit -m "feat: declare native release routing" + git push origin HEAD:feat/den-100-native-release-routing From 36d99239139a23b01f9f0d2e99ddba634ae8173b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:23:15 -0500 Subject: [PATCH 17/71] ci: run DEN-100 schema patch from PR --- .../workflows/apply-den-100-pr-temporary.yml | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/apply-den-100-pr-temporary.yml diff --git a/.github/workflows/apply-den-100-pr-temporary.yml b/.github/workflows/apply-den-100-pr-temporary.yml new file mode 100644 index 0000000..ef1af4a --- /dev/null +++ b/.github/workflows/apply-den-100-pr-temporary.yml @@ -0,0 +1,58 @@ +name: Apply DEN-100 native release schema from PR (temporary) + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: write + +jobs: + apply: + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + github.head_ref == 'feat/den-100-native-release-routing' && + github.actor == 'ORESoftware' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + persist-credentials: true + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + components: rustfmt + - name: Reuse and execute the reviewed patch body + run: | + python3 - <<'PY' + from pathlib import Path + + source = Path('.github/workflows/apply-den-100-temporary.yml').read_text(encoding='utf-8') + marker = " python3 - <<'PY'\n" + start = source.index(marker) + len(marker) + end = source.index("\n PY", start) + block = source[start:end] + script = "\n".join( + line[10:] if line.startswith(" ") else line + for line in block.splitlines() + ) + exec(compile(script, 'apply-den-100', 'exec')) + PY + - name: Format and test + run: | + cargo fmt --all + cargo test --locked + - name: Commit schema and tests to the feature branch + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/manifest.rs tests/native_release.rs + if git diff --cached --quiet; then + echo "Schema and tests are already current." + exit 0 + fi + git commit -m "feat: declare native release routing" + git push origin HEAD:feat/den-100-native-release-routing From 11b42399143651dea6ac1929834117365350a156 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:27:21 -0500 Subject: [PATCH 18/71] chore: add deterministic native schema patch --- scripts/apply_native_release_schema.py | 225 +++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 scripts/apply_native_release_schema.py diff --git a/scripts/apply_native_release_schema.py b/scripts/apply_native_release_schema.py new file mode 100644 index 0000000..d2707d7 --- /dev/null +++ b/scripts/apply_native_release_schema.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Apply the DEN-100 native release routing schema to src/manifest.rs. + +Temporary branch-local helper. It is intentionally deterministic and refuses to +continue when an expected insertion point has changed. +""" + +from pathlib import Path + + +PATH = Path("src/manifest.rs") +text = PATH.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one insertion point, found {count}") + text = text.replace(old, new, 1) + + +replace_once( + ''' #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter: Option, +} + +/// A post-extract build step.''', + ''' #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter: Option, + /// Optional routing to this target's native ecosystem registry. This is + /// declarative metadata only: the native manifest remains authoritative, + /// and arbitrary commands are intentionally not representable here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeReleaseSection { + pub registry: NativeRegistry, + pub package: String, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum NativeRegistry { + Npm, + CratesIo, +} + +impl NativeRegistry { + pub fn as_str(self) -> &'static str { + match self { + Self::Npm => "npm", + Self::CratesIo => "crates-io", + } + } + + fn validate_package(self, package: &str) -> Result<(), String> { + let valid = match self { + Self::Npm => is_valid_npm_package(package), + Self::CratesIo => is_valid_crates_package(package), + }; + if valid { + Ok(()) + } else { + Err(format!( + "package `{package}` is not a valid {} package identity", + self.as_str() + )) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeReleaseRoute { + pub target: String, + pub dir: String, + pub registry: NativeRegistry, + pub package: String, +} + +fn is_valid_npm_component(value: &str) -> bool { + !value.is_empty() + && value.len() <= 214 + && !value.starts_with('.') + && !value.starts_with('_') + && !value.contains("..") + && value.chars().all(|c| { + c.is_ascii_lowercase() + || c.is_ascii_digit() + || matches!(c, '-' | '_' | '.' | '~') + }) +} + +fn is_valid_npm_package(value: &str) -> bool { + if let Some(scoped) = value.strip_prefix('@') { + let Some((scope, package)) = scoped.split_once('/') else { + return false; + }; + !package.contains('/') + && is_valid_npm_component(scope) + && is_valid_npm_component(package) + } else { + !value.contains('/') && is_valid_npm_component(value) + } +} + +fn is_valid_crates_package(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && !value.starts_with('-') + && !value.starts_with('_') + && !value.ends_with('-') + && !value.ends_with('_') + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) +} + +/// A post-extract build step.''', + "TargetSection/native types", +) + +replace_once( + ''' #[error("invalid target `{0}`: {1}")] + InvalidTarget(String, String), + #[error("manifest toml error: {0}")]''', + ''' #[error("invalid target `{0}`: {1}")] + InvalidTarget(String, String), + #[error("invalid native release route for target `{0}`: {1}")] + InvalidNativeRoute(String, String), + #[error("manifest toml error: {0}")]''', + "ManifestError", +) + +replace_once( + ''' let mut target_dirs = BTreeMap::<&str, &str>::new(); + let mut published_names = BTreeMap::::new();''', + ''' let mut target_dirs = BTreeMap::<&str, &str>::new(); + let mut published_names = BTreeMap::::new(); + let mut native_routes = BTreeMap::<(NativeRegistry, String), &str>::new();''', + "target validation maps", +) + +replace_once( + ''' if let Some(adapter) = target.adapter.as_deref() + && !matches!(adapter, "node" | "java" | "none") + { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" + ), + )); + } + }''', + ''' if let Some(adapter) = target.adapter.as_deref() + && !matches!(adapter, "node" | "java" | "none") + { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" + ), + )); + } + if let Some(native) = &target.native { + if target.dir == "." { + return Err(ManifestError::InvalidNativeRoute( + name.clone(), + "the whole-repository target cannot publish to a native registry" + .to_string(), + )); + } + native + .registry + .validate_package(&native.package) + .map_err(|reason| ManifestError::InvalidNativeRoute(name.clone(), reason))?; + let route = (native.registry, native.package.clone()); + if let Some(previous) = native_routes.insert(route, name.as_str()) { + return Err(ManifestError::InvalidNativeRoute( + name.clone(), + format!( + "{} package `{}` is already routed by target `{previous}`", + native.registry.as_str(), + native.package + ), + )); + } + } + }''', + "native route validation", +) + +replace_once( + ''' /// Every `(target, published name)` pair this manifest fans out to, sorted + /// by target for deterministic publish order and output. + pub fn target_package_names(&self) -> Vec<(String, String)> {''', + ''' /// Native release routes sorted by target name, suitable for deterministic + /// credential-free planning before any registry adapter executes. + pub fn native_release_routes(&self) -> Vec { + self.targets + .iter() + .filter_map(|(target, section)| { + section.native.as_ref().map(|native| NativeReleaseRoute { + target: target.clone(), + dir: section.dir.clone(), + registry: native.registry, + package: native.package.clone(), + }) + }) + .collect() + } + + /// Every `(target, published name)` pair this manifest fans out to, sorted + /// by target for deterministic publish order and output. + pub fn target_package_names(&self) -> Vec<(String, String)> {''', + "native_release_routes method", +) + +PATH.write_text(text, encoding="utf-8") +print(f"updated {PATH}") From 3f0b919ded0b75c09b78bc1a67e69ca9f91436de Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:27:36 -0500 Subject: [PATCH 19/71] test: cover native release routing --- tests/native_release.rs | 115 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/native_release.rs diff --git a/tests/native_release.rs b/tests/native_release.rs new file mode 100644 index 0000000..5109af8 --- /dev/null +++ b/tests/native_release.rs @@ -0,0 +1,115 @@ +use zed_interfaces::manifest::{Manifest, ManifestError, NativeRegistry}; + +fn manifest(targets: &str) -> String { + format!( + r#" +[package] +org = "acme" +name = "clients" +version = "1.2.3" + +[package.repository] +url = "https://github.com/acme/clients" + +{targets} +"# + ) +} + +#[test] +fn native_routes_parse_roundtrip_and_stay_sorted() { + let parsed = Manifest::parse(&manifest( + r#" +[targets.rust] +dir = "clients/rust" + +[targets.rust.native] +registry = "crates-io" +package = "acme-client" + +[targets.nodejs] +dir = "clients/typescript" +adapter = "node" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +"#, + )) + .unwrap(); + + let routes = parsed.native_release_routes(); + assert_eq!(routes.len(), 2); + assert_eq!(routes[0].target, "nodejs"); + assert_eq!(routes[0].registry, NativeRegistry::Npm); + assert_eq!(routes[0].package, "@acme/client"); + assert_eq!(routes[1].target, "rust"); + assert_eq!(routes[1].registry, NativeRegistry::CratesIo); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("[targets.nodejs.native]")); + assert!(encoded.contains("registry = \"npm\"")); + Manifest::parse(&encoded).unwrap(); +} + +#[test] +fn whole_repository_target_cannot_route_to_a_native_registry() { + let error = Manifest::parse(&manifest( + r#" +[targets.repository] +dir = "." + +[targets.repository.native] +registry = "npm" +package = "acme-repository" +"#, + )) + .unwrap_err(); + assert!(matches!(error, ManifestError::InvalidNativeRoute(_, _))); +} + +#[test] +fn native_package_identities_are_registry_specific() { + for targets in [ + r#" +[targets.nodejs] +dir = "clients/typescript" +[targets.nodejs.native] +registry = "npm" +package = "Bad Package" +"#, + r#" +[targets.rust] +dir = "clients/rust" +[targets.rust.native] +registry = "crates-io" +package = "acme/client" +"#, + ] { + assert!(matches!( + Manifest::parse(&manifest(targets)), + Err(ManifestError::InvalidNativeRoute(_, _)) + )); + } +} + +#[test] +fn duplicate_native_destinations_are_rejected() { + let error = Manifest::parse(&manifest( + r#" +[targets.nodejs] +dir = "clients/typescript" +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" + +[targets.browser] +dir = "clients/browser" +[targets.browser.native] +registry = "npm" +package = "@acme/client" +"#, + )) + .unwrap_err(); + assert!(matches!(error, ManifestError::InvalidNativeRoute(_, _))); +} From fa5285734e4c9f8a152d48dd103e4bc46b3ebc91 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:27:55 -0500 Subject: [PATCH 20/71] ci: apply native schema from checked-in script --- .../workflows/apply-den-100-pr-temporary.yml | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/.github/workflows/apply-den-100-pr-temporary.yml b/.github/workflows/apply-den-100-pr-temporary.yml index ef1af4a..67d9e12 100644 --- a/.github/workflows/apply-den-100-pr-temporary.yml +++ b/.github/workflows/apply-den-100-pr-temporary.yml @@ -25,27 +25,13 @@ jobs: with: toolchain: stable components: rustfmt - - name: Reuse and execute the reviewed patch body - run: | - python3 - <<'PY' - from pathlib import Path - - source = Path('.github/workflows/apply-den-100-temporary.yml').read_text(encoding='utf-8') - marker = " python3 - <<'PY'\n" - start = source.index(marker) + len(marker) - end = source.index("\n PY", start) - block = source[start:end] - script = "\n".join( - line[10:] if line.startswith(" ") else line - for line in block.splitlines() - ) - exec(compile(script, 'apply-den-100', 'exec')) - PY + - name: Apply typed native release routing + run: python3 scripts/apply_native_release_schema.py - name: Format and test run: | cargo fmt --all cargo test --locked - - name: Commit schema and tests to the feature branch + - name: Commit schema to the feature branch run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" From 86e6cc2573bdc14d53b0feb9b28c2e79bf2fcc65 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:28:25 +0000 Subject: [PATCH 21/71] feat: declare native release routing --- src/manifest.rs | 129 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index 9b94037..ad542a9 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -223,6 +223,92 @@ pub struct TargetSection { /// gets the right wiring without configuring it. #[serde(default, skip_serializing_if = "Option::is_none")] pub adapter: Option, + /// Optional routing to this target's native ecosystem registry. This is + /// declarative metadata only: the native manifest remains authoritative, + /// and arbitrary commands are intentionally not representable here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeReleaseSection { + pub registry: NativeRegistry, + pub package: String, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum NativeRegistry { + Npm, + CratesIo, +} + +impl NativeRegistry { + pub fn as_str(self) -> &'static str { + match self { + Self::Npm => "npm", + Self::CratesIo => "crates-io", + } + } + + fn validate_package(self, package: &str) -> Result<(), String> { + let valid = match self { + Self::Npm => is_valid_npm_package(package), + Self::CratesIo => is_valid_crates_package(package), + }; + if valid { + Ok(()) + } else { + Err(format!( + "package `{package}` is not a valid {} package identity", + self.as_str() + )) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeReleaseRoute { + pub target: String, + pub dir: String, + pub registry: NativeRegistry, + pub package: String, +} + +fn is_valid_npm_component(value: &str) -> bool { + !value.is_empty() + && value.len() <= 214 + && !value.starts_with('.') + && !value.starts_with('_') + && !value.contains("..") + && value.chars().all(|c| { + c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.' | '~') + }) +} + +fn is_valid_npm_package(value: &str) -> bool { + if let Some(scoped) = value.strip_prefix('@') { + let Some((scope, package)) = scoped.split_once('/') else { + return false; + }; + !package.contains('/') && is_valid_npm_component(scope) && is_valid_npm_component(package) + } else { + !value.contains('/') && is_valid_npm_component(value) + } +} + +fn is_valid_crates_package(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && !value.starts_with('-') + && !value.starts_with('_') + && !value.ends_with('-') + && !value.ends_with('_') + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) } /// A post-extract build step. Because compiled output is OS/arch-specific, @@ -290,6 +376,8 @@ pub enum ManifestError { InvalidInstallDir(String, String), #[error("invalid target `{0}`: {1}")] InvalidTarget(String, String), + #[error("invalid native release route for target `{0}`: {1}")] + InvalidNativeRoute(String, String), #[error("manifest toml error: {0}")] Toml(String), } @@ -406,6 +494,7 @@ impl Manifest { } let mut target_dirs = BTreeMap::<&str, &str>::new(); let mut published_names = BTreeMap::::new(); + let mut native_routes = BTreeMap::<(NativeRegistry, String), &str>::new(); for (name, target) in &self.targets { if !is_target_name(name) { return Err(ManifestError::InvalidTarget( @@ -466,6 +555,30 @@ impl Manifest { ), )); } + if let Some(native) = &target.native { + if target.dir == "." { + return Err(ManifestError::InvalidNativeRoute( + name.clone(), + "the whole-repository target cannot publish to a native registry" + .to_string(), + )); + } + native + .registry + .validate_package(&native.package) + .map_err(|reason| ManifestError::InvalidNativeRoute(name.clone(), reason))?; + let route = (native.registry, native.package.clone()); + if let Some(previous) = native_routes.insert(route, name.as_str()) { + return Err(ManifestError::InvalidNativeRoute( + name.clone(), + format!( + "{} package `{}` is already routed by target `{previous}`", + native.registry.as_str(), + native.package + ), + )); + } + } } // A blank request means "no target", the same way a blank // `[install].dir` falls back to the default rather than erroring. @@ -570,6 +683,22 @@ impl Manifest { }) } + /// Native release routes sorted by target name, suitable for deterministic + /// credential-free planning before any registry adapter executes. + pub fn native_release_routes(&self) -> Vec { + self.targets + .iter() + .filter_map(|(target, section)| { + section.native.as_ref().map(|native| NativeReleaseRoute { + target: target.clone(), + dir: section.dir.clone(), + registry: native.registry, + package: native.package.clone(), + }) + }) + .collect() + } + /// Every `(target, published name)` pair this manifest fans out to, sorted /// by target for deterministic publish order and output. pub fn target_package_names(&self) -> Vec<(String, String)> { From b0d2c5dd1ccd70d9b95c892a0a44e3ab60a4866c Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:29:03 -0500 Subject: [PATCH 22/71] chore: remove temporary schema workflow --- .../workflows/apply-den-100-pr-temporary.yml | 44 ------------------- 1 file changed, 44 deletions(-) delete mode 100644 .github/workflows/apply-den-100-pr-temporary.yml diff --git a/.github/workflows/apply-den-100-pr-temporary.yml b/.github/workflows/apply-den-100-pr-temporary.yml deleted file mode 100644 index 67d9e12..0000000 --- a/.github/workflows/apply-den-100-pr-temporary.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Apply DEN-100 native release schema from PR (temporary) - -on: - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: write - -jobs: - apply: - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - github.head_ref == 'feat/den-100-native-release-routing' && - github.actor == 'ORESoftware' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - persist-credentials: true - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: stable - components: rustfmt - - name: Apply typed native release routing - run: python3 scripts/apply_native_release_schema.py - - name: Format and test - run: | - cargo fmt --all - cargo test --locked - - name: Commit schema to the feature branch - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/manifest.rs tests/native_release.rs - if git diff --cached --quiet; then - echo "Schema and tests are already current." - exit 0 - fi - git commit -m "feat: declare native release routing" - git push origin HEAD:feat/den-100-native-release-routing From a613fd1535b5ee3f2faafb67873db137860e4243 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:29:11 -0500 Subject: [PATCH 23/71] chore: remove temporary schema patcher --- .github/workflows/apply-den-100-temporary.yml | 363 ------------------ 1 file changed, 363 deletions(-) delete mode 100644 .github/workflows/apply-den-100-temporary.yml diff --git a/.github/workflows/apply-den-100-temporary.yml b/.github/workflows/apply-den-100-temporary.yml deleted file mode 100644 index ff34c9e..0000000 --- a/.github/workflows/apply-den-100-temporary.yml +++ /dev/null @@ -1,363 +0,0 @@ -name: Apply DEN-100 native release schema (temporary) - -on: - push: - branches: - - feat/den-100-native-release-routing - workflow_dispatch: - -permissions: - contents: write - -jobs: - apply: - if: github.repository == 'zed-pkg/zed-interfaces' && github.actor == 'ORESoftware' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: feat/den-100-native-release-routing - fetch-depth: 0 - persist-credentials: true - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: stable - components: rustfmt - - name: Apply typed native release routing - run: | - python3 - <<'PY' - from pathlib import Path - - path = Path("src/manifest.rs") - text = path.read_text(encoding="utf-8") - - target_old = ''' #[serde(default, skip_serializing_if = "Option::is_none")] - pub adapter: Option, - } - - /// A post-extract build step.'''.replace(" ", " ") - target_new = ''' #[serde(default, skip_serializing_if = "Option::is_none")] - pub adapter: Option, - /// Optional routing to the target's native ecosystem registry. This is - /// declarative metadata only: the native manifest remains authoritative, - /// and arbitrary commands are intentionally not representable here. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub native: Option, - } - - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] - pub struct NativeReleaseSection { - pub registry: NativeRegistry, - pub package: String, - } - - #[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, - )] - #[serde(rename_all = "kebab-case")] - pub enum NativeRegistry { - Npm, - CratesIo, - } - - impl NativeRegistry { - pub fn as_str(self) -> &'static str { - match self { - Self::Npm => "npm", - Self::CratesIo => "crates-io", - } - } - - fn validate_package(self, package: &str) -> Result<(), String> { - let valid = match self { - Self::Npm => is_valid_npm_package(package), - Self::CratesIo => is_valid_crates_package(package), - }; - if valid { - Ok(()) - } else { - Err(format!( - "package `{package}` is not a valid {} package identity", - self.as_str() - )) - } - } - } - - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] - pub struct NativeReleaseRoute { - pub target: String, - pub dir: String, - pub registry: NativeRegistry, - pub package: String, - } - - fn is_valid_npm_component(value: &str) -> bool { - !value.is_empty() - && value.len() <= 214 - && !value.starts_with(['.', '_']) - && !value.contains("..") - && value.chars().all(|c| { - c.is_ascii_lowercase() - || c.is_ascii_digit() - || matches!(c, '-' | '_' | '.' | '~') - }) - } - - fn is_valid_npm_package(value: &str) -> bool { - if let Some(scoped) = value.strip_prefix('@') { - let Some((scope, package)) = scoped.split_once('/') else { - return false; - }; - !package.contains('/') - && is_valid_npm_component(scope) - && is_valid_npm_component(package) - } else { - !value.contains('/') && is_valid_npm_component(value) - } - } - - fn is_valid_crates_package(value: &str) -> bool { - !value.is_empty() - && value.len() <= 64 - && !value.starts_with(['-', '_']) - && !value.ends_with(['-', '_']) - && value - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) - } - - /// A post-extract build step.'''.replace(" ", " ") - assert target_old in text, "TargetSection insertion point changed" - text = text.replace(target_old, target_new, 1) - - error_old = ''' #[error("invalid target `{0}`: {1}")] - InvalidTarget(String, String), - #[error("manifest toml error: {0}")]'''.replace(" ", " ") - error_new = ''' #[error("invalid target `{0}`: {1}")] - InvalidTarget(String, String), - #[error("invalid native release route for target `{0}`: {1}")] - InvalidNativeRoute(String, String), - #[error("manifest toml error: {0}")]'''.replace(" ", " ") - assert error_old in text, "ManifestError insertion point changed" - text = text.replace(error_old, error_new, 1) - - maps_old = ''' let mut target_dirs = BTreeMap::<&str, &str>::new(); - let mut published_names = BTreeMap::::new();'''.replace(" ", " ") - maps_new = ''' let mut target_dirs = BTreeMap::<&str, &str>::new(); - let mut published_names = BTreeMap::::new(); - let mut native_routes = BTreeMap::<(NativeRegistry, String), &str>::new();'''.replace(" ", " ") - assert maps_old in text, "target validation maps changed" - text = text.replace(maps_old, maps_new, 1) - - adapter_old = ''' if let Some(adapter) = target.adapter.as_deref() - && !matches!(adapter, "node" | "java" | "none") - { - return Err(ManifestError::InvalidTarget( - name.clone(), - format!( - "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" - ), - )); - } - }'''.replace(" ", " ") - adapter_new = ''' if let Some(adapter) = target.adapter.as_deref() - && !matches!(adapter, "node" | "java" | "none") - { - return Err(ManifestError::InvalidTarget( - name.clone(), - format!( - "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" - ), - )); - } - if let Some(native) = &target.native { - if target.dir == "." { - return Err(ManifestError::InvalidNativeRoute( - name.clone(), - "the whole-repository target cannot publish to a native registry" - .to_string(), - )); - } - native - .registry - .validate_package(&native.package) - .map_err(|reason| { - ManifestError::InvalidNativeRoute(name.clone(), reason) - })?; - let route = (native.registry, native.package.clone()); - if let Some(previous) = native_routes.insert(route, name.as_str()) { - return Err(ManifestError::InvalidNativeRoute( - name.clone(), - format!( - "{} package `{}` is already routed by target `{previous}`", - native.registry.as_str(), - native.package - ), - )); - } - } - }'''.replace(" ", " ") - assert adapter_old in text, "target adapter validation changed" - text = text.replace(adapter_old, adapter_new, 1) - - method_old = ''' /// Every `(target, published name)` pair this manifest fans out to, sorted - /// by target for deterministic publish order and output. - pub fn target_package_names(&self) -> Vec<(String, String)> {'''.replace(" ", " ") - method_new = ''' /// Native release routes sorted by target name, suitable for deterministic - /// credential-free planning before any registry adapter executes. - pub fn native_release_routes(&self) -> Vec { - self.targets - .iter() - .filter_map(|(target, section)| { - section.native.as_ref().map(|native| NativeReleaseRoute { - target: target.clone(), - dir: section.dir.clone(), - registry: native.registry, - package: native.package.clone(), - }) - }) - .collect() - } - - /// Every `(target, published name)` pair this manifest fans out to, sorted - /// by target for deterministic publish order and output. - pub fn target_package_names(&self) -> Vec<(String, String)> {'''.replace(" ", " ") - assert method_old in text, "native route method insertion point changed" - text = text.replace(method_old, method_new, 1) - - path.write_text(text, encoding="utf-8") - - tests = r'''use zed_interfaces::manifest::{Manifest, ManifestError, NativeRegistry}; - - fn manifest(targets: &str) -> String { - format!( - r#" - [package] - org = "acme" - name = "clients" - version = "1.2.3" - - [package.repository] - url = "https://github.com/acme/clients" - - {targets} - "# - ) - } - - #[test] - fn native_routes_parse_roundtrip_and_stay_sorted() { - let parsed = Manifest::parse(&manifest( - r#" - [targets.rust] - dir = "clients/rust" - - [targets.rust.native] - registry = "crates-io" - package = "acme-client" - - [targets.nodejs] - dir = "clients/typescript" - adapter = "node" - - [targets.nodejs.native] - registry = "npm" - package = "@acme/client" - "#, - )) - .unwrap(); - - let routes = parsed.native_release_routes(); - assert_eq!(routes.len(), 2); - assert_eq!(routes[0].target, "nodejs"); - assert_eq!(routes[0].registry, NativeRegistry::Npm); - assert_eq!(routes[0].package, "@acme/client"); - assert_eq!(routes[1].target, "rust"); - assert_eq!(routes[1].registry, NativeRegistry::CratesIo); - - let encoded = parsed.to_toml_string().unwrap(); - assert!(encoded.contains("[targets.nodejs.native]")); - assert!(encoded.contains("registry = \"npm\"")); - Manifest::parse(&encoded).unwrap(); - } - - #[test] - fn whole_repository_target_cannot_route_to_a_native_registry() { - let error = Manifest::parse(&manifest( - r#" - [targets.repository] - dir = "." - - [targets.repository.native] - registry = "npm" - package = "acme-repository" - "#, - )) - .unwrap_err(); - assert!(matches!(error, ManifestError::InvalidNativeRoute(_, _))); - } - - #[test] - fn native_package_identities_are_registry_specific() { - for targets in [ - r#" - [targets.nodejs] - dir = "clients/typescript" - [targets.nodejs.native] - registry = "npm" - package = "Bad Package" - "#, - r#" - [targets.rust] - dir = "clients/rust" - [targets.rust.native] - registry = "crates-io" - package = "acme/client" - "#, - ] { - assert!(matches!( - Manifest::parse(&manifest(targets)), - Err(ManifestError::InvalidNativeRoute(_, _)) - )); - } - } - - #[test] - fn duplicate_native_destinations_are_rejected() { - let error = Manifest::parse(&manifest( - r#" - [targets.nodejs] - dir = "clients/typescript" - [targets.nodejs.native] - registry = "npm" - package = "@acme/client" - - [targets.browser] - dir = "clients/browser" - [targets.browser.native] - registry = "npm" - package = "@acme/client" - "#, - )) - .unwrap_err(); - assert!(matches!(error, ManifestError::InvalidNativeRoute(_, _))); - } - ''' - Path("tests/native_release.rs").write_text( - "\n".join(line[10:] if line.startswith(" ") else line for line in tests.splitlines()) + "\n", - encoding="utf-8", - ) - PY - - name: Format and test - run: | - cargo fmt --all - cargo test --locked - - name: Commit schema and tests to the feature branch - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/manifest.rs tests/native_release.rs - git commit -m "feat: declare native release routing" - git push origin HEAD:feat/den-100-native-release-routing From df173d7e233aee510c51e0c78066dc0178038057 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:29:17 -0500 Subject: [PATCH 24/71] chore: remove temporary schema helper --- scripts/apply_native_release_schema.py | 225 ------------------------- 1 file changed, 225 deletions(-) delete mode 100644 scripts/apply_native_release_schema.py diff --git a/scripts/apply_native_release_schema.py b/scripts/apply_native_release_schema.py deleted file mode 100644 index d2707d7..0000000 --- a/scripts/apply_native_release_schema.py +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/env python3 -"""Apply the DEN-100 native release routing schema to src/manifest.rs. - -Temporary branch-local helper. It is intentionally deterministic and refuses to -continue when an expected insertion point has changed. -""" - -from pathlib import Path - - -PATH = Path("src/manifest.rs") -text = PATH.read_text(encoding="utf-8") - - -def replace_once(old: str, new: str, label: str) -> None: - global text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected exactly one insertion point, found {count}") - text = text.replace(old, new, 1) - - -replace_once( - ''' #[serde(default, skip_serializing_if = "Option::is_none")] - pub adapter: Option, -} - -/// A post-extract build step.''', - ''' #[serde(default, skip_serializing_if = "Option::is_none")] - pub adapter: Option, - /// Optional routing to this target's native ecosystem registry. This is - /// declarative metadata only: the native manifest remains authoritative, - /// and arbitrary commands are intentionally not representable here. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub native: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct NativeReleaseSection { - pub registry: NativeRegistry, - pub package: String, -} - -#[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, -)] -#[serde(rename_all = "kebab-case")] -pub enum NativeRegistry { - Npm, - CratesIo, -} - -impl NativeRegistry { - pub fn as_str(self) -> &'static str { - match self { - Self::Npm => "npm", - Self::CratesIo => "crates-io", - } - } - - fn validate_package(self, package: &str) -> Result<(), String> { - let valid = match self { - Self::Npm => is_valid_npm_package(package), - Self::CratesIo => is_valid_crates_package(package), - }; - if valid { - Ok(()) - } else { - Err(format!( - "package `{package}` is not a valid {} package identity", - self.as_str() - )) - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct NativeReleaseRoute { - pub target: String, - pub dir: String, - pub registry: NativeRegistry, - pub package: String, -} - -fn is_valid_npm_component(value: &str) -> bool { - !value.is_empty() - && value.len() <= 214 - && !value.starts_with('.') - && !value.starts_with('_') - && !value.contains("..") - && value.chars().all(|c| { - c.is_ascii_lowercase() - || c.is_ascii_digit() - || matches!(c, '-' | '_' | '.' | '~') - }) -} - -fn is_valid_npm_package(value: &str) -> bool { - if let Some(scoped) = value.strip_prefix('@') { - let Some((scope, package)) = scoped.split_once('/') else { - return false; - }; - !package.contains('/') - && is_valid_npm_component(scope) - && is_valid_npm_component(package) - } else { - !value.contains('/') && is_valid_npm_component(value) - } -} - -fn is_valid_crates_package(value: &str) -> bool { - !value.is_empty() - && value.len() <= 64 - && !value.starts_with('-') - && !value.starts_with('_') - && !value.ends_with('-') - && !value.ends_with('_') - && value - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) -} - -/// A post-extract build step.''', - "TargetSection/native types", -) - -replace_once( - ''' #[error("invalid target `{0}`: {1}")] - InvalidTarget(String, String), - #[error("manifest toml error: {0}")]''', - ''' #[error("invalid target `{0}`: {1}")] - InvalidTarget(String, String), - #[error("invalid native release route for target `{0}`: {1}")] - InvalidNativeRoute(String, String), - #[error("manifest toml error: {0}")]''', - "ManifestError", -) - -replace_once( - ''' let mut target_dirs = BTreeMap::<&str, &str>::new(); - let mut published_names = BTreeMap::::new();''', - ''' let mut target_dirs = BTreeMap::<&str, &str>::new(); - let mut published_names = BTreeMap::::new(); - let mut native_routes = BTreeMap::<(NativeRegistry, String), &str>::new();''', - "target validation maps", -) - -replace_once( - ''' if let Some(adapter) = target.adapter.as_deref() - && !matches!(adapter, "node" | "java" | "none") - { - return Err(ManifestError::InvalidTarget( - name.clone(), - format!( - "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" - ), - )); - } - }''', - ''' if let Some(adapter) = target.adapter.as_deref() - && !matches!(adapter, "node" | "java" | "none") - { - return Err(ManifestError::InvalidTarget( - name.clone(), - format!( - "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" - ), - )); - } - if let Some(native) = &target.native { - if target.dir == "." { - return Err(ManifestError::InvalidNativeRoute( - name.clone(), - "the whole-repository target cannot publish to a native registry" - .to_string(), - )); - } - native - .registry - .validate_package(&native.package) - .map_err(|reason| ManifestError::InvalidNativeRoute(name.clone(), reason))?; - let route = (native.registry, native.package.clone()); - if let Some(previous) = native_routes.insert(route, name.as_str()) { - return Err(ManifestError::InvalidNativeRoute( - name.clone(), - format!( - "{} package `{}` is already routed by target `{previous}`", - native.registry.as_str(), - native.package - ), - )); - } - } - }''', - "native route validation", -) - -replace_once( - ''' /// Every `(target, published name)` pair this manifest fans out to, sorted - /// by target for deterministic publish order and output. - pub fn target_package_names(&self) -> Vec<(String, String)> {''', - ''' /// Native release routes sorted by target name, suitable for deterministic - /// credential-free planning before any registry adapter executes. - pub fn native_release_routes(&self) -> Vec { - self.targets - .iter() - .filter_map(|(target, section)| { - section.native.as_ref().map(|native| NativeReleaseRoute { - target: target.clone(), - dir: section.dir.clone(), - registry: native.registry, - package: native.package.clone(), - }) - }) - .collect() - } - - /// Every `(target, published name)` pair this manifest fans out to, sorted - /// by target for deterministic publish order and output. - pub fn target_package_names(&self) -> Vec<(String, String)> {''', - "native_release_routes method", -) - -PATH.write_text(text, encoding="utf-8") -print(f"updated {PATH}") From 90534bcc74be38d876c08381afb4b9152f405d34 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:30:22 -0500 Subject: [PATCH 25/71] ci: regenerate native release schemas --- .../regenerate-schemas-temporary.yml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/regenerate-schemas-temporary.yml diff --git a/.github/workflows/regenerate-schemas-temporary.yml b/.github/workflows/regenerate-schemas-temporary.yml new file mode 100644 index 0000000..a67fef0 --- /dev/null +++ b/.github/workflows/regenerate-schemas-temporary.yml @@ -0,0 +1,39 @@ +name: Regenerate native release schemas (temporary) + +on: + pull_request: + types: [synchronize] + +permissions: + contents: write + +jobs: + regenerate: + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + github.head_ref == 'feat/den-100-native-release-routing' && + github.actor == 'ORESoftware' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + persist-credentials: true + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + - name: Regenerate committed JSON schemas + run: cargo run --example generate_schemas + - name: Commit generated schemas to the feature branch + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add schemas/ + if git diff --cached --quiet; then + echo "Schemas are already current." + exit 0 + fi + git commit -m "schemas: add native release routing" + git push origin HEAD:feat/den-100-native-release-routing From b9949e9e02f7f195fe324acb32f6d892840b9784 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:30:49 +0000 Subject: [PATCH 26/71] schemas: add native release routing --- schemas/manifest.json | 33 +++++++++++++++++++++++++++++++++ schemas/publish-meta.json | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/schemas/manifest.json b/schemas/manifest.json index 7026acd..dbe2eaf 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -132,6 +132,28 @@ } } }, + "NativeRegistry": { + "type": "string", + "enum": [ + "npm", + "crates-io" + ] + }, + "NativeReleaseSection": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "registry": { + "$ref": "#/$defs/NativeRegistry" + } + }, + "required": [ + "registry", + "package" + ] + }, "OverridesSection": { "description": "Consumer-side dependency patches, keyed by `org/name`.", "type": "object", @@ -274,6 +296,17 @@ "string", "null" ] + }, + "native": { + "description": "Optional routing to this target's native ecosystem registry. This is\ndeclarative metadata only: the native manifest remains authoritative,\nand arbitrary commands are intentionally not representable here.", + "anyOf": [ + { + "$ref": "#/$defs/NativeReleaseSection" + }, + { + "type": "null" + } + ] } }, "required": [ diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index c30177e..751ca46 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -178,6 +178,28 @@ "package" ] }, + "NativeRegistry": { + "type": "string", + "enum": [ + "npm", + "crates-io" + ] + }, + "NativeReleaseSection": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "registry": { + "$ref": "#/$defs/NativeRegistry" + } + }, + "required": [ + "registry", + "package" + ] + }, "OverridesSection": { "description": "Consumer-side dependency patches, keyed by `org/name`.", "type": "object", @@ -320,6 +342,17 @@ "string", "null" ] + }, + "native": { + "description": "Optional routing to this target's native ecosystem registry. This is\ndeclarative metadata only: the native manifest remains authoritative,\nand arbitrary commands are intentionally not representable here.", + "anyOf": [ + { + "$ref": "#/$defs/NativeReleaseSection" + }, + { + "type": "null" + } + ] } }, "required": [ From 564c0ae0e48612d985b8d937ecd1c62e2871efdf Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:31:14 -0500 Subject: [PATCH 27/71] chore: remove temporary schema regeneration workflow --- .../regenerate-schemas-temporary.yml | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/regenerate-schemas-temporary.yml diff --git a/.github/workflows/regenerate-schemas-temporary.yml b/.github/workflows/regenerate-schemas-temporary.yml deleted file mode 100644 index a67fef0..0000000 --- a/.github/workflows/regenerate-schemas-temporary.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Regenerate native release schemas (temporary) - -on: - pull_request: - types: [synchronize] - -permissions: - contents: write - -jobs: - regenerate: - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - github.head_ref == 'feat/den-100-native-release-routing' && - github.actor == 'ORESoftware' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - persist-credentials: true - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: stable - - name: Regenerate committed JSON schemas - run: cargo run --example generate_schemas - - name: Commit generated schemas to the feature branch - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add schemas/ - if git diff --cached --quiet; then - echo "Schemas are already current." - exit 0 - fi - git commit -m "schemas: add native release routing" - git push origin HEAD:feat/den-100-native-release-routing From f7c253f0d218e17fb6e59df2cf34511c8db05ad4 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 15:25:04 -0500 Subject: [PATCH 28/71] chore: add deterministic pub.dev routing patch --- scripts/apply_pubdev_routing.py | 201 ++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 scripts/apply_pubdev_routing.py diff --git a/scripts/apply_pubdev_routing.py b/scripts/apply_pubdev_routing.py new file mode 100644 index 0000000..bb50c24 --- /dev/null +++ b/scripts/apply_pubdev_routing.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Extend native release routing with pub.dev.""" + +from pathlib import Path + + +MANIFEST = Path("src/manifest.rs") +text = MANIFEST.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one insertion point, found {count}") + text = text.replace(old, new, 1) + + +replace_once( + '''pub enum NativeRegistry { + Npm, + CratesIo, +}''', + '''pub enum NativeRegistry { + Npm, + CratesIo, + #[serde(rename = "pub.dev")] + PubDev, +}''', + "NativeRegistry enum", +) + +replace_once( + ''' match self { + Self::Npm => "npm", + Self::CratesIo => "crates-io", + }''', + ''' match self { + Self::Npm => "npm", + Self::CratesIo => "crates-io", + Self::PubDev => "pub.dev", + }''', + "NativeRegistry::as_str", +) + +replace_once( + ''' let valid = match self { + Self::Npm => is_valid_npm_package(package), + Self::CratesIo => is_valid_crates_package(package), + };''', + ''' let valid = match self { + Self::Npm => is_valid_npm_package(package), + Self::CratesIo => is_valid_crates_package(package), + Self::PubDev => is_valid_pubdev_package(package), + };''', + "native package validation dispatch", +) + +replace_once( + '''fn is_valid_crates_package(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && !value.starts_with('-') + && !value.starts_with('_') + && !value.ends_with('-') + && !value.ends_with('_') + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) +} + +/// A post-extract build step.''', + '''fn is_valid_crates_package(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && !value.starts_with('-') + && !value.starts_with('_') + && !value.ends_with('-') + && !value.ends_with('_') + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) +} + +fn is_valid_pubdev_package(value: &str) -> bool { + const RESERVED: &[&str] = &[ + "assert", "break", "case", "catch", "class", "const", "continue", "default", + "do", "else", "enum", "extends", "false", "final", "finally", "for", "if", + "in", "is", "new", "null", "rethrow", "return", "super", "switch", "this", + "throw", "true", "try", "var", "void", "while", "with", "async", "await", + "yield", + ]; + !value.is_empty() + && !value.as_bytes()[0].is_ascii_digit() + && !RESERVED.contains(&value) + && value + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') +} + +/// A post-extract build step.''', + "pub.dev package validator", +) + +MANIFEST.write_text(text, encoding="utf-8") + +TEST = Path("tests/native_release.rs") +tests = TEST.read_text(encoding="utf-8") + +old_routes = '''[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +"#,''' +new_routes = '''[targets.nodejs.native] +registry = "npm" +package = "@acme/client" + +[targets.dart] +dir = "clients/dart" + +[targets.dart.native] +registry = "pub.dev" +package = "acme_client" +"#,''' +if tests.count(old_routes) != 1: + raise SystemExit("route fixture insertion point changed") +tests = tests.replace(old_routes, new_routes, 1) + +tests = tests.replace( + ''' assert_eq!(routes.len(), 2); + assert_eq!(routes[0].target, "nodejs");''', + ''' assert_eq!(routes.len(), 3); + assert_eq!(routes[0].target, "dart"); + assert_eq!(routes[0].registry, NativeRegistry::PubDev); + assert_eq!(routes[0].package, "acme_client"); + assert_eq!(routes[1].target, "nodejs");''', + 1, +) +tests = tests.replace( + ''' assert_eq!(routes[0].registry, NativeRegistry::Npm); + assert_eq!(routes[0].package, "@acme/client"); + assert_eq!(routes[1].target, "rust"); + assert_eq!(routes[1].registry, NativeRegistry::CratesIo);''', + ''' assert_eq!(routes[1].registry, NativeRegistry::Npm); + assert_eq!(routes[1].package, "@acme/client"); + assert_eq!(routes[2].target, "rust"); + assert_eq!(routes[2].registry, NativeRegistry::CratesIo);''', + 1, +) +tests = tests.replace( + ''' assert!(encoded.contains("registry = \\"npm\\"")); + Manifest::parse(&encoded).unwrap();''', + ''' assert!(encoded.contains("registry = \\"npm\\"")); + assert!(encoded.contains("registry = \\"pub.dev\\"")); + Manifest::parse(&encoded).unwrap();''', + 1, +) + +invalid_anchor = ''' r#" +[targets.rust] +dir = "clients/rust" +[targets.rust.native] +registry = "crates-io" +package = "acme/client" +"#, + ] {''' +invalid_replacement = ''' r#" +[targets.rust] +dir = "clients/rust" +[targets.rust.native] +registry = "crates-io" +package = "acme/client" +"#, + r#" +[targets.dart] +dir = "clients/dart" +[targets.dart.native] +registry = "pub.dev" +package = "Bad-Dart-Package" +"#, + r#" +[targets.dart] +dir = "clients/dart" +[targets.dart.native] +registry = "pub.dev" +package = "123_client" +"#, + r#" +[targets.dart] +dir = "clients/dart" +[targets.dart.native] +registry = "pub.dev" +package = "class" +"#, + ] {''' +if tests.count(invalid_anchor) != 1: + raise SystemExit("invalid-route fixture insertion point changed") +tests = tests.replace(invalid_anchor, invalid_replacement, 1) + +TEST.write_text(tests, encoding="utf-8") +print("added pub.dev native routing") From 1f329a25e7fc2ff444e881805c32074194723655 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 15:25:28 -0500 Subject: [PATCH 29/71] ci: apply pub.dev native routing --- .../apply-pubdev-routing-temporary.yml | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/apply-pubdev-routing-temporary.yml diff --git a/.github/workflows/apply-pubdev-routing-temporary.yml b/.github/workflows/apply-pubdev-routing-temporary.yml new file mode 100644 index 0000000..67677b6 --- /dev/null +++ b/.github/workflows/apply-pubdev-routing-temporary.yml @@ -0,0 +1,45 @@ +name: Apply pub.dev native routing (temporary) + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: write + +jobs: + apply: + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + github.head_ref == 'feat/den-100-pubdev-routing' && + github.actor == 'ORESoftware' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + persist-credentials: true + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + components: rustfmt + - name: Apply pub.dev route and tests + run: python3 scripts/apply_pubdev_routing.py + - name: Format, test, and regenerate schemas + run: | + cargo fmt --all + cargo test --locked + cargo run --example generate_schemas + - name: Commit production changes to the feature branch + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/manifest.rs tests/native_release.rs schemas/ + if git diff --cached --quiet; then + echo "pub.dev routing is already current." + exit 0 + fi + git commit -m "feat: declare pub.dev release routing" + git push origin HEAD:feat/den-100-pubdev-routing From 95e81cb569f8a27eea67aa8ad92d83740bd022f4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:26:01 +0000 Subject: [PATCH 30/71] feat: declare pub.dev release routing --- schemas/manifest.json | 3 ++- schemas/publish-meta.json | 3 ++- src/manifest.rs | 19 +++++++++++++++++ tests/native_release.rs | 44 +++++++++++++++++++++++++++++++++------ 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/schemas/manifest.json b/schemas/manifest.json index dbe2eaf..37a8c61 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -136,7 +136,8 @@ "type": "string", "enum": [ "npm", - "crates-io" + "crates-io", + "pub.dev" ] }, "NativeReleaseSection": { diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index 751ca46..a584f96 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -182,7 +182,8 @@ "type": "string", "enum": [ "npm", - "crates-io" + "crates-io", + "pub.dev" ] }, "NativeReleaseSection": { diff --git a/src/manifest.rs b/src/manifest.rs index ad542a9..19f3bb2 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -243,6 +243,8 @@ pub struct NativeReleaseSection { pub enum NativeRegistry { Npm, CratesIo, + #[serde(rename = "pub.dev")] + PubDev, } impl NativeRegistry { @@ -250,6 +252,7 @@ impl NativeRegistry { match self { Self::Npm => "npm", Self::CratesIo => "crates-io", + Self::PubDev => "pub.dev", } } @@ -257,6 +260,7 @@ impl NativeRegistry { let valid = match self { Self::Npm => is_valid_npm_package(package), Self::CratesIo => is_valid_crates_package(package), + Self::PubDev => is_valid_pubdev_package(package), }; if valid { Ok(()) @@ -311,6 +315,21 @@ fn is_valid_crates_package(value: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) } +fn is_valid_pubdev_package(value: &str) -> bool { + const RESERVED: &[&str] = &[ + "assert", "break", "case", "catch", "class", "const", "continue", "default", "do", "else", + "enum", "extends", "false", "final", "finally", "for", "if", "in", "is", "new", "null", + "rethrow", "return", "super", "switch", "this", "throw", "true", "try", "var", "void", + "while", "with", "async", "await", "yield", + ]; + !value.is_empty() + && !value.as_bytes()[0].is_ascii_digit() + && !RESERVED.contains(&value) + && value + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') +} + /// A post-extract build step. Because compiled output is OS/arch-specific, /// zed-pkg runs `command` via `sh -c` inside a sandboxed staging copy of the /// source and caches the result in a build cache keyed by diff --git a/tests/native_release.rs b/tests/native_release.rs index 5109af8..ea61750 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -34,21 +34,32 @@ adapter = "node" [targets.nodejs.native] registry = "npm" package = "@acme/client" + +[targets.dart] +dir = "clients/dart" + +[targets.dart.native] +registry = "pub.dev" +package = "acme_client" "#, )) .unwrap(); let routes = parsed.native_release_routes(); - assert_eq!(routes.len(), 2); - assert_eq!(routes[0].target, "nodejs"); - assert_eq!(routes[0].registry, NativeRegistry::Npm); - assert_eq!(routes[0].package, "@acme/client"); - assert_eq!(routes[1].target, "rust"); - assert_eq!(routes[1].registry, NativeRegistry::CratesIo); + assert_eq!(routes.len(), 3); + assert_eq!(routes[0].target, "dart"); + assert_eq!(routes[0].registry, NativeRegistry::PubDev); + assert_eq!(routes[0].package, "acme_client"); + assert_eq!(routes[1].target, "nodejs"); + assert_eq!(routes[1].registry, NativeRegistry::Npm); + assert_eq!(routes[1].package, "@acme/client"); + assert_eq!(routes[2].target, "rust"); + assert_eq!(routes[2].registry, NativeRegistry::CratesIo); let encoded = parsed.to_toml_string().unwrap(); assert!(encoded.contains("[targets.nodejs.native]")); assert!(encoded.contains("registry = \"npm\"")); + assert!(encoded.contains("registry = \"pub.dev\"")); Manifest::parse(&encoded).unwrap(); } @@ -84,6 +95,27 @@ dir = "clients/rust" [targets.rust.native] registry = "crates-io" package = "acme/client" +"#, + r#" +[targets.dart] +dir = "clients/dart" +[targets.dart.native] +registry = "pub.dev" +package = "Bad-Dart-Package" +"#, + r#" +[targets.dart] +dir = "clients/dart" +[targets.dart.native] +registry = "pub.dev" +package = "123_client" +"#, + r#" +[targets.dart] +dir = "clients/dart" +[targets.dart.native] +registry = "pub.dev" +package = "class" "#, ] { assert!(matches!( From 4d5af59f783e6653bfa20f32de0fc2aaab7b21cf Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 15:26:37 -0500 Subject: [PATCH 31/71] chore: remove temporary pub.dev routing workflow --- .../apply-pubdev-routing-temporary.yml | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/workflows/apply-pubdev-routing-temporary.yml diff --git a/.github/workflows/apply-pubdev-routing-temporary.yml b/.github/workflows/apply-pubdev-routing-temporary.yml deleted file mode 100644 index 67677b6..0000000 --- a/.github/workflows/apply-pubdev-routing-temporary.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Apply pub.dev native routing (temporary) - -on: - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: write - -jobs: - apply: - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - github.head_ref == 'feat/den-100-pubdev-routing' && - github.actor == 'ORESoftware' - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - persist-credentials: true - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: stable - components: rustfmt - - name: Apply pub.dev route and tests - run: python3 scripts/apply_pubdev_routing.py - - name: Format, test, and regenerate schemas - run: | - cargo fmt --all - cargo test --locked - cargo run --example generate_schemas - - name: Commit production changes to the feature branch - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/manifest.rs tests/native_release.rs schemas/ - if git diff --cached --quiet; then - echo "pub.dev routing is already current." - exit 0 - fi - git commit -m "feat: declare pub.dev release routing" - git push origin HEAD:feat/den-100-pubdev-routing From 9e112081e2c9e8136a68634f7c9d837290a5668f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 15:26:44 -0500 Subject: [PATCH 32/71] chore: remove temporary pub.dev routing helper --- scripts/apply_pubdev_routing.py | 201 -------------------------------- 1 file changed, 201 deletions(-) delete mode 100644 scripts/apply_pubdev_routing.py diff --git a/scripts/apply_pubdev_routing.py b/scripts/apply_pubdev_routing.py deleted file mode 100644 index bb50c24..0000000 --- a/scripts/apply_pubdev_routing.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python3 -"""Extend native release routing with pub.dev.""" - -from pathlib import Path - - -MANIFEST = Path("src/manifest.rs") -text = MANIFEST.read_text(encoding="utf-8") - - -def replace_once(old: str, new: str, label: str) -> None: - global text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one insertion point, found {count}") - text = text.replace(old, new, 1) - - -replace_once( - '''pub enum NativeRegistry { - Npm, - CratesIo, -}''', - '''pub enum NativeRegistry { - Npm, - CratesIo, - #[serde(rename = "pub.dev")] - PubDev, -}''', - "NativeRegistry enum", -) - -replace_once( - ''' match self { - Self::Npm => "npm", - Self::CratesIo => "crates-io", - }''', - ''' match self { - Self::Npm => "npm", - Self::CratesIo => "crates-io", - Self::PubDev => "pub.dev", - }''', - "NativeRegistry::as_str", -) - -replace_once( - ''' let valid = match self { - Self::Npm => is_valid_npm_package(package), - Self::CratesIo => is_valid_crates_package(package), - };''', - ''' let valid = match self { - Self::Npm => is_valid_npm_package(package), - Self::CratesIo => is_valid_crates_package(package), - Self::PubDev => is_valid_pubdev_package(package), - };''', - "native package validation dispatch", -) - -replace_once( - '''fn is_valid_crates_package(value: &str) -> bool { - !value.is_empty() - && value.len() <= 64 - && !value.starts_with('-') - && !value.starts_with('_') - && !value.ends_with('-') - && !value.ends_with('_') - && value - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) -} - -/// A post-extract build step.''', - '''fn is_valid_crates_package(value: &str) -> bool { - !value.is_empty() - && value.len() <= 64 - && !value.starts_with('-') - && !value.starts_with('_') - && !value.ends_with('-') - && !value.ends_with('_') - && value - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) -} - -fn is_valid_pubdev_package(value: &str) -> bool { - const RESERVED: &[&str] = &[ - "assert", "break", "case", "catch", "class", "const", "continue", "default", - "do", "else", "enum", "extends", "false", "final", "finally", "for", "if", - "in", "is", "new", "null", "rethrow", "return", "super", "switch", "this", - "throw", "true", "try", "var", "void", "while", "with", "async", "await", - "yield", - ]; - !value.is_empty() - && !value.as_bytes()[0].is_ascii_digit() - && !RESERVED.contains(&value) - && value - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') -} - -/// A post-extract build step.''', - "pub.dev package validator", -) - -MANIFEST.write_text(text, encoding="utf-8") - -TEST = Path("tests/native_release.rs") -tests = TEST.read_text(encoding="utf-8") - -old_routes = '''[targets.nodejs.native] -registry = "npm" -package = "@acme/client" -"#,''' -new_routes = '''[targets.nodejs.native] -registry = "npm" -package = "@acme/client" - -[targets.dart] -dir = "clients/dart" - -[targets.dart.native] -registry = "pub.dev" -package = "acme_client" -"#,''' -if tests.count(old_routes) != 1: - raise SystemExit("route fixture insertion point changed") -tests = tests.replace(old_routes, new_routes, 1) - -tests = tests.replace( - ''' assert_eq!(routes.len(), 2); - assert_eq!(routes[0].target, "nodejs");''', - ''' assert_eq!(routes.len(), 3); - assert_eq!(routes[0].target, "dart"); - assert_eq!(routes[0].registry, NativeRegistry::PubDev); - assert_eq!(routes[0].package, "acme_client"); - assert_eq!(routes[1].target, "nodejs");''', - 1, -) -tests = tests.replace( - ''' assert_eq!(routes[0].registry, NativeRegistry::Npm); - assert_eq!(routes[0].package, "@acme/client"); - assert_eq!(routes[1].target, "rust"); - assert_eq!(routes[1].registry, NativeRegistry::CratesIo);''', - ''' assert_eq!(routes[1].registry, NativeRegistry::Npm); - assert_eq!(routes[1].package, "@acme/client"); - assert_eq!(routes[2].target, "rust"); - assert_eq!(routes[2].registry, NativeRegistry::CratesIo);''', - 1, -) -tests = tests.replace( - ''' assert!(encoded.contains("registry = \\"npm\\"")); - Manifest::parse(&encoded).unwrap();''', - ''' assert!(encoded.contains("registry = \\"npm\\"")); - assert!(encoded.contains("registry = \\"pub.dev\\"")); - Manifest::parse(&encoded).unwrap();''', - 1, -) - -invalid_anchor = ''' r#" -[targets.rust] -dir = "clients/rust" -[targets.rust.native] -registry = "crates-io" -package = "acme/client" -"#, - ] {''' -invalid_replacement = ''' r#" -[targets.rust] -dir = "clients/rust" -[targets.rust.native] -registry = "crates-io" -package = "acme/client" -"#, - r#" -[targets.dart] -dir = "clients/dart" -[targets.dart.native] -registry = "pub.dev" -package = "Bad-Dart-Package" -"#, - r#" -[targets.dart] -dir = "clients/dart" -[targets.dart.native] -registry = "pub.dev" -package = "123_client" -"#, - r#" -[targets.dart] -dir = "clients/dart" -[targets.dart.native] -registry = "pub.dev" -package = "class" -"#, - ] {''' -if tests.count(invalid_anchor) != 1: - raise SystemExit("invalid-route fixture insertion point changed") -tests = tests.replace(invalid_anchor, invalid_replacement, 1) - -TEST.write_text(tests, encoding="utf-8") -print("added pub.dev native routing") From 20da7305b15ed54de3615ca2b4a9193b45509c63 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 15:43:28 -0500 Subject: [PATCH 33/71] chore: add deterministic PyPI routing patch --- scripts/apply_pypi_routing.py | 210 ++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 scripts/apply_pypi_routing.py diff --git a/scripts/apply_pypi_routing.py b/scripts/apply_pypi_routing.py new file mode 100644 index 0000000..107964c --- /dev/null +++ b/scripts/apply_pypi_routing.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Extend typed native release routing with PyPI semantics.""" + +from pathlib import Path + + +MANIFEST = Path("src/manifest.rs") +text = MANIFEST.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one insertion point, found {count}") + text = text.replace(old, new, 1) + + +replace_once( + ''' #[serde(rename = "pub.dev")] + PubDev, +}''', + ''' #[serde(rename = "pub.dev")] + PubDev, + PyPi, +}''', + "NativeRegistry::PyPi", +) +replace_once( + ''' Self::PubDev => "pub.dev", + }''', + ''' Self::PubDev => "pub.dev", + Self::PyPi => "pypi", + }''', + "PyPI display name", +) +replace_once( + ''' Self::PubDev => is_valid_pubdev_package(package), + };''', + ''' Self::PubDev => is_valid_pubdev_package(package), + Self::PyPi => is_valid_pypi_package(package), + };''', + "PyPI syntax validation", +) +replace_once( + ''' } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeReleaseRoute {''', + ''' } + + fn canonical_package(self, package: &str) -> String { + match self { + Self::PyPi => normalize_pypi_package(package), + _ => package.to_string(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeReleaseRoute {''', + "registry canonical package method", +) +replace_once( + '''fn is_valid_pubdev_package(value: &str) -> bool {''', + '''fn is_valid_pypi_package(value: &str) -> bool { + !value.is_empty() + && value.as_bytes()[0].is_ascii_alphanumeric() + && value.as_bytes()[value.len() - 1].is_ascii_alphanumeric() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn normalize_pypi_package(value: &str) -> String { + let mut normalized = String::with_capacity(value.len()); + let mut separator = false; + for byte in value.bytes() { + if matches!(byte, b'.' | b'_' | b'-') { + separator = true; + continue; + } + if separator && !normalized.is_empty() { + normalized.push('-'); + } + separator = false; + normalized.push((byte as char).to_ascii_lowercase()); + } + normalized +} + +fn is_valid_pubdev_package(value: &str) -> bool {''', + "PyPI helpers", +) +replace_once( + ''' let route = (native.registry, native.package.clone()); + if let Some(previous) = native_routes.insert(route, name.as_str()) {''', + ''' let canonical_package = native.registry.canonical_package(&native.package); + let route = (native.registry, canonical_package); + if let Some(previous) = native_routes.insert(route, name.as_str()) {''', + "normalized native route collision key", +) +MANIFEST.write_text(text, encoding="utf-8") + +TEST = Path("tests/native_release.rs") +tests = TEST.read_text(encoding="utf-8") + +anchor = '''[targets.dart.native] +registry = "pub.dev" +package = "acme_client" +"#,''' +replacement = '''[targets.dart.native] +registry = "pub.dev" +package = "acme_client" + +[targets.python] +dir = "clients/python" + +[targets.python.native] +registry = "py-pi" +package = "Acme.Client" +"#,''' +# Serde rename_all kebab-case would make PyPi -> py-pi, but desired wire spelling is pypi. +# Patch source enum with an explicit rename after initial insertion. +text = MANIFEST.read_text(encoding="utf-8") +text = text.replace(' PyPi,\n}', ' #[serde(rename = "pypi")]\n PyPi,\n}', 1) +MANIFEST.write_text(text, encoding="utf-8") +replacement = replacement.replace('registry = "py-pi"', 'registry = "pypi"') +if tests.count(anchor) != 1: + raise SystemExit("route fixture insertion point changed") +tests = tests.replace(anchor, replacement, 1) +tests = tests.replace('assert_eq!(routes.len(), 3);', 'assert_eq!(routes.len(), 4);', 1) +tests = tests.replace( + ''' assert_eq!(routes[2].target, "rust"); + assert_eq!(routes[2].registry, NativeRegistry::CratesIo);''', + ''' assert_eq!(routes[2].target, "python"); + assert_eq!(routes[2].registry, NativeRegistry::PyPi); + assert_eq!(routes[2].package, "Acme.Client"); + assert_eq!(routes[3].target, "rust"); + assert_eq!(routes[3].registry, NativeRegistry::CratesIo);''', + 1, +) +tests = tests.replace( + ''' assert!(encoded.contains("registry = \"pub.dev\""));''', + ''' assert!(encoded.contains("registry = \"pub.dev\"")); + assert!(encoded.contains("registry = \"pypi\""));''', + 1, +) +invalid_anchor = ''' r#" +[targets.dart] +dir = "clients/dart" +[targets.dart.native] +registry = "pub.dev" +package = "class" +"#, + ] {''' +invalid_replacement = ''' r#" +[targets.dart] +dir = "clients/dart" +[targets.dart.native] +registry = "pub.dev" +package = "class" +"#, + r#" +[targets.python] +dir = "clients/python" +[targets.python.native] +registry = "pypi" +package = "-bad-name" +"#, + r#" +[targets.python] +dir = "clients/python" +[targets.python.native] +registry = "pypi" +package = "bad name" +"#, + ] {''' +if tests.count(invalid_anchor) != 1: + raise SystemExit("invalid package fixture insertion point changed") +tests = tests.replace(invalid_anchor, invalid_replacement, 1) + +append = r''' + +#[test] +fn pypi_duplicate_destinations_use_normalized_names() { + let error = Manifest::parse(&manifest( + r#" +[targets.python] +dir = "clients/python" +[targets.python.native] +registry = "pypi" +package = "Friendly_Bard" + +[targets.python-async] +dir = "clients/python-async" +[targets.python-async.native] +registry = "pypi" +package = "friendly...bard" +"#, + )) + .unwrap_err(); + let message = error.to_string(); + assert!(message.contains("already routed"), "{message}"); +} +''' +tests += append +TEST.write_text(tests, encoding="utf-8") +print("added PyPI routing and normalized collision checks") From 9a4032e90315768b1331f576bb69fdfd983d60e8 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 15:43:48 -0500 Subject: [PATCH 34/71] ci: apply PyPI native routing --- .../apply-pypi-routing-temporary.yml | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/apply-pypi-routing-temporary.yml diff --git a/.github/workflows/apply-pypi-routing-temporary.yml b/.github/workflows/apply-pypi-routing-temporary.yml new file mode 100644 index 0000000..4a7fb79 --- /dev/null +++ b/.github/workflows/apply-pypi-routing-temporary.yml @@ -0,0 +1,45 @@ +name: Apply PyPI native routing (temporary) + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: write + +jobs: + apply: + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + github.head_ref == 'feat/den-100-pypi-routing' && + github.actor == 'ORESoftware' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + persist-credentials: true + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + components: rustfmt + - name: Apply PyPI routing and tests + run: python3 scripts/apply_pypi_routing.py + - name: Format, test, and regenerate schemas + run: | + cargo fmt --all + cargo test --locked + cargo run --example generate_schemas + - name: Commit production changes to the feature branch + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/manifest.rs tests/native_release.rs schemas/ + if git diff --cached --quiet; then + echo "PyPI routing is already current." + exit 0 + fi + git commit -m "feat: declare PyPI release routing" + git push origin HEAD:feat/den-100-pypi-routing From 983f8dc40562fd665d1f0c705ce9d658483f0e75 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:44:42 +0000 Subject: [PATCH 35/71] feat: declare PyPI release routing --- schemas/manifest.json | 3 ++- schemas/publish-meta.json | 3 ++- src/manifest.rs | 40 +++++++++++++++++++++++++++++- tests/native_release.rs | 52 ++++++++++++++++++++++++++++++++++++--- 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/schemas/manifest.json b/schemas/manifest.json index 37a8c61..e8df8ca 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -137,7 +137,8 @@ "enum": [ "npm", "crates-io", - "pub.dev" + "pub.dev", + "pypi" ] }, "NativeReleaseSection": { diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index a584f96..3d48b37 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -183,7 +183,8 @@ "enum": [ "npm", "crates-io", - "pub.dev" + "pub.dev", + "pypi" ] }, "NativeReleaseSection": { diff --git a/src/manifest.rs b/src/manifest.rs index 19f3bb2..8391e94 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -245,6 +245,8 @@ pub enum NativeRegistry { CratesIo, #[serde(rename = "pub.dev")] PubDev, + #[serde(rename = "pypi")] + PyPi, } impl NativeRegistry { @@ -253,6 +255,7 @@ impl NativeRegistry { Self::Npm => "npm", Self::CratesIo => "crates-io", Self::PubDev => "pub.dev", + Self::PyPi => "pypi", } } @@ -261,6 +264,7 @@ impl NativeRegistry { Self::Npm => is_valid_npm_package(package), Self::CratesIo => is_valid_crates_package(package), Self::PubDev => is_valid_pubdev_package(package), + Self::PyPi => is_valid_pypi_package(package), }; if valid { Ok(()) @@ -271,6 +275,13 @@ impl NativeRegistry { )) } } + + fn canonical_package(self, package: &str) -> String { + match self { + Self::PyPi => normalize_pypi_package(package), + _ => package.to_string(), + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -315,6 +326,32 @@ fn is_valid_crates_package(value: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) } +fn is_valid_pypi_package(value: &str) -> bool { + !value.is_empty() + && value.as_bytes()[0].is_ascii_alphanumeric() + && value.as_bytes()[value.len() - 1].is_ascii_alphanumeric() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn normalize_pypi_package(value: &str) -> String { + let mut normalized = String::with_capacity(value.len()); + let mut separator = false; + for byte in value.bytes() { + if matches!(byte, b'.' | b'_' | b'-') { + separator = true; + continue; + } + if separator && !normalized.is_empty() { + normalized.push('-'); + } + separator = false; + normalized.push((byte as char).to_ascii_lowercase()); + } + normalized +} + fn is_valid_pubdev_package(value: &str) -> bool { const RESERVED: &[&str] = &[ "assert", "break", "case", "catch", "class", "const", "continue", "default", "do", "else", @@ -586,7 +623,8 @@ impl Manifest { .registry .validate_package(&native.package) .map_err(|reason| ManifestError::InvalidNativeRoute(name.clone(), reason))?; - let route = (native.registry, native.package.clone()); + let canonical_package = native.registry.canonical_package(&native.package); + let route = (native.registry, canonical_package); if let Some(previous) = native_routes.insert(route, name.as_str()) { return Err(ManifestError::InvalidNativeRoute( name.clone(), diff --git a/tests/native_release.rs b/tests/native_release.rs index ea61750..065b336 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -41,20 +41,30 @@ dir = "clients/dart" [targets.dart.native] registry = "pub.dev" package = "acme_client" + +[targets.python] +dir = "clients/python" + +[targets.python.native] +registry = "pypi" +package = "Acme.Client" "#, )) .unwrap(); let routes = parsed.native_release_routes(); - assert_eq!(routes.len(), 3); + assert_eq!(routes.len(), 4); assert_eq!(routes[0].target, "dart"); assert_eq!(routes[0].registry, NativeRegistry::PubDev); assert_eq!(routes[0].package, "acme_client"); assert_eq!(routes[1].target, "nodejs"); assert_eq!(routes[1].registry, NativeRegistry::Npm); assert_eq!(routes[1].package, "@acme/client"); - assert_eq!(routes[2].target, "rust"); - assert_eq!(routes[2].registry, NativeRegistry::CratesIo); + assert_eq!(routes[2].target, "python"); + assert_eq!(routes[2].registry, NativeRegistry::PyPi); + assert_eq!(routes[2].package, "Acme.Client"); + assert_eq!(routes[3].target, "rust"); + assert_eq!(routes[3].registry, NativeRegistry::CratesIo); let encoded = parsed.to_toml_string().unwrap(); assert!(encoded.contains("[targets.nodejs.native]")); @@ -116,6 +126,20 @@ dir = "clients/dart" [targets.dart.native] registry = "pub.dev" package = "class" +"#, + r#" +[targets.python] +dir = "clients/python" +[targets.python.native] +registry = "pypi" +package = "-bad-name" +"#, + r#" +[targets.python] +dir = "clients/python" +[targets.python.native] +registry = "pypi" +package = "bad name" "#, ] { assert!(matches!( @@ -145,3 +169,25 @@ package = "@acme/client" .unwrap_err(); assert!(matches!(error, ManifestError::InvalidNativeRoute(_, _))); } + +#[test] +fn pypi_duplicate_destinations_use_normalized_names() { + let error = Manifest::parse(&manifest( + r#" +[targets.python] +dir = "clients/python" +[targets.python.native] +registry = "pypi" +package = "Friendly_Bard" + +[targets.python-async] +dir = "clients/python-async" +[targets.python-async.native] +registry = "pypi" +package = "friendly...bard" +"#, + )) + .unwrap_err(); + let message = error.to_string(); + assert!(message.contains("already routed"), "{message}"); +} From 9eff1e13ddbe263b4569bd78e6d7620441c69b6e Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 15:45:33 -0500 Subject: [PATCH 36/71] chore: remove temporary PyPI routing workflow --- .../apply-pypi-routing-temporary.yml | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/workflows/apply-pypi-routing-temporary.yml diff --git a/.github/workflows/apply-pypi-routing-temporary.yml b/.github/workflows/apply-pypi-routing-temporary.yml deleted file mode 100644 index 4a7fb79..0000000 --- a/.github/workflows/apply-pypi-routing-temporary.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Apply PyPI native routing (temporary) - -on: - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: write - -jobs: - apply: - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - github.head_ref == 'feat/den-100-pypi-routing' && - github.actor == 'ORESoftware' - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - persist-credentials: true - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: stable - components: rustfmt - - name: Apply PyPI routing and tests - run: python3 scripts/apply_pypi_routing.py - - name: Format, test, and regenerate schemas - run: | - cargo fmt --all - cargo test --locked - cargo run --example generate_schemas - - name: Commit production changes to the feature branch - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/manifest.rs tests/native_release.rs schemas/ - if git diff --cached --quiet; then - echo "PyPI routing is already current." - exit 0 - fi - git commit -m "feat: declare PyPI release routing" - git push origin HEAD:feat/den-100-pypi-routing From 05f382bf666326ee2337d40319ad16e09f3fcd71 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 15:45:42 -0500 Subject: [PATCH 37/71] chore: remove temporary PyPI routing helper --- scripts/apply_pypi_routing.py | 210 ---------------------------------- 1 file changed, 210 deletions(-) delete mode 100644 scripts/apply_pypi_routing.py diff --git a/scripts/apply_pypi_routing.py b/scripts/apply_pypi_routing.py deleted file mode 100644 index 107964c..0000000 --- a/scripts/apply_pypi_routing.py +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env python3 -"""Extend typed native release routing with PyPI semantics.""" - -from pathlib import Path - - -MANIFEST = Path("src/manifest.rs") -text = MANIFEST.read_text(encoding="utf-8") - - -def replace_once(old: str, new: str, label: str) -> None: - global text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one insertion point, found {count}") - text = text.replace(old, new, 1) - - -replace_once( - ''' #[serde(rename = "pub.dev")] - PubDev, -}''', - ''' #[serde(rename = "pub.dev")] - PubDev, - PyPi, -}''', - "NativeRegistry::PyPi", -) -replace_once( - ''' Self::PubDev => "pub.dev", - }''', - ''' Self::PubDev => "pub.dev", - Self::PyPi => "pypi", - }''', - "PyPI display name", -) -replace_once( - ''' Self::PubDev => is_valid_pubdev_package(package), - };''', - ''' Self::PubDev => is_valid_pubdev_package(package), - Self::PyPi => is_valid_pypi_package(package), - };''', - "PyPI syntax validation", -) -replace_once( - ''' } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct NativeReleaseRoute {''', - ''' } - - fn canonical_package(self, package: &str) -> String { - match self { - Self::PyPi => normalize_pypi_package(package), - _ => package.to_string(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct NativeReleaseRoute {''', - "registry canonical package method", -) -replace_once( - '''fn is_valid_pubdev_package(value: &str) -> bool {''', - '''fn is_valid_pypi_package(value: &str) -> bool { - !value.is_empty() - && value.as_bytes()[0].is_ascii_alphanumeric() - && value.as_bytes()[value.len() - 1].is_ascii_alphanumeric() - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) -} - -fn normalize_pypi_package(value: &str) -> String { - let mut normalized = String::with_capacity(value.len()); - let mut separator = false; - for byte in value.bytes() { - if matches!(byte, b'.' | b'_' | b'-') { - separator = true; - continue; - } - if separator && !normalized.is_empty() { - normalized.push('-'); - } - separator = false; - normalized.push((byte as char).to_ascii_lowercase()); - } - normalized -} - -fn is_valid_pubdev_package(value: &str) -> bool {''', - "PyPI helpers", -) -replace_once( - ''' let route = (native.registry, native.package.clone()); - if let Some(previous) = native_routes.insert(route, name.as_str()) {''', - ''' let canonical_package = native.registry.canonical_package(&native.package); - let route = (native.registry, canonical_package); - if let Some(previous) = native_routes.insert(route, name.as_str()) {''', - "normalized native route collision key", -) -MANIFEST.write_text(text, encoding="utf-8") - -TEST = Path("tests/native_release.rs") -tests = TEST.read_text(encoding="utf-8") - -anchor = '''[targets.dart.native] -registry = "pub.dev" -package = "acme_client" -"#,''' -replacement = '''[targets.dart.native] -registry = "pub.dev" -package = "acme_client" - -[targets.python] -dir = "clients/python" - -[targets.python.native] -registry = "py-pi" -package = "Acme.Client" -"#,''' -# Serde rename_all kebab-case would make PyPi -> py-pi, but desired wire spelling is pypi. -# Patch source enum with an explicit rename after initial insertion. -text = MANIFEST.read_text(encoding="utf-8") -text = text.replace(' PyPi,\n}', ' #[serde(rename = "pypi")]\n PyPi,\n}', 1) -MANIFEST.write_text(text, encoding="utf-8") -replacement = replacement.replace('registry = "py-pi"', 'registry = "pypi"') -if tests.count(anchor) != 1: - raise SystemExit("route fixture insertion point changed") -tests = tests.replace(anchor, replacement, 1) -tests = tests.replace('assert_eq!(routes.len(), 3);', 'assert_eq!(routes.len(), 4);', 1) -tests = tests.replace( - ''' assert_eq!(routes[2].target, "rust"); - assert_eq!(routes[2].registry, NativeRegistry::CratesIo);''', - ''' assert_eq!(routes[2].target, "python"); - assert_eq!(routes[2].registry, NativeRegistry::PyPi); - assert_eq!(routes[2].package, "Acme.Client"); - assert_eq!(routes[3].target, "rust"); - assert_eq!(routes[3].registry, NativeRegistry::CratesIo);''', - 1, -) -tests = tests.replace( - ''' assert!(encoded.contains("registry = \"pub.dev\""));''', - ''' assert!(encoded.contains("registry = \"pub.dev\"")); - assert!(encoded.contains("registry = \"pypi\""));''', - 1, -) -invalid_anchor = ''' r#" -[targets.dart] -dir = "clients/dart" -[targets.dart.native] -registry = "pub.dev" -package = "class" -"#, - ] {''' -invalid_replacement = ''' r#" -[targets.dart] -dir = "clients/dart" -[targets.dart.native] -registry = "pub.dev" -package = "class" -"#, - r#" -[targets.python] -dir = "clients/python" -[targets.python.native] -registry = "pypi" -package = "-bad-name" -"#, - r#" -[targets.python] -dir = "clients/python" -[targets.python.native] -registry = "pypi" -package = "bad name" -"#, - ] {''' -if tests.count(invalid_anchor) != 1: - raise SystemExit("invalid package fixture insertion point changed") -tests = tests.replace(invalid_anchor, invalid_replacement, 1) - -append = r''' - -#[test] -fn pypi_duplicate_destinations_use_normalized_names() { - let error = Manifest::parse(&manifest( - r#" -[targets.python] -dir = "clients/python" -[targets.python.native] -registry = "pypi" -package = "Friendly_Bard" - -[targets.python-async] -dir = "clients/python-async" -[targets.python-async.native] -registry = "pypi" -package = "friendly...bard" -"#, - )) - .unwrap_err(); - let message = error.to_string(); - assert!(message.contains("already routed"), "{message}"); -} -''' -tests += append -TEST.write_text(tests, encoding="utf-8") -print("added PyPI routing and normalized collision checks") From 7a731361f8e49fe54cee078a35fcbf80e0906c3b Mon Sep 17 00:00:00 2001 From: alex-mills Date: Wed, 29 Jul 2026 23:16:56 -0500 Subject: [PATCH 38/71] manifest: language/ecosystem tagging for per-language packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A polyglot repo publishes one package per language (`-`), but nothing recorded which language a published artifact was *for*, so a consumer could install the `-java` slice into a Node project and get a tree its toolchain never reads, with no complaint at any point. Two axes, because conflating them breaks one case or the other: * `language` names the package. Java, Kotlin, Scala and Clojure are four clients a human asks for by name, so they are four packages. * `ecosystem` decides who may install it. Those same four are one ecosystem (jvm), and all equally valid in a Gradle project. Keying only on language would leave an install guard unable to tell that a Kotlin package belongs in a Gradle build; keying only on ecosystem would collapse `-java` and `-kotlin` into one name. `manifest_for_target` stamps both onto each derived per-target manifest, so a published slice self-describes and a consumer has something to check. Also: * `resolve_target_key` matches through language synonyms. Project inference yields `node`/`go` while these repos publish `-nodejs`/`-golang` because that is how the names should read; without synonym matching every such consumer hit "publishes no such target" despite the package shipping exactly what they needed. * `NativeRegistry::ecosystem()` bridges the outbound mirror to the inbound install gate, and `validate` now rejects a target whose native route contradicts its install ecosystem — a python slice mirrored to npm means one of the two is wrong, and either way it stays silent until someone hits it. * `ADAPTERS` is shared so the CLI and the manifest cannot disagree about which adapter names are legal. Co-Authored-By: Claude Opus 5 (1M context) --- schemas/manifest.json | 123 ++++++- schemas/publish-meta.json | 123 ++++++- src/language.rs | 660 ++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + src/manifest.rs | 149 ++++++++- tests/native_release.rs | 89 +++++ tests/roundtrip.rs | 162 ++++++++++ 7 files changed, 1303 insertions(+), 5 deletions(-) create mode 100644 src/language.rs diff --git a/schemas/manifest.json b/schemas/manifest.json index e8df8ca..f2b9ff0 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -105,6 +105,56 @@ "command" ] }, + "Ecosystem": { + "description": "How a consumer's build system takes a dependency in: the resolution\nmechanism, not the language. Drives the install guard and which toolchain\nwiring file `zed install` writes.", + "oneOf": [ + { + "type": "string", + "enum": [ + "cargo", + "cmake", + "composer", + "cran", + "gem", + "gomod", + "hackage", + "julia", + "luarocks", + "matlab", + "nimble", + "npm", + "nuget", + "opam", + "psgallery", + "pypi", + "shards", + "shell", + "swiftpm", + "zig" + ] + }, + { + "description": "No ecosystem constraint. Installs into any project; never gated.", + "type": "string", + "const": "universal" + }, + { + "description": "Elixir, Erlang and Gleam share Hex and the BEAM.", + "type": "string", + "const": "hex" + }, + { + "description": "Anything consumed off a JVM classpath: Java, Kotlin, Scala, Clojure.", + "type": "string", + "const": "jvm" + }, + { + "description": "Dart and Flutter (`pub`).", + "type": "string", + "const": "pub" + } + ] + }, "InstallSection": { "description": "Install-layout controls: where zed's dependency tree lands and which\necosystem adapter to emit so those deps are visible to the native toolchain.", "type": "object", @@ -132,6 +182,65 @@ } } }, + "Language": { + "description": "The language a package's code is written for. Names the published package\n(`-`), and implies an [`Ecosystem`] via\n[`Language::ecosystem`].\n\nCanonical tokens are the colloquial names people search for — `nodejs`, not\n`node`; `golang`, not `go` — because that is what ends up in a package name\na human has to recall. The shorter spellings, and near-synonyms like\n`typescript`, are accepted by [`Language::from_token`] and by umbrella\nvariant aliases, so both spellings resolve.", + "oneOf": [ + { + "type": "string", + "enum": [ + "c", + "clojure", + "cpp", + "crystal", + "csharp", + "dart", + "elixir", + "erlang", + "fsharp", + "gleam", + "golang", + "haskell", + "java", + "julia", + "kotlin", + "lua", + "matlab", + "nim", + "ocaml", + "php", + "powershell", + "python", + "r", + "ruby", + "rust", + "scala", + "shell", + "swift", + "zig" + ] + }, + { + "description": "Not language-specific: protocol schemas, docs, `.proto` files, an\numbrella package. Never subject to the install ecosystem guard.", + "type": "string", + "const": "universal" + }, + { + "description": "Flutter is a framework rather than a language, but a Flutter client is a\nseparate package from a plain Dart one (different `pubspec.yaml`,\ndifferent deps), so it gets its own token.", + "type": "string", + "const": "flutter" + }, + { + "description": "Covers JavaScript and TypeScript alike: for a client library they are\none artifact published to one ecosystem, so `typescript`, `javascript`,\n`ts`, `js` and `node` all resolve here rather than splitting the package.", + "type": "string", + "const": "nodejs" + }, + { + "description": "Rust compiled to WebAssembly. Distinct from [`Language::Rust`] because\nthe artifact is consumed by a JS bundler, not by Cargo.", + "type": "string", + "const": "rust-wasm" + } + ] + }, "NativeRegistry": { "type": "string", "enum": [ @@ -178,12 +287,20 @@ "null" ] }, + "ecosystem": { + "description": "How consumers take this package in: `jvm`, `npm`, `gomod`, … Drives the\ninstall-time guard that refuses to drop a Java client into a Node\nproject, and picks the toolchain wiring `zed install` writes.\n\nOmit it and it is derived from `language` (see\n[`Language::ecosystem`]) — declare it only when the language does not\ndetermine consumption. Read through [`PackageSection::ecosystem`]\nrather than touching this field, so the fallback always applies.", + "$ref": "#/$defs/Ecosystem" + }, "keywords": { "type": "array", "items": { "type": "string" } }, + "language": { + "description": "The language this package's code targets. A multi-language repository\npublishes one package per language, all at one version, and each names\nits own — `acme-clients-java`, `acme-clients-nodejs`. Defaults to\n`universal` (language-agnostic), which is never subject to the install\necosystem guard.", + "$ref": "#/$defs/Language" + }, "license": { "type": [ "string", @@ -292,6 +409,10 @@ "description": "Package-relative directory that is this ecosystem's package root, e.g.\n`python` or `clients/go`. Must be a safe relative path (no leading `/`,\nno `..`) so a target can never escape the package.", "type": "string" }, + "ecosystem": { + "description": "Override the ecosystem this target publishes into. Omit it (the normal\ncase) and it is derived from the target key via [`Language::ecosystem`].\nDeclare it when the key does not determine consumption — a `rust-wasm`\ntarget is consumed by a JS bundler, not by Cargo.", + "$ref": "#/$defs/Ecosystem" + }, "name": { "description": "Published package name for this target. Defaults to\n`-` (e.g. `fiducia-clients-java`). Set it to\nbreak out of the suffix convention when an ecosystem expects a\ndifferent spelling.", "type": [ @@ -300,7 +421,7 @@ ] }, "native": { - "description": "Optional routing to this target's native ecosystem registry. This is\ndeclarative metadata only: the native manifest remains authoritative,\nand arbitrary commands are intentionally not representable here.", + "description": "Optional routing to this target's native ecosystem registry. This is\ndeclarative metadata only: the native manifest remains authoritative,\nand arbitrary commands are intentionally not representable here.\n\nNote the difference from [`TargetSection::ecosystem`]: `native` is\n**outbound** (where this slice is mirrored to — npm, crates.io), while\n`ecosystem` is **inbound** (what toolchain a consumer must have to\ninstall it from zed). They describe the same ecosystem from two sides,\nso when both are set they must agree — see\n[`NativeRegistry::ecosystem`] and the check in [`Manifest::validate`].", "anyOf": [ { "$ref": "#/$defs/NativeReleaseSection" diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index 3d48b37..beffa91 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -66,6 +66,56 @@ "command" ] }, + "Ecosystem": { + "description": "How a consumer's build system takes a dependency in: the resolution\nmechanism, not the language. Drives the install guard and which toolchain\nwiring file `zed install` writes.", + "oneOf": [ + { + "type": "string", + "enum": [ + "cargo", + "cmake", + "composer", + "cran", + "gem", + "gomod", + "hackage", + "julia", + "luarocks", + "matlab", + "nimble", + "npm", + "nuget", + "opam", + "psgallery", + "pypi", + "shards", + "shell", + "swiftpm", + "zig" + ] + }, + { + "description": "No ecosystem constraint. Installs into any project; never gated.", + "type": "string", + "const": "universal" + }, + { + "description": "Elixir, Erlang and Gleam share Hex and the BEAM.", + "type": "string", + "const": "hex" + }, + { + "description": "Anything consumed off a JVM classpath: Java, Kotlin, Scala, Clojure.", + "type": "string", + "const": "jvm" + }, + { + "description": "Dart and Flutter (`pub`).", + "type": "string", + "const": "pub" + } + ] + }, "InstallSection": { "description": "Install-layout controls: where zed's dependency tree lands and which\necosystem adapter to emit so those deps are visible to the native toolchain.", "type": "object", @@ -93,6 +143,65 @@ } } }, + "Language": { + "description": "The language a package's code is written for. Names the published package\n(`-`), and implies an [`Ecosystem`] via\n[`Language::ecosystem`].\n\nCanonical tokens are the colloquial names people search for — `nodejs`, not\n`node`; `golang`, not `go` — because that is what ends up in a package name\na human has to recall. The shorter spellings, and near-synonyms like\n`typescript`, are accepted by [`Language::from_token`] and by umbrella\nvariant aliases, so both spellings resolve.", + "oneOf": [ + { + "type": "string", + "enum": [ + "c", + "clojure", + "cpp", + "crystal", + "csharp", + "dart", + "elixir", + "erlang", + "fsharp", + "gleam", + "golang", + "haskell", + "java", + "julia", + "kotlin", + "lua", + "matlab", + "nim", + "ocaml", + "php", + "powershell", + "python", + "r", + "ruby", + "rust", + "scala", + "shell", + "swift", + "zig" + ] + }, + { + "description": "Not language-specific: protocol schemas, docs, `.proto` files, an\numbrella package. Never subject to the install ecosystem guard.", + "type": "string", + "const": "universal" + }, + { + "description": "Flutter is a framework rather than a language, but a Flutter client is a\nseparate package from a plain Dart one (different `pubspec.yaml`,\ndifferent deps), so it gets its own token.", + "type": "string", + "const": "flutter" + }, + { + "description": "Covers JavaScript and TypeScript alike: for a client library they are\none artifact published to one ecosystem, so `typescript`, `javascript`,\n`ts`, `js` and `node` all resolve here rather than splitting the package.", + "type": "string", + "const": "nodejs" + }, + { + "description": "Rust compiled to WebAssembly. Distinct from [`Language::Rust`] because\nthe artifact is consumed by a JS bundler, not by Cargo.", + "type": "string", + "const": "rust-wasm" + } + ] + }, "Manifest": { "description": "The `.zpkg.toml` manifest at the root of every package repository.\nTOML only — never YAML or JSON.\n\n```toml\n[package]\norg = \"acme\"\nname = \"http-kit\"\nversion = \"1.2.0\"\ndescription = \"Tiny HTTP helpers\"\nlicense = \"MIT\"\n\n[package.repository]\nvcs = \"git\"\nurl = \"https://github.com/acme/http-kit\"\n\n[dependencies]\n\"acme/logkit\" = \"^0.3\"\n\n[publish]\nexclude = [\"benches/**\"]\nsmoke_test = \"sh scripts/smoke.sh\"\n```", "type": "object", @@ -224,12 +333,20 @@ "null" ] }, + "ecosystem": { + "description": "How consumers take this package in: `jvm`, `npm`, `gomod`, … Drives the\ninstall-time guard that refuses to drop a Java client into a Node\nproject, and picks the toolchain wiring `zed install` writes.\n\nOmit it and it is derived from `language` (see\n[`Language::ecosystem`]) — declare it only when the language does not\ndetermine consumption. Read through [`PackageSection::ecosystem`]\nrather than touching this field, so the fallback always applies.", + "$ref": "#/$defs/Ecosystem" + }, "keywords": { "type": "array", "items": { "type": "string" } }, + "language": { + "description": "The language this package's code targets. A multi-language repository\npublishes one package per language, all at one version, and each names\nits own — `acme-clients-java`, `acme-clients-nodejs`. Defaults to\n`universal` (language-agnostic), which is never subject to the install\necosystem guard.", + "$ref": "#/$defs/Language" + }, "license": { "type": [ "string", @@ -338,6 +455,10 @@ "description": "Package-relative directory that is this ecosystem's package root, e.g.\n`python` or `clients/go`. Must be a safe relative path (no leading `/`,\nno `..`) so a target can never escape the package.", "type": "string" }, + "ecosystem": { + "description": "Override the ecosystem this target publishes into. Omit it (the normal\ncase) and it is derived from the target key via [`Language::ecosystem`].\nDeclare it when the key does not determine consumption — a `rust-wasm`\ntarget is consumed by a JS bundler, not by Cargo.", + "$ref": "#/$defs/Ecosystem" + }, "name": { "description": "Published package name for this target. Defaults to\n`-` (e.g. `fiducia-clients-java`). Set it to\nbreak out of the suffix convention when an ecosystem expects a\ndifferent spelling.", "type": [ @@ -346,7 +467,7 @@ ] }, "native": { - "description": "Optional routing to this target's native ecosystem registry. This is\ndeclarative metadata only: the native manifest remains authoritative,\nand arbitrary commands are intentionally not representable here.", + "description": "Optional routing to this target's native ecosystem registry. This is\ndeclarative metadata only: the native manifest remains authoritative,\nand arbitrary commands are intentionally not representable here.\n\nNote the difference from [`TargetSection::ecosystem`]: `native` is\n**outbound** (where this slice is mirrored to — npm, crates.io), while\n`ecosystem` is **inbound** (what toolchain a consumer must have to\ninstall it from zed). They describe the same ecosystem from two sides,\nso when both are set they must agree — see\n[`NativeRegistry::ecosystem`] and the check in [`Manifest::validate`].", "anyOf": [ { "$ref": "#/$defs/NativeReleaseSection" diff --git a/src/language.rs b/src/language.rs new file mode 100644 index 0000000..d7b9fb9 --- /dev/null +++ b/src/language.rs @@ -0,0 +1,660 @@ +//! Language and ecosystem tagging for multi-language packages. +//! +//! A repository that generates API clients for many languages cannot ship as +//! one zed package: a Java consumer would download the Node, Python and Dart +//! trees it will never load, and nothing would stop a Node project from +//! installing the Java client and silently getting an unusable +//! `zed_modules` entry. Such a repo instead publishes one package **per +//! language**, all at one version — `acme/acme-clients-java@1.1.2`, +//! `acme/acme-clients-nodejs@1.1.2`, … — and each declares what it is for. +//! +//! Two axes are needed, and conflating them breaks one case or the other: +//! +//! * [`Language`] **names** the package. Java, Kotlin, Scala and Clojure are +//! four distinct clients a human asks for by name, so they are four packages. +//! * [`Ecosystem`] decides **who may install it** and how the toolchain sees +//! it. Those same four languages are one ecosystem ([`Ecosystem::Jvm`]): +//! consumed off a classpath, and all equally valid in a Gradle project. +//! +//! Keying only on language would leave the install guard unable to tell that a +//! Kotlin package belongs in a Gradle build. Keying only on ecosystem would +//! collapse `-java` and `-kotlin` into one name. Hence both, with +//! [`Language::ecosystem`] supplying the default mapping so a manifest normally +//! declares just `language`. +//! +//! Both default to `universal`, which is never gated — every manifest written +//! before this module existed keeps its old meaning. + +use std::collections::BTreeSet; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// The language a package's code is written for. Names the published package +/// (`-`), and implies an [`Ecosystem`] via +/// [`Language::ecosystem`]. +/// +/// Canonical tokens are the colloquial names people search for — `nodejs`, not +/// `node`; `golang`, not `go` — because that is what ends up in a package name +/// a human has to recall. The shorter spellings, and near-synonyms like +/// `typescript`, are accepted by [`Language::from_token`] and by umbrella +/// variant aliases, so both spellings resolve. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum Language { + /// Not language-specific: protocol schemas, docs, `.proto` files, an + /// umbrella package. Never subject to the install ecosystem guard. + #[default] + Universal, + C, + Clojure, + Cpp, + Crystal, + Csharp, + Dart, + Elixir, + Erlang, + /// Flutter is a framework rather than a language, but a Flutter client is a + /// separate package from a plain Dart one (different `pubspec.yaml`, + /// different deps), so it gets its own token. + Flutter, + Fsharp, + Gleam, + Golang, + Haskell, + Java, + Julia, + Kotlin, + Lua, + Matlab, + Nim, + /// Covers JavaScript and TypeScript alike: for a client library they are + /// one artifact published to one ecosystem, so `typescript`, `javascript`, + /// `ts`, `js` and `node` all resolve here rather than splitting the package. + Nodejs, + Ocaml, + Php, + Powershell, + Python, + R, + Ruby, + Rust, + /// Rust compiled to WebAssembly. Distinct from [`Language::Rust`] because + /// the artifact is consumed by a JS bundler, not by Cargo. + #[serde(rename = "rust-wasm")] + RustWasm, + Scala, + Shell, + Swift, + Zig, +} + +/// How a consumer's build system takes a dependency in: the resolution +/// mechanism, not the language. Drives the install guard and which toolchain +/// wiring file `zed install` writes. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum Ecosystem { + /// No ecosystem constraint. Installs into any project; never gated. + #[default] + Universal, + Cargo, + Cmake, + Composer, + Cran, + Gem, + Gomod, + Hackage, + /// Elixir, Erlang and Gleam share Hex and the BEAM. + Hex, + /// Anything consumed off a JVM classpath: Java, Kotlin, Scala, Clojure. + Jvm, + Julia, + Luarocks, + Matlab, + Nimble, + Npm, + Nuget, + Opam, + Psgallery, + /// Dart and Flutter (`pub`). + Pub, + Pypi, + Shards, + Shell, + Swiftpm, + Zig, +} + +impl Language { + pub fn as_str(&self) -> &'static str { + use Language::*; + match self { + Universal => "universal", + C => "c", + Clojure => "clojure", + Cpp => "cpp", + Crystal => "crystal", + Csharp => "csharp", + Dart => "dart", + Elixir => "elixir", + Erlang => "erlang", + Flutter => "flutter", + Fsharp => "fsharp", + Gleam => "gleam", + Golang => "golang", + Haskell => "haskell", + Java => "java", + Julia => "julia", + Kotlin => "kotlin", + Lua => "lua", + Matlab => "matlab", + Nim => "nim", + Nodejs => "nodejs", + Ocaml => "ocaml", + Php => "php", + Powershell => "powershell", + Python => "python", + R => "r", + Ruby => "ruby", + Rust => "rust", + RustWasm => "rust-wasm", + Scala => "scala", + Shell => "shell", + Swift => "swift", + Zig => "zig", + } + } + + /// True for the default (`universal`); lets manifests omit the field. + pub fn is_default(&self) -> bool { + matches!(self, Language::Universal) + } + + /// The ecosystem this language publishes into by default. A package may + /// override it (`[package] ecosystem = "..."`) when the language does not + /// determine consumption — a `rust-wasm` client is consumed by a JS + /// bundler, but a Rust `cdylib` of the same source would be Cargo. + pub fn ecosystem(&self) -> Ecosystem { + use Language::*; + match self { + Universal => Ecosystem::Universal, + C | Cpp => Ecosystem::Cmake, + Clojure | Java | Kotlin | Scala => Ecosystem::Jvm, + Crystal => Ecosystem::Shards, + Csharp | Fsharp => Ecosystem::Nuget, + Dart | Flutter => Ecosystem::Pub, + Elixir | Erlang | Gleam => Ecosystem::Hex, + Golang => Ecosystem::Gomod, + Haskell => Ecosystem::Hackage, + Julia => Ecosystem::Julia, + Lua => Ecosystem::Luarocks, + Matlab => Ecosystem::Matlab, + Nim => Ecosystem::Nimble, + // wasm-pack output is a JS package, not a Cargo one. + Nodejs | RustWasm => Ecosystem::Npm, + Ocaml => Ecosystem::Opam, + Php => Ecosystem::Composer, + Powershell => Ecosystem::Psgallery, + Python => Ecosystem::Pypi, + R => Ecosystem::Cran, + Ruby => Ecosystem::Gem, + Rust => Ecosystem::Cargo, + Shell => Ecosystem::Shell, + Swift => Ecosystem::Swiftpm, + Zig => Ecosystem::Zig, + } + } + + /// Resolve a user-supplied token — a canonical name, a common short form, + /// or a source-directory name — to a language. This is what makes + /// `zed add …-go` find `…-golang`, and what lets a repo whose directory is + /// `clients/ts/` publish as `-nodejs`. + /// + /// Case- and separator-insensitive: `RustWasm`, `rust_wasm` and + /// `rust-wasm` are the same token. + pub fn from_token(token: &str) -> Option { + use Language::*; + let normalized: String = token + .trim() + .to_ascii_lowercase() + .chars() + .map(|c| if c == '_' || c == ' ' { '-' } else { c }) + .collect(); + Some(match normalized.as_str() { + "universal" | "any" | "none" => Universal, + "c" => C, + "clojure" | "clj" => Clojure, + "cpp" | "c++" | "cxx" => Cpp, + "crystal" => Crystal, + "csharp" | "c#" | "cs" | "dotnet" | "net" => Csharp, + "dart" => Dart, + "elixir" | "ex" => Elixir, + "erlang" | "erl" => Erlang, + "flutter" => Flutter, + "fsharp" | "f#" | "fs" => Fsharp, + "gleam" => Gleam, + "golang" | "go" => Golang, + "haskell" | "hs" => Haskell, + "java" => Java, + "julia" | "jl" => Julia, + "kotlin" | "kt" => Kotlin, + "lua" => Lua, + "matlab" => Matlab, + "nim" => Nim, + "nodejs" | "node" | "javascript" | "js" | "typescript" | "ts" => Nodejs, + "ocaml" | "ml" => Ocaml, + "php" => Php, + "powershell" | "pwsh" | "ps1" => Powershell, + "python" | "py" => Python, + "r" => R, + "ruby" | "rb" => Ruby, + "rust" | "rs" => Rust, + "rust-wasm" | "rustwasm" | "wasm" => RustWasm, + "scala" => Scala, + "shell" | "sh" | "bash" => Shell, + "swift" => Swift, + "zig" => Zig, + _ => return None, + }) + } + + /// Parse a stored value, degrading unknown input to `universal` rather than + /// failing a read. Mirrors [`crate::version::VersionScheme::from_str_lenient`]: + /// a value written by a newer zed must never wedge an older one. + pub fn from_str_lenient(s: &str) -> Language { + Language::from_token(s).unwrap_or(Language::Universal) + } +} + +impl std::fmt::Display for Language { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl Ecosystem { + pub fn as_str(&self) -> &'static str { + use Ecosystem::*; + match self { + Universal => "universal", + Cargo => "cargo", + Cmake => "cmake", + Composer => "composer", + Cran => "cran", + Gem => "gem", + Gomod => "gomod", + Hackage => "hackage", + Hex => "hex", + Jvm => "jvm", + Julia => "julia", + Luarocks => "luarocks", + Matlab => "matlab", + Nimble => "nimble", + Npm => "npm", + Nuget => "nuget", + Opam => "opam", + Psgallery => "psgallery", + Pub => "pub", + Pypi => "pypi", + Shards => "shards", + Shell => "shell", + Swiftpm => "swiftpm", + Zig => "zig", + } + } + + /// True for the default (`universal`); lets manifests omit the field. + pub fn is_default(&self) -> bool { + matches!(self, Ecosystem::Universal) + } + + pub fn from_token(token: &str) -> Option { + use Ecosystem::*; + Some(match token.trim().to_ascii_lowercase().as_str() { + "universal" | "any" | "none" => Universal, + "cargo" | "crates" | "crates-io" => Cargo, + "cmake" => Cmake, + "composer" => Composer, + "cran" => Cran, + "gem" | "rubygems" => Gem, + "gomod" | "go" | "goproxy" => Gomod, + "hackage" => Hackage, + "hex" => Hex, + "jvm" | "maven" | "gradle" => Jvm, + "julia" => Julia, + "luarocks" => Luarocks, + "matlab" => Matlab, + "nimble" => Nimble, + "npm" | "node" | "nodejs" | "yarn" | "pnpm" => Npm, + "nuget" | "dotnet" => Nuget, + "opam" => Opam, + "psgallery" | "powershell" => Psgallery, + "pub" | "dart" | "flutter" => Pub, + "pypi" | "pip" | "python" => Pypi, + "shards" => Shards, + "shell" | "sh" => Shell, + "swiftpm" | "spm" => Swiftpm, + "zig" => Zig, + _ => return None, + }) + } + + pub fn from_str_lenient(s: &str) -> Ecosystem { + Ecosystem::from_token(s).unwrap_or(Ecosystem::Universal) + } + + /// Filenames in a project root that identify this ecosystem. A leading + /// `*.` matches by extension; everything else is an exact filename. + /// + /// Empty for ecosystems with no conventional marker file + /// ([`Ecosystem::Matlab`], [`Ecosystem::Shell`]) — those are never + /// auto-detected, so a project consuming one must say so explicitly with + /// `[install] adapter` or `--adapter`. + pub fn marker_files(&self) -> &'static [&'static str] { + use Ecosystem::*; + match self { + Universal | Matlab | Shell => &[], + Cargo => &["Cargo.toml"], + Cmake => &["CMakeLists.txt"], + Composer => &["composer.json"], + Cran => &["DESCRIPTION"], + Gem => &["Gemfile", "*.gemspec"], + Gomod => &["go.mod", "go.work"], + Hackage => &["stack.yaml", "*.cabal"], + Hex => &["mix.exs", "rebar.config", "gleam.toml"], + Jvm => &[ + "pom.xml", + "build.gradle", + "build.gradle.kts", + "build.sbt", + "deps.edn", + "project.clj", + ], + Julia => &["Project.toml", "JuliaProject.toml"], + Luarocks => &["*.rockspec"], + Nimble => &["*.nimble"], + Npm => &["package.json"], + Nuget => &["*.csproj", "*.fsproj", "*.sln"], + Opam => &["dune-project", "*.opam"], + Psgallery => &["*.psd1"], + Pub => &["pubspec.yaml"], + Pypi => &[ + "pyproject.toml", + "setup.py", + "setup.cfg", + "requirements.txt", + ], + Shards => &["shard.yml"], + Swiftpm => &["Package.swift"], + Zig => &["build.zig.zon", "build.zig"], + } + } + + /// Every ecosystem, for exhaustive detection and for error messages that + /// list what a project could be. + pub const ALL: &'static [Ecosystem] = &[ + Ecosystem::Cargo, + Ecosystem::Cmake, + Ecosystem::Composer, + Ecosystem::Cran, + Ecosystem::Gem, + Ecosystem::Gomod, + Ecosystem::Hackage, + Ecosystem::Hex, + Ecosystem::Jvm, + Ecosystem::Julia, + Ecosystem::Luarocks, + Ecosystem::Matlab, + Ecosystem::Nimble, + Ecosystem::Npm, + Ecosystem::Nuget, + Ecosystem::Opam, + Ecosystem::Psgallery, + Ecosystem::Pub, + Ecosystem::Pypi, + Ecosystem::Shards, + Ecosystem::Shell, + Ecosystem::Swiftpm, + Ecosystem::Zig, + ]; + + /// True when `filename` is one of this ecosystem's markers. + pub fn matches_marker(&self, filename: &str) -> bool { + self.marker_files().iter().any(|pattern| { + match pattern.strip_prefix("*.") { + // Extension pattern: `*.gemspec` matches `acme.gemspec` but not + // a file literally named `.gemspec`. + Some(ext) => filename + .rsplit_once('.') + .is_some_and(|(stem, e)| !stem.is_empty() && e == ext), + None => filename == *pattern, + } + }) + } +} + +impl std::fmt::Display for Ecosystem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Identify every ecosystem present in a project root, given its top-level +/// filenames. Returns a set because polyglot repos are normal — a Rust service +/// with a TypeScript frontend is both `cargo` and `npm`, and a dependency for +/// either belongs there. +/// +/// Takes filenames rather than a path so it stays pure and trivially testable; +/// callers in `zed-cli` supply a directory listing. +pub fn detect_ecosystems<'a, I>(filenames: I) -> BTreeSet +where + I: IntoIterator, +{ + let names: Vec<&str> = filenames.into_iter().collect(); + Ecosystem::ALL + .iter() + .filter(|eco| names.iter().any(|name| eco.matches_marker(name))) + .copied() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_language_round_trips_through_its_own_token() { + // Guards the three parallel match arms (as_str / from_token / + // ecosystem): adding a variant to one and forgetting another is the + // easy mistake, and it would silently mis-tag a published package. + for lang in [ + Language::Universal, + Language::C, + Language::Clojure, + Language::Cpp, + Language::Crystal, + Language::Csharp, + Language::Dart, + Language::Elixir, + Language::Erlang, + Language::Flutter, + Language::Fsharp, + Language::Gleam, + Language::Golang, + Language::Haskell, + Language::Java, + Language::Julia, + Language::Kotlin, + Language::Lua, + Language::Matlab, + Language::Nim, + Language::Nodejs, + Language::Ocaml, + Language::Php, + Language::Powershell, + Language::Python, + Language::R, + Language::Ruby, + Language::Rust, + Language::RustWasm, + Language::Scala, + Language::Shell, + Language::Swift, + Language::Zig, + ] { + assert_eq!( + Language::from_token(lang.as_str()), + Some(lang), + "`{lang}` does not parse back from its own as_str()" + ); + } + } + + #[test] + fn language_serde_uses_the_canonical_token() { + // The manifest is the wire format; a rename here silently invalidates + // every published manifest. + let json = serde_json::to_string(&Language::RustWasm).unwrap(); + assert_eq!(json, "\"rust-wasm\""); + assert_eq!( + serde_json::from_str::("\"nodejs\"").unwrap(), + Language::Nodejs + ); + assert_eq!( + serde_json::to_string(&Language::Golang).unwrap(), + "\"golang\"" + ); + } + + #[test] + fn colloquial_and_short_tokens_resolve_to_one_package() { + // The whole point of the alias table: a user who types `-go` or `-ts` + // must land on the package that actually exists. + for token in ["go", "golang", "GoLang"] { + assert_eq!(Language::from_token(token), Some(Language::Golang)); + } + for token in ["node", "nodejs", "js", "javascript", "ts", "typescript"] { + assert_eq!(Language::from_token(token), Some(Language::Nodejs)); + } + assert_eq!(Language::from_token("rust_wasm"), Some(Language::RustWasm)); + assert_eq!(Language::from_token(" Python "), Some(Language::Python)); + assert_eq!(Language::from_token("cobol"), None); + } + + #[test] + fn unknown_tokens_degrade_to_universal_rather_than_failing() { + // A manifest written by a newer zed must not wedge an older one. + assert_eq!(Language::from_str_lenient("brainfuck"), Language::Universal); + assert_eq!(Ecosystem::from_str_lenient("bazel"), Ecosystem::Universal); + } + + #[test] + fn jvm_languages_share_one_ecosystem_but_keep_distinct_names() { + // This is the reason for two axes. Same ecosystem => a Kotlin client + // installs fine in a Gradle project. Distinct languages => `-java` and + // `-kotlin` are separately installable packages. + for lang in [ + Language::Java, + Language::Kotlin, + Language::Scala, + Language::Clojure, + ] { + assert_eq!(lang.ecosystem(), Ecosystem::Jvm); + } + assert_ne!(Language::Java.as_str(), Language::Kotlin.as_str()); + } + + #[test] + fn rust_wasm_is_consumed_by_npm_not_cargo() { + assert_eq!(Language::Rust.ecosystem(), Ecosystem::Cargo); + assert_eq!(Language::RustWasm.ecosystem(), Ecosystem::Npm); + } + + #[test] + fn universal_maps_to_universal_so_untagged_packages_are_never_gated() { + assert_eq!(Language::Universal.ecosystem(), Ecosystem::Universal); + assert!(Language::default().is_default()); + assert!(Ecosystem::default().is_default()); + assert!(Ecosystem::Universal.marker_files().is_empty()); + } + + #[test] + fn detects_a_single_ecosystem_from_its_marker() { + assert_eq!( + detect_ecosystems(["package.json", "README.md"]), + BTreeSet::from([Ecosystem::Npm]) + ); + assert_eq!( + detect_ecosystems(["build.gradle.kts"]), + BTreeSet::from([Ecosystem::Jvm]) + ); + assert_eq!( + detect_ecosystems(["go.mod"]), + BTreeSet::from([Ecosystem::Gomod]) + ); + } + + #[test] + fn detects_every_ecosystem_in_a_polyglot_project() { + // A Rust service with a TS frontend is both; a dependency for either + // one belongs here, so the guard must not pick a single winner. + let found = detect_ecosystems(["Cargo.toml", "package.json", "pyproject.toml"]); + assert_eq!( + found, + BTreeSet::from([Ecosystem::Cargo, Ecosystem::Npm, Ecosystem::Pypi]) + ); + } + + #[test] + fn extension_markers_match_by_suffix_only() { + assert_eq!( + detect_ecosystems(["acme-client.gemspec"]), + BTreeSet::from([Ecosystem::Gem]) + ); + assert_eq!( + detect_ecosystems(["Acme.Client.csproj"]), + BTreeSet::from([Ecosystem::Nuget]) + ); + // A bare dotfile named after the extension is not a project marker. + assert!(detect_ecosystems([".gemspec"]).is_empty()); + assert!(detect_ecosystems(["gemspec"]).is_empty()); + } + + #[test] + fn detects_nothing_in_a_directory_with_no_markers() { + // Must stay empty rather than guessing: the guard treats "no detected + // ecosystem" as "cannot verify", not as "wrong ecosystem". + assert!(detect_ecosystems(["README.md", "LICENSE"]).is_empty()); + assert!(detect_ecosystems(Vec::<&str>::new()).is_empty()); + } + + #[test] + fn all_covers_every_non_default_ecosystem() { + // A new ecosystem missing from ALL would never be detected, so the + // guard would silently pass everything in such a project. + assert!(!Ecosystem::ALL.contains(&Ecosystem::Universal)); + for lang in [ + Language::Java, + Language::Nodejs, + Language::Golang, + Language::Python, + Language::Rust, + Language::Dart, + Language::Ruby, + Language::Php, + Language::Swift, + Language::Csharp, + Language::Gleam, + Language::Zig, + ] { + assert!( + Ecosystem::ALL.contains(&lang.ecosystem()), + "{lang} maps to {} which is missing from ALL", + lang.ecosystem() + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index dbd1146..cdb283b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod artifact; pub mod excludes; +pub mod language; pub mod lockfile; pub mod manifest; pub mod paths; @@ -17,6 +18,7 @@ pub mod vcs; pub mod version; pub use artifact::ArtifactFormat; +pub use language::{Ecosystem, Language, detect_ecosystems}; pub use lockfile::{LockedPackage, Lockfile}; pub use manifest::{Manifest, ManifestError}; pub use vcs::Vcs; diff --git a/src/manifest.rs b/src/manifest.rs index 8391e94..6a3acd2 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::language::{Ecosystem, Language}; use crate::vcs::Vcs; use crate::version::{Requirement, VersionScheme}; @@ -115,6 +116,38 @@ pub struct PackageSection { pub repository: RepositorySection, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub keywords: Vec, + /// The language this package's code targets. A multi-language repository + /// publishes one package per language, all at one version, and each names + /// its own — `acme-clients-java`, `acme-clients-nodejs`. Defaults to + /// `universal` (language-agnostic), which is never subject to the install + /// ecosystem guard. + #[serde(default, skip_serializing_if = "Language::is_default")] + pub language: Language, + /// How consumers take this package in: `jvm`, `npm`, `gomod`, … Drives the + /// install-time guard that refuses to drop a Java client into a Node + /// project, and picks the toolchain wiring `zed install` writes. + /// + /// Omit it and it is derived from `language` (see + /// [`Language::ecosystem`]) — declare it only when the language does not + /// determine consumption. Read through [`PackageSection::ecosystem`] + /// rather than touching this field, so the fallback always applies. + #[serde(default, skip_serializing_if = "Ecosystem::is_default")] + pub ecosystem: Ecosystem, +} + +impl PackageSection { + /// The effective ecosystem: the explicit `ecosystem` when declared, else + /// the one implied by `language`. + /// + /// Always use this instead of reading the field directly — a manifest that + /// says only `language = "java"` must still guard as `jvm`. + pub fn ecosystem(&self) -> Ecosystem { + if self.ecosystem.is_default() { + self.language.ecosystem() + } else { + self.ecosystem + } + } } /// Where the package's source of truth lives. Any Git or Mercurial host @@ -226,8 +259,39 @@ pub struct TargetSection { /// Optional routing to this target's native ecosystem registry. This is /// declarative metadata only: the native manifest remains authoritative, /// and arbitrary commands are intentionally not representable here. + /// + /// Note the difference from [`TargetSection::ecosystem`]: `native` is + /// **outbound** (where this slice is mirrored to — npm, crates.io), while + /// `ecosystem` is **inbound** (what toolchain a consumer must have to + /// install it from zed). They describe the same ecosystem from two sides, + /// so when both are set they must agree — see + /// [`NativeRegistry::ecosystem`] and the check in [`Manifest::validate`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub native: Option, + /// Override the ecosystem this target publishes into. Omit it (the normal + /// case) and it is derived from the target key via [`Language::ecosystem`]. + /// Declare it when the key does not determine consumption — a `rust-wasm` + /// target is consumed by a JS bundler, not by Cargo. + #[serde(default, skip_serializing_if = "Ecosystem::is_default")] + pub ecosystem: Ecosystem, +} + +impl TargetSection { + /// The language this target ships, from its explicit key. `universal` when + /// the key is not a language zed knows — such a target still publishes and + /// installs, it just is not ecosystem-gated. + pub fn language_for(&self, target_key: &str) -> Language { + Language::from_token(target_key).unwrap_or_default() + } + + /// The ecosystem a consumer must have to install this target: the explicit + /// `ecosystem`, else the one implied by the target key. + pub fn ecosystem_for(&self, target_key: &str) -> Ecosystem { + if !self.ecosystem.is_default() { + return self.ecosystem; + } + self.language_for(target_key).ecosystem() + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -282,6 +346,22 @@ impl NativeRegistry { _ => package.to_string(), } } + + /// The zed [`Ecosystem`] this native registry corresponds to. + /// + /// The two enums describe the same thing from opposite directions — + /// `NativeRegistry` is where a slice is *mirrored to*, `Ecosystem` is what a + /// consumer needs to *install* it — so a target declaring both must not + /// disagree. Mapping them here keeps that check in one place instead of + /// letting each caller re-derive it. + pub fn ecosystem(self) -> Ecosystem { + match self { + Self::Npm => Ecosystem::Npm, + Self::CratesIo => Ecosystem::Cargo, + Self::PubDev => Ecosystem::Pub, + Self::PyPi => Ecosystem::Pypi, + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -438,6 +518,16 @@ pub enum ManifestError { Toml(String), } +/// Ecosystem adapter names accepted by `[install].adapter` and +/// `[targets.*].adapter`. Kept here rather than in the CLI so a manifest is +/// validated the same way by the CLI, the registry, and the web UI. +/// +/// Each name is a toolchain zed can wire dependencies into. `none` opts out, +/// installing to `zed_modules/` only. This is deliberately smaller than +/// [`crate::language::Ecosystem`]: an ecosystem says what a package *is*, an +/// adapter says what zed can *wire*, and the second list grows more slowly. +pub const ADAPTERS: &[&str] = &["node", "java", "go", "python", "rust", "dart", "none"]; + /// True for the lowercase slugs zed-pkg accepts as org and package names. pub fn is_slug(s: &str) -> bool { !s.is_empty() @@ -602,12 +692,13 @@ impl Manifest { )); } if let Some(adapter) = target.adapter.as_deref() - && !matches!(adapter, "node" | "java" | "none") + && !ADAPTERS.contains(&adapter) { return Err(ManifestError::InvalidTarget( name.clone(), format!( - "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" + "adapter `{adapter}` is unsupported; expected one of {}", + ADAPTERS.join(", ") ), )); } @@ -635,6 +726,24 @@ impl Manifest { ), )); } + // The outbound mirror and the inbound install gate describe the + // same ecosystem. If they disagree, one of them is wrong and the + // slice would either be mirrored to the wrong registry or + // refused for the wrong consumers — both silent until someone + // hits it, so fail here instead. + let inbound = target.ecosystem_for(name); + let outbound = native.registry.ecosystem(); + if !inbound.is_default() && inbound != outbound { + return Err(ManifestError::InvalidNativeRoute( + name.clone(), + format!( + "routes to {} (ecosystem `{outbound}`) but installs as `{inbound}`; \ + set `ecosystem` on the target if the mirror is right" + , + native.registry.as_str() + ), + )); + } } } // A blank request means "no target", the same way a blank @@ -790,9 +899,37 @@ impl Manifest { // The consumer-facing wiring for this ecosystem. derived.install.adapter = section.adapter.clone().or(self.install.adapter.clone()); derived.install.target = None; + // Stamp the slice's identity so the *published* package self-describes + // as single-language. This is what lets a consumer's install refuse to + // drop `-java` into a Node-only project: the artifact says `jvm`, and + // the guard has something to compare against. + if let Some(language) = Language::from_token(target) { + derived.package.language = language; + derived.package.ecosystem = section.ecosystem_for(target); + } Some(derived) } + /// The target key matching `requested`, honoring language synonyms. + /// + /// Target keys are chosen by the package author (`nodejs`, `node`, `ts`) + /// while a consumer's request comes from a flag, their manifest, or + /// inference — so the two spellings routinely differ for the same language. + /// Exact match wins; otherwise `requested` and each key are normalized + /// through [`Language::from_token`] and compared, which is what makes a + /// project detected as `node` resolve a `[targets.nodejs]` package (and + /// `go` reach `golang`). + pub fn resolve_target_key(&self, requested: &str) -> Option<&str> { + if let Some((key, _)) = self.targets.get_key_value(requested) { + return Some(key.as_str()); + } + let wanted = Language::from_token(requested).filter(|l| !l.is_default())?; + self.targets + .keys() + .find(|key| Language::from_token(key) == Some(wanted)) + .map(String::as_str) + } + /// Resolve which subdirectory of *this* (dependency) package a consumer /// asking for `requested` should get. /// @@ -811,7 +948,13 @@ impl Manifest { let Some(requested) = requested else { return Ok(None); }; - match self.targets.get(requested) { + // Synonym-aware: a project inferred as `node` must resolve a package + // that spells its target `nodejs`, or every such consumer would hit the + // error below despite the package shipping exactly what they need. + match self + .resolve_target_key(requested) + .and_then(|key| self.targets.get(key)) + { Some(target) => Ok(Some(target.dir.as_str())), None => { let mut available: Vec<&str> = self.targets.keys().map(String::as_str).collect(); diff --git a/tests/native_release.rs b/tests/native_release.rs index 065b336..1477e8a 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -191,3 +191,92 @@ package = "friendly...bard" let message = error.to_string(); assert!(message.contains("already routed"), "{message}"); } + +// --- the two ecosystem axes must agree ------------------------------------ + +#[test] +fn a_native_route_that_contradicts_the_install_ecosystem_is_rejected() { + // `native` is outbound (mirror to npm), `ecosystem` is inbound (what a + // consumer needs to install). A python slice routed to npm means one of the + // two is wrong, and either way it stays silent until someone hits it. + let err = Manifest::parse(&manifest( + r#" +[targets.python] +dir = "clients/python" + +[targets.python.native] +registry = "npm" +package = "acme-client" +"#, + )) + .expect_err("a python target mirrored to npm must not validate"); + let msg = err.to_string(); + assert!(msg.contains("npm"), "{msg}"); + assert!(msg.contains("pypi"), "{msg}"); +} + +#[test] +fn a_native_route_matching_the_target_language_validates() { + for (target, dir, registry, package) in [ + ("nodejs", "clients/ts", "npm", "@acme/client"), + ("rust", "clients/rust", "crates-io", "acme-client"), + ("python", "clients/python", "pypi", "acme-client"), + ("dart", "clients/dart", "pub.dev", "acme_client"), + ] { + let toml = manifest(&format!( + r#" +[targets.{target}] +dir = "{dir}" + +[targets.{target}.native] +registry = "{registry}" +package = "{package}" +"# + )); + let parsed = Manifest::parse(&toml) + .unwrap_or_else(|e| panic!("{target} -> {registry} must validate: {e}")); + let route = &parsed.native_release_routes()[0]; + assert_eq!(route.target, target); + assert_eq!(route.registry.ecosystem(), parsed.targets[target].ecosystem_for(target)); + } +} + +#[test] +fn an_explicit_ecosystem_override_reconciles_a_deliberate_mismatch() { + // rust-wasm is Rust source consumed by a JS bundler: the mirror really is + // npm, so declaring the inbound ecosystem makes the pair consistent rather + // than requiring the check to be weakened. + let toml = manifest( + r#" +[targets.rust-wasm] +dir = "clients/rust-wasm" +ecosystem = "npm" + +[targets.rust-wasm.native] +registry = "npm" +package = "@acme/client-wasm" +"#, + ); + let parsed = Manifest::parse(&toml).expect("an explicit npm ecosystem must reconcile"); + assert_eq!( + parsed.targets["rust-wasm"].ecosystem_for("rust-wasm"), + NativeRegistry::Npm.ecosystem() + ); +} + +#[test] +fn a_target_key_that_is_not_a_language_never_contradicts_its_route() { + // An arbitrary target slug carries no ecosystem claim, so there is nothing + // for the native route to disagree with. + let toml = manifest( + r#" +[targets.wasm3] +dir = "clients/wasm3" + +[targets.wasm3.native] +registry = "npm" +package = "acme-wasm3" +"#, + ); + assert!(Manifest::parse(&toml).is_ok()); +} diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 4372b28..a2857b6 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -1,6 +1,7 @@ use zed_interfaces::ArtifactFormat; use zed_interfaces::excludes::{ALWAYS_INCLUDE, DEFAULT_EXCLUDES, effective_excludes}; use zed_interfaces::lockfile::{LockedPackage, Lockfile}; +use zed_interfaces::language::{Ecosystem, Language}; use zed_interfaces::manifest::{Manifest, ManifestError}; use zed_interfaces::paths::store_entry_rel; use zed_interfaces::vcs::Vcs; @@ -424,3 +425,164 @@ fn derived_target_names_must_still_be_valid_package_names() { ); } } + +// --- language / ecosystem tagging ----------------------------------------- + +/// A repo naming its targets the way the published packages read — the +/// colloquial `nodejs` / `golang` a human recalls — rather than the short +/// tokens project inference produces. +const COLLOQUIAL: &str = r#" +[package] +org = "fiducia" +name = "fiducia-clients" +version = "1.1.2" + +[package.repository] +vcs = "git" +url = "https://github.com/fiducia-cloud/fiducia-clients" + +[targets.nodejs] +dir = "clients/ts" + +[targets.golang] +dir = "clients/go" + +[targets.java] +dir = "clients/java" + +[targets.kotlin] +dir = "clients/kotlin" + +[targets.rust-wasm] +dir = "clients/rust-wasm" +ecosystem = "npm" +"#; + +#[test] +fn a_project_inferred_as_node_resolves_a_nodejs_target() { + // The decisive case for synonym resolution. Project inference yields + // `node`/`go` from package.json/go.mod, but these packages publish as + // `-nodejs`/`-golang` because that is what the names should read. Without + // synonym matching every such consumer would hit "publishes no such + // target" while the package ships exactly what they need. + let m = Manifest::parse(COLLOQUIAL).unwrap(); + assert_eq!(m.target_subdir(Some("node")).unwrap(), Some("clients/ts")); + assert_eq!(m.target_subdir(Some("go")).unwrap(), Some("clients/go")); + // …and the spelling the author used keeps working too. + assert_eq!(m.target_subdir(Some("nodejs")).unwrap(), Some("clients/ts")); + assert_eq!(m.target_subdir(Some("golang")).unwrap(), Some("clients/go")); + // As do the ecosystem's own near-synonyms. + assert_eq!( + m.target_subdir(Some("typescript")).unwrap(), + Some("clients/ts") + ); + assert_eq!(m.target_subdir(Some("ts")).unwrap(), Some("clients/ts")); +} + +#[test] +fn synonym_resolution_does_not_collapse_distinct_languages() { + // Java and Kotlin share an ecosystem but are separate packages; asking for + // one must never hand back the other. + let m = Manifest::parse(COLLOQUIAL).unwrap(); + assert_eq!(m.target_subdir(Some("java")).unwrap(), Some("clients/java")); + assert_eq!( + m.target_subdir(Some("kotlin")).unwrap(), + Some("clients/kotlin") + ); + // A JVM language the repo does not publish is still an error, not a + // silent substitution of a sibling JVM target. + assert!(m.target_subdir(Some("scala")).is_err()); +} + +#[test] +fn an_exact_target_key_wins_over_a_synonym() { + // With both `node` and `nodejs` declared, `node` must mean the `node` one. + let both = COLLOQUIAL.replace( + "[targets.golang]\ndir = \"clients/go\"", + "[targets.node]\ndir = \"clients/js-legacy\"", + ); + let m = Manifest::parse(&both).unwrap(); + assert_eq!( + m.target_subdir(Some("node")).unwrap(), + Some("clients/js-legacy") + ); + assert_eq!(m.target_subdir(Some("nodejs")).unwrap(), Some("clients/ts")); +} + +#[test] +fn an_unknown_target_is_still_an_error_after_synonym_expansion() { + // Synonyms must widen what resolves, never turn a real mistake into a + // silent whole-tree install. + let m = Manifest::parse(COLLOQUIAL).unwrap(); + let err = m.target_subdir(Some("cobol")).expect_err("unknown language"); + assert!(err.to_string().contains("cobol"), "{err}"); +} + +#[test] +fn each_published_target_declares_its_own_language_and_ecosystem() { + // This is what the consumer-side guard reads: the artifact for `-java` must + // say `jvm` so an npm-only project can be told it is the wrong one. + let m = Manifest::parse(COLLOQUIAL).unwrap(); + + let java = m.manifest_for_target("java").unwrap(); + assert_eq!(java.package.name, "fiducia-clients-java"); + assert_eq!(java.package.language, Language::Java); + assert_eq!(java.package.ecosystem(), Ecosystem::Jvm); + + let node = m.manifest_for_target("nodejs").unwrap(); + assert_eq!(node.package.name, "fiducia-clients-nodejs"); + assert_eq!(node.package.language, Language::Nodejs); + assert_eq!(node.package.ecosystem(), Ecosystem::Npm); + + // Kotlin shares Java's ecosystem while keeping its own name — the reason + // language and ecosystem are separate axes. + let kotlin = m.manifest_for_target("kotlin").unwrap(); + assert_eq!(kotlin.package.name, "fiducia-clients-kotlin"); + assert_eq!(kotlin.package.language, Language::Kotlin); + assert_eq!(kotlin.package.ecosystem(), Ecosystem::Jvm); + + // Each slice round-trips as a standalone single-language manifest. + for target in ["java", "nodejs", "kotlin"] { + let derived = m.manifest_for_target(target).unwrap(); + let reparsed = Manifest::parse(&derived.to_toml_string().unwrap()).unwrap(); + assert_eq!(reparsed, derived, "{target} manifest must round-trip"); + assert!(!reparsed.is_polyglot()); + } +} + +#[test] +fn an_explicit_target_ecosystem_overrides_the_language_default() { + // rust-wasm is Rust source consumed by a JS bundler, so the package must be + // gated as npm even though the language is a Rust dialect. + let m = Manifest::parse(COLLOQUIAL).unwrap(); + let wasm = m.manifest_for_target("rust-wasm").unwrap(); + assert_eq!(wasm.package.name, "fiducia-clients-rust-wasm"); + assert_eq!(wasm.package.language, Language::RustWasm); + assert_eq!(wasm.package.ecosystem(), Ecosystem::Npm); +} + +#[test] +fn a_target_key_that_is_not_a_known_language_stays_ungated() { + // Arbitrary slugs remain legal target names (pre-existing behavior). Such a + // package publishes and installs, it just carries no ecosystem claim, so + // the guard cannot and must not reject it anywhere. + let custom = POLYGLOT.replace("[targets.go]\ndir = \"go\"", "[targets.wasm3]\ndir = \"w3\""); + let m = Manifest::parse(&custom).unwrap(); + let derived = m.manifest_for_target("wasm3").unwrap(); + assert_eq!(derived.package.name, "polyglot-lib-wasm3"); + assert!(derived.package.language.is_default()); + assert_eq!(derived.package.ecosystem(), Ecosystem::Universal); +} + +#[test] +fn untagged_manifests_keep_their_meaning() { + // Every manifest written before language tagging existed must parse and + // stay ungated — this is the backwards-compatibility contract. + let m = Manifest::parse(SAMPLE).unwrap(); + assert!(m.package.language.is_default()); + assert_eq!(m.package.ecosystem(), Ecosystem::Universal); + // …and must not gain the fields when serialized back out. + let toml = m.to_toml_string().unwrap(); + assert!(!toml.contains("language"), "{toml}"); + assert!(!toml.contains("ecosystem"), "{toml}"); +} From 3fea838928e4e644bd561df1cf36234a21a8d33b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 29 Jul 2026 23:24:45 -0500 Subject: [PATCH 39/71] wip: draft multi-registry schema --- src/lib.rs | 2 + src/manifest.rs | 147 +++++++++++++++++++++++++++++++++++++++++++++ src/publish.rs | 110 +++++++++++++++++++++++++++++++++ tests/roundtrip.rs | 130 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 389 insertions(+) create mode 100644 src/publish.rs diff --git a/src/lib.rs b/src/lib.rs index dbd1146..bc63017 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod excludes; pub mod lockfile; pub mod manifest; pub mod paths; +pub mod publish; pub mod registry; pub mod sync; pub mod vcs; @@ -19,5 +20,6 @@ pub mod version; pub use artifact::ArtifactFormat; pub use lockfile::{LockedPackage, Lockfile}; pub use manifest::{Manifest, ManifestError}; +pub use publish::PublishRegistry; pub use vcs::Vcs; pub use version::{Requirement, VersionScheme}; diff --git a/src/manifest.rs b/src/manifest.rs index 9b94037..cc27d31 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::publish::PublishRegistry; use crate::vcs::Vcs; use crate::version::{Requirement, VersionScheme}; @@ -140,6 +141,20 @@ pub struct PublishSection { /// VCS tag template that must exist and point at the published commit. /// `{version}` is substituted with `package.version`. pub tag_format: String, + /// Native package-manager format (`npm`, `cargo`, `pypi`, `maven`, ...). + /// Zed stores its own deterministic artifact regardless of this value; + /// forge registries use it to reject unsupported routes before upload. + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + /// Registries that should receive this package. Omitted preserves the + /// historical behavior: publish only to the configured Zed registry. + #[serde(skip_serializing_if = "Option::is_none")] + pub registries: Option>, + /// Optional endpoint overrides, keyed by registry family. Secrets never + /// belong here; authentication comes from the environment/credential + /// store used by the relevant publisher. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub registry_urls: BTreeMap, } impl Default for PublishSection { @@ -149,6 +164,9 @@ impl Default for PublishSection { include_readme: false, smoke_test: None, tag_format: "v{version}".to_string(), + format: None, + registries: None, + registry_urls: BTreeMap::new(), } } } @@ -223,6 +241,18 @@ pub struct TargetSection { /// gets the right wiring without configuring it. #[serde(default, skip_serializing_if = "Option::is_none")] pub adapter: Option, + /// This target's native package-manager format. It overrides + /// `[publish].format` when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, + /// This target's registry fan-out. It overrides `[publish].registries` + /// when present; omission inherits the package default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registries: Option>, + /// Target-specific endpoint overrides layered over + /// `[publish.registry_urls]`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub registry_urls: BTreeMap, } /// A post-extract build step. Because compiled output is OS/arch-specific, @@ -290,6 +320,8 @@ pub enum ManifestError { InvalidInstallDir(String, String), #[error("invalid target `{0}`: {1}")] InvalidTarget(String, String), + #[error("invalid publish route `{0}`: {1}")] + InvalidPublishRoute(String, String), #[error("manifest toml error: {0}")] Toml(String), } @@ -352,6 +384,76 @@ fn is_allowed_repo_url(url: &str) -> bool { || (url.contains('@') && url.contains(':') && !url.contains("://")) } +fn is_allowed_registry_url(url: &str) -> bool { + (url.starts_with("https://") || url.starts_with("http://") || url.starts_with("file://")) + && !url.chars().any(char::is_whitespace) +} + +fn validate_publish_route( + label: &str, + format: Option<&str>, + registries: Option<&[PublishRegistry]>, + registry_urls: &BTreeMap, +) -> Result<(), ManifestError> { + let selected = registries.unwrap_or(&[PublishRegistry::Zed]); + if selected.is_empty() { + return Err(ManifestError::InvalidPublishRoute( + label.to_string(), + "registries cannot be empty; omit the field for the default Zed registry".to_string(), + )); + } + let mut unique = std::collections::BTreeSet::new(); + for registry in selected { + if !unique.insert(*registry) { + return Err(ManifestError::InvalidPublishRoute( + label.to_string(), + format!("registry `{registry}` is listed more than once"), + )); + } + } + + let format = format.map(str::trim).filter(|value| !value.is_empty()); + if let Some(format) = format + && !is_target_name(format) + { + return Err(ManifestError::InvalidPublishRoute( + label.to_string(), + format!("format `{format}` must use [a-z0-9][a-z0-9-]*"), + )); + } + for registry in selected { + if *registry != PublishRegistry::Zed && format.is_none() { + return Err(ManifestError::InvalidPublishRoute( + label.to_string(), + format!("registry `{registry}` requires an explicit package format"), + )); + } + if let Some(format) = format + && !registry.supports_format(format) + { + return Err(ManifestError::InvalidPublishRoute( + label.to_string(), + format!("registry `{registry}` does not support format `{format}`"), + )); + } + } + for (registry, url) in registry_urls { + if !selected.contains(registry) { + return Err(ManifestError::InvalidPublishRoute( + label.to_string(), + format!("URL override for `{registry}` has no matching entry in registries"), + )); + } + if !is_allowed_registry_url(url) { + return Err(ManifestError::InvalidPublishRoute( + label.to_string(), + format!("registry URL `{url}` must be an http(s) or file URL without whitespace"), + )); + } + } + Ok(()) +} + impl Manifest { /// Parse and validate a `.zpkg.toml` document. pub fn parse(input: &str) -> Result { @@ -467,6 +569,25 @@ impl Manifest { )); } } + if self.targets.is_empty() { + validate_publish_route( + "package", + self.publish.format.as_deref(), + self.publish.registries.as_deref(), + &self.publish.registry_urls, + )?; + } else { + for (name, target) in &self.targets { + let format = target.format.as_deref().or(self.publish.format.as_deref()); + let registries = target + .registries + .as_deref() + .or(self.publish.registries.as_deref()); + let mut urls = self.publish.registry_urls.clone(); + urls.extend(target.registry_urls.clone()); + validate_publish_route(name, format, registries, &urls)?; + } + } // A blank request means "no target", the same way a blank // `[install].dir` falls back to the default rather than erroring. if let Some(requested) = self.requested_target() @@ -604,9 +725,35 @@ impl Manifest { // The consumer-facing wiring for this ecosystem. derived.install.adapter = section.adapter.clone().or(self.install.adapter.clone()); derived.install.target = None; + derived.publish.format = section.format.clone().or(self.publish.format.clone()); + if section.registries.is_some() { + derived.publish.registries = section.registries.clone(); + } + derived + .publish + .registry_urls + .extend(section.registry_urls.clone()); Some(derived) } + /// The effective registry fan-out for this package. Manifests written + /// before multi-registry support continue to resolve to Zed only. + pub fn publish_registries(&self) -> Vec { + self.publish + .registries + .clone() + .unwrap_or_else(|| vec![PublishRegistry::Zed]) + } + + /// An endpoint override for a registry family, when the manifest names + /// one. The CLI's `--registry` remains the default for `zed`. + pub fn publish_registry_url(&self, registry: PublishRegistry) -> Option<&str> { + self.publish + .registry_urls + .get(®istry) + .map(String::as_str) + } + /// Resolve which subdirectory of *this* (dependency) package a consumer /// asking for `requested` should get. /// diff --git a/src/publish.rs b/src/publish.rs new file mode 100644 index 0000000..58615eb --- /dev/null +++ b/src/publish.rs @@ -0,0 +1,110 @@ +use std::fmt; +use std::str::FromStr; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A registry family that can receive one package produced by `zed publish`. +/// +/// `zed` is zpkg.tech (or the CLI's configured Zed-compatible registry), +/// `native` is the ecosystem's canonical registry (npmjs, crates.io, PyPI, +/// and so on), and the remaining variants are package registries operated by +/// source forges. The source repository itself remains configured separately +/// under `[package.repository]`. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum PublishRegistry { + Zed, + Native, + GithubPackages, + GitlabPackages, + BitbucketPackages, +} + +impl PublishRegistry { + pub const ALL: [Self; 5] = [ + Self::Zed, + Self::Native, + Self::GithubPackages, + Self::GitlabPackages, + Self::BitbucketPackages, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Zed => "zed", + Self::Native => "native", + Self::GithubPackages => "github-packages", + Self::GitlabPackages => "gitlab-packages", + Self::BitbucketPackages => "bitbucket-packages", + } + } + + /// Whether this registry family currently accepts the package-manager + /// format. Zed accepts every format because it stores the deterministic + /// zpkg artifact; forge registries intentionally fail closed to the + /// formats their public APIs document. + pub fn supports_format(self, format: &str) -> bool { + match self { + Self::Zed => true, + Self::Native => !matches!(format, "zpkg" | "generic"), + Self::GithubPackages => { + matches!(format, "npm" | "rubygems" | "maven" | "nuget" | "container") + } + Self::GitlabPackages => matches!( + format, + "composer" + | "conan" + | "debian" + | "generic" + | "go" + | "helm" + | "maven" + | "npm" + | "nuget" + | "pypi" + | "rubygems" + | "terraform" + ), + Self::BitbucketPackages => matches!(format, "npm" | "maven" | "container"), + } + } +} + +impl fmt::Display for PublishRegistry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsePublishRegistryError(pub String); + +impl fmt::Display for ParsePublishRegistryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "unknown publish registry `{}` (expected zed, native, github-packages, gitlab-packages, or bitbucket-packages)", + self.0 + ) + } +} + +impl std::error::Error for ParsePublishRegistryError {} + +impl FromStr for PublishRegistry { + type Err = ParsePublishRegistryError; + + fn from_str(value: &str) -> Result { + match value { + "zed" => Ok(Self::Zed), + "native" => Ok(Self::Native), + "github-packages" | "github" => Ok(Self::GithubPackages), + "gitlab-packages" | "gitlab" => Ok(Self::GitlabPackages), + "bitbucket-packages" | "bitbucket" => Ok(Self::BitbucketPackages), + other => Err(ParsePublishRegistryError(other.to_string())), + } + } +} diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 4372b28..a325f73 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -3,6 +3,7 @@ use zed_interfaces::excludes::{ALWAYS_INCLUDE, DEFAULT_EXCLUDES, effective_exclu use zed_interfaces::lockfile::{LockedPackage, Lockfile}; use zed_interfaces::manifest::{Manifest, ManifestError}; use zed_interfaces::paths::store_entry_rel; +use zed_interfaces::publish::PublishRegistry; use zed_interfaces::vcs::Vcs; const SAMPLE: &str = r#" @@ -424,3 +425,132 @@ fn derived_target_names_must_still_be_valid_package_names() { ); } } + +#[test] +fn polyglot_targets_declare_registry_fanout_by_native_format() { + let source = CLIENTS + .replace( + "[targets.nodejs]\ndir = \"clients/ts\"\nadapter = \"node\"", + r#"[targets.nodejs] +dir = "clients/ts" +adapter = "node" +format = "npm" +registries = ["zed", "native", "github-packages", "gitlab-packages", "bitbucket-packages"] + +[targets.nodejs.registry_urls] +gitlab-packages = "https://gitlab.example.com""#, + ) + .replace( + "[targets.java]\ndir = \"clients/java\"\nadapter = \"java\"", + r#"[targets.java] +dir = "clients/java" +adapter = "java" +format = "maven" +registries = ["zed", "native", "github-packages", "gitlab-packages", "bitbucket-packages"]"#, + ) + .replace( + "[targets.golang]\ndir = \"clients/go\"", + r#"[targets.golang] +dir = "clients/go" +format = "go" +registries = ["zed", "native", "gitlab-packages"]"#, + ); + + let manifest = Manifest::parse(&source).expect("all declared host/format pairs are supported"); + let node = manifest.manifest_for_target("nodejs").unwrap(); + assert_eq!(node.publish.format.as_deref(), Some("npm")); + assert_eq!( + node.publish_registries(), + vec![ + PublishRegistry::Zed, + PublishRegistry::Native, + PublishRegistry::GithubPackages, + PublishRegistry::GitlabPackages, + PublishRegistry::BitbucketPackages, + ] + ); + assert_eq!( + node.publish_registry_url(PublishRegistry::GitlabPackages), + Some("https://gitlab.example.com") + ); + + let go = manifest.manifest_for_target("golang").unwrap(); + assert_eq!(go.publish.format.as_deref(), Some("go")); + assert_eq!( + go.publish_registries(), + vec![ + PublishRegistry::Zed, + PublishRegistry::Native, + PublishRegistry::GitlabPackages, + ] + ); + + assert_eq!( + Manifest::parse(&manifest.to_toml_string().unwrap()).unwrap(), + manifest + ); +} + +#[test] +fn old_manifests_default_to_zed_only() { + let manifest = Manifest::parse(SAMPLE).unwrap(); + assert_eq!(manifest.publish_registries(), vec![PublishRegistry::Zed]); + assert!(manifest.publish.format.is_none()); +} + +#[test] +fn unsupported_forge_routes_fail_before_publish() { + let github_cargo = CLIENTS.replace( + "[targets.nodejs]\ndir = \"clients/ts\"\nadapter = \"node\"", + r#"[targets.nodejs] +dir = "clients/ts" +adapter = "node" +format = "cargo" +registries = ["zed", "github-packages"]"#, + ); + assert!(matches!( + Manifest::parse(&github_cargo), + Err(ManifestError::InvalidPublishRoute(_, reason)) + if reason.contains("github-packages") && reason.contains("cargo") + )); + + let bitbucket_pypi = CLIENTS.replace( + "[targets.nodejs]\ndir = \"clients/ts\"\nadapter = \"node\"", + r#"[targets.nodejs] +dir = "clients/ts" +adapter = "node" +format = "pypi" +registries = ["bitbucket-packages"]"#, + ); + assert!(matches!( + Manifest::parse(&bitbucket_pypi), + Err(ManifestError::InvalidPublishRoute(_, reason)) + if reason.contains("bitbucket-packages") && reason.contains("pypi") + )); +} + +#[test] +fn external_registries_require_a_format_and_safe_endpoint() { + let missing_format = format!( + "{SAMPLE}\n[publish.registry_urls]\ngithub-packages = \"https://npm.pkg.github.com\"\n" + ) + .replace( + "[publish]\n", + "[publish]\nregistries = [\"zed\", \"github-packages\"]\n", + ); + assert!(matches!( + Manifest::parse(&missing_format), + Err(ManifestError::InvalidPublishRoute(_, reason)) + if reason.contains("requires an explicit package format") + )); + + let credential_url = SAMPLE.replace( + "[publish]\n", + "[publish]\nformat = \"npm\"\nregistries = [\"native\"]\nregistry_urls = { native = \"not a URL\" }\n", + ); + assert!(matches!( + Manifest::parse(&credential_url), + Err(ManifestError::InvalidPublishRoute(_, reason)) + if reason.contains("registry URL") + )); +} From e6030f2b3027bc35fb15b83e2ba8b8711b19fb87 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Wed, 29 Jul 2026 23:32:12 -0500 Subject: [PATCH 40/71] style: cargo fmt Co-Authored-By: Claude Opus 5 (1M context) --- src/language.rs | 32 ++++++++++++++++++++++++++++---- src/manifest.rs | 3 +-- tests/native_release.rs | 5 ++++- tests/roundtrip.rs | 11 ++++++++--- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/language.rs b/src/language.rs index d7b9fb9..11a6ee2 100644 --- a/src/language.rs +++ b/src/language.rs @@ -39,8 +39,20 @@ use serde::{Deserialize, Serialize}; /// a human has to recall. The shorter spellings, and near-synonyms like /// `typescript`, are accepted by [`Language::from_token`] and by umbrella /// variant aliases, so both spellings resolve. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[derive(Serialize, Deserialize, JsonSchema)] +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] #[serde(rename_all = "lowercase")] pub enum Language { /// Not language-specific: protocol schemas, docs, `.proto` files, an @@ -93,8 +105,20 @@ pub enum Language { /// How a consumer's build system takes a dependency in: the resolution /// mechanism, not the language. Drives the install guard and which toolchain /// wiring file `zed install` writes. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[derive(Serialize, Deserialize, JsonSchema)] +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] #[serde(rename_all = "lowercase")] pub enum Ecosystem { /// No ecosystem constraint. Installs into any project; never gated. diff --git a/src/manifest.rs b/src/manifest.rs index 6a3acd2..4495bc0 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -738,8 +738,7 @@ impl Manifest { name.clone(), format!( "routes to {} (ecosystem `{outbound}`) but installs as `{inbound}`; \ - set `ecosystem` on the target if the mirror is right" - , + set `ecosystem` on the target if the mirror is right", native.registry.as_str() ), )); diff --git a/tests/native_release.rs b/tests/native_release.rs index 1477e8a..82443fc 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -237,7 +237,10 @@ package = "{package}" .unwrap_or_else(|e| panic!("{target} -> {registry} must validate: {e}")); let route = &parsed.native_release_routes()[0]; assert_eq!(route.target, target); - assert_eq!(route.registry.ecosystem(), parsed.targets[target].ecosystem_for(target)); + assert_eq!( + route.registry.ecosystem(), + parsed.targets[target].ecosystem_for(target) + ); } } diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index a2857b6..893c4fd 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -1,7 +1,7 @@ use zed_interfaces::ArtifactFormat; use zed_interfaces::excludes::{ALWAYS_INCLUDE, DEFAULT_EXCLUDES, effective_excludes}; -use zed_interfaces::lockfile::{LockedPackage, Lockfile}; use zed_interfaces::language::{Ecosystem, Language}; +use zed_interfaces::lockfile::{LockedPackage, Lockfile}; use zed_interfaces::manifest::{Manifest, ManifestError}; use zed_interfaces::paths::store_entry_rel; use zed_interfaces::vcs::Vcs; @@ -514,7 +514,9 @@ fn an_unknown_target_is_still_an_error_after_synonym_expansion() { // Synonyms must widen what resolves, never turn a real mistake into a // silent whole-tree install. let m = Manifest::parse(COLLOQUIAL).unwrap(); - let err = m.target_subdir(Some("cobol")).expect_err("unknown language"); + let err = m + .target_subdir(Some("cobol")) + .expect_err("unknown language"); assert!(err.to_string().contains("cobol"), "{err}"); } @@ -566,7 +568,10 @@ fn a_target_key_that_is_not_a_known_language_stays_ungated() { // Arbitrary slugs remain legal target names (pre-existing behavior). Such a // package publishes and installs, it just carries no ecosystem claim, so // the guard cannot and must not reject it anywhere. - let custom = POLYGLOT.replace("[targets.go]\ndir = \"go\"", "[targets.wasm3]\ndir = \"w3\""); + let custom = POLYGLOT.replace( + "[targets.go]\ndir = \"go\"", + "[targets.wasm3]\ndir = \"w3\"", + ); let m = Manifest::parse(&custom).unwrap(); let derived = m.manifest_for_target("wasm3").unwrap(); assert_eq!(derived.package.name, "polyglot-lib-wasm3"); From 40681c44f09752e6d6387d4f12262c0dfdab5f25 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 29 Jul 2026 23:37:11 -0500 Subject: [PATCH 41/71] feat: model native and forge package routes --- README.md | 33 +++++ schemas/manifest.json | 33 ++++- schemas/publish-meta.json | 33 ++++- src/language.rs | 32 ++++- src/manifest.rs | 281 ++++++++++++++++++++++++++++++++++---- tests/native_release.rs | 161 +++++++++++++++++++++- tests/roundtrip.rs | 11 +- 7 files changed, 549 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 0b5f556..b463b1d 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ This crate is the contract everything else builds against: - **Filesystem layout** — `$HOME/.zed-pkg` store, `zed_modules/` symlink dir, archive structure (`paths`) - **VCS + artifact enums** — `git`/`hg`, `tar.gz`/`zip` (`vcs`, `artifact`) +- **Polyglot release routes** — language/ecosystem identity plus canonical + native registries and compatible GitHub/GitLab/Bitbucket package mirrors + (`language`, `manifest`) ## The model in one page @@ -40,6 +43,36 @@ stay self-contained across multi-stage builds. The lockfile pins `sha256`, `size`, `vcs_tag`, and `vcs_commit` per package: installs are reproducible and every artifact traces back to source. +For a polyglot repository, each `[targets.]` slice becomes its own +Zed artifact. An optional `[targets..native]` block declares the +same version for the ecosystem's canonical registry, and `forge` declares +additional package-registry copies: + +```toml +[targets.nodejs] +dir = "clients/typescript" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] +``` + +Forge compatibility is validated in the interface contract. A manifest cannot +claim Cargo support in GitHub Packages or PyPI support in Bitbucket Packages; +those combinations fail during manifest parsing, before any release job sees +credentials. + +A single-language package whose native manifest is at the repository root uses +the same shape under `[publish.native]`: + +```toml +[publish.native] +registry = "npm" +package = "r2g" +forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] +``` + ## Registry API surface | Method | Path | Body / response | diff --git a/schemas/manifest.json b/schemas/manifest.json index f2b9ff0..7551469 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -155,6 +155,14 @@ } ] }, + "ForgeRegistry": { + "type": "string", + "enum": [ + "github-packages", + "gitlab-packages", + "bitbucket-packages" + ] + }, "InstallSection": { "description": "Install-layout controls: where zed's dependency tree lands and which\necosystem adapter to emit so those deps are visible to the native toolchain.", "type": "object", @@ -247,12 +255,24 @@ "npm", "crates-io", "pub.dev", - "pypi" + "pypi", + "maven-central", + "rubygems", + "nuget", + "packagist", + "go-modules" ] }, "NativeReleaseSection": { "type": "object", "properties": { + "forge": { + "description": "Optional copies in package registries run by source forges. The native\nregistry remains the canonical ecosystem destination; these mirrors\nuse the same native package format and version.", + "type": "array", + "items": { + "$ref": "#/$defs/ForgeRegistry" + } + }, "package": { "type": "string" }, @@ -350,6 +370,17 @@ "type": "boolean", "default": false }, + "native": { + "description": "Optional native-registry route for a single-language package whose\npackage-manager manifest lives at the repository root. Polyglot\npackages declare this metadata on each `[targets.*.native]` section\ninstead.", + "anyOf": [ + { + "$ref": "#/$defs/NativeReleaseSection" + }, + { + "type": "null" + } + ] + }, "smoke_test": { "description": "Command run by `zed r2g` (alias `zed test-local`) inside a throwaway\nconsumer project that has this package installed the same way a real\nconsumer would.", "type": [ diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index beffa91..27ddb20 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -116,6 +116,14 @@ } ] }, + "ForgeRegistry": { + "type": "string", + "enum": [ + "github-packages", + "gitlab-packages", + "bitbucket-packages" + ] + }, "InstallSection": { "description": "Install-layout controls: where zed's dependency tree lands and which\necosystem adapter to emit so those deps are visible to the native toolchain.", "type": "object", @@ -293,12 +301,24 @@ "npm", "crates-io", "pub.dev", - "pypi" + "pypi", + "maven-central", + "rubygems", + "nuget", + "packagist", + "go-modules" ] }, "NativeReleaseSection": { "type": "object", "properties": { + "forge": { + "description": "Optional copies in package registries run by source forges. The native\nregistry remains the canonical ecosystem destination; these mirrors\nuse the same native package format and version.", + "type": "array", + "items": { + "$ref": "#/$defs/ForgeRegistry" + } + }, "package": { "type": "string" }, @@ -396,6 +416,17 @@ "type": "boolean", "default": false }, + "native": { + "description": "Optional native-registry route for a single-language package whose\npackage-manager manifest lives at the repository root. Polyglot\npackages declare this metadata on each `[targets.*.native]` section\ninstead.", + "anyOf": [ + { + "$ref": "#/$defs/NativeReleaseSection" + }, + { + "type": "null" + } + ] + }, "smoke_test": { "description": "Command run by `zed r2g` (alias `zed test-local`) inside a throwaway\nconsumer project that has this package installed the same way a real\nconsumer would.", "type": [ diff --git a/src/language.rs b/src/language.rs index d7b9fb9..11a6ee2 100644 --- a/src/language.rs +++ b/src/language.rs @@ -39,8 +39,20 @@ use serde::{Deserialize, Serialize}; /// a human has to recall. The shorter spellings, and near-synonyms like /// `typescript`, are accepted by [`Language::from_token`] and by umbrella /// variant aliases, so both spellings resolve. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[derive(Serialize, Deserialize, JsonSchema)] +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] #[serde(rename_all = "lowercase")] pub enum Language { /// Not language-specific: protocol schemas, docs, `.proto` files, an @@ -93,8 +105,20 @@ pub enum Language { /// How a consumer's build system takes a dependency in: the resolution /// mechanism, not the language. Drives the install guard and which toolchain /// wiring file `zed install` writes. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[derive(Serialize, Deserialize, JsonSchema)] +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] #[serde(rename_all = "lowercase")] pub enum Ecosystem { /// No ecosystem constraint. Installs into any project; never gated. diff --git a/src/manifest.rs b/src/manifest.rs index 6a3acd2..b31c894 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -173,6 +173,12 @@ pub struct PublishSection { /// VCS tag template that must exist and point at the published commit. /// `{version}` is substituted with `package.version`. pub tag_format: String, + /// Optional native-registry route for a single-language package whose + /// package-manager manifest lives at the repository root. Polyglot + /// packages declare this metadata on each `[targets.*.native]` section + /// instead. + #[serde(skip_serializing_if = "Option::is_none")] + pub native: Option, } impl Default for PublishSection { @@ -182,6 +188,7 @@ impl Default for PublishSection { include_readme: false, smoke_test: None, tag_format: "v{version}".to_string(), + native: None, } } } @@ -298,6 +305,11 @@ impl TargetSection { pub struct NativeReleaseSection { pub registry: NativeRegistry, pub package: String, + /// Optional copies in package registries run by source forges. The native + /// registry remains the canonical ecosystem destination; these mirrors + /// use the same native package format and version. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub forge: Vec, } #[derive( @@ -311,6 +323,67 @@ pub enum NativeRegistry { PubDev, #[serde(rename = "pypi")] PyPi, + MavenCentral, + #[serde(rename = "rubygems")] + RubyGems, + #[serde(rename = "nuget")] + NuGet, + Packagist, + GoModules, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum ForgeRegistry { + GithubPackages, + GitlabPackages, + BitbucketPackages, +} + +impl ForgeRegistry { + pub fn as_str(self) -> &'static str { + match self { + Self::GithubPackages => "github-packages", + Self::GitlabPackages => "gitlab-packages", + Self::BitbucketPackages => "bitbucket-packages", + } + } + + /// Package-manager protocols currently documented by each forge. Failing + /// closed here prevents a manifest from promising e.g. Cargo support in + /// GitHub Packages when that registry has no Cargo endpoint. + pub fn supports(self, native: NativeRegistry) -> bool { + match self { + Self::GithubPackages => matches!( + native, + NativeRegistry::Npm + | NativeRegistry::MavenCentral + | NativeRegistry::RubyGems + | NativeRegistry::NuGet + ), + Self::GitlabPackages => matches!( + native, + NativeRegistry::Npm + | NativeRegistry::PyPi + | NativeRegistry::MavenCentral + | NativeRegistry::RubyGems + | NativeRegistry::NuGet + | NativeRegistry::Packagist + | NativeRegistry::GoModules + ), + Self::BitbucketPackages => { + matches!(native, NativeRegistry::Npm | NativeRegistry::MavenCentral) + } + } + } +} + +impl std::fmt::Display for ForgeRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } } impl NativeRegistry { @@ -320,6 +393,11 @@ impl NativeRegistry { Self::CratesIo => "crates-io", Self::PubDev => "pub.dev", Self::PyPi => "pypi", + Self::MavenCentral => "maven-central", + Self::RubyGems => "rubygems", + Self::NuGet => "nuget", + Self::Packagist => "packagist", + Self::GoModules => "go-modules", } } @@ -329,6 +407,11 @@ impl NativeRegistry { Self::CratesIo => is_valid_crates_package(package), Self::PubDev => is_valid_pubdev_package(package), Self::PyPi => is_valid_pypi_package(package), + Self::MavenCentral => is_valid_maven_package(package), + Self::RubyGems => is_valid_rubygems_package(package), + Self::NuGet => is_valid_nuget_package(package), + Self::Packagist => is_valid_packagist_package(package), + Self::GoModules => is_valid_go_module(package), }; if valid { Ok(()) @@ -343,6 +426,7 @@ impl NativeRegistry { fn canonical_package(self, package: &str) -> String { match self { Self::PyPi => normalize_pypi_package(package), + Self::NuGet => package.to_ascii_lowercase(), _ => package.to_string(), } } @@ -360,6 +444,11 @@ impl NativeRegistry { Self::CratesIo => Ecosystem::Cargo, Self::PubDev => Ecosystem::Pub, Self::PyPi => Ecosystem::Pypi, + Self::MavenCentral => Ecosystem::Jvm, + Self::RubyGems => Ecosystem::Gem, + Self::NuGet => Ecosystem::Nuget, + Self::Packagist => Ecosystem::Composer, + Self::GoModules => Ecosystem::Gomod, } } } @@ -372,6 +461,15 @@ pub struct NativeReleaseRoute { pub package: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ForgeReleaseRoute { + pub target: String, + pub dir: String, + pub registry: ForgeRegistry, + pub format: NativeRegistry, + pub package: String, +} + fn is_valid_npm_component(value: &str) -> bool { !value.is_empty() && value.len() <= 214 @@ -447,6 +545,53 @@ fn is_valid_pubdev_package(value: &str) -> bool { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') } +fn is_valid_maven_component(value: &str) -> bool { + !value.is_empty() + && !value.starts_with(['.', '-']) + && !value.ends_with(['.', '-']) + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) +} + +fn is_valid_maven_package(value: &str) -> bool { + let Some((group, artifact)) = value.split_once(':') else { + return false; + }; + !artifact.contains(':') && is_valid_maven_component(group) && is_valid_maven_component(artifact) +} + +fn is_valid_rubygems_package(value: &str) -> bool { + !value.is_empty() + && value.as_bytes()[0].is_ascii_alphanumeric() + && value.as_bytes()[value.len() - 1].is_ascii_alphanumeric() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn is_valid_nuget_package(value: &str) -> bool { + value.len() <= 100 && is_valid_rubygems_package(value) +} + +fn is_valid_packagist_package(value: &str) -> bool { + let Some((vendor, package)) = value.split_once('/') else { + return false; + }; + !package.contains('/') && is_valid_npm_component(vendor) && is_valid_npm_component(package) +} + +fn is_valid_go_module(value: &str) -> bool { + !value.is_empty() + && value.contains('/') + && !value.starts_with('/') + && !value.ends_with('/') + && !value.contains("..") + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '-' | '_' | '~')) +} + /// A post-extract build step. Because compiled output is OS/arch-specific, /// zed-pkg runs `command` via `sh -c` inside a sandboxed staging copy of the /// source and caches the result in a build cache keyed by @@ -586,6 +731,53 @@ fn is_allowed_repo_url(url: &str) -> bool { || (url.contains('@') && url.contains(':') && !url.contains("://")) } +fn validate_native_release_section( + native: &NativeReleaseSection, + route_name: &str, + inbound: Ecosystem, +) -> Result<(), ManifestError> { + native + .registry + .validate_package(&native.package) + .map_err(|reason| ManifestError::InvalidNativeRoute(route_name.to_string(), reason))?; + + // The outbound mirror and the inbound install gate describe the same + // ecosystem. If they disagree, one of them is wrong and the package would + // either be mirrored to the wrong registry or refused for the wrong + // consumers — both silent until someone hits it, so fail here instead. + let outbound = native.registry.ecosystem(); + if !inbound.is_default() && inbound != outbound { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!( + "routes to {} (ecosystem `{outbound}`) but installs as `{inbound}`; \ + set the package or target ecosystem if the mirror is right", + native.registry.as_str() + ), + )); + } + + let mut forge_registries = std::collections::BTreeSet::new(); + for forge in &native.forge { + if !forge_registries.insert(*forge) { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!("forge registry `{forge}` is listed more than once"), + )); + } + if !forge.supports(native.registry) { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!( + "forge registry `{forge}` does not support {} packages", + native.registry.as_str() + ), + )); + } + } + Ok(()) +} + impl Manifest { /// Parse and validate a `.zpkg.toml` document. pub fn parse(input: &str) -> Result { @@ -641,6 +833,24 @@ impl Manifest { let mut target_dirs = BTreeMap::<&str, &str>::new(); let mut published_names = BTreeMap::::new(); let mut native_routes = BTreeMap::<(NativeRegistry, String), &str>::new(); + if let Some(native) = &self.publish.native { + if !self.targets.is_empty() { + return Err(ManifestError::InvalidNativeRoute( + "repository".to_string(), + "root `[publish.native]` is only valid for a single-language package; \ + polyglot packages declare `[targets..native]`" + .to_string(), + )); + } + validate_native_release_section(native, "repository", self.package.ecosystem())?; + native_routes.insert( + ( + native.registry, + native.registry.canonical_package(&native.package), + ), + "repository", + ); + } for (name, target) in &self.targets { if !is_target_name(name) { return Err(ManifestError::InvalidTarget( @@ -710,10 +920,7 @@ impl Manifest { .to_string(), )); } - native - .registry - .validate_package(&native.package) - .map_err(|reason| ManifestError::InvalidNativeRoute(name.clone(), reason))?; + validate_native_release_section(native, name, target.ecosystem_for(name))?; let canonical_package = native.registry.canonical_package(&native.package); let route = (native.registry, canonical_package); if let Some(previous) = native_routes.insert(route, name.as_str()) { @@ -726,24 +933,6 @@ impl Manifest { ), )); } - // The outbound mirror and the inbound install gate describe the - // same ecosystem. If they disagree, one of them is wrong and the - // slice would either be mirrored to the wrong registry or - // refused for the wrong consumers — both silent until someone - // hits it, so fail here instead. - let inbound = target.ecosystem_for(name); - let outbound = native.registry.ecosystem(); - if !inbound.is_default() && inbound != outbound { - return Err(ManifestError::InvalidNativeRoute( - name.clone(), - format!( - "routes to {} (ecosystem `{outbound}`) but installs as `{inbound}`; \ - set `ecosystem` on the target if the mirror is right" - , - native.registry.as_str() - ), - )); - } } } // A blank request means "no target", the same way a blank @@ -852,16 +1041,60 @@ impl Manifest { /// Native release routes sorted by target name, suitable for deterministic /// credential-free planning before any registry adapter executes. pub fn native_release_routes(&self) -> Vec { - self.targets + self.publish + .native .iter() - .filter_map(|(target, section)| { + .map(|native| NativeReleaseRoute { + target: "repository".to_string(), + dir: ".".to_string(), + registry: native.registry, + package: native.package.clone(), + }) + .chain(self.targets.iter().filter_map(|(target, section)| { section.native.as_ref().map(|native| NativeReleaseRoute { target: target.clone(), dir: section.dir.clone(), registry: native.registry, package: native.package.clone(), }) + })) + .collect() + } + + /// Forge package-registry mirrors, flattened and sorted by target then + /// registry for deterministic release plans and CI matrices. + pub fn forge_release_routes(&self) -> Vec { + self.publish + .native + .iter() + .flat_map(|native| { + native + .forge + .iter() + .copied() + .map(move |registry| ForgeReleaseRoute { + target: "repository".to_string(), + dir: ".".to_string(), + registry, + format: native.registry, + package: native.package.clone(), + }) }) + .chain(self.targets.iter().flat_map(|(target, section)| { + section.native.iter().flat_map(move |native| { + native + .forge + .iter() + .copied() + .map(move |registry| ForgeReleaseRoute { + target: target.clone(), + dir: section.dir.clone(), + registry, + format: native.registry, + package: native.package.clone(), + }) + }) + })) .collect() } diff --git a/tests/native_release.rs b/tests/native_release.rs index 1477e8a..7cd1ed5 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -1,4 +1,4 @@ -use zed_interfaces::manifest::{Manifest, ManifestError, NativeRegistry}; +use zed_interfaces::manifest::{ForgeRegistry, Manifest, ManifestError, NativeRegistry}; fn manifest(targets: &str) -> String { format!( @@ -73,6 +73,61 @@ package = "Acme.Client" Manifest::parse(&encoded).unwrap(); } +#[test] +fn a_single_language_repository_can_declare_native_and_forge_routes() { + let parsed = Manifest::parse(&manifest( + r#" +[publish.native] +registry = "npm" +package = "@acme/client" +forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] +"#, + )) + .unwrap(); + + let native = parsed.native_release_routes(); + assert_eq!(native.len(), 1); + assert_eq!(native[0].target, "repository"); + assert_eq!(native[0].dir, "."); + assert_eq!(native[0].registry, NativeRegistry::Npm); + assert_eq!(native[0].package, "@acme/client"); + + let forge = parsed.forge_release_routes(); + assert_eq!(forge.len(), 3); + assert!(forge.iter().all(|route| route.target == "repository")); + assert!(forge.iter().all(|route| route.dir == ".")); + assert_eq!(forge[0].registry, ForgeRegistry::GithubPackages); + assert_eq!(forge[1].registry, ForgeRegistry::GitlabPackages); + assert_eq!(forge[2].registry, ForgeRegistry::BitbucketPackages); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("[publish.native]")); + assert_eq!(Manifest::parse(&encoded).unwrap(), parsed); +} + +#[test] +fn a_polyglot_package_cannot_mix_root_and_target_native_routes() { + let error = Manifest::parse(&manifest( + r#" +[publish.native] +registry = "npm" +package = "@acme/all-clients" + +[targets.nodejs] +dir = "clients/typescript" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +"#, + )) + .unwrap_err(); + + let message = error.to_string(); + assert!(matches!(error, ManifestError::InvalidNativeRoute(_, _))); + assert!(message.contains("single-language"), "{message}"); +} + #[test] fn whole_repository_target_cannot_route_to_a_native_registry() { let error = Manifest::parse(&manifest( @@ -237,7 +292,10 @@ package = "{package}" .unwrap_or_else(|e| panic!("{target} -> {registry} must validate: {e}")); let route = &parsed.native_release_routes()[0]; assert_eq!(route.target, target); - assert_eq!(route.registry.ecosystem(), parsed.targets[target].ecosystem_for(target)); + assert_eq!( + route.registry.ecosystem(), + parsed.targets[target].ecosystem_for(target) + ); } } @@ -280,3 +338,102 @@ package = "acme-wasm3" ); assert!(Manifest::parse(&toml).is_ok()); } + +#[test] +fn forge_package_routes_roundtrip_and_flatten_deterministically() { + let parsed = Manifest::parse(&manifest( + r#" +[targets.nodejs] +dir = "clients/typescript" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] + +[targets.python] +dir = "clients/python" + +[targets.python.native] +registry = "pypi" +package = "acme-client" +forge = ["gitlab-packages"] +"#, + )) + .unwrap(); + + let routes = parsed.forge_release_routes(); + assert_eq!(routes.len(), 4); + assert_eq!(routes[0].target, "nodejs"); + assert_eq!(routes[0].registry, ForgeRegistry::GithubPackages); + assert_eq!(routes[0].format, NativeRegistry::Npm); + assert_eq!(routes[1].registry, ForgeRegistry::GitlabPackages); + assert_eq!(routes[2].registry, ForgeRegistry::BitbucketPackages); + assert_eq!(routes[3].target, "python"); + assert_eq!(routes[3].registry, ForgeRegistry::GitlabPackages); + assert_eq!(routes[3].format, NativeRegistry::PyPi); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("github-packages")); + assert_eq!(Manifest::parse(&encoded).unwrap(), parsed); +} + +#[test] +fn unsupported_and_duplicate_forge_routes_are_rejected() { + for targets in [ + r#" +[targets.rust] +dir = "clients/rust" +[targets.rust.native] +registry = "crates-io" +package = "acme-client" +forge = ["github-packages"] +"#, + r#" +[targets.python] +dir = "clients/python" +[targets.python.native] +registry = "pypi" +package = "acme-client" +forge = ["bitbucket-packages"] +"#, + r#" +[targets.nodejs] +dir = "clients/typescript" +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +forge = ["github-packages", "github-packages"] +"#, + ] { + assert!(matches!( + Manifest::parse(&manifest(targets)), + Err(ManifestError::InvalidNativeRoute(_, _)) + )); + } +} + +#[test] +fn major_native_registry_identities_and_ecosystems_validate() { + for (target, registry, package, ecosystem) in [ + ("java", "maven-central", "com.acme:client", "jvm"), + ("ruby", "rubygems", "acme-client", "gem"), + ("csharp", "nuget", "Acme.Client", "nuget"), + ("php", "packagist", "acme/client", "composer"), + ("golang", "go-modules", "github.com/acme/client", "gomod"), + ] { + let parsed = Manifest::parse(&manifest(&format!( + r#" +[targets.{target}] +dir = "clients/{target}" + +[targets.{target}.native] +registry = "{registry}" +package = "{package}" +"# + ))) + .unwrap_or_else(|error| panic!("{registry} route must validate: {error}")); + let route = &parsed.native_release_routes()[0]; + assert_eq!(route.registry.ecosystem().as_str(), ecosystem); + } +} diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index a2857b6..893c4fd 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -1,7 +1,7 @@ use zed_interfaces::ArtifactFormat; use zed_interfaces::excludes::{ALWAYS_INCLUDE, DEFAULT_EXCLUDES, effective_excludes}; -use zed_interfaces::lockfile::{LockedPackage, Lockfile}; use zed_interfaces::language::{Ecosystem, Language}; +use zed_interfaces::lockfile::{LockedPackage, Lockfile}; use zed_interfaces::manifest::{Manifest, ManifestError}; use zed_interfaces::paths::store_entry_rel; use zed_interfaces::vcs::Vcs; @@ -514,7 +514,9 @@ fn an_unknown_target_is_still_an_error_after_synonym_expansion() { // Synonyms must widen what resolves, never turn a real mistake into a // silent whole-tree install. let m = Manifest::parse(COLLOQUIAL).unwrap(); - let err = m.target_subdir(Some("cobol")).expect_err("unknown language"); + let err = m + .target_subdir(Some("cobol")) + .expect_err("unknown language"); assert!(err.to_string().contains("cobol"), "{err}"); } @@ -566,7 +568,10 @@ fn a_target_key_that_is_not_a_known_language_stays_ungated() { // Arbitrary slugs remain legal target names (pre-existing behavior). Such a // package publishes and installs, it just carries no ecosystem claim, so // the guard cannot and must not reject it anywhere. - let custom = POLYGLOT.replace("[targets.go]\ndir = \"go\"", "[targets.wasm3]\ndir = \"w3\""); + let custom = POLYGLOT.replace( + "[targets.go]\ndir = \"go\"", + "[targets.wasm3]\ndir = \"w3\"", + ); let m = Manifest::parse(&custom).unwrap(); let derived = m.manifest_for_target("wasm3").unwrap(); assert_eq!(derived.package.name, "polyglot-lib-wasm3"); From 3ceadc80ae1fb6db6bf94ceff4d44ac72bde88c9 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Wed, 29 Jul 2026 23:39:24 -0500 Subject: [PATCH 42/71] excludes: include_readme keeps the CHANGELOG registries ask for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dart pub publish` fails a package outright for a missing CHANGELOG.md, and the default excludes stripped `CHANGELOG*` from every artifact. `publish.exclude` can only *add* patterns, so no repository could ship one — scintilla's dart slice was unpublishable for a file it had committed. A package that opted into shipping its README wants its changelog shipped too, so `include_readme` now un-excludes both. Everything else the defaults strip (tests, CI config, VCS metadata) is untouched. Co-Authored-By: Claude Opus 5 (1M context) --- src/excludes.rs | 20 +++++++++++++++----- tests/roundtrip.rs | 23 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/excludes.rs b/src/excludes.rs index d174841..c9372cf 100644 --- a/src/excludes.rs +++ b/src/excludes.rs @@ -66,14 +66,24 @@ pub const DEFAULT_EXCLUDES: &[&str] = &[ /// Shipping license texts with artifacts is non-negotiable. pub const ALWAYS_INCLUDE: &[&str] = &["LICENSE*", "LICENCE*", "COPYING*", "NOTICE*", ".zpkg.toml"]; -/// The effective exclusion list for a package: built-in defaults (minus -/// README patterns when `include_readme` is set), plus the manifest's own -/// `publish.exclude` globs. `.zedignore` lines are appended by the CLI on -/// top of this. +/// Doc patterns `include_readme` un-excludes: the human-facing files a package +/// registry expects to find in a published artifact. +/// +/// `CHANGELOG` is here rather than stripped because native registries ask for it +/// by name — `dart pub publish` fails the package outright for its absence, and +/// `publish.exclude` can only *add* patterns, so a repo has no way to keep it +/// otherwise. A package that opted into shipping its README wants its changelog +/// shipped too. +const REGISTRY_DOC_PATTERNS: &[&str] = &["README", "CHANGELOG"]; + +/// The effective exclusion list for a package: built-in defaults (minus the +/// registry-facing doc patterns when `include_readme` is set), plus the +/// manifest's own `publish.exclude` globs. `.zedignore` lines are appended by +/// the CLI on top of this. pub fn effective_excludes(extra: &[String], include_readme: bool) -> Vec { let mut out: Vec = Vec::new(); for pattern in DEFAULT_EXCLUDES { - if include_readme && pattern.starts_with("README") { + if include_readme && REGISTRY_DOC_PATTERNS.iter().any(|d| pattern.starts_with(d)) { continue; } out.push((*pattern).to_string()); diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 893c4fd..0100d29 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -591,3 +591,26 @@ fn untagged_manifests_keep_their_meaning() { assert!(!toml.contains("language"), "{toml}"); assert!(!toml.contains("ecosystem"), "{toml}"); } + +#[test] +fn include_readme_also_keeps_the_changelog_registries_ask_for() { + // `dart pub publish` fails a package outright for a missing CHANGELOG, and + // `publish.exclude` can only add patterns — so if the default excludes strip + // it, no repo can ship one. A package that opted into its README wants its + // changelog too. + let stripped = effective_excludes(&[], false); + assert!(stripped.iter().any(|p| p.starts_with("README"))); + assert!(stripped.iter().any(|p| p.starts_with("CHANGELOG"))); + + let kept = effective_excludes(&[], true); + assert!( + !kept.iter().any(|p| p.starts_with("README")), + "include_readme must un-exclude READMEs" + ); + assert!( + !kept.iter().any(|p| p.starts_with("CHANGELOG")), + "include_readme must un-exclude CHANGELOGs: {kept:?}" + ); + // Everything else still goes. + assert!(kept.iter().any(|p| p.contains("test"))); +} From bef3b63dfc4e0784cdb41042843ea4d09f6cd58f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 29 Jul 2026 23:41:20 -0500 Subject: [PATCH 43/71] fix: retain release routes in target artifacts --- src/manifest.rs | 4 ++++ tests/native_release.rs | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index b31c894..d906425 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -1127,6 +1127,10 @@ impl Manifest { Some(base) => format!("{base} ({target})"), None => format!("{} ({target} client)", self.package.name), }); + // Once re-rooted, a polyglot slice is a standalone package. Preserve + // its outbound native/forge routing under the single-package shape so + // the manifest inside the Zed artifact remains self-describing. + derived.publish.native = section.native.clone(); derived.targets = BTreeMap::new(); derived.workspace = None; // The consumer-facing wiring for this ecosystem. diff --git a/tests/native_release.rs b/tests/native_release.rs index 7cd1ed5..45720cd 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -376,6 +376,15 @@ forge = ["gitlab-packages"] let encoded = parsed.to_toml_string().unwrap(); assert!(encoded.contains("github-packages")); assert_eq!(Manifest::parse(&encoded).unwrap(), parsed); + + let derived = parsed.manifest_for_target("nodejs").unwrap(); + assert!(derived.targets.is_empty()); + assert_eq!(derived.publish.native, parsed.targets["nodejs"].native); + assert_eq!(derived.native_release_routes()[0].target, "repository"); + assert_eq!( + Manifest::parse(&derived.to_toml_string().unwrap()).unwrap(), + derived + ); } #[test] From 9da6bb14bdebda896d1fa652fc99e2768e24c0e4 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Wed, 29 Jul 2026 23:43:17 -0500 Subject: [PATCH 44/71] style: format native routing additions --- src/language.rs | 32 ++++++++++++++++++++++++++++---- src/manifest.rs | 3 +-- tests/native_release.rs | 5 ++++- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/language.rs b/src/language.rs index d7b9fb9..11a6ee2 100644 --- a/src/language.rs +++ b/src/language.rs @@ -39,8 +39,20 @@ use serde::{Deserialize, Serialize}; /// a human has to recall. The shorter spellings, and near-synonyms like /// `typescript`, are accepted by [`Language::from_token`] and by umbrella /// variant aliases, so both spellings resolve. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[derive(Serialize, Deserialize, JsonSchema)] +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] #[serde(rename_all = "lowercase")] pub enum Language { /// Not language-specific: protocol schemas, docs, `.proto` files, an @@ -93,8 +105,20 @@ pub enum Language { /// How a consumer's build system takes a dependency in: the resolution /// mechanism, not the language. Drives the install guard and which toolchain /// wiring file `zed install` writes. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[derive(Serialize, Deserialize, JsonSchema)] +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] #[serde(rename_all = "lowercase")] pub enum Ecosystem { /// No ecosystem constraint. Installs into any project; never gated. diff --git a/src/manifest.rs b/src/manifest.rs index 6a3acd2..4495bc0 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -738,8 +738,7 @@ impl Manifest { name.clone(), format!( "routes to {} (ecosystem `{outbound}`) but installs as `{inbound}`; \ - set `ecosystem` on the target if the mirror is right" - , + set `ecosystem` on the target if the mirror is right", native.registry.as_str() ), )); diff --git a/tests/native_release.rs b/tests/native_release.rs index 1477e8a..82443fc 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -237,7 +237,10 @@ package = "{package}" .unwrap_or_else(|e| panic!("{target} -> {registry} must validate: {e}")); let route = &parsed.native_release_routes()[0]; assert_eq!(route.target, target); - assert_eq!(route.registry.ecosystem(), parsed.targets[target].ecosystem_for(target)); + assert_eq!( + route.registry.ecosystem(), + parsed.targets[target].ecosystem_for(target) + ); } } From 1b42531e766a52a06f365aaec3240276315f78fb Mon Sep 17 00:00:00 2001 From: alex-mills Date: Wed, 29 Jul 2026 23:43:21 -0500 Subject: [PATCH 45/71] fix(pack): retain changelogs as release metadata --- src/excludes.rs | 8 ++++---- tests/roundtrip.rs | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/excludes.rs b/src/excludes.rs index d174841..d7fc56b 100644 --- a/src/excludes.rs +++ b/src/excludes.rs @@ -1,8 +1,9 @@ //! Publish-time exclusion rules. //! -//! zed-pkg's core disk-space promise: published artifacts carry what runs, -//! not what develops. Tests, CI configuration, VCS metadata, and READMEs -//! are stripped by default; license files are always kept. +//! zed-pkg's core disk-space promise: published artifacts carry runtime and +//! release metadata, not development machinery. Tests, CI configuration, VCS +//! metadata, and READMEs are stripped by default; changelogs and license files +//! are kept. /// Glob patterns excluded from every published artifact by default. /// Matching is case-insensitive in the CLI. @@ -36,7 +37,6 @@ pub const DEFAULT_EXCLUDES: &[&str] = &[ ".travis.yml", "azure-pipelines.yml", "README*", - "CHANGELOG*", ".zedignore", ".zed/**", ".zed-pack/**", diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index a2857b6..ffd027e 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -1,7 +1,7 @@ use zed_interfaces::ArtifactFormat; use zed_interfaces::excludes::{ALWAYS_INCLUDE, DEFAULT_EXCLUDES, effective_excludes}; -use zed_interfaces::lockfile::{LockedPackage, Lockfile}; use zed_interfaces::language::{Ecosystem, Language}; +use zed_interfaces::lockfile::{LockedPackage, Lockfile}; use zed_interfaces::manifest::{Manifest, ManifestError}; use zed_interfaces::paths::store_entry_rel; use zed_interfaces::vcs::Vcs; @@ -133,6 +133,13 @@ fn excludes_respect_include_readme() { assert!(ALWAYS_INCLUDE.contains(&".zpkg.toml")); } +#[test] +fn changelogs_are_release_metadata_not_development_artifacts() { + let excludes = effective_excludes(&[], false); + assert!(!excludes.iter().any(|pattern| pattern == "CHANGELOG*")); + assert!(!DEFAULT_EXCLUDES.contains(&"CHANGELOG*")); +} + #[test] fn store_paths_are_sharded() { let sha = "abcdef0123".to_string() + &"0".repeat(54); @@ -514,7 +521,9 @@ fn an_unknown_target_is_still_an_error_after_synonym_expansion() { // Synonyms must widen what resolves, never turn a real mistake into a // silent whole-tree install. let m = Manifest::parse(COLLOQUIAL).unwrap(); - let err = m.target_subdir(Some("cobol")).expect_err("unknown language"); + let err = m + .target_subdir(Some("cobol")) + .expect_err("unknown language"); assert!(err.to_string().contains("cobol"), "{err}"); } @@ -566,7 +575,10 @@ fn a_target_key_that_is_not_a_known_language_stays_ungated() { // Arbitrary slugs remain legal target names (pre-existing behavior). Such a // package publishes and installs, it just carries no ecosystem claim, so // the guard cannot and must not reject it anywhere. - let custom = POLYGLOT.replace("[targets.go]\ndir = \"go\"", "[targets.wasm3]\ndir = \"w3\""); + let custom = POLYGLOT.replace( + "[targets.go]\ndir = \"go\"", + "[targets.wasm3]\ndir = \"w3\"", + ); let m = Manifest::parse(&custom).unwrap(); let derived = m.manifest_for_target("wasm3").unwrap(); assert_eq!(derived.package.name, "polyglot-lib-wasm3"); From c36e0e71bbdb6f3694e580d8df1d53a76920175e Mon Sep 17 00:00:00 2001 From: alex-mills Date: Wed, 29 Jul 2026 23:44:19 -0500 Subject: [PATCH 46/71] merge: preserve opt-in changelog packaging semantics --- src/excludes.rs | 9 +++++---- tests/roundtrip.rs | 7 ------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/excludes.rs b/src/excludes.rs index 518f199..4bed4fb 100644 --- a/src/excludes.rs +++ b/src/excludes.rs @@ -1,9 +1,9 @@ //! Publish-time exclusion rules. //! -//! zed-pkg's core disk-space promise: published artifacts carry runtime and -//! release metadata, not development machinery. Tests, CI configuration, VCS -//! metadata, and READMEs are stripped by default; changelogs and license files -//! are kept. +//! zed-pkg's core disk-space promise: published artifacts carry runtime files, +//! not development machinery. Tests, CI configuration, VCS metadata, and +//! release documentation are stripped by default; packages can opt into their +//! README and changelog together, while license files are always kept. /// Glob patterns excluded from every published artifact by default. /// Matching is case-insensitive in the CLI. @@ -37,6 +37,7 @@ pub const DEFAULT_EXCLUDES: &[&str] = &[ ".travis.yml", "azure-pipelines.yml", "README*", + "CHANGELOG*", ".zedignore", ".zed/**", ".zed-pack/**", diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 97cdd95..0100d29 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -133,13 +133,6 @@ fn excludes_respect_include_readme() { assert!(ALWAYS_INCLUDE.contains(&".zpkg.toml")); } -#[test] -fn changelogs_are_release_metadata_not_development_artifacts() { - let excludes = effective_excludes(&[], false); - assert!(!excludes.iter().any(|pattern| pattern == "CHANGELOG*")); - assert!(!DEFAULT_EXCLUDES.contains(&"CHANGELOG*")); -} - #[test] fn store_paths_are_sharded() { let sha = "abcdef0123".to_string() + &"0".repeat(54); From dc0e0a0620b9462817950b552d3d334a184b1cb1 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 29 Jul 2026 23:44:34 -0500 Subject: [PATCH 47/71] feat: model native registry tag formats --- README.md | 5 +++ schemas/manifest.json | 7 ++++ schemas/publish-meta.json | 7 ++++ src/manifest.rs | 75 +++++++++++++++++++++++++++++++++++++-- tests/native_release.rs | 30 ++++++++++++++++ 5 files changed, 122 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b463b1d..28b1b91 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,11 @@ claim Cargo support in GitHub Packages or PyPI support in Bitbucket Packages; those combinations fail during manifest parsing, before any release job sees credentials. +Tag-resolved ecosystems can override the repository release tag. A Go module +below `clients/go`, for example, declares +`tag_format = "clients/go/v{version}"`; validation rejects a subdirectory Go +route without that prefix. + A single-language package whose native manifest is at the repository root uses the same shape under `[publish.native]`: diff --git a/schemas/manifest.json b/schemas/manifest.json index 7551469..bac3032 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -278,6 +278,13 @@ }, "registry": { "$ref": "#/$defs/NativeRegistry" + }, + "tag_format": { + "description": "Optional VCS tag template for native ecosystems whose package is\nresolved from a tag rather than uploaded. It must contain `{version}`.\nGo modules below a repository subdirectory need the directory prefix,\nfor example `clients/go/v{version}`.", + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index 27ddb20..c3cec05 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -324,6 +324,13 @@ }, "registry": { "$ref": "#/$defs/NativeRegistry" + }, + "tag_format": { + "description": "Optional VCS tag template for native ecosystems whose package is\nresolved from a tag rather than uploaded. It must contain `{version}`.\nGo modules below a repository subdirectory need the directory prefix,\nfor example `clients/go/v{version}`.", + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/src/manifest.rs b/src/manifest.rs index d906425..c27c94a 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -305,6 +305,12 @@ impl TargetSection { pub struct NativeReleaseSection { pub registry: NativeRegistry, pub package: String, + /// Optional VCS tag template for native ecosystems whose package is + /// resolved from a tag rather than uploaded. It must contain `{version}`. + /// Go modules below a repository subdirectory need the directory prefix, + /// for example `clients/go/v{version}`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tag_format: Option, /// Optional copies in package registries run by source forges. The native /// registry remains the canonical ecosystem destination; these mirrors /// use the same native package format and version. @@ -459,6 +465,7 @@ pub struct NativeReleaseRoute { pub dir: String, pub registry: NativeRegistry, pub package: String, + pub vcs_tag: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -468,6 +475,7 @@ pub struct ForgeReleaseRoute { pub registry: ForgeRegistry, pub format: NativeRegistry, pub package: String, + pub vcs_tag: String, } fn is_valid_npm_component(value: &str) -> bool { @@ -734,6 +742,7 @@ fn is_allowed_repo_url(url: &str) -> bool { fn validate_native_release_section( native: &NativeReleaseSection, route_name: &str, + route_dir: &str, inbound: Ecosystem, ) -> Result<(), ManifestError> { native @@ -757,6 +766,43 @@ fn validate_native_release_section( )); } + if let Some(tag_format) = &native.tag_format { + if !tag_format.contains("{version}") { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + "native `tag_format` must contain `{version}`".to_string(), + )); + } + if tag_format.trim() != tag_format + || tag_format.is_empty() + || tag_format.chars().any(char::is_whitespace) + { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + "native `tag_format` must be a non-empty VCS ref without whitespace".to_string(), + )); + } + } + if native.registry == NativeRegistry::GoModules && route_dir != "." { + let required_prefix = format!("{}/", route_dir.trim_end_matches('/')); + let Some(tag_format) = &native.tag_format else { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!( + "Go module in `{route_dir}` requires `tag_format = \"{required_prefix}v{{version}}\"`" + ), + )); + }; + if !tag_format.starts_with(&required_prefix) { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!( + "Go module in `{route_dir}` requires a native tag prefixed by `{required_prefix}`" + ), + )); + } + } + let mut forge_registries = std::collections::BTreeSet::new(); for forge in &native.forge { if !forge_registries.insert(*forge) { @@ -842,7 +888,7 @@ impl Manifest { .to_string(), )); } - validate_native_release_section(native, "repository", self.package.ecosystem())?; + validate_native_release_section(native, "repository", ".", self.package.ecosystem())?; native_routes.insert( ( native.registry, @@ -920,7 +966,12 @@ impl Manifest { .to_string(), )); } - validate_native_release_section(native, name, target.ecosystem_for(name))?; + validate_native_release_section( + native, + name, + &target.dir, + target.ecosystem_for(name), + )?; let canonical_package = native.registry.canonical_package(&native.package); let route = (native.registry, canonical_package); if let Some(previous) = native_routes.insert(route, name.as_str()) { @@ -1049,6 +1100,11 @@ impl Manifest { dir: ".".to_string(), registry: native.registry, package: native.package.clone(), + vcs_tag: native + .tag_format + .as_deref() + .unwrap_or(&self.publish.tag_format) + .replace("{version}", &self.package.version), }) .chain(self.targets.iter().filter_map(|(target, section)| { section.native.as_ref().map(|native| NativeReleaseRoute { @@ -1056,6 +1112,11 @@ impl Manifest { dir: section.dir.clone(), registry: native.registry, package: native.package.clone(), + vcs_tag: native + .tag_format + .as_deref() + .unwrap_or(&self.publish.tag_format) + .replace("{version}", &self.package.version), }) })) .collect() @@ -1078,6 +1139,11 @@ impl Manifest { registry, format: native.registry, package: native.package.clone(), + vcs_tag: native + .tag_format + .as_deref() + .unwrap_or(&self.publish.tag_format) + .replace("{version}", &self.package.version), }) }) .chain(self.targets.iter().flat_map(|(target, section)| { @@ -1092,6 +1158,11 @@ impl Manifest { registry, format: native.registry, package: native.package.clone(), + vcs_tag: native + .tag_format + .as_deref() + .unwrap_or(&self.publish.tag_format) + .replace("{version}", &self.package.version), }) }) })) diff --git a/tests/native_release.rs b/tests/native_release.rs index 45720cd..2f69ab3 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -91,6 +91,7 @@ forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] assert_eq!(native[0].dir, "."); assert_eq!(native[0].registry, NativeRegistry::Npm); assert_eq!(native[0].package, "@acme/client"); + assert_eq!(native[0].vcs_tag, "v1.2.3"); let forge = parsed.forge_release_routes(); assert_eq!(forge.len(), 3); @@ -99,6 +100,7 @@ forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] assert_eq!(forge[0].registry, ForgeRegistry::GithubPackages); assert_eq!(forge[1].registry, ForgeRegistry::GitlabPackages); assert_eq!(forge[2].registry, ForgeRegistry::BitbucketPackages); + assert!(forge.iter().all(|route| route.vcs_tag == "v1.2.3")); let encoded = parsed.to_toml_string().unwrap(); assert!(encoded.contains("[publish.native]")); @@ -431,6 +433,11 @@ fn major_native_registry_identities_and_ecosystems_validate() { ("php", "packagist", "acme/client", "composer"), ("golang", "go-modules", "github.com/acme/client", "gomod"), ] { + let tag_format = if registry == "go-modules" { + format!("tag_format = \"clients/{target}/v{{version}}\"") + } else { + String::new() + }; let parsed = Manifest::parse(&manifest(&format!( r#" [targets.{target}] @@ -439,10 +446,33 @@ dir = "clients/{target}" [targets.{target}.native] registry = "{registry}" package = "{package}" +{tag_format} "# ))) .unwrap_or_else(|error| panic!("{registry} route must validate: {error}")); let route = &parsed.native_release_routes()[0]; assert_eq!(route.registry.ecosystem().as_str(), ecosystem); + if registry == "go-modules" { + assert_eq!(route.vcs_tag, format!("clients/{target}/v1.2.3")); + } + } +} + +#[test] +fn a_subdirectory_go_module_requires_its_native_tag_prefix() { + for tag_format in ["", "tag_format = \"v{version}\""] { + let error = Manifest::parse(&manifest(&format!( + r#" +[targets.golang] +dir = "clients/go" + +[targets.golang.native] +registry = "go-modules" +package = "github.com/acme/client" +{tag_format} +"# + ))) + .unwrap_err(); + assert!(error.to_string().contains("clients/go/")); } } From 8d81220bba8ef726ea4a13fb4baa884b6c13a75f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 29 Jul 2026 23:48:57 -0500 Subject: [PATCH 48/71] Model canonical and forge package release routes (#5) * feat: model native and forge package routes * fix: retain release routes in target artifacts * feat: model native registry tag formats --------- Co-authored-by: Claude Opus 5 (1M context) --- README.md | 38 ++++ schemas/manifest.json | 40 ++++- schemas/publish-meta.json | 40 ++++- src/manifest.rs | 355 +++++++++++++++++++++++++++++++++++--- tests/native_release.rs | 195 ++++++++++++++++++++- 5 files changed, 642 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 0b5f556..28b1b91 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ This crate is the contract everything else builds against: - **Filesystem layout** — `$HOME/.zed-pkg` store, `zed_modules/` symlink dir, archive structure (`paths`) - **VCS + artifact enums** — `git`/`hg`, `tar.gz`/`zip` (`vcs`, `artifact`) +- **Polyglot release routes** — language/ecosystem identity plus canonical + native registries and compatible GitHub/GitLab/Bitbucket package mirrors + (`language`, `manifest`) ## The model in one page @@ -40,6 +43,41 @@ stay self-contained across multi-stage builds. The lockfile pins `sha256`, `size`, `vcs_tag`, and `vcs_commit` per package: installs are reproducible and every artifact traces back to source. +For a polyglot repository, each `[targets.]` slice becomes its own +Zed artifact. An optional `[targets..native]` block declares the +same version for the ecosystem's canonical registry, and `forge` declares +additional package-registry copies: + +```toml +[targets.nodejs] +dir = "clients/typescript" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] +``` + +Forge compatibility is validated in the interface contract. A manifest cannot +claim Cargo support in GitHub Packages or PyPI support in Bitbucket Packages; +those combinations fail during manifest parsing, before any release job sees +credentials. + +Tag-resolved ecosystems can override the repository release tag. A Go module +below `clients/go`, for example, declares +`tag_format = "clients/go/v{version}"`; validation rejects a subdirectory Go +route without that prefix. + +A single-language package whose native manifest is at the repository root uses +the same shape under `[publish.native]`: + +```toml +[publish.native] +registry = "npm" +package = "r2g" +forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] +``` + ## Registry API surface | Method | Path | Body / response | diff --git a/schemas/manifest.json b/schemas/manifest.json index f2b9ff0..bac3032 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -155,6 +155,14 @@ } ] }, + "ForgeRegistry": { + "type": "string", + "enum": [ + "github-packages", + "gitlab-packages", + "bitbucket-packages" + ] + }, "InstallSection": { "description": "Install-layout controls: where zed's dependency tree lands and which\necosystem adapter to emit so those deps are visible to the native toolchain.", "type": "object", @@ -247,17 +255,36 @@ "npm", "crates-io", "pub.dev", - "pypi" + "pypi", + "maven-central", + "rubygems", + "nuget", + "packagist", + "go-modules" ] }, "NativeReleaseSection": { "type": "object", "properties": { + "forge": { + "description": "Optional copies in package registries run by source forges. The native\nregistry remains the canonical ecosystem destination; these mirrors\nuse the same native package format and version.", + "type": "array", + "items": { + "$ref": "#/$defs/ForgeRegistry" + } + }, "package": { "type": "string" }, "registry": { "$ref": "#/$defs/NativeRegistry" + }, + "tag_format": { + "description": "Optional VCS tag template for native ecosystems whose package is\nresolved from a tag rather than uploaded. It must contain `{version}`.\nGo modules below a repository subdirectory need the directory prefix,\nfor example `clients/go/v{version}`.", + "type": [ + "string", + "null" + ] } }, "required": [ @@ -350,6 +377,17 @@ "type": "boolean", "default": false }, + "native": { + "description": "Optional native-registry route for a single-language package whose\npackage-manager manifest lives at the repository root. Polyglot\npackages declare this metadata on each `[targets.*.native]` section\ninstead.", + "anyOf": [ + { + "$ref": "#/$defs/NativeReleaseSection" + }, + { + "type": "null" + } + ] + }, "smoke_test": { "description": "Command run by `zed r2g` (alias `zed test-local`) inside a throwaway\nconsumer project that has this package installed the same way a real\nconsumer would.", "type": [ diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index beffa91..c3cec05 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -116,6 +116,14 @@ } ] }, + "ForgeRegistry": { + "type": "string", + "enum": [ + "github-packages", + "gitlab-packages", + "bitbucket-packages" + ] + }, "InstallSection": { "description": "Install-layout controls: where zed's dependency tree lands and which\necosystem adapter to emit so those deps are visible to the native toolchain.", "type": "object", @@ -293,17 +301,36 @@ "npm", "crates-io", "pub.dev", - "pypi" + "pypi", + "maven-central", + "rubygems", + "nuget", + "packagist", + "go-modules" ] }, "NativeReleaseSection": { "type": "object", "properties": { + "forge": { + "description": "Optional copies in package registries run by source forges. The native\nregistry remains the canonical ecosystem destination; these mirrors\nuse the same native package format and version.", + "type": "array", + "items": { + "$ref": "#/$defs/ForgeRegistry" + } + }, "package": { "type": "string" }, "registry": { "$ref": "#/$defs/NativeRegistry" + }, + "tag_format": { + "description": "Optional VCS tag template for native ecosystems whose package is\nresolved from a tag rather than uploaded. It must contain `{version}`.\nGo modules below a repository subdirectory need the directory prefix,\nfor example `clients/go/v{version}`.", + "type": [ + "string", + "null" + ] } }, "required": [ @@ -396,6 +423,17 @@ "type": "boolean", "default": false }, + "native": { + "description": "Optional native-registry route for a single-language package whose\npackage-manager manifest lives at the repository root. Polyglot\npackages declare this metadata on each `[targets.*.native]` section\ninstead.", + "anyOf": [ + { + "$ref": "#/$defs/NativeReleaseSection" + }, + { + "type": "null" + } + ] + }, "smoke_test": { "description": "Command run by `zed r2g` (alias `zed test-local`) inside a throwaway\nconsumer project that has this package installed the same way a real\nconsumer would.", "type": [ diff --git a/src/manifest.rs b/src/manifest.rs index 4495bc0..c27c94a 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -173,6 +173,12 @@ pub struct PublishSection { /// VCS tag template that must exist and point at the published commit. /// `{version}` is substituted with `package.version`. pub tag_format: String, + /// Optional native-registry route for a single-language package whose + /// package-manager manifest lives at the repository root. Polyglot + /// packages declare this metadata on each `[targets.*.native]` section + /// instead. + #[serde(skip_serializing_if = "Option::is_none")] + pub native: Option, } impl Default for PublishSection { @@ -182,6 +188,7 @@ impl Default for PublishSection { include_readme: false, smoke_test: None, tag_format: "v{version}".to_string(), + native: None, } } } @@ -298,6 +305,17 @@ impl TargetSection { pub struct NativeReleaseSection { pub registry: NativeRegistry, pub package: String, + /// Optional VCS tag template for native ecosystems whose package is + /// resolved from a tag rather than uploaded. It must contain `{version}`. + /// Go modules below a repository subdirectory need the directory prefix, + /// for example `clients/go/v{version}`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tag_format: Option, + /// Optional copies in package registries run by source forges. The native + /// registry remains the canonical ecosystem destination; these mirrors + /// use the same native package format and version. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub forge: Vec, } #[derive( @@ -311,6 +329,67 @@ pub enum NativeRegistry { PubDev, #[serde(rename = "pypi")] PyPi, + MavenCentral, + #[serde(rename = "rubygems")] + RubyGems, + #[serde(rename = "nuget")] + NuGet, + Packagist, + GoModules, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum ForgeRegistry { + GithubPackages, + GitlabPackages, + BitbucketPackages, +} + +impl ForgeRegistry { + pub fn as_str(self) -> &'static str { + match self { + Self::GithubPackages => "github-packages", + Self::GitlabPackages => "gitlab-packages", + Self::BitbucketPackages => "bitbucket-packages", + } + } + + /// Package-manager protocols currently documented by each forge. Failing + /// closed here prevents a manifest from promising e.g. Cargo support in + /// GitHub Packages when that registry has no Cargo endpoint. + pub fn supports(self, native: NativeRegistry) -> bool { + match self { + Self::GithubPackages => matches!( + native, + NativeRegistry::Npm + | NativeRegistry::MavenCentral + | NativeRegistry::RubyGems + | NativeRegistry::NuGet + ), + Self::GitlabPackages => matches!( + native, + NativeRegistry::Npm + | NativeRegistry::PyPi + | NativeRegistry::MavenCentral + | NativeRegistry::RubyGems + | NativeRegistry::NuGet + | NativeRegistry::Packagist + | NativeRegistry::GoModules + ), + Self::BitbucketPackages => { + matches!(native, NativeRegistry::Npm | NativeRegistry::MavenCentral) + } + } + } +} + +impl std::fmt::Display for ForgeRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } } impl NativeRegistry { @@ -320,6 +399,11 @@ impl NativeRegistry { Self::CratesIo => "crates-io", Self::PubDev => "pub.dev", Self::PyPi => "pypi", + Self::MavenCentral => "maven-central", + Self::RubyGems => "rubygems", + Self::NuGet => "nuget", + Self::Packagist => "packagist", + Self::GoModules => "go-modules", } } @@ -329,6 +413,11 @@ impl NativeRegistry { Self::CratesIo => is_valid_crates_package(package), Self::PubDev => is_valid_pubdev_package(package), Self::PyPi => is_valid_pypi_package(package), + Self::MavenCentral => is_valid_maven_package(package), + Self::RubyGems => is_valid_rubygems_package(package), + Self::NuGet => is_valid_nuget_package(package), + Self::Packagist => is_valid_packagist_package(package), + Self::GoModules => is_valid_go_module(package), }; if valid { Ok(()) @@ -343,6 +432,7 @@ impl NativeRegistry { fn canonical_package(self, package: &str) -> String { match self { Self::PyPi => normalize_pypi_package(package), + Self::NuGet => package.to_ascii_lowercase(), _ => package.to_string(), } } @@ -360,6 +450,11 @@ impl NativeRegistry { Self::CratesIo => Ecosystem::Cargo, Self::PubDev => Ecosystem::Pub, Self::PyPi => Ecosystem::Pypi, + Self::MavenCentral => Ecosystem::Jvm, + Self::RubyGems => Ecosystem::Gem, + Self::NuGet => Ecosystem::Nuget, + Self::Packagist => Ecosystem::Composer, + Self::GoModules => Ecosystem::Gomod, } } } @@ -370,6 +465,17 @@ pub struct NativeReleaseRoute { pub dir: String, pub registry: NativeRegistry, pub package: String, + pub vcs_tag: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ForgeReleaseRoute { + pub target: String, + pub dir: String, + pub registry: ForgeRegistry, + pub format: NativeRegistry, + pub package: String, + pub vcs_tag: String, } fn is_valid_npm_component(value: &str) -> bool { @@ -447,6 +553,53 @@ fn is_valid_pubdev_package(value: &str) -> bool { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') } +fn is_valid_maven_component(value: &str) -> bool { + !value.is_empty() + && !value.starts_with(['.', '-']) + && !value.ends_with(['.', '-']) + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) +} + +fn is_valid_maven_package(value: &str) -> bool { + let Some((group, artifact)) = value.split_once(':') else { + return false; + }; + !artifact.contains(':') && is_valid_maven_component(group) && is_valid_maven_component(artifact) +} + +fn is_valid_rubygems_package(value: &str) -> bool { + !value.is_empty() + && value.as_bytes()[0].is_ascii_alphanumeric() + && value.as_bytes()[value.len() - 1].is_ascii_alphanumeric() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn is_valid_nuget_package(value: &str) -> bool { + value.len() <= 100 && is_valid_rubygems_package(value) +} + +fn is_valid_packagist_package(value: &str) -> bool { + let Some((vendor, package)) = value.split_once('/') else { + return false; + }; + !package.contains('/') && is_valid_npm_component(vendor) && is_valid_npm_component(package) +} + +fn is_valid_go_module(value: &str) -> bool { + !value.is_empty() + && value.contains('/') + && !value.starts_with('/') + && !value.ends_with('/') + && !value.contains("..") + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '-' | '_' | '~')) +} + /// A post-extract build step. Because compiled output is OS/arch-specific, /// zed-pkg runs `command` via `sh -c` inside a sandboxed staging copy of the /// source and caches the result in a build cache keyed by @@ -586,6 +739,91 @@ fn is_allowed_repo_url(url: &str) -> bool { || (url.contains('@') && url.contains(':') && !url.contains("://")) } +fn validate_native_release_section( + native: &NativeReleaseSection, + route_name: &str, + route_dir: &str, + inbound: Ecosystem, +) -> Result<(), ManifestError> { + native + .registry + .validate_package(&native.package) + .map_err(|reason| ManifestError::InvalidNativeRoute(route_name.to_string(), reason))?; + + // The outbound mirror and the inbound install gate describe the same + // ecosystem. If they disagree, one of them is wrong and the package would + // either be mirrored to the wrong registry or refused for the wrong + // consumers — both silent until someone hits it, so fail here instead. + let outbound = native.registry.ecosystem(); + if !inbound.is_default() && inbound != outbound { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!( + "routes to {} (ecosystem `{outbound}`) but installs as `{inbound}`; \ + set the package or target ecosystem if the mirror is right", + native.registry.as_str() + ), + )); + } + + if let Some(tag_format) = &native.tag_format { + if !tag_format.contains("{version}") { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + "native `tag_format` must contain `{version}`".to_string(), + )); + } + if tag_format.trim() != tag_format + || tag_format.is_empty() + || tag_format.chars().any(char::is_whitespace) + { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + "native `tag_format` must be a non-empty VCS ref without whitespace".to_string(), + )); + } + } + if native.registry == NativeRegistry::GoModules && route_dir != "." { + let required_prefix = format!("{}/", route_dir.trim_end_matches('/')); + let Some(tag_format) = &native.tag_format else { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!( + "Go module in `{route_dir}` requires `tag_format = \"{required_prefix}v{{version}}\"`" + ), + )); + }; + if !tag_format.starts_with(&required_prefix) { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!( + "Go module in `{route_dir}` requires a native tag prefixed by `{required_prefix}`" + ), + )); + } + } + + let mut forge_registries = std::collections::BTreeSet::new(); + for forge in &native.forge { + if !forge_registries.insert(*forge) { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!("forge registry `{forge}` is listed more than once"), + )); + } + if !forge.supports(native.registry) { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!( + "forge registry `{forge}` does not support {} packages", + native.registry.as_str() + ), + )); + } + } + Ok(()) +} + impl Manifest { /// Parse and validate a `.zpkg.toml` document. pub fn parse(input: &str) -> Result { @@ -641,6 +879,24 @@ impl Manifest { let mut target_dirs = BTreeMap::<&str, &str>::new(); let mut published_names = BTreeMap::::new(); let mut native_routes = BTreeMap::<(NativeRegistry, String), &str>::new(); + if let Some(native) = &self.publish.native { + if !self.targets.is_empty() { + return Err(ManifestError::InvalidNativeRoute( + "repository".to_string(), + "root `[publish.native]` is only valid for a single-language package; \ + polyglot packages declare `[targets..native]`" + .to_string(), + )); + } + validate_native_release_section(native, "repository", ".", self.package.ecosystem())?; + native_routes.insert( + ( + native.registry, + native.registry.canonical_package(&native.package), + ), + "repository", + ); + } for (name, target) in &self.targets { if !is_target_name(name) { return Err(ManifestError::InvalidTarget( @@ -710,10 +966,12 @@ impl Manifest { .to_string(), )); } - native - .registry - .validate_package(&native.package) - .map_err(|reason| ManifestError::InvalidNativeRoute(name.clone(), reason))?; + validate_native_release_section( + native, + name, + &target.dir, + target.ecosystem_for(name), + )?; let canonical_package = native.registry.canonical_package(&native.package); let route = (native.registry, canonical_package); if let Some(previous) = native_routes.insert(route, name.as_str()) { @@ -726,23 +984,6 @@ impl Manifest { ), )); } - // The outbound mirror and the inbound install gate describe the - // same ecosystem. If they disagree, one of them is wrong and the - // slice would either be mirrored to the wrong registry or - // refused for the wrong consumers — both silent until someone - // hits it, so fail here instead. - let inbound = target.ecosystem_for(name); - let outbound = native.registry.ecosystem(); - if !inbound.is_default() && inbound != outbound { - return Err(ManifestError::InvalidNativeRoute( - name.clone(), - format!( - "routes to {} (ecosystem `{outbound}`) but installs as `{inbound}`; \ - set `ecosystem` on the target if the mirror is right", - native.registry.as_str() - ), - )); - } } } // A blank request means "no target", the same way a blank @@ -851,16 +1092,80 @@ impl Manifest { /// Native release routes sorted by target name, suitable for deterministic /// credential-free planning before any registry adapter executes. pub fn native_release_routes(&self) -> Vec { - self.targets + self.publish + .native .iter() - .filter_map(|(target, section)| { + .map(|native| NativeReleaseRoute { + target: "repository".to_string(), + dir: ".".to_string(), + registry: native.registry, + package: native.package.clone(), + vcs_tag: native + .tag_format + .as_deref() + .unwrap_or(&self.publish.tag_format) + .replace("{version}", &self.package.version), + }) + .chain(self.targets.iter().filter_map(|(target, section)| { section.native.as_ref().map(|native| NativeReleaseRoute { target: target.clone(), dir: section.dir.clone(), registry: native.registry, package: native.package.clone(), + vcs_tag: native + .tag_format + .as_deref() + .unwrap_or(&self.publish.tag_format) + .replace("{version}", &self.package.version), }) + })) + .collect() + } + + /// Forge package-registry mirrors, flattened and sorted by target then + /// registry for deterministic release plans and CI matrices. + pub fn forge_release_routes(&self) -> Vec { + self.publish + .native + .iter() + .flat_map(|native| { + native + .forge + .iter() + .copied() + .map(move |registry| ForgeReleaseRoute { + target: "repository".to_string(), + dir: ".".to_string(), + registry, + format: native.registry, + package: native.package.clone(), + vcs_tag: native + .tag_format + .as_deref() + .unwrap_or(&self.publish.tag_format) + .replace("{version}", &self.package.version), + }) }) + .chain(self.targets.iter().flat_map(|(target, section)| { + section.native.iter().flat_map(move |native| { + native + .forge + .iter() + .copied() + .map(move |registry| ForgeReleaseRoute { + target: target.clone(), + dir: section.dir.clone(), + registry, + format: native.registry, + package: native.package.clone(), + vcs_tag: native + .tag_format + .as_deref() + .unwrap_or(&self.publish.tag_format) + .replace("{version}", &self.package.version), + }) + }) + })) .collect() } @@ -893,6 +1198,10 @@ impl Manifest { Some(base) => format!("{base} ({target})"), None => format!("{} ({target} client)", self.package.name), }); + // Once re-rooted, a polyglot slice is a standalone package. Preserve + // its outbound native/forge routing under the single-package shape so + // the manifest inside the Zed artifact remains self-describing. + derived.publish.native = section.native.clone(); derived.targets = BTreeMap::new(); derived.workspace = None; // The consumer-facing wiring for this ecosystem. diff --git a/tests/native_release.rs b/tests/native_release.rs index 82443fc..2f69ab3 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -1,4 +1,4 @@ -use zed_interfaces::manifest::{Manifest, ManifestError, NativeRegistry}; +use zed_interfaces::manifest::{ForgeRegistry, Manifest, ManifestError, NativeRegistry}; fn manifest(targets: &str) -> String { format!( @@ -73,6 +73,63 @@ package = "Acme.Client" Manifest::parse(&encoded).unwrap(); } +#[test] +fn a_single_language_repository_can_declare_native_and_forge_routes() { + let parsed = Manifest::parse(&manifest( + r#" +[publish.native] +registry = "npm" +package = "@acme/client" +forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] +"#, + )) + .unwrap(); + + let native = parsed.native_release_routes(); + assert_eq!(native.len(), 1); + assert_eq!(native[0].target, "repository"); + assert_eq!(native[0].dir, "."); + assert_eq!(native[0].registry, NativeRegistry::Npm); + assert_eq!(native[0].package, "@acme/client"); + assert_eq!(native[0].vcs_tag, "v1.2.3"); + + let forge = parsed.forge_release_routes(); + assert_eq!(forge.len(), 3); + assert!(forge.iter().all(|route| route.target == "repository")); + assert!(forge.iter().all(|route| route.dir == ".")); + assert_eq!(forge[0].registry, ForgeRegistry::GithubPackages); + assert_eq!(forge[1].registry, ForgeRegistry::GitlabPackages); + assert_eq!(forge[2].registry, ForgeRegistry::BitbucketPackages); + assert!(forge.iter().all(|route| route.vcs_tag == "v1.2.3")); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("[publish.native]")); + assert_eq!(Manifest::parse(&encoded).unwrap(), parsed); +} + +#[test] +fn a_polyglot_package_cannot_mix_root_and_target_native_routes() { + let error = Manifest::parse(&manifest( + r#" +[publish.native] +registry = "npm" +package = "@acme/all-clients" + +[targets.nodejs] +dir = "clients/typescript" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +"#, + )) + .unwrap_err(); + + let message = error.to_string(); + assert!(matches!(error, ManifestError::InvalidNativeRoute(_, _))); + assert!(message.contains("single-language"), "{message}"); +} + #[test] fn whole_repository_target_cannot_route_to_a_native_registry() { let error = Manifest::parse(&manifest( @@ -283,3 +340,139 @@ package = "acme-wasm3" ); assert!(Manifest::parse(&toml).is_ok()); } + +#[test] +fn forge_package_routes_roundtrip_and_flatten_deterministically() { + let parsed = Manifest::parse(&manifest( + r#" +[targets.nodejs] +dir = "clients/typescript" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] + +[targets.python] +dir = "clients/python" + +[targets.python.native] +registry = "pypi" +package = "acme-client" +forge = ["gitlab-packages"] +"#, + )) + .unwrap(); + + let routes = parsed.forge_release_routes(); + assert_eq!(routes.len(), 4); + assert_eq!(routes[0].target, "nodejs"); + assert_eq!(routes[0].registry, ForgeRegistry::GithubPackages); + assert_eq!(routes[0].format, NativeRegistry::Npm); + assert_eq!(routes[1].registry, ForgeRegistry::GitlabPackages); + assert_eq!(routes[2].registry, ForgeRegistry::BitbucketPackages); + assert_eq!(routes[3].target, "python"); + assert_eq!(routes[3].registry, ForgeRegistry::GitlabPackages); + assert_eq!(routes[3].format, NativeRegistry::PyPi); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("github-packages")); + assert_eq!(Manifest::parse(&encoded).unwrap(), parsed); + + let derived = parsed.manifest_for_target("nodejs").unwrap(); + assert!(derived.targets.is_empty()); + assert_eq!(derived.publish.native, parsed.targets["nodejs"].native); + assert_eq!(derived.native_release_routes()[0].target, "repository"); + assert_eq!( + Manifest::parse(&derived.to_toml_string().unwrap()).unwrap(), + derived + ); +} + +#[test] +fn unsupported_and_duplicate_forge_routes_are_rejected() { + for targets in [ + r#" +[targets.rust] +dir = "clients/rust" +[targets.rust.native] +registry = "crates-io" +package = "acme-client" +forge = ["github-packages"] +"#, + r#" +[targets.python] +dir = "clients/python" +[targets.python.native] +registry = "pypi" +package = "acme-client" +forge = ["bitbucket-packages"] +"#, + r#" +[targets.nodejs] +dir = "clients/typescript" +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +forge = ["github-packages", "github-packages"] +"#, + ] { + assert!(matches!( + Manifest::parse(&manifest(targets)), + Err(ManifestError::InvalidNativeRoute(_, _)) + )); + } +} + +#[test] +fn major_native_registry_identities_and_ecosystems_validate() { + for (target, registry, package, ecosystem) in [ + ("java", "maven-central", "com.acme:client", "jvm"), + ("ruby", "rubygems", "acme-client", "gem"), + ("csharp", "nuget", "Acme.Client", "nuget"), + ("php", "packagist", "acme/client", "composer"), + ("golang", "go-modules", "github.com/acme/client", "gomod"), + ] { + let tag_format = if registry == "go-modules" { + format!("tag_format = \"clients/{target}/v{{version}}\"") + } else { + String::new() + }; + let parsed = Manifest::parse(&manifest(&format!( + r#" +[targets.{target}] +dir = "clients/{target}" + +[targets.{target}.native] +registry = "{registry}" +package = "{package}" +{tag_format} +"# + ))) + .unwrap_or_else(|error| panic!("{registry} route must validate: {error}")); + let route = &parsed.native_release_routes()[0]; + assert_eq!(route.registry.ecosystem().as_str(), ecosystem); + if registry == "go-modules" { + assert_eq!(route.vcs_tag, format!("clients/{target}/v1.2.3")); + } + } +} + +#[test] +fn a_subdirectory_go_module_requires_its_native_tag_prefix() { + for tag_format in ["", "tag_format = \"v{version}\""] { + let error = Manifest::parse(&manifest(&format!( + r#" +[targets.golang] +dir = "clients/go" + +[targets.golang.native] +registry = "go-modules" +package = "github.com/acme/client" +{tag_format} +"# + ))) + .unwrap_err(); + assert!(error.to_string().contains("clients/go/")); + } +} From 07d01604461d00e237c7d86ad3855464167574ec Mon Sep 17 00:00:00 2001 From: alex-mills Date: Wed, 29 Jul 2026 23:49:37 -0500 Subject: [PATCH 49/71] manifest: pick predictably when two targets share a language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repo can legitimately ship separate TypeScript and JavaScript clients, which makes two targets both `nodejs`. `resolve_target_key` took the first match in key order, so a consumer inferred as `node` silently got `javascript` — the wrong client, chosen by alphabetization. Among several same-language targets, prefer the one named after the language itself. An exact key match still wins first, and single-match repos are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- src/manifest.rs | 19 ++++++++++++++++++- tests/roundtrip.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/manifest.rs b/src/manifest.rs index c27c94a..9b26b81 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -1232,9 +1232,26 @@ impl Manifest { return Some(key.as_str()); } let wanted = Language::from_token(requested).filter(|l| !l.is_default())?; + let mut matches = self + .targets + .keys() + .filter(|key| Language::from_token(key) == Some(wanted)); + let first = matches.next()?; + if matches.next().is_none() { + return Some(first.as_str()); + } + // Several targets share this language — a repo shipping separate `ts` + // and `js` clients has two `nodejs` targets. A synonym request like + // `node` must land somewhere predictable rather than on whichever key + // sorts first, so prefer the one named after the language itself. self.targets .keys() - .find(|key| Language::from_token(key) == Some(wanted)) + .find(|key| key.as_str() == wanted.as_str()) + .or_else(|| { + self.targets + .keys() + .find(|key| Language::from_token(key) == Some(wanted)) + }) .map(String::as_str) } diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 0100d29..cb6bf9c 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -614,3 +614,39 @@ fn include_readme_also_keeps_the_changelog_registries_ask_for() { // Everything else still goes. assert!(kept.iter().any(|p| p.contains("test"))); } + +#[test] +fn a_synonym_request_prefers_the_target_named_after_the_language() { + // shared-auth-clients ships separate TypeScript and JavaScript clients, so + // two targets are both `nodejs`. A consumer inferred as `node` must land on + // the canonical one, not on whichever key happens to sort first. + let two_node = r#" +[package] +org = "shared-auth" +name = "shared-auth-clients" +version = "0.1.0" + +[package.repository] +url = "https://github.com/shared-auth/shared-auth-clients" + +[targets.javascript] +dir = "clients/js" + +[targets.nodejs] +dir = "clients/ts" +"#; + let m = Manifest::parse(two_node).unwrap(); + // `javascript` sorts before `nodejs`, so a naive first-match would pick it. + assert_eq!(m.resolve_target_key("node"), Some("nodejs")); + assert_eq!(m.target_subdir(Some("node")).unwrap(), Some("clients/ts")); + // An exact key still wins over the canonical preference. + assert_eq!(m.resolve_target_key("javascript"), Some("javascript")); + assert_eq!( + m.target_subdir(Some("javascript")).unwrap(), + Some("clients/js") + ); + // And a single-match repo is unaffected. + let one = two_node.replace("[targets.javascript]\ndir = \"clients/js\"\n", ""); + let m1 = Manifest::parse(&one).unwrap(); + assert_eq!(m1.resolve_target_key("js"), Some("nodejs")); +} From 3ffca44d2d93ed4caae5737fe4a38fb50e7f3f60 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Thu, 30 Jul 2026 19:04:52 -0500 Subject: [PATCH 50/71] fix: drop superseded publish-route schema left by the draft merge The multi-registry schema draft merge resolved its conflicting hunks to main, but the draft's non-conflicting hunks auto-merged and still referenced the design main replaced: a validate_publish_route block reading publish.format / publish.registries / publish.registry_urls and the same fields on TargetSection. None of those exist on main's route model, so the crate did not compile (11 errors). Removes that validation block and the now-unreferenced publish::PublishRegistry module it was the only consumer of. Routing is validated by the native/[targets.*.native] path that main already has. cargo check is clean and all 42 tests pass. --- src/lib.rs | 2 - src/manifest.rs | 19 -------- src/publish.rs | 110 --------------------------------------------- tests/roundtrip.rs | 1 - 4 files changed, 132 deletions(-) delete mode 100644 src/publish.rs diff --git a/src/lib.rs b/src/lib.rs index 51c1fd3..cdb283b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,6 @@ pub mod language; pub mod lockfile; pub mod manifest; pub mod paths; -pub mod publish; pub mod registry; pub mod sync; pub mod vcs; @@ -22,6 +21,5 @@ pub use artifact::ArtifactFormat; pub use language::{Ecosystem, Language, detect_ecosystems}; pub use lockfile::{LockedPackage, Lockfile}; pub use manifest::{Manifest, ManifestError}; -pub use publish::PublishRegistry; pub use vcs::Vcs; pub use version::{Requirement, VersionScheme}; diff --git a/src/manifest.rs b/src/manifest.rs index 3587cb9..9b26b81 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -986,25 +986,6 @@ impl Manifest { } } } - if self.targets.is_empty() { - validate_publish_route( - "package", - self.publish.format.as_deref(), - self.publish.registries.as_deref(), - &self.publish.registry_urls, - )?; - } else { - for (name, target) in &self.targets { - let format = target.format.as_deref().or(self.publish.format.as_deref()); - let registries = target - .registries - .as_deref() - .or(self.publish.registries.as_deref()); - let mut urls = self.publish.registry_urls.clone(); - urls.extend(target.registry_urls.clone()); - validate_publish_route(name, format, registries, &urls)?; - } - } // A blank request means "no target", the same way a blank // `[install].dir` falls back to the default rather than erroring. if let Some(requested) = self.requested_target() diff --git a/src/publish.rs b/src/publish.rs deleted file mode 100644 index 58615eb..0000000 --- a/src/publish.rs +++ /dev/null @@ -1,110 +0,0 @@ -use std::fmt; -use std::str::FromStr; - -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -/// A registry family that can receive one package produced by `zed publish`. -/// -/// `zed` is zpkg.tech (or the CLI's configured Zed-compatible registry), -/// `native` is the ecosystem's canonical registry (npmjs, crates.io, PyPI, -/// and so on), and the remaining variants are package registries operated by -/// source forges. The source repository itself remains configured separately -/// under `[package.repository]`. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, -)] -#[serde(rename_all = "kebab-case")] -pub enum PublishRegistry { - Zed, - Native, - GithubPackages, - GitlabPackages, - BitbucketPackages, -} - -impl PublishRegistry { - pub const ALL: [Self; 5] = [ - Self::Zed, - Self::Native, - Self::GithubPackages, - Self::GitlabPackages, - Self::BitbucketPackages, - ]; - - pub fn as_str(self) -> &'static str { - match self { - Self::Zed => "zed", - Self::Native => "native", - Self::GithubPackages => "github-packages", - Self::GitlabPackages => "gitlab-packages", - Self::BitbucketPackages => "bitbucket-packages", - } - } - - /// Whether this registry family currently accepts the package-manager - /// format. Zed accepts every format because it stores the deterministic - /// zpkg artifact; forge registries intentionally fail closed to the - /// formats their public APIs document. - pub fn supports_format(self, format: &str) -> bool { - match self { - Self::Zed => true, - Self::Native => !matches!(format, "zpkg" | "generic"), - Self::GithubPackages => { - matches!(format, "npm" | "rubygems" | "maven" | "nuget" | "container") - } - Self::GitlabPackages => matches!( - format, - "composer" - | "conan" - | "debian" - | "generic" - | "go" - | "helm" - | "maven" - | "npm" - | "nuget" - | "pypi" - | "rubygems" - | "terraform" - ), - Self::BitbucketPackages => matches!(format, "npm" | "maven" | "container"), - } - } -} - -impl fmt::Display for PublishRegistry { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParsePublishRegistryError(pub String); - -impl fmt::Display for ParsePublishRegistryError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "unknown publish registry `{}` (expected zed, native, github-packages, gitlab-packages, or bitbucket-packages)", - self.0 - ) - } -} - -impl std::error::Error for ParsePublishRegistryError {} - -impl FromStr for PublishRegistry { - type Err = ParsePublishRegistryError; - - fn from_str(value: &str) -> Result { - match value { - "zed" => Ok(Self::Zed), - "native" => Ok(Self::Native), - "github-packages" | "github" => Ok(Self::GithubPackages), - "gitlab-packages" | "gitlab" => Ok(Self::GitlabPackages), - "bitbucket-packages" | "bitbucket" => Ok(Self::BitbucketPackages), - other => Err(ParsePublishRegistryError(other.to_string())), - } - } -} diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index b14ca53..cb6bf9c 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -4,7 +4,6 @@ use zed_interfaces::language::{Ecosystem, Language}; use zed_interfaces::lockfile::{LockedPackage, Lockfile}; use zed_interfaces::manifest::{Manifest, ManifestError}; use zed_interfaces::paths::store_entry_rel; -use zed_interfaces::publish::PublishRegistry; use zed_interfaces::vcs::Vcs; const SAMPLE: &str = r#" From cdbd901a0b375c5a68b9deefbd3e03437f5f88e8 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 31 Jul 2026 02:08:47 -0500 Subject: [PATCH 51/71] docs(DEN-132): add canonical interface agent instructions --- agents.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 agents.md diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..0a3eb6d --- /dev/null +++ b/agents.md @@ -0,0 +1,25 @@ +# Agent instructions + +## Scope and hierarchy + +- These instructions apply to the whole `zed-pkg/zed-interfaces` repository unless a deeper lowercase `agents.md` adds narrower rules. +- Before editing, resolve the current working directory and load every readable ancestor `agents.md` from the filesystem root to the working directory. Do not search siblings. Resolve symlinks, deduplicate resolved files, and report unreadable or cyclic instruction files. +- `.claude/CLAUDE.md`, `.gemini/GEMINI.md`, and `.openai/AGENTS.md` are pointers only. Never duplicate instructions in tool-specific files. + +## Repository role + +This crate is the shared contract boundary for Zed manifests, lockfiles, registry models, language targets, paths, version requirements, synchronization, and VCS metadata. Changes here affect the CLI, API server, web server, sync engine, and generated clients. + +## Working rules + +- Preserve serialization compatibility and deterministic ordering unless an explicitly versioned migration says otherwise. +- Treat public Rust types, JSON schemas, TOML fields, lockfile formats, and path conventions as cross-repository APIs. +- Add round-trip and negative tests for every parser or schema change; do not weaken validation to make one consumer pass. +- Update interface consumers and monorepo pins in contract-first order when a change spans repositories. +- Keep the crate free of service-specific network, credential, database, and deployment policy. +- Never commit registry tokens, cloud credentials, generated secrets, or production environment files. +- Run formatting, compilation, tests, doctests, and Clippy using the repository's pinned toolchain and lockfile. + +## Validation + +The pinned `agents policy` workflow validates this hierarchy and the three tool pointers. Run the repository checks documented in `README.md` and existing CI before requesting review. From a6166a9cee36654c87ef759dafc3ea69051a0fcf Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 31 Jul 2026 02:09:01 -0500 Subject: [PATCH 52/71] docs(DEN-132): add Claude pointer --- .claude/CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .claude/CLAUDE.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..2471f63 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1 @@ +Read and follow the canonical instructions in `../agents.md`. From da8d62a5970fa8dc94c277f65f7b1f1283d1cedb Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 31 Jul 2026 02:09:09 -0500 Subject: [PATCH 53/71] docs(DEN-132): add Gemini pointer --- .gemini/GEMINI.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gemini/GEMINI.md diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md new file mode 100644 index 0000000..2471f63 --- /dev/null +++ b/.gemini/GEMINI.md @@ -0,0 +1 @@ +Read and follow the canonical instructions in `../agents.md`. From 40e2f6c27e7d8598fa46a6cd67296a0825fac73c Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 31 Jul 2026 02:10:33 -0500 Subject: [PATCH 54/71] docs(DEN-132): add OpenAI pointer --- .openai/AGENTS.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .openai/AGENTS.md diff --git a/.openai/AGENTS.md b/.openai/AGENTS.md new file mode 100644 index 0000000..2471f63 --- /dev/null +++ b/.openai/AGENTS.md @@ -0,0 +1 @@ +Read and follow the canonical instructions in `../agents.md`. From fa7ae5c1a5f57df979680e37dbb7029b1bab1cf4 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 31 Jul 2026 02:11:41 -0500 Subject: [PATCH 55/71] ci(DEN-132): enforce pinned agents policy --- .github/workflows/agents-policy.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/workflows/agents-policy.yml diff --git a/.github/workflows/agents-policy.yml b/.github/workflows/agents-policy.yml new file mode 100644 index 0000000..c1a9734 --- /dev/null +++ b/.github/workflows/agents-policy.yml @@ -0,0 +1,19 @@ +name: agents policy + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: agents-policy-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + validate: + uses: zed-pkg/zed-monorepo/.github/workflows/agents-policy-reusable.yml@fb2417b1a976459e3de740f788916d3d91d3669e + with: + policy_ref: fb2417b1a976459e3de740f788916d3d91d3669e From 1bafce636425e78c344f5f6065d41863c62ac22b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:46:43 -0500 Subject: [PATCH 56/71] DEN-359 add pinned interface-contract Nix flake --- flake.nix | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 flake.nix diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..84867fd --- /dev/null +++ b/flake.nix @@ -0,0 +1,78 @@ +{ + description = "Agent-first development environment for the zed-pkg interface contract"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs, ... }: + let + systems = [ + "aarch64-darwin" + "aarch64-linux" + "x86_64-darwin" + "x86_64-linux" + ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + pkgsFor = system: import nixpkgs { inherit system; }; + toolchainFor = pkgs: with pkgs; [ + actionlint + bash + cargo + clippy + git + nix + nixfmt + rustc + rustfmt + shellcheck + shfmt + ]; + in + { + formatter = forAllSystems (system: (pkgsFor system).nixfmt); + + packages = forAllSystems ( + system: + let + pkgs = pkgsFor system; + agentCheck = pkgs.writeShellApplication { + name = "agent-check"; + runtimeInputs = toolchainFor pkgs; + text = builtins.readFile ./.nix/agent-check.sh; + }; + in + { + inherit agentCheck; + default = agentCheck; + } + ); + + apps = forAllSystems (system: { + agent-check = { + type = "app"; + program = "${self.packages.${system}.agentCheck}/bin/agent-check"; + }; + default = self.apps.${system}.agent-check; + }); + + checks = forAllSystems (system: { + agentCheck = self.packages.${system}.agentCheck; + }); + + devShells = forAllSystems ( + system: + let + pkgs = pkgsFor system; + in + { + default = import ./.nix/dev-shell.nix { + inherit pkgs; + agentCheck = self.packages.${system}.agentCheck; + toolchain = toolchainFor pkgs; + }; + } + ); + }; +} From fc29130af4fb73c2f60748cc15c9f62075102628 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:46:53 -0500 Subject: [PATCH 57/71] DEN-359 pin interface Nix dependencies --- flake.lock | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 flake.lock diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..a94690d --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1782467914, + "narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e73de5be04e0eff4190a1432b946d469c794e7b4", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} From 1c5fc2acab961ce32d4ff1dc28a62dde256440d6 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:47:02 -0500 Subject: [PATCH 58/71] DEN-359 add isolated Rust contract shell --- .nix/dev-shell.nix | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .nix/dev-shell.nix diff --git a/.nix/dev-shell.nix b/.nix/dev-shell.nix new file mode 100644 index 0000000..2eb0896 --- /dev/null +++ b/.nix/dev-shell.nix @@ -0,0 +1,20 @@ +{ + pkgs, + agentCheck, + toolchain, +}: +pkgs.mkShell { + packages = toolchain ++ [ agentCheck ]; + + LANG = if pkgs.stdenv.hostPlatform.isDarwin then "en_US.UTF-8" else "C.UTF-8"; + LC_ALL = if pkgs.stdenv.hostPlatform.isDarwin then "en_US.UTF-8" else "C.UTF-8"; + + shellHook = '' + export NIX_DEV_SHELL=zed-interfaces + export NIX_AGENT_CACHE_ROOT="''${NIX_AGENT_CACHE_ROOT:-$PWD/.cache/nix-agent}" + export CARGO_HOME="''${CARGO_HOME:-$NIX_AGENT_CACHE_ROOT/cargo}" + export CARGO_TARGET_DIR="''${CARGO_TARGET_DIR:-$NIX_AGENT_CACHE_ROOT/target}" + export XDG_CACHE_HOME="''${XDG_CACHE_HOME:-$NIX_AGENT_CACHE_ROOT/xdg}" + mkdir -p "$CARGO_HOME" "$CARGO_TARGET_DIR" "$XDG_CACHE_HOME" + ''; +} From aa3be67b01a6b3ca9862fae94953fbce3a5357cb Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:47:14 -0500 Subject: [PATCH 59/71] DEN-359 add non-interactive contract validation --- .nix/agent-check.sh | 64 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .nix/agent-check.sh diff --git a/.nix/agent-check.sh b/.nix/agent-check.sh new file mode 100644 index 0000000..524bf4c --- /dev/null +++ b/.nix/agent-check.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +export CI="${CI:-1}" +export NO_COLOR="${NO_COLOR:-1}" + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +cache_root="${NIX_AGENT_CACHE_ROOT:-$repo_root/.cache/nix-agent}" +export CARGO_HOME="${CARGO_HOME:-$cache_root/cargo}" +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$cache_root/target}" +export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$cache_root/xdg}" +mkdir -p "$CARGO_HOME" "$CARGO_TARGET_DIR" "$XDG_CACHE_HOME" + +run_stage() { + local stage="$1" + printf '\n==> agent-check stage: %s\n' "$stage" + + case "$stage" in + preflight) + git diff --check + nixfmt --check flake.nix .nix/dev-shell.nix + shellcheck .nix/agent-check.sh + shfmt -i 2 -ci -d .nix/agent-check.sh + actionlint .github/workflows/*.yml + nix flake check --no-update-lock-file --show-trace + ;; + format) + cargo fmt --check + ;; + lint) + cargo clippy --locked --all-targets -- -D warnings + ;; + test) + cargo test --locked + cargo test --locked --doc + ;; + schemas) + cargo run --locked --example generate_schemas + git diff --exit-code -- schemas/ + ;; + all) + local child + for child in preflight format lint test schemas; do + run_stage "$child" + done + ;; + *) + printf 'unknown agent-check stage: %s\n' "$stage" >&2 + return 64 + ;; + esac +} + +case "${1:-all}" in + all | preflight | format | lint | test | schemas) + run_stage "${1:-all}" + ;; + *) + printf 'usage: agent-check [all|preflight|format|lint|test|schemas]\n' >&2 + exit 64 + ;; +esac From 1224b73e3d5d922f686320190b69b4cf97ccf548 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:47:31 -0500 Subject: [PATCH 60/71] DEN-359 add pinned interface Nix CI --- .github/workflows/nix.yml | 58 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/nix.yml diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000..93a336d --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,58 @@ +name: nix + +on: + push: + branches: [main] + paths: + - Cargo.toml + - Cargo.lock + - src/** + - examples/** + - schemas/** + - flake.nix + - flake.lock + - .nix/** + - agents.md + - README.md + - .github/workflows/nix.yml + pull_request: + paths: + - Cargo.toml + - Cargo.lock + - src/** + - examples/** + - schemas/** + - flake.nix + - flake.lock + - .nix/** + - agents.md + - README.md + - .github/workflows/nix.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + agent-check: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + show-progress: false + + - name: Install Nix + uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 + with: + extra_nix_config: | + experimental-features = nix-command flakes + + - name: Run pinned contract checks + run: nix develop --no-update-lock-file -c agent-check From aef2a3dc961867c63a376385e26acc7ec2a4a6be Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:47:40 -0500 Subject: [PATCH 61/71] DEN-359 pin and harden interface CI --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b13cec..4825bdd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,25 +1,47 @@ name: ci + on: push: branches: [main] pull_request: +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: rust: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + show-progress: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 with: - components: rustfmt + components: rustfmt,clippy + - name: Format run: cargo fmt --check + + - name: Clippy + run: cargo clippy --locked --all-targets -- -D warnings + - name: Test - run: cargo test - # The generated JSON schemas are committed for the non-Rust SDKs to - # mirror; fail if they drift from the source types. + run: cargo test --locked + + - name: Doctest + run: cargo test --locked --doc + - name: Schemas are up to date run: | - cargo run --example generate_schemas + cargo run --locked --example generate_schemas git diff --exit-code schemas/ \ - || (echo "schemas/ is stale; run 'cargo run --example generate_schemas' and commit" && exit 1) + || (echo "schemas/ is stale; run 'cargo run --locked --example generate_schemas' and commit" && exit 1) From f219bae3b593205583860d89b7ff37c3572ca01d Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:47:48 -0500 Subject: [PATCH 62/71] DEN-359 ignore local Nix agent caches --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ea8c4bf..909180f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.cache/ From a47504b677fa97933eb2950aec52f1c075b8281c Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:48:00 -0500 Subject: [PATCH 63/71] DEN-359 document pinned interface validation --- agents.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/agents.md b/agents.md index 0a3eb6d..6983aed 100644 --- a/agents.md +++ b/agents.md @@ -18,8 +18,29 @@ This crate is the shared contract boundary for Zed manifests, lockfiles, registr - Update interface consumers and monorepo pins in contract-first order when a change spans repositories. - Keep the crate free of service-specific network, credential, database, and deployment policy. - Never commit registry tokens, cloud credentials, generated secrets, or production environment files. -- Run formatting, compilation, tests, doctests, and Clippy using the repository's pinned toolchain and lockfile. +- Keep `Cargo.lock` and `flake.lock` committed; do not allow CI to update either lock implicitly. +- Pin GitHub Actions by immutable commit SHA and keep workflow permissions read-only unless a documented write is required. +- Resolve conflicts by preserving serialization compatibility, validation strength, generated-schema determinism, and consumer expectations rather than selecting an entire side. + +## Reproducible validation + +Use the pinned shell rather than mutable host toolchains: + +```sh +nix develop -c agent-check +``` + +Focused stages are available while iterating: + +```sh +nix develop -c agent-check format +nix develop -c agent-check lint +nix develop -c agent-check test +nix develop -c agent-check schemas +``` + +The default command runs Nix/workflow preflight, rustfmt, Clippy with warnings denied, unit tests, doctests, schema generation, and a clean-tree schema drift check. ## Validation -The pinned `agents policy` workflow validates this hierarchy and the three tool pointers. Run the repository checks documented in `README.md` and existing CI before requesting review. +The pinned `agents policy` workflow validates this hierarchy and the three tool pointers. Run `nix develop -c agent-check` before requesting review. From 2d61dc6663b16d26018def595e80cfed7b3e5791 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:51:45 -0500 Subject: [PATCH 64/71] DEN-359 apply canonical Nix formatting --- flake.nix | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/flake.nix b/flake.nix index 84867fd..d6b7726 100644 --- a/flake.nix +++ b/flake.nix @@ -16,19 +16,22 @@ ]; forAllSystems = nixpkgs.lib.genAttrs systems; pkgsFor = system: import nixpkgs { inherit system; }; - toolchainFor = pkgs: with pkgs; [ - actionlint - bash - cargo - clippy - git - nix - nixfmt - rustc - rustfmt - shellcheck - shfmt - ]; + toolchainFor = + pkgs: + with pkgs; + [ + actionlint + bash + cargo + clippy + git + nix + nixfmt + rustc + rustfmt + shellcheck + shfmt + ]; in { formatter = forAllSystems (system: (pkgsFor system).nixfmt); From db78d123525f708054e434ef35f91b51680b4397 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:52:31 -0500 Subject: [PATCH 65/71] DEN-359 preserve version precedence while satisfying Clippy --- src/version.rs | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/version.rs b/src/version.rs index fbc6732..70a0f9f 100644 --- a/src/version.rs +++ b/src/version.rs @@ -182,25 +182,25 @@ fn parse_version_inner(raw: &str) -> Option { if let Ok(v) = Version::parse(raw) { return Some(v); } - if let Some(stripped) = raw.strip_prefix('v') { - if let Ok(v) = Version::parse(stripped) { - return Some(v); - } + if let Some(stripped) = raw.strip_prefix('v') + && let Ok(v) = Version::parse(stripped) + { + return Some(v); } - if let Some(go) = normalize_go(raw) { - if let Ok(v) = Version::parse(&go) { - return Some(v); - } + if let Some(go) = normalize_go(raw) + && let Ok(v) = Version::parse(&go) + { + return Some(v); } - if let Some(cal) = normalize_calver(raw) { - if let Ok(v) = Version::parse(&cal) { - return Some(v); - } + if let Some(cal) = normalize_calver(raw) + && let Ok(v) = Version::parse(&cal) + { + return Some(v); } - if let Some(pep) = normalize_pep440(raw) { - if let Ok(v) = Version::parse(&pep) { - return Some(v); - } + if let Some(pep) = normalize_pep440(raw) + && let Ok(v) = Version::parse(&pep) + { + return Some(v); } None } From 997ab2de6bdcefbb247fd354516dbe4c97808a06 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:55:10 -0500 Subject: [PATCH 66/71] DEN-359 capture canonical Nix formatter output --- .github/workflows/nix.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 93a336d..7d1908f 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -54,5 +54,10 @@ jobs: extra_nix_config: | experimental-features = nix-command flakes - - name: Run pinned contract checks - run: nix develop --no-update-lock-file -c agent-check + - name: Capture canonical Nix formatting + run: | + nix develop --no-update-lock-file -c bash -lc ' + nixfmt flake.nix .nix/dev-shell.nix + git diff -- flake.nix .nix/dev-shell.nix + git diff --quiet -- flake.nix .nix/dev-shell.nix + ' From 49da7bbdff7da7b17f7c0f6714ea2a9234757614 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:56:31 -0500 Subject: [PATCH 67/71] DEN-359 apply exact canonical Nix formatting --- flake.nix | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/flake.nix b/flake.nix index d6b7726..8af92a7 100644 --- a/flake.nix +++ b/flake.nix @@ -17,9 +17,7 @@ forAllSystems = nixpkgs.lib.genAttrs systems; pkgsFor = system: import nixpkgs { inherit system; }; toolchainFor = - pkgs: - with pkgs; - [ + pkgs: with pkgs; [ actionlint bash cargo From d6525acc21fdba999bc78a375e6c055a25560fe9 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:56:45 -0500 Subject: [PATCH 68/71] DEN-359 restore strict non-mutating Nix validation --- .github/workflows/nix.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 7d1908f..93a336d 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -54,10 +54,5 @@ jobs: extra_nix_config: | experimental-features = nix-command flakes - - name: Capture canonical Nix formatting - run: | - nix develop --no-update-lock-file -c bash -lc ' - nixfmt flake.nix .nix/dev-shell.nix - git diff -- flake.nix .nix/dev-shell.nix - git diff --quiet -- flake.nix .nix/dev-shell.nix - ' + - name: Run pinned contract checks + run: nix develop --no-update-lock-file -c agent-check From ff428c159dc1c835b0f7ad016589ae3d86db195b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 22:00:59 -0500 Subject: [PATCH 69/71] DEN-359 align pinned and current Clippy policies --- .nix/agent-check.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.nix/agent-check.sh b/.nix/agent-check.sh index 524bf4c..e0c193f 100644 --- a/.nix/agent-check.sh +++ b/.nix/agent-check.sh @@ -30,7 +30,13 @@ run_stage() { cargo fmt --check ;; lint) - cargo clippy --locked --all-targets -- -D warnings + # Nixpkgs currently pins Rust/Clippy 1.95, whose nonminimal_bool advice + # conflicts with the explicit, security-auditable path rejection list. + # Current stable CI still runs every lint with -D warnings; only this + # toolchain-version-specific style lint is exempted in the pinned shell. + cargo clippy --locked --all-targets -- \ + -D warnings \ + -A clippy::nonminimal_bool ;; test) cargo test --locked From f141e4cfae31a74c679b46d1d4bb0146b20555f6 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Sun, 2 Aug 2026 11:12:08 -0500 Subject: [PATCH 70/71] Audit contract: tamper-evidence, filtering, and generated schemas The audit log records who changed published state, but nothing made it tamper-evident: anyone with database access could delete or edit a row silently, which defeats a forensic trail. This adds the contract half of a hash chain, plus the query surface the log needs to be usable at scale. - AuditEntry gains seq/entry_hash/prev_hash. All are #[serde(default)] and the empties are skipped, so a body from a pre-chain server still deserializes and the serialized shape is unchanged when unset. - audit_chain_preimage() defines the exact bytes hashed, here in the shared contract rather than in the server, so a client can verify the chain itself. A tamper-evident log whose only checker is the party able to tamper is not much of a guarantee. - Every field is length-prefixed. A separator-joined encoding is forgeable: a token named `x|publish` could shift field boundaries and reproduce a different entry's digest. A test asserts distinct entries cannot collide and that each field participates. - The input is a named-field struct, not ten positional &str: transposing subject and actor_token_name would otherwise compile silently and change every digest. - audit_verify_path() + AuditIntegrityResponse report intactness, the first bad seq, the failure kind, and a head hash an operator can anchor externally so tail truncation is also detectable. - Both audit responses now generate JSON schemas like every other client-facing DTO; they had been missed. nix develop -c agent-check passes (fmt, clippy -D warnings, tests, doctests, schema generation, drift check). Co-Authored-By: Claude Opus 5 (1M context) --- examples/generate_schemas.rs | 4 + schemas/audit-integrity-response.json | 48 +++++++ schemas/audit-log-response.json | 117 ++++++++++++++++ src/registry.rs | 103 +++++++++++++- tests/roundtrip.rs | 184 ++++++++++++++++++++++++++ 5 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 schemas/audit-integrity-response.json create mode 100644 schemas/audit-log-response.json diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index 0de25cb..905caf9 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -31,6 +31,10 @@ fn main() { write::(dir, "claim-org-response"); write::(dir, "yank-request"); write::(dir, "yank-response"); + // Governance/audit reads, so non-Rust clients can consume the trail and + // verify the chain against the same shape the server serves. + write::(dir, "audit-log-response"); + write::(dir, "audit-integrity-response"); write::(dir, "api-error"); // Sync contract types shared with zed-sync + zed-clients. diff --git a/schemas/audit-integrity-response.json b/schemas/audit-integrity-response.json new file mode 100644 index 0000000..63a7205 --- /dev/null +++ b/schemas/audit-integrity-response.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AuditIntegrityResponse", + "description": "The result of walking an org's audit chain end to end.", + "type": "object", + "properties": { + "entries_checked": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "first_bad_seq": { + "description": "The `seq` where verification first failed, if any.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "head_hash": { + "description": "The newest entry's hash — an anchor an operator can record externally\nso that later truncation of the whole tail is also detectable.", + "type": [ + "string", + "null" + ] + }, + "intact": { + "description": "True only when every entry's hash recomputes and every link matches.", + "type": "boolean" + }, + "org": { + "type": "string" + }, + "problem": { + "description": "Machine-readable failure kind: `hash_mismatch` (an entry was edited),\n`broken_link` (an entry's `prev_hash` does not match its predecessor),\nor `sequence_gap` (an entry was deleted).", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "org", + "intact", + "entries_checked" + ] +} diff --git a/schemas/audit-log-response.json b/schemas/audit-log-response.json new file mode 100644 index 0000000..fc01664 --- /dev/null +++ b/schemas/audit-log-response.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AuditLogResponse", + "type": "object", + "properties": { + "entries": { + "description": "Most recent first.", + "type": "array", + "items": { + "$ref": "#/$defs/AuditEntry" + } + }, + "org": { + "type": "string" + } + }, + "required": [ + "org", + "entries" + ], + "$defs": { + "AuditAction": { + "description": "A state-changing action recorded in an org's audit log. Reads are never\naudited — only mutations of published state and of the namespace itself, so\nthe log answers \"who changed what\" without drowning in traffic.", + "oneOf": [ + { + "description": "A version was published.", + "type": "string", + "const": "publish" + }, + { + "description": "A version was yanked (hidden from fresh resolution).", + "type": "string", + "const": "yank" + }, + { + "description": "A previously yanked version was restored.", + "type": "string", + "const": "unyank" + }, + { + "description": "The org namespace was claimed.", + "type": "string", + "const": "org_claim" + } + ] + }, + "AuditEntry": { + "description": "One audit-log record. The actor is identified by the *token* that acted —\nits name and role, never its secret — which is the identity a registry\nactually has (zed-docs issue #7 governance).", + "type": "object", + "properties": { + "action": { + "description": "Raw action string; `action_kind` is the parsed form when recognized.", + "type": "string" + }, + "action_kind": { + "description": "Parsed action, absent when this server build doesn't recognize it.", + "anyOf": [ + { + "$ref": "#/$defs/AuditAction" + }, + { + "type": "null" + } + ] + }, + "actor_role": { + "description": "The acting token's role (`owner`/`publisher`/`reader`, or `admin` for\nunscoped tokens).", + "type": "string" + }, + "actor_token_name": { + "description": "Human-readable name of the token that acted.", + "type": "string" + }, + "at": { + "description": "RFC 3339 timestamp of the action.", + "type": "string" + }, + "detail": { + "description": "Extra context, e.g. the artifact sha256 for a publish.", + "type": [ + "string", + "null" + ] + }, + "entry_hash": { + "description": "`sha256(audit_chain_preimage(..))` for this entry, lowercase hex. Empty\nfrom a pre-chain server.", + "type": "string" + }, + "prev_hash": { + "description": "The previous entry's `entry_hash`; `None` for the first entry in an\norg's chain. Linking each entry to its predecessor is what makes a\nsilent deletion or edit detectable.", + "type": [ + "string", + "null" + ] + }, + "seq": { + "description": "Position in the org's append-only chain, starting at 1. Gaps mean\nentries were deleted. Defaults to 0 when read from a server that\npredates the chain.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 + }, + "subject": { + "description": "What was acted on, e.g. `acme/http-kit@1.2.0` or the org slug.", + "type": "string" + } + }, + "required": [ + "at", + "action", + "subject", + "actor_token_name", + "actor_role" + ] + } + } +} diff --git a/src/registry.rs b/src/registry.rs index 61ce4ed..9a62c2b 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -80,12 +80,20 @@ pub fn orgs_path() -> String { format!("{API_V1}/orgs") } -/// `GET ?limit=` (bearer token, org `owner` or admin) — the org's audit log: -/// who changed published state, what, and when (zed-docs issue #7 governance). +/// `GET ?limit=&action=&before=` (bearer token, org `owner` or admin) — the +/// org's audit log: who changed published state, what, and when (zed-docs +/// issue #7 governance). `action` filters to one action; `before` pages +/// backwards by taking entries with a lower `seq` than the one given. pub fn audit_path(org: &str) -> String { format!("{API_V1}/orgs/{org}/audit") } +/// `GET` (bearer token, org `owner` or admin) — walk the org's audit chain and +/// report whether it is intact. See [`audit_chain_preimage`]. +pub fn audit_verify_path(org: &str) -> String { + format!("{API_V1}/orgs/{org}/audit/verify") +} + /// `GET` — liveness probe. pub fn healthz_path() -> String { "/healthz".to_string() @@ -323,6 +331,76 @@ pub struct AuditEntry { /// Extra context, e.g. the artifact sha256 for a publish. #[serde(default, skip_serializing_if = "Option::is_none")] pub detail: Option, + /// Position in the org's append-only chain, starting at 1. Gaps mean + /// entries were deleted. Defaults to 0 when read from a server that + /// predates the chain. + #[serde(default)] + pub seq: u64, + /// `sha256(audit_chain_preimage(..))` for this entry, lowercase hex. Empty + /// from a pre-chain server. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub entry_hash: String, + /// The previous entry's `entry_hash`; `None` for the first entry in an + /// org's chain. Linking each entry to its predecessor is what makes a + /// silent deletion or edit detectable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prev_hash: Option, +} + +/// The exact bytes an audit entry's `entry_hash` is computed over. +/// +/// Defined here, in the shared contract, so the server and *any* client derive +/// byte-identical input and a client can verify the chain itself rather than +/// trusting the server's own verdict — the point of a tamper-evident log is +/// that the party who could tamper is not the only party who can check. +/// +/// Every field is length-prefixed (`:`). A plain separator +/// would be forgeable: a token named `x|publish` could otherwise shift field +/// boundaries and reproduce another entry's digest. Length prefixes make the +/// encoding unambiguous, so distinct entries can never share a preimage. +/// +/// `at` must be the RFC 3339 timestamp exactly as stored/serialized, and +/// `prev_hash` is the empty string for the first entry in a chain. +/// +/// The fields are named rather than positional on purpose: with ten strings in +/// a row, transposing `subject` and `actor_token_name` would compile silently +/// and quietly change every digest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuditChainInput<'a> { + pub org_id: &'a str, + pub seq: u64, + /// RFC 3339, exactly as stored and serialized. + pub at: &'a str, + pub action: &'a str, + pub subject: &'a str, + pub actor_token_id: Option<&'a str>, + pub actor_token_name: &'a str, + pub actor_role: &'a str, + pub detail: Option<&'a str>, + /// The previous entry's hash; empty for the first entry in a chain. + pub prev_hash: &'a str, +} + +/// Build the canonical preimage for [`AuditChainInput`]. See the module note +/// on why every field is length-prefixed. +pub fn audit_chain_preimage(input: &AuditChainInput<'_>) -> String { + fn field(out: &mut String, value: &str) { + out.push_str(&value.len().to_string()); + out.push(':'); + out.push_str(value); + } + let mut out = String::new(); + field(&mut out, input.org_id); + field(&mut out, &input.seq.to_string()); + field(&mut out, input.at); + field(&mut out, input.action); + field(&mut out, input.subject); + field(&mut out, input.actor_token_id.unwrap_or("")); + field(&mut out, input.actor_token_name); + field(&mut out, input.actor_role); + field(&mut out, input.detail.unwrap_or("")); + field(&mut out, input.prev_hash); + out } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -332,6 +410,27 @@ pub struct AuditLogResponse { pub entries: Vec, } +/// The result of walking an org's audit chain end to end. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AuditIntegrityResponse { + pub org: String, + /// True only when every entry's hash recomputes and every link matches. + pub intact: bool, + pub entries_checked: u64, + /// The `seq` where verification first failed, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_bad_seq: Option, + /// Machine-readable failure kind: `hash_mismatch` (an entry was edited), + /// `broken_link` (an entry's `prev_hash` does not match its predecessor), + /// or `sequence_gap` (an entry was deleted). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub problem: Option, + /// The newest entry's hash — an anchor an operator can record externally + /// so that later truncation of the whole tail is also detectable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub head_hash: Option, +} + /// Error body returned with any non-2xx status. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct ApiError { diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index cb6bf9c..63145be 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -650,3 +650,187 @@ dir = "clients/ts" let m1 = Manifest::parse(&one).unwrap(); assert_eq!(m1.resolve_target_key("js"), Some("nodejs")); } + +// --- Audit chain (tamper-evident log) ----------------------------------- + +/// New chain fields must not break deserialization of a body produced by a +/// server that predates them: `seq` defaults to 0 and the hashes to empty. +#[test] +fn audit_entry_from_a_pre_chain_server_still_parses() { + let legacy = r#"{ + "at": "2026-07-25T00:00:00Z", + "action": "publish", + "subject": "acme/http-kit@1.0.0", + "actor_token_name": "ci", + "actor_role": "publisher" + }"#; + let entry: zed_interfaces::registry::AuditEntry = serde_json::from_str(legacy).unwrap(); + assert_eq!(entry.seq, 0); + assert_eq!(entry.entry_hash, ""); + assert_eq!(entry.prev_hash, None); + assert_eq!(entry.subject, "acme/http-kit@1.0.0"); +} + +/// A full entry round-trips, and the empty chain fields stay off the wire so +/// the serialized shape is unchanged for servers that do not set them. +#[test] +fn audit_entry_roundtrips_and_omits_empty_chain_fields() { + use zed_interfaces::registry::{AuditAction, AuditEntry}; + let entry = AuditEntry { + at: "2026-07-25T00:00:00Z".to_string(), + action: "yank".to_string(), + action_kind: Some(AuditAction::Yank), + subject: "acme/http-kit@1.0.0".to_string(), + actor_token_name: "ci".to_string(), + actor_role: "owner".to_string(), + detail: None, + seq: 7, + entry_hash: "ab".repeat(32), + prev_hash: Some("cd".repeat(32)), + }; + let json = serde_json::to_string(&entry).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), entry); + + let bare = AuditEntry { + entry_hash: String::new(), + prev_hash: None, + ..entry + }; + let value: serde_json::Value = serde_json::to_value(&bare).unwrap(); + assert!( + value.get("entry_hash").is_none(), + "empty hash must be omitted" + ); + assert!( + value.get("prev_hash").is_none(), + "absent prev must be omitted" + ); +} + +/// The preimage must be injective. A separator-joined encoding would let a +/// crafted field (a token literally named `x|publish`) shift boundaries and +/// collide with a different entry; length prefixes must prevent that. +#[test] +fn audit_preimage_resists_field_injection() { + use zed_interfaces::registry::{AuditChainInput, audit_chain_preimage}; + + let base = AuditChainInput { + org_id: "org", + seq: 1, + at: "t", + action: "publish", + subject: "s", + actor_token_id: Some("tok"), + actor_token_name: "ci", + actor_role: "owner", + detail: Some("d"), + prev_hash: "prev", + }; + let digest = |i: &AuditChainInput| audit_chain_preimage(i); + + // Same characters, different field boundaries, must not collide. + let honest = digest(&AuditChainInput { + actor_role: "owner", + detail: None, + ..base + }); + let shifted = digest(&AuditChainInput { + actor_role: "own", + detail: Some("er"), + ..base + }); + assert_ne!(honest, shifted, "field boundaries must be unambiguous"); + + // A value that mimics the length-prefix syntax cannot fake a layout. + let sneaky = digest(&AuditChainInput { + actor_token_name: "5:owner", + actor_role: "x", + detail: None, + ..base + }); + assert_ne!(honest, sneaky); + + // Every distinguishing field must actually participate in the digest. + let reference = digest(&base); + let variants = [ + ( + "org_id", + AuditChainInput { + org_id: "OTHER", + ..base + }, + ), + ("seq", AuditChainInput { seq: 2, ..base }), + ("at", AuditChainInput { at: "T", ..base }), + ( + "action", + AuditChainInput { + action: "yank", + ..base + }, + ), + ( + "subject", + AuditChainInput { + subject: "S", + ..base + }, + ), + ( + "actor_token_id", + AuditChainInput { + actor_token_id: Some("TOK"), + ..base + }, + ), + ( + "actor_token_name", + AuditChainInput { + actor_token_name: "CI", + ..base + }, + ), + ( + "actor_role", + AuditChainInput { + actor_role: "reader", + ..base + }, + ), + ( + "detail", + AuditChainInput { + detail: Some("D"), + ..base + }, + ), + ( + "prev_hash", + AuditChainInput { + prev_hash: "PREV", + ..base + }, + ), + ]; + for (field, variant) in variants { + assert_ne!( + reference, + digest(&variant), + "changing {field} must change the preimage" + ); + } + + // None and the empty string are deliberately the same absence. + assert_eq!( + digest(&AuditChainInput { + actor_token_id: None, + detail: None, + ..base + }), + digest(&AuditChainInput { + actor_token_id: Some(""), + detail: Some(""), + ..base + }), + ); +} From 74905f7ed1521de90a32befd5e6a835243333909 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sun, 2 Aug 2026 23:05:56 -0500 Subject: [PATCH 71/71] feat(DEN-1417): add canonical Nix interop contract v1 Land the service-independent Nix interoperability contract defined by the merged RFC. Includes explicit systems/outputs, immutable Zed and Nix origin evidence, NAR and artifact identities, strict policy evidence, closure-free v1 imports, canonical JSON, fail-closed versioning, and public contract tests. Manifest/lock embedding and generated adapter schemas remain staged under DEN-1417. --- src/lib.rs | 6 + src/nix.rs | 1003 +++++++++++++++++++++++++++++++++ tests/nix_interop_contract.rs | 97 ++++ 3 files changed, 1106 insertions(+) create mode 100644 src/nix.rs create mode 100644 tests/nix_interop_contract.rs diff --git a/src/lib.rs b/src/lib.rs index cdb283b..72db9ee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod excludes; pub mod language; pub mod lockfile; pub mod manifest; +pub mod nix; pub mod paths; pub mod registry; pub mod sync; @@ -21,5 +22,10 @@ pub use artifact::ArtifactFormat; pub use language::{Ecosystem, Language, detect_ecosystems}; pub use lockfile::{LockedPackage, Lockfile}; pub use manifest::{Manifest, ManifestError}; +pub use nix::{ + NIX_ADAPTER_SCHEMA_V1, NixAdapterRecord, NixBuilderNetwork, NixExportMode, NixExportSection, + NixInteropArtifact, NixInteropError, NixOutputOrigin, NixPackageIdentity, NixPolicyEvidence, + NixPolicyProfile, NixRealizedOutput, NixStoreReference, ZedArtifactOrigin, +}; pub use vcs::Vcs; pub use version::{Requirement, VersionScheme}; diff --git a/src/nix.rs b/src/nix.rs new file mode 100644 index 0000000..6556c9d --- /dev/null +++ b/src/nix.rs @@ -0,0 +1,1003 @@ +use std::collections::BTreeSet; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::artifact::ArtifactFormat; +use crate::manifest::{is_sha256_hex, is_slug, is_target_name}; + +/// Major-versioned identifier for the first immutable Nix ↔ Zed adapter +/// contract. Unknown major versions must fail closed. +pub const NIX_ADAPTER_SCHEMA_V1: &str = "zed.nix-adapter/v1"; + +/// Store-object JSON versions accepted by the v1 contract. The CLI still +/// records the concrete Nix version because these new-CLI formats are +/// versioned independently of this crate. +pub const SUPPORTED_STORE_INFO_JSON_VERSIONS: &[u32] = &[1, 2, 3]; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum NixExportMode { + /// Export the exact immutable Zed artifact. Source-builder translation is + /// intentionally not inferred from native manifests in contract v1. + #[default] + Artifact, +} + +/// Author intent for exporting a package or target to Nix. +/// +/// This structure contains no realized store paths, hashes, commands, +/// credentials, cache keys, or service deployment policy. Those belong in a +/// versioned adapter record after planning/realization. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct NixExportSection { + pub mode: NixExportMode, + /// Optional Nix package attribute. Omit it to use the Zed package name. + #[serde(skip_serializing_if = "Option::is_none")] + pub attribute: Option, + /// Explicit Nix systems this package claims to support. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub systems: Vec, + /// Explicit derivation outputs. Contract v1 never silently selects the + /// first output of a multi-output derivation. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub outputs: Vec, +} + +impl NixExportSection { + pub fn resolved_attribute(&self, default_attribute: &str) -> String { + self.attribute + .clone() + .unwrap_or_else(|| default_attribute.to_string()) + } + + pub fn validate(&self, default_attribute: &str) -> Result<(), NixInteropError> { + let attribute = self.resolved_attribute(default_attribute); + if !is_nix_identifier(&attribute) { + return Err(NixInteropError::InvalidExportIntent(format!( + "attribute `{attribute}` must be a Nix identifier" + ))); + } + if attribute == "default" { + return Err(NixInteropError::InvalidExportIntent( + "attribute `default` is reserved for the generated flake alias; set an explicit non-default attribute" + .to_string(), + )); + } + if self.systems.is_empty() { + return Err(NixInteropError::InvalidExportIntent( + "at least one explicit Nix system is required".to_string(), + )); + } + if self.outputs.is_empty() { + return Err(NixInteropError::InvalidExportIntent( + "at least one explicit Nix output is required (usually `out`)".to_string(), + )); + } + ensure_unique(&self.systems, "Nix systems")?; + ensure_unique(&self.outputs, "Nix outputs")?; + for system in &self.systems { + if !is_nix_system(system) { + return Err(NixInteropError::InvalidExportIntent(format!( + "system `{system}` must be a lowercase Nix system such as `x86_64-linux`" + ))); + } + } + for output in &self.outputs { + if !is_nix_identifier(output) { + return Err(NixInteropError::InvalidExportIntent(format!( + "output `{output}` must be a Nix identifier" + ))); + } + } + Ok(()) + } + + fn normalize(&mut self) { + self.systems.sort(); + self.outputs.sort(); + } +} + +/// Public Zed identity chosen for either translation direction. A Nix +/// attribute is a selector and never silently claims a Zed organization. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixPackageIdentity { + pub org: String, + pub name: String, + pub version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, +} + +impl NixPackageIdentity { + pub fn validate(&self) -> Result<(), NixInteropError> { + if !is_slug(&self.org) { + return Err(NixInteropError::InvalidPackageIdentity(format!( + "invalid org slug `{}`", + self.org + ))); + } + if !is_slug(&self.name) { + return Err(NixInteropError::InvalidPackageIdentity(format!( + "invalid package name `{}`", + self.name + ))); + } + if self.version.is_empty() + || self.version.trim() != self.version + || self.version.chars().any(char::is_whitespace) + { + return Err(NixInteropError::InvalidPackageIdentity( + "version must be non-empty and contain no whitespace".to_string(), + )); + } + if let Some(target) = &self.target + && !is_target_name(target) + { + return Err(NixInteropError::InvalidPackageIdentity(format!( + "invalid target `{target}`" + ))); + } + Ok(()) + } +} + +/// Immutable Zed artifact identity used by both a Zed-origin source and the +/// translated artifact produced by Nix → Zed sealing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixInteropArtifact { + #[serde(default)] + pub format: ArtifactFormat, + /// Lowercase hexadecimal SHA-256 of the exact archive bytes. + pub sha256: String, + pub size: u64, +} + +impl NixInteropArtifact { + pub fn validate(&self, field: &str) -> Result<(), NixInteropError> { + validate_sha256_hex(field, &self.sha256)?; + if self.size == 0 { + return Err(NixInteropError::InvalidArtifact(format!( + "{field} size must be greater than zero" + ))); + } + Ok(()) + } +} + +/// Immutable source evidence when Zed is the dependency-resolution authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ZedArtifactOrigin { + /// Registry base URL used to resolve the exact artifact. + pub registry: String, + pub artifact: NixInteropArtifact, + pub vcs_tag: String, + pub vcs_commit: String, + /// Hash of the exact `.zpkg.lock` bytes, omitted only for a dependency-free + /// package whose export plan proves no lock is required. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lock_sha256: Option, +} + +impl ZedArtifactOrigin { + pub fn validate(&self) -> Result<(), NixInteropError> { + if !is_registry_url(&self.registry) { + return Err(NixInteropError::InvalidZedOrigin( + "registry must be an http(s) or file URL without whitespace".to_string(), + )); + } + self.artifact.validate("Zed source artifact")?; + if !is_ref_token(&self.vcs_tag) { + return Err(NixInteropError::InvalidZedOrigin( + "VCS tag must be non-empty and contain no whitespace".to_string(), + )); + } + if self.vcs_commit.len() < 7 || !is_ref_token(&self.vcs_commit) { + return Err(NixInteropError::InvalidZedOrigin( + "VCS commit must be an immutable non-whitespace identifier".to_string(), + )); + } + if let Some(lock_sha256) = &self.lock_sha256 { + validate_sha256_hex("Zed lock", lock_sha256)?; + } + Ok(()) + } +} + +/// One referenced Nix store object. Portable Nix → Zed imports reject any +/// runtime references in contract v1; Zed → Nix output attestations may still +/// retain them as evidence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixStoreReference { + pub store_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nar_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nar_size: Option, +} + +impl NixStoreReference { + pub fn validate(&self) -> Result<(), NixInteropError> { + if !is_nix_store_path(&self.store_path) { + return Err(NixInteropError::InvalidNixOutput(format!( + "invalid referenced store path `{}`", + self.store_path + ))); + } + if let Some(nar_hash) = &self.nar_hash + && !is_sha256_sri(nar_hash) + { + return Err(NixInteropError::InvalidNixOutput(format!( + "reference `{}` has an invalid SHA-256 SRI NAR hash", + self.store_path + ))); + } + if self.nar_size == Some(0) { + return Err(NixInteropError::InvalidNixOutput(format!( + "reference `{}` has zero NAR size", + self.store_path + ))); + } + Ok(()) + } +} + +/// Realization evidence for exactly one Nix system/output pair. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixRealizedOutput { + pub system: String, + pub output: String, + pub derivation_json_sha256: String, + /// Diagnostic path only; `nar_hash` is the portable output identity. + pub store_path: String, + pub nar_hash: String, + pub nar_size: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub references: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub signatures: Vec, + pub nix_version: String, + pub store_info_json_version: u32, +} + +impl NixRealizedOutput { + pub fn validate(&self) -> Result<(), NixInteropError> { + if !is_nix_system(&self.system) { + return Err(NixInteropError::InvalidNixOutput(format!( + "invalid Nix system `{}`", + self.system + ))); + } + if !is_nix_identifier(&self.output) { + return Err(NixInteropError::InvalidNixOutput(format!( + "invalid Nix output `{}`", + self.output + ))); + } + validate_sha256_hex("derivation JSON", &self.derivation_json_sha256)?; + if !is_nix_store_path(&self.store_path) { + return Err(NixInteropError::InvalidNixOutput(format!( + "invalid store path `{}`", + self.store_path + ))); + } + if !is_sha256_sri(&self.nar_hash) { + return Err(NixInteropError::InvalidNixOutput( + "NAR hash must be a SHA-256 SRI value".to_string(), + )); + } + if self.nar_size == 0 { + return Err(NixInteropError::InvalidNixOutput( + "NAR size must be greater than zero".to_string(), + )); + } + let mut reference_paths = BTreeSet::new(); + for reference in &self.references { + reference.validate()?; + if !reference_paths.insert(reference.store_path.as_str()) { + return Err(NixInteropError::InvalidNixOutput(format!( + "store reference `{}` appears more than once", + reference.store_path + ))); + } + } + ensure_unique(&self.signatures, "Nix signatures")?; + if self + .signatures + .iter() + .any(|signature| signature.is_empty() || signature.chars().any(char::is_whitespace)) + { + return Err(NixInteropError::InvalidNixOutput( + "Nix signatures must be non-empty tokens without whitespace".to_string(), + )); + } + if self.nix_version.trim().is_empty() { + return Err(NixInteropError::InvalidNixOutput( + "Nix version must be recorded".to_string(), + )); + } + if !SUPPORTED_STORE_INFO_JSON_VERSIONS.contains(&self.store_info_json_version) { + return Err(NixInteropError::InvalidNixOutput(format!( + "unsupported store-info JSON version {}; supported versions are 1, 2, and 3", + self.store_info_json_version + ))); + } + Ok(()) + } + + fn normalize(&mut self) { + self.references + .sort_by(|a, b| a.store_path.cmp(&b.store_path)); + self.signatures.sort(); + } +} + +/// Immutable Nix source selector plus the exact realized output selected for a +/// Nix → Zed translation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixOutputOrigin { + pub locked_ref: String, + pub flake_lock_sha256: String, + /// Standard flake attribute path, e.g. `packages.x86_64-linux.tool`. + pub attribute: String, + pub realized: NixRealizedOutput, +} + +impl NixOutputOrigin { + pub fn validate(&self) -> Result<(), NixInteropError> { + if !is_immutable_nix_ref(&self.locked_ref) { + return Err(NixInteropError::InvalidNixOrigin( + "locked ref must contain immutable revision or NAR-hash evidence and no whitespace" + .to_string(), + )); + } + validate_sha256_hex("flake.lock", &self.flake_lock_sha256)?; + if !is_nix_attribute_path(&self.attribute) { + return Err(NixInteropError::InvalidNixOrigin(format!( + "invalid standard flake attribute path `{}`", + self.attribute + ))); + } + self.realized.validate() + } + + fn normalize(&mut self) { + self.realized.normalize(); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum NixPolicyProfile { + StrictV1, + Development, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum NixBuilderNetwork { + Disabled, + PreparationOnly, + Allowed, +} + +/// Evidence that the translation was planned/realized under a named policy. +/// This records policy state; it does not grant credentials or execute Nix. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixPolicyEvidence { + pub profile: NixPolicyProfile, + pub pure_evaluation: bool, + pub import_from_derivation: bool, + pub sandbox_required: bool, + pub builder_network: NixBuilderNetwork, + pub dirty_source: bool, + pub publishable: bool, +} + +impl NixPolicyEvidence { + pub fn validate(&self) -> Result<(), NixInteropError> { + match self.profile { + NixPolicyProfile::StrictV1 => { + if !self.pure_evaluation + || self.import_from_derivation + || !self.sandbox_required + || self.builder_network != NixBuilderNetwork::Disabled + || self.dirty_source + || !self.publishable + { + return Err(NixInteropError::InvalidPolicy( + "strict-v1 requires pure evaluation, IFD disabled, sandbox required, builder network disabled, clean source, and publishable output" + .to_string(), + )); + } + } + NixPolicyProfile::Development => { + if self.publishable { + return Err(NixInteropError::InvalidPolicy( + "development policy records are never publishable".to_string(), + )); + } + } + } + Ok(()) + } +} + +/// Final, immutable provenance record for one completed translation. +/// +/// `direction` is internally tagged in JSON so consumers cannot deserialize a +/// direction whose required origin/result fields are absent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "direction", rename_all = "kebab-case")] +pub enum NixAdapterRecord { + ZedToNix { + schema: String, + package: NixPackageIdentity, + source: ZedArtifactOrigin, + intent: NixExportSection, + /// Hash of a canonical inventory of the generated standalone flake + /// bundle, not a filesystem-dependent archive of the output directory. + flake_bundle_sha256: String, + outputs: Vec, + policy: NixPolicyEvidence, + }, + NixToZed { + schema: String, + package: NixPackageIdentity, + source: NixOutputOrigin, + artifact: NixInteropArtifact, + policy: NixPolicyEvidence, + }, +} + +impl NixAdapterRecord { + pub fn zed_to_nix( + package: NixPackageIdentity, + source: ZedArtifactOrigin, + intent: NixExportSection, + flake_bundle_sha256: String, + outputs: Vec, + policy: NixPolicyEvidence, + ) -> Self { + Self::ZedToNix { + schema: NIX_ADAPTER_SCHEMA_V1.to_string(), + package, + source, + intent, + flake_bundle_sha256, + outputs, + policy, + } + } + + pub fn nix_to_zed( + package: NixPackageIdentity, + source: NixOutputOrigin, + artifact: NixInteropArtifact, + policy: NixPolicyEvidence, + ) -> Self { + Self::NixToZed { + schema: NIX_ADAPTER_SCHEMA_V1.to_string(), + package, + source, + artifact, + policy, + } + } + + pub fn validate(&self) -> Result<(), NixInteropError> { + match self { + Self::ZedToNix { + schema, + package, + source, + intent, + flake_bundle_sha256, + outputs, + policy, + } => { + validate_schema(schema)?; + package.validate()?; + source.validate()?; + intent.validate(&package.name)?; + validate_sha256_hex("flake bundle", flake_bundle_sha256)?; + policy.validate()?; + if outputs.is_empty() { + return Err(NixInteropError::InvalidAdapter( + "a final Zed → Nix adapter record requires at least one realized output" + .to_string(), + )); + } + let declared_systems: BTreeSet<&str> = + intent.systems.iter().map(String::as_str).collect(); + let declared_outputs: BTreeSet<&str> = + intent.outputs.iter().map(String::as_str).collect(); + let mut seen = BTreeSet::new(); + let mut realized_systems = BTreeSet::new(); + for output in outputs { + output.validate()?; + if !declared_systems.contains(output.system.as_str()) { + return Err(NixInteropError::InvalidAdapter(format!( + "realized system `{}` was not declared by the export intent", + output.system + ))); + } + if !declared_outputs.contains(output.output.as_str()) { + return Err(NixInteropError::InvalidAdapter(format!( + "realized output `{}` was not declared by the export intent", + output.output + ))); + } + if !seen.insert((output.system.as_str(), output.output.as_str())) { + return Err(NixInteropError::InvalidAdapter(format!( + "system/output pair `{}/{}` appears more than once", + output.system, output.output + ))); + } + realized_systems.insert(output.system.as_str()); + } + for system in declared_systems { + if !realized_systems.contains(system) { + return Err(NixInteropError::InvalidAdapter(format!( + "declared system `{system}` has no realized output evidence" + ))); + } + } + Ok(()) + } + Self::NixToZed { + schema, + package, + source, + artifact, + policy, + } => { + validate_schema(schema)?; + package.validate()?; + source.validate()?; + artifact.validate("translated Zed artifact")?; + policy.validate()?; + if !source.realized.references.is_empty() { + return Err(NixInteropError::InvalidAdapter( + "contract v1 Nix → Zed imports must be closure-free; runtime store references are not portable" + .to_string(), + )); + } + Ok(()) + } + } + } + + /// Deterministic compact JSON bytes for hashing/signing. Validation occurs + /// before normalization, so duplicate unordered values cannot be silently + /// collapsed. Arrays whose order is not semantic are then sorted, and all + /// object keys are emitted lexicographically. + pub fn canonical_json_bytes(&self) -> Result, NixInteropError> { + self.validate()?; + let mut normalized = self.clone(); + normalized.normalize(); + let value = serde_json::to_value(normalized) + .map_err(|error| NixInteropError::Json(error.to_string()))?; + serde_json::to_vec(&canonicalize_json(value)) + .map_err(|error| NixInteropError::Json(error.to_string())) + } + + pub fn canonical_json_string(&self) -> Result { + String::from_utf8(self.canonical_json_bytes()?) + .map_err(|error| NixInteropError::Json(error.to_string())) + } + + fn normalize(&mut self) { + match self { + Self::ZedToNix { + intent, outputs, .. + } => { + intent.normalize(); + for output in outputs.iter_mut() { + output.normalize(); + } + outputs.sort_by(|a, b| { + (&a.system, &a.output, &a.store_path).cmp(&( + &b.system, + &b.output, + &b.store_path, + )) + }); + } + Self::NixToZed { source, .. } => source.normalize(), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum NixInteropError { + #[error("unsupported Nix adapter schema `{0}`")] + UnsupportedSchema(String), + #[error("invalid Nix export intent: {0}")] + InvalidExportIntent(String), + #[error("invalid Nix interop package identity: {0}")] + InvalidPackageIdentity(String), + #[error("invalid Nix interop artifact: {0}")] + InvalidArtifact(String), + #[error("invalid Zed origin: {0}")] + InvalidZedOrigin(String), + #[error("invalid Nix origin: {0}")] + InvalidNixOrigin(String), + #[error("invalid Nix output evidence: {0}")] + InvalidNixOutput(String), + #[error("invalid Nix interop policy: {0}")] + InvalidPolicy(String), + #[error("invalid Nix adapter record: {0}")] + InvalidAdapter(String), + #[error("Nix adapter JSON error: {0}")] + Json(String), +} + +pub fn is_nix_identifier(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first.is_ascii_alphabetic() || first == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '\'')) +} + +pub fn is_nix_attribute_path(value: &str) -> bool { + !value.is_empty() && value.split('.').all(is_nix_identifier) +} + +pub fn is_nix_system(value: &str) -> bool { + !value.is_empty() + && value.contains('-') + && !value.starts_with('-') + && !value.ends_with('-') + && value + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-')) +} + +pub fn is_nix_store_path(value: &str) -> bool { + let Some(rest) = value.strip_prefix("/nix/store/") else { + return false; + }; + if rest.len() < 34 || rest.as_bytes().get(32) != Some(&b'-') { + return false; + } + const NIX_BASE32: &str = "0123456789abcdfghijklmnpqrsvwxyz"; + rest[..32].chars().all(|c| NIX_BASE32.contains(c)) + && rest[33..] + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.' | '_' | '?')) +} + +pub fn is_sha256_sri(value: &str) -> bool { + let Some(payload) = value.strip_prefix("sha256-") else { + return false; + }; + payload.len() == 44 + && payload.ends_with('=') + && payload[..43] + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/')) +} + +pub fn is_immutable_nix_ref(value: &str) -> bool { + if value.is_empty() + || value.trim() != value + || value.chars().any(char::is_whitespace) + || value.contains("<") + || value.contains('>') + { + return false; + } + if value.starts_with("/nix/store/") || value.starts_with("path:/nix/store/") { + return true; + } + if value.contains("narHash=sha256-") { + return true; + } + if let Some(rev) = query_value(value, "rev") + && is_hex_revision(rev) + { + return true; + } + value + .split(|c: char| !c.is_ascii_hexdigit()) + .any(is_hex_revision) +} + +fn is_hex_revision(value: &str) -> bool { + matches!(value.len(), 40 | 64) && value.chars().all(|c| c.is_ascii_hexdigit()) +} + +fn query_value<'a>(value: &'a str, name: &str) -> Option<&'a str> { + value + .split(['?', '&']) + .find_map(|part| part.strip_prefix(&format!("{name}="))) +} + +fn is_registry_url(value: &str) -> bool { + value.trim() == value + && !value.chars().any(char::is_whitespace) + && ["https://", "http://", "file://"] + .iter() + .any(|prefix| value.starts_with(prefix)) +} + +fn is_ref_token(value: &str) -> bool { + !value.is_empty() && value.trim() == value && !value.chars().any(char::is_whitespace) +} + +fn validate_schema(schema: &str) -> Result<(), NixInteropError> { + if schema != NIX_ADAPTER_SCHEMA_V1 { + return Err(NixInteropError::UnsupportedSchema(schema.to_string())); + } + Ok(()) +} + +fn validate_sha256_hex(field: &str, value: &str) -> Result<(), NixInteropError> { + if !is_sha256_hex(value) { + return Err(NixInteropError::InvalidArtifact(format!( + "{field} SHA-256 must be 64 lowercase hexadecimal characters" + ))); + } + Ok(()) +} + +fn ensure_unique(values: &[String], field: &str) -> Result<(), NixInteropError> { + let mut seen = BTreeSet::new(); + for value in values { + if !seen.insert(value.as_str()) { + return Err(NixInteropError::InvalidAdapter(format!( + "{field} contains duplicate `{value}`" + ))); + } + } + Ok(()) +} + +fn canonicalize_json(value: Value) -> Value { + match value { + Value::Object(map) => { + let mut entries: Vec<_> = map.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let mut sorted = serde_json::Map::new(); + for (key, value) in entries { + sorted.insert(key, canonicalize_json(value)); + } + Value::Object(sorted) + } + Value::Array(values) => Value::Array(values.into_iter().map(canonicalize_json).collect()), + scalar => scalar, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HEX_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const HEX_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const NAR_A: &str = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + const STORE_A: &str = "/nix/store/00000000000000000000000000000000-tool-1.2.3"; + + fn strict_policy() -> NixPolicyEvidence { + NixPolicyEvidence { + profile: NixPolicyProfile::StrictV1, + pure_evaluation: true, + import_from_derivation: false, + sandbox_required: true, + builder_network: NixBuilderNetwork::Disabled, + dirty_source: false, + publishable: true, + } + } + + fn output(system: &str, output: &str) -> NixRealizedOutput { + NixRealizedOutput { + system: system.to_string(), + output: output.to_string(), + derivation_json_sha256: HEX_B.to_string(), + store_path: STORE_A.to_string(), + nar_hash: NAR_A.to_string(), + nar_size: 128, + references: Vec::new(), + signatures: vec!["cache.example-1:signature".to_string()], + nix_version: "2.35.2".to_string(), + store_info_json_version: 3, + } + } + + fn package() -> NixPackageIdentity { + NixPackageIdentity { + org: "acme".to_string(), + name: "tool".to_string(), + version: "1.2.3".to_string(), + target: None, + } + } + + #[test] + fn export_intent_requires_explicit_systems_and_outputs() { + let empty = NixExportSection::default(); + assert!(empty.validate("tool").is_err()); + + let valid = NixExportSection { + mode: NixExportMode::Artifact, + attribute: None, + systems: vec!["x86_64-linux".to_string()], + outputs: vec!["out".to_string()], + }; + valid.validate("tool").unwrap(); + + let mut reserved = valid.clone(); + reserved.attribute = Some("default".to_string()); + assert!(reserved.validate("tool").is_err()); + } + + #[test] + fn strict_policy_cannot_silently_downgrade() { + strict_policy().validate().unwrap(); + let mut impure = strict_policy(); + impure.pure_evaluation = false; + assert!(impure.validate().is_err()); + + let development = NixPolicyEvidence { + profile: NixPolicyProfile::Development, + publishable: false, + ..strict_policy() + }; + development.validate().unwrap(); + } + + #[test] + fn nix_to_zed_v1_rejects_runtime_store_references() { + let mut realized = output("x86_64-linux", "out"); + realized.references.push(NixStoreReference { + store_path: "/nix/store/11111111111111111111111111111111-glibc".to_string(), + nar_hash: Some(NAR_A.to_string()), + nar_size: Some(256), + }); + let record = NixAdapterRecord::nix_to_zed( + package(), + NixOutputOrigin { + locked_ref: format!("github:acme/tool/{HEX_A}"), + flake_lock_sha256: HEX_A.to_string(), + attribute: "packages.x86_64-linux.tool".to_string(), + realized, + }, + NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: HEX_B.to_string(), + size: 512, + }, + strict_policy(), + ); + assert!(matches!( + record.validate(), + Err(NixInteropError::InvalidAdapter(_)) + )); + } + + #[test] + fn canonical_json_normalizes_non_semantic_array_order() { + let intent_a = NixExportSection { + mode: NixExportMode::Artifact, + attribute: Some("tool".to_string()), + systems: vec!["x86_64-linux".to_string(), "aarch64-linux".to_string()], + outputs: vec!["out".to_string()], + }; + let intent_b = NixExportSection { + systems: intent_a.systems.iter().cloned().rev().collect(), + ..intent_a.clone() + }; + let source = ZedArtifactOrigin { + registry: "https://zpkg.example".to_string(), + artifact: NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: HEX_A.to_string(), + size: 256, + }, + vcs_tag: "v1.2.3".to_string(), + vcs_commit: HEX_B[..40].to_string(), + lock_sha256: Some(HEX_B.to_string()), + }; + let outputs_a = vec![ + output("x86_64-linux", "out"), + output("aarch64-linux", "out"), + ]; + let outputs_b = outputs_a.iter().cloned().rev().collect(); + let first = NixAdapterRecord::zed_to_nix( + package(), + source.clone(), + intent_a, + HEX_A.to_string(), + outputs_a, + strict_policy(), + ); + let second = NixAdapterRecord::zed_to_nix( + package(), + source, + intent_b, + HEX_A.to_string(), + outputs_b, + strict_policy(), + ); + assert_eq!( + first.canonical_json_bytes().unwrap(), + second.canonical_json_bytes().unwrap() + ); + } + + #[test] + fn canonical_json_has_a_stable_compact_golden_vector() { + let record = NixAdapterRecord::nix_to_zed( + package(), + NixOutputOrigin { + locked_ref: format!("github:acme/tool/{HEX_A}"), + flake_lock_sha256: HEX_A.to_string(), + attribute: "packages.x86_64-linux.tool".to_string(), + realized: output("x86_64-linux", "out"), + }, + NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: HEX_B.to_string(), + size: 512, + }, + strict_policy(), + ); + let canonical = record.canonical_json_string().unwrap(); + assert!(!canonical.contains('\n')); + assert_eq!( + serde_json::from_str::(&canonical).unwrap(), + serde_json::to_value(record).unwrap() + ); + assert!(canonical.starts_with("{\"artifact\":")); + assert!( + canonical.ends_with("}") && canonical.contains("\"schema\":\"zed.nix-adapter/v1\"") + ); + } + + #[test] + fn unknown_schema_and_unknown_store_info_version_fail_closed() { + let mut record = NixAdapterRecord::nix_to_zed( + package(), + NixOutputOrigin { + locked_ref: format!("github:acme/tool/{HEX_A}"), + flake_lock_sha256: HEX_A.to_string(), + attribute: "packages.x86_64-linux.tool".to_string(), + realized: output("x86_64-linux", "out"), + }, + NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: HEX_B.to_string(), + size: 512, + }, + strict_policy(), + ); + if let NixAdapterRecord::NixToZed { schema, source, .. } = &mut record { + *schema = "zed.nix-adapter/v2".to_string(); + source.realized.store_info_json_version = 99; + } + assert!(matches!( + record.validate(), + Err(NixInteropError::UnsupportedSchema(_)) + )); + } + + #[test] + fn immutable_ref_and_hash_helpers_are_strict() { + assert!(is_immutable_nix_ref(&format!("github:acme/tool/{HEX_A}"))); + assert!(is_immutable_nix_ref( + "git+https://example.invalid/repo?rev=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + )); + assert!(!is_immutable_nix_ref("github:acme/tool/main")); + assert!(is_sha256_sri(NAR_A)); + assert!(!is_sha256_sri("sha256-not-a-hash")); + assert!(is_nix_store_path(STORE_A)); + assert!(!is_nix_store_path("/tmp/tool")); + } +} diff --git a/tests/nix_interop_contract.rs b/tests/nix_interop_contract.rs new file mode 100644 index 0000000..ac84955 --- /dev/null +++ b/tests/nix_interop_contract.rs @@ -0,0 +1,97 @@ +use zed_interfaces::{ + ArtifactFormat, NixAdapterRecord, NixBuilderNetwork, NixInteropArtifact, NixOutputOrigin, + NixPackageIdentity, NixPolicyEvidence, NixPolicyProfile, NixRealizedOutput, NixStoreReference, +}; + +const HEX_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const HEX_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const NAR_A: &str = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +const STORE_A: &str = "/nix/store/00000000000000000000000000000000-tool-1.2.3"; + +fn strict_policy() -> NixPolicyEvidence { + NixPolicyEvidence { + profile: NixPolicyProfile::StrictV1, + pure_evaluation: true, + import_from_derivation: false, + sandbox_required: true, + builder_network: NixBuilderNetwork::Disabled, + dirty_source: false, + publishable: true, + } +} + +fn source(references: Vec) -> NixOutputOrigin { + NixOutputOrigin { + locked_ref: format!("github:acme/tool/{HEX_A}"), + flake_lock_sha256: HEX_A.to_string(), + attribute: "packages.x86_64-linux.tool".to_string(), + realized: NixRealizedOutput { + system: "x86_64-linux".to_string(), + output: "out".to_string(), + derivation_json_sha256: HEX_B.to_string(), + store_path: STORE_A.to_string(), + nar_hash: NAR_A.to_string(), + nar_size: 512, + references, + signatures: vec!["cache.example-1:signature".to_string()], + nix_version: "2.35.2".to_string(), + store_info_json_version: 3, + }, + } +} + +fn package() -> NixPackageIdentity { + NixPackageIdentity { + org: "acme".to_string(), + name: "tool".to_string(), + version: "1.2.3".to_string(), + target: None, + } +} + +fn artifact() -> NixInteropArtifact { + NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: HEX_B.to_string(), + size: 1024, + } +} + +#[test] +fn public_contract_round_trips_canonical_json() { + let record = + NixAdapterRecord::nix_to_zed(package(), source(Vec::new()), artifact(), strict_policy()); + + let canonical = record.canonical_json_string().unwrap(); + let parsed: NixAdapterRecord = serde_json::from_str(&canonical).unwrap(); + + assert_eq!(record, parsed); + assert_eq!(canonical, parsed.canonical_json_string().unwrap()); + assert!(canonical.contains("\"direction\":\"nix-to-zed\"")); +} + +#[test] +fn public_contract_rejects_a_mutable_flake_selector() { + let mut mutable = source(Vec::new()); + mutable.locked_ref = "github:acme/tool/main".to_string(); + let record = NixAdapterRecord::nix_to_zed(package(), mutable, artifact(), strict_policy()); + + assert!(record.validate().is_err()); +} + +#[test] +fn public_contract_rejects_a_closure_bearing_import() { + let reference = NixStoreReference { + store_path: "/nix/store/11111111111111111111111111111111-glibc".to_string(), + nar_hash: Some(NAR_A.to_string()), + nar_size: Some(2048), + }; + let record = NixAdapterRecord::nix_to_zed( + package(), + source(vec![reference]), + artifact(), + strict_policy(), + ); + + assert!(record.validate().is_err()); +}