gemkeeper 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -1
  3. data/README.md +11 -11
  4. data/lib/gemkeeper/bundler_mirror_configurator.rb +1 -1
  5. data/lib/gemkeeper/cli/commands/list.rb +2 -2
  6. data/lib/gemkeeper/cli/commands/server/start.rb +4 -4
  7. data/lib/gemkeeper/cli/commands/server/status.rb +3 -3
  8. data/lib/gemkeeper/cli/commands/server/stop.rb +3 -3
  9. data/lib/gemkeeper/cli/commands/sync.rb +1 -1
  10. data/lib/gemkeeper/compact_index_server/cache_meta.rb +34 -0
  11. data/lib/gemkeeper/compact_index_server/cache_store.rb +64 -0
  12. data/lib/gemkeeper/compact_index_server/gem_cache.rb +88 -0
  13. data/lib/gemkeeper/compact_index_server/gem_index.rb +78 -0
  14. data/lib/gemkeeper/compact_index_server/index_merger.rb +81 -0
  15. data/lib/gemkeeper/compact_index_server/response.rb +12 -0
  16. data/lib/gemkeeper/compact_index_server/response_builder.rb +63 -0
  17. data/lib/gemkeeper/compact_index_server/rubygems_client.rb +59 -0
  18. data/lib/gemkeeper/compact_index_server/spec_mapper.rb +38 -0
  19. data/lib/gemkeeper/compact_index_server/upload_handler.rb +36 -0
  20. data/lib/gemkeeper/compact_index_server/upstream_cache.rb +26 -0
  21. data/lib/gemkeeper/compact_index_server.rb +131 -0
  22. data/lib/gemkeeper/configuration.rb +1 -1
  23. data/lib/gemkeeper/gem_syncer.rb +53 -84
  24. data/lib/gemkeeper/gem_uploader.rb +26 -18
  25. data/lib/gemkeeper/rackup_process.rb +12 -7
  26. data/lib/gemkeeper/repo_fetcher.rb +80 -0
  27. data/lib/gemkeeper/server_manager.rb +1 -1
  28. data/lib/gemkeeper/version.rb +1 -1
  29. data/lib/gemkeeper.rb +2 -0
  30. data/specs/20260529-091429-replace-geminabox-compact-proxy/critique-consolidated-v-1.md +168 -0
  31. data/specs/20260529-091429-replace-geminabox-compact-proxy/critique-v-1-claude.md +124 -0
  32. data/specs/20260529-091429-replace-geminabox-compact-proxy/critique-v-1-codex.md +125 -0
  33. data/specs/20260529-091429-replace-geminabox-compact-proxy/critique-v-1-copilot.md +261 -0
  34. data/specs/20260529-091429-replace-geminabox-compact-proxy/spec.md +360 -0
  35. data/specs/20260529-131354-sync-serve-cache-contract/critique-consolidated-v-1.md +95 -0
  36. data/specs/20260529-131354-sync-serve-cache-contract/critique-v-1-claude.md +47 -0
  37. data/specs/20260529-131354-sync-serve-cache-contract/critique-v-1-codex.md +112 -0
  38. data/specs/20260529-131354-sync-serve-cache-contract/critique-v-1-copilot.md +169 -0
  39. data/specs/20260529-131354-sync-serve-cache-contract/implementation-summary.md +59 -0
  40. data/specs/20260529-131354-sync-serve-cache-contract/spec.md +169 -0
  41. metadata +38 -28
@@ -0,0 +1,95 @@
1
+ # Spec 20260529-131354: Consolidated Critique (v1)
2
+
3
+ ## Overview
4
+
5
+ **Critiques received from:** Claude, Codex, Copilot (claude-sonnet-4.6)
6
+ **Critiques missing:** Gemini (not installed; Copilot used as the third critic)
7
+
8
+ ## Executive Summary
9
+
10
+ All three critics agree the spec targets the right bug with the right shape (server-authoritative skip + artifact re-upload, uploader-seam placement, both deployment modes preserved). But they converge on one finding that **undercuts the chosen mechanism**: `GET /info/<name>` is *not* a private-store-authoritative signal. `CompactIndexServer#serve_info` falls back to `serve_upstream_info` (rubygems.org) when the gem isn't in the private `GemIndex` (`compact_index_server.rb:74`). That breaks the presence check two ways:
11
+
12
+ 1. **Public name-collision false positive** — a public gem sharing the private gem's name/version makes `/info` return 200, so `sync` wrongly skips while the private store is still missing the artifact (Codex risk 1, Claude point 2).
13
+ 2. **Offline failure / slow recovery** — a missing private gem triggers an *upstream* probe with 5s/10s timeouts, returning **503** (not 404) when offline. The spec only treats 404 as "not present," and 503 is exactly the recovery scenario the spec exists to fix (Copilot MR-2, Codex risk 2).
14
+
15
+ This means **Q1 (the cache-check mechanism) needs to be reconsidered**: "reuse `/info`, no new server surface" is not actually authoritative. The fix is either a private-store-only signal (small dedicated endpoint, or a flag/header on the existing one) or a checksum comparison. The dedicated-endpoint option I originally dismissed is now the better-justified path.
16
+
17
+ Beyond that, the strongest convergent gaps are the `version: latest` contradiction, platform filenames, the `build_and_upload` decomposition, and HTTP-status handling.
18
+
19
+ ## Consolidated Requirements Feedback
20
+
21
+ ### A. `/info` is not private-authoritative (mechanism flaw) — HIGHEST PRIORITY
22
+ **Issue:** The presence check must reflect only the private uploaded store, never the upstream-proxied merge.
23
+ **Agreement:** All three. Codex and Copilot independently trace the `serve_upstream_info` fallback; Claude flagged the public-name-collision edge.
24
+ **Divergence:** Mechanism. Codex: add a private presence contract *or* a checksum-based rule. Copilot: at minimum treat 503 as not-present. Claude: pin an AR that presence is read only from the private index.
25
+ **Recommendation:** Reverse the Q1 decision toward a **private-store-only presence signal**. Cleanest: a tiny read-only endpoint that consults `GemIndex` only (e.g. `GET /gemkeeper/has/<name>/<version>` → 200/404, or `HEAD /gems/<file>` wired to the private store), returning unambiguous present/absent with no upstream probe. This also removes the private-name leak to rubygems.org and the offline-timeout problem in one move. If avoiding a new endpoint is still preferred, define a checksum rule and explicit 503-means-not-present handling — but the endpoint is simpler and more correct.
26
+
27
+ ### B. `version: latest` contradicts "never re-clone"
28
+ **Issue:** Goals promise recovery never re-clones, but `latest` resolves its version only post-checkout, so it must clone first.
29
+ **Agreement:** All three (Claude point 4, Codex risk 3, Copilot MR-1/PI-1).
30
+ **Recommendation:** Pick one and write it down: (a) scope the no-reclone guarantee to pinned + `from_lockfile` versions, and state `latest` keeps today's always-fetch behavior; or (b) for `latest` with a local artifact, read the version from the artifact via `Gem::Package.new(path).spec.version` to avoid cloning. (a) is the smaller, safer change and matches today's `!gem_def.latest?` cache bypass; recommend (a) unless offline `latest` recovery is a stated requirement.
31
+
32
+ ### C. Platform filenames
33
+ **Issue:** Presence is framed as `(name, version)`, but artifacts/served files can be `<name>-<version>-<platform>.gem` (`SpecMapper.filename`).
34
+ **Agreement:** Claude point 1, Codex risk 4.
35
+ **Recommendation:** Either declare private gems pure-Ruby (filename `<name>-<version>.gem`) as an explicit assumption, or make presence/artifact lookup operate on the exact filename including platform. Given these are internal source-built gems, the pure-Ruby assumption is likely fine — but it must be stated.
36
+
37
+ ### D. Artifact integrity before re-upload
38
+ **Issue:** FR-1.3 re-uploads a local `.gem` without validating it; a partial/corrupt artifact (interrupted build) would fail server-side in `GemIndex#add` (`Gem::Package.new(...).spec`).
39
+ **Agreement:** Codex (missing req), Copilot EH-3.
40
+ **Recommendation:** Require a pre-upload integrity/identity check — parse the artifact's spec and confirm name+version match the requested gem before declaring success — or explicitly scope corrupt-artifact handling out. Recommend the lightweight check; it also closes Codex's "upload the wrong thing" concern.
41
+
42
+ ### E. `build_and_upload` decomposition unspecified
43
+ **Issue:** FR-1.3 needs an upload-without-build path, but `GemSyncer#build_and_upload` does both unconditionally and the spec doesn't name the structural split.
44
+ **Agreement:** Copilot MR-3, Codex "refactoring GemSyncer ordering."
45
+ **Recommendation:** Add an AR naming the flow: defer repo/manifest resolution until after the server-missing check; separate "upload existing artifact" from "build then upload." This also matters because today `sync` resolves the repo *before* the cache check (`gem_syncer.rb:21`) — ordering must change.
46
+
47
+ ### F. HTTP status handling + counting/messaging
48
+ **Issue:** AR-1.4 covers unreachable + malformed but not the full status matrix (400/500/503), and the CLI summary only knows `:synced`/`:skipped` while the spec introduces a third outcome.
49
+ **Agreement:** All three.
50
+ **Recommendation:** Add a status table: 200→inspect, 404 (and 503-from-upstream-miss, if `/info` is retained)→not present, 400→programming error (raise, not "absent"), connection failure→`ServerNotReachableError`. Define whether artifact re-upload counts as `:synced` (recommended; differentiate via output text only) or a new symbol that `run_sync`/`report_results` must learn.
51
+
52
+ ### G. Faraday connection reuse for GET
53
+ **Issue:** `GemUploader#connection` carries `:multipart`/`:url_encoded` middleware meant for `POST /upload`; reusing it for a GET is wasteful and theoretically fragile.
54
+ **Agreement:** Copilot CA-1.
55
+ **Recommendation:** Acceptable to reuse (note it), or use a plain connection for read requests. Low priority.
56
+
57
+ ### H. `list_gems` vs `has_version?` ambiguity
58
+ **Issue:** AR-1.1's "e.g. `has_version?` / replacing the `list_gems` stub" conflates two different contracts.
59
+ **Agreement:** Copilot AM-1.
60
+ **Recommendation:** State explicitly: add `has_version?(name, version)` (or the chosen private-presence call); leave or remove `list_gems` deliberately, not as a side effect.
61
+
62
+ ### I. Security is not strictly N/A
63
+ **Issue:** The `/info` upstream fallback can leak private gem names to rubygems.org; client doesn't validate the name before building the URL.
64
+ **Agreement:** Codex (FAIL), Copilot (defense-in-depth note).
65
+ **Recommendation:** If the private-endpoint fix (A) is adopted, the leak disappears. Regardless, add a note: client treats 400 as a programming error; gem names come from config/manifest and should match `VALID_NAME`. Downgrade from "N/A" to "low, with these mitigations."
66
+
67
+ ## Additional Requirements Identified
68
+
69
+ - **AR:** Presence is determined solely from the private index, never an upstream-proxied response (resolves A, I).
70
+ - **AR:** Repo/manifest resolution is deferred until after the server-presence and local-artifact checks (resolves E).
71
+ - **FR:** Before re-uploading an existing artifact, verify its embedded spec name+version match the requested gem (resolves D).
72
+ - **FR/AR:** Define the presence-check HTTP status → outcome mapping (resolves F).
73
+ - **Testing:** `test_gem_syncer.rb` currently tests only `resolve_repo` — `sync()` orchestration tests must be **created**. Add integration coverage for: empty server + local artifact (the original bug), divergent server `gems_path`, offline upstream, upload 409 conflict, and public-name collision (all critics).
74
+
75
+ ## Ambiguities Requiring Clarification
76
+
77
+ 1. **Mechanism for private-authoritative presence** (new endpoint vs. checksum vs. retained `/info` + 503 handling) — the central open decision.
78
+ 2. **`latest` scope** — accept always-fetch, or add artifact version-read path.
79
+ 3. **Platform** — pure-Ruby assumption or full filename matching.
80
+ 4. **Third-outcome counting** — `:synced` vs new symbol.
81
+
82
+ ## Summary of Required Changes
83
+
84
+ 1. **Reconsider Q1:** make the presence check private-store-authoritative (recommend a small read-only private endpoint; eliminates name-collision, offline-503, and name-leak issues at once).
85
+ 2. Resolve the `latest` / "never re-clone" contradiction (recommend: scope guarantee to pinned + `from_lockfile`).
86
+ 3. State the platform assumption (recommend: private gems pure-Ruby, filename `<name>-<version>.gem`).
87
+ 4. Require pre-upload artifact identity check (name+version match).
88
+ 5. Specify the `GemSyncer` flow change: defer repo/manifest resolution; split build vs. upload-existing.
89
+ 6. Add the HTTP status→outcome table and the third-outcome counting decision.
90
+ 7. Resolve `list_gems`/`has_version?`; note Faraday connection choice.
91
+ 8. Update testing: create `sync()` tests + the listed integration scenarios; downgrade security from N/A with explicit mitigations.
92
+
93
+ ## Verdict
94
+
95
+ Right problem, right overall shape, well-bounded scope — but **NEEDS WORK before implementation**, primarily because the recommended `/info` mechanism isn't private-authoritative. That single decision (change A) cascades into the security and offline-503 items. With A resolved plus the `latest`, platform, decomposition, and status-handling tightenings, this is ready to implement.
@@ -0,0 +1,47 @@
1
+ # Critique (v1) — Claude
2
+
3
+ Spec: Server-authoritative sync cache check (`20260529-131354`)
4
+
5
+ ## Summary
6
+
7
+ The spec correctly diagnoses the root cause and picks the right core fix (server-authoritative skip via the existing `/info` endpoint, with artifact re-upload to avoid rebuilds). Scope is well-bounded and the deployment-mode constraint (AR-1.3) is the key insight that keeps the design honest. Below are gaps that would cause an implementer to stop and ask, plus a few correctness traps in the actual `/info` and upload mechanics.
8
+
9
+ ## Blocking / should-fix
10
+
11
+ ### 1. `/info` presence parsing is under-specified against the real format (FR-1.2)
12
+ The compact-index info document produced by `CompactIndex.info` is **not** a flat list of versions. Each line is roughly `VERSION DEP:REQ,...|checksum:...,ruby:...`, and the document begins with a `---` header line. FR-1.2 says "contains a version line for that exact version," but doesn't pin down:
13
+ - That the match must be on the **first whitespace-delimited token** of a line, anchored, not a substring (`1.0.5` must not match `1.0.50` or a checksum that happens to contain `1.0.5`).
14
+ - That the leading `---` and any blank lines are ignored.
15
+ - **Platform variants:** a gem can have multiple lines for the same version with different platforms (e.g. `1.0.5` and `1.0.5-x86_64-darwin`). The spec treats presence as `(name, version)` only. If a platformed gem is involved, "version present" may be true while the *specific artifact filename* `<name>-<version>-<platform>.gem` is still missing from the server. Either declare platforms out of scope explicitly, or check the artifact filename, not just the version token.
16
+
17
+ Recommend FR-1.2 specify anchored first-token matching and state the platform assumption (private gems are pure-Ruby → filename is `<name>-<version>.gem`); if that assumption holds it should be written down, because `SpecMapper.filename` already branches on platform.
18
+
19
+ ### 2. Presence-vs-served gap: `/info` is built from `GemIndex`, but is it the same store the binary is served from? (FR-1.1/FR-1.2)
20
+ The skip decision trusts `/info` to mean "the server can serve `/gems/<file>`." In the current server, `serve_info` reads `@index[gemname]` (from `GemIndex`, i.e. `gems_path/gems`) and `serve_gem_file` reads `@index.gem_path || @cache.gem_binary`. These share `@index`, so they should agree — but the spec should state this invariant explicitly as the thing it depends on: **a version appearing in `/info` implies `/gems/<file>` is serveable from the private store.** If that ever stops holding (e.g. `/info` proxied upstream), the skip becomes wrong. Worth an AR pinning "presence is determined only from the private index, never an upstream-proxied `/info`." Note `serve_info` falls back to `@cache.info(gemname)` (upstream) when the gem isn't private — for a private gem name that the server doesn't have, `/info` would proxy to rubygems.org, 404, and return not-found, which is fine; but a name collision with a public gem could make `/info` return a *public* document and produce a false "present." This edge (private gem sharing a name with a public gem) should be acknowledged.
21
+
22
+ ### 3. "Existing local artifact" lookup location is ambiguous (FR-1.3, AR-1.2)
23
+ FR-1.3 says re-upload when "the corresponding `.gem` already exists in the local `gems_path`." But `cached?` today checks `gems_path/<name>-<bare>.gem` (flat) while the server store is `gems_path/gems/`. The spec should state unambiguously that the artifact lookup is the **flat build-output location** (`gems_path/<name>-<version>.gem`, where `GemBuilder` writes), to avoid an implementer re-introducing the same flat-vs-nested confusion the spec is trying to fix. Tie it to `SpecMapper.filename`/the bare-semver key so the filename is derived one way.
24
+
25
+ ### 4. `version: latest` interaction needs the ordering spelled out (AR-1.2)
26
+ For `latest`, the version isn't known until after clone+checkout (`current_version` post-checkout). So the "ask server first, skip without building" optimization **cannot apply to `latest`** — you must fetch the repo to learn the version before you can query `/info`. The spec acknowledges ordering in AR-1.2 but doesn't state the consequence: `latest` gems always incur a fetch (today they do too — `cached?` is bypassed for `latest` via `!gem_def.latest?` in `sync`). Make explicit that `latest` keeps today's behavior (always fetch, then the server check applies to the resolved version for the *upload/skip* decision), so the skip optimization is for pinned versions only.
27
+
28
+ ## Edge cases / smaller
29
+
30
+ - **FR-1.4 conflict handling:** `UploadHandler` maps `Errno::EEXIST` → `409 "Gem already exists"`. `GemUploader#handle_response` currently treats `409` as `{ success: true, skipped: true }`. Good — the spec's "treat conflict as skip" already matches code, but FR-1.4 should cite the 409 path so the implementer doesn't change it.
31
+ - **Counting/reporting (FR-1.5):** the `sync` command tallies `:synced`/`:skipped` from `GemSyncer#sync`'s return symbol. Adding a third outcome (artifact re-upload) — is it `:synced` or a new symbol? `report_results` only knows two. Decide whether re-upload counts as `:synced` (simplest, keeps the tally) with differentiated *output text*, or a new `:uploaded` symbol (touches `run_sync`/`report_results`). The spec implies differentiated messaging but not the symbol contract.
32
+ - **Idempotency race (FR-1.4):** "upload that races with an already-present gem" — concurrency isn't really present (sync is sequential), so this is just the 409 path. Reword to avoid implying real concurrency handling is required.
33
+ - **Malformed `/info` → not-present (AR-1.4):** treating malformed as not-present means a flaky/garbage response triggers an upload attempt, which then 409s or 201s harmlessly. That's a safe failure direction; worth stating that "not-present on parse failure" is deliberately biased toward re-uploading rather than skipping.
34
+ - **`reachable?` already exists** on `GemUploader` but `sync` doesn't currently call it; the new presence call effectively becomes the reachability probe. Consider whether the first presence call should produce the not-reachable error early (per-gem vs once up front).
35
+
36
+ ## Testing
37
+
38
+ - The spec leans on stubbing `/info`. `test_gem_uploader.rb` likely already stubs Faraday — confirm the presence method is testable the same way (it is, if it's on `GemUploader`).
39
+ - Add an integration test that reproduces the original bug: build artifact present, fresh/empty server, `sync` → server serves the gem afterward. This is the regression guard and should be called out as required, not optional.
40
+
41
+ ## Checklist assessment
42
+
43
+ Honest and well-evidenced. Security N/A is justified (loopback, `VALID_NAME`, read-only `/info`) — but the **private-name-collides-with-public-gem** false-positive (point 2) is a small correctness/security-adjacent edge the checklist's security note should acknowledge. Performance and rollout are appropriately sized.
44
+
45
+ ## Verdict
46
+
47
+ Sound design, right scope. Resolve the `/info` parsing precision + platform assumption (1), the presence-implies-serveable invariant incl. public-name-collision (2), the artifact-location wording (3), and the `latest` ordering consequence (4) before implementing. The rest are wording tightenings.
@@ -0,0 +1,112 @@
1
+ # Critique: Server-authoritative sync cache check
2
+
3
+ ## Overview
4
+
5
+ The spec targets the right bug: `sync` currently treats a flat local build artifact as proof that the running compact-index server can serve the gem.
6
+ Moving the skip decision to a server-side check and re-uploading existing artifacts is the right shape, but the proposed `/info/<name>` contract is not yet authoritative enough because that endpoint is merged with the RubyGems upstream cache.
7
+
8
+ ## Approach Summary
9
+
10
+ - Put the presence check in `GemUploader`, keeping HTTP concerns out of `GemSyncer`.
11
+ - Replace the local `cached?` skip with a server query, then choose skip, upload existing artifact, or build and upload.
12
+ - Reuse existing version normalization for fixed tags, `from_lockfile`, and `latest`.
13
+ - Keep the current `POST /upload` bridge and avoid assuming `sync` and server `gems_path` are the same.
14
+ - The major under-justified choice is "no new server endpoint": current `/info/<name>` is not private-store-only, so using it as the authoritative signal has correctness, privacy, and performance consequences.
15
+
16
+ ## Risks
17
+
18
+ 1. `/info/<name>` can report an upstream public gem, not just a privately uploaded gem.
19
+ Likelihood: medium.
20
+ Severity: high.
21
+ `CompactIndexServer#serve_info` falls back to `serve_upstream_info` when `@index[gemname]` is absent (`lib/gemkeeper/compact_index_server.rb:74`), so a public gem with the same name/version could make `sync` skip even though the private server store is missing the intended artifact.
22
+ The spec does not address this.
23
+
24
+ 2. Offline recovery can stall or fail on upstream RubyGems lookups.
25
+ Likelihood: high for the stated offline use case.
26
+ Severity: high.
27
+ A missing private gem causes `/info/<name>` to probe RubyGems through `GemCache#info` (`lib/gemkeeper/compact_index_server/gem_cache.rb:20`), with 5s open and 10s read timeouts in `RubygemsClient` (`lib/gemkeeper/compact_index_server/rubygems_client.rb:15`).
28
+ The spec says 404 means not-present, but offline misses may return 503 after waiting, which conflicts with the goal of fast local recovery.
29
+
30
+ 3. `version: latest` conflicts with the no-git recovery goal.
31
+ Likelihood: high.
32
+ Severity: medium.
33
+ The spec says `latest` must resolve from the checked-out gemspec before presence checking (`spec.md:49`), which requires clone/pull via the existing `GemSyncer` flow (`lib/gemkeeper/gem_syncer.rb:30`).
34
+ That contradicts the goal that recovery never re-clones when an artifact exists unless the spec explicitly scopes that guarantee to fixed or `from_lockfile` versions.
35
+
36
+ 4. Existing artifact selection can upload the wrong thing or miss valid platform gems.
37
+ Likelihood: medium.
38
+ Severity: medium.
39
+ The spec repeats the current `gems_path/<name>-<version>.gem` shape, but the server stores platform filenames as `<name>-<version>-<platform>.gem` via `SpecMapper.filename` (`lib/gemkeeper/compact_index_server/spec_mapper.rb:13`).
40
+ It also does not require validating that a reused local artifact's embedded gemspec name/version matches the requested gem before declaring success.
41
+
42
+ 5. Security is not actually N/A.
43
+ Likelihood: medium.
44
+ Severity: medium.
45
+ The client will construct a path from a config/manifest gem name, and `Configuration::GemDefinition` validates version but not name.
46
+ The server validates names after routing (`lib/gemkeeper/compact_index_server.rb:49`), but the client still needs path-segment escaping and the `/info` fallback can leak private gem names to RubyGems.org.
47
+
48
+ ## Complexity Hotspots
49
+
50
+ ### Making `/info` Authoritative
51
+
52
+ This is the hardest part.
53
+ The endpoint is a Bundler-facing merged compact index endpoint, not a private-store API.
54
+ If the spec keeps "no new endpoint," it needs a precise rule for distinguishing private presence from upstream presence, probably by comparing the compact-index checksum to the local artifact when one exists or by changing server behavior for sync-specific checks.
55
+
56
+ ### Refactoring `GemSyncer` Ordering
57
+
58
+ Current `sync` resolves the repo before cache handling (`lib/gemkeeper/gem_syncer.rb:21`) and fetches the repo before `latest_version!`.
59
+ To satisfy "upload existing artifact without git/build," implementation likely must defer repo resolution and `GitRepository` creation until after the server-missing/local-artifact path.
60
+ The spec names the high-level flow but does not call out this ordering change.
61
+
62
+ ### HTTP Status And Parse Semantics
63
+
64
+ The presence method needs exact status handling: 200 parse, 404 absent, 400 invalid config/name, 503 upstream unavailable, 5xx server failure, redirects, and Faraday connection failures.
65
+ AR-1.4 currently says both "erroring server" maps to `ServerNotReachableError` and "malformed `/info`" means not-present, which leaves important cases open.
66
+
67
+ ### Counting And Messaging
68
+
69
+ FR-1.4 says an upload conflict is "success/skip," while FR-1.1 says re-uploaded missing gems report as synced, not skipped.
70
+ The existing CLI summary only knows `:synced` and `:skipped` (`lib/gemkeeper/cli/commands/sync.rb:40`), so the spec should say how conflict, re-upload, and freshly built upload affect the summary counts.
71
+
72
+ ## Missing Or Ambiguous Requirements
73
+
74
+ - Define whether "server reports exact version" means private uploaded gem only, merged private-or-public compact index entry, or matching checksum for the exact artifact.
75
+ - Specify how `GET /info/<name>` names are encoded and what happens when the server returns 400 for an invalid name.
76
+ - Clarify whether 503 from upstream miss while the local server is otherwise healthy should mean not-present or hard failure.
77
+ - State whether local artifact reuse requires reading the `.gem` spec and verifying expected name/version/platform before upload.
78
+ - Specify platform gem behavior: exact filename lookup, multiple artifacts for one name/version, and whether any platform version is considered present.
79
+ - Clarify the `latest` guarantee: either accept that it still needs git to discover the current version or define a local-artifact discovery rule for latest.
80
+ - Require deferring repo and manifest resolution when fixed-version or lockfile-version artifact upload can succeed without source checkout.
81
+ - Define how upload conflicts are counted in the sync summary.
82
+ - Add acceptance coverage for a server whose `/info` would proxy upstream, not only a stubbed 404.
83
+
84
+ ## Completeness Checklist Audit
85
+
86
+ | Item | Status | Notes |
87
+ |------------------------------|--------|-------|
88
+ | Scope & acceptance criteria | WARN | Main behavior is clear, but `latest`, platform artifacts, and public upstream collisions are not bounded. |
89
+ | Testing strategy | WARN | Needs `test_gem_syncer.rb` flow tests, CLI summary updates, upstream 503/offline tests, public name collision tests, and platform/corrupt artifact cases. |
90
+ | Existing patterns | WARN | Correctly uses `GemUploader`, but misses current `GemSyncer` repo-resolution ordering and `CompactIndexServer` upstream fallback behavior. |
91
+ | Dependencies | PASS | No new library dependency is needed. |
92
+ | Architecture & interfaces | WARN | Uploader seam is right, but `/info` is not currently a private-store interface. |
93
+ | Error handling & failures | WARN | Unreachable server is covered, but HTTP status mapping and upstream 503 are underspecified. |
94
+ | Security review | FAIL | The N/A claim misses private-name leakage to RubyGems.org, client path encoding, and local artifact validation. |
95
+ | Performance impact | FAIL | The spec assumes one cheap loopback GET, but misses per-gem upstream lookups and offline timeout behavior. |
96
+ | Rollout & migration | PASS | No data migration is needed and existing stores can remain in place. |
97
+ | Assumptions & risks | WARN | Identifies `/info` parsing risk, but misses that `/info` is merged/proxied rather than private authoritative. |
98
+
99
+ ## Verdict
100
+
101
+ NEEDS WORK.
102
+
103
+ The intended sync behavior is implementable, but the spec needs to resolve the `/info` authority problem, offline 503 behavior, artifact validation, and `latest` semantics before implementation can proceed without guessing.
104
+
105
+ ## Suggested Next Steps
106
+
107
+ 1. Decide whether the authoritative check must be private-store-only.
108
+ If yes, either add a private presence contract or specify a checksum-based rule that cannot be fooled by upstream public gems.
109
+ 2. Clarify fixed, `from_lockfile`, and `latest` flows separately, including exactly when repo/manifest resolution is required.
110
+ 3. Specify local artifact lookup and validation, including platform suffixes and corrupt/wrong gem files.
111
+ 4. Define presence-check HTTP status handling and output/counting semantics.
112
+ 5. Update the test plan to include syncer-level orchestration tests plus integration coverage for empty server, offline upstream, divergent server path, upload conflict, and public name collision.
@@ -0,0 +1,169 @@
1
+ # Critique: Spec 20260529-131354 — Server-authoritative sync cache check
2
+
3
+ Reviewer: GitHub Copilot (claude-sonnet-4.6)
4
+ Date: 2026-05-29
5
+
6
+ ## Summary
7
+
8
+ The spec is well-scoped and the core idea is sound.
9
+ The major gap is an unresolved contradiction between the Goals and AR-1.2 around `version: latest`, and several smaller gaps in error handling, format parsing, and implementation structure that need tightening before handoff to an implementer.
10
+
11
+ ---
12
+
13
+ ## Missing Requirements
14
+
15
+ ### MR-1: The `version: latest` + clone contradiction is unresolved
16
+
17
+ The Goals promise "Recovery never re-clones or rebuilds a gem whose `.gem` artifact already exists locally."
18
+ AR-1.2 then acknowledges that for `version: latest`, version resolution happens *post-checkout*, meaning `clone_or_pull` still runs before any server check.
19
+ These two statements directly contradict each other for the case where a `latest` gem is already on the server.
20
+
21
+ The spec needs to explicitly choose one of:
22
+ (a) The no-reclone goal only applies to non-`latest` gems (add that scope qualifier to the Goals section).
23
+ (b) For `version: latest` with a local artifact, read the version from the `.gem` file via `Gem::Package.new(path).spec.version` to avoid cloning, then check the server — add this as a new code path.
24
+
25
+ Without a resolution, an implementer will produce inconsistent behavior and the Goals will be technically false for `latest` gems.
26
+
27
+ ### MR-2: 503 response from `/info/<name>` is not addressed
28
+
29
+ When a private gem has never been uploaded to a running server, `GemIndex#[]` returns `nil` for that name.
30
+ The server then calls `serve_upstream_info`, which returns 503 (`upstream_unavailable`) when offline.
31
+ This is the exact recovery scenario the spec is designed to fix — the gem doesn't exist on the server — yet only 404 is explicitly listed as "not present."
32
+ A 503 from the server during the presence check should also be treated as "not present" (or at minimum documented as an expected response that triggers the upload path), but it is not mentioned in FR-1.2, AR-1.4, or the Assumptions & Risks.
33
+
34
+ ### MR-3: No description of how `build_and_upload` is decomposed
35
+
36
+ FR-1.3 requires a new code path: upload an existing artifact without building.
37
+ `GemSyncer#build_and_upload` currently performs both steps unconditionally.
38
+ The spec never mentions that this method must be split (e.g., `upload_artifact(gem_path)` vs. `build_gem(local_path, gems_path)`) or what the resulting `GemSyncer` flow looks like.
39
+ An implementer must infer the structural change from the FR alone, which increases the chance of inventing different designs across teams or in agentic handoff.
40
+
41
+ ### MR-4: Test file for `sync()` behavior does not yet exist in `test_gem_syncer.rb`
42
+
43
+ The Constraints point to `test_gem_syncer.rb` for `GemSyncer` test coverage, and FR-1.3's Verify describes stubs for `GitRepository` and `GemBuilder`.
44
+ The existing `test_gem_syncer.rb` tests only `resolve_repo` — it has no tests for `sync()` at all.
45
+ The spec should acknowledge that new test methods for `sync()` must be added (not just extended), and whether the integration-level Verify for FR-1.3 is better placed in the lifecycle integration test or in a new unit-level test for `GemSyncer#sync`.
46
+
47
+ ---
48
+
49
+ ## Ambiguous Language
50
+
51
+ ### AM-1: "replacing the `list_gems` `NotImplementedError` stub" conflates two different methods
52
+
53
+ AR-1.1 says to add a presence method "e.g. `has_version?(name, version)` / replacing the `list_gems` `NotImplementedError` stub."
54
+ `list_gems` returns a list; `has_version?` checks existence by name+version.
55
+ They are not substitutes for each other.
56
+ The parenthetical implies `list_gems` would be removed and `has_version?` added in its place, but `list_gems` has its own contract (error message references `gemkeeper list`).
57
+ The spec should state explicitly whether `list_gems` is being removed, renamed, or left alone, and where `has_version?` fits in the public interface.
58
+
59
+ ### AM-2: "version line for that exact version" underspecifies the compact-index format
60
+
61
+ FR-1.2 says the gem is present when "the info document contains a version line for that exact version."
62
+ The compact-index `/info` format emitted by `CompactIndex.info(versions)` includes a `---` separator and lines structured as `<version> <platform> <checksum>|<deps>`.
63
+ "Version line" is not defined.
64
+ The Assumptions & Risks note does say "parsing only the leading version token per line," which is a workable rule, but it belongs in FR-1.2 as a normative requirement, not only as a risk mitigation note.
65
+ The spec should state explicitly: skip lines starting with `---` or `created_at:`; split on whitespace; compare the first token against the bare semver.
66
+
67
+ ### AM-3: "present only if the server reports that exact `<name>` and `<version>`" — case sensitivity unspecified
68
+
69
+ Gem names in the compact-index are case-sensitive by convention, but the spec does not state whether the comparison is case-sensitive.
70
+ This matters if a name is stored differently in the manifest vs. what the server indexes.
71
+ A one-line clarification ("comparison is case-sensitive, matching the gem name exactly as configured") would prevent a subtle bug.
72
+
73
+ ---
74
+
75
+ ## Codebase Assumptions That May Be Wrong
76
+
77
+ ### CA-1: The `GemUploader` Faraday connection uses `multipart` middleware, which is unnecessary for a plain GET
78
+
79
+ The existing `connection` method in `GemUploader` builds a Faraday connection with `:multipart` and `:url_encoded` middleware.
80
+ Both are only relevant for `POST /upload`.
81
+ A `GET /info/<name>` on that same connection is harmless but wasteful (adds a `Content-Type: multipart/form-data` header that is ignored by the server).
82
+ The spec should either (a) note this is acceptable and leave it as-is, or (b) instruct the implementer to use a separate plain connection for read-only requests.
83
+ Ignoring this risks the presence check failing on edge-case Rack middleware that rejects multipart headers on GETs.
84
+
85
+ ### CA-2: `latest_version!` raises `BuildError`, but the new flow may need a softer return
86
+
87
+ `latest_version!` in `GemSyncer` raises `BuildError` if `current_version` returns `nil`.
88
+ With the new server-check path for `version: latest`, there is a question of whether the existing exception should propagate before or after the server presence check.
89
+ The spec does not address what happens if `current_version` returns `nil` and the server already has the gem (build failure, but gem is present — should it be a skip or an error?).
90
+
91
+ ### CA-3: `GemIndex#add` raises `Errno::EEXIST` on conflict — not HTTP 409
92
+
93
+ AR-1.4 says "an upload that races with an already-present gem (server returns the existing-gem conflict) is treated as success/skip."
94
+ `GemUploader#handle_response` already handles 409 as `{ success: true, skipped: true }`, so this is correct at the HTTP layer.
95
+ However, the spec says the *presence check* comes before the upload.
96
+ After a successful presence check shows the gem is absent, the gem could be uploaded by another process before this process's upload completes — the race actually produces a 409 at upload time, not a "missing" from the presence check.
97
+ The spec's idempotency claim in FR-1.4 is correct by accident (existing 409 handling covers it), but the causal chain described is subtly wrong and could confuse an implementer.
98
+
99
+ ---
100
+
101
+ ## Error Handling & Edge Cases
102
+
103
+ ### EH-1: HTTP status codes other than 200, 404 are unspecified for the presence check
104
+
105
+ AR-1.4 addresses "unreachable or erroring server" and "malformed `/info` response" but not intermediate HTTP errors: 400 (invalid name), 500, 503.
106
+ A 503 is the expected response when the gem is absent and the server is offline (see MR-2 above).
107
+ A 400 would indicate a bug in name validation, which is a different class of problem.
108
+ The spec should enumerate what status codes are treated as "not present" vs. "raise `ServerNotReachableError`" vs. "raise a different error."
109
+
110
+ ### EH-2: Connection failure during presence check timing vs. upload timing
111
+
112
+ The spec says a connection failure during the presence check raises `ServerNotReachableError`.
113
+ But the current `GemSyncer#sync` does no upfront reachability check; it discovers unreachability only when `upload` is called.
114
+ With the new flow, the error surface moves earlier (before any git work), which is an improvement.
115
+ However, the spec should note whether this earlier detection changes any user-visible behavior (e.g., error message wording, exit code).
116
+ Currently `GemUploader#upload` and the proposed `has_version?` would raise the same exception class, but the message may differ.
117
+
118
+ ### EH-3: What if the local artifact is corrupt?
119
+
120
+ FR-1.3 says when the server is missing the gem and the local `.gem` exists, upload it directly.
121
+ The spec does not address what happens if the local artifact is a partial/corrupt file (e.g., interrupted build).
122
+ The server's `GemIndex#add` reads the file via `Gem::Package.new(source_path).spec` and will raise `StandardError` on a corrupt gem, which `UploadHandler` likely does not gracefully convert to a 4xx.
123
+ The spec should either require a pre-upload integrity check (`Gem::Package.new(path).spec` before upload) or explicitly acknowledge this as out of scope.
124
+
125
+ ---
126
+
127
+ ## Security Concerns
128
+
129
+ The spec's security review says "N/A beyond existing posture."
130
+ This is broadly correct given the loopback-only deployment, but there is one minor gap worth noting:
131
+
132
+ `GemUploader` currently does not validate the gem name before constructing the `/info/<name>` URL.
133
+ On the server side, `VALID_NAME` (line 17 of `compact_index_server.rb`) rejects names that don't match `[a-zA-Z0-9._-]+` and returns 400.
134
+ The client should treat a 400 response as a programming error (not a "not present" outcome) rather than silently proceeding.
135
+ This is a defense-in-depth note, not a new attack surface, but it is relevant to EH-1 above.
136
+
137
+ ---
138
+
139
+ ## Performance Implications
140
+
141
+ ### PI-1: `version: latest` still always clones/pulls before the skip can fire
142
+
143
+ For `version: latest`, the server presence check can only fire after `clone_or_pull` and version resolution.
144
+ If the intent is fast recovery (re-run sync, get back to work quickly), `version: latest` gems will still incur a network round-trip to the git remote before they are skipped.
145
+ The spec acknowledges this implicitly in AR-1.2 but does not flag it as a known cost or consider mitigations (e.g., reading the version from the existing artifact).
146
+ This is worth calling out explicitly so the implementer does not optimize away the clone for `latest` and inadvertently break version freshness.
147
+
148
+ ---
149
+
150
+ ## Spec Completeness Checklist — Gaps
151
+
152
+ | Item | Status | Issue |
153
+ | ---- | ------ | ----- |
154
+ | Scope & acceptance criteria | Partial | Goals "never re-clones" is untrue for `version: latest` (MR-1) |
155
+ | Testing strategy | Partial | `test_gem_syncer.rb` has no `sync()` tests; spec implies extension, not creation (MR-4) |
156
+ | Error handling & failure modes | Partial | 503 and other non-200/404 statuses unspecified (MR-2, EH-1) |
157
+ | Assumptions & risks | Partial | 503/offline risk for never-uploaded private gems not listed; corrupt artifact not listed |
158
+ | Architecture & interfaces | Partial | `build_and_upload` decomposition not specified (MR-3); `list_gems` ambiguity (AM-1) |
159
+
160
+ ---
161
+
162
+ ## Recommended Changes Before Implementation
163
+
164
+ 1. Clarify the `version: latest` goal: either scope "never re-clones" to non-`latest` gems, or add the `Gem::Package` version-read path as an explicit alternative to cloning (MR-1).
165
+ 2. Add 503 to FR-1.2 as a "not present" signal, or explain why it should be an error (MR-2).
166
+ 3. Replace the risk-mitigation note about `/info` parsing with a normative parsing rule in FR-1.2 (AM-2).
167
+ 4. Specify the `build_and_upload` decomposition or name the resulting private methods (MR-3).
168
+ 5. Clarify whether `list_gems` is removed or retained, and add `has_version?` as a standalone addition (AM-1).
169
+ 6. Add EH-1's status-code table (200 = check version, 404/503 = not present, others = raise).
@@ -0,0 +1,59 @@
1
+ # Implementation Summary: 20260529-131354-sync-serve-cache-contract
2
+
3
+ **Status:** Completed
4
+ **Date:** 2026-05-29
5
+
6
+ ## Overview
7
+
8
+ Made `gemkeeper sync`'s skip decision authoritative against the server's *private* store instead of a local build artifact, fixing the bug where a fresh or repointed server could never be repopulated (every gem skipped, 404s on `bundle install`). Added a private-store-only presence endpoint, server-authoritative `serves?` on the uploader, and reordered `GemSyncer` to defer repo work — skipping when the server already has a gem and re-uploading an existing artifact without rebuilding.
9
+
10
+ ## Execution
11
+
12
+ Solo, sequential (the client depends on the server endpoint contract): server endpoint → client `serves?` → `GemSyncer` reorder → tests → quality-gate refactor.
13
+
14
+ ## Files Created
15
+ - `lib/gemkeeper/repo_fetcher.rb` — `RepoFetcher`: resolves a gem's repo URL (manifest + override) and clones/pulls it, with git-auth error mapping. Extracted from `GemSyncer` to keep it under the rubycritic gate.
16
+ - `test/gemkeeper/test_repo_fetcher.rb` — repo-resolution tests (migrated from `test_gem_syncer.rb`).
17
+
18
+ ## Files Modified
19
+ - `lib/gemkeeper/compact_index_server.rb` — new `GET /gemkeeper/has/<name>/<version>` route (`serve_presence`) + `present` responder; reads the private index only.
20
+ - `lib/gemkeeper/compact_index_server/gem_index.rb` — `serves?(name, version)` predicate over the in-memory index.
21
+ - `lib/gemkeeper/gem_uploader.rb` — `serves?(name, version)` (hits the private endpoint; 200/404/else→`ServerError`; connection failure→`ServerNotReachableError`); extracted `not_reachable!` helper.
22
+ - `lib/gemkeeper/gem_syncer.rb` — reordered: `sync_pinned` (server check → artifact reuse → build) / `sync_latest` (always fetch); `reusable_artifact?` identity check; delegates repo acquisition to `RepoFetcher`.
23
+ - `lib/gemkeeper.rb` — require `repo_fetcher`.
24
+ - `test/gemkeeper/test_compact_index_server.rb` — 4 presence-endpoint tests.
25
+ - `test/gemkeeper/test_gem_uploader.rb` — `serves?` connection-failure test.
26
+ - `test/gemkeeper/test_gem_syncer.rb` — `reusable_artifact?` (4) + `sync()` orchestration (2) tests; resolve tests moved out.
27
+ - `test/integration/test_server_lifecycle_integration.rb` — original-bug regression + skip-when-served tests.
28
+ - `test/integration/test_cli_integration.rb` — repurposed the obsolete local-cache-skip test to assert the new unreachable-server error.
29
+ - `CHANGELOG.md` — `[Unreleased]` Fixed entry.
30
+
31
+ ## Test Results
32
+ `bundle exec rake test` → 230 runs, 512 assertions, 0 failures, 0 errors.
33
+ `bundle exec rubocop` → 76 files, no offenses.
34
+ `bundle exec rubycritic lib --no-browser` → 90.13 (gate ≥ 90); `gem_syncer` B, `repo_fetcher` A, no C/D/F.
35
+
36
+ ## Spec Adherence
37
+ | Requirement | Status | Implementation | Test |
38
+ |-------------|--------|---------------|------|
39
+ | FR-1.1 | Done | `gem_syncer.rb` `sync_pinned`/`sync_latest` via `@uploader.serves?` | `test_sync_skips_when_server_already_serves` (unit + integration) |
40
+ | FR-1.2 | Done | `gem_uploader.rb` `serves?` hits `/gemkeeper/has` | `test_gem_uploader`, presence endpoint tests |
41
+ | FR-1.3 | Done | `reusable_artifact?` + `reupload` | `test_sync_reuploads_existing_artifact_without_rebuild`, regression |
42
+ | FR-1.4 | Done | `GemUploader#handle_response` 409→skip | `test_sync_skips_when_server_already_serves` (runs sync twice) |
43
+ | FR-1.5 | Done | `skip`/`reupload`/`build_gem` symbols + output text | syncer unit tests, integration `1 skipped` |
44
+ | FR-1.6 | Done | `sync_latest` always fetches | covered by `sync_latest` path |
45
+ | FR-2.1 | Done | `compact_index_server.rb` `serve_presence` + `GemIndex#serves?` | `test_presence_endpoint_*` (incl. no-upstream-probe) |
46
+ | FR-2.2 | Done | `serve_presence` `VALID_NAME` check → 400 | `test_presence_endpoint_rejects_invalid_name` |
47
+ | AR-1.1 | Done | `serves?` on `GemUploader`; `list_gems` untouched | `test_gem_uploader` |
48
+ | AR-1.2 | Done | reuse `resolve_version`; `<name>-<version>.gem` | syncer tests |
49
+ | AR-1.3 | Done | presence strictly over HTTP | integration (real server) |
50
+ | AR-1.4 | Done | status mapping in `serves?` | `test_serves_raises_server_not_reachable_on_connection_failure`, CLI unreachable test |
51
+ | AR-1.5 | Done | `RepoFetcher` deferral; build/upload split | unit stubs assert no fetch/build on skip/reuse |
52
+ | AR-2.1 | Done | endpoint reads `@index` only; gates green | rubycritic 90.13 |
53
+
54
+ ## Deviations from Spec
55
+ - **`RepoFetcher` extraction (not in the spec's file list).** The `GemSyncer` reorder added methods and pushed it to a rubycritic C (120); reek flagged `resolve_repo` and the auth helpers as belonging elsewhere. Extracting `RepoFetcher` both satisfied the spec's quality-gate constraint and is the cleaner boundary (sync orchestrates; `RepoFetcher` acquires the repo). The 6 repo-resolution tests moved with it. No behavior change.
56
+ - **`test_cli_integration#test_sync_skips_already_cached_gem` repurposed.** It asserted the old local-cache skip (no server) that the spec deliberately removes; rewritten as `test_sync_errors_when_server_unreachable` to assert the new server-authoritative guard (AR-1.4).
57
+
58
+ ## Living docs
59
+ `specs/docs/` does not exist — skipped. Run `/spec-docs --full` to bootstrap if desired.