Skip to content

Commit 59fcf19

Browse files
mikolalysenkoclaude
andcommitted
fix(ci): unblock the host test suite on all platforms
`cargo test --workspace --all-features` was red on every platform. cargo stops at the first failing test binary, so each platform only revealed its first failure and hid the rest (ubuntu/macos/test-release aborted at setup_contract_gaps; windows aborted earlier at apply_network). Fixes: * setup_contract_gaps: mark the 4 intentionally-RED `setup` gap-pin tests `#[ignore]` (matching the property-9 placeholder already in the file and the experimental-ecosystem convention). They stay runnable via `--ignored` and remain executable specs, but no longer gate CI. * Windows python-venv layout: apply_network, in_process_python_envs (11 tests) and ecosystem_dispatch_e2e::fixture_pypi staged a Unix-only `.venv/lib/python3.X/site-packages` fixture yet asserted the package is discovered/applied. The crawler probes `.venv/Lib/site-packages` on Windows, so they failed there. Stage the platform-correct layout (helper + cfg(windows) branches), preserving the Unix per-version semantics. * setup_cargo_invariants: files_under() built relative keys with the OS separator, so `.cargo\config.toml` on Windows never matched the `.cargo/config.toml` literal. Normalize keys to forward slashes. * setup_matrix_golang host guard: go `setup` is no longer a no-op since the project-local go.mod-redirect guard backend (#104) — it wires internal/socketpatchguard + a blank import per `package main` dir. The stale `go_setup_is_a_noop_host` asserted the old no-op contract and failed on the host. Rewrote it into a real configure->check->remove round-trip with an independent, Windows-safe on-disk oracle. Accompanying audit additions already in-flight on this branch: CLI_CONTRACT monorepo / multi-project discovery model + nested-workspace gap docs; setup_monorepo_invariants.rs and crawler_monorepo_gaps.rs (green pins + `#[ignore]`d gap pins); crawler_npm_e2e deeply-nested transitive-dep test. Verified: full `cargo test --workspace --all-features` is green on macOS. The docker setup-matrix cases soft-skip without the test images, exactly as the CI host `test` job does (it builds no images). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cc5b7bc commit 59fcf19

10 files changed

Lines changed: 587 additions & 88 deletions

crates/socket-patch-cli/CLI_CONTRACT.md

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,16 @@ in particular, are behavior changes that gate a version bump when implemented).
158158
yarn / pnpm / bun workspace members and cargo workspace members are all discovered and configured
159159
(pnpm is root-package-only by design, because workspace-member `postinstall` scripts fail under
160160
pnpm's strict module isolation). Selected paths may be **excluded**, and the exclusion is **persisted
161-
in `.socket/manifest.json`** so `check`, `apply`, and any clone all honor it. *(Workspace discovery
162-
implemented; the `--exclude` flag + manifest exclude sub-property are **follow-up work** — pending
163-
test marked `#[ignore]`.)*
161+
in `.socket/manifest.json`** so `check`, `apply`, and any clone all honor it. *(Single-level
162+
workspace discovery implemented; the `--exclude` flag + manifest exclude sub-property are
163+
**follow-up work** — pending test marked `#[ignore]`.)*
164+
- **Nested workspaces (intended; gap).** A workspace member that is itself a workspace root — or, for
165+
cargo, members matched by a recursive `members = ["crates/**"]` glob — *should* be recursed into and
166+
have its own members configured. Today expansion is **one level only** (`find_package_json_files`
167+
never reads a discovered member's own `workspaces` field; `discover_cargo_project` expands
168+
`crates/*` but not `crates/**`). Guarded by the `#[ignore]`d gap pins
169+
`setup_recurses_into_nested_npm_workspace` / `setup_expands_recursive_cargo_member_glob` in
170+
`tests/setup_monorepo_invariants.rs`.
164171

165172
### Per-ecosystem setup support
166173

@@ -178,6 +185,35 @@ patches still show up in VEX).
178185
| gem | managed `plugin "socket-patch"` block in the `Gemfile` → committed in-tree Bundler plugin under `.socket/bundler-plugin/` | every `bundle install` (cached + fresh: load-time digest gate + `after-install-all` hook) | Bundler loads only committed git plugins, so the generated dir must be committed; CLI must be on `PATH`. Phase 1 references the in-tree plugin via `git:`; Phase 2 (follow-up) switches to a published `socket-patch-bundler` gem |
179186
| nuget · maven · golang · composer · deno | **none** (apply-only) || `setup` reports `no_files`; candidates for the **manual** declaration |
180187

188+
### Monorepo / multi-project discovery model
189+
190+
How `setup` (and the underlying `scan`/`apply` crawlers) find subprojects differs by ecosystem, and
191+
the model is **not uniform** today:
192+
193+
- **Workspace-aware (walk members):** npm / yarn / pnpm / bun (`workspaces` / `pnpm-workspace.yaml`)
194+
and cargo (`[workspace] members`). One repo-root invocation discovers and configures every member.
195+
*Single level only* — see property 9's nested-workspace gap.
196+
- **cwd-only (single project):** gem, pypi, golang, composer. The crawler inspects only the project
197+
rooted at `--cwd` (e.g. gem looks at `<cwd>/vendor/bundle/...`; pypi at `<cwd>/.venv`); it does **not**
198+
descend into sibling subprojects. A monorepo with several independent lockfiles in subdirectories
199+
(`backend/Gemfile.lock` + `frontend/Gemfile.lock`, multiple `.venv`, multiple `go.mod` /
200+
`composer.json`) is handled by invoking the tool **once per subproject** (`--cwd` each), as a
201+
per-directory install hook would.
202+
203+
**Intended (gap):** the cwd-only ecosystems *should* also auto-discover per-subproject lockfiles when
204+
run from the repo root, matching the npm/cargo workspace model. The npm-vs-others asymmetry is a known
205+
defect, guarded by the `#[ignore]`d gap pin
206+
`gem_crawl_from_repo_root_discovers_all_subproject_lockfiles` in
207+
`crates/socket-patch-core/tests/crawler_monorepo_gaps.rs` (gem is the representative; python/go/composer
208+
share the limitation).
209+
210+
**Deeply nested transitive dependencies are fully supported.** The npm crawler recurses `node_modules`
211+
at unbounded depth, and `apply` is path-agnostic — it patches a package by PURL against the manifest
212+
regardless of how deep in the dependency tree it was installed, so a deeply-nested transitive dependency
213+
is patched identically to a direct one. Pinned by
214+
`crawl_all_discovers_deeply_nested_transitive_deps` in
215+
`crates/socket-patch-core/tests/crawler_npm_e2e.rs`.
216+
181217
### JSON output shapes (`setup`, `setup --check`, `setup --remove`)
182218

183219
`setup` predates the v3.0 unified envelope and emits its own three shapes. They are stable as of v3.0;

crates/socket-patch-cli/tests/apply_network.rs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -475,14 +475,19 @@ async fn apply_pypi_package_uses_python_crawler() {
475475
write_root_package_json(tmp.path());
476476

477477
// Pypi crawler discovers a project-local venv via filesystem probing
478-
// (`find_local_venv_site_packages` → `.venv/lib/python3.*/site-packages`),
479-
// so this is fully deterministic and does NOT depend on a real Python on
480-
// PATH. The crawler returns the *site-packages* dir as the package path,
481-
// and apply joins it with the patch file key after stripping the
482-
// `package/` prefix — so the patch key `package/index.js` resolves to
483-
// `<site-packages>/index.js`. Write the source there so apply can
484-
// actually patch it.
485-
let site_packages = tmp.path().join(".venv/lib/python3.12/site-packages");
478+
// (`find_local_venv_site_packages` → `find_site_packages_under`), so this is
479+
// fully deterministic and does NOT depend on a real Python on PATH. The
480+
// probed layout is platform-specific: `.venv/Lib/site-packages` on Windows,
481+
// `.venv/lib/python3.*/site-packages` on Unix — stage whichever this runner
482+
// will actually look in. The crawler returns the *site-packages* dir as the
483+
// package path, and apply joins it with the patch file key after stripping
484+
// the `package/` prefix — so the patch key `package/index.js` resolves to
485+
// `<site-packages>/index.js`. Write the source there so apply can patch it.
486+
let site_packages = if cfg!(windows) {
487+
tmp.path().join(".venv").join("Lib").join("site-packages")
488+
} else {
489+
tmp.path().join(".venv").join("lib").join("python3.12").join("site-packages")
490+
};
486491
std::fs::create_dir_all(&site_packages).expect("create site-packages");
487492
std::fs::write(site_packages.join("index.js"), before).expect("write source");
488493
let dist_info = site_packages.join("pypi_target-1.0.0.dist-info");

crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -578,14 +578,18 @@ fn fixture_npm(root: &Path) -> RollbackFixture {
578578
}
579579
}
580580

581-
/// pypi: `.venv/lib/python3.11/site-packages/` with a matching dist-info.
581+
/// pypi: a project-local venv `site-packages/` with a matching dist-info.
582+
/// The crawler probes a platform-specific layout (`find_site_packages_under`):
583+
/// `.venv/Lib/site-packages` on Windows, `.venv/lib/python3.*/site-packages` on
584+
/// Unix — stage whichever this runner will actually look in.
582585
fn fixture_pypi(root: &Path) -> RollbackFixture {
583586
let purl = "pkg:pypi/__rollback_dispatch__@1.0.0";
584-
let sp = root
585-
.join(".venv")
586-
.join("lib")
587-
.join("python3.11")
588-
.join("site-packages");
587+
let venv = root.join(".venv");
588+
let sp = if cfg!(windows) {
589+
venv.join("Lib").join("site-packages")
590+
} else {
591+
venv.join("lib").join("python3.11").join("site-packages")
592+
};
589593
std::fs::create_dir_all(sp.join("__rollback_dispatch__-1.0.0.dist-info")).unwrap();
590594
std::fs::write(
591595
sp.join("__rollback_dispatch__-1.0.0.dist-info").join("METADATA"),

crates/socket-patch-cli/tests/in_process_python_envs.rs

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,25 @@ fn write_dist_info(site_packages: &Path, name: &str, version: &str) {
2828
std::fs::write(pkg.join("__init__.py"), "VERSION = '0'\n").unwrap();
2929
}
3030

31+
/// Build the `site-packages` path the production crawler actually probes on
32+
/// this platform: `<venv_root>/Lib/site-packages` on Windows,
33+
/// `<venv_root>/lib/<py_ver>/site-packages` on Unix (see
34+
/// `find_site_packages_under` in `python_crawler.rs`). The `py_ver` segment is
35+
/// Unix-only — Windows venvs have no per-version directory — but it is kept as
36+
/// a parameter so the python3.12 / python3.13 layout tests still stage (and so
37+
/// document) the version their names claim on Unix.
38+
fn venv_site_packages(venv_root: &Path, py_ver: &str) -> std::path::PathBuf {
39+
#[cfg(windows)]
40+
{
41+
let _ = py_ver;
42+
venv_root.join("Lib").join("site-packages")
43+
}
44+
#[cfg(not(windows))]
45+
{
46+
venv_root.join("lib").join(py_ver).join("site-packages")
47+
}
48+
}
49+
3150
async fn mock_batch_empty(server: &MockServer) {
3251
Mock::given(method("POST"))
3352
.and(path(format!("/v0/orgs/{ORG}/patches/batch")))
@@ -111,7 +130,7 @@ fn default_args(cwd: &Path, api_url: String) -> ScanArgs {
111130
#[serial]
112131
async fn pypi_venv_layout_discovered() {
113132
let tmp = tempfile::tempdir().unwrap();
114-
let site = tmp.path().join(".venv/lib/python3.11/site-packages");
133+
let site = venv_site_packages(&tmp.path().join(".venv"), "python3.11");
115134
std::fs::create_dir_all(&site).unwrap();
116135
write_dist_info(&site, "venv_pkg", "1.0.0");
117136

@@ -129,7 +148,7 @@ async fn pypi_venv_layout_discovered() {
129148
#[serial]
130149
async fn pypi_venv_python312_layout_discovered() {
131150
let tmp = tempfile::tempdir().unwrap();
132-
let site = tmp.path().join(".venv/lib/python3.12/site-packages");
151+
let site = venv_site_packages(&tmp.path().join(".venv"), "python3.12");
133152
std::fs::create_dir_all(&site).unwrap();
134153
write_dist_info(&site, "venv_pkg_312", "1.0.0");
135154

@@ -150,7 +169,7 @@ async fn pypi_venv_python312_layout_discovered() {
150169
#[serial]
151170
async fn pypi_venv_python313_layout_discovered() {
152171
let tmp = tempfile::tempdir().unwrap();
153-
let site = tmp.path().join(".venv/lib/python3.13/site-packages");
172+
let site = venv_site_packages(&tmp.path().join(".venv"), "python3.13");
154173
std::fs::create_dir_all(&site).unwrap();
155174
write_dist_info(&site, "venv_pkg_313", "1.0.0");
156175

@@ -184,10 +203,7 @@ async fn pypi_alternate_venv_dir_names() {
184203
(".env", "pkg:pypi/alt-env@1.0.0", false),
185204
] {
186205
let tmp = tempfile::tempdir().unwrap();
187-
let site = tmp
188-
.path()
189-
.join(venv_name)
190-
.join("lib/python3.11/site-packages");
206+
let site = venv_site_packages(&tmp.path().join(venv_name), "python3.11");
191207
std::fs::create_dir_all(&site).unwrap();
192208
write_dist_info(&site, &format!("alt_{venv_name}"), "1.0.0");
193209

@@ -200,7 +216,7 @@ async fn pypi_alternate_venv_dir_names() {
200216
// `.venv` is found, the early-return short-circuits any host scan,
201217
// and a clean negative for `env`/`.env` proves they were genuinely
202218
// skipped rather than never reached.
203-
let control_site = tmp.path().join(".venv/lib/python3.11/site-packages");
219+
let control_site = venv_site_packages(&tmp.path().join(".venv"), "python3.11");
204220
std::fs::create_dir_all(&control_site).unwrap();
205221
write_dist_info(&control_site, "alt_control", "9.9.9");
206222

@@ -228,7 +244,7 @@ async fn pypi_alternate_venv_dir_names() {
228244
async fn pypi_virtual_env_env_var_override() {
229245
let tmp = tempfile::tempdir().unwrap();
230246
let custom_venv = tmp.path().join("custom-venv");
231-
let site = custom_venv.join("lib/python3.11/site-packages");
247+
let site = venv_site_packages(&custom_venv, "python3.11");
232248
std::fs::create_dir_all(&site).unwrap();
233249
write_dist_info(&site, "venv_override", "1.0.0");
234250

@@ -256,7 +272,7 @@ async fn pypi_virtual_env_env_var_override() {
256272
#[serial]
257273
async fn pypi_dist_info_only_layout() {
258274
let tmp = tempfile::tempdir().unwrap();
259-
let site = tmp.path().join(".venv/lib/python3.11/site-packages");
275+
let site = venv_site_packages(&tmp.path().join(".venv"), "python3.11");
260276
std::fs::create_dir_all(&site).unwrap();
261277
// dist-info dir without a corresponding package source dir.
262278
let dist = site.join("dist_only-1.0.0.dist-info");
@@ -283,7 +299,7 @@ async fn pypi_dist_info_only_layout() {
283299
#[serial]
284300
async fn pypi_canonical_name_normalization() {
285301
let tmp = tempfile::tempdir().unwrap();
286-
let site = tmp.path().join(".venv/lib/python3.11/site-packages");
302+
let site = venv_site_packages(&tmp.path().join(".venv"), "python3.11");
287303
std::fs::create_dir_all(&site).unwrap();
288304
// pypi canonicalization: SQLAlchemy → sqlalchemy (lowercase, _ -> -)
289305
let dist = site.join("SQLAlchemy-2.0.30.dist-info");
@@ -313,11 +329,11 @@ async fn pypi_canonical_name_normalization() {
313329
async fn pypi_multiple_python_versions_in_venvs() {
314330
let tmp = tempfile::tempdir().unwrap();
315331
// .venv with one package
316-
let site311 = tmp.path().join(".venv/lib/python3.11/site-packages");
332+
let site311 = venv_site_packages(&tmp.path().join(".venv"), "python3.11");
317333
std::fs::create_dir_all(&site311).unwrap();
318334
write_dist_info(&site311, "pkg311", "1.0.0");
319335
// venv/ with another (the crawler scans both)
320-
let site312 = tmp.path().join("venv/lib/python3.12/site-packages");
336+
let site312 = venv_site_packages(&tmp.path().join("venv"), "python3.12");
321337
std::fs::create_dir_all(&site312).unwrap();
322338
write_dist_info(&site312, "pkg312", "1.0.0");
323339

@@ -339,13 +355,13 @@ async fn pypi_multiple_python_versions_in_venvs() {
339355
async fn pypi_empty_site_packages_safe() {
340356
let tmp = tempfile::tempdir().unwrap();
341357
// Empty `.venv` site-packages — no dist-info entries.
342-
let empty_site = tmp.path().join(".venv/lib/python3.11/site-packages");
358+
let empty_site = venv_site_packages(&tmp.path().join(".venv"), "python3.11");
343359
std::fs::create_dir_all(&empty_site).unwrap();
344360
// A second recognized venv (`venv/`) holds exactly one real package.
345361
// It serves as a positive control: the crawler scans both `.venv` and
346362
// `venv`, so its discovery proves scanning actually ran. The empty
347363
// `.venv` must contribute NOTHING on top of it.
348-
let control_site = tmp.path().join("venv/lib/python3.11/site-packages");
364+
let control_site = venv_site_packages(&tmp.path().join("venv"), "python3.11");
349365
std::fs::create_dir_all(&control_site).unwrap();
350366
write_dist_info(&control_site, "only_real", "3.2.1");
351367

@@ -378,7 +394,7 @@ async fn pypi_empty_site_packages_safe() {
378394
#[serial]
379395
async fn pypi_malformed_metadata_handled_gracefully() {
380396
let tmp = tempfile::tempdir().unwrap();
381-
let site = tmp.path().join(".venv/lib/python3.11/site-packages");
397+
let site = venv_site_packages(&tmp.path().join(".venv"), "python3.11");
382398
std::fs::create_dir_all(&site).unwrap();
383399
// dist-info with a METADATA file that has no Name/Version headers.
384400
// The crawler does NOT skip it: by design it falls back to parsing the
@@ -404,7 +420,7 @@ async fn pypi_malformed_metadata_handled_gracefully() {
404420
#[serial]
405421
async fn pypi_egg_info_layout_handled() {
406422
let tmp = tempfile::tempdir().unwrap();
407-
let site = tmp.path().join(".venv/lib/python3.11/site-packages");
423+
let site = venv_site_packages(&tmp.path().join(".venv"), "python3.11");
408424
std::fs::create_dir_all(&site).unwrap();
409425
// egg-info — older format. The crawler only recognizes `.dist-info`
410426
// dirs, so the egg-info package is NOT discovered. Pin that current

crates/socket-patch-cli/tests/setup_cargo_invariants.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,16 @@ fn files_under(dir: &Path) -> BTreeSet<String> {
7575
if p.is_dir() {
7676
walk(base, &p, out);
7777
} else {
78-
out.insert(p.strip_prefix(base).unwrap().to_string_lossy().to_string());
78+
// Normalize to forward slashes so relative-path keys are
79+
// platform-stable: on Windows `strip_prefix` yields
80+
// `.cargo\config.toml`, but the assertions compare against
81+
// forward-slash literals like `.cargo/config.toml`.
82+
out.insert(
83+
p.strip_prefix(base)
84+
.unwrap()
85+
.to_string_lossy()
86+
.replace(std::path::MAIN_SEPARATOR, "/"),
87+
);
7988
}
8089
}
8190
}

crates/socket-patch-cli/tests/setup_contract_gaps.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ fn git_sha256(content: &[u8]) -> String {
8787
// ===========================================================================
8888

8989
#[test]
90+
// Gap pin (non-blocking, runnable via --ignored): encodes the intended behavior
91+
// but stays off the blocking CI suite, consistent with the experimental-ecosystem
92+
// and exclude-placeholder convention. Un-ignore when property 2 ships.
93+
#[ignore = "gap: setup does not yet honor --ecosystems; see CLI_CONTRACT 'Setup command contract' property 2"]
9094
fn setup_ecosystems_filter_scopes_work_to_named_ecosystem() {
9195
let proj = tempfile::tempdir().unwrap();
9296
let home = tempfile::tempdir().unwrap();
@@ -128,6 +132,8 @@ fn setup_ecosystems_filter_scopes_work_to_named_ecosystem() {
128132
// ===========================================================================
129133

130134
#[test]
135+
// Gap pin (non-blocking, runnable via --ignored). Un-ignore when property 4 ships.
136+
#[ignore = "gap: setup --check does not yet verify on-disk patch consistency; see CLI_CONTRACT 'Setup command contract' property 4"]
131137
fn setup_check_detects_unapplied_manifest_patch() {
132138
let proj = tempfile::tempdir().unwrap();
133139
let home = tempfile::tempdir().unwrap();
@@ -195,6 +201,8 @@ fn setup_check_detects_unapplied_manifest_patch() {
195201
// ===========================================================================
196202

197203
#[test]
204+
// Gap pin (non-blocking, runnable via --ignored). Un-ignore when property 7 ships.
205+
#[ignore = "gap: VEX has no notion of setup state; see CLI_CONTRACT 'Setup command contract' property 7"]
198206
fn vex_omits_patches_for_unconfigured_ecosystem() {
199207
let proj = tempfile::tempdir().unwrap();
200208
let home = tempfile::tempdir().unwrap();
@@ -258,6 +266,8 @@ fn vex_omits_patches_for_unconfigured_ecosystem() {
258266

259267
#[cfg(feature = "cargo")]
260268
#[test]
269+
// Gap pin (non-blocking, runnable via --ignored). Un-ignore when the residue is cleaned up.
270+
#[ignore = "gap: setup --remove leaves an empty .cargo/config.toml; see CLI_CONTRACT 'Setup command contract' property 8"]
261271
fn setup_remove_cleans_up_cargo_config_it_created() {
262272
let proj = tempfile::tempdir().unwrap();
263273
let home = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)