diff --git a/.github/workflows/versioned-circular-dependencies.yml b/.github/workflows/versioned-circular-dependencies.yml new file mode 100644 index 00000000..c555e0a0 --- /dev/null +++ b/.github/workflows/versioned-circular-dependencies.yml @@ -0,0 +1,63 @@ +name: versioned circular dependencies + +on: + pull_request: + paths: + - Cargo.toml + - Cargo.lock + - src/graph_export.rs + - src/versioned_graph.rs + - tests/versioned_dependency_cycles.rs + - docs/versioned-circular-dependencies.md + - .github/workflows/versioned-circular-dependencies.yml + push: + branches: [main] + paths: + - Cargo.toml + - Cargo.lock + - src/graph_export.rs + - src/versioned_graph.rs + - tests/versioned_dependency_cycles.rs + - docs/versioned-circular-dependencies.md + - .github/workflows/versioned-circular-dependencies.yml + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + exact-graph: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + - name: Exact-node detection, ORE logging, and platform safety + run: cargo test --locked --lib versioned_graph -- --nocapture + - name: Public graph command parsing + run: cargo test --locked --lib graph_export::tests::materialize_requires_exactly_one_graph_input + - name: Finite symlink graph through the public executable + if: runner.os != 'Windows' + run: cargo test --locked --test versioned_dependency_cycles -- --nocapture + - name: Lint exact graph implementation + if: runner.os == 'Linux' + # These three repository-wide lints were reproduced on the PR base in + # the pre-PR validation run. Every other warning remains denied. + run: | + cargo clippy --locked --all-targets -- \ + -D warnings \ + -A clippy::large-enum-variant \ + -A clippy::too-many-arguments \ + -A clippy::type-complexity diff --git a/Cargo.lock b/Cargo.lock index 3e61a382..863c6275 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,9 +139,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.4.3" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "shlex", @@ -161,12 +161,12 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -210,7 +210,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -242,18 +242,18 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -305,6 +305,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -344,7 +350,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -418,9 +424,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "flags2env" @@ -435,9 +441,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", "miniz_oxide", @@ -621,9 +627,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -748,9 +754,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -784,9 +790,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", "hashbrown", @@ -839,9 +845,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.20" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ "libc", ] @@ -860,9 +866,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru-slab" @@ -894,9 +900,9 @@ dependencies = [ [[package]] name = "miniz_oxide" -version = "0.8.9" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -904,15 +910,21 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "once_cell" version = "1.21.4" @@ -931,6 +943,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "oresoftware-next-loggers" +version = "0.1.0" +source = "git+https://github.com/ores-otel/ores.otel.log.git?rev=9e28eca35b4c3f283cde933ae645216972d2f3e3#9e28eca35b4c3f283cde933ae645216972d2f3e3" +dependencies = [ + "serde", + "serde_json", + "time", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -962,6 +984,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -993,9 +1021,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "bytes", "getrandom 0.4.3", @@ -1090,22 +1118,22 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1193,12 +1221,12 @@ dependencies = [ [[package]] name = "rtoolbox" -version = "0.0.5" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1255,9 +1283,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -1307,7 +1335,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1347,7 +1375,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1358,7 +1386,7 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1435,9 +1463,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "socket2" @@ -1490,9 +1518,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -1580,7 +1608,37 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", ] [[package]] @@ -1595,9 +1653,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ "tinyvec_macros", ] @@ -1624,9 +1682,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -1634,9 +1692,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" dependencies = [ "indexmap", "serde_core", @@ -1797,9 +1855,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -1977,15 +2035,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2175,6 +2224,7 @@ dependencies = [ "fs2", "globset", "hex", + "oresoftware-next-loggers", "rand_core 0.6.4", "reqwest", "rpassword", @@ -2257,9 +2307,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -2268,13 +2318,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9019c132..4a153f15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,9 @@ path = "src/lib.rs" zed-interfaces = { git = "https://github.com/zed-pkg/zed-interfaces.git", rev = "0c51d732cb01a377b2bc00e8d945b355e41961c1" } # Includes the Windows ERROR_LOCK_VIOLATION contention normalization certified by DEN-3167. zed-lock = { git = "https://github.com/zed-pkg/zed-lock.git", rev = "1db0da00d30fcf2e0762f50eedb1f88458020b52" } +# ORE structured cycle diagnostics. This exact logger revision was certified by +# the finite version-qualified graph implementation before the v0.3.0 merge. +next_loggers = { git = "https://github.com/ores-otel/ores.otel.log.git", rev = "9e28eca35b4c3f283cde933ae645216972d2f3e3", package = "oresoftware-next-loggers" } anyhow = "1" clap = { version = "4.5", features = ["derive", "env"] } clap_complete = "4.5" diff --git a/docs/den-3488-v0.3.0-integration.md b/docs/den-3488-v0.3.0-integration.md new file mode 100644 index 00000000..cdc0abb8 --- /dev/null +++ b/docs/den-3488-v0.3.0-integration.md @@ -0,0 +1,39 @@ +# DEN-3488 v0.3.0 integration receipt + +## Immutable composition + +The finite exact-version circular-dependency implementation has been reconciled with released `zed-cli` v0.3.0 through a true two-parent merge. + +- v0.3.0 main: `b38a1edd2ca7852bc81ad2e01e2989f44009e93e` +- pre-integration DEN-3488 head: `1aa85862e897a79b084b2a1be908e6b7704f7664` +- semantic merge: `44b926349cb8a45d8a8867af69dd601c50f3ce54` +- regenerated-lock head: `c218781a5fd3d92c75d6bdad6773e8e13bb4f929` + +GitHub compare reports the regenerated-lock head ahead-only on v0.3.0 main. The effective product delta is limited to the exact graph implementation, public graph command, focused workflow/tests, documentation, the immutable ORES logger dependency, and its regenerated lock graph. + +## Semantic conflict decisions + +- Preserve v0.3.0 package version, current `zed-interfaces`, `zed-lock`, `flags-2-env`, CLI, mirror, publication, and release behavior. +- Add `next_loggers` only for structured, terminal-visible circular-dependency diagnostics. +- Keep the current install graph's registry ownership; an obsolete test-only import exposed by all-target compilation was removed rather than suppressing the warning. +- Keep ordinary `.zpkg.lock` v1 one-version-per-coordinate semantics. The new materializer consumes an explicit exact resolved graph; automatic solver fallback remains a separate contract migration. +- Preserve the already-merged, self-hosted Claritas graph visualization in `zed-web-server.rs`; this CLI change does not add a duplicate browser runtime. + +## Exact validation + +Bounded workflow run `33909111236` completed successfully before publishing the regenerated lockfile and deleting itself. It proved: + +- `cargo generate-lockfile` resolved `zed-cli` 0.3.0; +- the lock includes `oresoftware-next-loggers` at exact revision `9e28eca35b4c3f283cde933ae645216972d2f3e3`; +- `cargo fmt --all -- --check` passed; +- `cargo check --locked --all-targets` passed; +- the complete `versioned_graph` unit suite passed; +- the public graph-materialization parser regression passed; +- the executable finite-cycle integration suite passed; +- strict all-target Clippy passed with only the three narrowly documented current-main lint exceptions used by the original certified DEN-3488 workflow. + +The one-shot workflow is absent from the review tree. This receipt commit intentionally triggers the permanent repository pull-request workflows on the exact current product head. + +## Promotion boundary + +Do not merge based solely on this receipt. Every permanent workflow on the exact current PR head must be terminal and green, independent review must be complete, and companion `zed-pkg-test` acceptance must pin the resulting immutable product revision. Pending, skipped, canceled, older-head, or zero-job runs are not passing evidence. diff --git a/docs/versioned-circular-dependencies.md b/docs/versioned-circular-dependencies.md new file mode 100644 index 00000000..ca59a509 --- /dev/null +++ b/docs/versioned-circular-dependencies.md @@ -0,0 +1,258 @@ +# Exact-version circular dependencies + +This document defines how Zed represents and materializes dependency graphs that +contain both circular edges and more than one exact version of the same package. +It complements the ordinary constraint solver, which intentionally selects one +version per `org/name` when all requirements can share a version. + +The implementation lives in `src/versioned_graph.rs` and is exposed through +`zed graph materialize`. + +## Canonical example + +```text +A@1 -> B@1 +B@1 -> A@2 +A@2 -> B@0 +B@0 -> A@2 +``` + +The resolved graph contains exactly four nodes and four edges. The final edge is +not expanded into another copy of `A@2`. It closes the cycle by pointing to the +existing exact `A@2` node. + +`A@1` and `A@2` are different nodes. Seeing the package name `A` twice is not a +cycle by itself. + +## Exact node identity + +A materialized node is identified by all of the following: + +1. registry identity; +2. organization and package name; +3. exact version; +4. immutable artifact digest. + +The public dependency-graph contract stores the first three fields in +`PackageVersionIdentity` and stores the digest on `ResolvedDependencyNode`. +Zed joins them before cycle reporting or filesystem materialization. + +A graph that omits an artifact digest is rejected. Digests must use canonical +`sha256:<64 lowercase hexadecimal characters>` spelling and cannot be all zero. + +## What is authoritative + +The exact resolved graph is authoritative. Directory shape is not. + +A production materialization accepts a byte-canonical graph whose +`graph_digest` verifies. Roots, nodes, edges, artifact digests, target, enabled +features, registry checkpoints, and lock digest therefore remain explicit and +replayable. A filtered graph projection is rejected because it is not complete +lock authority. + +The local fixture-plan entry point may finalize a graph with no digest. It is +for tests and development fixtures; normal users should supply a verified graph +and let Zed bind its nodes to the content-addressed store. + +## Detection algorithm + +Zed builds a deterministic adjacency map keyed by exact package-version +identity. It then performs an iterative depth-first traversal. + +Each node has one of three states: + +- unseen; +- visiting, with its index in the active path; +- complete. + +An edge to an unseen node descends into that node. An edge to a complete node is +ordinary graph sharing, such as the bottom point of a diamond, and is not a +cycle. An edge to a visiting exact node is a cycle-closing back edge. + +For a back edge, Zed: + +1. records the closed path from the earlier active node through the current + node and back to the target; +2. rotates the path into a canonical deterministic spelling; +3. adds the edge to the materialized graph; +4. does not recursively expand that edge again. + +The traversal uses an explicit frame stack rather than Rust call-stack +recursion. A deep hostile graph therefore cannot cause a recursive stack +overflow. Graph materialization is bounded to 50,000 nodes and 500,000 edges. +Cycle diagnostics are bounded to 4,096 deterministic witnesses so a hostile +strongly connected graph cannot flood the terminal or telemetry pipeline. + +The detector reports deterministic DFS cycle witnesses rather than attempting +to enumerate every mathematically distinct simple cycle. Enumerating every +simple cycle can be exponential. Every cyclic strongly connected region still +produces a witness. + +## Finite symlink layout + +Package payloads remain extracted once in the global content-addressed store. +A project receives a finite overlay: + +```text +project/ + zed_modules/ + / -> exact root node + .zed/versioned-graph/v1/graphs/ + / + / + graph.json + cycles.json + .complete.json + nodes/ + / + node.json + root/ + -> immutable store payload entries + zed_modules/ + / -> exact target node root +``` + +There is one overlay root per exact node and one dependency link per exact edge. +The `B@0 -> A@2` back edge is therefore one symlink to the already-created +`A@2` overlay root. It does not create `A@2/B@0/A@2/...` copies. + +The stable node key includes a readable `org+name@version` prefix plus a hash of +the complete exact identity. Human-readable names remain useful while registry +identity and artifact digest still prevent collisions. + +### Graph digest versus source-binding digest + +`graph_digest` identifies semantic lock authority. It does not include local +filesystem paths. + +The source-binding digest identifies one physical projection of that graph. It +hashes the graph digest plus the canonical local source path and artifact digest +for every node. Moving the store creates another finite generation without +changing graph identity. Repeating a materialization with the same graph and +sources reuses and verifies the existing generation. + +## Atomicity and validation + +Zed stages a complete generation next to its final location, writes canonical +graph and cycle records, writes a completion marker, and atomically renames the +staging directory. Concurrent processes may build the same generation; the +winner publishes it and the loser verifies and reuses it. + +Before an existing generation is reused, Zed verifies: + +- graph and source-binding digests; +- exact canonical graph bytes; +- deterministic cycle records; +- one real node directory per exact node; +- exact node metadata; +- every payload entry is a symlink to the expected immutable source; +- every dependency edge is a symlink to the expected exact target. + +The project-level `zed_modules` root is replaced through a sibling transaction +backup. A failed publication restores the previous root. + +Payload sources and the project must be disjoint. A source containing a +`zed_modules` entry is rejected so a previously materialized dependency tree +cannot be mistaken for immutable package payload. + +## Copy mode and Windows + +A circular graph cannot be naively copied recursively: that recreates the same +subtree forever. A parallel-version graph also cannot be flattened without +losing parent-specific selection. + +The exact-graph command therefore fails closed in copy mode. It never silently +falls back to recursive copying. The ordinary flat installer remains available +for acyclic graphs that select one version per package coordinate. + +On Unix, exact graphs use directory symlinks. On platforms where directory +symlinks are unavailable, exact-graph symlink materialization fails with an +explicit diagnostic. A future Windows implementation must use a reviewed finite +junction/symlink overlay with the same graph and safety invariants; copy fallback +is not acceptable for cycles. + +## Terminal and ORE logging + +Every deterministic cycle witness emits one warning through the Rust SDK from +`github.com/ores-otel/ores.otel.log`. Console output is enabled at the CLI +boundary, so the user sees the exact path and closing edge. + +Structured fields include: + +```text +event = zed.install.circular_dependency +cycle_id = sha256: +path = [exact node labels including version and artifact digest] +closing_from = +closing_to = +target_node_key = +target_artifact_digest = sha256: +resolution = exact-node-symlink +graph_digest = sha256: +materialization_digest = sha256: +``` + +A representative terminal message is: + +```text +circular dependency detected: registry::org/a@2#... -> registry::org/b@0#... -> registry::org/a@2#...; closing edge ... reuses the existing exact node through a symlink +``` + +The warning is evidence of deliberate cycle handling, not a failure by itself. +An incompatible or malformed graph still fails validation. + +## CLI + +Materialize a canonical graph from the configured Zed store: + +```sh +zed --home "$ZED_PKG_HOME" graph materialize \ + --graph exact-resolution.json \ + --project . \ + --mode symlink +``` + +Every artifact digest named by the graph must already exist in the store. Zed +reports the exact missing node and store path instead of fetching or guessing. + +Use an explicit local source plan for an isolated fixture: + +```sh +zed graph materialize \ + --plan tests/fixtures/versioned-cycle/plan.json \ + --project "$TMPDIR/cycle-project" +``` + +The command prints ORE warnings as cycles are recognized and prints one JSON +materialization report as its final stdout line. + +## Verification + +Product gates: + +```sh +cargo fmt --all --check +cargo test --locked --lib versioned_graph +cargo test --locked --test versioned_dependency_cycles -- --nocapture +cargo test --locked --all-targets +cargo clippy --locked --all-targets -- -D warnings +``` + +The integration test exercises the real `zed` binary, the four-node example, +visible ORE output, finite node and edge counts, payload symlinks, exact back-edge +reuse, idempotent replay, content-addressed store binding, and copy-mode refusal. + +`zed-pkg-test/zed-pkg-e2e` independently reconstructs the same graph outside the +product crate and runs the public executable in normal and replay modes. + +## Current boundary and next integration step + +This implementation provides the exact-version graph validation, +materialization, diagnostics, and public CLI substrate. The ordinary +manifest-driven solver and `.zpkg.lock` v1 still intentionally carry one selected +version per `org/name`. + +Automatic fallback from an unsatisfiable one-version solve to an isolated +multi-version solve requires a separately reviewed lockfile extension that +persists exact roots and edges. That extension must reuse this materializer; it +must not invent a second directory-derived graph or weaken frozen replay. diff --git a/src/graph_export.rs b/src/graph_export.rs index 386bcaf9..2c20b762 100644 --- a/src/graph_export.rs +++ b/src/graph_export.rs @@ -1,8 +1,12 @@ -//! Immutable package dependency-graph downloads. +//! Immutable package dependency-graph downloads and finite exact-graph materialization. //! //! `zed graph package /@` is a byte-preserving client for //! the registry graph endpoints. It never resolves a mutable version, rewrites //! a graph, or treats a convenience projection as lockfile authority. +//! +//! `zed graph materialize` projects a complete exact-version graph into one +//! finite symlink forest. Back edges reuse existing exact nodes; no dependency +//! subtree is recursively copied into another dependency subtree. use std::ffi::OsString; use std::path::PathBuf; @@ -13,6 +17,11 @@ use serde::Serialize; use crate::cli::Globals; use crate::config::Config; +use crate::store::Store; +use crate::versioned_graph::{ + VersionedMaterializationMode, materialize_graph_file_from_store, materialize_plan_file, + terminal_cycle_logger, +}; mod coordinate; mod download; @@ -60,6 +69,38 @@ pub struct PackageGraphArgs { pub metadata_json: bool, } +#[derive(Debug, Clone, Args)] +pub struct MaterializeGraphArgs { + /// Canonical digest-verified resolved graph. Every artifact must already + /// exist in the configured content-addressed Zed store. + #[arg( + long, + value_name = "PATH", + conflicts_with = "plan", + required_unless_present = "plan" + )] + pub graph: Option, + + /// Development/test plan containing the exact graph plus explicit local + /// payload sources. This path may finalize a graph that has no digest yet. + #[arg( + long, + value_name = "PATH", + conflicts_with = "graph", + required_unless_present = "graph" + )] + pub plan: Option, + + /// Project whose `zed_modules/` root will be atomically replaced. + #[arg(long, value_name = "DIR", default_value = ".")] + pub project: PathBuf, + + /// Exact-graph projection mode. Circular or parallel-version graphs are + /// symlink-only; copy mode fails closed instead of mirroring forever. + #[arg(long, value_name = "MODE", default_value = "symlink")] + pub mode: String, +} + #[derive(Debug, Clone, Args)] struct GraphArgs { #[command(subcommand)] @@ -70,6 +111,8 @@ struct GraphArgs { enum GraphSubcommand { /// Download one immutable package-version dependency graph. Package(PackageGraphArgs), + /// Materialize a finite exact-version graph with reusable symlink nodes. + Materialize(MaterializeGraphArgs), } #[derive(Debug, Parser)] @@ -88,7 +131,7 @@ struct GraphCli { #[derive(Debug, Subcommand)] enum GraphCommand { - /// Inspect and export dependency graphs. + /// Inspect, export, and materialize dependency graphs. Graph(GraphArgs), } @@ -115,8 +158,8 @@ struct DownloadMetadata { } /// Route only `zed graph ...`; established commands remain on the ordinary -/// CLI parser. This modular boundary leaves `zed graph github` available as a -/// sibling command without coupling package downloads to GitHub inventory. +/// CLI parser. This modular boundary leaves future graph providers available +/// without coupling them to the main command model. pub fn dispatch(args: Vec) -> Option> { match route(&args) { Route::Graph => Some(run_cli(args)), @@ -130,8 +173,7 @@ pub fn dispatch(args: Vec) -> Option> { } } -/// Add the graph namespace and immutable package downloader to root help and -/// shell completion generation. +/// Add the graph namespace to root help and shell completion generation. pub fn augment_root_command(command: clap::Command) -> clap::Command { if command .get_subcommands() @@ -143,12 +185,17 @@ pub fn augment_root_command(command: clap::Command) -> clap::Command { clap::Command::new("package") .about("Download one immutable package-version dependency graph"), ); + let materialize = ::augment_args( + clap::Command::new("materialize") + .about("Materialize one finite exact-version dependency graph"), + ); command.subcommand( clap::Command::new("graph") - .about("Inspect and export dependency graphs") + .about("Inspect, export, and materialize dependency graphs") .subcommand_required(true) .arg_required_else_help(true) - .subcommand(package), + .subcommand(package) + .subcommand(materialize), ) } @@ -166,6 +213,9 @@ fn run_cli(args: Vec) -> Result { GraphCommand::Graph(GraphArgs { command: GraphSubcommand::Package(options), }) => run_package(&config, options), + GraphCommand::Graph(GraphArgs { + command: GraphSubcommand::Materialize(options), + }) => run_materialize(&config, options), } } @@ -221,6 +271,26 @@ fn run_package(config: &Config, options: PackageGraphArgs) -> Result { Ok(0) } +fn run_materialize(config: &Config, options: MaterializeGraphArgs) -> Result { + let mode = options.mode.parse::()?; + let logger = terminal_cycle_logger(); + let result = if let Some(graph) = options.graph { + let store = Store::new(&config.home); + materialize_graph_file_from_store(&graph, &store, &options.project, mode, &logger) + } else if let Some(plan) = options.plan { + materialize_plan_file(&plan, &options.project, mode, &logger) + } else { + unreachable!("clap requires either --graph or --plan") + }; + logger.close().context("closing ORE graph logger")?; + let report = result?; + println!( + "{}", + serde_json::to_string(&report).context("serializing materialization report")? + ); + Ok(0) +} + fn route(args: &[OsString]) -> Route { let Some((command_index, command)) = first_command(args) else { return Route::Existing; @@ -290,9 +360,12 @@ fn global_option_takes_value(token: &str) -> bool { mod tests { use super::*; + fn argv(values: &[&str]) -> Vec { + values.iter().map(OsString::from).collect() + } + #[test] fn route_detects_graph_and_help_without_stealing_existing_commands() { - let argv = |values: &[&str]| values.iter().map(OsString::from).collect::>(); assert_eq!( route(&argv(&["zed", "graph", "package", "acme/pkg@1.0.0"])), Route::Graph @@ -310,4 +383,41 @@ mod tests { ); assert_eq!(route(&argv(&["zed", "task", "graph"])), Route::Existing); } + + #[test] + fn materialize_requires_exactly_one_graph_input() { + assert!( + GraphCli::try_parse_from(argv(&[ + "zed", + "graph", + "materialize", + "--graph", + "graph.json" + ])) + .is_ok() + ); + assert!( + GraphCli::try_parse_from(argv(&[ + "zed", + "graph", + "materialize", + "--plan", + "plan.json" + ])) + .is_ok() + ); + assert!(GraphCli::try_parse_from(argv(&["zed", "graph", "materialize"])).is_err()); + assert!( + GraphCli::try_parse_from(argv(&[ + "zed", + "graph", + "materialize", + "--graph", + "graph.json", + "--plan", + "plan.json" + ])) + .is_err() + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 5efcdc91..2d1bbf8e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -148,5 +148,6 @@ pub mod transaction; pub mod update; pub mod validation; pub mod vcs; +pub mod versioned_graph; pub mod tool_versions; diff --git a/src/versioned_graph.rs b/src/versioned_graph.rs new file mode 100644 index 00000000..081942ba --- /dev/null +++ b/src/versioned_graph.rs @@ -0,0 +1,1722 @@ +//! Exact-version dependency graph cycle detection and finite symlink materialization. +//! +//! The ordinary one-version solver remains useful when every requirement can share +//! one package version. This module owns the stronger graph shape required when +//! separate parents intentionally select separate versions or when an exact +//! package-version edge closes a cycle. Package payloads remain immutable and are +//! never recursively mirrored into descendant directories. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use anyhow::{Context, Result, bail, ensure}; +use next_loggers::{JsonObject, Logger, Options, json}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; +use zed_interfaces::dependency_graph::{ + DependencyGraphCompleteness, DependencyGraphData, DependencyGraphDocument, + PackageVersionIdentity, ResolvedDependencyEdge, ResolvedDependencyNode, +}; + +use crate::store::Store; + +pub const LOCAL_MATERIALIZATION_SCHEMA_V1: &str = "zpkg/local-graph-materialization/v1"; +pub const CYCLE_EVENT: &str = "zed.install.circular_dependency"; +pub const RESOLUTION_MODE: &str = "exact-node-symlink"; +pub const MATERIALIZATION_REPORT_SCHEMA_V1: &str = "zpkg/versioned-graph-materialization-report/v1"; +const MAX_MATERIALIZED_NODES: usize = 50_000; +const MAX_MATERIALIZED_EDGES: usize = 500_000; +const MAX_CYCLE_WITNESSES: usize = 4_096; + +fn local_materialization_schema_v1() -> String { + LOCAL_MATERIALIZATION_SCHEMA_V1.to_string() +} + +/// Local payload location for one exact node in the canonical resolved graph. +/// +/// `source` is interpreted relative to the plan file's parent when it is not +/// absolute. Production callers should point it at the immutable global store. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalPackageSource { + pub id: PackageVersionIdentity, + pub source: PathBuf, +} + +/// A canonical exact resolved graph plus the local immutable source directory +/// for every node. Edges and roots remain authoritative in `graph`; filesystem +/// layout is only a projection of that document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalGraphMaterializationPlan { + #[serde(default = "local_materialization_schema_v1")] + pub schema: String, + pub graph: DependencyGraphDocument, + pub sources: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VersionedMaterializationMode { + Symlink, + Copy, +} + +impl FromStr for VersionedMaterializationMode { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "symlink" | "link" => Ok(Self::Symlink), + "copy" | "isolate" => Ok(Self::Copy), + other => bail!( + "unsupported exact-graph materialization mode `{other}`; expected `symlink` or `copy`" + ), + } + } +} + +/// Exact materialized node identity. The graph contract keeps the artifact +/// digest on the node rather than inside [`PackageVersionIdentity`], so cycle +/// diagnostics carry both pieces explicitly. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct ExactDependencyNodeIdentity { + pub package: PackageVersionIdentity, + pub artifact_digest: String, +} + +impl ExactDependencyNodeIdentity { + pub fn label(&self) -> String { + let digest = self + .artifact_digest + .strip_prefix("sha256:") + .unwrap_or(&self.artifact_digest); + let short = &digest[..digest.len().min(12)]; + format!("{}#{short}", self.package) + } +} + +/// One deterministic directed-cycle witness. `path` is closed: its final item +/// equals its first item. A repeated package name at another version is not a +/// cycle unless the exact package-version-and-digest identity is revisited. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DependencyCycle { + pub cycle_id: String, + pub path: Vec, + pub closing_from: ExactDependencyNodeIdentity, + pub closing_to: ExactDependencyNodeIdentity, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VersionedMaterializationReport { + pub schema: String, + pub graph_digest: String, + pub materialization_digest: String, + pub generation_dir: PathBuf, + pub modules_dir: PathBuf, + pub nodes: usize, + pub edges: usize, + pub cycles: Vec, + pub reused_generation: bool, +} + +#[derive(Debug, Clone)] +struct ValidatedNode { + id: PackageVersionIdentity, + artifact_digest: String, + source: PathBuf, + key: String, +} + +#[derive(Debug, Clone)] +struct ValidatedPlan { + graph: DependencyGraphDocument, + graph_digest: String, + materialization_digest: String, + roots: Vec, + nodes: BTreeMap, + edges: Vec, + cycles: Vec, + has_parallel_versions: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct GenerationMarker { + schema: String, + graph_digest: String, + materialization_digest: String, + nodes: usize, + edges: usize, +} + +impl GenerationMarker { + fn for_plan(plan: &ValidatedPlan) -> Self { + Self { + schema: MATERIALIZATION_REPORT_SCHEMA_V1.into(), + graph_digest: plan.graph_digest.clone(), + materialization_digest: plan.materialization_digest.clone(), + nodes: plan.nodes.len(), + edges: plan.edges.len(), + } + } +} + +/// ORE structured logger used by the public CLI boundary. Console output is +/// enabled so users see exact circular-dependency paths during materialization. +pub fn terminal_cycle_logger() -> Logger { + let fields = JsonObject::from_iter([ + ("component".into(), json!("versioned_dependency_graph")), + ("event_family".into(), json!("dependency_resolution")), + ]); + Logger::new(Options { + app_name: "zed-cli".into(), + name: Some("exact-version-graph".into()), + fields, + console: true, + ..Options::default() + }) +} + +/// Read a materialization plan and project its exact graph into the project. +pub fn materialize_plan_file( + plan_path: &Path, + project: &Path, + mode: VersionedMaterializationMode, + logger: &Logger, +) -> Result { + let bytes = fs::read(plan_path).with_context(|| { + format!( + "failed to read exact dependency graph plan {}", + plan_path.display() + ) + })?; + let plan: LocalGraphMaterializationPlan = + serde_json::from_slice(&bytes).with_context(|| { + format!( + "failed to parse exact dependency graph plan {}", + plan_path.display() + ) + })?; + let source_base = plan_path.parent().unwrap_or_else(|| Path::new(".")); + materialize_plan(&plan, source_base, project, mode, logger) +} + +/// Read one byte-canonical, digest-verified resolved graph and bind every node +/// to its immutable package directory in the configured Zed store. +pub fn materialize_graph_file_from_store( + graph_path: &Path, + store: &Store, + project: &Path, + mode: VersionedMaterializationMode, + logger: &Logger, +) -> Result { + let bytes = fs::read(graph_path).with_context(|| { + format!( + "failed to read exact dependency graph {}", + graph_path.display() + ) + })?; + let graph = DependencyGraphDocument::parse_verified_canonical(&bytes).with_context(|| { + format!( + "{} is not a canonical digest-verified exact dependency graph", + graph_path.display() + ) + })?; + let plan = plan_from_store(graph, store)?; + materialize_plan(&plan, Path::new("."), project, mode, logger) +} + +/// Bind an exact resolved graph to the content-addressed Zed store. Missing +/// artifacts are reported explicitly; this projection never fetches or guesses. +pub fn plan_from_store( + graph: DependencyGraphDocument, + store: &Store, +) -> Result { + let DependencyGraphData::Resolved { nodes, .. } = &graph.graph else { + bail!("store materialization requires a resolved exact dependency graph"); + }; + let mut sources = Vec::with_capacity(nodes.len()); + for node in nodes { + let digest = required_artifact_digest(node)?; + let sha256 = digest + .strip_prefix("sha256:") + .context("validated artifact digest lost its sha256 prefix")?; + let source = store.pkg_dir(sha256); + ensure!( + source.is_dir(), + "immutable store artifact `{}` for exact node `{}` is missing at {}; fetch the locked graph before materializing it", + digest, + node.id, + source.display() + ); + sources.push(LocalPackageSource { + id: node.id.clone(), + source, + }); + } + Ok(LocalGraphMaterializationPlan { + schema: LOCAL_MATERIALIZATION_SCHEMA_V1.into(), + graph, + sources, + }) +} + +/// Validate, report, and materialize a finite exact-version dependency graph. +pub fn materialize_plan( + plan: &LocalGraphMaterializationPlan, + source_base: &Path, + project: &Path, + mode: VersionedMaterializationMode, + logger: &Logger, +) -> Result { + fs::create_dir_all(project) + .with_context(|| format!("failed to create project directory {}", project.display()))?; + let project = project + .canonicalize() + .with_context(|| format!("failed to resolve project directory {}", project.display()))?; + let validated = validate_plan(plan, source_base, &project)?; + log_cycles(logger, &validated)?; + + if mode == VersionedMaterializationMode::Copy { + if !validated.cycles.is_empty() { + bail!( + "copy mode cannot represent a circular exact-version dependency graph without recursively duplicating nodes; use `--mode symlink`" + ); + } + if validated.has_parallel_versions { + bail!( + "copy mode cannot represent multiple exact versions of one package without duplicating payloads; use `--mode symlink`" + ); + } + bail!( + "copy mode is not defined for exact-version node overlays; use the ordinary flat installer for an acyclic one-version graph" + ); + } + + #[cfg(not(unix))] + { + let _ = project; + bail!( + "exact-version graph materialization requires directory symlinks; this platform must use a symlink-capable environment" + ); + } + + #[cfg(unix)] + { + materialize_symlink_graph(validated, &project) + } +} + +fn validate_plan( + plan: &LocalGraphMaterializationPlan, + source_base: &Path, + project: &Path, +) -> Result { + ensure!( + plan.schema == LOCAL_MATERIALIZATION_SCHEMA_V1, + "unsupported local graph materialization schema `{}`; expected `{LOCAL_MATERIALIZATION_SCHEMA_V1}`", + plan.schema + ); + + let mut graph = plan.graph.clone(); + if graph.graph_digest.is_some() { + graph + .verify_digest() + .context("exact dependency graph digest verification failed")?; + graph.normalize_in_place(); + } else { + graph = graph + .finalize() + .context("failed to finalize exact dependency graph")?; + } + let graph_digest = graph + .graph_digest + .clone() + .context("finalized exact dependency graph omitted graph_digest")?; + + let (completeness, roots, graph_nodes, edges) = match &graph.graph { + DependencyGraphData::Resolved { + completeness, + roots, + nodes, + edges, + .. + } => (*completeness, roots.clone(), nodes.clone(), edges.clone()), + DependencyGraphData::Declared { .. } => { + bail!("local graph materialization requires a resolved exact dependency graph") + } + }; + ensure!( + completeness == DependencyGraphCompleteness::Complete, + "projected dependency graphs are not lock authority and cannot be materialized" + ); + ensure!( + graph_nodes.len() <= MAX_MATERIALIZED_NODES, + "exact dependency graph contains {} nodes; limit is {MAX_MATERIALIZED_NODES}", + graph_nodes.len() + ); + ensure!( + edges.len() <= MAX_MATERIALIZED_EDGES, + "exact dependency graph contains {} edges; limit is {MAX_MATERIALIZED_EDGES}", + edges.len() + ); + + let mut source_map = BTreeMap::new(); + for source in &plan.sources { + ensure!( + source_map + .insert(source.id.clone(), source.source.clone()) + .is_none(), + "duplicate local source mapping for `{}`", + source.id + ); + } + + let source_base = source_base + .canonicalize() + .with_context(|| format!("failed to resolve source base {}", source_base.display()))?; + let mut nodes = BTreeMap::new(); + let mut coordinate_versions: BTreeMap<(String, String, String), BTreeSet<(String, String)>> = + BTreeMap::new(); + for node in graph_nodes { + let artifact_digest = required_artifact_digest(&node)?; + let source = source_map + .remove(&node.id) + .with_context(|| format!("missing local source mapping for `{}`", node.id))?; + let source = if source.is_absolute() { + source + } else { + source_base.join(source) + }; + let source = source + .canonicalize() + .with_context(|| format!("failed to resolve package source {}", source.display()))?; + ensure!( + path_is_real_directory(&source)?, + "package source is not a real directory: {}", + source.display() + ); + ensure!( + !paths_overlap(&source, project), + "package source {} overlaps project {}; immutable payloads and generated dependency links must remain disjoint", + source.display(), + project.display() + ); + ensure_absent( + &source.join("zed_modules"), + &format!( + "package source `{}` already contains reserved `zed_modules`; immutable payloads must not embed a project materialization tree", + node.id + ), + )?; + + let key = node_key(&node.id, &artifact_digest); + coordinate_versions + .entry(( + node.id.registry_id.clone(), + node.id.org.clone(), + node.id.name.clone(), + )) + .or_default() + .insert((node.id.version.clone(), artifact_digest.clone())); + let validated = ValidatedNode { + id: node.id.clone(), + artifact_digest, + source, + key, + }; + ensure!( + nodes.insert(node.id.clone(), validated).is_none(), + "duplicate exact dependency node `{}`", + node.id + ); + } + ensure!( + source_map.is_empty(), + "local source map contains {} entry(s) absent from the exact dependency graph", + source_map.len() + ); + + let mut root_aliases = BTreeSet::new(); + for root in &roots { + ensure!( + nodes.contains_key(root), + "root node `{root}` is not materializable" + ); + ensure!( + root_aliases.insert((root.org.clone(), root.name.clone())), + "root aliases collide at `zed_modules/{}/{}`; select one direct root version per package coordinate", + root.org, + root.name + ); + } + + let mut dependency_aliases = BTreeSet::new(); + for edge in &edges { + ensure!( + nodes.contains_key(&edge.from), + "edge source `{}` is not materializable", + edge.from + ); + ensure!( + nodes.contains_key(&edge.to), + "edge target `{}` is not materializable", + edge.to + ); + ensure!( + dependency_aliases.insert(( + edge.from.clone(), + edge.to.org.clone(), + edge.to.name.clone(), + )), + "package `{}` has multiple exact targets for dependency alias `zed_modules/{}/{}`", + edge.from, + edge.to.org, + edge.to.name + ); + } + + let cycle_paths = detect_cycle_paths(&roots, nodes.keys(), &edges)?; + let cycles = decorate_cycles(cycle_paths, &nodes)?; + let has_parallel_versions = coordinate_versions + .values() + .any(|versions| versions.len() > 1); + let materialization_digest = compute_materialization_digest(&graph_digest, &nodes)?; + Ok(ValidatedPlan { + graph, + graph_digest, + materialization_digest, + roots, + nodes, + edges, + cycles, + has_parallel_versions, + }) +} + +fn required_artifact_digest(node: &ResolvedDependencyNode) -> Result { + let digest = node + .artifact_digest + .clone() + .with_context(|| format!("exact dependency node `{}` has no artifact_digest", node.id))?; + let Some(hex) = digest.strip_prefix("sha256:") else { + bail!( + "exact dependency node `{}` artifact_digest must use `sha256:`", + node.id + ); + }; + ensure!( + hex.len() == 64 + && hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)), + "exact dependency node `{}` has a non-canonical artifact_digest", + node.id + ); + ensure!( + hex.bytes().any(|byte| byte != b'0'), + "exact dependency node `{}` has an all-zero artifact_digest", + node.id + ); + Ok(digest) +} + +fn path_is_real_directory(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) => Ok(metadata.is_dir() && !metadata.file_type().is_symlink()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => { + Err(error).with_context(|| format!("failed to inspect directory {}", path.display())) + } + } +} + +fn ensure_absent(path: &Path, message: &str) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(_) => bail!("{message}"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| format!("failed to inspect {}", path.display())), + } +} + +fn paths_overlap(left: &Path, right: &Path) -> bool { + left == right || left.starts_with(right) || right.starts_with(left) +} + +fn compute_materialization_digest( + graph_digest: &str, + nodes: &BTreeMap, +) -> Result { + #[derive(Serialize)] + struct Binding<'a> { + package: &'a PackageVersionIdentity, + artifact_digest: &'a str, + source: String, + } + + let bindings = nodes + .values() + .map(|node| Binding { + package: &node.id, + artifact_digest: &node.artifact_digest, + source: node.source.to_string_lossy().into_owned(), + }) + .collect::>(); + let payload = serde_json::to_vec(&(graph_digest, bindings)) + .context("serializing exact graph source bindings")?; + Ok(format!("sha256:{}", hex_sha256(&payload))) +} + +fn detect_cycle_paths<'a, I>( + roots: &[PackageVersionIdentity], + nodes: I, + edges: &[ResolvedDependencyEdge], +) -> Result>> +where + I: IntoIterator, +{ + let mut adjacency: BTreeMap> = + BTreeMap::new(); + for node in nodes { + adjacency.entry(node.clone()).or_default(); + } + for edge in edges { + adjacency + .entry(edge.from.clone()) + .or_default() + .insert(edge.to.clone()); + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Visit { + Visiting(usize), + Done, + } + + #[derive(Debug)] + struct Frame { + node: PackageVersionIdentity, + children: Vec, + next_child: usize, + } + + impl Frame { + fn new( + node: PackageVersionIdentity, + adjacency: &BTreeMap>, + ) -> Self { + Self { + children: adjacency + .get(&node) + .map(|children| children.iter().cloned().collect()) + .unwrap_or_default(), + node, + next_child: 0, + } + } + } + + let mut states = BTreeMap::new(); + let mut stack = Vec::new(); + let mut found: BTreeMap> = BTreeMap::new(); + let mut traversal = roots.to_vec(); + traversal.extend(adjacency.keys().cloned()); + traversal.sort(); + traversal.dedup(); + + for start in traversal { + if states.contains_key(&start) { + continue; + } + states.insert(start.clone(), Visit::Visiting(0)); + stack.push(start.clone()); + let mut frames = vec![Frame::new(start, &adjacency)]; + + while !frames.is_empty() { + let next_child = { + let frame = frames.last_mut().expect("checked non-empty frame stack"); + if frame.next_child == frame.children.len() { + None + } else { + let child = frame.children[frame.next_child].clone(); + frame.next_child += 1; + Some(child) + } + }; + + let Some(child) = next_child else { + let frame = frames.pop().expect("checked non-empty frame stack"); + let popped = stack.pop().expect("DFS node stack matches frame stack"); + debug_assert_eq!(popped, frame.node); + states.insert(frame.node, Visit::Done); + continue; + }; + + match states.get(&child).copied() { + None => { + states.insert(child.clone(), Visit::Visiting(stack.len())); + stack.push(child.clone()); + frames.push(Frame::new(child, &adjacency)); + } + Some(Visit::Visiting(index)) => { + let mut path = stack[index..].to_vec(); + path.push(child); + let path = canonical_cycle(path); + let key = path + .iter() + .map(ToString::to_string) + .collect::>() + .join(" -> "); + if !found.contains_key(&key) && found.len() >= MAX_CYCLE_WITNESSES { + bail!( + "exact dependency graph exposes more than {MAX_CYCLE_WITNESSES} deterministic cycle witnesses; refusing to flood diagnostics" + ); + } + found.entry(key).or_insert(path); + } + Some(Visit::Done) => {} + } + } + } + Ok(found.into_values().collect()) +} + +fn canonical_cycle(path: Vec) -> Vec { + debug_assert!(path.len() >= 2 && path.first() == path.last()); + let body = &path[..path.len() - 1]; + let mut best = 0; + for start in 1..body.len() { + let candidate = (0..body.len()) + .map(|offset| &body[(start + offset) % body.len()]) + .collect::>(); + let current = (0..body.len()) + .map(|offset| &body[(best + offset) % body.len()]) + .collect::>(); + if candidate < current { + best = start; + } + } + let mut canonical = (0..body.len()) + .map(|offset| body[(best + offset) % body.len()].clone()) + .collect::>(); + canonical.push(canonical[0].clone()); + canonical +} + +fn decorate_cycles( + paths: Vec>, + nodes: &BTreeMap, +) -> Result> { + paths + .into_iter() + .map(|path| { + let exact_path = path + .iter() + .map(|id| { + let node = nodes + .get(id) + .with_context(|| format!("cycle references absent exact node `{id}`"))?; + Ok(ExactDependencyNodeIdentity { + package: id.clone(), + artifact_digest: node.artifact_digest.clone(), + }) + }) + .collect::>>()?; + let key = exact_path + .iter() + .map(ExactDependencyNodeIdentity::label) + .collect::>() + .join(" -> "); + let closing_from = exact_path[exact_path.len() - 2].clone(); + let closing_to = exact_path[exact_path.len() - 1].clone(); + Ok(DependencyCycle { + cycle_id: format!("sha256:{}", hex_sha256(key.as_bytes())), + path: exact_path, + closing_from, + closing_to, + }) + }) + .collect() +} + +fn log_cycles(logger: &Logger, plan: &ValidatedPlan) -> Result<()> { + for cycle in &plan.cycles { + let exact_path = cycle + .path + .iter() + .map(ExactDependencyNodeIdentity::label) + .collect::>(); + let closing_from = cycle.closing_from.label(); + let closing_to = cycle.closing_to.label(); + let message = format!( + "circular dependency detected: {}; closing edge {} -> {} reuses the existing exact node through a symlink", + exact_path.join(" -> "), + closing_from, + closing_to + ); + let target = &plan.nodes[&cycle.closing_to.package]; + let fields = JsonObject::from_iter([ + ("event".into(), json!(CYCLE_EVENT)), + ("cycle_id".into(), json!(&cycle.cycle_id)), + ("path".into(), json!(&exact_path)), + ("closing_from".into(), json!(closing_from)), + ("closing_to".into(), json!(closing_to)), + ("target_node_key".into(), json!(&target.key)), + ( + "target_artifact_digest".into(), + json!(&target.artifact_digest), + ), + ("resolution".into(), json!(RESOLUTION_MODE)), + ("graph_digest".into(), json!(&plan.graph_digest)), + ( + "materialization_digest".into(), + json!(&plan.materialization_digest), + ), + ]); + logger + .warn(vec![json!(message)]) + .add_fields(fields) + .add_tags(["zed-pkg", "dependency-cycle", RESOLUTION_MODE]) + .send() + .context("ORE cycle log delivery failed")?; + } + Ok(()) +} + +fn node_key(id: &PackageVersionIdentity, artifact_digest: &str) -> String { + let identity = format!( + "{}\0{}\0{}\0{}\0{}", + id.registry_id, id.org, id.name, id.version, artifact_digest + ); + let identity_digest = hex_sha256(identity.as_bytes()); + format!( + "{}+{}@{}+{}", + id.org, + id.name, + sanitize_segment(&id.version), + &identity_digest[..16] + ) +} + +fn sanitize_segment(value: &str) -> String { + let sanitized = value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { + character + } else { + '_' + } + }) + .collect::(); + if sanitized.is_empty() { + "version".into() + } else { + sanitized + } +} + +fn hex_sha256(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(unix)] +fn create_real_directory(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) => ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "refusing non-directory or symlinked control path {}", + path.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(path).with_context(|| { + format!("failed to create control directory {}", path.display()) + })?; + } + Err(error) => { + return Err(error) + .with_context(|| format!("failed to inspect control path {}", path.display())); + } + } + Ok(()) +} + +#[cfg(unix)] +fn materialize_symlink_graph( + plan: ValidatedPlan, + project: &Path, +) -> Result { + use std::os::unix::fs::symlink; + + let dot_zed = project.join(".zed"); + create_real_directory(&dot_zed)?; + let graph_control = dot_zed.join("versioned-graph"); + create_real_directory(&graph_control)?; + let schema_control = graph_control.join("v1"); + create_real_directory(&schema_control)?; + let graphs = schema_control.join("graphs"); + create_real_directory(&graphs)?; + + let graph_id = plan + .graph_digest + .strip_prefix("sha256:") + .context("graph_digest must use sha256")?; + let materialization_id = plan + .materialization_digest + .strip_prefix("sha256:") + .context("materialization_digest must use sha256")?; + let graph_group = graphs.join(graph_id); + create_real_directory(&graph_group)?; + let generation = graph_group.join(materialization_id); + + let reused_generation = match fs::symlink_metadata(&generation) { + Ok(metadata) => { + ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "refusing non-directory or symlinked graph generation {}", + generation.display() + ); + verify_generation(&generation, &plan)?; + true + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let staging = graph_group.join(format!(".tmp-{}", Uuid::new_v4())); + let build_result = build_generation(&staging, &generation, &plan); + if build_result.is_err() { + let _ = remove_path(&staging); + } + build_result?; + false + } + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to inspect graph generation {}", + generation.display() + ) + }); + } + }; + + let modules_staging = schema_control.join(format!(".modules-{}", Uuid::new_v4())); + create_real_directory(&modules_staging)?; + let root_links = (|| -> Result<()> { + for root in &plan.roots { + let node = &plan.nodes[root]; + let alias_parent = modules_staging.join(&root.org); + if !alias_parent.exists() { + create_real_directory(&alias_parent)?; + } + symlink( + generation.join("nodes").join(&node.key).join("root"), + alias_parent.join(&root.name), + ) + .with_context(|| format!("failed to link direct root `{root}`"))?; + } + Ok(()) + })(); + if let Err(error) = root_links { + let _ = remove_path(&modules_staging); + return Err(error); + } + + let modules_dir = project.join("zed_modules"); + if let Err(error) = + replace_directory_atomically(&modules_staging, &modules_dir, &schema_control) + { + let _ = remove_path(&modules_staging); + return Err(error); + } + + Ok(VersionedMaterializationReport { + schema: MATERIALIZATION_REPORT_SCHEMA_V1.into(), + graph_digest: plan.graph_digest, + materialization_digest: plan.materialization_digest, + generation_dir: generation, + modules_dir, + nodes: plan.nodes.len(), + edges: plan.edges.len(), + cycles: plan.cycles, + reused_generation, + }) +} + +#[cfg(unix)] +fn build_generation(staging: &Path, generation: &Path, plan: &ValidatedPlan) -> Result<()> { + use std::ffi::OsStr; + use std::os::unix::fs::symlink; + + create_real_directory(staging)?; + create_real_directory(&staging.join("nodes"))?; + for node in plan.nodes.values() { + let node_dir = staging.join("nodes").join(&node.key); + create_real_directory(&node_dir)?; + let root = node_dir.join("root"); + create_real_directory(&root)?; + create_real_directory(&root.join("zed_modules"))?; + + let mut entries = fs::read_dir(&node.source) + .with_context(|| format!("failed to read immutable payload {}", node.source.display()))? + .collect::>>()?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let name = entry.file_name(); + ensure!( + name != OsStr::new("zed_modules"), + "package source `{}` contains reserved `zed_modules`", + node.id + ); + symlink(entry.path(), root.join(&name)).with_context(|| { + format!( + "failed to link payload entry {} for `{}`", + entry.path().display(), + node.id + ) + })?; + } + fs::write(node_dir.join("node.json"), node_record_bytes(node)?)?; + } + + for edge in &plan.edges { + let from = &plan.nodes[&edge.from]; + let to = &plan.nodes[&edge.to]; + let org_dir = staging + .join("nodes") + .join(&from.key) + .join("root") + .join("zed_modules") + .join(&edge.to.org); + if !org_dir.exists() { + create_real_directory(&org_dir)?; + } + symlink( + generation.join("nodes").join(&to.key).join("root"), + org_dir.join(&edge.to.name), + ) + .with_context(|| { + format!( + "failed to link exact dependency `{}` -> `{}`", + edge.from, edge.to + ) + })?; + } + + fs::write( + staging.join("graph.json"), + plan.graph + .canonical_document_bytes() + .context("serializing canonical exact dependency graph")?, + )?; + fs::write( + staging.join("cycles.json"), + serde_json::to_vec_pretty(&plan.cycles)?, + )?; + fs::write( + staging.join(".complete.json"), + serde_json::to_vec_pretty(&GenerationMarker::for_plan(plan))?, + )?; + + match fs::rename(staging, generation) { + Ok(()) => Ok(()), + Err(error) => { + if path_is_real_directory(generation)? { + remove_path(staging)?; + verify_generation(generation, plan) + } else { + Err(error).with_context(|| { + format!( + "failed to publish graph generation {} -> {}", + staging.display(), + generation.display() + ) + }) + } + } + } +} + +fn node_record_bytes(node: &ValidatedNode) -> Result> { + let metadata = serde_json::json!({ + "id": &node.id, + "artifact_digest": &node.artifact_digest, + "source": &node.source, + "node_key": &node.key, + }); + serde_json::to_vec_pretty(&metadata).context("serializing exact node metadata") +} + +#[cfg(unix)] +fn verify_generation(generation: &Path, plan: &ValidatedPlan) -> Result<()> { + use std::ffi::OsStr; + + ensure!( + path_is_real_directory(generation)?, + "graph generation {} is not a real directory", + generation.display() + ); + let marker: GenerationMarker = serde_json::from_slice( + &fs::read(generation.join(".complete.json")).with_context(|| { + format!( + "graph generation {} is incomplete; missing .complete.json marker", + generation.display() + ) + })?, + ) + .with_context(|| format!("invalid generation marker in {}", generation.display()))?; + ensure!( + marker == GenerationMarker::for_plan(plan), + "graph generation {} marker does not match the requested graph and source bindings", + generation.display() + ); + + let expected_graph = plan + .graph + .canonical_document_bytes() + .context("serializing canonical graph for generation verification")?; + ensure!( + fs::read(generation.join("graph.json"))? == expected_graph, + "graph generation {} contains different graph bytes", + generation.display() + ); + let expected_cycles = serde_json::to_vec_pretty(&plan.cycles)?; + ensure!( + fs::read(generation.join("cycles.json"))? == expected_cycles, + "graph generation {} contains different cycle diagnostics", + generation.display() + ); + + for node in plan.nodes.values() { + let node_dir = generation.join("nodes").join(&node.key); + let root = node_dir.join("root"); + ensure!( + path_is_real_directory(&root)?, + "graph generation {} is missing exact node `{}`", + generation.display(), + node.id + ); + ensure!( + fs::read(node_dir.join("node.json"))? == node_record_bytes(node)?, + "graph generation {} contains different metadata for exact node `{}`", + generation.display(), + node.id + ); + let mut entries = fs::read_dir(&node.source)?.collect::>>()?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let name = entry.file_name(); + ensure!( + name != OsStr::new("zed_modules"), + "reserved payload entry appeared" + ); + let link = root.join(&name); + let metadata = fs::symlink_metadata(&link).with_context(|| { + format!( + "graph generation is missing payload link {}", + link.display() + ) + })?; + ensure!( + metadata.file_type().is_symlink(), + "payload projection {} is not a symlink", + link.display() + ); + ensure!( + fs::read_link(&link)? == entry.path(), + "payload projection {} points at the wrong immutable source", + link.display() + ); + } + } + + for edge in &plan.edges { + let from = &plan.nodes[&edge.from]; + let to = &plan.nodes[&edge.to]; + let link = generation + .join("nodes") + .join(&from.key) + .join("root") + .join("zed_modules") + .join(&edge.to.org) + .join(&edge.to.name); + let metadata = fs::symlink_metadata(&link) + .with_context(|| format!("graph generation is missing edge link {}", link.display()))?; + ensure!( + metadata.file_type().is_symlink(), + "exact dependency edge {} is not a symlink", + link.display() + ); + ensure!( + fs::read_link(&link)? == generation.join("nodes").join(&to.key).join("root"), + "exact dependency edge {} points at the wrong node", + link.display() + ); + } + Ok(()) +} + +#[cfg(unix)] +fn replace_directory_atomically(staging: &Path, destination: &Path, control: &Path) -> Result<()> { + let backup = control.join(format!(".modules-backup-{}", Uuid::new_v4())); + let had_destination = match fs::symlink_metadata(destination) { + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to inspect existing modules {}", + destination.display() + ) + }); + } + }; + if had_destination { + fs::rename(destination, &backup).with_context(|| { + format!( + "failed to move existing modules {} to transaction backup {}", + destination.display(), + backup.display() + ) + })?; + } + if let Err(error) = fs::rename(staging, destination) { + if had_destination { + let _ = fs::rename(&backup, destination); + } + return Err(error).with_context(|| { + format!( + "failed to publish exact dependency modules {} -> {}", + staging.display(), + destination.display() + ) + }); + } + if had_destination { + remove_path(&backup)?; + } + Ok(()) +} + +fn remove_path(path: &Path) -> Result<()> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(error) + .with_context(|| format!("failed to inspect path {}", path.display())); + } + }; + if metadata.file_type().is_symlink() || metadata.is_file() { + fs::remove_file(path).with_context(|| format!("failed to remove path {}", path.display())) + } else { + fs::remove_dir_all(path) + .with_context(|| format!("failed to remove directory {}", path.display())) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use next_loggers::MemoryTransport; + use tempfile::tempdir; + use zed_interfaces::dependency_graph::{ + DependencyGraphCompleteness, DependencyGraphData, DependencyKind, RegistrySnapshot, + ResolutionProvenance, ResolvedDependencyEdge, ResolvedDependencyNode, + }; + + use super::*; + + fn id(name: &str, version: &str) -> PackageVersionIdentity { + PackageVersionIdentity { + registry_id: "fixture-registry".into(), + org: "fixture".into(), + name: name.into(), + version: version.into(), + } + } + + fn digest(seed: u8) -> String { + format!("sha256:{}", format!("{seed:02x}").repeat(32)) + } + + fn node(id: PackageVersionIdentity, seed: u8) -> ResolvedDependencyNode { + ResolvedDependencyNode { + id, + artifact_digest: Some(digest(seed)), + features: Vec::new(), + } + } + + fn edge(from: PackageVersionIdentity, to: PackageVersionIdentity) -> ResolvedDependencyEdge { + ResolvedDependencyEdge { + from, + to, + kind: DependencyKind::Runtime, + requirement: Some("exact fixture edge".into()), + target: None, + optional: false, + features: Vec::new(), + } + } + + fn graph( + roots: Vec, + nodes: Vec, + edges: Vec, + ) -> DependencyGraphDocument { + DependencyGraphDocument { + schema: zed_interfaces::dependency_graph::DEPENDENCY_GRAPH_SCHEMA_V1.into(), + graph: DependencyGraphData::Resolved { + completeness: DependencyGraphCompleteness::Complete, + roots, + nodes, + edges, + provenance: ResolutionProvenance { + resolver_version: "versioned-graph-test/v1".into(), + target: "test-target".into(), + enabled_features: Vec::new(), + registry_snapshots: vec![RegistrySnapshot { + registry_id: "fixture-registry".into(), + checkpoint_digest: digest(0xee), + }], + lock_digest: digest(0xdd), + }, + parent_graph_digest: None, + projection: None, + }, + graph_digest: None, + } + .finalize() + .unwrap() + } + + fn canonical_cycle_graph() -> DependencyGraphDocument { + let a1 = id("a", "1"); + let b1 = id("b", "1"); + let a2 = id("a", "2"); + let b0 = id("b", "0"); + graph( + vec![a1.clone()], + vec![ + node(a1.clone(), 1), + node(b1.clone(), 2), + node(a2.clone(), 3), + node(b0.clone(), 4), + ], + vec![ + edge(a1, b1.clone()), + edge(b1, a2.clone()), + edge(a2.clone(), b0.clone()), + edge(b0, a2), + ], + ) + } + + fn write_sources(root: &Path, graph: &DependencyGraphDocument) -> Vec { + let DependencyGraphData::Resolved { nodes, .. } = &graph.graph else { + unreachable!() + }; + nodes + .iter() + .enumerate() + .map(|(index, node)| { + let source = root.join(format!("source-{index}")); + fs::create_dir_all(&source).unwrap(); + fs::write(source.join("payload.txt"), format!("{}\n", node.id)).unwrap(); + LocalPackageSource { + id: node.id.clone(), + source, + } + }) + .collect() + } + + #[test] + fn exact_version_back_edge_terminates_without_conflating_a1_and_a2() { + let graph = canonical_cycle_graph(); + let DependencyGraphData::Resolved { + roots, + nodes, + edges, + .. + } = &graph.graph + else { + unreachable!() + }; + let cycles = detect_cycle_paths(roots, nodes.iter().map(|node| &node.id), edges).unwrap(); + assert_eq!(nodes.len(), 4); + assert_eq!(edges.len(), 4); + assert_eq!(cycles.len(), 1); + assert_eq!(cycles[0], vec![id("a", "2"), id("b", "0"), id("a", "2")]); + } + + #[test] + fn a_repeated_package_name_at_a_different_version_is_not_a_cycle() { + let a1 = id("a", "1"); + let b1 = id("b", "1"); + let a2 = id("a", "2"); + let graph = graph( + vec![a1.clone()], + vec![ + node(a1.clone(), 1), + node(b1.clone(), 2), + node(a2.clone(), 3), + ], + vec![edge(a1, b1.clone()), edge(b1, a2)], + ); + let DependencyGraphData::Resolved { + roots, + nodes, + edges, + .. + } = &graph.graph + else { + unreachable!() + }; + assert!( + detect_cycle_paths(roots, nodes.iter().map(|node| &node.id), edges) + .unwrap() + .is_empty() + ); + } + + #[test] + fn a_shared_completed_node_is_not_reported_as_a_cycle() { + let root = id("root", "1"); + let left = id("left", "1"); + let right = id("right", "1"); + let shared = id("shared", "1"); + let graph = graph( + vec![root.clone()], + vec![ + node(root.clone(), 1), + node(left.clone(), 2), + node(right.clone(), 3), + node(shared.clone(), 4), + ], + vec![ + edge(root.clone(), left.clone()), + edge(root, right.clone()), + edge(left, shared.clone()), + edge(right, shared), + ], + ); + let DependencyGraphData::Resolved { + roots, + nodes, + edges, + .. + } = &graph.graph + else { + unreachable!() + }; + assert!( + detect_cycle_paths(roots, nodes.iter().map(|node| &node.id), edges) + .unwrap() + .is_empty() + ); + } + + #[test] + fn self_cycle_has_one_closed_exact_path() { + let only = id("self", "1"); + let graph = graph( + vec![only.clone()], + vec![node(only.clone(), 1)], + vec![edge(only.clone(), only.clone())], + ); + let DependencyGraphData::Resolved { + roots, + nodes, + edges, + .. + } = &graph.graph + else { + unreachable!() + }; + let cycles = detect_cycle_paths(roots, nodes.iter().map(|node| &node.id), edges).unwrap(); + assert_eq!(cycles.len(), 1); + assert_eq!(cycles[0], vec![only.clone(), only]); + } + + #[test] + fn two_node_same_version_cycle_is_reported_once() { + let left = id("left", "1.0.0"); + let right = id("right", "1.0.0"); + let graph = graph( + vec![left.clone()], + vec![node(left.clone(), 1), node(right.clone(), 2)], + vec![ + edge(left.clone(), right.clone()), + edge(right.clone(), left.clone()), + ], + ); + let DependencyGraphData::Resolved { + roots, + nodes, + edges, + .. + } = &graph.graph + else { + unreachable!() + }; + let cycles = detect_cycle_paths(roots, nodes.iter().map(|node| &node.id), edges).unwrap(); + assert_eq!(cycles, vec![vec![left.clone(), right, left]]); + } + + #[test] + fn cycle_witnesses_are_deterministic_when_edges_arrive_in_another_order() { + let graph = canonical_cycle_graph(); + let DependencyGraphData::Resolved { + roots, + nodes, + edges, + .. + } = &graph.graph + else { + unreachable!() + }; + let forward = detect_cycle_paths(roots, nodes.iter().map(|node| &node.id), edges).unwrap(); + let mut reversed = edges.clone(); + reversed.reverse(); + let backward = + detect_cycle_paths(roots, nodes.iter().map(|node| &node.id), &reversed).unwrap(); + assert_eq!(forward, backward); + } + + #[test] + fn decorated_cycle_identity_includes_the_artifact_digest() { + let temp = tempdir().unwrap(); + let graph = canonical_cycle_graph(); + let plan = LocalGraphMaterializationPlan { + schema: LOCAL_MATERIALIZATION_SCHEMA_V1.into(), + sources: write_sources(temp.path(), &graph), + graph, + }; + let project = temp.path().join("project"); + fs::create_dir_all(&project).unwrap(); + let validated = + validate_plan(&plan, temp.path(), &project.canonicalize().unwrap()).unwrap(); + assert_eq!(validated.cycles.len(), 1); + assert_eq!(validated.cycles[0].path[0].package, id("a", "2")); + assert_eq!(validated.cycles[0].path[0].artifact_digest, digest(3)); + assert!(validated.cycles[0].cycle_id.starts_with("sha256:")); + } + + #[cfg(unix)] + #[test] + fn materializes_one_finite_symlink_node_per_exact_version_and_logs_ore_event() { + let temp = tempdir().unwrap(); + let graph = canonical_cycle_graph(); + let sources = write_sources(temp.path(), &graph); + let plan = LocalGraphMaterializationPlan { + schema: LOCAL_MATERIALIZATION_SCHEMA_V1.into(), + graph, + sources, + }; + let transport = Arc::new(MemoryTransport::default()); + let logger = Logger::new(Options { + app_name: "zed-cli-test".into(), + console: false, + ..Options::default().with_transport(transport.clone()) + }); + let project = temp.path().join("project"); + let first = materialize_plan( + &plan, + temp.path(), + &project, + VersionedMaterializationMode::Symlink, + &logger, + ) + .unwrap(); + assert_eq!(first.nodes, 4); + assert_eq!(first.edges, 4); + assert_eq!(first.cycles.len(), 1); + assert!(!first.reused_generation); + + let records = transport.records(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].fields["event"], json!(CYCLE_EVENT)); + assert_eq!(records[0].fields["resolution"], json!(RESOLUTION_MODE)); + assert_eq!( + records[0].fields["materialization_digest"], + json!(&first.materialization_digest) + ); + assert!(records[0].message.contains("fixture-registry::fixture/a@2")); + assert!(records[0].message.contains("fixture-registry::fixture/b@0")); + + let node_dirs = fs::read_dir(first.generation_dir.join("nodes")) + .unwrap() + .collect::>>() + .unwrap(); + assert_eq!(node_dirs.len(), 4); + for entry in node_dirs { + let payload = entry.path().join("root/payload.txt"); + assert!( + fs::symlink_metadata(payload) + .unwrap() + .file_type() + .is_symlink() + ); + } + + let root = project.join("zed_modules/fixture/a"); + let b1 = root.join("zed_modules/fixture/b"); + let a2 = b1.join("zed_modules/fixture/a"); + let b0 = a2.join("zed_modules/fixture/b"); + let closing_a2 = b0.join("zed_modules/fixture/a"); + assert_eq!( + fs::read_link(&closing_a2).unwrap(), + a2.canonicalize().unwrap() + ); + + let second = materialize_plan( + &plan, + temp.path(), + &project, + VersionedMaterializationMode::Symlink, + &logger, + ) + .unwrap(); + assert!(second.reused_generation); + assert_eq!(first.generation_dir, second.generation_dir); + assert_eq!( + fs::read_dir(first.generation_dir.parent().unwrap()) + .unwrap() + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .chars() + .all(|character| character.is_ascii_hexdigit()) + }) + .count(), + 1 + ); + } + + #[test] + fn store_binding_uses_each_nodes_content_addressed_package_directory() { + let temp = tempdir().unwrap(); + let graph = canonical_cycle_graph(); + let store = Store::new(&temp.path().join("home")); + let DependencyGraphData::Resolved { nodes, .. } = &graph.graph else { + unreachable!() + }; + for node in nodes { + let digest = node.artifact_digest.as_deref().unwrap(); + let sha256 = digest.strip_prefix("sha256:").unwrap(); + fs::create_dir_all(store.pkg_dir(sha256)).unwrap(); + } + let plan = plan_from_store(graph, &store).unwrap(); + assert_eq!(plan.sources.len(), 4); + for source in plan.sources { + assert!(source.source.starts_with(store.root())); + assert!(source.source.ends_with("pkg")); + } + } + + #[cfg(unix)] + #[test] + fn changing_source_bindings_creates_another_generation_without_changing_graph_identity() { + let temp = tempdir().unwrap(); + let graph = canonical_cycle_graph(); + let first_source_root = temp.path().join("first-sources"); + let second_source_root = temp.path().join("second-sources"); + fs::create_dir_all(&first_source_root).unwrap(); + fs::create_dir_all(&second_source_root).unwrap(); + let first_plan = LocalGraphMaterializationPlan { + schema: LOCAL_MATERIALIZATION_SCHEMA_V1.into(), + graph: graph.clone(), + sources: write_sources(&first_source_root, &graph), + }; + let second_plan = LocalGraphMaterializationPlan { + schema: LOCAL_MATERIALIZATION_SCHEMA_V1.into(), + graph, + sources: write_sources(&second_source_root, &first_plan.graph), + }; + let logger = Logger::new(Options { + console: false, + ..Options::default() + }); + let project = temp.path().join("project"); + let first = materialize_plan( + &first_plan, + temp.path(), + &project, + VersionedMaterializationMode::Symlink, + &logger, + ) + .unwrap(); + let second = materialize_plan( + &second_plan, + temp.path(), + &project, + VersionedMaterializationMode::Symlink, + &logger, + ) + .unwrap(); + assert_eq!(first.graph_digest, second.graph_digest); + assert_ne!(first.materialization_digest, second.materialization_digest); + assert_ne!(first.generation_dir, second.generation_dir); + assert!(first.generation_dir.is_dir()); + assert!(second.generation_dir.is_dir()); + } + + #[cfg(not(unix))] + #[test] + fn symlink_mode_fails_explicitly_when_directory_symlinks_are_unavailable() { + let temp = tempdir().unwrap(); + let graph = canonical_cycle_graph(); + let plan = LocalGraphMaterializationPlan { + schema: LOCAL_MATERIALIZATION_SCHEMA_V1.into(), + sources: write_sources(temp.path(), &graph), + graph, + }; + let logger = Logger::new(Options { + console: false, + ..Options::default() + }); + let project = temp.path().join("project"); + let error = materialize_plan( + &plan, + temp.path(), + &project, + VersionedMaterializationMode::Symlink, + &logger, + ) + .unwrap_err() + .to_string(); + assert!(error.contains("requires directory symlinks")); + assert!(!project.join("zed_modules").exists()); + } + + #[test] + fn copy_mode_fails_closed_before_recursive_mirroring() { + let temp = tempdir().unwrap(); + let graph = canonical_cycle_graph(); + let sources = write_sources(temp.path(), &graph); + let plan = LocalGraphMaterializationPlan { + schema: LOCAL_MATERIALIZATION_SCHEMA_V1.into(), + graph, + sources, + }; + let logger = Logger::new(Options { + console: false, + ..Options::default() + }); + let error = materialize_plan( + &plan, + temp.path(), + &temp.path().join("project"), + VersionedMaterializationMode::Copy, + &logger, + ) + .unwrap_err() + .to_string(); + assert!(error.contains("cannot represent a circular exact-version dependency graph")); + } +} diff --git a/tests/versioned_dependency_cycles.rs b/tests/versioned_dependency_cycles.rs new file mode 100644 index 00000000..1ca78eea --- /dev/null +++ b/tests/versioned_dependency_cycles.rs @@ -0,0 +1,289 @@ +#![cfg(unix)] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_json::Value; +use tempfile::tempdir; +use zed_cli::store::Store; +use zed_cli::versioned_graph::{ + LOCAL_MATERIALIZATION_SCHEMA_V1, LocalGraphMaterializationPlan, LocalPackageSource, + VersionedMaterializationReport, +}; +use zed_interfaces::dependency_graph::{ + DependencyGraphCompleteness, DependencyGraphData, DependencyGraphDocument, DependencyKind, + PackageVersionIdentity, RegistrySnapshot, ResolutionProvenance, ResolvedDependencyEdge, + ResolvedDependencyNode, +}; + +fn id(name: &str, version: &str) -> PackageVersionIdentity { + PackageVersionIdentity { + registry_id: "fixture-registry".into(), + org: "fixture".into(), + name: name.into(), + version: version.into(), + } +} + +fn digest(seed: u8) -> String { + format!("sha256:{}", format!("{seed:02x}").repeat(32)) +} + +fn node(id: PackageVersionIdentity, seed: u8) -> ResolvedDependencyNode { + ResolvedDependencyNode { + id, + artifact_digest: Some(digest(seed)), + features: Vec::new(), + } +} + +fn edge(from: PackageVersionIdentity, to: PackageVersionIdentity) -> ResolvedDependencyEdge { + ResolvedDependencyEdge { + from, + to, + kind: DependencyKind::Runtime, + requirement: Some("exact fixture edge".into()), + target: None, + optional: false, + features: Vec::new(), + } +} + +fn canonical_cycle_graph() -> DependencyGraphDocument { + let a1 = id("a", "1"); + let b1 = id("b", "1"); + let a2 = id("a", "2"); + let b0 = id("b", "0"); + DependencyGraphDocument { + schema: zed_interfaces::dependency_graph::DEPENDENCY_GRAPH_SCHEMA_V1.into(), + graph: DependencyGraphData::Resolved { + completeness: DependencyGraphCompleteness::Complete, + roots: vec![a1.clone()], + nodes: vec![ + node(a1.clone(), 1), + node(b1.clone(), 2), + node(a2.clone(), 3), + node(b0.clone(), 4), + ], + edges: vec![ + edge(a1, b1.clone()), + edge(b1, a2.clone()), + edge(a2.clone(), b0.clone()), + edge(b0, a2), + ], + provenance: ResolutionProvenance { + resolver_version: "versioned-cycle-integration/v1".into(), + target: "test-target".into(), + enabled_features: Vec::new(), + registry_snapshots: vec![RegistrySnapshot { + registry_id: "fixture-registry".into(), + checkpoint_digest: digest(0xee), + }], + lock_digest: digest(0xdd), + }, + parent_graph_digest: None, + projection: None, + }, + graph_digest: None, + } + .finalize() + .unwrap() +} + +fn write_payload(source: &Path, label: &str) { + fs::create_dir_all(source).unwrap(); + fs::write(source.join("payload.txt"), format!("{label}\n")).unwrap(); +} + +fn run_zed(home: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_zed")) + .arg("--home") + .arg(home) + .args(args) + .output() + .unwrap() +} + +fn parse_report(output: &Output) -> VersionedMaterializationReport { + assert!( + output.status.success(), + "status={}\nstdout={}\nstderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let line = String::from_utf8_lossy(&output.stdout) + .lines() + .last() + .unwrap() + .to_string(); + serde_json::from_str(&line).unwrap() +} + +fn follow_chain(project: &Path) -> (PathBuf, PathBuf) { + let a1 = project.join("zed_modules/fixture/a"); + let b1 = a1.join("zed_modules/fixture/b"); + let a2 = b1.join("zed_modules/fixture/a"); + let b0 = a2.join("zed_modules/fixture/b"); + let closing_a2 = b0.join("zed_modules/fixture/a"); + (a2, closing_a2) +} + +#[test] +fn public_cli_materializes_and_replays_the_four_node_cycle_without_payload_copies() { + let temp = tempdir().unwrap(); + let graph = canonical_cycle_graph(); + let DependencyGraphData::Resolved { nodes, .. } = &graph.graph else { + unreachable!() + }; + let sources = nodes + .iter() + .enumerate() + .map(|(index, node)| { + let source = temp.path().join(format!("source-{index}")); + write_payload(&source, &node.id.to_string()); + LocalPackageSource { + id: node.id.clone(), + source, + } + }) + .collect(); + let plan = LocalGraphMaterializationPlan { + schema: LOCAL_MATERIALIZATION_SCHEMA_V1.into(), + graph, + sources, + }; + let plan_path = temp.path().join("plan.json"); + fs::write(&plan_path, serde_json::to_vec_pretty(&plan).unwrap()).unwrap(); + let project = temp.path().join("project"); + let home = temp.path().join("home"); + + let first = run_zed( + &home, + &[ + "graph", + "materialize", + "--plan", + plan_path.to_str().unwrap(), + "--project", + project.to_str().unwrap(), + ], + ); + let stdout = String::from_utf8_lossy(&first.stdout); + assert!(stdout.contains("[Warn] [zed-cli] circular dependency detected:")); + assert!(stdout.contains("fixture-registry::fixture/a@2")); + assert!(stdout.contains("fixture-registry::fixture/b@0")); + assert!(stdout.contains("reuses the existing exact node through a symlink")); + let first_report = parse_report(&first); + assert_eq!(first_report.nodes, 4); + assert_eq!(first_report.edges, 4); + assert_eq!(first_report.cycles.len(), 1); + assert!(!first_report.reused_generation); + + let node_dirs = fs::read_dir(first_report.generation_dir.join("nodes")) + .unwrap() + .collect::>>() + .unwrap(); + assert_eq!(node_dirs.len(), 4); + for node_dir in node_dirs { + assert!( + fs::symlink_metadata(node_dir.path().join("root/payload.txt")) + .unwrap() + .file_type() + .is_symlink() + ); + } + let (a2, closing_a2) = follow_chain(&project); + assert_eq!( + fs::read_link(closing_a2).unwrap(), + a2.canonicalize().unwrap() + ); + + let second = run_zed( + &home, + &[ + "graph", + "materialize", + "--plan", + plan_path.to_str().unwrap(), + "--project", + project.to_str().unwrap(), + ], + ); + let second_report = parse_report(&second); + assert!(second_report.reused_generation); + assert_eq!(first_report.generation_dir, second_report.generation_dir); + assert_eq!(first_report.graph_digest, second_report.graph_digest); + assert_eq!( + first_report.materialization_digest, + second_report.materialization_digest + ); + + let copy = run_zed( + &home, + &[ + "graph", + "materialize", + "--plan", + plan_path.to_str().unwrap(), + "--project", + project.to_str().unwrap(), + "--mode", + "copy", + ], + ); + assert!(!copy.status.success()); + assert!( + String::from_utf8_lossy(©.stderr) + .contains("copy mode cannot represent a circular exact-version dependency graph") + ); +} + +#[test] +fn verified_graph_path_binds_nodes_to_the_content_addressed_store() { + let temp = tempdir().unwrap(); + let graph = canonical_cycle_graph(); + let graph_path = temp.path().join("graph.json"); + fs::write(&graph_path, graph.canonical_document_bytes().unwrap()).unwrap(); + let home = temp.path().join("home"); + let store = Store::new(&home); + let DependencyGraphData::Resolved { nodes, .. } = &graph.graph else { + unreachable!() + }; + for node in nodes { + let sha256 = node + .artifact_digest + .as_deref() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + write_payload(&store.pkg_dir(sha256), &node.id.to_string()); + } + let canonical_store_root = store.root().canonicalize().unwrap(); + let project = temp.path().join("store-project"); + let output = run_zed( + &home, + &[ + "graph", + "materialize", + "--graph", + graph_path.to_str().unwrap(), + "--project", + project.to_str().unwrap(), + ], + ); + let report = parse_report(&output); + assert_eq!(report.nodes, 4); + let node_metadata = fs::read_dir(report.generation_dir.join("nodes")) + .unwrap() + .map(|entry| { + let entry = entry.unwrap(); + serde_json::from_slice::(&fs::read(entry.path().join("node.json")).unwrap()) + .unwrap() + }) + .collect::>(); + assert!(node_metadata.iter().all(|record| { + Path::new(record["source"].as_str().unwrap()).starts_with(&canonical_store_root) + })); +}