From 904297a1bba855d9225fb17e97e49ca0c45978a2 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 23 Sep 2026 21:29:18 +0300 Subject: [PATCH 01/17] feat: the four conformance suites, the runner and three invariant gates Phase 9's instrument. Four suites beside phase 8a's transport one, over one shared Runner so the five statuses are decided in one place: InvariantSuite (28 assertions over all twenty-four XCUT ids, ten private_constant groups), PackagingSuite (8 over NFR-1, 2, 3, 10, 11, 13, 14 and 15, read from published gem metadata rather than a source gemspec), CodecSuite (2 portable seam properties, re-derived from the requirement text rather than lifted out of dexpace-serde-json, whose files are untouched) and ExecutorSuite (7, SEAM-25's harness half among them, with ASYNC-3 written so it genuinely fails). Report gains .merge, #to_h and the MUST-level vacuity blocker: Levels carries every requirement id's normative level, generated from appendix C, and a vacuity on a MUST fails the run until an acceptance names that id with a citation. SharedInstance is XCUT-11's structural predicate, R8's disjunction, with the declaration supplied by the driver and never read off the subject. Aggregate gives a whole run one verdict and a preamble that prints what green does not prove; APPENDIX_B.md is the 61-row coverage map. Three repository-wide invariant scans become blocking gates over one RubyVM::AbstractSyntaxTree walker -- gates:cause_walk, gates:bounded_map and gates:seam_names -- taking the set to twenty-one, all in DEFAULT_GATES and all in ci.yml's gates job. gates:serde_boundary now asserts its PENDING list empty, which is the clause SSE-37 handed forward. --- .github/workflows/ci.yml | 3 + Rakefile | 3 + gems/dexpace-conformance/APPENDIX_B.md | 116 +++ .../lib/dexpace/conformance.rb | 17 + .../lib/dexpace/conformance/aggregate.rb | 81 +++ .../lib/dexpace/conformance/check.rb | 29 + .../lib/dexpace/conformance/codec_case.rb | 56 ++ .../lib/dexpace/conformance/codec_suite.rb | 60 ++ .../dexpace/conformance/codec_suite/seam.rb | 91 +++ .../lib/dexpace/conformance/executor_case.rb | 137 ++++ .../lib/dexpace/conformance/executor_suite.rb | 69 ++ .../conformance/executor_suite/lifecycle.rb | 148 ++++ .../conformance/executor_suite/shutdown.rb | 163 +++++ .../lib/dexpace/conformance/invariant_case.rb | 147 ++++ .../dexpace/conformance/invariant_suite.rb | 89 +++ .../invariant_suite/classification.rb | 178 +++++ .../invariant_suite/concurrency.rb | 170 +++++ .../invariant_suite/credentials.rb | 164 +++++ .../invariant_suite/header_syntax.rb | 157 ++++ .../conformance/invariant_suite/memory.rb | 124 ++++ .../conformance/invariant_suite/models.rb | 107 +++ .../conformance/invariant_suite/outcomes.rb | 58 ++ .../conformance/invariant_suite/resolution.rb | 189 +++++ .../conformance/invariant_suite/security.rb | 153 ++++ .../conformance/invariant_suite/sharing.rb | 127 ++++ .../conformance/invariant_suite/taxonomy.rb | 165 +++++ .../lib/dexpace/conformance/levels.rb | 680 ++++++++++++++++++ .../lib/dexpace/conformance/packaging_case.rb | 147 ++++ .../dexpace/conformance/packaging_suite.rb | 70 ++ .../packaging_suite/dependencies.rb | 140 ++++ .../conformance/packaging_suite/surface.rb | 136 ++++ .../lib/dexpace/conformance/report.rb | 193 ++++- .../lib/dexpace/conformance/runner.rb | 88 +++ .../dexpace/conformance/shared_instance.rb | 46 ++ .../dexpace/conformance/transport_suite.rb | 13 +- .../sig/dexpace/conformance/aggregate.rbs | 19 + .../sig/dexpace/conformance/check.rbs | 9 + .../sig/dexpace/conformance/codec_case.rbs | 30 + .../sig/dexpace/conformance/codec_suite.rbs | 15 + .../dexpace/conformance/codec_suite/seam.rbs | 19 + .../sig/dexpace/conformance/executor_case.rbs | 52 ++ .../dexpace/conformance/executor_suite.rbs | 16 + .../conformance/executor_suite/lifecycle.rbs | 22 + .../conformance/executor_suite/shutdown.rbs | 36 + .../dexpace/conformance/invariant_case.rbs | 42 ++ .../dexpace/conformance/invariant_suite.rbs | 18 + .../invariant_suite/classification.rbs | 25 + .../invariant_suite/concurrency.rbs | 21 + .../invariant_suite/credentials.rbs | 39 + .../invariant_suite/header_syntax.rbs | 23 + .../conformance/invariant_suite/memory.rbs | 23 + .../conformance/invariant_suite/models.rbs | 28 + .../conformance/invariant_suite/outcomes.rbs | 14 + .../invariant_suite/resolution.rbs | 33 + .../conformance/invariant_suite/security.rbs | 18 + .../conformance/invariant_suite/sharing.rbs | 23 + .../conformance/invariant_suite/taxonomy.rbs | 22 + .../sig/dexpace/conformance/levels.rbs | 13 + .../dexpace/conformance/packaging_case.rbs | 35 + .../dexpace/conformance/packaging_suite.rbs | 17 + .../packaging_suite/dependencies.rbs | 20 + .../conformance/packaging_suite/surface.rbs | 24 + .../sig/dexpace/conformance/report.rbs | 32 +- .../sig/dexpace/conformance/runner.rbs | 19 + .../dexpace/conformance/shared_instance.rbs | 9 + .../dexpace/conformance/transport_suite.rbs | 4 +- .../test/dexpace/conformance_test.rb | 13 +- tasks/gates.rake | 60 +- test/fixtures/surface/dexpace-conformance.txt | 80 +++ test/gates/default_task_test.rb | 24 +- tools/appendix_b.rb | 140 ++++ tools/ast_scan.rb | 257 +++++++ tools/invariant_gates.rb | 133 ++++ tools/requirement_levels.rb | 96 +++ tools/serde_boundary.rb | 23 +- 75 files changed, 5813 insertions(+), 47 deletions(-) create mode 100644 gems/dexpace-conformance/APPENDIX_B.md create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/aggregate.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/check.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/codec_case.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/codec_suite.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/codec_suite/seam.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/executor_case.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/executor_suite.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/lifecycle.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/shutdown.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/invariant_case.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/classification.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/concurrency.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/credentials.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/header_syntax.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/memory.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/models.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/outcomes.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/resolution.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/security.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/sharing.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/taxonomy.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/levels.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/packaging_case.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/surface.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/runner.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/shared_instance.rb create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/aggregate.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/check.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/codec_case.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/codec_suite.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/codec_suite/seam.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/executor_case.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/executor_suite.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/executor_suite/lifecycle.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/executor_suite/shutdown.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/invariant_case.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/classification.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/concurrency.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/credentials.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/header_syntax.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/memory.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/models.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/outcomes.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/resolution.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/security.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/sharing.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/taxonomy.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/levels.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/packaging_case.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/packaging_suite.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/packaging_suite/dependencies.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/packaging_suite/surface.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/runner.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/shared_instance.rbs create mode 100644 tools/appendix_b.rb create mode 100644 tools/ast_scan.rb create mode 100644 tools/invariant_gates.rb create mode 100644 tools/requirement_levels.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa042cf..799f440 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,9 @@ jobs: - run: bundle exec rake steep - run: bundle exec rake test:gates - run: bundle exec rake gates:serde_boundary + - run: bundle exec rake gates:cause_walk + - run: bundle exec rake gates:bounded_map + - run: bundle exec rake gates:seam_names - run: bundle exec rake gates:rbs_surface - run: bundle exec rake gates:sig_diff - run: bundle exec rake gates:surface_snapshot diff --git a/Rakefile b/Rakefile index aae4b95..1de1c93 100644 --- a/Rakefile +++ b/Rakefile @@ -24,6 +24,9 @@ DEFAULT_GATES = %w[ gates:gemspec_audit gates:require_allowlist gates:serde_boundary + gates:cause_walk + gates:bounded_map + gates:seam_names gates:clean_bundle gates:rbs_surface gates:sig_diff diff --git a/gems/dexpace-conformance/APPENDIX_B.md b/gems/dexpace-conformance/APPENDIX_B.md new file mode 100644 index 0000000..ef5c102 --- /dev/null +++ b/gems/dexpace-conformance/APPENDIX_B.md @@ -0,0 +1,116 @@ +# Appendix B coverage map + +One row per item of `docs/product-spec/appendix-b-conformance-test-checklist.md` — sixty-one, in +the specification's own order — naming the requirement IDs that item exercises, the evidence that +covers it, and a status. + +Regenerate the ID column and the row set with `ruby tools/appendix_b.rb`. The generator reads this +file first and carries every hand-written **Evidence** and **Status** cell over, so a row's +judgement survives a regeneration and a dropped or added checklist item does not. + +## The statuses + +| Status | What it means | +|---|---| +| `suite` | An assertion in `dexpace-conformance`: this gem's own, portable across implementations of one seam | +| `by reference` | A test file in the gem that owns the subsystem. **See the limit below.** | +| `restated per §9.3` | Design §9.3 restates the item for this port, and the restatement is what is checked | +| `scoped out` | This port declines the item or a clause of it, with the reason in the row | + +## What the checks over this map establish, and what they do not + +Three checks run in `test/gates/appendix_b_test.rb`: + +1. the table has **exactly** as many rows as appendix B has items, and each section's count matches + the count parsed from the specification's own text; +2. every row's **ID column equals** the ID set parsed from that item's own text — 276 distinct IDs + over the 61 items, no ID in two items, at most 12 in one — so no row can be wrong about which + requirements its item covers, and every ID appendix B names is in some row by construction; +3. every row's **evidence path exists** on disk. + +**What they do not establish: that a referenced test asserts the behaviour the item describes.** A +`by reference` row's evidence is a test file in a gem this phase does not own, and nothing makes a +test file announce which appendix-B item it covers. Such a row proves an ID is *claimed* and a file +*exists* — never more (design `P9-7`). `Aggregate::PREAMBLE` says the same thing about a green +suite run, so a reader who meets only one of the two documents is still told. + +**The closing condition, priced and deferred rather than overlooked.** The gap shrinks as the +`by reference` rows do, and the event that makes shrinking them worth its cost is the one +`docs/first-release.md` § Post-release triggers already records for lifting `B.1`, `B.2` and `B.5`: +**a second implementation** of the pagination engine, the SSE reader or the configuration chain. +Until that fires, the only other route is a per-section ID reconciliation done by hand once and then +checked — a piece of work rather than a line, which phase 9 does not take. + +## Why `B.1`, `B.2` and `B.5` are `by reference` + +§9.3's argument for shipping `dexpace-conformance` as a gem is portability across *implementations +of one seam*: "the *same* assertions could not run unchanged against `dexpace-transport-async_http` +or a future `httpx` adapter — which is the whole point." Pagination, SSE and the configuration chain +have exactly one implementation each, and `PAGE-8` makes the pagination engine stateless and +shareable rather than pluggable. A lifted assertion over a single subject is not more true for +having moved gems; it is a test with one subject living in a package whose purpose is many, and it +costs a second file to keep in step (`P9-1`). + +| Section | Item | IDs | Evidence | Status | +|---|---|---|---|---| +| B.1 | 1 | PAGE-1, PAGE-2, PAGE-3 | gems/dexpace-core/test/dexpace/page/paginator_test.rb | by reference | +| B.1 | 2 | PAGE-6, PAGE-7, PAGE-8 | gems/dexpace-core/test/dexpace/page/walk_test.rb | by reference | +| B.1 | 3 | PAGE-9, PAGE-10 | gems/dexpace-core/test/dexpace/page/walk_test.rb | by reference | +| B.1 | 4 | PAGE-11, PAGE-12, PAGE-14, PAGE-15 | gems/dexpace-core/test/dexpace/page/lifetime_test.rb | by reference | +| B.1 | 5 | PAGE-13 | gems/dexpace-core/test/dexpace/page/lifetime_test.rb | by reference | +| B.1 | 6 | PAGE-16, PAGE-17, PAGE-18, PAGE-20, PAGE-19 | gems/dexpace-core/test/dexpace/page/link_strategy_test.rb | by reference | +| B.1 | 7 | PAGE-21, PAGE-22, PAGE-23, PAGE-24 | gems/dexpace-core/test/dexpace/page/query_rewriter_test.rb | by reference | +| B.1 | 8 | PAGE-25, PAGE-26, PAGE-27, PAGE-28, PAGE-29, PAGE-30, PAGE-31, PAGE-32, PAGE-33 | gems/dexpace-core/test/dexpace/page/async_paginator_test.rb | by reference | +| B.1 | 9 | PAGE-34, PAGE-35 | gems/dexpace-core/test/dexpace/page/fetchers_test.rb | by reference | +| B.1 | 10 | PAGE-36 | gems/dexpace-core/test/dexpace/page/paginator_test.rb | by reference | +| B.2 | 1 | SSE-1, SSE-2, SSE-3, SSE-4, SSE-5, SSE-6, SSE-7, SSE-8, SSE-9, SSE-10, SSE-11, SSE-12 | gems/dexpace-core/test/dexpace/sse/reader_test.rb | by reference | +| B.2 | 2 | SSE-13, SSE-14, SSE-15, SSE-16, SSE-17, SSE-18, SSE-19 | gems/dexpace-core/test/dexpace/sse/boundaries_test.rb | by reference | +| B.2 | 3 | SSE-20, SSE-21, SSE-22 | gems/dexpace-core/test/dexpace/sse/event_test.rb | by reference | +| B.2 | 4 | SSE-23, SSE-24, SSE-25, SSE-26, SSE-27, SSE-28, SSE-29, SSE-30, SSE-31, SSE-32 | gems/dexpace-core/test/dexpace/sse/stream_test.rb | by reference | +| B.2 | 5 | SSE-33, SSE-34, SSE-35, SSE-36 | gems/dexpace-core/test/dexpace/sse/typed_stream_test.rb | by reference | +| B.2 | 6 | SSE-37, SSE-38, SSE-39, SSE-40, SSE-41 | tools/serde_boundary.rb | suite | +| B.3 | 1 | SERDE-1, SERDE-2 | gems/dexpace-serde-json/test/dexpace/serde/json/codec_test.rb | by reference | +| B.3 | 2 | SERDE-3, SERDE-4 | gems/dexpace-conformance/lib/dexpace/conformance/codec_suite.rb | suite | +| B.3 | 3 | SERDE-5, SERDE-6, SERDE-7, SERDE-8 | gems/dexpace-core/test/dexpace/serde/witness_test.rb | restated per §9.3 | +| B.3 | 4 | SERDE-9, SERDE-10, SERDE-11, SERDE-12, SERDE-13 | gems/dexpace-conformance/lib/dexpace/conformance/codec_suite.rb | suite | +| B.3 | 5 | SERDE-14, SERDE-15, SERDE-16, SERDE-17, SERDE-18, SERDE-19, SERDE-20, SERDE-30 | gems/dexpace-core/test/dexpace/serde/tristate_test.rb | by reference | +| B.3 | 6 | SERDE-21, SERDE-22, SERDE-23, SERDE-24, SERDE-25, SERDE-26, SERDE-29 | gems/dexpace-core/test/dexpace/serde/scalars_test.rb | by reference | +| B.3 | 7 | SERDE-27, SERDE-28 | gems/dexpace-serde-json/test/dexpace/serde/json/codec_load_test.rb | by reference | +| B.4 | 1 | OBS-1, OBS-2, OBS-3, OBS-4, OBS-5, OBS-6, OBS-7, OBS-8, OBS-9, OBS-40 | gems/dexpace-core/test/dexpace/instrumentation/event_test.rb | by reference | +| B.4 | 2 | OBS-10 | gems/dexpace-core/test/dexpace/instrumentation/diagnostics_test.rb | by reference | +| B.4 | 3 | OBS-11, OBS-12, OBS-13, OBS-14, OBS-15, OBS-16, OBS-17, OBS-18, OBS-19 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/security.rb | suite | +| B.4 | 4 | OBS-20 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/security.rb | suite | +| B.4 | 5 | OBS-21, OBS-22, OBS-23, OBS-24, OBS-25 | gems/dexpace-conformance/lib/dexpace/conformance/recording_span.rb | restated per §9.3 | +| B.4 | 6 | OBS-26, OBS-27, OBS-28, OBS-29, OBS-30 | gems/dexpace-core/test/dexpace/instrumentation/http_tracer_test.rb | by reference | +| B.4 | 7 | OBS-31, OBS-32, OBS-33 | gems/dexpace-core/test/dexpace/instrumentation/meter_test.rb | by reference | +| B.4 | 8 | OBS-34, OBS-35, OBS-36, OBS-37, OBS-38, OBS-39 | gems/dexpace-core/test/dexpace/instrumentation/step_test.rb | by reference | +| B.5 | 1 | CFG-1, CFG-2, CFG-3, CFG-4, CFG-38 | gems/dexpace-core/test/dexpace/configuration_test.rb | restated per §9.3 | +| B.5 | 2 | CFG-5, CFG-6, CFG-7 | gems/dexpace-core/test/dexpace/configuration_test.rb | by reference | +| B.5 | 3 | CFG-8, CFG-9, CFG-10, CFG-11, CFG-12, CFG-13, CFG-14, CFG-37 | gems/dexpace-core/test/dexpace/config_test.rb | by reference | +| B.5 | 4 | CFG-15, CFG-16, CFG-17, CFG-18, CFG-19, CFG-20, CFG-21 | gems/dexpace-core/test/dexpace/clock_test.rb | by reference | +| B.5 | 5 | CFG-22, CFG-23, CFG-24, CFG-25, CFG-26, CFG-27, CFG-28 | gems/dexpace-core/test/dexpace/proxy_test.rb | by reference | +| B.5 | 6 | CFG-29, CFG-30, CFG-31, CFG-32, CFG-33, CFG-34, CFG-35, CFG-36 | gems/dexpace-core/test/dexpace/http_date_test.rb | by reference | +| B.6 | 1 | TRANSPORT-1, TRANSPORT-2 | gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/outbound.rb | suite | +| B.6 | 2 | TRANSPORT-3, TRANSPORT-4, TRANSPORT-5, TRANSPORT-6, TRANSPORT-7, TRANSPORT-8, TRANSPORT-9 | gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/resilience.rb | suite | +| B.6 | 3 | TRANSPORT-10, TRANSPORT-11, TRANSPORT-12, TRANSPORT-13, TRANSPORT-14 | gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/header_drops.rb | suite | +| B.6 | 4 | TRANSPORT-15, TRANSPORT-16, TRANSPORT-17, TRANSPORT-18, TRANSPORT-19 | gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/lifecycle.rb | suite | +| B.6 | 5 | TRANSPORT-20, TRANSPORT-21, TRANSPORT-22, TRANSPORT-23, TRANSPORT-24, TRANSPORT-25, TRANSPORT-26, TRANSPORT-27, TRANSPORT-28, TRANSPORT-29, TRANSPORT-30 | gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/streaming.rb | suite | +| B.7 | 1 | ASYNC-1, ASYNC-2 | gems/dexpace-async-thread/test/dexpace/async/thread/pool_test.rb | by reference | +| B.7 | 2 | ASYNC-3, ASYNC-4, ASYNC-5, ASYNC-6, ASYNC-7 | gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/shutdown.rb | suite | +| B.7 | 3 | ASYNC-8, ASYNC-9, ASYNC-10, ASYNC-11, ASYNC-12 | gems/dexpace-async-thread/test/dexpace/async/thread/pool_diagnostics_test.rb | by reference | +| B.7 | 4 | ASYNC-13, ASYNC-14 | gems/dexpace-async-thread/test/dexpace/async/thread/bridge_test.rb | scoped out | +| B.7 | 5 | ASYNC-15, ASYNC-16, ASYNC-17 | gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/lifecycle.rb | suite | +| B.7 | 6 | ASYNC-18, ASYNC-19, ASYNC-20, ASYNC-21, ASYNC-22 | gems/dexpace-transport-async_http/test/dexpace/transport/async_http/adapter_test.rb | by reference | +| B.8 | 1 | XCUT-1, XCUT-2, XCUT-3 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/concurrency.rb | suite | +| B.8 | 2 | XCUT-4, XCUT-5, XCUT-6, XCUT-7, XCUT-8, XCUT-9 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/taxonomy.rb | suite | +| B.8 | 3 | XCUT-10 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/classification.rb | suite | +| B.8 | 4 | XCUT-11, XCUT-12, XCUT-13, XCUT-22, XCUT-23 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/sharing.rb | suite | +| B.8 | 5 | XCUT-14, XCUT-15 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/memory.rb | suite | +| B.8 | 6 | XCUT-16, XCUT-17, XCUT-18, XCUT-19, XCUT-20, XCUT-21, XCUT-24 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/credentials.rb | suite | +| B.9 | 1 | NFR-1, NFR-2 | gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb | suite | +| B.9 | 2 | NFR-3, NFR-4 | gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/surface.rb | suite | +| B.9 | 3 | NFR-5, NFR-6, NFR-7, NFR-17 | Rakefile | restated per §9.3 | +| B.9 | 4 | NFR-8, NFR-9 | docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md | scoped out | +| B.9 | 5 | NFR-10 | gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb | suite | +| B.9 | 6 | NFR-11 | gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/surface.rb | suite | +| B.9 | 7 | NFR-12, NFR-13, NFR-14, NFR-15, NFR-16 | gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb | suite | diff --git a/gems/dexpace-conformance/lib/dexpace/conformance.rb b/gems/dexpace-conformance/lib/dexpace/conformance.rb index e493639..73c5f40 100644 --- a/gems/dexpace-conformance/lib/dexpace/conformance.rb +++ b/gems/dexpace-conformance/lib/dexpace/conformance.rb @@ -10,7 +10,24 @@ require_relative "conformance/vacuous" require_relative "conformance/assertion" require_relative "conformance/result" +# The requirement-level map a Report reads to tell a MUST-level vacuity from a SHOULD-level one +# (phase 9, design R3); generated from appendix C by tools/requirement_levels.rb. +require_relative "conformance/levels" require_relative "conformance/report" +# Phase 9's shared instrument: the one assertion primitive, the one status loop and XCUT-11's +# structural predicate, then the four suites it carries and the one aggregate over them. +require_relative "conformance/check" +require_relative "conformance/runner" +require_relative "conformance/shared_instance" +require_relative "conformance/invariant_case" +require_relative "conformance/invariant_suite" +require_relative "conformance/packaging_case" +require_relative "conformance/packaging_suite" +require_relative "conformance/codec_case" +require_relative "conformance/codec_suite" +require_relative "conformance/executor_case" +require_relative "conformance/executor_suite" +require_relative "conformance/aggregate" # The TCPServer fixture and its named scripts (design §9.3; `socket` is permitted to this gem # alone by the require allowlist's scoped denial, P8-14). require_relative "conformance/scripts" diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/aggregate.rb b/gems/dexpace-conformance/lib/dexpace/conformance/aggregate.rb new file mode 100644 index 0000000..b62d189 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/aggregate.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "report" + +module Dexpace + module Conformance + # One report over every suite in a run. This is the caller phase 8a deferred `Report#to_h` + # for, and the place `docs/first-release.md`'s standing blocker -- "before release, + # `docs/sdk-documentation/` must state what a green run does and does not prove, and the run's + # own report preamble must name the same omissions" -- is made mechanical: PREAMBLE prints on + # every render rather than sitting in a document nobody re-reads. + module Aggregate + # §12 distinguishes "not satisfied" from "holds vacuously", and §9.3 requires a waived gap + # to stay visible. So this states, on every run, what a green result does NOT prove. + PREAMBLE = [ + "A pass here proves the assertions below ran green. It does not prove:", + " - TLS verification or connect-timeout classification: the wire fixture speaks", + " plaintext only and there is no portable way to make a local listener accept slowly,", + " so both are asserted in dexpace-transport-net_http's own suite and not here (8a's", + " P8-9), as is TRANSPORT-8, whose antecedent only an adapter's own suite can name", + " - anything covered 'by reference' in APPENDIX_B.md, where the evidence is another", + " gem's test file: the row proves the ID is claimed and the file exists, never that", + " the behaviour is asserted (phase 9's P9-7)", + " - any requirement whose assertion is listed below as waived or vacuous", + " - a MUST-level requirement whose assertion is vacuous and that no accepted_vacuous:", + " citation names: such a result is listed below as a report blocker, fails this run,", + " and earns a docs/first-release.md line (design R3)", + ].freeze + + extend self + + # @param reports [Array] one per suite run + # @param accepted_vacuous [Hash{String => String}] acceptances that apply to the whole run, + # merged over each suite's own + # @param would_fail [Array] waived IDs whose assertion would have failed + # @return [Report] one report with ONE #passed? over the whole run + def run(reports, accepted_vacuous: {}, would_fail: []) + Report.merge(reports, preamble: PREAMBLE.join("\n"), + accepted_vacuous: accepted_vacuous, would_fail: would_fail,) + end + + # @param report [Report] + # @return [String] the preamble and the report, in that order + def render(report) + report.to_s + end + + # The generated half of the appendix-B coverage map: every assertion each suite DECLARES, + # keyed by requirement ID. + # + # It takes the SUITES and not a Report, and that is load-bearing: a Report holds only the + # assertions that actually RAN, so reading one would let a suite skipped in a given + # invocation silently shorten the map -- and the map's own row-count check would then fail + # for a reason that has nothing to do with a dropped row (design R7). + # + # `statuses:` is an optional Report: when given, each row carries the status that run + # produced, and an assertion with no result is marked `:not_run` rather than omitted. + # + # @param suites [Array<#assertions>] every suite whose declarations belong in the map + # @param statuses [Report, nil] one run's results + # @return [Hash{String => Array}] requirement ID -> its rows + def by_requirement_id(suites, statuses: nil) + ran = statuses.nil? ? [] : statuses.results #: Array[Result] + observed = ran.to_h { |result| [result.assertion.name, result.status] } + rows = {} #: Hash[String, Array[untyped]] + suites.each_with_object(rows) { |suite, map| add_rows(suite, observed, map) } + end + + private + + def add_rows(suite, observed, map) + suite.assertions.each do |assertion| + row = { suite: suite.name, assertion: assertion.name, + status: observed.fetch(assertion.name, :not_run), } + assertion.ids.each { |id| (map[id] ||= []) << row } + end + end + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/check.rb b/gems/dexpace-conformance/lib/dexpace/conformance/check.rb new file mode 100644 index 0000000..40f73cc --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/check.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "failure" + +module Dexpace + module Conformance + # The one assertion primitive every phase-9 suite body uses: a condition, the sentence that + # says what went wrong, the two values compared, and the requirement IDs the comparison is + # evidence for. Its own file because one public constant per file is this repository's rule + # and phase 9's own code sits inside the NFR-3 assertion phase 9 ships. + module Check + extend self + + # @param condition [Object] truthy when the property holds + # @param message [String] what went wrong, in words + # @param expected [Object] what the assertion expected + # @param actual [Object] what it found + # @param ids [Array] the requirement IDs this comparison is evidence for + # @return [nil] + # @raise [Failure] when `condition` is falsey + def that(condition, message, expected:, actual:, ids:) + return nil if condition + + raise Failure.new(message, expected: expected, actual: actual, requirement_ids: ids) + end + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/codec_case.rb b/gems/dexpace-conformance/lib/dexpace/conformance/codec_case.rb new file mode 100644 index 0000000..ff108da --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/codec_case.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module Dexpace + module Conformance + # What a CodecSuite assertion receives. + # + # The witness and the source factory are the DRIVER's: phase 2's contract is + # `#load(source, witness)` with no witness-less overload, and the source type is the adapter's + # (the JSON codec's is a `Dexpace::IO::BufferedSource`), so neither is the suite's to invent. + class CodecCase + # A sink that counts its own closes, so SERDE-3's "streaming/buffer targets not closed" is a + # COUNT and not an inference, and records what was written so an encode can be checked to + # have produced something at all. + class CountingSink + # @return [String] every byte written, in order + attr_reader :bytes + # @return [Integer] how many times #close was called + attr_reader :close_count + + def initialize + @bytes = +"" + @close_count = 0 + end + + # @param chunk [String] bytes the codec wrote + # @return [Integer] the running total written + def write(chunk) = (@bytes << chunk).bytesize + # @return [Integer] how many closes this sink has now seen -- SERDE-3's count + def close = @close_count += 1 + end + private_constant :CountingSink + + # @return [Object] the driver's witness, answering #dexpace_load(parsed, ctx) + attr_reader :witness + + # @param build [#call] a zero-argument codec factory + # @param witness [Object] a witness the codec's #load accepts + # @param source [#call] text -> the source type this codec's #load reads + def initialize(build:, witness:, source:) + @build = build + @witness = witness + @source = source + end + + # @return [Object] a fresh codec + def codec = @build.call + + # @return [CountingSink] a fresh counting sink + def sink = CountingSink.new + + # @param text [String] the bytes the source yields + def source(text) = @source.call(text) + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite.rb b/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite.rb new file mode 100644 index 0000000..6b732ed --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "codec_case" +require_relative "runner" +require_relative "codec_suite/seam" + +module Dexpace + module Conformance + # Appendix B.3's seam half: the two properties a wire codec must have whatever library it + # wraps -- SEAM-20 with SERDE-3 (it never closes a target it was handed) and SERDE-9 (no + # library exception type escapes the seam). + # + # **These assertions are RE-DERIVED from the requirement text, not lifted out of + # `dexpace-serde-json`'s own suite, and that gem's files are left byte-identical.** A lift + # would have moved `assert_closes_nothing`, whose body is the only evidence SEAM-21 has in + # that suite, and `assert_failure_model`, which carries SERDE-10 and the ENCODE half of + # SERDE-9 as well -- so a lift that took the portable halves would have dropped three + # properties on its way out. The relationship here is TransportSuite's to the net_http suite's + # instead: a portable assertion beside a gem-local one, both alive. + # + # The post-v1 `dexpace-serde-oj` is the second subject these two exist for. + module CodecSuite + extend self + + # What a green run of this suite does NOT prove. + PREAMBLE = "dexpace-conformance codec suite: two SEAM properties, re-derived from the " \ + "requirement text. The offset matrix (SERDE-4), the I/O-error pass-through " \ + "(SERDE-12), the explicit type token (SEAM-21), the encode half of SERDE-9 and " \ + "SERDE-10's mapping are asserted in the adapter's own suite and are NOT among " \ + "the things a green run here proves." + + # @return [Array] the suite's assertions, frozen and ordered + def assertions + ASSERTIONS + end + + # @param build [#call] a zero-argument codec factory + # @param witness [Object] a witness the codec's #load accepts + # @param source [#call] text -> the source type this codec's #load reads + # @param waive [Array] requirement IDs recorded :waived without running + # @param around [#call, nil] the suite contract's clause 9 wrapper + # @param accepted_vacuous [Hash{String => String}] sanctioned MUST-level vacuities + # @param would_fail [Array] waived IDs whose assertion would have failed + # @return [Report] + def run(build:, witness:, source:, waive: [], around: nil, accepted_vacuous: {}, + would_fail: []) + Runner.run(assertions, waive: waive, around: around, preamble: PREAMBLE, + accepted_vacuous: accepted_vacuous, would_fail: would_fail,) do + CodecCase.new(build: build, witness: witness, source: source) + end + end + + # The one group, a private module of its own file, so the assertion bodies are not public + # surface. + ASSERTIONS = Seam::ASSERTIONS + private_constant :ASSERTIONS + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite/seam.rb b/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite/seam.rb new file mode 100644 index 0000000..8ae2c50 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite/seam.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../check" +require_relative "../failure" +require_relative "../runner" +require_relative "../vacuous" + +module Dexpace + module Conformance + module CodecSuite + # The two portable seam properties, in a group of their own so the assertion bodies are not + # public surface: every other suite's groups are private_constants and this one is too, which + # is what keeps each suite's public surface exactly `assertions`, `run` and `PREAMBLE`. + # A private_constant of CodecSuite. + module Seam + extend self + + # The value every encode assertion dumps: small, native, and encodable by any codec. + SAMPLE = { "a" => 1 }.freeze + # Input no codec can decode, which is SERDE-9's antecedent. + MALFORMED = "{not json" + + # SEAM-20 and SERDE-3: a codec MUST NOT close a target it was handed. `#dump_to` and never + # `#dump`: phase 2's CONTRACT names `#dump_to` and keeps the `#dump` shorthand out of it + # deliberately, so a conforming codec need not define `#dump` at all. The bytes are checked + # too, so a codec that wrote nothing cannot pass by having closed nothing. + def never_closes_its_target(subject) + sink = subject.sink + subject.codec.dump_to(SAMPLE, sink) + + Check.that(sink.close_count.zero?, "the codec closed a caller-supplied sink", + expected: 0, actual: sink.close_count, ids: %w[SEAM-20 SERDE-3],) + Check.that(!sink.bytes.empty?, + "the codec wrote nothing, so closing nothing proves nothing", + expected: "some bytes", actual: sink.bytes.bytesize, + ids: %w[SEAM-20 SERDE-3],) + end + + # SERDE-9: "failures surface the stable serde type CHAINING THE ORIGINAL CAUSE, no library + # type escapes." Three clauses and all three are asserted -- a Dexpace error OUTSIDE the + # serde hierarchy, or a serde error with NO cause chained, satisfies neither the "stable + # serde type" nor the "chaining the original cause" half. + def no_library_type_escapes(subject) + raised = capture_failure(subject) + + Check.that(!raised.nil?, "malformed input decoded without raising", + expected: "a Dexpace::Serde::Error", actual: "no error", ids: ["SERDE-9"],) + Check.that(serde_error?(raised), + "a failure outside the SDK's serde hierarchy escaped the seam", + expected: "Dexpace::Serde::Error", actual: raised.class.name, + ids: ["SERDE-9"],) + # The chain is read through core's ONE cause walk rather than through a bare + # `raised.cause`: XCUT-9 makes `Dexpace.each_cause` the single walk, `gates:cause_walk` + # keeps it single by scanning for any other `#cause` send, and that scan cannot tell a + # one-step read from a walk. Using the walk costs nothing and keeps the gate's allowlist + # at one entry. + Check.that(::Dexpace.each_cause(raised).count > 1, + "the serde failure chained no original cause", + expected: "a chained #cause", actual: 1, ids: ["SERDE-9"],) + end + + # Vacuous and Failure are re-RAISED rather than captured: both are ::StandardError + # descendants (8a), so a bare rescue would turn this suite's own vacuity into a PASS. + def capture_failure(subject) + subject.codec.load(subject.source(MALFORMED), subject.witness) + nil + rescue Vacuous, Failure + raise + rescue ::StandardError => error + error + end + + def serde_error?(error) + defined?(::Dexpace::Serde::Error) && error.is_a?(::Dexpace::Serde::Error) + end + + ROWS = [ + [%w[SEAM-20 SERDE-3], "a codec never closes a target it was handed", + :never_closes_its_target,], + ["SERDE-9", "a decode failure surfaces the SDK's serde type", :no_library_type_escapes], + ].freeze + private_constant :ROWS + + # @return [Array] this group's assertions, frozen + ASSERTIONS = Runner.registry(self, ROWS) + end + private_constant :Seam + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/executor_case.rb b/gems/dexpace-conformance/lib/dexpace/conformance/executor_case.rb new file mode 100644 index 0000000..9b2d45a --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/executor_case.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Keys::EVENT and Events::INSTRUMENTATION_SHUTDOWN both live in this one file. +require "dexpace/instrumentation/keys" +require_relative "vacuous" + +module Dexpace + module Conformance + # What an ExecutorSuite assertion receives. The executor is a FACTORY, never a constant -- + # dexpace-conformance declares dexpace-core and nothing else, so it can name no adapter. + # + # The event recorder is built ONCE PER CASE and passed into the factory as an `events:` + # setting, never shared across a run: Runner builds a fresh case per assertion, so an earlier + # assertion's shutdown event cannot be counted by a later one. That is testing/4ef070df + # applied to a fixture the SUITE constructs rather than one a test writes, and it is the + # defect this phase's own planning run hit -- one shared recorder made the CONFORMING double + # fail, because three earlier assertions had each built and closed a pool. + class ExecutorCase + # XCUT-11's own conformance shape: "invoke one shared instance from MANY threads". + THREADS = 16 + + # The recorder is a SINK, because a sink is where a shutdown is observable. No filed + # executor in this repository exposes a shutdown counter: the pool reports its shutdown + # exactly once, from its release, through the injected logger -- and `Event#emit` ends in + # `@sink.public_send(severity.sink_method) { rendered_record }` with the event tag under + # `Keys::EVENT`. So the portable observation is the payload this sink receives, and the + # adapter's `build:` lambda is what wires it into its own logger. + # + # Shaped on core's `_Sink` duck type; every predicate answers true, so an INFO-severity + # shutdown is never filtered by `Logger#enabled?` before it reaches here. + class EventRecorder + def initialize + @payloads = [] + @mutex = ::Thread::Mutex.new + end + + # @return [Array] the payloads this sink received, oldest first, as a dup -- a + # caller iterating cannot race a close still running on another thread + def entries = @mutex.synchronize { @payloads.dup } + + # @return [nil] core's `_Sink` duck type; every severity records the same way + def debug(message = nil, &) = record(message, &) + # @return [nil] + def info(message = nil, &) = record(message, &) + # @return [nil] + def warn(message = nil, &) = record(message, &) + # @return [nil] + def error(message = nil, &) = record(message, &) + + def debug? = true + def info? = true + def warn? = true + def error? = true + + private + + def record(message) + payload = block_given? ? yield : message + @mutex.synchronize { @payloads << payload } + nil + end + end + private_constant :EventRecorder + + # @param build [#call] keyword-taking executor factory; the suite passes `events:` when it + # has a recorder and nothing else + # @param borrow [#call, nil] pool -> a holder that borrows it, or nil when the adapter + # exposes no borrowing entry point + # @param functional [#call, nil] a RESOURCE-FREE implementation for ASYNC-17, or nil + # @param record_events [Boolean] whether this case builds its OWN event recorder and hands + # it to `build:` as `events:`. The recorder's class stays private -- a driver declares + # that its factory accepts the keyword rather than constructing one, which keeps a test + # double out of the gem's locked public surface. + def initialize(build:, borrow: nil, functional: nil, record_events: false) + @build = build + @borrow = borrow + @functional = functional + @recorder = record_events ? EventRecorder.new : nil + end + + # @return [Object] a fresh executor, wired to this case's recorder when there is one + def executor + return @build.call if @recorder.nil? + + @build.call(events: @recorder) + end + + # @return [Boolean] whether the adapter exposes a borrowing entry point + def borrowed? = !@borrow.nil? + + # @param pool [Object] the executor to borrow + # @return [Object] a holder that borrowed it + def borrowed(pool) + raise Vacuous, "this adapter exposes no borrowing entry point" if @borrow.nil? + + @borrow.call(pool) + end + + # @return [Boolean] whether a resource-free implementation was supplied + def functional? = !@functional.nil? + + # ASYNC-17's subject is a RESOURCE-FREE implementation, supplied separately: asserting the + # no-op default against a pool that owns a thread would assert the opposite requirement. + def functional + raise Vacuous, "no resource-free implementation supplied" if @functional.nil? + return @functional.call if @recorder.nil? + + @functional.call(events: @recorder) + end + + # @return [Boolean] whether an event recorder was supplied + def events? = !@recorder.nil? + + # SEAM-25's "close twice -> executor shut once, one event" needs this and nothing else: how + # many shutdown payloads the recorder saw. Only the event NAME is read -- the pool's two + # field keys are its own private constants and are deliberately absent from core's Keys, + # because the portable assertion needs the event name only. + # + # @return [Integer] + def shutdowns + recorder = @recorder + raise Vacuous, "no event recorder supplied to ExecutorSuite.run" if recorder.nil? + + recorder.entries.count { |payload| shutdown?(payload) } + end + + private + + def shutdown?(payload) + payload.is_a?(::Hash) && + payload[Dexpace::Instrumentation::Keys::EVENT] == + Dexpace::Instrumentation::Events::INSTRUMENTATION_SHUTDOWN + end + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite.rb b/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite.rb new file mode 100644 index 0000000..87be87e --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "executor_case" +require_relative "runner" +require_relative "executor_suite/lifecycle" +require_relative "executor_suite/shutdown" + +module Dexpace + module Conformance + # Appendix B.7's lifecycle half, and the HARNESS half of SEAM-25's lifecycle event. + # + # **That reassignment is a CORRECTION to a committed record, stated as one.** Phase 2 + # postponed the event; 8b's design said "the HARNESS half -- the assertion living in + # dexpace-conformance -- is 8a's, and 8b hands it the shape rather than writing it." 8a wrote + # the protocol, the wire fixture and the transport suite, and wrote NO executor suite; 8b + # supplied the shape and emits the event. So the harness half was unwritten after phase 8, + # phase 9 writes it, and the earlier record is wrong about which phase delivers it. + # + # Two clauses are SCOPED OUT with reasons rather than silently dropped: ASYNC-15's clause (c), + # interrupt-safety, needs a pending interrupt and §8.3 bans every primitive that could arrange + # one; and SEAM-18 is the executor seam's SHAPE rather than an implementation property, which + # 8b's own suite asserts. + # + # Every lifecycle observation here goes through `ExecutorCase#shutdowns`, the count of + # `Events::INSTRUMENTATION_SHUTDOWN` payloads the case's own recorder saw. That is the only + # channel a filed executor exposes a shutdown on; an assertion reading a `#shutdown_count` + # would be reading a method no adapter in this repository defines. + module ExecutorSuite + extend self + + # What a green run of this suite does NOT prove. + PREAMBLE = "dexpace-conformance executor suite: ASYNC-15's clause (c) and SEAM-18 are " \ + "scoped out with their reasons and are NOT among the things a green run " \ + "proves. Without `record_events: true` an UNLATCHED executor passes XCUT-13, " \ + "because \"the shutdown ran twice\" is visible only in the lifecycle event. " \ + "ASYNC-3 is an unsatisfied MUST (design §10.5): it fails against every executor " \ + "this repository can build, and a first-party run waives it by ID." + + # @return [Array] the suite's assertions, frozen and ordered + def assertions + ASSERTIONS + end + + # @param build [#call] keyword-taking executor factory; the suite passes `events:` when + # `record_events:` is true and nothing otherwise + # @param borrow [#call, nil] pool -> a holder that borrows it, or nil + # @param functional [#call, nil] a RESOURCE-FREE implementation for ASYNC-17, or nil + # @param record_events [Boolean] whether `build:` accepts an `events:` sink + # @param waive [Array] requirement IDs recorded :waived without running + # @param around [#call, nil] the suite contract's clause 9 wrapper + # @param accepted_vacuous [Hash{String => String}] sanctioned MUST-level vacuities + # @param would_fail [Array] waived IDs whose assertion would have failed + # @return [Report] + def run(build:, borrow: nil, functional: nil, record_events: false, waive: [], around: nil, + accepted_vacuous: {}, would_fail: []) + Runner.run(assertions, waive: waive, around: around, preamble: PREAMBLE, + accepted_vacuous: accepted_vacuous, would_fail: would_fail,) do + ExecutorCase.new(build: build, borrow: borrow, functional: functional, + record_events: record_events,) + end + end + + # The two groups, each a private module of its own file. + ASSERTIONS = (Lifecycle::ASSERTIONS + Shutdown::ASSERTIONS).freeze + private_constant :ASSERTIONS + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/lifecycle.rb b/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/lifecycle.rb new file mode 100644 index 0000000..688b970 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/lifecycle.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../check" +require_relative "../executor_case" +require_relative "../runner" + +module Dexpace + module Conformance + module ExecutorSuite + # Group 1, the executor's lifecycle: SEAM-12 with XCUT-11, XCUT-13 with ASYNC-15's clauses + # (a) and (b), XCUT-22 with the same, and ASYNC-16. A private_constant of ExecutorSuite. + module Lifecycle + extend self + + # How many units the graceful-shutdown assertion posts before it closes. + DRAIN = 8 + + # SEAM-12 / XCUT-11: "invoke one shared instance from many threads and assert no + # cross-talk." Sixteen threads each post a distinct value; the assertion is on the + # collected set, never on timing, so the test runs alone in any order. + def concurrent_post(subject) + pool = subject.executor + seen = ::Thread::Queue.new + ::Array.new(ExecutorCase::THREADS) { |i| ::Thread.new { pool.post { seen << i } } } + .each(&:join) + pool.close + collected = drain(seen) + + Check.that(collected == (0...ExecutorCase::THREADS).to_a, + "work posted from many threads was lost or duplicated", + expected: ExecutorCase::THREADS, actual: collected.size, + ids: %w[SEAM-12 XCUT-11],) + end + + # XCUT-13 / ASYNC-15 clauses (a) and (b): close is latched and ownership-aware. Clause + # (c), interrupt-safety, is SCOPED OUT with its reason -- §8.3 bans every primitive that + # could arrange a pending interrupt, so the clause holds by the flag never being touched + # and there is nothing observable to assert. + # + # Two halves, because only one needs a recorder. `Closeable#close` answers nil on the + # winning AND the losing call, which is assertable against any adapter; that the shutdown + # WORK ran once is visible only in the lifecycle event, so that half runs when the driver + # declared `record_events:` and is SKIPPED -- not failed -- when it did not. What is lost + # without a recorder is worth naming: an UNLATCHED executor passes this assertion, because + # "the shutdown ran twice" is visible nowhere else. + def close_is_latched(subject) + pool = subject.executor + pool.close + second = outcome_of(-> { pool.close }) + Check.that(second.nil?, + "a second close raised or returned a value instead of latching", + expected: nil, actual: second, ids: %w[XCUT-13 ASYNC-15],) + + return nil unless subject.events? + + shutdowns = subject.shutdowns + Check.that(shutdowns == 1, "close shut the executor more than once", + expected: 1, actual: shutdowns, ids: %w[XCUT-13 ASYNC-15],) + end + + # XCUT-22 / ASYNC-15 clause (b): the SDK closes only what it created. So the borrowed + # executor must still be USABLE afterwards -- observable with no recorder at all -- and + # must have emitted no shutdown event. + def borrowed_executor_survives(subject) + underlying = subject.executor + subject.borrowed(underlying).close + alive = ::Thread::Queue.new + still_open = outcome_of(-> { underlying.post { alive << :ok } }).nil? + + Check.that(still_open, "the SDK shut down an executor it borrowed", + expected: "still accepting work", actual: "refused work", + ids: %w[XCUT-22 ASYNC-15],) + check_no_shutdown(subject) + underlying.close + nil + end + + # XCUT-22's event half, which runs only when the driver declared `record_events:`. + # @return [nil] + def check_no_shutdown(subject) + return nil unless subject.events? + + shutdowns = subject.shutdowns + Check.that(shutdowns.zero?, + "the SDK emitted a shutdown event for an executor it borrowed", + expected: 0, actual: shutdowns, ids: %w[XCUT-22 ASYNC-15],) + end + + # ASYNC-16 (SHOULD): "shut it down gracefully on close -- stop accepting new work and WAIT + # for in-flight tasks to finish rather than interrupting them." Both halves are observable + # without an interrupt. + def graceful_shutdown(subject) + pool = subject.executor + done = ::Thread::Queue.new + DRAIN.times { |index| pool.post { done << index } } + pool.close + drained = drain(done) + + Check.that(drained == (0...DRAIN).to_a, + "close did not wait for in-flight work to finish", + expected: DRAIN, actual: drained.size, ids: ["ASYNC-16"],) + check_refusal(pool, done) + end + + # ASYNC-16's second half: nothing new is accepted after close. + # @return [nil] + def check_refusal(pool, done) + refused = !outcome_of(-> { pool.post { done << :after } }).nil? + + Check.that(refused || done.empty?, "the executor accepted new work after close", + expected: "refused", actual: "accepted", ids: ["ASYNC-16"],) + end + + # @return [Array] everything the queue holds, sorted, taken without blocking + def drain(queue) + collected = [] #: Array[untyped] + collected << queue.pop until queue.empty? + collected.sort + end + + # The error a call raised, or nil when it returned. Vacuous and Failure are re-raised: + # both are ::StandardError descendants, so a bare rescue would swallow this suite's own. + def outcome_of(callable) + callable.call + nil + rescue Vacuous, Failure + raise + rescue ::StandardError => error + error + end + + ROWS = [ + [%w[SEAM-12 XCUT-11], "a shared executor takes work from many threads", :concurrent_post], + [%w[XCUT-13 ASYNC-15], "an owned executor's close is idempotent", :close_is_latched], + [%w[XCUT-22 ASYNC-15], "a caller-supplied executor survives its holder's close", + :borrowed_executor_survives,], + ["ASYNC-16", "close drains in-flight work and refuses new work", :graceful_shutdown], + ].freeze + private_constant :ROWS + + # @return [Array] this group's assertions, frozen + ASSERTIONS = Runner.registry(self, ROWS) + end + private_constant :Lifecycle + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/shutdown.rb b/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/shutdown.rb new file mode 100644 index 0000000..da2b926 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/shutdown.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../check" +require_relative "../runner" +require_relative "../vacuous" + +module Dexpace + module Conformance + module ExecutorSuite + # Group 2, the no-op default, the lifecycle event and the interrupt MUST: ASYNC-17, SEAM-25 + # and ASYNC-3. A private_constant of ExecutorSuite. + module Shutdown + extend self + + # Seconds a cancelled worker has to be released in. The requirement's own shape is "within + # a bound"; this is one, and it is generous. + BLOCKED_WORKER_BOUND = 1.0 + + # ASYNC-17 (SHOULD): "the async transport SPI SHOULD provide a NO-OP DEFAULT close so + # lightweight/functional implementations need not implement lifecycle management." A no-op + # close shuts nothing down, so it emits no lifecycle event -- the same recorder, read for + # zero rather than for one. + def default_close_is_a_no_op(subject) + functional = subject.functional + Check.that(functional.respond_to?(:close), + "a functional implementation has no close at all", + expected: "#close", actual: "absent", ids: ["ASYNC-17"],) + functional.close + functional.close + + return nil unless subject.events? + + shutdowns = subject.shutdowns + Check.that(shutdowns.zero?, + "a resource-free implementation's close shut something down", + expected: 0, actual: shutdowns, ids: ["ASYNC-17"],) + end + + # SEAM-25, the harness half phase 2 postponed and 8b's design assigned to 8a, which wrote + # no executor suite: one lifecycle event on the FIRST close of an owned executor, and none + # on the second. The event name is core's; the two field keys around it are the adapter's + # private constants and are never read. + def one_shutdown_event(subject) + raise Vacuous, "no event recorder supplied to ExecutorSuite.run" unless subject.events? + + pool = subject.executor + pool.close + pool.close + shutdowns = subject.shutdowns + + Check.that(shutdowns == 1, + "an owned executor's close did not emit exactly one event", + expected: 1, actual: shutdowns, ids: ["SEAM-25"],) + end + + # ASYNC-3, an UNSATISFIED MUST (design §10.5): "cancel-with-interrupt against a blocking + # worker". Written so it genuinely FAILS on every executor this repository can build -- + # the thread pool cannot interrupt a worker, because §8.3 bans Thread#raise, Thread#kill + # and Timeout.timeout outright -- and the first-party drivers waive it BY ID so the report + # prints `waived (would fail): ASYNC-3`, never passed and never vacuous. That is appendix + # B.7's "recorded as FAILING rather than vacuous" item given a runnable subject. + # + # The pivot is core's `Transport.async_over`, which is how a cancellation token reaches a + # posted unit at all: the executor SPI's `#post` takes none. The wait is the requirement's + # own shape, "within a bound", observed through queues -- the worker's ENTRY is a queue + # push, so no sleep guesses that it started -- and the ensure frees the gate so a failed + # run leaks no worker and no thread. + def blocked_worker_is_released_on_cancel(subject) + probe! + gate = ::Thread::Queue.new + transport = BlockingTransport.new(gate: gate) + pool = subject.executor + source = ::Dexpace::Cancellation.source + poster = dispatch(transport, pool, source) + begin + check_release(transport, source) + ensure + gate << :free + poster.join(BLOCKED_WORKER_BOUND) + pool.close + end + end + + # The bounded wait ASYNC-3 names, observed through the poster rather than slept on. + # @return [nil] + def check_release(transport, source) + transport.entered.pop + source.cancel(:interrupt_requested) + freed = transport.released.pop(timeout: BLOCKED_WORKER_BOUND) + + Check.that(!freed.nil?, + "a worker blocked in an uninterruptible call was not released after cancel", + expected: "released within #{BLOCKED_WORKER_BOUND}s of cancel", + actual: "still blocked", ids: ["ASYNC-3"],) + end + + def probe! + if defined?(::Dexpace::Transport) && ::Dexpace::Transport.respond_to?(:async_over) + return nil + end + + raise Vacuous, "Dexpace::Transport.async_over is absent; phase 2 committed to it" + end + + # @return [Thread] the poster, which the assertion's own ensure joins + def dispatch(transport, pool, source) + bridge = ::Dexpace::Transport.async_over(transport, executor: pool) + ::Thread.new do + bridge.call(:request, nil, source.token).value + rescue ::StandardError + nil + end + end + + # The blocking subject: it answers the transport seam's #call, parks on a gate with NO + # timeout, and reports its own entry and release. It returns a closeable so the pivot's + # orphan-close path has something to close when the cancelled caller is refused the + # result. + class BlockingTransport + attr_reader :entered, :released + + def initialize(gate:) + @gate = gate + @entered = ::Thread::Queue.new + @released = ::Thread::Queue.new + end + + # The transport seam's `#call`, parked on a gate with no timeout of its own. + # @return [Closeable] what the pivot closes when the cancelled caller is refused + def call(_request, _options, _cancellation) + @entered << :in + @gate.pop + Closeable.new + ensure + @released << :out + end + + # What the pivot closes when nobody is left to take the result. + class Closeable + # @return [nil] + def close = nil + end + end + private_constant :BlockingTransport + + ROWS = [ + ["ASYNC-17", "a resource-free implementation inherits a no-op close", + :default_close_is_a_no_op,], + ["SEAM-25", "closing an owned executor emits exactly one shutdown event", + :one_shutdown_event,], + ["ASYNC-3", "cancelling a task blocked on a worker releases the worker within a bound", + :blocked_worker_is_released_on_cancel,], + ].freeze + private_constant :ROWS + + # @return [Array] this group's assertions, frozen + ASSERTIONS = Runner.registry(self, ROWS) + end + private_constant :Shutdown + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_case.rb b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_case.rb new file mode 100644 index 0000000..f46dad1 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_case.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "vacuous" + +module Dexpace + module Conformance + # What an InvariantSuite assertion receives. + # + # `core` is the loaded Dexpace module -- XCUT's subjects are core constants, not an instance. + # Everything else is a factory the DRIVER supplies, because the suite can reach none of them + # itself: `Dexpace::BoundedMap` is a `private_constant`, 6c's cnonce is drawn inside + # `#authorization_for`, and building a redirect hop needs a pipeline cursor and a scripted 3xx. + # + # `mutable` is the driver's declaration of which instance variables on a shared instance are a + # mutex with the state it guards or Closeable's latch (design P9-9): never read off the audited + # object, which could then exempt the per-call state XCUT-11 forbids. + class InvariantCase + # @return [Module] the loaded core module under audit + attr_reader :core + # @return [Array] the driver's declared-mutable ivars for XCUT-11 + attr_reader :mutable + + def initialize(core:, seam: nil, transport: nil, mutable: [], shared: [], bounded_map: nil, + bounded_map_store: nil, cnonce: nil, redirect_hops: nil, credential_hop: nil) + @core = core + @seam = seam + @transport = transport + @mutable = mutable + @shared = shared + @bounded_map = bounded_map + @bounded_map_store = bounded_map_store + @cnonce = cnonce + @redirect_hops = redirect_hops + @credential_hop = credential_hop + end + + # Every shared instance the driver declares, as `[label, object, declared_ivars]`. Design R8 + # names NINE across five phase documents; the suite audits each through SharedInstance and + # cannot reach one of them by itself, because "which ivar is the mutex" is the driver's + # knowledge and not the audited object's (P9-9). + # + # @return [Array)>] + def shared_instances + @shared + end + + # Design R3: every audit opens with an existence probe against the name the owning phase + # committed to, and an ABSENT artifact is :vacuous NAMING WHO PROMISED IT -- not :failed. + # Phase 9 was planned before any code existed, so reporting :failed would conflate "not + # built" with "built wrong", which is the distinction phase 10 acts on. Nothing passes by not + # being built: an un-waived MUST-level vacuity is a report blocker + # (Report#blocking_vacuities). + # + # @param name [Symbol] the constant or method name promised + # @param promised_by [String] the phase and artifact that promised it + # @param method [Boolean] probe `#respond_to?` rather than `#const_defined?` + # @return [nil] + # @raise [Vacuous] when the artifact is absent + def probe!(name, promised_by, method: false) + present = method ? @core.respond_to?(name) : @core.const_defined?(name) + return nil if present + + raise Vacuous, "#{name} is absent; #{promised_by} committed to it" + end + + # @return [Boolean] whether the driver supplied a seam factory + def seam? = !@seam.nil? + + # The seam under audit: a closeable component the driver builds. `client:` is the ONE setting + # the suite passes -- XCUT-22 needs a caller-supplied resource and nothing else does -- and it + # is a named keyword rather than a splat, because `Dexpace/NoKeywordSplat` refuses one in any + # gem's lib/ and 8a's TransportCase#transport already names its two. + # + # @param client [Object, nil] a caller-supplied resource the seam must not close + def seam(client: nil) + factory = required!(@seam, "no seam implementation supplied to InvariantSuite.run") + factory.call(client: client) + end + + # @return [Boolean] whether the driver supplied a transport factory + def transport? = !@transport.nil? + + # A transport factory, for the one assertion whose subject is an adapter's dispatch path + # (XCUT-18's second assertion, the wire-boundary re-validation phase 1 named for this phase). + # Kept apart from `seam` because a driver may supply both and they are different objects. + def transport + required!(@transport, "no transport factory supplied to InvariantSuite.run").call + end + + # Dexpace::BoundedMap is a private_constant of Dexpace (4a's P4-3), reachable by its BARE name + # from a full-nesting body and by `Dexpace.const_get` -- but not by the scoped spelling. The + # factory route keeps the suite out of that question entirely: the driver is core's own suite + # and knows what to build. + def bounded_map(cap:) + required!(@bounded_map, "no bounded-map factory supplied to InvariantSuite.run") + .call(cap: cap) + end + + # XCUT-14's drain clause needs the map's backing Hash, which BoundedMap keeps private. The + # driver knows that name and hands in a reader; the suite never reaches into the object. + def bounded_map_store(map) + required!(@bounded_map_store, "no bounded-map store reader supplied to InvariantSuite.run") + .call(map) + end + + # @return [Boolean] whether the driver supplied a cnonce driver + def cnonce? = !@cnonce.nil? + + # 6c's DigestHandler takes `cnonce_source:` and calls `#hex(16)` on it inside + # `#authorization_for`; the driver knows how to build one with a credential and a challenge, + # and the assertion needs only the rendered value back. + def draw_cnonce(source) + required!(@cnonce, "no cnonce driver supplied to InvariantSuite.run").call(source) + end + + # XCUT-17 needs one redirect re-issue driven through 6b's step. Building the step, its cursor + # and the scripted 3xx is the driver's job; the assertion needs only the re-issued request + # back -- anything answering `#headers` (with `#[]`) and `#url` -- or the error a refusal + # raises. + def redirect_hop(from:, to:, headers:) + required!(@redirect_hops, "no redirect driver supplied to InvariantSuite.run") + .call(from: from, to: to, headers: headers) + end + + # XCUT-16 needs one drive of the credential-attaching path. Building 6c's step, its stamper + # and a cursor carrying 6b's cross-origin marker is the driver's job; the assertion needs + # only the request that went out -- anything answering `#headers` with `#[]` -- or the error + # the HTTPS guard raised. + # + # @param url [String] the request target + # @param cross_origin [Boolean] the REDIRECT pillar's marker, which suppresses the stamp + def credential_hop(url:, cross_origin: false) + required!(@credential_hop, "no credential driver supplied to InvariantSuite.run") + .call(url: url, cross_origin: cross_origin) + end + + private + + def required!(factory, reason) + raise Vacuous, reason if factory.nil? + + factory + end + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite.rb b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite.rb new file mode 100644 index 0000000..c6a4649 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "invariant_case" +require_relative "runner" +require_relative "invariant_suite/outcomes" +require_relative "invariant_suite/models" +require_relative "invariant_suite/taxonomy" +require_relative "invariant_suite/classification" +require_relative "invariant_suite/memory" +require_relative "invariant_suite/security" +require_relative "invariant_suite/header_syntax" +require_relative "invariant_suite/credentials" +require_relative "invariant_suite/sharing" +require_relative "invariant_suite/concurrency" +require_relative "invariant_suite/resolution" + +module Dexpace + module Conformance + # Appendix B.8, the cross-cutting invariants: twenty-eight assertions over all twenty-four + # XCUT IDs, with XCUT-11, XCUT-13, XCUT-14 and XCUT-18 carrying two each. + # + # One Result per ASSERTION, assertions keyed by requirement ID, and an appendix-B item is a + # view over the assertions for its IDs whose status is the worst among them (design P9-8) -- + # which is what lets XCUT-13's two clauses each have a check without a second ID. "One + # assertion per ID" would forbid exactly that. + # + # Most of what this suite reaches is a core constant it can name itself. Six things it cannot, + # and the driver supplies each as a factory: the closeable seam, a transport, a bounded map + # and a reader for its private store, a cnonce draw and a redirect re-issue -- plus the + # credential hop and the list of shared instances whose declared-mutable ivars only the driver + # knows (P9-9). A factory left nil makes its assertions report :vacuous WITH A REASON, never + # :passed; an un-waived MUST-level vacuity then blocks the report (Report#blocking_vacuities), + # which is what stops "not supplied" from reading as "conforming". + module InvariantSuite + extend self + + # What a green run of this suite does NOT prove. + PREAMBLE = "dexpace-conformance invariant suite: every assertion here is reached through " \ + "the loaded core module and the factories the driver supplied. A factory left " \ + "nil makes its assertions vacuous rather than passed, and a MUST-level vacuity " \ + "blocks the report -- so read the vacuity list, not only the pass count." + + # @return [Array] the suite's assertions, frozen and ordered + def assertions + ASSERTIONS + end + + # Runs the suite against one core module and the driver's factories. + # + # @param core [Module] the loaded core module under audit + # @param seam [#call, nil] `->(client:) { … }` building a closeable component with + # `#call(request)` and `#release_count` + # @param transport [#call, nil] a zero-argument factory for a transport seam + # @param mutable [Array] the seam's declared-mutable ivars (design P9-9) + # @param shared [Array)>] every other shared instance + # under audit, as `[label, object, declared ivars]` + # @param bounded_map [#call, nil] `->(cap:) { … }` + # @param bounded_map_store [#call, nil] `->(map) { … }` returning the map's backing Hash + # @param cnonce [#call, nil] `->(source) { … }` returning a rendered cnonce + # @param redirect_hops [#call, nil] `->(from:, to:, headers:) { … }` + # @param credential_hop [#call, nil] `->(url:, cross_origin:) { … }` + # @param waive [Array] requirement IDs recorded :waived without running + # @param around [#call, nil] the suite contract's clause 9 wrapper + # @param accepted_vacuous [Hash{String => String}] sanctioned MUST-level vacuities + # @param would_fail [Array] waived IDs whose assertion would have failed + # @return [Report] + def run(core: ::Dexpace, seam: nil, transport: nil, mutable: [], shared: [], + bounded_map: nil, bounded_map_store: nil, cnonce: nil, redirect_hops: nil, + credential_hop: nil, waive: [], around: nil, accepted_vacuous: {}, would_fail: []) + Runner.run(assertions, waive: waive, around: around, preamble: PREAMBLE, + accepted_vacuous: accepted_vacuous, would_fail: would_fail,) do + InvariantCase.new(core: core, seam: seam, transport: transport, mutable: mutable, + shared: shared, bounded_map: bounded_map, + bounded_map_store: bounded_map_store, cnonce: cnonce, + redirect_hops: redirect_hops, credential_hop: credential_hop,) + end + end + + # The ten groups, each a private module of its own file (one constant per file, and each + # under RuboCop's module-length cap), concatenated in the order a reader meets the chapter. + ASSERTIONS = (Models::ASSERTIONS + Taxonomy::ASSERTIONS + Classification::ASSERTIONS + + Memory::ASSERTIONS + Security::ASSERTIONS + HeaderSyntax::ASSERTIONS + + Credentials::ASSERTIONS + Sharing::ASSERTIONS + Concurrency::ASSERTIONS + + Resolution::ASSERTIONS).freeze + private_constant :ASSERTIONS + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/classification.rb b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/classification.rb new file mode 100644 index 0000000..90413bf --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/classification.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../check" +require_relative "../runner" +require_relative "outcomes" + +module Dexpace + module Conformance + module InvariantSuite + # Group 3, the error factory, the cause walk and the retry-safety gate: XCUT-8, XCUT-9 and + # XCUT-10. A private_constant of InvariantSuite. + module Classification + extend self + + # A cycle SHORTER than this cannot discriminate: a two-node cycle is indistinguishable + # from a walk that simply stops after two steps, so a depth-capped implementation passes. + CYCLE_NODES = 3 + # The step bound on the drive. A walk that never terminates raises StopIteration never, so + # the drive is bounded by COUNT -- which needs none of §8.3's banned interrupt primitives + # and is why the "a bound is unavailable" reasoning was simply false. + MAX_STEPS = 8 + + # Each assertion below is one requirement's CLAUSES, and each clause is one Check, so the + # metric is counting the requirement's own size. Splitting by count would split by + # arithmetic rather than by behaviour -- which is .rubocop.yml's own recorded argument for + # turning Minitest/MultipleAssertions off, applied to the assertions that mirror them. + # rubocop:disable Metrics/AbcSize + # XCUT-8: "the status-to-exception mapping factory MUST reject being asked to map a + # non-error status -- it MUST raise an argument error rather than fabricate a 'successful + # exception'. A convenience form MAY instead return an absent/null value." Two forms, one + # assertion, and the error form is checked for BOTH halves: it raises on 200 and it still + # maps a real error status. + def error_factory_refuses_non_error_status(subject) + subject.probe!(:ProtocolError, "4b's recovery layer") + factory = subject.core.const_get(:ProtocolError) + refused = Outcomes.raised_class(-> { factory.for(response(subject, 200)) }) + + Check.that(refused == subject.core.const_get(:InvalidArgumentError), + "the status-to-exception factory fabricated an error for a 2xx", + expected: "InvalidArgumentError", actual: refused, ids: ["XCUT-8"],) + Check.that(factory.for_or_nil(response(subject, 200)).nil?, + "the convenience form returned something for a non-error status", + expected: nil, actual: "an error", ids: ["XCUT-8"],) + mapped = factory.for(response(subject, 503)) + Check.that(mapped.status.code == 503, "the factory did not map a real error status", + expected: 503, actual: mapped.status.code, ids: ["XCUT-8"],) + end + + # XCUT-9: "MUST track visited causes BY REFERENCE IDENTITY and terminate on a + # self-referential or cyclic chain instead of looping forever." + # + # THREE nodes, not two, for the reason CYCLE_NODES states. The walk is driven through + # Enumerator#next under a STEP BOUND rather than #to_a, so a non-terminating walk reports + # :failed instead of hanging the suite. + # + # **Stated residue, because a green result must not read as more than it is.** A + # collect-then-yield walk -- one that gathers the whole chain before yielding its first + # element -- never returns a step to count, and bounding THAT would need an interrupt + # §8.3 bans outright; the suite would hang, and only the harness's own outer bound would + # end it. And a depth cap of exactly CYCLE_NODES passes: a black-box test over one finite + # input cannot tell a counter from a visited set. gates:cause_walk is the second line, + # keeping the walk in one file so neither defect can live anywhere else; it proves neither + # absent. Both are on the XCUT-9 checklist row. + def cycle_safe_cause_walk(subject) + subject.probe!(:each_cause, "4b's Dexpace.each_cause", method: true) + walked = drive(subject.core.each_cause(cycle)) + + Check.that(walked.size == CYCLE_NODES, + "the cause walk did not terminate after visiting the cycle once", + expected: CYCLE_NODES, actual: walked.size, ids: ["XCUT-9"],) + Check.that(walked.uniq(&:object_id).size == CYCLE_NODES, + "the cause walk revisited a node, so it does not track by reference identity", + expected: CYCLE_NODES, actual: walked.uniq(&:object_id).size, ids: ["XCUT-9"],) + end + + # XCUT-10: "retry-SAFETY MUST be decided at the retry step, independently of retryability, + # and applied UNIFORMLY to both protocol and transport failures: (a) a request WITHOUT a + # body is retry-safe only if its method is in the configured idempotent-method set -- a + # bare POST MUST NOT be retried, EVEN when the failure is a transport error that never + # reached the server; (b) a request WITH a body is retry-safe only if that body is + # replayable." The gate takes the REQUEST and nothing else, which is what makes the + # uniformity structural: there is no failure parameter to special-case on. + def retry_safety_is_uniform(subject) + subject.probe!(:Resilience, "6a's retry layer") + resend = subject.core::Resilience.const_get(:Resend) + cases = { "a body-less GET" => [safety_request(subject, "GET", nil), true], + "a body-less POST" => [safety_request(subject, "POST", nil), false], + "a POST with a replayable body" => [replayable_post(subject), true], + "a POST with a streaming body" => [streaming_post(subject), false], } + + wrong = cases.reject { |_, (request, want)| resend.eligible?(request) == want } + Check.that(wrong.empty?, + "the retry-safety gate does not decide every case XCUT-10 enumerates", + expected: cases.transform_values(&:last), actual: wrong.keys, + ids: ["XCUT-10"],) + Check.that(resend.method(:eligible?).parameters == [%i[req request]], + "the safety gate takes a failure parameter, so it CAN special-case a " \ + "transport error -- the uniformity XCUT-10 demands is structural", + expected: [%i[req request]], actual: resend.method(:eligible?).parameters, + ids: ["XCUT-10"],) + end + + # Exception#cause is not assignable, so the cycle is built through a singleton reader -- + # the only portable way to produce the shape XCUT-9 names on CRuby, and the shape 4b's own + # fixture uses. + def cycle + nodes = ::Array.new(CYCLE_NODES) { |i| ::StandardError.new("node-#{i}") } + nodes.each_with_index do |node, index| + node.instance_variable_set(:@dexpace_loop, nodes[(index + 1) % nodes.size]) + def node.cause = @dexpace_loop + end + nodes.first + end + + # @return [Array] at most the step bound, so a non-terminating walk still ends + def drive(enumerator) + walked = [] #: Array[untyped] + collect(enumerator, walked) + walked + end + + # @return [nil] fills `walked` under the step bound, stopping at StopIteration + def collect(enumerator, walked) + MAX_STEPS.times { walked << enumerator.next } + nil + rescue ::StopIteration + nil + end + + # @return [untyped] a minimal response carrying the status under test + def response(subject, code) + subject.core::Response.build( + request: safety_request(subject, "GET", nil), + protocol: subject.core::Protocol::HTTP_1_1, + status: subject.core::Status.of(code), headers: subject.core::Headers::EMPTY, + ) + end + + # @return [untyped] a minimal request, which is the retry-safety gate's whole input + def safety_request(subject, verb, body) + subject.core::Request.build(method: subject.core::Method.of(verb), + url: "https://a.example/x", + headers: subject.core::Headers::EMPTY, body: body,) + end + + # @return [untyped] a POST whose body answers `#replayable?` true + def replayable_post(subject) + safety_request(subject, "POST", subject.core::Body.string("{}")) + end + + # A single-use streaming body: Body.chunked takes anything answering #each, and what + # XCUT-10 cares about is that `#replayable?` is false. + def streaming_post(subject) + safety_request(subject, "POST", subject.core::Body.chunked(::Enumerator.new do |y| + y << "x" + end),) + end + + # rubocop:enable Metrics/AbcSize + + ROWS = [ + ["XCUT-8", "the status-to-exception factory refuses a non-error status", + :error_factory_refuses_non_error_status,], + ["XCUT-9", "the cause walk terminates on a cyclic chain, tracking by reference identity", + :cycle_safe_cause_walk,], + ["XCUT-10", "retry safety is decided from the request alone, uniformly", + :retry_safety_is_uniform,], + ].freeze + private_constant :ROWS + + # @return [Array] this group's assertions, frozen + ASSERTIONS = Runner.registry(self, ROWS) + end + private_constant :Classification + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/concurrency.rb b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/concurrency.rb new file mode 100644 index 0000000..2234003 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/concurrency.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../check" +require_relative "../runner" +require_relative "outcomes" + +module Dexpace + module Conformance + module InvariantSuite + # Group 9, cancellation and the inter-attempt wait: XCUT-1, XCUT-2 and XCUT-3. + # A private_constant of InvariantSuite. + module Concurrency + extend self + + # A wait this long has not been "promptly cancellable" by any reading of XCUT-3. + WAKE_BOUND_SECONDS = 0.5 + # Long enough that an uncancelled wait would blow the bound by an order of magnitude. + WAIT_SECONDS = 30.0 + + # Each assertion below is one requirement's CLAUSES, and each clause is one Check, so the + # metric is counting the requirement's own size. Splitting by count would split by + # arithmetic rather than by behaviour -- which is .rubocop.yml's own recorded argument for + # turning Minitest/MultipleAssertions off, applied to the assertions that mirror them. + # rubocop:disable Metrics/AbcSize + # XCUT-1: "a cancellation MUST be surfaced as a distinct, TERMINAL, NON-retryable signal, + # kept separate from a timeout ... and never let a cancelled operation be automatically + # retried." + # + # The non-retryability is checked through the classifier AND through a wrapper, because a + # transport that wrapped the token's raise in its own always-retryable error is the case + # the requirement exists for: a check reading only the outermost error would retry it. + def cancellation_is_terminal(subject) + subject.probe!(:Cancellation, "phase 2's seam layer") + subject.probe!(:CancelledError, "phase 2's seam layer") + policy = subject.core.const_get(:Resilience).const_get(:Policy) + source = subject.core.const_get(:Cancellation).source + source.cancel(:interrupt_requested) + raised = Outcomes.raised_by(-> { source.token.check! }) + + Check.that(raised.is_a?(subject.core.const_get(:CancelledError)), + "a cancelled token did not surface the distinct cancellation type", + expected: "Dexpace::CancelledError", actual: raised&.class, ids: ["XCUT-1"],) + Check.that(!policy.throwable_retryable?(raised), + "a cancellation classified retryable, so a cancelled operation can be retried", + expected: false, actual: true, ids: ["XCUT-1"],) + Check.that(policy.cancellation?(wrap(raised)), + "a cancellation wrapped in a retryable transport error was not recognised, " \ + "so the wrapper makes it retryable again", + expected: true, actual: false, ids: ["XCUT-1"],) + end + + # XCUT-2: "a TIMEOUT MUST be classified as a RETRYABLE transport failure and MUST NOT set + # the cancellation flag. Timeout and cancellation MUST be told apart BY THE AMBIENT + # CANCELLATION STATE, not by matching an error message string, even when the timeout type + # is a SUBTYPE of the cancellation type -- the timeout branch must be checked first to + # stay reachable." + def timeout_is_retryable_and_distinct(subject) + policy = subject.core.const_get(:Resilience).const_get(:Policy) + timeout = subject.core.const_get(:TransportError).new("read timed out", phase: :read) + subtype = Class.new(subject.core.const_get(:CancelledError)) + + Check.that(policy.throwable_retryable?(timeout), + "a read timeout did not classify as a retryable transport failure", + expected: true, actual: false, ids: ["XCUT-2"],) + Check.that(!subject.core.const_get(:Cancellation).none.cancelled?, + "a timeout set the ambient cancellation flag", + expected: false, actual: true, ids: ["XCUT-2"],) + Check.that(policy.cancellation?(subtype.new("elapsed")), + "a cancellation SUBTYPE was not recognised, so the two are told apart by a " \ + "message string rather than by the type", + expected: true, actual: false, ids: ["XCUT-2"],) + end + + # XCUT-3: "inter-attempt waits MUST be promptly cancellable: a pending wait MUST abort + # near-immediately when the operation is cancelled, surface the cancellation signal (not a + # spurious timeout), and cancel any timer it armed." + # + # A thirty-second wait cancelled from another thread, bounded at half a second. The wait + # must RAISE the cancellation rather than return a value, which is the "not a spurious + # timeout" half. Kernel#sleep would fail this: it cannot be woken without Thread#raise, + # which §8.3 bans. + def interruptible_waits(subject) + subject.probe!(:Clock, "5a's configuration layer") + source = subject.core.const_get(:Cancellation).source + settled, elapsed, parked = wait_and_cancel(subject, source) + + # The cancel must land while the wait is PENDING, which is what "a pending wait aborts" + # means. Without this the cancel could beat the waiter into Clock#sleep, whose own entry + # check then raises -- and an implementation that ignores the token entirely passes. + # Measured: it did. So the barrier is asserted rather than assumed. + Check.that(parked, "the waiter never reached the wait, so the cancel was not delivered " \ + "to a PENDING wait and this assertion could not discriminate", + expected: "parked", actual: "never parked", ids: ["XCUT-3"],) + Check.that(!settled.nil?, + "a pending inter-attempt wait was not released by a cancellation", + expected: "released within #{WAKE_BOUND_SECONDS}s", actual: "still waiting", + ids: ["XCUT-3"],) + Check.that(settled.is_a?(subject.core.const_get(:CancelledError)), + "a cancelled wait surfaced something other than the cancellation signal", + expected: "Dexpace::CancelledError", actual: settled.class, ids: ["XCUT-3"],) + Check.that(elapsed < WAKE_BOUND_SECONDS, "the wait was not released near-immediately", + expected: "< #{WAKE_BOUND_SECONDS}s", actual: elapsed.round(3), + ids: ["XCUT-3"],) + end + + # @return [Array(untyped, Float, bool)] what the wait surfaced, how long the cancel took, + # and whether the waiter was parked in the wait when the cancel was issued + def wait_and_cancel(subject, source) + outcome = ::Thread::Queue.new + waiter = ::Thread.new do + subject.core.const_get(:Clock)::SYSTEM.sleep(WAIT_SECONDS, cancellation: source.token) + outcome << :returned + rescue ::StandardError => error + outcome << error + end + parked = parked?(waiter) + started = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + source.cancel(:interrupt_requested) + settled = outcome.pop(timeout: WAKE_BOUND_SECONDS) + elapsed = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - started + waiter.join(WAKE_BOUND_SECONDS) + [settled, elapsed, parked] + end + + # Spins until the thread is blocked, bounded. `Thread#status` is "sleep" for a thread + # parked on a queue, a mutex or a sleep on every supported interpreter, and `false` for one + # that has already finished -- which is NOT parked and is reported as such. A spin and not + # a sleep-and-hope: the bound is what makes it terminate, and the return value is what the + # assertion checks rather than assumes. + def parked?(thread, bound: WAKE_BOUND_SECONDS) + deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + bound + until ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) > deadline + return true if thread.status == "sleep" + break unless thread.status + + ::Thread.pass + end + false + end + + # An adapter's own retryable wrapper around the token's raise: the shape a check that read + # only the outermost error would retry. + def wrap(inner) + raise inner + rescue ::StandardError + begin + raise "an adapter's own retryable wrapper" + rescue ::StandardError => outer + outer + end + end + + # rubocop:enable Metrics/AbcSize + + ROWS = [ + ["XCUT-1", "a cancellation is terminal and never retried", :cancellation_is_terminal], + ["XCUT-2", "a timeout is retryable and is told apart from a cancellation by state", + :timeout_is_retryable_and_distinct,], + ["XCUT-3", "a pending inter-attempt wait is promptly cancellable", :interruptible_waits], + ].freeze + private_constant :ROWS + + # @return [Array] this group's assertions, frozen + ASSERTIONS = Runner.registry(self, ROWS) + end + private_constant :Concurrency + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/credentials.rb b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/credentials.rb new file mode 100644 index 0000000..8c1c9a4 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/credentials.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../check" +require_relative "../runner" +require_relative "outcomes" + +module Dexpace + module Conformance + module InvariantSuite + # Group 7, redirect credential hygiene and the CSPRNG: XCUT-17 and XCUT-21. + # A private_constant of InvariantSuite. + module Credentials + extend self + + # The three headers XCUT-17 (b) strips once a hop leaves the seed origin. + ORIGIN_SCOPED = %w[authorization cookie proxy-authorization].freeze + # The ORIGINAL seed origin every hop is judged against, never the previous hop's. + SEED = "https://a.example/one" + # XCUT-21's floor: ">= 128 bits for the Digest cnonce". + ENTROPY_BYTES = 16 + # The two parameter kinds a `cnonce_source:` keyword can take. + KEYWORD_KINDS = %i[key keyreq].freeze + # How many drawn bytes are probed for reaching the rendering; a cnonce is far shorter. + MAX_PROBED = 64 + + # Each assertion below is one requirement's CLAUSES, and each clause is one Check, so the + # metric is counting the requirement's own size. Splitting by count would split by + # arithmetic rather than by behaviour -- which is .rubocop.yml's own recorded argument for + # turning Minitest/MultipleAssertions off, applied to the assertions that mirror them. + # XCUT-17, all four clauses, because a port can satisfy any three. + def redirect_credential_hygiene(subject) + subject.probe!(:Redirect, "6b's redirect step") + same = subject.redirect_hop(from: SEED, to: "https://a.example/two", + headers: { "authorization" => ["Bearer t"], + "cookie" => ["s=1"], },) + Check.that(same.headers["authorization"].nil?, + "Authorization survived a SAME-ORIGIN re-issue (clause a)", + expected: nil, actual: same.headers["authorization"], ids: ["XCUT-17"],) + check_cross_origin(subject) + check_userinfo(subject) + check_downgrade(subject) + end + + # XCUT-17 clause (b). + # @return [void] + def check_cross_origin(subject) + cross = subject.redirect_hop(from: SEED, to: "https://b.example/two", + headers: { "authorization" => ["Bearer t"], + "cookie" => ["s=1"], + "proxy-authorization" => ["Basic x"], },) + ORIGIN_SCOPED.each do |name| + Check.that(cross.headers[name].nil?, + "#{name} survived a CROSS-ORIGIN re-issue (clause b)", + expected: nil, actual: cross.headers[name], ids: ["XCUT-17"],) + end + end + + # XCUT-17 clause (c). + # @return [nil] + def check_userinfo(subject) + hop = subject.redirect_hop(from: SEED, to: "https://user:pw@a.example/two", headers: {}) + + Check.that(!hop.url.to_s.include?("user:pw"), + "userinfo in the Location survived the re-issue (clause c)", + expected: "no userinfo", actual: hop.url.to_s, ids: ["XCUT-17"],) + end + + # XCUT-17 clause (d). + # @return [nil] + def check_downgrade(subject) + refused = Outcomes.refused?(lambda { + subject.redirect_hop(from: SEED, to: "http://a.example/two", headers: {}) + }) + + Check.that(refused, + "an HTTPS-to-HTTP downgrade was followed without opt-in (clause d)", + expected: "rejected", actual: "followed", ids: ["XCUT-17"],) + end + + # XCUT-21: ">= 128 bits from a cryptographically-strong PRNG, NEVER a non-cryptographic + # RNG." Two observations and no character count -- a character count called a 128-bit draw + # truncated to eight characters conforming and a 22-character urlsafe_base64(16) not. + # + # 1. the handler takes an injectable cnonce_source:, so its randomness can be audited; + # 2. bytes are DRAWN from the injected recorder and CARRIED into the rendered value -- + # each drawn byte is flipped in turn and the rendering must change. + def csprng_for_security_values(subject) + subject.probe!(:Auth, "6c's authentication layer") + handler = subject.core.const_get(:Auth).const_get(:DigestHandler) + + Check.that(injectable?(handler), + "the digest handler takes no injectable cnonce source, so its randomness " \ + "source cannot be audited at all", + expected: "a cnonce_source: keyword", actual: "absent", ids: ["XCUT-21"],) + check_entropy(subject) + end + + def injectable?(handler) + handler.instance_method(:initialize).parameters.any? do |(kind, name)| + KEYWORD_KINDS.include?(kind) && name == :cnonce_source + end + end + + # XCUT-21's second and third observations: bytes drawn, and bytes carried. + # @return [nil] + def check_entropy(subject) + base = Recorder.new + rendered = subject.draw_cnonce(base).to_s + Check.that(base.drawn.positive?, + "the handler drew no bytes from the injected source, so it uses some other " \ + "randomness", + expected: "at least one draw", actual: 0, ids: ["XCUT-21"],) + carried = (0...[base.drawn, MAX_PROBED].min).count do |index| + subject.draw_cnonce(Recorder.new(index)).to_s != rendered + end + Check.that(carried >= ENTROPY_BYTES, + "the rendered cnonce carries fewer than 128 bits of the bytes drawn for it", + expected: ">= #{ENTROPY_BYTES} drawn bytes reach the cnonce", + actual: "#{carried} of #{base.drawn}", ids: ["XCUT-21"],) + end + + # A Random::Formatter whose every draw is recorded. Formatter routes #hex, + # #urlsafe_base64, #base64, #random_bytes, #uuid, #alphanumeric and #random_number through + # #bytes, so defining #bytes records them all. Byte i is (0xA5 ^ i) & 0xFF, complemented + # when i == flip, which is what makes "carried into the rendering" measurable per byte. + class Recorder + include ::Random::Formatter + + attr_reader :drawn + + def initialize(flip = nil) + @flip = flip + @drawn = 0 + end + + # @param count [Integer] how many bytes the subject asked for + # @return [String] a deterministic run, one byte complemented when `flip` names it + def bytes(count) + chunk = ::Array.new(count) do |offset| + byte = (0xA5 ^ (@drawn + offset)) & 0xFF + @drawn + offset == @flip ? byte ^ 0xFF : byte + end + @drawn += count + chunk.pack("C*") + end + end + private_constant :Recorder + + ROWS = [ + ["XCUT-17", "redirect handling enforces credential hygiene", + :redirect_credential_hygiene,], + ["XCUT-21", "security-relevant randomness comes from a CSPRNG", + :csprng_for_security_values,], + ].freeze + private_constant :ROWS + + # @return [Array] this group's assertions, frozen + ASSERTIONS = Runner.registry(self, ROWS) + end + private_constant :Credentials + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/header_syntax.rb b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/header_syntax.rb new file mode 100644 index 0000000..d2329ad --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/header_syntax.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# XCUT-18's call-site assertion observes wire activity rather than inferring it, so it owns a +# listener for the length of one assertion. `socket` is reachable to THIS gem alone: the require +# allowlist's denial is scoped to dexpace-core and the transport adapters, whose wire access goes +# through the one gem each declares (phase 8a's P8-14). +require "socket" +require_relative "../check" +require_relative "../runner" +require_relative "../vacuous" +require_relative "outcomes" + +module Dexpace + module Conformance + module InvariantSuite + # Group 6, XCUT-18's two assertions: the model layer's validator and the adapter's + # re-validation at the call site. A private_constant of InvariantSuite. + module HeaderSyntax + extend self + + # HTAB is the ONE byte a NAME must reject and a VALUE must accept; the asymmetry IS the + # requirement, and a port applying one rule to both passes a single-case check. + NAME_REJECTS = { "CR" => "\r", "LF" => "\n", "NUL" => "\x00", "DEL" => "\x7F", + "HTAB" => "\t", "non-ASCII" => "\xC3\xA5", }.freeze + # Every control byte XCUT-18 refuses in a value, HTAB deliberately absent. + VALUE_REJECTS = NAME_REJECTS.except("HTAB") + # A header name no builder would ever produce: the CRLF in it is the injection. + FORGED_NAME = "X-Evil\r\nInjected" + + # XCUT-18 at the MODEL layer: "names MUST reject all C0 control bytes (including CR, LF, + # NUL and HTAB) and DEL. Outbound values MUST reject the same set EXCEPT horizontal tab. + # Both MUST reject non-ASCII bytes." + def header_syntax_validation(subject) + subject.probe!(:HeaderSyntax, "phase 1's HeaderSyntax") + syntax = subject.core.const_get(:HeaderSyntax) + + NAME_REJECTS.each do |label, byte| + Check.that(Outcomes.refused?(-> { syntax.validate_name!("X-A#{byte}B") }), + "a header NAME containing #{label} was accepted", + expected: "rejected", actual: "accepted", ids: ["XCUT-18"],) + end + check_values(syntax) + nil + end + + # XCUT-18 at the CALL SITE -- the wire-boundary re-validation phase 1 postponed to every + # adapter and named this phase's suite as the home of the assertion that it happened. + # + # Phase 8's own per-adapter tests prove the call site in two first-party adapters; a + # property asserted only there is one a THIRD-PARTY adapter omits silently. The forged + # request never met a builder -- that is the point -- and wire activity is OBSERVED, not + # inferred: the URL names a listener this assertion owns, and one accepted connection is + # the failure. The listener is acquired and released in this method's own scope with an + # ensure, never inside an Enumerator (§7.1). + def forged_request_is_refused_at_dispatch(subject) + unless subject.transport? + raise Vacuous, + "no transport factory supplied to InvariantSuite.run" + end + + subject.probe!(:Request, "phase 1's domain model") + listener = listen + begin + check_dispatch(subject, listener) + ensure + listener.close + end + end + + # The VALUE half of XCUT-18's asymmetry: HTAB admitted, every other control refused. + # @return [void] + def check_values(syntax) + Check.that(!Outcomes.refused?(lambda { + syntax.validate_outbound_value!("a\tb", name: "X-T") + }), + "an outbound VALUE containing HTAB was rejected; only a NAME must reject it", + expected: "accepted", actual: "rejected", ids: ["XCUT-18"],) + VALUE_REJECTS.each do |label, byte| + Check.that(Outcomes.refused?(lambda { + syntax.validate_outbound_value!("a#{byte}b", name: "X-T") + }), + "an outbound VALUE containing #{label} was accepted", + expected: "rejected", actual: "accepted", ids: ["XCUT-18"],) + end + end + + # @return [nil] the two observations together: no connection, and a raise + def check_dispatch(subject, listener) + forged, route = forge(subject.core, "http://127.0.0.1:#{listener.addr[1]}/") + refused = Outcomes.refused?(lambda { + subject.transport.call(forged, nil, subject.core.const_get(:Cancellation).none) + }) + connected = listener.accept_nonblock(exception: false) != :wait_readable + + Check.that(!connected, + "the adapter accepted a connection for a request forged via #{route} whose " \ + "header name carries CRLF", + expected: "no wire activity", actual: "accepted a connection", + ids: ["XCUT-18"],) + Check.that(refused, + "the adapter did not raise on a forged request (via #{route}) whose header " \ + "name carries CRLF", + expected: "raised before dispatch", actual: "returned", ids: ["XCUT-18"],) + end + + # Through an untyped local, exactly as 8a's WireServer does: rbs 4.2.0 reads + # `TCPServer#initialize: (?String host, Integer port)` as a single optional positional, so + # the two-argument call Ruby accepts is refused by the checker and not by Ruby. + def listen + server_class = ::TCPServer #: untyped + server_class.new("127.0.0.1", 0) + end + + # Two forged shapes, tried in order. Neither goes through Headers.build, because HTTP-17 + # rejects the name there -- which is exactly why a forged model is the one that must be + # re-validated at the wire boundary (design §10.10's documented `send` hole). + def forge(core, url) + headers = Object.new + headers.define_singleton_method(:each_entry) { |&block| block.call(FORGED_NAME, "v") } + parsed = core.const_get(:URL).parse!(url) + verb = core.const_get(:Method)::GET + begin + [core.const_get(:Request).send(:new, method: verb, url: parsed, headers: headers, + body: nil,), + "Request.send(:new, ...)",] + rescue ::StandardError + [duck(verb, parsed, headers), + "a duck-typed object answering #method/#url/#headers/#body",] + end + end + + # @return [Object] a request that never met a builder and never could + def duck(verb, url, headers) + forged = Object.new + forged.define_singleton_method(:method) { verb } + forged.define_singleton_method(:url) { url } + forged.define_singleton_method(:headers) { headers } + forged.define_singleton_method(:body) { nil } + forged + end + + ROWS = [ + ["XCUT-18", "header names and outbound values reject splitting bytes at the model layer", + :header_syntax_validation,], + ["XCUT-18", "an adapter refuses a forged request at dispatch, before any wire activity", + :forged_request_is_refused_at_dispatch,], + ].freeze + private_constant :ROWS + + # @return [Array] this group's assertions, frozen + ASSERTIONS = Runner.registry(self, ROWS) + end + private_constant :HeaderSyntax + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/memory.rb b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/memory.rb new file mode 100644 index 0000000..c124f78 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/memory.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../check" +require_relative "../runner" + +module Dexpace + module Conformance + module InvariantSuite + # Group 4, bounded memory: XCUT-14's two clauses and XCUT-24's two. A private_constant of + # InvariantSuite. + module Memory + extend self + + # The cap every bounded-map assertion builds its subject with. + CAP = 8 + # The over-cap state a concurrent insert burst leaves, arranged without a thread. + OVERSHOOT = 5 + # Far more insertions than the cap, which is XCUT-14's own conformance shape. + BURST = CAP * 40 + # The byte cap a body log preview is built with. + PREVIEW_BYTES = 64 + # The byte cap an error-body snapshot is taken under. + SNAPSHOT_CAP = 100 + # A payload far past both caps, so a missing cap is visible in the result. + PAYLOAD_BYTES = 5000 + + # Each assertion below is one requirement's CLAUSES, and each clause is one Check, so the + # metric is counting the requirement's own size. Splitting by count would split by + # arithmetic rather than by behaviour -- which is .rubocop.yml's own recorded argument for + # turning Minitest/MultipleAssertions off, applied to the assertions that mirror them. + # rubocop:disable Metrics/AbcSize + # XCUT-14's CAP clause: "insert far more than the cap of distinct keys; assert map size + # never exceeds the cap." Single-threaded and DETERMINISTIC, reading the size after EVERY + # insert -- a check that read it only at the end would pass an implementation that + # overshoots and then trims. + def bounded_maps(subject) + map = subject.bounded_map(cap: CAP) + sizes = ::Array.new(BURST) do |index| + map.set("key-#{index}", index) + map.size + end + + Check.that(sizes.max <= CAP, "an insert burst pushed the map past its cap", + expected: "<= #{CAP}", actual: sizes.max, ids: ["XCUT-14"],) + end + + # XCUT-14's DRAIN-TO-CAP clause: evict "using a loop (not a single pre-insert + # check-then-evict), so a concurrent insert burst converges to the bound instead of + # overshooting permanently." + # + # Decided behaviourally and deterministically: fill the BACKING STORE to cap + 5 -- the + # state a concurrent overshoot leaves, arranged without a thread -- then perform ONE #set. + # A drain loop ends at the cap; a check-then-evict ends at cap + 5, on every interpreter. + # This assertion is the only line on that clause: a shape gate over the source caught 1 of + # 3 non-conforming forms with a false positive, and is out of v1 for that reason. + def bounded_map_drains(subject) + map = subject.bounded_map(cap: CAP) + store = subject.bounded_map_store(map) + (CAP + OVERSHOOT).times { |index| store["overshoot-#{index}"] = index } + before = map.size + map.set("after-overshoot", 0) + + Check.that(before > CAP, "the store reader did not reach past the cap, so this " \ + "assertion could not have discriminated", + expected: "> #{CAP}", actual: before, ids: ["XCUT-14"],) + Check.that(map.size <= CAP, + "one insert into an over-cap map evicted once instead of draining back to " \ + "the cap", + expected: "<= #{CAP}", actual: map.size, ids: ["XCUT-14"],) + end + + # XCUT-24: "diagnostic/preview reads of caller- or server-controlled payloads MUST be + # byte-capped and SHOULD be non-consuming -- a preview MUST NOT materialize an unbounded + # payload into memory and MUST NOT disturb the primary read path the consumer will use." + # + # Both subjects, because the two halves live in different objects: 4b's error-body + # snapshot is the cap, 3b's logging tap is the non-consumption. A preview that capped but + # drained would pass the first alone, which is the failure the second clause names. + def capped_non_consuming_preview(subject) + subject.probe!(:Body, "3b's body layer") + subject.probe!(:ResponseLoggingBody, "3b's logging wrappers") + snapshot = subject.core::Body.buffer_bounded(payload(subject), cap: SNAPSHOT_CAP) + + Check.that(snapshot.content_length <= SNAPSHOT_CAP, + "an error-body snapshot materialised an unbounded payload", + expected: "<= #{SNAPSHOT_CAP}", actual: snapshot.content_length, + ids: ["XCUT-24"],) + tap = subject.core::ResponseLoggingBody.new(payload(subject), + preview_bytes: PREVIEW_BYTES,) + through = tap.source.read(PAYLOAD_BYTES * 2).bytesize + Check.that(tap.snapshot.bytesize <= PREVIEW_BYTES, + "a body log preview is not byte-capped", + expected: "<= #{PREVIEW_BYTES}", actual: tap.snapshot.bytesize, + ids: ["XCUT-24"],) + Check.that(through == PAYLOAD_BYTES, + "the preview disturbed the primary read path: the consumer saw a short body", + expected: PAYLOAD_BYTES, actual: through, ids: ["XCUT-24"],) + end + + # @return [untyped] a fresh single-use response body of PAYLOAD_BYTES + def payload(subject) + source = subject.core::IO::BufferedSource.of_bytes("a".b * PAYLOAD_BYTES) + subject.core::ResponseBody.new(source: source, content_length: PAYLOAD_BYTES, + media_type: subject.core::MediaType.parse("text/plain"),) + end + + # rubocop:enable Metrics/AbcSize + + ROWS = [ + ["XCUT-14", "a caller-keyed map never exceeds its cap", :bounded_maps], + ["XCUT-14", "one insert drains an over-cap map back to its cap", :bounded_map_drains], + ["XCUT-24", "a diagnostic preview is byte-capped and leaves the primary read intact", + :capped_non_consuming_preview,], + ].freeze + private_constant :ROWS + + # @return [Array] this group's assertions, frozen + ASSERTIONS = Runner.registry(self, ROWS) + end + private_constant :Memory + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/models.rb b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/models.rb new file mode 100644 index 0000000..67999fd --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/models.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../check" +require_relative "../runner" + +module Dexpace + module Conformance + module InvariantSuite + # Group 1, models and lifecycle: XCUT-15's immutability, XCUT-13's two clauses and + # XCUT-22's ownership. A private_constant of InvariantSuite. + module Models + extend self + + # A close that takes longer than this has blocked rather than signalled and returned. + CLOSE_BOUND_SECONDS = 0.5 + + # XCUT-15: "a model MUST NOT retain an alias to externally-mutable state that a + # post-construction mutation could use to alter it". The mutation is on the INNER array, + # because phase 1 files Headers.build(values:, casing:) over a Hash of + # String => Array[String] and a port that dup'd only the outer Hash would pass a + # top-level check. + def immutable_models(subject) + subject.probe!(:Headers, "phase 1's domain model") + live = { "accept" => ["text/plain"] } + casing = { "accept" => "Accept" } + model = subject.core.const_get(:Headers).build(values: live, casing: casing) + live["accept"] << "application/json" + live["x-added"] = ["v"] + + Check.that(model["accept"] == ["text/plain"] && model["x-added"].nil?, + "a model changed when a collection passed into its builder was mutated", + expected: ["text/plain"], actual: model["accept"], ids: ["XCUT-15"],) + end + + # XCUT-13, clause 1: "close()/shutdown() MUST be idempotent (latched so repeats are + # no-ops)". Counted AT THE RESOURCE, never inferred from close's return value -- phase 2's + # Closeable#close answers nil on the winning and the losing call alike. + def idempotent_close(subject) + seam = subject.seam + seam.close + seam.close + + Check.that(seam.release_count == 1, "close ran its release more than once", + expected: 1, actual: seam.release_count, ids: ["XCUT-13"],) + end + + # XCUT-13, clause 2: "and MUST NOT block on interrupt-sensitive waits". Bounded by elapsed + # monotonic time, never by an interrupt -- §8.3 bans Timeout.timeout, Thread#raise and + # Thread#kill outright. Both calls are timed, so a latch that blocks only on the LOSING + # call is caught too. + def non_blocking_close(subject) + seam = subject.seam + started = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + seam.close + seam.close + elapsed = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - started + + Check.that(elapsed < CLOSE_BOUND_SECONDS, + "close blocked rather than signalling and returning", + expected: "< #{CLOSE_BOUND_SECONDS}s", actual: elapsed.round(3), + ids: ["XCUT-13"],) + end + + # XCUT-22: "build a transport around a caller-supplied client, close the transport, then + # reuse the client -> it still works." Both halves: the resource is not closed, AND it is + # still usable, because a component that merely forgot to call #close while tearing the + # resource down another way would pass the first alone. + def only_closes_what_it_created(subject) + borrowed = Borrowed.new + seam = subject.seam(client: borrowed) + seam.close + + Check.that(!borrowed.closed?, "the SDK closed a resource it did not create", + expected: false, actual: borrowed.closed?, ids: ["XCUT-22"],) + Check.that(borrowed.usable?, "a caller-supplied resource stopped working after the " \ + "SDK component that borrowed it was closed", + expected: true, actual: borrowed.usable?, ids: ["XCUT-22"],) + end + + # The caller-supplied resource XCUT-22 is about, with the two observations the requirement + # names: it was not closed, and it still works. + class Borrowed + def initialize = (@closed = false) + # @return [Boolean] the latch this double flips, which is the whole observation + def close = (@closed = true) + def closed? = @closed + def usable? = !@closed + end + private_constant :Borrowed + + ROWS = [ + ["XCUT-15", "public wire models retain no external-mutable alias", :immutable_models], + ["XCUT-13", "close is latched so repeats are no-ops", :idempotent_close], + ["XCUT-13", "close does not block on an interrupt-sensitive wait", :non_blocking_close], + ["XCUT-22", "a caller-supplied resource survives the SDK's close", + :only_closes_what_it_created,], + ].freeze + private_constant :ROWS + + # @return [Array] this group's assertions, frozen + ASSERTIONS = Runner.registry(self, ROWS) + end + private_constant :Models + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/outcomes.rb b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/outcomes.rb new file mode 100644 index 0000000..6eb17e1 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/outcomes.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../failure" +require_relative "../vacuous" + +module Dexpace + module Conformance + module InvariantSuite + # The four ways an assertion in this suite observes what a call DID, shared by every group so + # the discipline below is written once. + # + # **Vacuous and Failure are re-RAISED rather than captured.** Both are ::StandardError + # descendants (8a), so a bare `rescue ::StandardError` here would turn this suite's OWN + # vacuity -- a factory the driver did not supply -- into evidence that the subject refused, + # which is the silent-pass shape design R3 exists to remove. + # + # A private_constant of InvariantSuite. + module Outcomes + extend self + + # @return [Class, nil] the class of the error the call raised, or nil when it returned + def raised_class(callable) + raised_by(callable)&.class + end + + # @return [StandardError, nil] the error the call raised, or nil when it returned + def raised_by(callable) + callable.call + nil + rescue Vacuous, Failure + raise + rescue ::StandardError => error + error + end + + # @return [Boolean] whether the call raised at all + def refused?(callable) + !raised_by(callable).nil? + end + + # The value OR the error, so a clause whose subject is "this must NOT raise" reports + # :failed rather than :error -- the status for "the subject is non-conformant" rather than + # for "this assertion is broken". + # + # @return [Object] the call's value, or the error it raised + def outcome_of(callable) + callable.call + rescue Vacuous, Failure + raise + rescue ::StandardError => error + error + end + end + private_constant :Outcomes + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/resolution.rb b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/resolution.rb new file mode 100644 index 0000000..58640d3 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/resolution.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../check" +require_relative "../runner" + +module Dexpace + module Conformance + module InvariantSuite + # Group 8, the credential cache and seam resolution: XCUT-12 and XCUT-23. + # A private_constant of InvariantSuite. + module Resolution + extend self + + # How many threads race one credential cache, which is XCUT-12's single-flight shape. + RACERS = 16 + # A bound on every join, so a stuck racer fails the assertion rather than hanging it. + JOIN_SECONDS = 5.0 + # The explicit-install entry point XCUT-23's message must name to be actionable. + INSTALLER = "Dexpace::Conformance probe #install" + + # Each assertion below is one requirement's CLAUSES, and each clause is one Check, so the + # metric is counting the requirement's own size. Splitting by count would split by + # arithmetic rather than by behaviour -- which is .rubocop.yml's own recorded argument for + # turning Minitest/MultipleAssertions off, applied to the assertions that mirror them. + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + # XCUT-12 (SHOULD): "refresh SHOULD be single-flight -- only ONE concurrent caller fetches + # an expiring token while the others reuse the result. Any lock guarding the refresh MUST + # be scoped to that cache so it never serializes UNRELATED in-flight requests; holding + # that scoped lock across the (possibly blocking) token fetch is acceptable and intended." + # + # N threads race one stamper over an expiring token and exactly one fetch must happen. The + # THREAD form only: the fiber-scheduler form needs a credential path running under a + # reactor, a composition no first-party suite assembles, and is phase 10's judgement. + def single_flight_credential_cache(subject) + subject.probe!(:Auth, "6c's authentication layer") + provider = CountingProvider.new(subject.core::Auth::BearerToken) + stamper = subject.core::Auth::BearerStamper.new(provider: provider) + request = blank_request(subject) + stamped = ::Thread::Queue.new + racers = ::Array.new(RACERS) { ::Thread.new { stamped << stamper.call(request) } } + # Every racer must be INSIDE the refresh before the gate opens, or the first thread + # finishes alone and the other fifteen take the cached-token path without ever racing. + # Measured: with the gate released immediately, a stamper that fetches per caller passed. + # Under a conforming stamper one racer parks in #fetch and fifteen park on the + # credential's lock; under a non-conforming one all sixteen park in #fetch. Both are + # "sleep", so the barrier is the same for either and decides nothing by itself. + all_parked = all_parked?(racers) + provider.release + racers.each { |thread| thread.join(JOIN_SECONDS) } + + Check.that(all_parked, + "not every concurrent caller reached the refresh before the fetch was " \ + "released, so this assertion could not have discriminated", + expected: RACERS, actual: "some finished early", ids: ["XCUT-12"],) + Check.that(provider.fetches == 1, + "a token refresh was not single-flight: every concurrent caller fetched", + expected: 1, actual: provider.fetches, ids: ["XCUT-12"],) + Check.that(stamped.size == RACERS, + "not every concurrent caller was stamped from the one coalesced fetch", + expected: RACERS, actual: stamped.size, ids: ["XCUT-12"],) + end + + # A provider that counts its fetches and parks until released, so "exactly one fetch" is a + # measurement rather than a race that usually passes. 6c's contract is `#fetch`, never a + # lambda: AUTH-11 refuses a provider that does not answer it. + class CountingProvider + def initialize(token_type) + @token_type = token_type + @gate = ::Thread::Queue.new + @fetches = 0 + @lock = ::Thread::Mutex.new + end + + # @return [void] lets every parked fetch return + def release = @gate.close + + # @return [Integer] how many fetches actually reached the provider + def fetches = @lock.synchronize { @fetches } + + # 6c's provider contract is `#fetch` and never a lambda, which AUTH-11 refuses. + # @return [untyped] one bearer token, once the gate opens + def fetch + @lock.synchronize { @fetches += 1 } + @gate.pop + @token_type.build(token: "single-flight") + end + end + private_constant :CountingProvider + + # XCUT-23: "an explicit install ALWAYS WINS; otherwise the implementation is + # auto-discovered; and the presence of ZERO or MULTIPLE candidates with no explicit + # selection MUST FAIL LOUDLY with an ACTIONABLE error rather than silently pick one or + # no-op." + # + # Three ordered rules, and the ORDERING is the content: a port that fails loudly on + # ambiguity but lets discovery beat an explicit install satisfies two of three. "Loud" is + # checked for its own reason too -- the error must name the explicit-install entry point, + # which is what makes it actionable rather than merely raised. Every construction and + # registration sits OUTSIDE the rescues, so a call-shape mistake is :error and can never + # masquerade as a loud failure. + def seam_resolution_is_deterministic(subject) + subject.probe!(:Registry, "phase 2's Dexpace::Registry") + registry = subject.core.const_get(:Registry) + fresh = lambda do + registry.new(seam: "conformance-probe", installer: INSTALLER, + conforms: ->(_provider) { true },) + end + major, minor = subject.core.const_get(:VERSION).to_s.split(".") + core = "~> #{major}.#{minor}" + + empty = failure_of(fresh.call) + Check.that(!empty.nil? && empty.message.include?(INSTALLER), + "resolving with no candidate did not fail loudly with an actionable error " \ + "naming the explicit-install entry point", + expected: "an error naming #{INSTALLER}", actual: empty&.message, + ids: ["XCUT-23"],) + + ambiguous = fresh.call + ambiguous.register(:a, -> { :a }, core: core) + ambiguous.register(:b, -> { :b }, core: core) + outcome = failure_of(ambiguous) + Check.that(!outcome.nil? && outcome.message.include?(INSTALLER), + "two registered candidates with no explicit install did not fail loudly", + expected: "an error naming #{INSTALLER}", actual: outcome&.message, + ids: ["XCUT-23"],) + + explicit = fresh.call + explicit.register(:discovered, -> { :discovered }, core: core) + explicit.install(:installed) + Check.that(explicit.resolve == :installed, + "an auto-discovered implementation beat an explicit install", + expected: :installed, actual: explicit.resolve, ids: ["XCUT-23"],) + + discovered = fresh.call + discovered.register(:only, -> { :only }, core: core) + Check.that(discovered.resolve == :only, + "a single registered candidate was not auto-discovered", + expected: :only, actual: discovered.resolve, ids: ["XCUT-23"],) + end + + # Spins until every thread is blocked, bounded. `Thread#status` is "sleep" for a thread + # parked on a queue or a mutex on every supported interpreter, and falsy for one that has + # already finished -- which is not parked, and makes this answer false rather than hang. + # + # @return [Boolean] whether all of them were parked at once inside the bound + def all_parked?(threads, bound: JOIN_SECONDS) + deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + bound + until ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) > deadline + return true if threads.all? { |thread| thread.status == "sleep" } + break if threads.any? { |thread| !thread.status } + + ::Thread.pass + end + false + end + + # @return [StandardError, nil] what resolving raised, or nil when it resolved + def failure_of(registry) + registry.resolve + nil + rescue ::StandardError => error + error + end + + # @return [untyped] a minimal request for the stamper to stamp + def blank_request(subject) + subject.core::Request.build(method: subject.core::Method::GET, + url: "https://a.example/x", + headers: subject.core::Headers::EMPTY,) + end + + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + ROWS = [ + ["XCUT-12", "a credential cache's refresh is single-flight", + :single_flight_credential_cache,], + ["XCUT-23", "a single-implementation seam resolves deterministically", + :seam_resolution_is_deterministic,], + ].freeze + private_constant :ROWS + + # @return [Array] this group's assertions, frozen + ASSERTIONS = Runner.registry(self, ROWS) + end + private_constant :Resolution + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/security.rb b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/security.rb new file mode 100644 index 0000000..75e3171 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/security.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../check" +require_relative "../runner" +require_relative "outcomes" + +module Dexpace + module Conformance + module InvariantSuite + # Group 5, security by default and observability totality: XCUT-16, XCUT-19 and XCUT-20. + # A private_constant of InvariantSuite. + module Security + extend self + + # One URL carrying every secret XCUT-19 names: userinfo, a query value and a fragment. + SECRET_URL = "https://u:pw@h.example/p?token=s&page=2#k=v" + + # Each assertion below is one requirement's CLAUSES, and each clause is one Check, so the + # metric is counting the requirement's own size. Splitting by count would split by + # arithmetic rather than by behaviour -- which is .rubocop.yml's own recorded argument for + # turning Minitest/MultipleAssertions off, applied to the assertions that mirror them. + # rubocop:disable Metrics/AbcSize + # XCUT-16: "a credential MUST NOT be stamped onto a request over a non-secure transport. + # The auth layer MUST reject (fail loudly) BEFORE any token fetch or header write. The + # guard applies ONLY on the credential-attaching path -- a deliberately credential-free + # re-issue (a marker-suppressed cross-origin redirect) MAY proceed over any scheme." + # + # All three clauses, because a port can satisfy any two: the plaintext refusal, the HTTPS + # stamp that proves the refusal is about the scheme and not about the path being broken, + # and the marker-suppressed exception that proves the guard is not applied where no + # credential is attached. + def credentials_require_https(subject) + subject.probe!(:Auth, "6c's authentication layer") + refusal = Outcomes.raised_class(-> { subject.credential_hop(url: "http://a.example/x") }) + + Check.that(refusal == subject.core::Auth.const_get(:HTTPSRequiredError), + "a credential was stamped over a plaintext transport, or the refusal was " \ + "not the loud one the requirement names", + expected: "Auth::HTTPSRequiredError", actual: refusal, ids: ["XCUT-16"],) + stamped = subject.credential_hop(url: "https://a.example/x") + Check.that(!stamped.headers["authorization"].nil?, + "no credential was attached over HTTPS, so the plaintext refusal proves " \ + "nothing about the scheme", + expected: "an Authorization header", actual: nil, ids: ["XCUT-16"],) + suppressed = Outcomes.outcome_of(lambda { + subject.credential_hop(url: "http://a.example/x", cross_origin: true) + }) + Check.that(suppressed.respond_to?(:headers), + "a credential-FREE cross-origin re-issue was refused by the HTTPS guard, " \ + "which applies only on the credential-attaching path", + expected: "a re-issued request", actual: suppressed.class, ids: ["XCUT-16"],) + Check.that(suppressed.headers["authorization"].nil?, + "a marker-suppressed cross-origin re-issue carried a credential anyway", + expected: nil, actual: suppressed.headers["authorization"], ids: ["XCUT-16"],) + end + + # XCUT-19, clauses (a), (b), (c) and (d): userinfo ALWAYS redacted and never allow-listed; + # query values and key=value fragment tokens redacted unless allow-listed; header logging + # default-deny; a credential object revealing no secret in string form. Clause (e), body + # logging off by default, is HTTPLogging::DEFAULT's own. + def redaction_is_default_deny(subject) + subject.probe!(:Instrumentation, "5b's logging facade") + redactor = subject.core::Instrumentation::Redactor::DEFAULT + rendered = redactor.url(SECRET_URL) + + %w[pw token=s k=v].each do |leak| + Check.that(!rendered.include?(leak), "#{leak.inspect} survived URL redaction", + expected: "redacted", actual: rendered, ids: ["XCUT-19"],) + end + Check.that(rendered.include?("h.example") && rendered.include?("/p"), + "host and path were redacted, which XCUT-19 does not ask", + expected: "preserved", actual: rendered, ids: ["XCUT-19"],) + Check.that(!redactor.header_name?("authorization"), + "the header allow-list is not default-deny", + expected: false, actual: true, ids: ["XCUT-19"],) + credential_reveals_nothing(subject) + end + + # XCUT-20: "observability code paths MUST NEVER throw into the caller's request path. A + # failure to redact/render/emit MUST degrade gracefully -- substitute a safe placeholder + # such as a malformed-URL marker, or emit a self-describing instrumentation-error event -- + # and let the request proceed." + # + # Scoped exactly as 5c handed it forward: satisfied for what the SDK owns, and NOT + # extended to a foreign tracer or meter callback, which OBS-20 deliberately carves out of + # the containment. A `rescue` added around those is a guard this suite must not demand. + def observability_never_throws(subject) + subject.probe!(:Instrumentation, "5b's logging facade") + redactor = subject.core::Instrumentation::Redactor::DEFAULT + marker = redactor.url(Object.new) + + Check.that(marker.is_a?(::String), + "redacting a malformed URL threw into the caller's path instead of " \ + "substituting a placeholder", + expected: "a String marker", actual: marker.class, ids: ["XCUT-20"],) + Check.that(redactor.header_value("authorization", "Bearer x").is_a?(::String), + "a header value redaction returned something other than a String", + expected: "a String", actual: "other", ids: ["XCUT-20"],) + contained = subject.core::Instrumentation.contain( + subject.core::Instrumentation::Logger::NULL, event: "http.instrumentation.log", + ) { raise "a sink exploded" } + Check.that(contained.nil?, + "a failing log emission propagated into the caller's request path", + expected: nil, actual: contained, ids: ["XCUT-20"],) + Check.that(subject.core::Instrumentation::Preview.render("\xFF\xFE".b, media_type: nil) + .is_a?(::String), + "rendering an undecodable body preview threw", + expected: "a String", actual: "raised", ids: ["XCUT-20"],) + end + + # XCUT-19 (d): "credential objects MUST NOT reveal their secret in string/serialized + # form." Three renderings, and the third is checked STRUCTURALLY: pp.rb gives a Data its + # OWN #pretty_print over `members` and never consults an #inspect override, so a + # credential that overrode only #to_s and #inspect still printed its token under `pp`. + # The owner test rather than a rendered comparison, because this gem declares dexpace-core + # and nothing else and `pp` is outside the require allowlist -- the rendered form is + # asserted only where a consumer's process has already loaded it. + def credential_reveals_nothing(subject) + secret = "s3cr3t-value" + token = subject.core::Auth::BearerToken.build(token: secret) + rendered = [token.to_s, token.inspect] + + Check.that(rendered.none? { |text| text.include?(secret) }, + "a credential revealed its secret in string or inspect form", + expected: "redacted in every rendering", actual: rendered.grep(/#{secret}/), + ids: ["XCUT-19"],) + owner = token.class.method_defined?(:pretty_print) && + token.class.instance_method(:pretty_print).owner + Check.that(owner == token.class, + "a credential does not override #pretty_print, so `pp credential` prints " \ + "its members -- pp.rb never consults an #inspect override on a Data", + expected: token.class, actual: owner, ids: ["XCUT-19"],) + end + + # rubocop:enable Metrics/AbcSize + + ROWS = [ + ["XCUT-16", "a credential is never stamped over a non-HTTPS transport", + :credentials_require_https,], + ["XCUT-19", "logging redacts secrets by default", :redaction_is_default_deny], + ["XCUT-20", "observability never throws into the caller's request path", + :observability_never_throws,], + ].freeze + private_constant :ROWS + + # @return [Array] this group's assertions, frozen + ASSERTIONS = Runner.registry(self, ROWS) + end + private_constant :Security + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/sharing.rb b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/sharing.rb new file mode 100644 index 0000000..2265156 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/sharing.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../check" +require_relative "../failure" +require_relative "../runner" +require_relative "../shared_instance" + +module Dexpace + module Conformance + module InvariantSuite + # Group 8, XCUT-11's two clauses: the structural predicate plus sixteen threads, and the + # two-fibers-on-one-thread shape 8b handed forward. A private_constant of InvariantSuite. + module Sharing + extend self + + # XCUT-11's own conformance shape: "invoke one shared instance from MANY threads". + THREADS = 16 + # Two fibers on ONE thread: the only shape a per-fiber mutex deadlock is visible in. + FIBERS = 2 + # Enough resumes for both fibers to finish, and a bound if one of them never does. + RESUMES = 4 + + # XCUT-11, clause 1: the structural predicate over every shared instance the DRIVER + # declares (design R8, P9-9), plus the requirement's own conformance shape -- "invoke one + # shared step instance from many threads with distinct requests and assert no cross-talk". + # The assertion is on the collected set, never on timing. + def shared_instances_are_concurrent_safe(subject) + audit_declared(subject) + step = subject.seam + SharedInstance.audit(step, mutable: subject.mutable) + seen = drive_threads(step) + + Check.that(seen == expected_requests, + "a shared step crossed one call's request into another's", + expected: THREADS, actual: seen.uniq.size, ids: ["XCUT-11"],) + end + + # XCUT-11, clause 2: TWO FIBERS ON ONE THREAD, which 8b handed forward as "the only shape + # that proves a per-fiber mutex is not held across a suspension point". Thread::Mutex + # ownership in Ruby is per-FIBER and non-reentrant, so a lock held across a yield + # deadlocks two fibers of one thread and sixteen threads cannot see it. + # + # The deadlock must be CAUGHT, not merely outlived: the second fiber's resume raises + # ThreadError ("deadlock; lock already owned by another fiber belonging to the same + # thread"), and a ThreadError escaping the body would reach Runner's bare rescue and be + # reported :error -- the status for "this assertion is broken", not for "the subject is + # non-conformant". So the resume loop converts it into a Failure carrying the message. + def shared_instances_are_fiber_safe(subject) + step = subject.seam + seen = [] #: Array[untyped] + fibers = ::Array.new(FIBERS) { |index| fiber(step, index, seen) } + RESUMES.times { fibers.each { |one| resume(one) } } + + Check.that(seen.size == FIBERS * 2, + "two fibers on one thread did not both complete; a lock is held across a " \ + "suspension point", + expected: FIBERS * 2, actual: seen.size, ids: ["XCUT-11"],) + end + + # Every shared instance the driver declared, put through R8's predicate. + # @return [void] + def audit_declared(subject) + subject.shared_instances.each do |(label, object, declared)| + SharedInstance.audit(object, mutable: declared) + rescue Failure => error + raise Failure.new("#{label}: #{error.message}", expected: error.expected, + actual: error.actual, + requirement_ids: ["XCUT-11"],) + end + end + + # @return [Array] the requests the one shared step saw, sorted + def drive_threads(step) + results = ::Thread::Queue.new + ::Array.new(THREADS) { |i| ::Thread.new { results << step.call("request-#{i}") } } + .each(&:join) + collected = [] #: Array[untyped] + collected << results.pop until results.empty? + collected.map(&:first).sort + end + + # @return [Array] what a step with no cross-talk must have seen + def expected_requests + ::Array.new(THREADS) { |index| "request-#{index}" }.sort + end + + # @return [Fiber] one of the two, calling the step on either side of a yield + def fiber(step, index, seen) + ::Fiber.new do + seen << step.call("fiber-#{index}") + ::Fiber.yield + seen << step.call("fiber-#{index}-again") + end + end + + # Converts Ruby's own deadlock report into a Failure, so the status is :failed rather + # than :error -- which would read as a broken harness and not as a non-conformance. + # @return [void] + def resume(one) + return unless one.alive? + + one.resume + rescue ::ThreadError => error + raise Failure.new( + "two fibers on one thread deadlocked; a lock is held across a suspension " \ + "point: #{error.message}", + expected: FIBERS * 2, actual: "#{error.class}: #{error.message}", + requirement_ids: ["XCUT-11"], + ) + end + + ROWS = [ + ["XCUT-11", "a shared component holds no per-call state and does not cross-talk", + :shared_instances_are_concurrent_safe,], + ["XCUT-11", "a shared component's lock is not held across a suspension point", + :shared_instances_are_fiber_safe,], + ].freeze + private_constant :ROWS + + # @return [Array] this group's assertions, frozen + ASSERTIONS = Runner.registry(self, ROWS) + end + private_constant :Sharing + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/taxonomy.rb b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/taxonomy.rb new file mode 100644 index 0000000..476286a --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/taxonomy.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../check" +require_relative "../runner" + +module Dexpace + module Conformance + module InvariantSuite + # Group 2, the error taxonomy and the three retry classifiers: XCUT-4, XCUT-5, XCUT-6 and + # XCUT-7. Three distinct objects, audited as three, because each answers a different + # question and a port that folded them together would pass a single-object check. + # A private_constant of InvariantSuite. + module Taxonomy + extend self + + # XCUT-5's classifier, stated as the requirement states it: 408, 429 and all 5xx EXCEPT + # 501 and 505. 404 and 200 are the negative controls, 507 the 5xx a hard-coded + # {500,502,503,504} list would miss. + CLASSIFIER_CASES = { 408 => true, 429 => true, 500 => true, 502 => true, 503 => true, + 504 => true, 507 => true, 501 => false, 505 => false, 404 => false, + 400 => false, }.freeze + # XCUT-7's two directions: a set admitting a status the classifier refuses, and one + # refusing a status it admits; plus the default the requirement fixes. + WIDENED = [404].freeze + # A configured set that REFUSES a status the built-in classifier admits. + NARROWED = [408].freeze + # The default retryable-status set XCUT-7 fixes. + DEFAULT_SET = [408, 429, 500, 502, 503, 504].freeze + + # Each assertion below is one requirement's CLAUSES, and each clause is one Check, so the + # metric is counting the requirement's own size. Splitting by count would split by + # arithmetic rather than by behaviour -- which is .rubocop.yml's own recorded argument for + # turning Minitest/MultipleAssertions off, applied to the assertions that mirror them. + # rubocop:disable Metrics/AbcSize + # XCUT-4: "exactly two top-level branches" -- a protocol error carrying a fully-received + # response, and a transport error carrying none and belonging to the runtime's I/O-error + # family so existing I/O catch sites keep matching. The ancestry is the half a port gets + # wrong: phase 1 made Dexpace::Error a MODULE precisely so a transport error could sit in + # Ruby's IOError family and still be caught by a broad `rescue Dexpace::Error`. + def two_branch_taxonomy(subject) + subject.probe!(:TransportError, "8a's phase-level task") + subject.probe!(:ProtocolError, "4b's recovery layer") + klass = subject.core.const_get(:TransportError) + + Check.that(klass < ::IOError, + "a transport error is outside the runtime's I/O-error family", + expected: "< ::IOError", actual: klass.ancestors.take(4), ids: ["XCUT-4"],) + error = klass.new("refused") + Check.that(error.retryable? == true, + "a transport error does not report itself always-retryable", + expected: true, actual: error.retryable?, ids: ["XCUT-4"],) + Check.that(error.is_a?(subject.core.const_get(:Error)), + "a transport error is outside the SDK's own rescue root, so a broad " \ + "`rescue Dexpace::Error` stops matching it", + expected: true, actual: false, ids: ["XCUT-4"],) + protocol = subject.core.const_get(:ProtocolError) + Check.that(protocol.method_defined?(:response) && protocol.method_defined?(:status), + "a protocol error carries no fully-received response", + expected: "#response and #status", actual: "absent", ids: ["XCUT-4"],) + Check.that(!(protocol <= ::IOError), + "the two branches are not distinct: a protocol error is also in the " \ + "I/O-error family, so an I/O catch site swallows a protocol outcome", + expected: false, actual: true, ids: ["XCUT-4"],) + end + + # XCUT-5: "computed ONCE at construction from a SINGLE shared status classifier, never + # hardcoded per status subclass. That classifier MUST treat 408, 429, and all 5xx EXCEPT + # 501 and 505 as retryable." The membership IS the requirement, so every case is walked. + def one_shared_status_classifier(subject) + subject.probe!(:Retryability, "5a's configuration layer") + classifier = subject.core.const_get(:Retryability) + wrong = CLASSIFIER_CASES.reject do |code, want| + classifier.retryable_status?(code) == want + end + + Check.that(wrong.empty?, + "the shared status classifier does not treat 408, 429 and every 5xx but 501 " \ + "and 505 as retryable", + expected: CLASSIFIER_CASES, actual: wrong, ids: ["XCUT-5"],) + end + + # XCUT-6: "a transport-family or CUSTOM error type that declares itself retryable via the + # retryability capability MUST be able to participate WITHOUT EDITING the retry + # classifier". So the double is a type the classifier has never heard of, and the query + # must reach it through a cause chain as well as directly -- a capability read only off the + # outermost error would miss every wrapped adapter failure. + def open_capability_query(subject) + subject.probe!(:Resilience, "6a's retry layer") + policy = subject.core::Resilience.const_get(:Policy) + yes = declaring(true) + no = declaring(false) + wrapped = begin + begin + raise yes + rescue ::StandardError + raise "wrapper" + end + rescue ::StandardError => error + error + end + + Check.that(policy.throwable_retryable?(yes), + "a custom error declaring itself retryable was not believed by the classifier", + expected: true, actual: false, ids: ["XCUT-6"],) + Check.that(!policy.throwable_retryable?(no), "a custom error declaring itself NOT " \ + "retryable was retried anyway", + expected: false, actual: true, ids: ["XCUT-6"],) + Check.that(policy.throwable_retryable?(wrapped), + "the capability query reads only the outermost error, so a wrapped adapter " \ + "failure never participates", + expected: true, actual: false, ids: ["XCUT-6"],) + end + + # XCUT-7: "retry eligibility MUST be decided by a CONFIGURABLE retryable-status set, which + # is AUTHORITATIVE: the set MAY widen the built-in classification or narrow it. The default + # set is {408, 429, 500, 502, 503, 504}." Both directions, because a port that only ANDed + # the set with the baked classifier would pass the narrowing half and fail the widening. + def configurable_status_set_is_authoritative(subject) + subject.probe!(:Resilience, "6a's retry layer") + policy = subject.core::Resilience.const_get(:Policy) + settings = subject.core::Resilience.const_get(:RetrySettings) + default = settings.build.retryable_statuses.to_a.sort + + Check.that(default == DEFAULT_SET, + "the default retryable-status set is not the one XCUT-7 fixes", + expected: DEFAULT_SET, actual: default, ids: ["XCUT-7"],) + Check.that(policy.retry_eligible?(404, set: WIDENED), + "a configured set could not WIDEN the built-in classification", + expected: true, actual: false, ids: ["XCUT-7"],) + Check.that(!policy.retry_eligible?(503, set: NARROWED), + "a configured set could not NARROW the built-in classification", + expected: false, actual: true, ids: ["XCUT-7"],) + end + + # An error type the classifier has never heard of, declaring its own retryability. + # Built on the INSTANCE rather than as a class body, so the capability is the one the + # query reads and nothing is declared on this module. + def declaring(retryable) + error = ::StandardError.new("custom") + error.define_singleton_method(:retryable?) { retryable } + error + end + + # rubocop:enable Metrics/AbcSize + + ROWS = [ + ["XCUT-4", "the error taxonomy has two branches and a transport error is I/O-family", + :two_branch_taxonomy,], + ["XCUT-5", "one shared status classifier decides the baked retryability flag", + :one_shared_status_classifier,], + ["XCUT-6", "a custom error's retryability capability is queried, not its type", + :open_capability_query,], + ["XCUT-7", "the configured retryable-status set is authoritative and can widen or narrow", + :configurable_status_set_is_authoritative,], + ].freeze + private_constant :ROWS + + # @return [Array] this group's assertions, frozen + ASSERTIONS = Runner.registry(self, ROWS) + end + private_constant :Taxonomy + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/levels.rb b/gems/dexpace-conformance/lib/dexpace/conformance/levels.rb new file mode 100644 index 0000000..8dad8f7 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/levels.rb @@ -0,0 +1,680 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# GENERATED by tools/requirement_levels.rb from appendix C -- do not edit; re-run the tool. +module Dexpace + module Conformance + # The normative level of every requirement ID, so a Report can tell a MUST-level vacuity + # from a SHOULD-level one (design R3; NFR-17). A gem cannot read docs/ at runtime, so the + # map is committed here and test/gates/requirement_levels_test.rb diffs it against + # appendix C on every run. `MUST NOT` is :must -- a MUST with a negated predicate. + # + # rubocop:disable Metrics/ModuleLength, Metrics/CollectionLiteralLength -- a generated + # table of every requirement ID in the specification: the size IS the specification's, + # and "read the data from an external source" is what a gem cannot do, since docs/ is + # not on a consumer's machine. + module Levels + # Every requirement ID in appendix C mapped to its RFC 2119 level. + OF = { + "SEAM-1" => :must, + "SEAM-2" => :must, + "SEAM-3" => :must, + "SEAM-4" => :must, + "SEAM-5" => :must, + "SEAM-6" => :must, + "SEAM-7" => :must, + "SEAM-8" => :should, + "SEAM-9" => :must, + "SEAM-10" => :should, + "SEAM-11" => :must, + "SEAM-12" => :must, + "SEAM-13" => :should, + "SEAM-14" => :must, + "SEAM-15" => :may, + "SEAM-16" => :must, + "SEAM-17" => :should, + "SEAM-18" => :must, + "SEAM-19" => :must, + "SEAM-20" => :must, + "SEAM-21" => :must, + "SEAM-22" => :must, + "SEAM-23" => :must, + "SEAM-24" => :should, + "SEAM-25" => :must, + "SEAM-26" => :must, + "SEAM-27" => :must, + "SEAM-28" => :may, + "SEAM-29" => :must, + "SEAM-30" => :must, + "HTTP-1" => :must, + "HTTP-2" => :must, + "HTTP-3" => :must, + "HTTP-4" => :must, + "HTTP-5" => :must, + "HTTP-6" => :must, + "HTTP-7" => :must, + "HTTP-8" => :should, + "HTTP-9" => :must, + "HTTP-10" => :must, + "HTTP-11" => :must, + "HTTP-12" => :must, + "HTTP-13" => :must, + "HTTP-14" => :must, + "HTTP-15" => :must, + "HTTP-16" => :should, + "HTTP-17" => :must, + "HTTP-18" => :must, + "HTTP-19" => :must, + "HTTP-20" => :must, + "HTTP-21" => :must, + "HTTP-22" => :may, + "HTTP-23" => :must, + "HTTP-24" => :must, + "HTTP-25" => :must, + "HTTP-26" => :must, + "HTTP-27" => :should, + "HTTP-28" => :must, + "HTTP-29" => :must, + "HTTP-30" => :must, + "HTTP-31" => :must, + "HTTP-32" => :should, + "HTTP-33" => :must, + "HTTP-34" => :must, + "HTTP-35" => :must, + "HTTP-36" => :must, + "HTTP-37" => :must, + "HTTP-38" => :must, + "HTTP-39" => :must, + "HTTP-40" => :must, + "HTTP-41" => :must, + "HTTP-42" => :must, + "HTTP-43" => :must, + "HTTP-44" => :must, + "HTTP-45" => :must, + "HTTP-46" => :must, + "HTTP-47" => :should, + "HTTP-48" => :should, + "HTTP-49" => :should, + "HTTP-50" => :should, + "HTTP-51" => :should, + "HTTP-52" => :must, + "HTTP-53" => :must, + "IO-1" => :must, + "IO-2" => :must, + "IO-3" => :must, + "IO-4" => :must, + "IO-5" => :must, + "IO-6" => :must, + "IO-7" => :must, + "IO-8" => :must, + "IO-9" => :should, + "IO-10" => :must, + "IO-11" => :must, + "IO-12" => :must, + "IO-13" => :must, + "IO-14" => :must, + "IO-15" => :must, + "IO-16" => :should, + "IO-17" => :must, + "IO-18" => :should, + "IO-19" => :must, + "IO-20" => :must, + "IO-21" => :must, + "IO-22" => :must, + "IO-23" => :must, + "IO-24" => :must, + "IO-25" => :must, + "IO-26" => :must, + "IO-27" => :must, + "IO-28" => :must, + "IO-29" => :must, + "IO-30" => :must, + "IO-31" => :must, + "IO-32" => :must, + "IO-33" => :must, + "IO-34" => :should, + "IO-35" => :should, + "IO-36" => :may, + "IO-37" => :must, + "IO-38" => :must, + "IO-39" => :should, + "IO-40" => :must, + "IO-41" => :must, + "IO-42" => :must, + "BODY-1" => :must, + "BODY-2" => :must, + "BODY-3" => :must, + "BODY-4" => :must, + "BODY-5" => :must, + "BODY-6" => :must, + "BODY-7" => :must, + "BODY-8" => :must, + "BODY-9" => :should, + "BODY-10" => :must, + "BODY-11" => :must, + "BODY-12" => :should, + "BODY-13" => :must, + "BODY-14" => :must, + "BODY-15" => :must, + "BODY-16" => :must, + "BODY-17" => :must, + "BODY-18" => :must, + "BODY-19" => :must, + "BODY-20" => :should, + "BODY-21" => :must, + "BODY-22" => :must, + "BODY-23" => :must, + "BODY-24" => :must, + "BODY-25" => :must, + "BODY-26" => :must, + "BODY-27" => :must, + "BODY-28" => :must, + "BODY-29" => :should, + "BODY-30" => :must, + "BODY-31" => :must, + "BODY-32" => :must, + "BODY-33" => :should, + "BODY-34" => :must, + "BODY-35" => :must, + "BODY-36" => :may, + "BODY-37" => :must, + "CTX-1" => :must, + "CTX-2" => :must, + "CTX-3" => :must, + "CTX-4" => :must, + "CTX-5" => :must, + "CTX-6" => :must, + "CTX-7" => :must, + "CTX-8" => :must, + "CTX-9" => :must, + "CTX-10" => :must, + "CTX-11" => :must, + "CTX-12" => :should, + "CTX-13" => :may, + "CTX-14" => :must, + "CTX-15" => :must, + "CTX-16" => :should, + "CTX-17" => :must, + "CTX-18" => :must, + "CTX-19" => :must, + "CTX-20" => :should, + "PIPE-1" => :must, + "PIPE-2" => :must, + "PIPE-3" => :should, + "PIPE-4" => :must, + "PIPE-5" => :must, + "PIPE-6" => :must, + "PIPE-7" => :must, + "PIPE-8" => :must, + "PIPE-9" => :must, + "PIPE-10" => :must, + "PIPE-11" => :must, + "PIPE-12" => :must, + "PIPE-13" => :must, + "PIPE-14" => :must, + "PIPE-15" => :must, + "PIPE-16" => :must, + "PIPE-17" => :must, + "PIPE-18" => :must, + "PIPE-19" => :must, + "PIPE-20" => :must, + "PIPE-21" => :must, + "PIPE-22" => :must, + "PIPE-23" => :must, + "PIPE-24" => :must, + "PIPE-25" => :must, + "PIPE-26" => :must, + "PIPE-27" => :must, + "PIPE-28" => :must, + "PIPE-29" => :must, + "PIPE-30" => :must, + "PIPE-31" => :must, + "PIPE-32" => :must, + "PIPE-33" => :must, + "PIPE-34" => :must, + "PIPE-35" => :should, + "PIPE-36" => :should, + "PIPE-37" => :must, + "PIPE-38" => :must, + "PIPE-39" => :should, + "PIPE-40" => :must, + "RECOV-1" => :must, + "RECOV-2" => :must, + "RECOV-3" => :must, + "RECOV-4" => :must, + "RECOV-5" => :must, + "RECOV-6" => :must, + "RECOV-7" => :must, + "RECOV-8" => :must, + "RECOV-9" => :should, + "RECOV-10" => :must, + "RECOV-11" => :must, + "RECOV-12" => :must, + "RECOV-13" => :must, + "RECOV-14" => :must, + "RECOV-15" => :must, + "RECOV-16" => :must, + "RECOV-17" => :must, + "RECOV-18" => :must, + "RECOV-19" => :must, + "RECOV-20" => :must, + "RECOV-21" => :must, + "RECOV-22" => :must, + "RECOV-23" => :must, + "RECOV-24" => :must, + "RECOV-25" => :should, + "RECOV-26" => :must, + "RECOV-27" => :must, + "RECOV-28" => :must, + "RECOV-29" => :must, + "RECOV-30" => :should, + "RECOV-31" => :may, + "RECOV-32" => :must, + "RECOV-33" => :must, + "RECOV-34" => :must, + "RETRY-1" => :must, + "RETRY-2" => :must, + "RETRY-3" => :must, + "RETRY-4" => :must, + "RETRY-5" => :must, + "RETRY-6" => :must, + "RETRY-7" => :must, + "RETRY-8" => :must, + "RETRY-9" => :must, + "RETRY-10" => :must, + "RETRY-11" => :must, + "RETRY-12" => :should, + "RETRY-13" => :must, + "RETRY-14" => :must, + "RETRY-15" => :must, + "RETRY-16" => :must, + "RETRY-17" => :must, + "RETRY-18" => :must, + "RETRY-19" => :must, + "RETRY-20" => :must, + "RETRY-21" => :must, + "RETRY-22" => :must, + "RETRY-23" => :must, + "RETRY-24" => :must, + "RETRY-25" => :must, + "RETRY-26" => :must, + "RETRY-27" => :must, + "RETRY-28" => :must, + "RETRY-29" => :may, + "RETRY-30" => :must, + "RETRY-31" => :must, + "RETRY-32" => :must, + "RETRY-33" => :must, + "RETRY-34" => :must, + "RETRY-35" => :must, + "RETRY-36" => :must, + "RETRY-37" => :must, + "RETRY-38" => :should, + "RETRY-39" => :must, + "RETRY-40" => :should, + "RETRY-41" => :must, + "RETRY-42" => :must, + "RETRY-43" => :may, + "RETRY-44" => :must, + "RETRY-45" => :must, + "REDIR-1" => :must, + "REDIR-2" => :must, + "REDIR-3" => :must, + "REDIR-4" => :must, + "REDIR-5" => :must, + "REDIR-6" => :must, + "REDIR-7" => :must, + "REDIR-8" => :must, + "REDIR-9" => :must, + "REDIR-10" => :should, + "REDIR-11" => :must, + "REDIR-12" => :must, + "REDIR-13" => :must, + "REDIR-14" => :must, + "REDIR-15" => :must, + "REDIR-16" => :must, + "REDIR-17" => :must, + "REDIR-18" => :must, + "REDIR-19" => :must, + "REDIR-20" => :must, + "REDIR-21" => :should, + "REDIR-22" => :must, + "REDIR-23" => :should, + "REDIR-24" => :must, + "REDIR-25" => :must, + "REDIR-26" => :must, + "REDIR-27" => :may, + "REDIR-28" => :should, + "AUTH-1" => :must, + "AUTH-2" => :must, + "AUTH-3" => :must, + "AUTH-4" => :must, + "AUTH-5" => :must, + "AUTH-6" => :must, + "AUTH-7" => :must, + "AUTH-8" => :must, + "AUTH-9" => :must, + "AUTH-10" => :must, + "AUTH-11" => :must, + "AUTH-12" => :must, + "AUTH-13" => :must, + "AUTH-14" => :must, + "AUTH-15" => :must, + "AUTH-16" => :must, + "AUTH-17" => :must, + "AUTH-18" => :must, + "AUTH-19" => :should, + "AUTH-20" => :must, + "AUTH-21" => :must, + "AUTH-22" => :must, + "AUTH-23" => :must, + "AUTH-24" => :must, + "AUTH-25" => :must, + "AUTH-26" => :must, + "AUTH-27" => :must, + "AUTH-28" => :must, + "AUTH-29" => :must, + "AUTH-30" => :must, + "AUTH-31" => :must, + "AUTH-32" => :must, + "AUTH-33" => :must, + "AUTH-34" => :must, + "AUTH-35" => :must, + "AUTH-36" => :must, + "AUTH-37" => :must, + "AUTH-38" => :should, + "PAGE-1" => :must, + "PAGE-2" => :must, + "PAGE-3" => :must, + "PAGE-4" => :must, + "PAGE-5" => :must, + "PAGE-6" => :must, + "PAGE-7" => :must, + "PAGE-8" => :must, + "PAGE-9" => :must, + "PAGE-10" => :should, + "PAGE-11" => :must, + "PAGE-12" => :must, + "PAGE-13" => :must, + "PAGE-14" => :must, + "PAGE-15" => :must, + "PAGE-16" => :must, + "PAGE-17" => :must, + "PAGE-18" => :must, + "PAGE-19" => :must, + "PAGE-20" => :should, + "PAGE-21" => :must, + "PAGE-22" => :must, + "PAGE-23" => :must, + "PAGE-24" => :must, + "PAGE-25" => :must, + "PAGE-26" => :must, + "PAGE-27" => :must, + "PAGE-28" => :must, + "PAGE-29" => :must, + "PAGE-30" => :must, + "PAGE-31" => :should, + "PAGE-32" => :must, + "PAGE-33" => :must, + "PAGE-34" => :must, + "PAGE-35" => :should, + "PAGE-36" => :must, + "SSE-1" => :must, + "SSE-2" => :must, + "SSE-3" => :must, + "SSE-4" => :must, + "SSE-5" => :must, + "SSE-6" => :must, + "SSE-7" => :must, + "SSE-8" => :must, + "SSE-9" => :must, + "SSE-10" => :must, + "SSE-11" => :must, + "SSE-12" => :must, + "SSE-13" => :must, + "SSE-14" => :must, + "SSE-15" => :must, + "SSE-16" => :must, + "SSE-17" => :must, + "SSE-18" => :must, + "SSE-19" => :may, + "SSE-20" => :must, + "SSE-21" => :should, + "SSE-22" => :should, + "SSE-23" => :must, + "SSE-24" => :must, + "SSE-25" => :must, + "SSE-26" => :must, + "SSE-27" => :must, + "SSE-28" => :must, + "SSE-29" => :must, + "SSE-30" => :must, + "SSE-31" => :must, + "SSE-32" => :must, + "SSE-33" => :must, + "SSE-34" => :must, + "SSE-35" => :must, + "SSE-36" => :must, + "SSE-37" => :must, + "SSE-38" => :must, + "SSE-39" => :must, + "SSE-40" => :should, + "SSE-41" => :may, + "SERDE-1" => :must, + "SERDE-2" => :must, + "SERDE-3" => :must, + "SERDE-4" => :must, + "SERDE-5" => :must, + "SERDE-6" => :must, + "SERDE-7" => :must, + "SERDE-8" => :must, + "SERDE-9" => :must, + "SERDE-10" => :must, + "SERDE-11" => :should, + "SERDE-12" => :must, + "SERDE-13" => :must, + "SERDE-14" => :must, + "SERDE-15" => :must, + "SERDE-16" => :must, + "SERDE-17" => :must, + "SERDE-18" => :should, + "SERDE-19" => :must, + "SERDE-20" => :should, + "SERDE-21" => :must, + "SERDE-22" => :must, + "SERDE-23" => :should, + "SERDE-24" => :should, + "SERDE-25" => :should, + "SERDE-26" => :must, + "SERDE-27" => :must, + "SERDE-28" => :must, + "SERDE-29" => :should, + "SERDE-30" => :may, + "OBS-1" => :must, + "OBS-2" => :must, + "OBS-3" => :must, + "OBS-4" => :must, + "OBS-5" => :must, + "OBS-6" => :must, + "OBS-7" => :should, + "OBS-8" => :must, + "OBS-9" => :must, + "OBS-10" => :must, + "OBS-11" => :must, + "OBS-12" => :must, + "OBS-13" => :must, + "OBS-14" => :must, + "OBS-15" => :must, + "OBS-16" => :must, + "OBS-17" => :must, + "OBS-18" => :must, + "OBS-19" => :should, + "OBS-20" => :must, + "OBS-21" => :must, + "OBS-22" => :must, + "OBS-23" => :must, + "OBS-24" => :must, + "OBS-25" => :must, + "OBS-26" => :must, + "OBS-27" => :must, + "OBS-28" => :should, + "OBS-29" => :must, + "OBS-30" => :must, + "OBS-31" => :must, + "OBS-32" => :should, + "OBS-33" => :must, + "OBS-34" => :must, + "OBS-35" => :should, + "OBS-36" => :must, + "OBS-37" => :should, + "OBS-38" => :should, + "OBS-39" => :must, + "OBS-40" => :should, + "CFG-1" => :must, + "CFG-2" => :must, + "CFG-3" => :must, + "CFG-4" => :must, + "CFG-5" => :must, + "CFG-6" => :must, + "CFG-7" => :must, + "CFG-8" => :must, + "CFG-9" => :must, + "CFG-10" => :must, + "CFG-11" => :must, + "CFG-12" => :should, + "CFG-13" => :should, + "CFG-14" => :should, + "CFG-15" => :must, + "CFG-16" => :must, + "CFG-17" => :must, + "CFG-18" => :should, + "CFG-19" => :should, + "CFG-20" => :should, + "CFG-21" => :must, + "CFG-22" => :must, + "CFG-23" => :must, + "CFG-24" => :must, + "CFG-25" => :must, + "CFG-26" => :must, + "CFG-27" => :must, + "CFG-28" => :may, + "CFG-29" => :must, + "CFG-30" => :must, + "CFG-31" => :must, + "CFG-32" => :must, + "CFG-33" => :must, + "CFG-34" => :must, + "CFG-35" => :should, + "CFG-36" => :should, + "CFG-37" => :must, + "CFG-38" => :must, + "TRANSPORT-1" => :must, + "TRANSPORT-2" => :must, + "TRANSPORT-3" => :must, + "TRANSPORT-4" => :must, + "TRANSPORT-5" => :must, + "TRANSPORT-6" => :should, + "TRANSPORT-7" => :must, + "TRANSPORT-8" => :must, + "TRANSPORT-9" => :must, + "TRANSPORT-10" => :must, + "TRANSPORT-11" => :must, + "TRANSPORT-12" => :must, + "TRANSPORT-13" => :should, + "TRANSPORT-14" => :must, + "TRANSPORT-15" => :must, + "TRANSPORT-16" => :must, + "TRANSPORT-17" => :must, + "TRANSPORT-18" => :must, + "TRANSPORT-19" => :should, + "TRANSPORT-20" => :must, + "TRANSPORT-21" => :must, + "TRANSPORT-22" => :must, + "TRANSPORT-23" => :must, + "TRANSPORT-24" => :must, + "TRANSPORT-25" => :must, + "TRANSPORT-26" => :must, + "TRANSPORT-27" => :should, + "TRANSPORT-28" => :should, + "TRANSPORT-29" => :must, + "TRANSPORT-30" => :should, + "ASYNC-1" => :must, + "ASYNC-2" => :must, + "ASYNC-3" => :must, + "ASYNC-4" => :must, + "ASYNC-5" => :must, + "ASYNC-6" => :must, + "ASYNC-7" => :should, + "ASYNC-8" => :should, + "ASYNC-9" => :must, + "ASYNC-10" => :must, + "ASYNC-11" => :must, + "ASYNC-12" => :must, + "ASYNC-13" => :must, + "ASYNC-14" => :must, + "ASYNC-15" => :must, + "ASYNC-16" => :should, + "ASYNC-17" => :should, + "ASYNC-18" => :must, + "ASYNC-19" => :must, + "ASYNC-20" => :must, + "ASYNC-21" => :must, + "ASYNC-22" => :must, + "XCUT-1" => :must, + "XCUT-2" => :must, + "XCUT-3" => :must, + "XCUT-4" => :must, + "XCUT-5" => :must, + "XCUT-6" => :must, + "XCUT-7" => :must, + "XCUT-8" => :must, + "XCUT-9" => :must, + "XCUT-10" => :must, + "XCUT-11" => :must, + "XCUT-12" => :should, + "XCUT-13" => :must, + "XCUT-14" => :must, + "XCUT-15" => :must, + "XCUT-16" => :must, + "XCUT-17" => :must, + "XCUT-18" => :must, + "XCUT-19" => :must, + "XCUT-20" => :must, + "XCUT-21" => :must, + "XCUT-22" => :must, + "XCUT-23" => :must, + "XCUT-24" => :should, + "NFR-1" => :must, + "NFR-2" => :should, + "NFR-3" => :should, + "NFR-4" => :should, + "NFR-5" => :should, + "NFR-6" => :should, + "NFR-7" => :should, + "NFR-8" => :must, + "NFR-9" => :should, + "NFR-10" => :must, + "NFR-11" => :should, + "NFR-12" => :should, + "NFR-13" => :should, + "NFR-14" => :should, + "NFR-15" => :should, + "NFR-16" => :should, + "NFR-17" => :must, + }.freeze + + # @param id [String] a requirement ID + # @return [Boolean] true when appendix C holds it + def self.known?(id) = OF.key?(id) + + # @param id [String] a requirement ID + # @return [Symbol] :must, :should, :may, or :unknown for an ID appendix C does not hold + def self.of(id) = OF.fetch(id, :unknown) + + # @param id [String] a requirement ID + # @return [Boolean] true only for a MUST or a MUST NOT + def self.must?(id) = of(id) == :must + end + # rubocop:enable Metrics/ModuleLength, Metrics/CollectionLiteralLength + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/packaging_case.rb b/gems/dexpace-conformance/lib/dexpace/conformance/packaging_case.rb new file mode 100644 index 0000000..883fd8b --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/packaging_case.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "failure" +require_relative "vacuous" + +module Dexpace + module Conformance + # What a PackagingSuite assertion receives. The subject is a set of gem NAMES resolved through + # RubyGems, not paths: NFR-1's conformance clause names "the core artifact's PUBLISHED + # dependency metadata", and only a resolved Gem::Specification is that (design P9-2). A source + # gemspec and a published one can differ -- a gemspec that computes its dependencies, a build + # that injects one, a `.gem` assembled from a different tree -- and phase 0's + # `gates:gemspec_audit` is the pre-publication check over the source. + # + # **`resolve:` is what decides which of the two a run reads, and a run inside `bundle exec` + # reads the SOURCE gemspec of a path gem.** Measured: `Gem::Specification.find_by_name` under + # the repository's own bundle answers a spec whose `full_gem_path` is the source tree, and the + # same call with the BUNDLE_* keys cleared raises `Gem::MissingSpecError`. So a run that means + # P9-2's claim installs built `.gem` files and runs outside the bundle, and a run inside it + # proves what `gates:gemspec_audit` already proves -- which is why every recorded result names + # which environment produced it. + # + # No `require` is needed: RubyGems is loaded before user code, so Gem::Specification is + # available without one -- which is what keeps this gem's declared dependency set at + # dexpace-core and nothing else. + class PackagingCase + # Resolves a gem name to its installed specification, or nil when it is not installed. + DEFAULT_RESOLVE = lambda do |name| + ::Gem::Specification.find_by_name(name) + rescue ::StandardError + nil + end + + # @return [String] the core gem's name + attr_reader :core_name + # @return [Array] the adapter gems' names + attr_reader :adapter_names + + # @param core_name [String] the core gem's name + # @param adapter_names [Array] the adapters under audit + # @param resolve [#call] name -> Gem::Specification or nil + # @param constants [Hash{String => String}] gem name -> the constant path holding its VERSION + # @param versions [Hash{String => String}] what the single source of truth states per gem + # @param sig_roots [Hash{String => String}] gem name -> a shipped sig/ root, when the + # resolved spec's own path is not where the signatures are (a test seam, and the only way + # NFR-13's assertion can be driven against a hand-built specification) + def initialize(core_name:, adapter_names:, resolve: DEFAULT_RESOLVE, constants: {}, + versions: {}, sig_roots: {}) + @core_name = core_name + @adapter_names = adapter_names + @resolve = resolve + @constants = constants + @versions = versions + @sig_roots = sig_roots + end + + # @return [Hash{String => String}] what the driver's single source of truth states, keyed by + # gem name; empty when the driver named none + attr_reader :versions + + # @param name [String] a gem name + # @return [Gem::Specification] + # @raise [Vacuous] when the gem is not installed, which is an absent antecedent and not a + # failure -- a porter running this suite against one gem has the others absent by design + def spec(name) + found = @resolve.call(name) + if found.nil? + raise Vacuous, + "#{name} is not installed, so its published metadata cannot be read" + end + + found + end + + # @return [Array] the core gem and every adapter, in order + def every_name + [@core_name, *@adapter_names] + end + + # NFR-15's runtime half. `constants:` maps each gem name to its constant path and is what a + # real run passes: the derived default below is a convenience for a porter and is WRONG for + # this repository's own gems -- dexpace-core is `Dexpace`, not `Dexpace::Core`, and + # `net_http` -> `NetHTTP` is not a mechanical casing. + # + # An unloaded path is :vacuous (nothing to read). A LOADED module with no VERSION is + # :failed: the gem is present and reports no version at runtime, which is the defect NFR-15 + # names, not an absent antecedent. + # + # @param name [String] a gem name + # @return [String] the version the loaded constant reports + def runtime_version(name) + path = @constants.fetch(name) { default_constant_path(name) } + scope = resolve_constant(path) + unless scope.const_defined?(:VERSION, false) + raise Failure.new("#{path} is loaded but defines no VERSION", + expected: "#{path}::VERSION", actual: "undefined", + requirement_ids: ["NFR-15"],) + end + + scope.const_get(:VERSION, false).to_s + end + + # The shipped `.rbs` files with no SPDX header. NFR-13's conformance clause is "scan ALL + # source files", and `sig/` ships inside every gem, so this is the half no RuboCop cop can + # reach -- `.rbs` is not Ruby and no cop parses it. + # + # @return [Array] every shipped signature file missing the header + def shipped_rbs_without_header + every_name.flat_map { |name| unheadered(sig_root(name)) } + end + + # @param name [String] a gem name + # @return [String, nil] where that gem's shipped signatures are, or nil when it has none + def sig_root(name) + return @sig_roots[name] if @sig_roots.key?(name) + + root = @resolve.call(name)&.full_gem_path + root.nil? ? nil : ::File.join(root, "sig") + end + + private + + def unheadered(root) + return [] if root.nil? || !::File.directory?(root) + + ::Dir.glob(::File.join(root, "**", "*.rbs")).reject do |file| + ::File.foreach(file).first(2).any? { |line| line.include?("SPDX-License-Identifier") } + end + end + + def resolve_constant(path) + path.split("::").reduce(::Object) do |mod, segment| + unless mod.const_defined?(segment, false) + raise Vacuous, "#{path} is not loaded, so no runtime version can be read" + end + + mod.const_get(segment, false) + end + end + + def default_constant_path(name) + name.split("-").map { |segment| segment.split("_").map(&:capitalize).join }.join("::") + end + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite.rb b/gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite.rb new file mode 100644 index 0000000..0c7cd03 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "packaging_case" +require_relative "runner" +require_relative "packaging_suite/dependencies" +require_relative "packaging_suite/surface" + +module Dexpace + module Conformance + # Appendix B.9's PORTABLE half -- the properties of a UNIT, which a downstream porter can + # check against their own reimplementation: NFR-1, NFR-2, NFR-3, NFR-10, NFR-11, NFR-13, + # NFR-14 and NFR-15. + # + # The properties of a BUILD -- NFR-4, NFR-5, NFR-6, NFR-7, NFR-12, NFR-16 and NFR-17 -- are + # recorded gate results and are deliberately not here: a portable assertion for NFR-5 would be + # asserting that a stranger's gem has 80% coverage, which the requirement does not ask and + # this suite has no way to know. NFR-10, NFR-13 and NFR-14 are in BOTH forms, which is not a + # double disposition but two audiences (design R2): the gate answers "is THIS repository's CI + # enforcing it", the assertion answers "can a porter check it against their own unit". + # + # NFR-8 and NFR-9 are in neither: both are vacuous by NFR-8's own text ("in ecosystems without + # such a build step this requirement does not apply"), and the checks §9.2 retargets are + # dispositioned under NFR-1 rather than counted twice (design P9-5). + module PackagingSuite + extend self + + # What a green run of this suite does NOT prove. + PREAMBLE = "dexpace-conformance packaging suite: every assertion here reads a RESOLVED " \ + "Gem::Specification. Inside `bundle exec` a path gem resolves to its SOURCE " \ + "gemspec and its source tree, which is what a pre-publication gate already " \ + "checks; the claim these assertions are written for -- published metadata -- " \ + "needs a built .gem installed and a run outside the bundle. Record which " \ + "environment produced a result." + + # @return [Array] the suite's assertions, frozen and ordered + def assertions + ASSERTIONS + end + + # Runs the suite against one set of gem names. + # + # @param core [String] the core gem's name + # @param adapters [Array] the adapter gems' names + # @param resolve [#call] name -> Gem::Specification or nil + # @param constants [Hash{String => String}] gem name -> the constant path holding its VERSION + # @param versions [Hash{String => String}] the single source of truth's entry per gem + # @param sig_roots [Hash{String => String}] gem name -> a shipped sig/ root, where the + # resolved spec's own path is not it + # @param waive [Array] requirement IDs recorded :waived without running + # @param around [#call, nil] the suite contract's clause 9 wrapper + # @param accepted_vacuous [Hash{String => String}] sanctioned MUST-level vacuities + # @param would_fail [Array] waived IDs whose assertion would have failed + # @return [Report] + def run(core: "dexpace-core", adapters: [], resolve: PackagingCase::DEFAULT_RESOLVE, + constants: {}, versions: {}, sig_roots: {}, waive: [], around: nil, + accepted_vacuous: {}, would_fail: []) + Runner.run(assertions, waive: waive, around: around, preamble: PREAMBLE, + accepted_vacuous: accepted_vacuous, would_fail: would_fail,) do + PackagingCase.new(core_name: core, adapter_names: adapters, resolve: resolve, + constants: constants, versions: versions, sig_roots: sig_roots,) + end + end + + # The two groups, each a private module of its own file. + ASSERTIONS = (Dependencies::ASSERTIONS + Surface::ASSERTIONS).freeze + private_constant :ASSERTIONS + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb b/gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb new file mode 100644 index 0000000..9b4fb53 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../check" +require_relative "../failure" +require_relative "../runner" +require_relative "../vacuous" + +module Dexpace + module Conformance + module PackagingSuite + # Group 1, what a unit DECLARES: NFR-1, NFR-2, NFR-10, NFR-14 and NFR-15. + # A private_constant of PackagingSuite. + module Dependencies + extend self + + # NFR-1: "the core module MUST depend only on the language standard library ... it MUST + # NOT carry a runtime dependency on any concrete HTTP transport, serialization library, + # I/O implementation, or async framework." + # + # EMPTY, not "contains no transport": Ruby has no compile-versus-runtime dependency scope + # to lean on, so any non-empty set is a claim someone has to adjudicate. + def core_has_no_runtime_dependencies(subject) + names = subject.spec(subject.core_name).runtime_dependencies.map(&:name) + + Check.that(names.empty?, "#{subject.core_name} declares runtime dependencies", + expected: [], actual: names, ids: ["NFR-1"],) + end + + # NFR-2: "a separately installable unit that depends on the core plus AT MOST ONE + # third-party library." Both halves: the core is declared, and the rest is at most one. + def adapter_budget(subject) + subject.adapter_names.each do |name| + declared = subject.spec(name).runtime_dependencies.map(&:name) + third_party = declared - [subject.core_name] + next if declared.include?(subject.core_name) && third_party.size <= 1 + + raise Failure.new("#{name} is outside NFR-2's dependency budget", + expected: "#{subject.core_name} plus at most one", + actual: declared, requirement_ids: ["NFR-2"],) + end + nil + end + + # NFR-10: "the SDK MUST declare a lowest-supported-runtime floor and target it for all + # general-purpose units. A capability that genuinely requires a newer runtime MUST be + # ISOLATED into its own unit that declares the higher floor explicitly; that unit MUST NOT + # be a hard dependency of the general-purpose core." + # + # A HIGHER floor on one unit is therefore CONFORMING and not a defect -- which is the half + # a gate asserting "every gemspec equals the global floor" gets wrong, and the reason this + # assertion exists beside that gate rather than restating it. + def declared_runtime_floor(subject) + floors = subject.every_name.to_h do |name| + [name, subject.spec(name).required_ruby_version] + end + undeclared = floors.reject { |_, floor| declared?(floor) }.keys + Check.that(undeclared.empty?, "a unit declares no lowest-supported-runtime floor", + expected: "every unit declares one", actual: undeclared, ids: ["NFR-10"],) + check_isolation(subject, floors) + end + + # A unit whose floor is higher than the core's must not be a hard dependency of the core. + def check_isolation(subject, floors) + core_floor = floors.fetch(subject.core_name) + raised = floors.reject { |name, floor| name == subject.core_name || floor == core_floor } + declared = subject.spec(subject.core_name).runtime_dependencies.map(&:name) + + Check.that(!raised.keys.intersect?(declared), + "a unit declaring a HIGHER runtime floor is a hard dependency of the core, " \ + "so the core no longer targets the floor it declares", + expected: [], actual: raised.keys & declared, ids: ["NFR-10"],) + end + + # NFR-14: "dependency versions, plugin/tool versions and project coordinates SHOULD live + # in a SINGLE SOURCE OF TRUTH rather than being restated per unit." + # + # The source is the driver's to name -- a porter's is not this repository's `VERSIONS` + # file -- so the assertion is that every unit's PUBLISHED version equals the one that + # source states for it. With no source supplied it is :vacuous with that reason, never a + # pass, because "nobody told us" is not evidence of a single source. + def single_version_source(subject) + stated = subject.versions + mismatched = subject.every_name.filter_map do |name| + published = subject.spec(name).version.to_s + want = stated[name] + next if want.nil? || want.to_s == published + + [name, want.to_s, published] + end + + Check.that(mismatched.empty?, + "a unit's published version does not equal the single source's entry for it", + expected: "every unit at its declared version", actual: mismatched, + ids: ["NFR-14"],) + end + + # NFR-15: "published artifacts SHOULD embed self-identifying version metadata that the SDK + # can resolve AT RUNTIME, so runtime-emitted identifiers report the REAL version rather + # than an 'unknown' placeholder fallback." + # + # So the assertion COMPARES the loaded constant against the resolved gemspec's version. A + # not-a-placeholder check alone passes at 0.0.0 -- the version every gem in this + # repository currently carries -- which would make it green on a tree where NFR-15 has + # never been satisfied. + def version_matches_the_gemspec(subject) + subject.every_name.each do |name| + built = subject.spec(name).version.to_s + reported = subject.runtime_version(name) + next if reported == built + + raise Failure.new("#{name}'s runtime version does not equal its build version", + expected: built, actual: reported, requirement_ids: ["NFR-15"],) + end + nil + end + + # A `Gem::Requirement` of `>= 0` is RubyGems' default and declares nothing. + def declared?(floor) + !floor.nil? && floor.to_s != ">= 0" + end + + ROWS = [ + ["NFR-1", "core declares zero runtime dependencies", :core_has_no_runtime_dependencies], + ["NFR-2", "each adapter declares the core plus at most one library", :adapter_budget], + ["NFR-10", "every unit declares a runtime floor, and a higher one stays isolated", + :declared_runtime_floor,], + ["NFR-14", "every unit's version comes from one source of truth", :single_version_source], + ["NFR-15", "each unit reports its build version at runtime", + :version_matches_the_gemspec,], + ].freeze + private_constant :ROWS + + # @return [Array] this group's assertions, frozen + ASSERTIONS = Runner.registry(self, ROWS) + end + private_constant :Dependencies + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/surface.rb b/gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/surface.rb new file mode 100644 index 0000000..d534e7e --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/surface.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../check" +require_relative "../runner" +require_relative "../vacuous" + +module Dexpace + module Conformance + module PackagingSuite + # Group 2, what a unit SHIPS: NFR-3, NFR-11 and NFR-13. A private_constant of + # PackagingSuite. + module Surface + extend self + + # The async-framework namespaces NFR-11's first clause names ("coroutines, reactive + # publishers"), as a Ruby ecosystem writes them. The negative lookbehind is what keeps + # core's OWN `Dexpace::Async::Future` out of the match: the character before a leaked name + # is never a colon. + ASYNC_FRAMEWORKS = %w[Async Concurrent EventMachine Celluloid Reactor Fiber].freeze + # A framework namespace at the START of a constant path. The lookbehind is what keeps + # core's own `Dexpace::Async::Future` out of the match. + LEAK = /(?, nil] every framework leak in a shipped sig/ tree, or nil when + # there is none to scan + def leaks_in(root) + return nil if root.nil? || !::File.directory?(root) + + ::Dir.glob(::File.join(root, "**", "*.rbs")).flat_map { |file| leaks_in_file(file) } + end + + # @return [Array] one `path:line: match` per leak in one signature file + def leaks_in_file(file) + ::File.readlines(file).filter_map.with_index do |line, index| + next if line.match?(COMMENT) + + match = line[LEAK] + "#{file}:#{index + 1}: #{match}" unless match.nil? + end + end + + # The two trees as relative, extension-free paths, so they compare directly. + def trees(subject, name) + root = subject.sig_root(name) + return [nil, nil] if root.nil? + + gem_root = ::File.dirname(root) + [relative(::File.join(gem_root, "lib"), ".rb"), relative(root, ".rbs")] + end + + # @return [Array, nil] every file under `root`, relative and extension-free + def relative(root, extension) + return nil unless ::File.directory?(root) + + ::Dir.glob("**/*#{extension}", base: root).map { |path| path.delete_suffix(extension) } + .sort + end + + ROWS = [ + ["NFR-3", "every shipped implementation file has a signature beside it", + :explicit_typed_surface,], + ["NFR-11", "the core's public signatures name no async-framework type", + :no_async_framework_type_in_the_core_surface,], + ["NFR-13", "every shipped signature file carries the SPDX header", :spdx_header_coverage], + ].freeze + private_constant :ROWS + + # @return [Array] this group's assertions, frozen + ASSERTIONS = Runner.registry(self, ROWS) + end + private_constant :Surface + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/report.rb b/gems/dexpace-conformance/lib/dexpace/conformance/report.rb index 20c4137..ac7e870 100644 --- a/gems/dexpace-conformance/lib/dexpace/conformance/report.rb +++ b/gems/dexpace-conformance/lib/dexpace/conformance/report.rb @@ -1,89 +1,216 @@ # frozen_string_literal: true # SPDX-License-Identifier: MIT +require_relative "levels" require_relative "result" module Dexpace module Conformance - # A frozen list of Results, partitioned by status, with the one renderer this sub-phase ships. - # `#to_s` is the only renderer (8a's open question 6): a structured `#to_h` is NFR-4-locked - # surface with no caller until phase 9 aggregates three suites, and phase 9 is the phase with - # the caller. + # A frozen list of Results, partitioned by status, with the one renderer 8a shipped and the + # structured form it deferred ("NFR-4-locked surface with no caller until phase 9 aggregates + # three suites" -- phase 9 is the phase with the caller). # - # Two things print on EVERY run and not only on failure, because a green run is the run a + # Three things print on EVERY run and not only on failure, because a green run is the run a # reader is most likely to over-read. Design §9.3: "the gap stays visible rather than - # disappearing into a restated item" -- so every waived ID is named. And P8-9: the suite's - # fixture speaks plaintext only and exercises no connect timeout, so the suite hands its - # preamble in and it prints first, which is what makes a third-party adapter author's green run - # honest about TLS and connect-timeout classification. - class Report + # disappearing into a restated item" -- so every waived ID is named. P8-9: the suite's fixture + # speaks plaintext only and exercises no connect timeout, so the suite hands its preamble in + # and it prints first. And design R3: nothing passes by not being built, so an un-waived, + # un-accepted MUST-level vacuity is a REPORT BLOCKER -- `#passed?` is false while one stands. + # + # `waived (would fail)` is a DRIVER's declaration, never a blanket rename of 8a's rendering: a + # waiver can mean "the adapter does not support the property" (8c waives TRANSPORT-14 and + # TRANSPORT-27 for exactly that) or "this would fail and the port has named the gap" + # (ASYNC-3, design R5). Renaming every waiver would make the report lie about the first kind, + # so the second kind names itself through `would_fail:`. + class Report # rubocop:disable Metrics/ClassLength -- one report: the five statuses, the two vacuity sections design R3 adds and the two renderings are one value's faces; a split would invent a second constant for half a report + # Merging Results rather than Reports keeps ONE #passed? over the whole run, which is what a + # CI step needs. The acceptances and the would-fail declarations merge too: a suite-level + # one survives aggregation and an aggregate-level one applies to every suite. + # + # @param reports [Array] + # @param preamble [String, nil] + # @param accepted_vacuous [Hash{String => String}] + # @param would_fail [Array] + # @return [Report] + def self.merge(reports, preamble: nil, accepted_vacuous: {}, would_fail: []) + inherited = reports.map(&:accepted_vacuous).reduce({}, :merge) + new(reports.flat_map(&:results), preamble: preamble, + accepted_vacuous: inherited.merge(accepted_vacuous), + would_fail: reports.flat_map(&:would_fail) | would_fail,) + end + # @return [Array] every result, frozen, in the suite's order attr_reader :results # @return [String, nil] what the run does not prove, printed first (P8-9) attr_reader :preamble + # @return [Hash{String => String}] sanctioned MUST-level vacuities, each with its citation + attr_reader :accepted_vacuous + # @return [Array] waived IDs whose assertion would have failed (design R5) + attr_reader :would_fail # @param results [Enumerable] the run's results, in order # @param preamble [String, nil] the suite's stated omissions - def initialize(results, preamble: nil) + # @param accepted_vacuous [Hash{String => String}] ID => citation. Every citation is + # mandatory and non-empty: an acceptance is a claim that design §12 or §10.5 sanctions the + # vacuity, and a blank claim is the silent pass this mechanism exists to remove. + # @param would_fail [Array] IDs whose waiver the driver declares as would-fail + # @raise [ArgumentError] on an acceptance with no citation + def initialize(results, preamble: nil, accepted_vacuous: {}, would_fail: []) @results = results.to_a.freeze @preamble = preamble + @accepted_vacuous = accepted_vacuous.transform_keys(&:to_s).freeze + @would_fail = would_fail.map(&:to_s).freeze + @accepted_vacuous.each { |id, citation| validate_citation!(id, citation) } end - # True when nothing failed and nothing errored. A vacuous or a waived result does not fail a - # run -- the first has no antecedent to fail on and the second is a gap the port has named -- - # and both are printed so neither reads as a pass. + # True when nothing failed, nothing errored and no un-waived, un-accepted MUST-level + # vacuity stands. A waived result never fails a run -- it is a gap the port has named -- and + # a SHOULD- or MAY-level vacuity is recorded and does not block. # # @return [Boolean] def passed? - failures.empty? && errors.empty? + failures.empty? && errors.empty? && blocking_vacuities.empty? end + # @return [Array] the results whose assertion returned cleanly + def passed = by_status(:passed) + # @return [Array] the results whose assertion raised a Failure - def failures - by_status(:failed) - end + def failures = by_status(:failed) # @return [Array] the results whose assertion raised a Vacuous - def vacuous - by_status(:vacuous) - end + def vacuous = by_status(:vacuous) # @return [Array] the results a named waiver suppressed before they ran - def waived - by_status(:waived) - end + def waived = by_status(:waived) # @return [Array] the results whose assertion raised anything else - def errors - by_status(:error) + def errors = by_status(:error) + + # Design R3: an absent artifact is :vacuous rather than :failed so phase 10 can tell "not + # built" from "built wrong" -- and that is safe only because nothing passes by not being + # built. These are the vacuities on a MUST that no acceptance names. + # + # @return [Array] + def blocking_vacuities + must_level_vacuities.reject { |result| accepted?(result) } end - # The rendering: the preamble when there is one, a count line, then every waived, vacuous, - # failed and errored result named by its IDs -- the waived ones on every run. + # @return [Array] MUST-level vacuities a driver sanctioned, with a citation + def accepted_vacuities + must_level_vacuities.select { |result| accepted?(result) } + end + + # @return [Array] every ID in this run that appendix C does not hold -- a typo in an + # assertion's `ids:` is otherwise invisible, because an unknown ID never blocks + def unknown_ids + @results.flat_map { |result| result.assertion.ids }.uniq.reject { |id| Levels.known?(id) } + end + + # The rendering: the preamble when there is one, a count line, then every waived, accepted, + # vacuous, blocking, failed and errored result named by its IDs -- and, last, the blocker + # verdict, so a reader who sees only the tail sees it. # # @return [String] def to_s - [preamble, counts_line, *waived_lines, *detail_lines].compact.join("\n") + [preamble, counts_line, *waived_lines, *accepted_lines, *vacuous_lines, + *blocking_lines, *detail_lines, blocked_line,].compact.join("\n") + end + + # The structured form 8a deferred for phase 9's aggregate caller. + # + # @return [Hash{Symbol => Object}] + def to_h # rubocop:disable Metrics/AbcSize -- one structured row per section, and the sections are the report's own + counts.merge( + blocking_vacuities: blocking_vacuities.map do |r| + { ids: r.assertion.ids, detail: r.detail } + end, + accepted_vacuities: accepted_vacuities.map do |r| + { ids: r.assertion.ids, citation: citation_for(r) } + end, + unknown_ids: unknown_ids, + results: @results.map do |result| + { ids: result.assertion.ids, name: result.assertion.name, status: result.status, + detail: result.detail, } + end, + ) end private + def validate_citation!(id, citation) + return if citation.is_a?(::String) && !citation.strip.empty? + + raise ::ArgumentError, + "accepted_vacuous[#{id.inspect}] needs a citation (design §12 or §10.5)" + end + def by_status(status) @results.select { |result| result.status == status } end + def must_level_vacuities + vacuous.select { |result| must_level?(result) } + end + + def must_level?(result) + result.assertion.ids.any? { |id| Levels.must?(id) } + end + + # Every MUST-level ID the assertion carries must be named, never just one: an acceptance of + # one ID would otherwise silence a co-carried MUST, which is the gap staying invisible. + def accepted?(result) + result.assertion.ids.all? { |id| !Levels.must?(id) || @accepted_vacuous.key?(id) } + end + + def citation_for(result) + result.assertion.ids.filter_map { |id| @accepted_vacuous[id] }.uniq.join("; ") + end + + def counts + { passed: passed.size, failed: failures.size, vacuous: vacuous.size, + waived: waived.size, errored: errors.size, } + end + def counts_line - "#{by_status(:passed).size} passed, #{failures.size} failed, #{vacuous.size} vacuous, " \ + "#{passed.size} passed, #{failures.size} failed, #{vacuous.size} vacuous, " \ "#{waived.size} waived, #{errors.size} errored" end def waived_lines - waived.map { |result| " waived: #{ids(result)} (#{result.assertion.name})" } + waived.map do |result| + label = result.assertion.ids.intersect?(@would_fail) ? "waived (would fail)" : "waived" + " #{label}: #{ids(result)} (#{result.assertion.name})" + end + end + + def accepted_lines + accepted_vacuities.map do |result| + " accepted MUST-level vacuity (design-sanctioned): " \ + "#{ids(result)}: #{citation_for(result)}" + end + end + + def vacuous_lines + (vacuous - must_level_vacuities).map { |r| " vacuous: #{ids(r)}: #{r.detail}" } + end + + def blocking_lines + blocking_vacuities.map do |result| + " MUST-level vacuity (report blocker): #{ids(result)}: #{result.detail}" + end + end + + def blocked_line + return nil if blocking_vacuities.empty? + + count = blocking_vacuities.size + "REPORT BLOCKED: #{count} un-waived MUST-level " \ + "#{count == 1 ? "vacuity" : "vacuities"} (design R3)" end def detail_lines - { "vacuous" => vacuous, "FAILED" => failures, - "ERROR" => errors, }.flat_map do |label, results| + { "FAILED" => failures, "ERROR" => errors }.flat_map do |label, results| results.map { |result| " #{label}: #{ids(result)}: #{result.detail}" } end end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/runner.rb b/gems/dexpace-conformance/lib/dexpace/conformance/runner.rb new file mode 100644 index 0000000..fe47817 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/runner.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "assertion" +require_relative "failure" +require_relative "vacuous" +require_relative "result" +require_relative "report" + +module Dexpace + module Conformance + # The one loop every phase-9 suite's `.run` delegates to, so the five statuses are decided in + # one place. Four suites would otherwise carry four copies of one status loop, which is + # §11.12's "four reference sync/async drifts" reappearing inside the port's own suite. + # + # TransportSuite (phase 8a) predates this and is deliberately NOT refactored onto it: phase 8 + # owns that file and phase 9 reports rather than refactors (design R6, P9-10). The residue is + # real -- two status-deciding paths in one gem -- and is stated rather than hidden. + module Runner + extend self + + # @param assertions [Array] ordered, frozen + # @param waive [Array] requirement IDs, never assertion names (design §9.3) + # @param around [#call, nil] wraps each invocation; an async driver passes + # `->(&blk) { Sync { blk.call } }`, which is the suite contract's clause 9 + # @param accepted_vacuous [Hash{String => String}] MUST-level IDs whose vacuity the port + # has sanctioned, each with its citation (design R3) + # @param would_fail [Array] waived IDs whose assertion WOULD have failed, rendered + # `waived (would fail): ID` (design R5) + # @param preamble [String, nil] what this suite's green run does not prove + # @yield a subject built fresh per assertion (testing/4ef070df) + # @return [Report] + def run(assertions, waive: [], around: nil, accepted_vacuous: {}, would_fail: [], + preamble: nil, &subject) + results = assertions.map { |assertion| one(assertion, waive, around, subject) } + Report.new(results, preamble: preamble, accepted_vacuous: accepted_vacuous, + would_fail: would_fail,) + end + + # Builds a group's frozen assertion list from a table of `[ids, name, method_name]` rows -- + # 8a's `Checks.registry` shape, in the file phase 9's suites can all reach. The table is what + # keeps an assertion's IDs, its human name and its body in one readable place, which is what + # a third-party suite author writing their own group needs too. + # + # @param group [Module] the module whose singleton methods the rows name + # @param rows [Array] + # @return [Array] frozen + def registry(group, rows) + rows.map do |ids, name, function| + Assertion.build(ids: Array(ids), name: name, + body: ->(subject) { group.public_send(function, subject) },) + end.freeze + end + + private + + # Rescue order is load-bearing and is not alphabetical: Vacuous and Failure are both + # ::StandardError descendants (8a), so the bare rescue must come last or every vacuity + # becomes an error. + def one(assertion, waive, around, subject) + return Result.build(assertion: assertion, status: :waived) if waived?(assertion, waive) + + invoke(assertion, around, subject) + Result.build(assertion: assertion, status: :passed) + rescue Vacuous => error + Result.build(assertion: assertion, status: :vacuous, detail: error.reason) + rescue Failure => error + Result.build(assertion: assertion, status: :failed, detail: detail_for(error)) + rescue ::StandardError => error + Result.build(assertion: assertion, status: :error, + detail: "#{error.class}: #{error.message}",) + end + + def waived?(assertion, waive) = assertion.ids.intersect?(waive) + + def detail_for(error) + "#{error.message} (expected #{error.expected.inspect}, got #{error.actual.inspect})" + end + + # A FRESH subject per assertion: a fixture shared across a run is what made the conforming + # executor double fail during this phase's own planning (testing/4ef070df). + def invoke(assertion, around, subject) + body = -> { assertion.call(subject.call) } + around.nil? ? body.call : around.call(&body) + end + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/shared_instance.rb b/gems/dexpace-conformance/lib/dexpace/conformance/shared_instance.rb new file mode 100644 index 0000000..7519c7e --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/shared_instance.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "failure" + +module Dexpace + module Conformance + # XCUT-11's structural half, as one predicate (design R8, P9-9). + # + # **The rule is a disjunction, and both halves matter.** A frozen instance conforms on + # `frozen?` alone: it cannot hold per-call state, whatever its ivars say. An UNFROZEN one + # conforms only when every instance variable it holds is one the DRIVER declared -- a + # `Thread::Mutex` with the state it guards, or Closeable's `@closed` latch. + # + # Two ways to get it wrong, both measured against the real tree. A *frozen and no ivars* rule + # condemns three conforming first-party subjects outright -- `Instrumentation::Redactor::DEFAULT` + # (frozen, `[:@policy]`), `Instrumentation::Logger::NULL` (frozen, six ivars) and + # `ContextStore.default` (unfrozen, `[:@map]`, a BoundedMap with its own mutex) -- and would + # condemn the exact latch shape `cross-cutting-invariants/89eb6533` prescribes. And reading + # `mutable:` OFF the audited object inverts the requirement the other way: a conforming + # latch-plus-mutex Closeable fails, because no phase committed to such a method and R6 forbids + # phase 9 adding one, while the identical per-call-state bug passes by declaring its own ivar + # exempt. So the declaration is the driver's, and the audited object gets no vote on its own + # exemption. + module SharedInstance + extend self + + # @param object [Object] the shared instance under audit + # @param mutable [Array] ivars the DRIVER declares as guarded state or a close latch + # @param ids [Array] requirement IDs the raised Failure carries + # @return [nil] + # @raise [Failure] when an unfrozen shared instance holds an undeclared instance variable + def audit(object, mutable: [], ids: ["XCUT-11"]) + return nil if object.frozen? + + undeclared = object.instance_variables - mutable + return nil if undeclared.empty? + + raise Failure.new( + "#{object.class} is shared across concurrent requests and holds undeclared mutable state", + expected: mutable.sort, actual: object.instance_variables.sort, requirement_ids: ids, + ) + end + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/transport_suite.rb b/gems/dexpace-conformance/lib/dexpace/conformance/transport_suite.rb index ef6a96e..7b600dd 100644 --- a/gems/dexpace-conformance/lib/dexpace/conformance/transport_suite.rb +++ b/gems/dexpace-conformance/lib/dexpace/conformance/transport_suite.rb @@ -63,9 +63,17 @@ def assertions # @param wire [#call, nil] clause 11's fixture factory; nil takes WireServer # @param assertions [Array] the assertions to run, the suite's own by default -- # a keyword so the suite's own tests hand in three of theirs without stubbing anything + # @param accepted_vacuous [Hash{String => String}] MUST-level IDs whose vacuity the port has + # sanctioned, each with its citation. Added by phase 9: `Report#passed?` is false while an + # un-waived MUST-level vacuity stands (design R3), and this is how a driver names one it + # has an argument for. A keyword with a default, so every existing call is unchanged. + # @param would_fail [Array] waived IDs whose assertion WOULD have failed, rendered + # `waived (would fail): ID` (design R5). This adapter's two waivers are NOT of that kind -- + # the native client refuses the head before a response exists -- so the default is empty + # and 8a's plain `waived:` rendering stands. # @return [Report] def run(build:, borrow: nil, waive: [], around: nil, settle: nil, wire: nil, - assertions: self.assertions) + assertions: self.assertions, accepted_vacuous: {}, would_fail: []) results = assertions.map do |assertion| if assertion.ids.intersect?(waive) Result.build(assertion: assertion, status: :waived) @@ -74,7 +82,8 @@ def run(build:, borrow: nil, waive: [], around: nil, settle: nil, wire: nil, wire: wire,) end end - Report.new(results, preamble: PREAMBLE) + Report.new(results, preamble: PREAMBLE, accepted_vacuous: accepted_vacuous, + would_fail: would_fail,) end private diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/aggregate.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/aggregate.rbs new file mode 100644 index 0000000..9b6530a --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/aggregate.rbs @@ -0,0 +1,19 @@ +module Dexpace + module Conformance + # One report over every suite in a run; the caller 8a deferred Report#to_h for. + module Aggregate + PREAMBLE: Array[String] + + def self?.run: (Array[Report] reports, ?accepted_vacuous: Hash[String, String], + ?would_fail: Array[String]) -> Report + def self?.render: (Report report) -> String + def self?.by_requirement_id: (Array[untyped] suites, + ?statuses: Report?) -> Hash[String, Array[untyped]] + + private + + def add_rows: (untyped suite, Hash[String, Symbol] observed, + Hash[String, Array[untyped]] map) -> void + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/check.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/check.rbs new file mode 100644 index 0000000..8f9ef6c --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/check.rbs @@ -0,0 +1,9 @@ +module Dexpace + module Conformance + # The one assertion primitive every phase-9 suite body uses. + module Check + def self?.that: (untyped condition, String message, expected: untyped, actual: untyped, + ids: Array[String]) -> nil + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/codec_case.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/codec_case.rbs new file mode 100644 index 0000000..643baad --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/codec_case.rbs @@ -0,0 +1,30 @@ +module Dexpace + module Conformance + # What a CodecSuite assertion receives: a codec factory, the driver's witness and its source + # factory, since phase 2's contract is `#load(source, witness)` with no witness-less overload. + class CodecCase + class CountingSink + @bytes: String + @close_count: Integer + + attr_reader bytes: String + attr_reader close_count: Integer + + def initialize: () -> void + def write: (String chunk) -> Integer + def close: () -> Integer + end + + @build: untyped + @witness: untyped + @source: untyped + + attr_reader witness: untyped + + def initialize: (build: untyped, witness: untyped, source: untyped) -> void + def codec: () -> untyped + def sink: () -> CountingSink + def source: (String text) -> untyped + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/codec_suite.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/codec_suite.rbs new file mode 100644 index 0000000..117a185 --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/codec_suite.rbs @@ -0,0 +1,15 @@ +module Dexpace + module Conformance + # Appendix B.3's seam half: SEAM-20 with SERDE-3, and SERDE-9. + module CodecSuite + PREAMBLE: String + # A private_constant with no visibility in RBS. + ASSERTIONS: Array[Assertion] + + def self?.assertions: () -> Array[Assertion] + def self?.run: (build: untyped, witness: untyped, source: untyped, ?waive: Array[String], + ?around: untyped, ?accepted_vacuous: Hash[String, String], + ?would_fail: Array[String]) -> Report + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/codec_suite/seam.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/codec_suite/seam.rbs new file mode 100644 index 0000000..def9413 --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/codec_suite/seam.rbs @@ -0,0 +1,19 @@ +# Dexpace::Conformance::CodecSuite::Seam is a private_constant (see seam.rb); declared because the +# conformance Steep target checks every file under lib/. +module Dexpace + module Conformance + module CodecSuite + module Seam + SAMPLE: Hash[String, Integer] + MALFORMED: String + ROWS: Array[untyped] + ASSERTIONS: Array[Assertion] + + def self?.never_closes_its_target: (CodecCase subject) -> nil + def self?.no_library_type_escapes: (CodecCase subject) -> nil + def self?.capture_failure: (CodecCase subject) -> untyped + def self?.serde_error?: (untyped error) -> bool + end + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/executor_case.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/executor_case.rbs new file mode 100644 index 0000000..3c8c74c --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/executor_case.rbs @@ -0,0 +1,52 @@ +module Dexpace + module Conformance + # What an ExecutorSuite assertion receives: an executor factory, an optional borrowing entry + # point, an optional resource-free implementation, and this case's own event recorder. + class ExecutorCase + THREADS: Integer + + class EventRecorder + @payloads: Array[untyped] + @mutex: Thread::Mutex + + def initialize: () -> void + def entries: () -> Array[untyped] + def debug: (?untyped message) { () -> untyped } -> nil + | (?untyped message) -> nil + def info: (?untyped message) { () -> untyped } -> nil + | (?untyped message) -> nil + def warn: (?untyped message) { () -> untyped } -> nil + | (?untyped message) -> nil + def error: (?untyped message) { () -> untyped } -> nil + | (?untyped message) -> nil + def debug?: () -> bool + def info?: () -> bool + def warn?: () -> bool + def error?: () -> bool + + private + + def record: (untyped message) ?{ () -> untyped } -> nil + end + + @build: untyped + @borrow: untyped + @functional: untyped + @recorder: EventRecorder? + + def initialize: (build: untyped, ?borrow: untyped, ?functional: untyped, + ?record_events: bool) -> void + def executor: () -> untyped + def borrowed?: () -> bool + def borrowed: (untyped pool) -> untyped + def functional?: () -> bool + def functional: () -> untyped + def events?: () -> bool + def shutdowns: () -> Integer + + private + + def shutdown?: (untyped payload) -> bool + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/executor_suite.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/executor_suite.rbs new file mode 100644 index 0000000..5e92f7f --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/executor_suite.rbs @@ -0,0 +1,16 @@ +module Dexpace + module Conformance + # Appendix B.7's lifecycle half, and the harness half of SEAM-25's lifecycle event. + module ExecutorSuite + PREAMBLE: String + # A private_constant with no visibility in RBS; the two groups, concatenated. + ASSERTIONS: Array[Assertion] + + def self?.assertions: () -> Array[Assertion] + def self?.run: (build: untyped, ?borrow: untyped, ?functional: untyped, + ?record_events: bool, ?waive: Array[String], ?around: untyped, + ?accepted_vacuous: Hash[String, String], + ?would_fail: Array[String]) -> Report + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/executor_suite/lifecycle.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/executor_suite/lifecycle.rbs new file mode 100644 index 0000000..1bc5492 --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/executor_suite/lifecycle.rbs @@ -0,0 +1,22 @@ +# Dexpace::Conformance::ExecutorSuite::Lifecycle is a private_constant (see lifecycle.rb); +# declared because the conformance Steep target checks every file under lib/. +module Dexpace + module Conformance + module ExecutorSuite + module Lifecycle + DRAIN: Integer + ROWS: Array[untyped] + ASSERTIONS: Array[Assertion] + + def self?.concurrent_post: (ExecutorCase subject) -> nil + def self?.close_is_latched: (ExecutorCase subject) -> nil + def self?.borrowed_executor_survives: (ExecutorCase subject) -> nil + def self?.check_no_shutdown: (ExecutorCase subject) -> nil + def self?.graceful_shutdown: (ExecutorCase subject) -> nil + def self?.check_refusal: (untyped pool, Thread::Queue done) -> nil + def self?.drain: (Thread::Queue queue) -> Array[untyped] + def self?.outcome_of: (untyped callable) -> untyped + end + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/executor_suite/shutdown.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/executor_suite/shutdown.rbs new file mode 100644 index 0000000..7bc614c --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/executor_suite/shutdown.rbs @@ -0,0 +1,36 @@ +# Dexpace::Conformance::ExecutorSuite::Shutdown is a private_constant (see shutdown.rb); declared +# because the conformance Steep target checks every file under lib/. +module Dexpace + module Conformance + module ExecutorSuite + module Shutdown + BLOCKED_WORKER_BOUND: Float + ROWS: Array[untyped] + ASSERTIONS: Array[Assertion] + + class BlockingTransport + @gate: Thread::Queue + @entered: Thread::Queue + @released: Thread::Queue + + attr_reader entered: Thread::Queue + attr_reader released: Thread::Queue + + def initialize: (gate: Thread::Queue) -> void + def call: (untyped request, untyped options, untyped cancellation) -> untyped + + class Closeable + def close: () -> nil + end + end + + def self?.default_close_is_a_no_op: (ExecutorCase subject) -> nil + def self?.one_shutdown_event: (ExecutorCase subject) -> nil + def self?.blocked_worker_is_released_on_cancel: (ExecutorCase subject) -> untyped + def self?.check_release: (untyped transport, untyped source) -> nil + def self?.probe!: () -> nil + def self?.dispatch: (untyped transport, untyped pool, untyped source) -> Thread + end + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_case.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_case.rbs new file mode 100644 index 0000000..2af60df --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_case.rbs @@ -0,0 +1,42 @@ +module Dexpace + module Conformance + # What an InvariantSuite assertion receives: the loaded core module and the driver's factories + # for what the suite cannot reach itself (design R3, P9-9). + class InvariantCase + @core: untyped + @seam: untyped + @transport: untyped + @mutable: Array[Symbol] + @shared: Array[untyped] + @bounded_map: untyped + @bounded_map_store: untyped + @cnonce: untyped + @redirect_hops: untyped + @credential_hop: untyped + + attr_reader core: untyped + attr_reader mutable: Array[Symbol] + + def initialize: (core: untyped, ?seam: untyped, ?transport: untyped, + ?mutable: Array[Symbol], ?shared: Array[untyped], ?bounded_map: untyped, + ?bounded_map_store: untyped, ?cnonce: untyped, ?redirect_hops: untyped, + ?credential_hop: untyped) -> void + def shared_instances: () -> Array[untyped] + def probe!: (Symbol name, String promised_by, ?method: bool) -> nil + def seam?: () -> bool + def seam: (?client: untyped) -> untyped + def transport?: () -> bool + def transport: () -> untyped + def bounded_map: (cap: Integer) -> untyped + def bounded_map_store: (untyped map) -> untyped + def cnonce?: () -> bool + def draw_cnonce: (untyped source) -> untyped + def redirect_hop: (from: String, to: String, headers: Hash[String, untyped]) -> untyped + def credential_hop: (url: String, ?cross_origin: bool) -> untyped + + private + + def required!: (untyped factory, String reason) -> untyped + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite.rbs new file mode 100644 index 0000000..d2bcbfd --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite.rbs @@ -0,0 +1,18 @@ +module Dexpace + module Conformance + # Appendix B.8: twenty-eight assertions over all twenty-four XCUT IDs (design P9-8). + module InvariantSuite + PREAMBLE: String + # A private_constant with no visibility in RBS; the eight groups, concatenated. + ASSERTIONS: Array[Assertion] + + def self?.assertions: () -> Array[Assertion] + def self?.run: (?core: untyped, ?seam: untyped, ?transport: untyped, ?mutable: Array[Symbol], + ?shared: Array[untyped], ?bounded_map: untyped, ?bounded_map_store: untyped, + ?cnonce: untyped, ?redirect_hops: untyped, ?credential_hop: untyped, + ?waive: Array[String], ?around: untyped, + ?accepted_vacuous: Hash[String, String], + ?would_fail: Array[String]) -> Report + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/classification.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/classification.rbs new file mode 100644 index 0000000..af1ed1e --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/classification.rbs @@ -0,0 +1,25 @@ +# Dexpace::Conformance::InvariantSuite::Classification is a private_constant (see +# classification.rb); declared because the conformance Steep target checks every file under lib/. +module Dexpace + module Conformance + module InvariantSuite + module Classification + CYCLE_NODES: Integer + MAX_STEPS: Integer + ROWS: Array[untyped] + ASSERTIONS: Array[Assertion] + + def self?.error_factory_refuses_non_error_status: (InvariantCase subject) -> nil + def self?.cycle_safe_cause_walk: (InvariantCase subject) -> nil + def self?.retry_safety_is_uniform: (InvariantCase subject) -> nil + def self?.cycle: () -> untyped + def self?.drive: (untyped enumerator) -> Array[untyped] + def self?.collect: (untyped enumerator, Array[untyped] walked) -> nil + def self?.response: (InvariantCase subject, Integer code) -> untyped + def self?.safety_request: (InvariantCase subject, String verb, untyped body) -> untyped + def self?.replayable_post: (InvariantCase subject) -> untyped + def self?.streaming_post: (InvariantCase subject) -> untyped + end + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/concurrency.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/concurrency.rbs new file mode 100644 index 0000000..961f83e --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/concurrency.rbs @@ -0,0 +1,21 @@ +# Dexpace::Conformance::InvariantSuite::Concurrency is a private_constant (see concurrency.rb); +# declared because the conformance Steep target checks every file under lib/. +module Dexpace + module Conformance + module InvariantSuite + module Concurrency + WAKE_BOUND_SECONDS: Float + WAIT_SECONDS: Float + ROWS: Array[untyped] + ASSERTIONS: Array[Assertion] + + def self?.cancellation_is_terminal: (InvariantCase subject) -> nil + def self?.timeout_is_retryable_and_distinct: (InvariantCase subject) -> nil + def self?.interruptible_waits: (InvariantCase subject) -> nil + def self?.wait_and_cancel: (InvariantCase subject, untyped source) -> [untyped, Float, bool] + def self?.parked?: (Thread thread, ?bound: Float) -> bool + def self?.wrap: (untyped inner) -> untyped + end + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/credentials.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/credentials.rbs new file mode 100644 index 0000000..e2c27cf --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/credentials.rbs @@ -0,0 +1,39 @@ +# Dexpace::Conformance::InvariantSuite::Credentials is a private_constant (see credentials.rb); +# declared because the conformance Steep target checks every file under lib/. +module Dexpace + module Conformance + module InvariantSuite + module Credentials + ORIGIN_SCOPED: Array[String] + SEED: String + ENTROPY_BYTES: Integer + KEYWORD_KINDS: Array[Symbol] + MAX_PROBED: Integer + ROWS: Array[untyped] + ASSERTIONS: Array[Assertion] + + def self?.redirect_credential_hygiene: (InvariantCase subject) -> nil + def self?.check_cross_origin: (InvariantCase subject) -> void + def self?.check_userinfo: (InvariantCase subject) -> nil + def self?.check_downgrade: (InvariantCase subject) -> nil + def self?.csprng_for_security_values: (InvariantCase subject) -> nil + def self?.injectable?: (untyped handler) -> bool + def self?.check_entropy: (InvariantCase subject) -> nil + + # `include ::Random::Formatter` is deliberately NOT declared: gates:rbs_surface refuses + # any constant outside Dexpace:: and its stdlib allowlist in a shipped signature, and + # widening that allowlist for a test double would be lowering a gate to make the tree + # pass. Nothing in this gem calls a Formatter method on a Recorder -- the SUBJECT does. + class Recorder + @flip: Integer? + @drawn: Integer + + attr_reader drawn: Integer + + def initialize: (?Integer? flip) -> void + def bytes: (Integer count) -> String + end + end + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/header_syntax.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/header_syntax.rbs new file mode 100644 index 0000000..8d37808 --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/header_syntax.rbs @@ -0,0 +1,23 @@ +# Dexpace::Conformance::InvariantSuite::HeaderSyntax is a private_constant (see header_syntax.rb); +# declared because the conformance Steep target checks every file under lib/. +module Dexpace + module Conformance + module InvariantSuite + module HeaderSyntax + NAME_REJECTS: Hash[String, String] + VALUE_REJECTS: Hash[String, String] + FORGED_NAME: String + ROWS: Array[untyped] + ASSERTIONS: Array[Assertion] + + def self?.header_syntax_validation: (InvariantCase subject) -> nil + def self?.forged_request_is_refused_at_dispatch: (InvariantCase subject) -> untyped + def self?.check_values: (untyped syntax) -> void + def self?.check_dispatch: (InvariantCase subject, untyped listener) -> nil + def self?.listen: () -> untyped + def self?.forge: (untyped core, String url) -> [untyped, String] + def self?.duck: (untyped verb, untyped url, untyped headers) -> untyped + end + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/memory.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/memory.rbs new file mode 100644 index 0000000..571d954 --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/memory.rbs @@ -0,0 +1,23 @@ +# Dexpace::Conformance::InvariantSuite::Memory is a private_constant (see memory.rb); declared +# because the conformance Steep target checks every file under lib/. +module Dexpace + module Conformance + module InvariantSuite + module Memory + CAP: Integer + OVERSHOOT: Integer + BURST: Integer + PREVIEW_BYTES: Integer + SNAPSHOT_CAP: Integer + PAYLOAD_BYTES: Integer + ROWS: Array[untyped] + ASSERTIONS: Array[Assertion] + + def self?.bounded_maps: (InvariantCase subject) -> nil + def self?.bounded_map_drains: (InvariantCase subject) -> nil + def self?.capped_non_consuming_preview: (InvariantCase subject) -> nil + def self?.payload: (InvariantCase subject) -> untyped + end + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/models.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/models.rbs new file mode 100644 index 0000000..5fdaf88 --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/models.rbs @@ -0,0 +1,28 @@ +# Dexpace::Conformance::InvariantSuite::Models is a private_constant and not public API: this +# declaration exists because the conformance Steep target checks every file under lib/. RBS has no +# visibility for a constant, so the privacy lives in models.rb alone. +module Dexpace + module Conformance + module InvariantSuite + module Models + CLOSE_BOUND_SECONDS: Float + ROWS: Array[untyped] + ASSERTIONS: Array[Assertion] + + def self?.immutable_models: (InvariantCase subject) -> nil + def self?.idempotent_close: (InvariantCase subject) -> nil + def self?.non_blocking_close: (InvariantCase subject) -> nil + def self?.only_closes_what_it_created: (InvariantCase subject) -> nil + + class Borrowed + @closed: bool + + def initialize: () -> void + def close: () -> bool + def closed?: () -> bool + def usable?: () -> bool + end + end + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/outcomes.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/outcomes.rbs new file mode 100644 index 0000000..88cd619 --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/outcomes.rbs @@ -0,0 +1,14 @@ +# Dexpace::Conformance::InvariantSuite::Outcomes is a private_constant (see outcomes.rb); declared +# because the conformance Steep target checks every file under lib/. +module Dexpace + module Conformance + module InvariantSuite + module Outcomes + def self?.raised_class: (untyped callable) -> Class? + def self?.raised_by: (untyped callable) -> StandardError? + def self?.refused?: (untyped callable) -> bool + def self?.outcome_of: (untyped callable) -> untyped + end + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/resolution.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/resolution.rbs new file mode 100644 index 0000000..a7d698d --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/resolution.rbs @@ -0,0 +1,33 @@ +# Dexpace::Conformance::InvariantSuite::Resolution is a private_constant (see resolution.rb); +# declared because the conformance Steep target checks every file under lib/. +module Dexpace + module Conformance + module InvariantSuite + module Resolution + RACERS: Integer + JOIN_SECONDS: Float + INSTALLER: String + ROWS: Array[untyped] + ASSERTIONS: Array[Assertion] + + def self?.single_flight_credential_cache: (InvariantCase subject) -> nil + def self?.seam_resolution_is_deterministic: (InvariantCase subject) -> nil + def self?.all_parked?: (Array[Thread] threads, ?bound: Float) -> bool + def self?.failure_of: (untyped registry) -> untyped + def self?.blank_request: (InvariantCase subject) -> untyped + + class CountingProvider + @token_type: untyped + @gate: Thread::Queue + @fetches: Integer + @lock: Thread::Mutex + + def initialize: (untyped token_type) -> void + def release: () -> void + def fetches: () -> Integer + def fetch: () -> untyped + end + end + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/security.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/security.rbs new file mode 100644 index 0000000..96a1eed --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/security.rbs @@ -0,0 +1,18 @@ +# Dexpace::Conformance::InvariantSuite::Security is a private_constant (see security.rb); declared +# because the conformance Steep target checks every file under lib/. +module Dexpace + module Conformance + module InvariantSuite + module Security + SECRET_URL: String + ROWS: Array[untyped] + ASSERTIONS: Array[Assertion] + + def self?.credentials_require_https: (InvariantCase subject) -> nil + def self?.redaction_is_default_deny: (InvariantCase subject) -> nil + def self?.observability_never_throws: (InvariantCase subject) -> nil + def self?.credential_reveals_nothing: (InvariantCase subject) -> nil + end + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/sharing.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/sharing.rbs new file mode 100644 index 0000000..ce33b9b --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/sharing.rbs @@ -0,0 +1,23 @@ +# Dexpace::Conformance::InvariantSuite::Sharing is a private_constant (see sharing.rb); declared +# because the conformance Steep target checks every file under lib/. +module Dexpace + module Conformance + module InvariantSuite + module Sharing + THREADS: Integer + FIBERS: Integer + RESUMES: Integer + ROWS: Array[untyped] + ASSERTIONS: Array[Assertion] + + def self?.shared_instances_are_concurrent_safe: (InvariantCase subject) -> nil + def self?.shared_instances_are_fiber_safe: (InvariantCase subject) -> nil + def self?.audit_declared: (InvariantCase subject) -> void + def self?.drive_threads: (untyped step) -> Array[untyped] + def self?.expected_requests: () -> Array[String] + def self?.fiber: (untyped step, Integer index, Array[untyped] seen) -> untyped + def self?.resume: (untyped one) -> void + end + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/taxonomy.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/taxonomy.rbs new file mode 100644 index 0000000..88eae9d --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/taxonomy.rbs @@ -0,0 +1,22 @@ +# Dexpace::Conformance::InvariantSuite::Taxonomy is a private_constant (see taxonomy.rb); declared +# because the conformance Steep target checks every file under lib/. +module Dexpace + module Conformance + module InvariantSuite + module Taxonomy + CLASSIFIER_CASES: Hash[Integer, bool] + WIDENED: Array[Integer] + NARROWED: Array[Integer] + DEFAULT_SET: Array[Integer] + ROWS: Array[untyped] + ASSERTIONS: Array[Assertion] + + def self?.two_branch_taxonomy: (InvariantCase subject) -> nil + def self?.one_shared_status_classifier: (InvariantCase subject) -> nil + def self?.open_capability_query: (InvariantCase subject) -> nil + def self?.configurable_status_set_is_authoritative: (InvariantCase subject) -> nil + def self?.declaring: (bool retryable) -> ::StandardError + end + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/levels.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/levels.rbs new file mode 100644 index 0000000..4b77032 --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/levels.rbs @@ -0,0 +1,13 @@ +module Dexpace + module Conformance + # Generated from appendix C by tools/requirement_levels.rb; what lets a Report tell a + # MUST-level vacuity from a SHOULD-level one (design R3). + module Levels + OF: Hash[String, Symbol] + + def self.known?: (String id) -> bool + def self.of: (String id) -> Symbol + def self.must?: (String id) -> bool + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/packaging_case.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/packaging_case.rbs new file mode 100644 index 0000000..c9410e6 --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/packaging_case.rbs @@ -0,0 +1,35 @@ +module Dexpace + module Conformance + # What a PackagingSuite assertion receives: gem NAMES resolved through RubyGems, which is + # what NFR-1's "published dependency metadata" names (design P9-2). + class PackagingCase + DEFAULT_RESOLVE: ^(String name) -> untyped + + @core_name: String + @adapter_names: Array[String] + @resolve: untyped + @constants: Hash[String, String] + @versions: Hash[String, String] + @sig_roots: Hash[String, String] + + attr_reader core_name: String + attr_reader adapter_names: Array[String] + attr_reader versions: Hash[String, String] + + def initialize: (core_name: String, adapter_names: Array[String], ?resolve: untyped, + ?constants: Hash[String, String], ?versions: Hash[String, String], + ?sig_roots: Hash[String, String]) -> void + def spec: (String name) -> untyped + def every_name: () -> Array[String] + def runtime_version: (String name) -> String + def shipped_rbs_without_header: () -> Array[String] + def sig_root: (String name) -> String? + + private + + def unheadered: (String? root) -> Array[String] + def resolve_constant: (String path) -> Module + def default_constant_path: (String name) -> String + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/packaging_suite.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/packaging_suite.rbs new file mode 100644 index 0000000..1533a04 --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/packaging_suite.rbs @@ -0,0 +1,17 @@ +module Dexpace + module Conformance + # Appendix B.9's portable half: the eight NFRs whose subject is a UNIT (design R2). + module PackagingSuite + PREAMBLE: String + # A private_constant with no visibility in RBS; the two groups, concatenated. + ASSERTIONS: Array[Assertion] + + def self?.assertions: () -> Array[Assertion] + def self?.run: (?core: String, ?adapters: Array[String], ?resolve: untyped, + ?constants: Hash[String, String], ?versions: Hash[String, String], + ?sig_roots: Hash[String, String], ?waive: Array[String], ?around: untyped, + ?accepted_vacuous: Hash[String, String], + ?would_fail: Array[String]) -> Report + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/packaging_suite/dependencies.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/packaging_suite/dependencies.rbs new file mode 100644 index 0000000..08dfed9 --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/packaging_suite/dependencies.rbs @@ -0,0 +1,20 @@ +# Dexpace::Conformance::PackagingSuite::Dependencies is a private_constant (see dependencies.rb); +# declared because the conformance Steep target checks every file under lib/. +module Dexpace + module Conformance + module PackagingSuite + module Dependencies + ROWS: Array[untyped] + ASSERTIONS: Array[Assertion] + + def self?.core_has_no_runtime_dependencies: (PackagingCase subject) -> nil + def self?.adapter_budget: (PackagingCase subject) -> nil + def self?.declared_runtime_floor: (PackagingCase subject) -> nil + def self?.check_isolation: (PackagingCase subject, Hash[String, untyped] floors) -> nil + def self?.single_version_source: (PackagingCase subject) -> nil + def self?.version_matches_the_gemspec: (PackagingCase subject) -> nil + def self?.declared?: (untyped floor) -> bool + end + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/packaging_suite/surface.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/packaging_suite/surface.rbs new file mode 100644 index 0000000..ce231c5 --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/packaging_suite/surface.rbs @@ -0,0 +1,24 @@ +# Dexpace::Conformance::PackagingSuite::Surface is a private_constant (see surface.rb); declared +# because the conformance Steep target checks every file under lib/. +module Dexpace + module Conformance + module PackagingSuite + module Surface + ASYNC_FRAMEWORKS: Array[String] + LEAK: Regexp + COMMENT: Regexp + ROWS: Array[untyped] + ASSERTIONS: Array[Assertion] + + def self?.explicit_typed_surface: (PackagingCase subject) -> nil + def self?.check_mirror: (PackagingCase subject, String name) -> nil + def self?.no_async_framework_type_in_the_core_surface: (PackagingCase subject) -> nil + def self?.spdx_header_coverage: (PackagingCase subject) -> nil + def self?.leaks_in: (String? root) -> Array[String]? + def self?.leaks_in_file: (String file) -> Array[String] + def self?.trees: (PackagingCase subject, String name) -> [Array[String]?, Array[String]?] + def self?.relative: (String root, String extension) -> Array[String]? + end + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/report.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/report.rbs index 72b29b7..428dbf2 100644 --- a/gems/dexpace-conformance/sig/dexpace/conformance/report.rbs +++ b/gems/dexpace-conformance/sig/dexpace/conformance/report.rbs @@ -1,27 +1,53 @@ module Dexpace module Conformance - # A frozen list of Results with the one renderer this phase ships; the waived IDs and the - # suite's preamble print on every run (design §9.3, P8-9). + # A frozen list of Results with the renderer 8a shipped and the structured form it deferred; + # the waived IDs, the suite's preamble and the MUST-level vacuity blockers print on every run + # (design §9.3, P8-9, R3). class Report @results: Array[Result] @preamble: String? + @accepted_vacuous: Hash[String, String] + @would_fail: Array[String] attr_reader results: Array[Result] attr_reader preamble: String? + attr_reader accepted_vacuous: Hash[String, String] + attr_reader would_fail: Array[String] - def initialize: (untyped results, ?preamble: String?) -> void + def self.merge: (Array[Report] reports, ?preamble: String?, + ?accepted_vacuous: Hash[String, String], + ?would_fail: Array[String]) -> Report + + def initialize: (untyped results, ?preamble: String?, + ?accepted_vacuous: Hash[String, String], + ?would_fail: Array[String]) -> void def passed?: () -> bool + def passed: () -> Array[Result] def failures: () -> Array[Result] def vacuous: () -> Array[Result] def waived: () -> Array[Result] def errors: () -> Array[Result] + def blocking_vacuities: () -> Array[Result] + def accepted_vacuities: () -> Array[Result] + def unknown_ids: () -> Array[String] def to_s: () -> String + def to_h: () -> Hash[Symbol, untyped] private + def validate_citation!: (String id, untyped citation) -> void def by_status: (Symbol status) -> Array[Result] + def must_level_vacuities: () -> Array[Result] + def must_level?: (Result result) -> bool + def accepted?: (Result result) -> bool + def citation_for: (Result result) -> String + def counts: () -> Hash[Symbol, Integer] def counts_line: () -> String def waived_lines: () -> Array[String] + def accepted_lines: () -> Array[String] + def vacuous_lines: () -> Array[String] + def blocking_lines: () -> Array[String] + def blocked_line: () -> String? def detail_lines: () -> Array[String] def ids: (Result result) -> String end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/runner.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/runner.rbs new file mode 100644 index 0000000..ab13167 --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/runner.rbs @@ -0,0 +1,19 @@ +module Dexpace + module Conformance + # The one loop every phase-9 suite's `.run` delegates to; the five statuses decided in one + # place (design P9-10). + module Runner + def self?.run: (Array[Assertion] assertions, ?waive: Array[String], ?around: untyped, + ?accepted_vacuous: Hash[String, String], ?would_fail: Array[String], + ?preamble: String?) { () -> untyped } -> Report + def self?.registry: (Module group, Array[untyped] rows) -> Array[Assertion] + + private + + def one: (Assertion assertion, Array[String] waive, untyped around, untyped subject) -> Result + def waived?: (Assertion assertion, Array[String] waive) -> bool + def detail_for: (Failure error) -> String + def invoke: (Assertion assertion, untyped around, untyped subject) -> untyped + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/shared_instance.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/shared_instance.rbs new file mode 100644 index 0000000..d534029 --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/shared_instance.rbs @@ -0,0 +1,9 @@ +module Dexpace + module Conformance + # XCUT-11's structural predicate (design R8, P9-9): frozen, or every ivar declared by the + # driver. + module SharedInstance + def self?.audit: (untyped object, ?mutable: Array[Symbol], ?ids: Array[String]) -> nil + end + end +end diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/transport_suite.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/transport_suite.rbs index 6cf9181..4a079df 100644 --- a/gems/dexpace-conformance/sig/dexpace/conformance/transport_suite.rbs +++ b/gems/dexpace-conformance/sig/dexpace/conformance/transport_suite.rbs @@ -10,7 +10,9 @@ module Dexpace def self?.assertions: () -> Array[Assertion] def self?.run: (build: untyped, ?borrow: untyped, ?waive: Array[String], ?around: untyped, - ?settle: untyped, ?wire: untyped, ?assertions: Array[Assertion]) -> Report + ?settle: untyped, ?wire: untyped, ?assertions: Array[Assertion], + ?accepted_vacuous: Hash[String, String], + ?would_fail: Array[String]) -> Report private diff --git a/gems/dexpace-conformance/test/dexpace/conformance_test.rb b/gems/dexpace-conformance/test/dexpace/conformance_test.rb index 1b5a512..3351f10 100644 --- a/gems/dexpace-conformance/test/dexpace/conformance_test.rb +++ b/gems/dexpace-conformance/test/dexpace/conformance_test.rb @@ -33,11 +33,22 @@ class ConformanceTest < DexpaceTestCase # Minitest driver, the two observability doubles and the borrowed pair. RSpecDriver is defined # by a file the entry point deliberately does NOT require (an RSpec-only consumer opts in), so # it is absent here and its own test loads it. - PUBLIC = %i[ + PHASE_8A = %i[ VERSION Failure Vacuous Assertion Result Report Scripts WireServer BorrowedPair TransportCase TransportSuite MinitestDriver RecordingSpan Allocations ].freeze + # Phase 9's additions: the shared instrument (the assertion primitive, the status loop, the + # generated requirement-level map and XCUT-11's structural predicate), the four suites it ships + # with the case each hands its assertions, and the one aggregate over every suite in a run. + PHASE_9 = %i[ + Check Runner Levels SharedInstance Aggregate + InvariantCase InvariantSuite PackagingCase PackagingSuite + CodecCase CodecSuite ExecutorCase ExecutorSuite + ].freeze + + PUBLIC = (PHASE_8A + PHASE_9).freeze + test "defines a semver VERSION string" do assert_match(/\A\d+\.\d+\.\d+\z/, Dexpace::Conformance::VERSION) end diff --git a/tasks/gates.rake b/tasks/gates.rake index 8b0cabf..a3b05b8 100644 --- a/tasks/gates.rake +++ b/tasks/gates.rake @@ -334,9 +334,65 @@ namespace :gates do found = SerdeBoundary.violations(root) abort(found.join("\n")) unless found.empty? - SerdeBoundary.pending(root).each do |glob, reason, matched| + pending = SerdeBoundary.pending(root) + pending.each do |glob, reason, matched| puts "gates:serde_boundary: PENDING #{glob} (#{matched} file(s) today) -- #{reason}" end - puts "gates:serde_boundary: #{SerdeBoundary::GUARDED.size} guarded globs clean." + # SSE-37's handed-forward clause: 7b asked for "the repository-wide check that its PENDING + # list is empty by the end of phase 7", which it could not assert while phase 7 was still + # running. Phase 9 asserts it. + abort(SerdeBoundary.pending_message(pending)) unless pending.empty? + puts "gates:serde_boundary: #{SerdeBoundary::GUARDED.size} guarded globs clean; the " \ + "pending list is empty (SSE-37)." + end + + # The three repository-wide invariant scans phase 9 adds (design R4). Each is a scan of THIS + # repository's source tree, which is why it is a gate and not a portable assertion: a scan of + # `gems/*/lib/` would be meaningless in a consumer's process. + # Dir.glob sorts its own result on every supported Ruby, so neither reader sorts again. + # + # The scan reads ABSOLUTE paths and the message is made relative afterwards. The other + # direction -- handing the scanner paths relative to `root` -- reads them relative to the + # process's CWD instead, which is the same directory only when DEXPACE_GATE_ROOT is unset; with + # a fixture root every open raised Errno::ENOENT, so the gate could never have been driven + # against the deliberately failing fixture this repository requires of every gate. Both + # allowlists are joined to the same root for the same reason: an entry that no longer matches a + # scanned path silences nothing and is invisible. + def invariant_files(root) = Dir.glob(File.join(root, "gems/*/lib/**/*.rb")) + def core_files(root) = Dir.glob(File.join(root, "gems/dexpace-core/lib/**/*.rb")) + def under(root, paths) = paths.map { |path| File.join(root, path) } + def relative_to(root, lines) = lines.map { |line| line.delete_prefix("#{root}/") } + + desc "XCUT-9: Dexpace.each_cause is the only walk of an error's cause chain" + task :cause_walk do + require_relative "../tools/invariant_gates" + root = gate_root + found = InvariantGates.cause_walk(invariant_files(root), + allowed: under(root, InvariantGates::CAUSE_WALK_ALLOWED),) + abort(relative_to(root, found).join("\n")) unless found.empty? + + puts "gates:cause_walk: #{invariant_files(root).size} files; one cause walk (XCUT-9)." + end + + desc "XCUT-14: only Dexpace::BoundedMap holds a caller- or server-keyed map" + task :bounded_map do + require_relative "../tools/invariant_gates" + root = gate_root + allowed = InvariantGates::BOUNDED_MAP_ALLOWED.transform_keys { |key| File.join(root, key) } + found = InvariantGates.bounded_map(invariant_files(root), allowed: allowed) + abort(relative_to(root, found).join("\n")) unless found.empty? + + puts "gates:bounded_map: #{invariant_files(root).size} files; " \ + "#{InvariantGates::BOUNDED_MAP_ALLOWED.size} adjudicated exceptions (XCUT-14)." + end + + desc "SEAM-2: core never names a concrete seam implementation" + task :seam_names do + require_relative "../tools/invariant_gates" + root = gate_root + found = InvariantGates.seam_names(core_files(root)) + abort(relative_to(root, found).join("\n")) unless found.empty? + + puts "gates:seam_names: #{core_files(root).size} core files name no adapter (SEAM-2)." end end diff --git a/test/fixtures/surface/dexpace-conformance.txt b/test/fixtures/surface/dexpace-conformance.txt index 5b17750..f87ad86 100644 --- a/test/fixtures/surface/dexpace-conformance.txt +++ b/test/fixtures/surface/dexpace-conformance.txt @@ -1,4 +1,9 @@ Dexpace::Conformance +Dexpace::Conformance::Aggregate +Dexpace::Conformance::Aggregate#by_requirement_id +Dexpace::Conformance::Aggregate#render +Dexpace::Conformance::Aggregate#run +Dexpace::Conformance::Aggregate::PREAMBLE : Array Dexpace::Conformance::Allocations Dexpace::Conformance::Allocations#delta Dexpace::Conformance::Allocations::ATTEMPTS : Integer @@ -13,15 +18,77 @@ Dexpace::Conformance::BorrowedPair#probe Dexpace::Conformance::BorrowedPair#still_usable? Dexpace::Conformance::BorrowedPair#transport Dexpace::Conformance::BorrowedPair.build +Dexpace::Conformance::Check +Dexpace::Conformance::Check#that +Dexpace::Conformance::CodecCase +Dexpace::Conformance::CodecCase#codec +Dexpace::Conformance::CodecCase#sink +Dexpace::Conformance::CodecCase#source +Dexpace::Conformance::CodecCase#witness +Dexpace::Conformance::CodecSuite +Dexpace::Conformance::CodecSuite#assertions +Dexpace::Conformance::CodecSuite#run +Dexpace::Conformance::CodecSuite::PREAMBLE : String +Dexpace::Conformance::ExecutorCase +Dexpace::Conformance::ExecutorCase#borrowed +Dexpace::Conformance::ExecutorCase#borrowed? +Dexpace::Conformance::ExecutorCase#events? +Dexpace::Conformance::ExecutorCase#executor +Dexpace::Conformance::ExecutorCase#functional +Dexpace::Conformance::ExecutorCase#functional? +Dexpace::Conformance::ExecutorCase#shutdowns +Dexpace::Conformance::ExecutorCase::THREADS : Integer +Dexpace::Conformance::ExecutorSuite +Dexpace::Conformance::ExecutorSuite#assertions +Dexpace::Conformance::ExecutorSuite#run +Dexpace::Conformance::ExecutorSuite::PREAMBLE : String Dexpace::Conformance::Failure Dexpace::Conformance::Failure#actual Dexpace::Conformance::Failure#expected Dexpace::Conformance::Failure#requirement_ids +Dexpace::Conformance::InvariantCase +Dexpace::Conformance::InvariantCase#bounded_map +Dexpace::Conformance::InvariantCase#bounded_map_store +Dexpace::Conformance::InvariantCase#cnonce? +Dexpace::Conformance::InvariantCase#core +Dexpace::Conformance::InvariantCase#credential_hop +Dexpace::Conformance::InvariantCase#draw_cnonce +Dexpace::Conformance::InvariantCase#mutable +Dexpace::Conformance::InvariantCase#probe! +Dexpace::Conformance::InvariantCase#redirect_hop +Dexpace::Conformance::InvariantCase#seam +Dexpace::Conformance::InvariantCase#seam? +Dexpace::Conformance::InvariantCase#shared_instances +Dexpace::Conformance::InvariantCase#transport +Dexpace::Conformance::InvariantCase#transport? +Dexpace::Conformance::InvariantSuite +Dexpace::Conformance::InvariantSuite#assertions +Dexpace::Conformance::InvariantSuite#run +Dexpace::Conformance::InvariantSuite::PREAMBLE : String +Dexpace::Conformance::Levels +Dexpace::Conformance::Levels.known? +Dexpace::Conformance::Levels.must? +Dexpace::Conformance::Levels.of +Dexpace::Conformance::Levels::OF : Hash Dexpace::Conformance::MinitestDriver Dexpace::Conformance::MinitestDriver#conformance Dexpace::Conformance::MinitestDriver.build_case Dexpace::Conformance::MinitestDriver.drive Dexpace::Conformance::MinitestDriver.method_name_for +Dexpace::Conformance::PackagingCase +Dexpace::Conformance::PackagingCase#adapter_names +Dexpace::Conformance::PackagingCase#core_name +Dexpace::Conformance::PackagingCase#every_name +Dexpace::Conformance::PackagingCase#runtime_version +Dexpace::Conformance::PackagingCase#shipped_rbs_without_header +Dexpace::Conformance::PackagingCase#sig_root +Dexpace::Conformance::PackagingCase#spec +Dexpace::Conformance::PackagingCase#versions +Dexpace::Conformance::PackagingCase::DEFAULT_RESOLVE : Proc +Dexpace::Conformance::PackagingSuite +Dexpace::Conformance::PackagingSuite#assertions +Dexpace::Conformance::PackagingSuite#run +Dexpace::Conformance::PackagingSuite::PREAMBLE : String Dexpace::Conformance::RecordingSpan Dexpace::Conformance::RecordingSpan#add_event Dexpace::Conformance::RecordingSpan#attributes @@ -36,20 +103,31 @@ Dexpace::Conformance::RecordingSpan#set_attribute Dexpace::Conformance::RecordingSpan#status Dexpace::Conformance::RecordingSpan#status= Dexpace::Conformance::Report +Dexpace::Conformance::Report#accepted_vacuities +Dexpace::Conformance::Report#accepted_vacuous +Dexpace::Conformance::Report#blocking_vacuities Dexpace::Conformance::Report#errors Dexpace::Conformance::Report#failures +Dexpace::Conformance::Report#passed Dexpace::Conformance::Report#passed? Dexpace::Conformance::Report#preamble Dexpace::Conformance::Report#results +Dexpace::Conformance::Report#to_h Dexpace::Conformance::Report#to_s +Dexpace::Conformance::Report#unknown_ids Dexpace::Conformance::Report#vacuous Dexpace::Conformance::Report#waived +Dexpace::Conformance::Report#would_fail +Dexpace::Conformance::Report.merge Dexpace::Conformance::Result Dexpace::Conformance::Result#assertion Dexpace::Conformance::Result#detail Dexpace::Conformance::Result#status Dexpace::Conformance::Result.build Dexpace::Conformance::Result::STATUSES : Array +Dexpace::Conformance::Runner +Dexpace::Conformance::Runner#registry +Dexpace::Conformance::Runner#run Dexpace::Conformance::Scripts Dexpace::Conformance::Scripts#dribble Dexpace::Conformance::Scripts#echo_path @@ -66,6 +144,8 @@ Dexpace::Conformance::Scripts#sequenced Dexpace::Conformance::Scripts#truncated Dexpace::Conformance::Scripts#vendor_status Dexpace::Conformance::Scripts#write_response +Dexpace::Conformance::SharedInstance +Dexpace::Conformance::SharedInstance#audit Dexpace::Conformance::TransportCase Dexpace::Conformance::TransportCase#borrowed_transport Dexpace::Conformance::TransportCase#request diff --git a/test/gates/default_task_test.rb b/test/gates/default_task_test.rb index 8feb434..e6cbe69 100644 --- a/test/gates/default_task_test.rb +++ b/test/gates/default_task_test.rb @@ -5,17 +5,35 @@ # NFR-17: the gates are blocking and automatic, and the default rake task is what makes them so. # The second case -- that every listed name is a real, separately invocable task -- was added -# once the last gate existed (plan Task 19): asserted earlier it would have left `rake -# test:gates` red for eighteen tasks, and a suite that is expected to be red is a suite nobody +# once the last gate existed (phase 0's plan Task 19): asserted earlier it would have left `rake +# test:gates` red for seventeen tasks, and a suite that is expected to be red is a suite nobody # reads. class DefaultTaskTest < GateCase + # Phase 0's seventeen, 7b's gates:serde_boundary and phase 9's three repository-wide invariant + # scans -- twenty-one. A gate outside DEFAULT_GATES is off `task default:` and therefore + # advisory, which NFR-17 forbids, so this list is what makes the count a fact. EXPECTED = %w[ rubocop cops:test rbs:validate steep test:gems test:gates gates:gemspec_audit - gates:require_allowlist gates:serde_boundary gates:clean_bundle gates:rbs_surface + gates:require_allowlist gates:serde_boundary gates:cause_walk gates:bounded_map + gates:seam_names gates:clean_bundle gates:rbs_surface gates:sig_diff gates:surface_snapshot gates:single_instance gates:versions gates:reproducible yard bundler_audit ].freeze + # The COUNT, asserted against the repository and not only against the literal above: the three + # tasks phase 9 added are gates because they are in `task default:`, and a count read off + # EXPECTED alone would still pass if all three left DEFAULT_GATES together. + test "there are twenty-one gates, and every one of them is blocking" do + prerequisites = `bundle exec rake -s -P`.split("\n").drop_while { |l| l != "rake default" } + .drop(1).take_while { |line| line.start_with?(" ") }.map(&:strip) + + assert_equal(21, EXPECTED.size) + assert_equal(21, prerequisites.size, "the default task's prerequisite count drifted") + %w[gates:cause_walk gates:bounded_map gates:seam_names].each do |gate| + assert_includes(prerequisites, gate, "#{gate} is not blocking") + end + end + test "the default task lists every gate, in the design's order" do listed = `bundle exec rake -s gates:list`.split("\n") diff --git a/tools/appendix_b.rb b/tools/appendix_b.rb new file mode 100644 index 0000000..d9525fa --- /dev/null +++ b/tools/appendix_b.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Two Markdown files compared: the specification's appendix B, and the coverage map beside the +# conformance gem. Plain text operations -- no AST, no gem. +# +# The map is phase 9's answer to "appendix B is phase 9's scope" without re-owning seven phases' +# work: one row per checklist item, naming the section, the item's requirement IDs, the suite or +# test file that covers it, and a status. A `by reference` row proves an ID is CLAIMED and a file +# EXISTS, never that the behaviour is tested (design P9-7), and the map says so in its own voice. +module AppendixB + # CLAUDE.md's nineteen prefixes, in appendix-C order. A bare /[A-Z]+-\d+/ matches `ISO-8601`, + # which appears in B.3's real text, and RFC-3986-shaped tokens elsewhere. + PREFIXES = %w[SEAM HTTP IO BODY CTX PIPE RECOV RETRY REDIR AUTH PAGE SSE SERDE OBS CFG + TRANSPORT ASYNC XCUT NFR].freeze + ID = /\b(?:#{PREFIXES.join("|")})-\d+\b/ + # `- [ ]` and never `- [`: an item ticked by hand would otherwise vanish from the count. + ITEM = "- [ ]" + SECTION = /^### (B\.\d)/ + # The statuses a row may carry. `suite` is an assertion in this gem; `by reference` is another + # gem's test file; `restated per §9.3` is an item the design restates; `scoped out` is an item + # or clause this port declines with its reason in the row. + STATUSES = ["suite", "by reference", "restated per §9.3", "scoped out", "unmapped"].freeze + + extend self + + # @param text [String] + # @return [Array] every requirement ID in it, deduplicated, in order + def ids_in(text) = text.scan(ID).uniq + + # @param path [String] the specification's appendix B + # @return [Integer] how many checklist items it holds + def spec_item_count(path) = ::File.readlines(path).count { |line| line.start_with?(ITEM) } + + # @param path [String] the specification's appendix B + # @return [Hash{String => Integer}] items per section + def spec_counts_by_section(path) + spec_items(path).each_with_object({}) do |(section, _, _), counts| + counts[section] = counts.fetch(section, 0) + 1 + end + end + + # `[[section, 1-based item number, item line]]` in the specification's order. + # + # @param path [String] + # @return [Array] + def spec_items(path) + section = nil + ::File.readlines(path).each_with_object([]) do |line, items| + if (match = SECTION.match(line)) + section = match[1] + elsif line.start_with?(ITEM) && section + items << [section, items.count { |(existing, _, _)| existing == section } + 1, line] + end + end + end + + # The map's own table: `| section | item | ids | evidence | status |`. + # + # @param path [String] the coverage map + # @return [Array] one row per line, parsed + def rows(path) + ::File.readlines(path).filter_map do |line| + next unless line.start_with?("| B.") + + cells = line.split("|").map(&:strip).reject(&:empty?) + next if cells.size < 5 + + { section: cells[0], item: cells[1], ids: ids_in(cells[2]), + evidence: evidence(cells[3]), status: cells[4], } + end + end + + # @param path [String] the coverage map + # @return [Hash{String => Integer}] rows per section + def counts_by_section(path) + rows(path).each_with_object({}) do |row, counts| + counts[row[:section]] = counts.fetch(row[:section], 0) + 1 + end + end + + # One row per specification item, in the specification's order, with the ID column parsed from + # the item's own text. An item's evidence and status are HAND-WRITTEN and carried over from the + # existing map; an item with neither is emitted `unmapped` with `unmapped` in the evidence + # column -- not a path, so the map's own evidence check names it until someone writes the row. + # + # @param spec_path [String] the specification's appendix B + # @param map_path [String] the coverage map, read BEFORE it is written + # @return [String] the whole map + def generate(spec_path, map_path) + hand = hand_written(map_path) + body = spec_items(spec_path).map do |(section, item, text)| + evidence, status = hand.fetch([section, item.to_s], %w[unmapped unmapped]) + "| #{section} | #{item} | #{ids_in(text).join(", ")} | #{evidence} | #{status} |" + end + "#{header(map_path)}#{body.join("\n")}\n" + end + + # The map's hand-written evidence and status per row, keyed by `[section, item]`. Read BEFORE + # the caller writes the file, which is why the CLI takes the map's PATH rather than redirecting + # stdout into it: a shell redirect truncates the file before the generator reads it. + def hand_written(path) + return {} unless ::File.exist?(path) + + rows(path).to_h do |row| + [[row[:section], row[:item]], [row[:evidence] || "--", row[:status]]] + end + end + + # Everything above the table, preserved verbatim from the existing map so the preamble is + # hand-written prose the generator never rewrites. + def header(path) + return DEFAULT_HEADER unless ::File.exist?(path) + + lines = ::File.readlines(path) + cut = lines.index { |line| line.start_with?("| B.") } + cut.nil? ? DEFAULT_HEADER : lines.take(cut).join + end + + DEFAULT_HEADER = <<~MARKDOWN + # Appendix B coverage map + + | Section | Item | IDs | Evidence | Status | + |---|---|---|---|---| + MARKDOWN + + private + + def evidence(cell) + stripped = cell.delete("`") + stripped == "--" ? nil : stripped + end +end + +if $PROGRAM_NAME == __FILE__ + spec = ARGV.fetch(0, "docs/product-spec/appendix-b-conformance-test-checklist.md") + map = ARGV.fetch(1, "gems/dexpace-conformance/APPENDIX_B.md") + File.write(map, AppendixB.generate(spec, map)) + puts "wrote #{map} (#{AppendixB.spec_item_count(spec)} rows)" +end diff --git a/tools/ast_scan.rb b/tools/ast_scan.rb new file mode 100644 index 0000000..321ddfa --- /dev/null +++ b/tools/ast_scan.rb @@ -0,0 +1,257 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The shared walker behind gates:cause_walk, gates:bounded_map and gates:seam_names. +# +# `RubyVM::AbstractSyntaxTree` rather than a regex, because `grep '\.cause'` matches a comment, a +# string, an `# XCUT-9` citation in a test header and the requirement ID itself. Verified present +# on 3.2.11, 3.3.12, 3.4.10 and 4.0.6. +# +# **Why not `prism`, since `tools/require_scan.rb` uses it and the bundle carries it on every +# row.** `prism` is a default gem only from 3.3 and the version the require gate was written +# against is the bundle's, not the interpreter's; `RubyVM::AbstractSyntaxTree` needs no gem at +# all, so these three gates run on any row with no bundle and no conditional require. That is the +# real reason, and it is not "consistency with the existing parsed scan" -- which, now that prism +# IS in the bundle on every row, would argue the other way. +# +# The PARSER emits no warning of its own for well-formed input; the SCANNED FILE's own `-w` +# diagnostics are a different thing and DO reach `Warning.warn`, which is load-bearing here. +# Every parse below goes through #parse, and nothing else in this file may call `parse_file`. +# rubocop:disable Metrics/ModuleLength -- one parsed-scan vocabulary over one parser. Every +# method here is knowledge about ONE thing, the shape of a `RubyVM::AbstractSyntaxTree` node on +# the four supported interpreters, and splitting it by line count would put half of that +# knowledge in a second file that has to be read with this one to mean anything. +module AstScan + # Node types a send can take. `:CALL` is `a.cause`, `:QCALL` is `a&.cause`, `:VCALL` is a bare + # `cause` and `:FCALL` is `cause()` or a receiverless `send(:cause)`. Measured identical on + # 3.2.11, 3.3.12, 3.4.10 and 4.0.6; a scan checking only `:CALL` misses every safe-navigated + # send. + SEND_TYPES = %i[CALL QCALL VCALL FCALL].freeze + + # A Symbol literal is a `:LIT` node on Ruby 3.2 and 3.3 and a `:SYM` node on 3.4 and 4.0. + # Measured on all four, and it is the reverse of the usual direction -- the NEWER interpreters + # diverge. A scan naming only `:LIT` caught 6 of 7 reflective shapes on the floor and 2 of 7 on + # the newer rows: strictest exactly where it runs least. + SYMBOL_TYPES = %i[LIT SYM].freeze + + # Sends that reach a method by NAME rather than by call syntax. All four bypass a scan that + # looks only at call syntax. + REFLECTIVE_SENDS = %i[send __send__ public_send method].freeze + + extend self + + # `parse_file` with the SCANNED FILE's own `-w` diagnostics suppressed, which is the whole + # reason this method exists rather than five direct `parse_file` calls. + # + # A file's `-w` diagnostics are emitted AT PARSE TIME and routed through `Warning.warn` exactly + # as they are at require time. Phase 0's shared test case prepends `FatalWarnings` to Warning's + # singleton class, and `test/gates/invariant_gates_test.rb` requires it, so a bare `parse_file` + # raises on the first such file. + # + # Which mechanism, measured on all four rows: `Warning[:deprecated] = false` does NOT reach it + # (the warning carries no category); `$VERBOSE = false` silences the `-w` class but not the + # always-on parse warnings, and `key :a is duplicated` still fires under it; only + # `$VERBOSE = nil` silences both. It is process-global, so the window is this one call and + # `ensure` closes it -- outside it, NFR-6's gate is armed exactly as before. + # + # @param path [String] + # @return [RubyVM::AbstractSyntaxTree::Node, nil] + def parse(path) + previous = $VERBOSE + $VERBOSE = nil + ::RubyVM::AbstractSyntaxTree.parse_file(path) + ensure + $VERBOSE = previous + end + + # Every send whose method name is in `names`, whatever the call syntax, plus every reflective + # send naming one of them as a Symbol literal, plus `&:name` block passes. + # + # @param path [String] + # @param names [Array] + # @return [Array] path, line, method name + def receiver_calls(path, names) + hits = [] #: Array[untyped] + walk(parse(path)) { |node| collect_call(node, names, path, hits) } + hits + end + + # Every instance-variable assignment whose value is a Hash by a statically decidable route: a + # literal `{}`, a `Hash.new` / `::Hash.new` call, a chained call on either + # (`{}.compare_by_identity`), or an `||=` of any of those. + # + # **Stated gap:** `@h = build_map`, `@h = OTHER.dup`, a Hash arriving through a parameter, a + # class variable, a bare constant, `instance_variable_set(:@h, {})` and a Hash held inside a + # value object rather than directly on the ivar are undecidable from one file and are NOT + # reported. The gate is a FLOOR on the invariant rather than proof of it. + # + # @param path [String] + # @return [Array] path, line, ivar name + def hash_ivar_assignments(path) + hits = [] #: Array[untyped] + walk(parse(path)) do |node| + next unless %i[IASGN OP_ASGN_OR].include?(node.type) + + name, value = ivar_assignment(node) + hits << [path, node.first_lineno, name] if !name.nil? && hash_valued?(value) + end + hits + end + + # Every constant path written in the file, rendered as `Dexpace::Serde::JSON`. + # + # @param path [String] + # @return [Array] path, line, constant path + def constant_paths(path) + hits = [] #: Array[untyped] + walk(parse(path)) do |node| + next unless %i[CONST COLON2 COLON3].include?(node.type) + + rendered = render_const(node) + hits << [path, node.first_lineno, rendered] unless rendered.nil? + end + hits + end + + # Every String and Symbol literal, so `const_get("Dexpace::Serde::JSON")` and + # `const_get(:"Dexpace::Serde::JSON")` are both reachable by the same match the constant scan + # uses. + # + # @param path [String] + # @return [Array] path, line, literal + def string_literals(path) + hits = [] #: Array[untyped] + walk(parse(path)) do |node| + text = literal_text(node) + hits << [path, node.first_lineno, text] unless text.nil? + end + hits + end + + def walk(node, &block) + return unless node.is_a?(::RubyVM::AbstractSyntaxTree::Node) + + yield(node) + node.children.each { |child| walk(child, &block) } + end + + private + + # The text of a String or Symbol literal node, nil for every other node. Both spellings answer + # here because `const_get("Dexpace::X")` and `const_get(:"Dexpace::X")` reach the same constant. + def literal_text(node) + return node.children.first if node.type == :STR + return nil unless SYMBOL_TYPES.include?(node.type) + + value = node.children.first + value.is_a?(::Symbol) ? value.to_s : nil + end + + def collect_call(node, names, path, hits) + if node.type == :BLOCK_PASS # `errors.map(&:cause)`: BLOCK_PASS(nil, SYM/LIT) + passed = symbol_value(node.children[1]) + hits << [path, node.first_lineno, passed] if names.include?(passed) + return + end + return unless SEND_TYPES.include?(node.type) + + record_send(node, names, path, hits) + end + + def record_send(node, names, path, hits) + called = method_name(node) + if names.include?(called) + # A walk sends #cause with NO argument; a send carrying one is some other #cause -- 5b's + # filed `Event#cause(error)` builder is the case that made this gate red over conforming + # code. + hits << [path, node.first_lineno, called] if arguments(node).nil? + return + end + return unless REFLECTIVE_SENDS.include?(called) + + reflected = named_argument(node) + hits << [path, node.first_lineno, reflected] if names.include?(reflected) + end + + # `:VCALL` and `:FCALL` carry the name first; `:CALL` and `:QCALL` carry the receiver first. + def method_name(node) = %i[VCALL FCALL].include?(node.type) ? node.children[0] : node.children[1] + + # The argument node, or nil when there is none (a `:VCALL` has no slot at all). + def arguments(node) + index = { CALL: 2, QCALL: 2, FCALL: 1 }[node.type] + index.nil? ? nil : node.children[index] + end + + # The SOLE argument of a send when it names a method: a Symbol literal OR a String literal, so + # `e.send("cause")` is caught beside `e.send(:cause)`. `nil` for anything dynamic -- + # `send(name)` where `name` is a variable is undecidable statically and is one of the shapes + # the stated gap covers. + def named_argument(node) + args = arguments(node) + return nil unless args.is_a?(::RubyVM::AbstractSyntaxTree::Node) && args.type == :LIST + + values = args.children.compact + values.size == 1 ? literal_name(values.first) : nil + end + + def literal_name(node) + return nil unless node.is_a?(::RubyVM::AbstractSyntaxTree::Node) + return node.children.first.to_sym if node.type == :STR && node.children.first.is_a?(::String) + + symbol_value(node) + end + + def symbol_value(node) + unless node.is_a?(::RubyVM::AbstractSyntaxTree::Node) && SYMBOL_TYPES.include?(node.type) + return nil + end + + value = node.children.first + value.is_a?(::Symbol) ? value : nil + end + + # `@h = …` is `:IASGN`; `@h ||= …` is `:OP_ASGN_OR` whose second child is the `:IASGN`. + def ivar_assignment(node) + return [node.children[0], node.children[1]] if node.type == :IASGN + + inner = node.children[1] + return [nil, nil] unless inner.is_a?(::RubyVM::AbstractSyntaxTree::Node) && inner.type == :IASGN + + [inner.children[0], inner.children[1]] + end + + def hash_valued?(node) + return false unless node.is_a?(::RubyVM::AbstractSyntaxTree::Node) + + case node.type + when :HASH then true + when :CALL, :QCALL + return true if node.children[1] == :new && const_name(node.children[0]) == :Hash + + hash_valued?(node.children[0]) + else false + end + end + + # `Hash` is a `:CONST` and `::Hash` is a `:COLON3`; a check naming only the first missed + # `@h = ::Hash.new` on every interpreter (measured). + def const_name(node) + return nil unless node.is_a?(::RubyVM::AbstractSyntaxTree::Node) + + %i[CONST COLON3].include?(node.type) ? node.children[0] : nil + end + + def render_const(node) + case node.type + when :CONST then node.children[0].to_s + when :COLON3 then "::#{node.children[0]}" + when :COLON2 + left = node.children[0] + return node.children[1].to_s if left.nil? + + inner = render_const(left) + inner.nil? ? nil : "#{inner}::#{node.children[1]}" + end + end +end +# rubocop:enable Metrics/ModuleLength diff --git a/tools/invariant_gates.rb b/tools/invariant_gates.rb new file mode 100644 index 0000000..4063540 --- /dev/null +++ b/tools/invariant_gates.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "ast_scan" + +# The repository-wide invariant scans (design R4). Each returns a list of offence strings; the +# Rake task prints them and exits non-zero when the list is non-empty. +# +# These are Rake GATES and not conformance assertions because their subject is THIS repository's +# source tree, which does not exist in a consumer's process. `dexpace-conformance` ships in `lib/` +# so a third party can run it against THEIR gem; a scan of `gems/*/lib/` would be meaningless +# there, and putting it in that gem would make it read a filesystem it does not own. +module InvariantGates + # XCUT-9: `Dexpace.each_cause` is the single cause walk (4b's hand-forward). + CAUSE_WALK_ALLOWED = [ + "gems/dexpace-core/lib/dexpace/each_cause.rb", # the single walk itself + ].freeze + + # SEAM-2, matched by the ADAPTER-OWNED LEAF namespace anywhere in the path. Measured twice + # during planning: a fixed list of fully qualified names caught 0 of 4 realistic shapes, and + # matching any path under a SEAM namespace (Serde, Transport, Async, Instrumentation) flagged 31 + # conforming references in 15 of core's own files -- `Async::Completer`, `Async::Future`, + # `Instrumentation::*` -- because core legitimately owns constants there. So the rule names the + # LEAF each MVP adapter gem owns (CLAUDE.md's gem table). + # + # **Stated gap:** a later adapter gem adds its leaf here or is invisible to this gate. + ADAPTER_NAMESPACES = [%w[Serde JSON], %w[Transport NetHTTP], %w[Transport AsyncHTTP], + %w[Async Thread],].freeze + + # XCUT-14: `Dexpace::BoundedMap` is the one bounded-keyed-map implementation (4a's + # hand-forward), so its own file is the first exclusion and every other entry is an ADJUDICATED + # false positive. A path => reason Hash, exactly as phase 0's require allowlist is, because an + # allowlist whose entries carry no argument is a list of things somebody once silenced. + # + # **Adjudicated 2026-09-23 against the tree AS BUILT, not against a filed plan fence.** With an + # empty allowlist the gate reports nine Hash ivars in nine files; six are here. XCUT-14 scopes + # itself to a "process/instance-lived map whose key space is influenced by callers or remote + # servers" and adds that "the cap is a memory backstop and MUST NOT be relied on as the + # primary cleanup mechanism". What every entry here has in common is that last clause: the + # map's primary cleanup is its OWNER being dropped after one operation, so a cap could never + # be the backstop. That property is a LIFETIME, and a lifetime is not decidable from one + # file -- which is why the allowlist is the mechanism and not a cleverer scan. + # + # The planning adjudication named `gems/dexpace-core/lib/dexpace/configuration.rb`, which holds + # no Hash ivar at all: 5a's two accumulators are in `configuration/builder.rb`. That entry + # silenced nothing and survived a whole adjudication, which is why the integrity test below + # asserts that every key still REPORTS at that path rather than only that the file exists. + BOUNDED_MAP_ALLOWED = { + "gems/dexpace-core/lib/dexpace/bounded_map.rb" => + "@h IS Dexpace::BoundedMap's own store -- the single implementation this gate exists to " \ + "keep single (4a's P4-3)", + "gems/dexpace-core/lib/dexpace/configuration/builder.rb" => + "@overrides and @properties are Configuration::Builder accumulators discarded at #build, " \ + "and the built Configuration is a frozen Data; the keys are the embedder's own CFG keys, " \ + "not caller or server input", + "gems/dexpace-core/lib/dexpace/instrumentation/event.rb" => + "@fields is ONE log event's field bag, allocated per call and dropped at #emit; its " \ + "lifetime is a single operation, so a cap could never be the memory backstop XCUT-14 " \ + "describes. The closest call of the five: http.response.header.* keys ARE " \ + "server-influenced, so it fails the key-space clause and passes on lifetime alone", + "gems/dexpace-core/lib/dexpace/auth/challenges.rb" => + "@params is ONE challenge parse's accumulator inside the private_constant Parser, reset at " \ + "every open_challenge/emit and dropped with the parse -- the same lifetime argument the " \ + "log event carries. The nonce COUNTER, which is caller- and server-keyed, is a real " \ + "BoundedMap in auth/digest_handler.rb", + "gems/dexpace-conformance/lib/dexpace/conformance/recording_span.rb" => + "@attributes is one RecordingSpan double's record, inert after #finish and dropped with " \ + "the span", + "gems/dexpace-transport-async_http/lib/dexpace/transport/async_http/clients.rb" => + "@by_key IS capped, and the scan cannot see a cap. VERIFIED by reading the file on " \ + "2026-09-23: Clients#fetch inserts with ||= and calls #drain inside the SAME " \ + "@mutex.synchronize as the insert, and #drain is a LOOP -- " \ + "`evicted << @by_key.delete(@by_key.keys.first) while @by_key.size > MAX_ORIGINS`, with " \ + "clients whose reactor has closed evicted first -- so a concurrent insert burst converges " \ + "to the bound instead of overshooting permanently, which is XCUT-14's drain clause " \ + "exactly; every evicted client's pool is retired outside the lock. Phase 9's planning " \ + "adjudicated this as the one TRUE positive of six, against 8c's filed plan fence rather " \ + "than its code, and 8c shipped the bound its Task 8 promised", + }.freeze + + extend self + + # @param files [Array] every file to scan + # @param allowed [Array] paths whose walk is the single walk itself + # @return [Array] one offence line per violation + def cause_walk(files, allowed: CAUSE_WALK_ALLOWED) + offences(files, allowed) do |path| + AstScan.receiver_calls(path, [:cause]).map do |(_, line, _)| + "#{path}:#{line}: walks #cause outside Dexpace.each_cause (XCUT-9)" + end + end + end + + # A Hash assigned to an instance variable is the shape a caller- or server-keyed map takes. + # + # @param files [Array] every file to scan + # @param allowed [Hash{String => String}] path => the reason it is not a caller-keyed map + # @return [Array] one offence line per violation + def bounded_map(files, allowed: BOUNDED_MAP_ALLOWED) + offences(files, allowed.keys) do |path| + AstScan.hash_ivar_assignments(path).map do |(_, line, ivar)| + "#{path}:#{line}: #{ivar} is a Hash on an instance; only Dexpace::BoundedMap may hold a " \ + "caller- or server-keyed map (XCUT-14)" + end + end + end + + # @param files [Array] core's own files + # @param namespaces [Array] each adapter's leaf pair + # @return [Array] one offence line per violation + def seam_names(files, namespaces: ADAPTER_NAMESPACES) + offences(files, []) do |path| + candidates = AstScan.constant_paths(path) + AstScan.string_literals(path) + candidates.filter_map do |(_, line, rendered)| + next unless concrete_seam?(rendered.to_s, namespaces) + + "#{path}:#{line}: core names the concrete seam implementation #{rendered} (SEAM-2)" + end + end + end + + # `Dexpace::Serde::JSON`, `::Dexpace::Serde::JSON`, bare `Serde::JSON` and + # `Dexpace::Serde::JSON::Codec` all contain an adapter's leaf pair as CONSECUTIVE segments; + # `Dexpace::Serde::Error`, `Dexpace::Async::Future` and `Instrumentation::Severity` contain + # none. + def concrete_seam?(rendered, namespaces) + rendered.delete_prefix("::").split("::").each_cons(2).any? { |pair| namespaces.include?(pair) } + end + + def offences(files, allowed, &) + (files - allowed).flat_map(&) + end +end diff --git a/tools/requirement_levels.rb b/tools/requirement_levels.rb new file mode 100644 index 0000000..ba5203f --- /dev/null +++ b/tools/requirement_levels.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Generates gems/dexpace-conformance/lib/dexpace/conformance/levels.rb from appendix C. +# +# Appendix C is normative and FROZEN (CLAUDE.md; Guard::FROZEN). This tool READS it and writes +# exactly one file, TARGET, which is not under docs/. There is no code path that writes SOURCE. +# +# A gem cannot read docs/ from a consumer's machine, so the map is committed inside the gem and +# test/gates/requirement_levels_test.rb regenerates it in memory and diffs it against the +# committed file -- the way the runtime surface snapshot is kept honest. `MUST NOT` maps to +# :must: it is a MUST with a negated predicate, and appendix C holds exactly one. +# +# ruby tools/requirement_levels.rb # regenerate levels.rb +# ruby tools/requirement_levels.rb --check # exit 1 if levels.rb is stale +require "pathname" + +# The appendix-C parser and the levels.rb renderer. +module RequirementLevels + ROOT = Pathname(__dir__).join("..").expand_path + SOURCE = ROOT.join("docs/product-spec/appendix-c-consolidated-normative-requirement-index.md") + TARGET = ROOT.join("gems/dexpace-conformance/lib/dexpace/conformance/levels.rb") + # CLAUDE.md's 19 prefixes, in appendix-C order. Restricting the regex is what keeps ISO-8601 and + # RFC-shaped tokens out of the map. + PREFIXES = %w[SEAM HTTP IO BODY CTX PIPE RECOV RETRY REDIR AUTH PAGE SSE SERDE OBS CFG + TRANSPORT ASYNC XCUT NFR].freeze + ROW = /\A\| ((?:#{PREFIXES.join("|")})-\d+) \| (MUST NOT|MUST|SHOULD|MAY) \|/ + LEVEL = { "MUST" => :must, "MUST NOT" => :must, "SHOULD" => :should, "MAY" => :may }.freeze + + extend self + + # @param source [Pathname] appendix C + # @return [Hash{String => Symbol}] every requirement ID's normative level + def parse(source = SOURCE) + source.each_line.filter_map { |line| (m = ROW.match(line)) && [m[1], LEVEL.fetch(m[2])] }.to_h + end + + # @param map [Hash{String => Symbol}] + # @return [String] the whole of levels.rb + def render(map) + rows = map.map { |id, level| " #{id.inspect} => #{level.inspect}," }.join("\n") + <<~RUBY + # frozen_string_literal: true + # SPDX-License-Identifier: MIT + + # GENERATED by tools/requirement_levels.rb from appendix C -- do not edit; re-run the tool. + module Dexpace + module Conformance + # The normative level of every requirement ID, so a Report can tell a MUST-level vacuity + # from a SHOULD-level one (design R3; NFR-17). A gem cannot read docs/ at runtime, so the + # map is committed here and test/gates/requirement_levels_test.rb diffs it against + # appendix C on every run. `MUST NOT` is :must -- a MUST with a negated predicate. + # + # rubocop:disable Metrics/ModuleLength, Metrics/CollectionLiteralLength -- a generated + # table of every requirement ID in the specification: the size IS the specification's, + # and "read the data from an external source" is what a gem cannot do, since docs/ is + # not on a consumer's machine. + module Levels + # Every requirement ID in appendix C mapped to its RFC 2119 level. + OF = { + #{rows} + }.freeze + + # @param id [String] a requirement ID + # @return [Boolean] true when appendix C holds it + def self.known?(id) = OF.key?(id) + + # @param id [String] a requirement ID + # @return [Symbol] :must, :should, :may, or :unknown for an ID appendix C does not hold + def self.of(id) = OF.fetch(id, :unknown) + + # @param id [String] a requirement ID + # @return [Boolean] true only for a MUST or a MUST NOT + def self.must?(id) = of(id) == :must + end + # rubocop:enable Metrics/ModuleLength, Metrics/CollectionLiteralLength + end + end + RUBY + end +end + +if $PROGRAM_NAME == __FILE__ + rendered = RequirementLevels.render(RequirementLevels.parse) + if ARGV.include?("--check") + # The optional path is what lets the gate's own test drive the FAILING case: with TARGET fixed + # the test could only ever assert exit 0, and a --check that always exited 0 would pass it -- + # measured, by mutation, during this phase. `--check ` compares the same render against + # a file the test wrote, so both exits are covered. + target = Pathname((ARGV - ["--check"]).first || RequirementLevels::TARGET) + exit(target.exist? && target.read == rendered ? 0 : 1) + else + RequirementLevels::TARGET.write(rendered) + puts "wrote #{RequirementLevels::TARGET} (#{RequirementLevels.parse.size} ids)" + end +end diff --git a/tools/serde_boundary.rb b/tools/serde_boundary.rb index d763bb9..0e73d3e 100644 --- a/tools/serde_boundary.rb +++ b/tools/serde_boundary.rb @@ -74,8 +74,27 @@ def violations(root) end # The PENDING rows with how many files each currently matches, for the task to print. - def pending(root) - PENDING.map { |glob, reason| [glob, reason, Dir.glob(File.join(root, glob)).size] } + # `list:` is a one-line widening phase 9 added so the gate's own test can drive a NON-empty + # pending list and watch the emptiness assertion fail: `PENDING` is a frozen constant and + # reading it directly gave the fixture nothing to inject. The default is that constant, so + # every caller outside the test is unchanged. + # + # @param root [String] the repository root, or a fixture standing in for it + # @param list [Array] glob and reason pairs + # @return [Array] glob, reason, how many files match today + def pending(root, list: PENDING) + list.map { |glob, reason| [glob, reason, Dir.glob(File.join(root, glob)).size] } + end + + # The abort message for a non-empty PENDING list. + # + # @param pending [Array] + # @return [String] + def pending_message(pending) + rows = pending.map { |glob, reason, _| " #{glob} -- #{reason}" } + "SerdeBoundary::PENDING is not empty, and SSE-37's handed-forward clause asks that it be " \ + "by the end of phase 7:\n#{rows.join("\n")}\nMove each row to GUARDED in the change " \ + "that lands its files, or delete it." end # One file's violations. A Ruby file that does not parse is a violation in itself: a file the From 1a6ad152d1b99f7653132b463f5eb4a79293d469 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 23 Sep 2026 23:48:17 +0300 Subject: [PATCH 02/17] fix: bound every wait in the new suites and raise NFR-14's vacuity Review round 0, R0-1 and R0-2. R0-1. Three waits in the phase-9 suites carried no bound, so a subject that never answers parked the whole run instead of failing an assertion. ExecutorSuite::Shutdown#check_release opened with an unbounded `transport.entered.pop`: an executor that REFUSES the post -- ASYNC-2's saturated queue, or a closed pool -- pushes nothing there and the poster rescues the refusal, so the run hung and the ensure that frees the gate and closes the pool was never reached. The entry pop now carries the assertion's own bound and an expired one is :vacuous with its reason: with no unit started there is no "blocking task on a worker thread", which is the antecedent ASYNC-3's sentence opens with, and a MUST-level vacuity blocks the report anyway. The two `.each(&:join)` sites take the same treatment against a subject whose submission or whose shared call never returns -- ExecutorSuite's concurrent_post and InvariantSuite's drive_threads -- each as a Failure naming the clause, with the whole thread set sharing one budget so sixteen stuck threads cost one bound and not sixteen. That is 8a's rule for this gem, which await_closed_connection's `timeout:` fixed: an adapter that never releases fails the assertion instead of hanging. R0-2. PackagingSuite's NFR-14 assertion documented "with no source supplied it is :vacuous with that reason, never a pass, because 'nobody told us' is not evidence of a single source" and did not implement it -- with no `versions:` every want was nil, every unit was skipped and the check passed having proved nothing. It now raises the vacuity the comment names, and the comment says what a PARTIAL source means too. --- .../conformance/executor_suite/lifecycle.rb | 33 +++++++++++++++++-- .../conformance/executor_suite/shutdown.rb | 15 ++++++++- .../conformance/invariant_suite/sharing.rb | 29 ++++++++++++++-- .../packaging_suite/dependencies.rb | 6 +++- .../conformance/executor_suite/lifecycle.rbs | 3 ++ .../conformance/invariant_suite/sharing.rbs | 2 ++ 6 files changed, 82 insertions(+), 6 deletions(-) diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/lifecycle.rb b/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/lifecycle.rb index 688b970..0a23270 100644 --- a/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/lifecycle.rb +++ b/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/lifecycle.rb @@ -15,6 +15,11 @@ module Lifecycle # How many units the graceful-shutdown assertion posts before it closes. DRAIN = 8 + # Seconds every poster has, in total, to return from `#post`. Submission is not the work: + # an executor whose `#post` blocks is the shape that would park this assertion for ever, + # so the join is bounded and an expired one is the failure (8a's "every wait carries a + # bound" rule, which `await_closed_connection` fixed for this gem). + POST_BOUND = 1.0 # SEAM-12 / XCUT-11: "invoke one shared instance from many threads and assert no # cross-talk." Sixteen threads each post a distinct value; the assertion is on the @@ -22,8 +27,10 @@ module Lifecycle def concurrent_post(subject) pool = subject.executor seen = ::Thread::Queue.new - ::Array.new(ExecutorCase::THREADS) { |i| ::Thread.new { pool.post { seen << i } } } - .each(&:join) + posters = ::Array.new(ExecutorCase::THREADS) do |index| + ::Thread.new { pool.post { seen << index } } + end + check_posters_returned(posters) pool.close collected = drain(seen) @@ -33,6 +40,28 @@ def concurrent_post(subject) ids: %w[SEAM-12 XCUT-11],) end + # The bounded half of `concurrent_post`, kept apart so the assertion reads as one thought. + # A poster still running when the budget is spent has a `#post` that blocks, which is a + # non-conformance to report and not a run to hang; the whole set shares ONE budget, so + # sixteen stuck posters cost one bound and not sixteen. + # @return [nil] + def check_posters_returned(posters) + stuck = still_running(posters, POST_BOUND) + + Check.that(stuck.zero?, "posting work from many threads did not return", + expected: "every #post returns", actual: "#{stuck} still posting", + ids: %w[SEAM-12 XCUT-11],) + end + + # @return [Integer] how many of the threads were still alive when the budget ran out + def still_running(threads, budget) + deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + budget + threads.count do |one| + left = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + one.join(left.positive? ? left : 0).nil? + end + end + # XCUT-13 / ASYNC-15 clauses (a) and (b): close is latched and ownership-aware. Clause # (c), interrupt-safety, is SCOPED OUT with its reason -- §8.3 bans every primitive that # could arrange a pending interrupt, so the clause holds by the flag never being touched diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/shutdown.rb b/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/shutdown.rb index da2b926..ccb58b3 100644 --- a/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/shutdown.rb +++ b/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/shutdown.rb @@ -83,9 +83,22 @@ def blocked_worker_is_released_on_cancel(subject) end # The bounded wait ASYNC-3 names, observed through the poster rather than slept on. + # + # BOTH pops carry the bound, and the ENTRY one is the load-bearing addition. An executor + # that REFUSES the post -- ASYNC-2's saturated queue, or a closed pool -- pushes nothing + # onto `entered` and the poster rescues the refusal, so an unbounded pop there parks the + # whole run for ever. An expired entry pop is :vacuous and not :failed: with no unit + # started there is no "blocking task on a worker thread", which is the antecedent + # ASYNC-3's own sentence opens with, and a MUST-level vacuity blocks the report anyway. + # 8a fixed the rule for this gem with `await_closed_connection`'s `timeout:` -- every wait + # carries a bound, so a non-conforming subject fails the assertion instead of hanging. # @return [nil] def check_release(transport, source) - transport.entered.pop + if transport.entered.pop(timeout: BLOCKED_WORKER_BOUND).nil? + raise Vacuous, "the executor did not start the posted unit within " \ + "#{BLOCKED_WORKER_BOUND}s, so no worker was ever blocked to cancel" + end + source.cancel(:interrupt_requested) freed = transport.released.pop(timeout: BLOCKED_WORKER_BOUND) diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/sharing.rb b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/sharing.rb index 2265156..d159865 100644 --- a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/sharing.rb +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/sharing.rb @@ -20,6 +20,12 @@ module Sharing FIBERS = 2 # Enough resumes for both fibers to finish, and a bound if one of them never does. RESUMES = 4 + # Seconds every concurrent call has, IN TOTAL, to return. A shared instance that holds a + # lock across the call returns on one thread and never on the other fifteen, so the join + # is bounded and an expired one is the failure. 8a fixed the rule for this gem with + # `await_closed_connection`'s `timeout:`: a non-conforming subject fails the assertion + # instead of hanging the run. + CALL_BOUND = 1.0 # XCUT-11, clause 1: the structural predicate over every shared instance the DRIVER # declares (design R8, P9-9), plus the requirement's own conformance shape -- "invoke one @@ -73,13 +79,32 @@ def audit_declared(subject) # @return [Array] the requests the one shared step saw, sorted def drive_threads(step) results = ::Thread::Queue.new - ::Array.new(THREADS) { |i| ::Thread.new { results << step.call("request-#{i}") } } - .each(&:join) + callers = ::Array.new(THREADS) do |index| + ::Thread.new { results << step.call("request-#{index}") } + end + check_all_returned(callers) collected = [] #: Array[untyped] collected << results.pop until results.empty? collected.map(&:first).sort end + # The bounded half of `drive_threads`, kept apart so the assertion reads as one thought. + # The whole set shares ONE budget, so sixteen stuck callers cost one bound and not sixteen. + # @return [nil] + def check_all_returned(callers) + deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + CALL_BOUND + stuck = callers.count do |one| + left = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + one.join(left.positive? ? left : 0).nil? + end + + Check.that(stuck.zero?, + "a shared instance did not return on every thread; it serialises or holds " \ + "a lock across the call", + expected: "#{THREADS} concurrent calls return", + actual: "#{stuck} still running", ids: ["XCUT-11"],) + end + # @return [Array] what a step with no cross-talk must have seen def expected_requests ::Array.new(THREADS) { |index| "request-#{index}" }.sort diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb b/gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb index 9b4fb53..1c334ad 100644 --- a/gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb +++ b/gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb @@ -78,9 +78,13 @@ def check_isolation(subject, floors) # The source is the driver's to name -- a porter's is not this repository's `VERSIONS` # file -- so the assertion is that every unit's PUBLISHED version equals the one that # source states for it. With no source supplied it is :vacuous with that reason, never a - # pass, because "nobody told us" is not evidence of a single source. + # pass, because "nobody told us" is not evidence of a single source. A source naming SOME + # units states nothing about the rest, so those are skipped rather than failed -- the + # declaration is the driver's, and this assertion checks it rather than inventing one. def single_version_source(subject) stated = subject.versions + raise Vacuous, "no single source of truth was named for any unit" if stated.empty? + mismatched = subject.every_name.filter_map do |name| published = subject.spec(name).version.to_s want = stated[name] diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/executor_suite/lifecycle.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/executor_suite/lifecycle.rbs index 1bc5492..9b9efbf 100644 --- a/gems/dexpace-conformance/sig/dexpace/conformance/executor_suite/lifecycle.rbs +++ b/gems/dexpace-conformance/sig/dexpace/conformance/executor_suite/lifecycle.rbs @@ -5,10 +5,13 @@ module Dexpace module ExecutorSuite module Lifecycle DRAIN: Integer + POST_BOUND: Float ROWS: Array[untyped] ASSERTIONS: Array[Assertion] def self?.concurrent_post: (ExecutorCase subject) -> nil + def self?.check_posters_returned: (Array[Thread] posters) -> nil + def self?.still_running: (Array[Thread] threads, Float budget) -> Integer def self?.close_is_latched: (ExecutorCase subject) -> nil def self?.borrowed_executor_survives: (ExecutorCase subject) -> nil def self?.check_no_shutdown: (ExecutorCase subject) -> nil diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/sharing.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/sharing.rbs index ce33b9b..e32a7ac 100644 --- a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/sharing.rbs +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/sharing.rbs @@ -7,6 +7,7 @@ module Dexpace THREADS: Integer FIBERS: Integer RESUMES: Integer + CALL_BOUND: Float ROWS: Array[untyped] ASSERTIONS: Array[Assertion] @@ -14,6 +15,7 @@ module Dexpace def self?.shared_instances_are_fiber_safe: (InvariantCase subject) -> nil def self?.audit_declared: (InvariantCase subject) -> void def self?.drive_threads: (untyped step) -> Array[untyped] + def self?.check_all_returned: (Array[Thread] callers) -> nil def self?.expected_requests: () -> Array[String] def self?.fiber: (untyped step, Integer index, Array[untyped] seen) -> untyped def self?.resume: (untyped one) -> void From 7721e1b43b82abf0aa28a09306bb1e01e28868b6 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 23 Sep 2026 23:54:07 +0300 Subject: [PATCH 03/17] fix: PackagingCase's default resolve never caught RubyGems' own error Found by writing review round 0's R0-4 test, which drives the shipped DEFAULT_RESOLVE for the first time: every case in packaging_suite_test.rb supplies its own `resolve:`, so the lambda a real driver gets had never run. RubyGems raises Gem::MissingSpecError for a name it cannot resolve, and that descends from Gem::LoadError < LoadError < ScriptError -- it is not a StandardError. So `rescue ::StandardError` let it past, and it escaped Runner's own bare rescue too, aborting the whole run where the suite's contract is one :vacuous result naming the absent unit. Measured on 4.0.6: a Suite.run naming an uninstalled gem errored out of the first assertion instead of reporting eight vacuities. The rescue now names Gem::LoadError beside StandardError, and the comment says why the explicit class is load-bearing. --- .../lib/dexpace/conformance/packaging_case.rb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/packaging_case.rb b/gems/dexpace-conformance/lib/dexpace/conformance/packaging_case.rb index 883fd8b..5178ea9 100644 --- a/gems/dexpace-conformance/lib/dexpace/conformance/packaging_case.rb +++ b/gems/dexpace-conformance/lib/dexpace/conformance/packaging_case.rb @@ -26,9 +26,16 @@ module Conformance # dexpace-core and nothing else. class PackagingCase # Resolves a gem name to its installed specification, or nil when it is not installed. + # + # `Gem::LoadError` is named explicitly and is the WHOLE point of the rescue: RubyGems raises + # `Gem::MissingSpecError` for a name it cannot resolve, and that descends from `Gem::LoadError + # < LoadError < ScriptError`, so it is NOT a `StandardError` and a bare `rescue + # ::StandardError` lets it past. It then escapes `Runner`'s own bare rescue too and aborts + # the whole run, where the suite's contract is one :vacuous result naming the absent unit. + # Measured 2026-09-23 on 4.0.6; the case is now driven in packaging_suite_test.rb. DEFAULT_RESOLVE = lambda do |name| ::Gem::Specification.find_by_name(name) - rescue ::StandardError + rescue ::Gem::LoadError, ::StandardError nil end From dd03c350e6ba0883f364e95524430de1de6b4139 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 24 Sep 2026 11:37:30 +0300 Subject: [PATCH 04/17] fix: make XCUT-22's second Check falsifiable with a second teardown route InvariantSuite::Models#only_closes_what_it_created makes two Checks against one caller-supplied resource: it was not closed, and it still works. `Borrowed#usable?` was `!@closed`, the exact negation of the `closed?` the first Check already reads, so no subject the suite can build separated the two and replacing the second Check's condition with a literal `true` left the whole gem's suite green. The code comment and the phase checklist both claimed the opposite -- that a component tearing the resource down another way would pass the first alone. `Borrowed` now carries that other way. `#finish` is the adapter-side teardown reached without going through `#close` -- `Net::HTTP` spells it exactly that, a pooled client spells it "retire" -- and `usable?` reads both flags, so a holder that shuts a borrowed resource down without calling `#close` passes the first Check and fails the second. The comment is rewritten to say what the double now does and how the gap was measured. --- .../conformance/invariant_suite/models.rb | 36 +++++++++++++++---- .../conformance/invariant_suite/models.rbs | 2 ++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/models.rb b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/models.rb index 67999fd..842e324 100644 --- a/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/models.rb +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/models.rb @@ -63,9 +63,12 @@ def non_blocking_close(subject) end # XCUT-22: "build a transport around a caller-supplied client, close the transport, then - # reuse the client -> it still works." Both halves: the resource is not closed, AND it is - # still usable, because a component that merely forgot to call #close while tearing the - # resource down another way would pass the first alone. + # reuse the client -> it still works." Both halves, and the second is INDEPENDENT of the + # first: `Borrowed` is torn down by `#finish` as well as by `#close`, so a component that + # never called `#close` while shutting the resource down another way passes the first Check + # and fails the second. A double whose `usable?` were merely the negation of its `closed?` + # would make this Check unfalsifiable -- measured, in review round 2, by replacing its + # condition with a literal `true` and finding the whole suite still green. def only_closes_what_it_created(subject) borrowed = Borrowed.new seam = subject.seam(client: borrowed) @@ -79,13 +82,32 @@ def only_closes_what_it_created(subject) end # The caller-supplied resource XCUT-22 is about, with the two observations the requirement - # names: it was not closed, and it still works. + # names on two INDEPENDENT routes: it was not closed, and it still works. + # + # `#finish` is the second route, and it is what makes the second observation worth making: + # a holder can shut a borrowed resource down without ever calling `#close` -- `Net::HTTP` + # spells that teardown `#finish`, a pooled client spells it "retire", and an adapter that + # tore down a caller's SOCKET rather than the caller's client would leave `closed?` false. + # Deriving `usable?` from `@closed` alone would make the second Check the negation of the + # first and it could then never fail on its own. class Borrowed - def initialize = (@closed = false) - # @return [Boolean] the latch this double flips, which is the whole observation + def initialize + @closed = false + @torn = false + end + + # @return [Boolean] the latch a holder flips when it closes what it borrowed def close = (@closed = true) def closed? = @closed - def usable? = !@closed + + # The adapter-side teardown reached WITHOUT going through `#close`. + # @return [Boolean] + def finish = (@torn = true) + + # "then reuse the client -> it still works", asked of the resource rather than derived + # from the latch the first Check already reads. + # @return [Boolean] + def usable? = !(@closed || @torn) end private_constant :Borrowed diff --git a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/models.rbs b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/models.rbs index 5fdaf88..0b033e9 100644 --- a/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/models.rbs +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/models.rbs @@ -16,10 +16,12 @@ module Dexpace class Borrowed @closed: bool + @torn: bool def initialize: () -> void def close: () -> bool def closed?: () -> bool + def finish: () -> bool def usable?: () -> bool end end From 94cd017062550a6e7040773a00baf607e42c074e Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 23 Sep 2026 21:29:18 +0300 Subject: [PATCH 05/17] test: the suites' own suites, the three drivers and the gate fixtures Every assertion is driven against a deliberately non-conforming double before a conforming one makes it pass, which is what proves it can fail at all; the four suites' own suites are that, plus the runner's status loop, the report's blocker, the predicate's disjunction and the aggregate's declaration-keyed map. Three first-party drivers, one per suite with a subject in this repository: core's for InvariantSuite (fifteen declared shared instances and the six factories the suite cannot build), dexpace-serde-json's for CodecSuite, and dexpace-async-thread's for ExecutorSuite, which waives ASYNC-3 by id and then runs it unwaived in a second test that requires the failure, so the waiver cannot outlive the limitation it records. The three new gates get a fixture workspace under DEXPACE_GATE_ROOT and gates:serde_boundary's abort branch a RUBYOPT prelude, because neither had a failing-fixture route at all -- and building the first found that the three gates opened every file relative to the process's CWD. --- .../dexpace/async/thread/conformance_test.rb | 142 ++++++++ .../dexpace/conformance/aggregate_test.rb | 105 ++++++ .../dexpace/conformance/codec_suite_test.rb | 142 ++++++++ .../conformance/executor_suite_test.rb | 217 ++++++++++++ .../conformance/invariant_suite_core_test.rb | 330 ++++++++++++++++++ .../conformance/invariant_suite_test.rb | 327 +++++++++++++++++ .../conformance/packaging_suite_test.rb | 251 +++++++++++++ .../dexpace/conformance/report_phase9_test.rb | 130 +++++++ .../test/dexpace/conformance/runner_test.rb | 94 +++++ .../conformance/shared_instance_test.rb | 86 +++++ .../dexpace/cross_cutting_invariants_test.rb | 266 ++++++++++++++ .../dexpace/serde/json/conformance_test.rb | 85 +++++ .../gates/invariants/delegates_cause.rb | 16 + test/fixtures/gates/invariants/hash_shapes.rb | 24 ++ .../gates/invariants/reflective_cause.rb | 15 + test/fixtures/gates/invariants/seam_shapes.rb | 20 ++ test/fixtures/gates/invariants/walks_cause.rb | 16 + .../fixtures/gates/invariants/warns_unused.rb | 17 + .../dexpace-core/lib/dexpace/each_cause.rb | 13 + .../gems/dexpace-core/lib/dexpace/offender.rb | 17 + .../dexpace-probe/lib/dexpace/probe/cache.rb | 20 ++ .../gates/serde_boundary/pending_probe.rb | 23 ++ test/gates/appendix_b_test.rb | 80 +++++ test/gates/invariant_gates_test.rb | 197 +++++++++++ test/gates/phase9_ruby_facts_test.rb | 181 ++++++++++ test/gates/requirement_levels_test.rb | 81 +++++ test/gates/serde_boundary_pending_test.rb | 55 +++ test/support/gate_warning_capture.rb | 54 +++ 28 files changed, 3004 insertions(+) create mode 100644 gems/dexpace-async-thread/test/dexpace/async/thread/conformance_test.rb create mode 100644 gems/dexpace-conformance/test/dexpace/conformance/aggregate_test.rb create mode 100644 gems/dexpace-conformance/test/dexpace/conformance/codec_suite_test.rb create mode 100644 gems/dexpace-conformance/test/dexpace/conformance/executor_suite_test.rb create mode 100644 gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_core_test.rb create mode 100644 gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_test.rb create mode 100644 gems/dexpace-conformance/test/dexpace/conformance/packaging_suite_test.rb create mode 100644 gems/dexpace-conformance/test/dexpace/conformance/report_phase9_test.rb create mode 100644 gems/dexpace-conformance/test/dexpace/conformance/runner_test.rb create mode 100644 gems/dexpace-conformance/test/dexpace/conformance/shared_instance_test.rb create mode 100644 gems/dexpace-core/test/dexpace/cross_cutting_invariants_test.rb create mode 100644 gems/dexpace-serde-json/test/dexpace/serde/json/conformance_test.rb create mode 100644 test/fixtures/gates/invariants/delegates_cause.rb create mode 100644 test/fixtures/gates/invariants/hash_shapes.rb create mode 100644 test/fixtures/gates/invariants/reflective_cause.rb create mode 100644 test/fixtures/gates/invariants/seam_shapes.rb create mode 100644 test/fixtures/gates/invariants/walks_cause.rb create mode 100644 test/fixtures/gates/invariants/warns_unused.rb create mode 100644 test/fixtures/gates/invariants/workspace/gems/dexpace-core/lib/dexpace/each_cause.rb create mode 100644 test/fixtures/gates/invariants/workspace/gems/dexpace-core/lib/dexpace/offender.rb create mode 100644 test/fixtures/gates/invariants/workspace/gems/dexpace-probe/lib/dexpace/probe/cache.rb create mode 100644 test/fixtures/gates/serde_boundary/pending_probe.rb create mode 100644 test/gates/appendix_b_test.rb create mode 100644 test/gates/invariant_gates_test.rb create mode 100644 test/gates/phase9_ruby_facts_test.rb create mode 100644 test/gates/requirement_levels_test.rb create mode 100644 test/gates/serde_boundary_pending_test.rb create mode 100644 test/support/gate_warning_capture.rb diff --git a/gems/dexpace-async-thread/test/dexpace/async/thread/conformance_test.rb b/gems/dexpace-async-thread/test/dexpace/async/thread/conformance_test.rb new file mode 100644 index 0000000..e16300d --- /dev/null +++ b/gems/dexpace-async-thread/test/dexpace/async/thread/conformance_test.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# SEAM-12, SEAM-25, XCUT-11, XCUT-13, XCUT-22, ASYNC-3, ASYNC-15, ASYNC-16, ASYNC-17. The +# first-party driver for dexpace-conformance's ExecutorSuite (phase 9's plan Task 11), in the +# adapter gem's own test tree following 8a's placement. +# +# Three of the four factories are real SDK objects and none is a stand-in for one: +# +# * `build:` is `Pool.build`, wired to the case's own recorder through a real +# `Instrumentation::Logger`, because SEAM-25's event is the ONLY channel a filed executor +# exposes a shutdown on -- without `record_events: true` an unlatched pool passes XCUT-13. +# * `borrow:` is core's `Transport.async_over` bridge, which is Closeable with `owned: false`: +# the SDK object that takes a caller's executor and must never shut it down (XCUT-22). +# * `functional:` is that same bridge over an INLINE executor -- a lightweight async transport +# that manages no lifecycle at all, which is exactly ASYNC-17's subject. The pool is not, and +# handing the pool there would assert the opposite requirement. +# +# ASYNC-3 is WAIVED BY ID and declared `would_fail:`. It is an unsatisfied MUST (design §10.5): +# a worker blocked in an uninterruptible call cannot be released, because §8.3 bans Thread#raise, +# Thread#kill and Timeout.timeout outright. The assertion is written to FAIL rather than to be +# absent, and the waiver prints `waived (would fail): ASYNC-3` -- never passed and never vacuous. +# `assertion_would_fail` below runs it UNWAIVED and asserts it really does fail, so the waiver +# cannot outlive the limitation it records. +require_relative "../../../test_helper" +require "dexpace/async/thread" +require "dexpace/conformance" + +# The pool's run of the shared executor suite: one generated test per assertion, plus the +# report-level checks the waiver rests on. +class DexpaceAsyncThreadConformanceTest < DexpaceTestCase + Conformance = Dexpace::Conformance + SUITE = Conformance::ExecutorSuite + Pool = Dexpace::Async::Thread::Pool + + # Four workers: enough for SEAM-12's sixteen concurrent posts to interleave, small enough that a + # leaked pool is obvious in the thread count DexpaceTestCase's teardown takes. + SIZE = 4 + # The one unsatisfied MUST this suite carries, waived by ID in the first-party build. + UNSATISFIED = "ASYNC-3" + + # A resource-free executor: it runs the unit on the caller's thread and owns nothing, so the + # bridge over it is the lightweight implementation ASYNC-17 says needs no lifecycle management. + class PoolInlineExecutor + # @yield the unit of work, run inline on the caller's own thread + # @return [nil] + def post + yield + nil + end + end + + # A transport the bridge will accept; nothing in ASYNC-17 reaches it, and it is never dispatched. + class PoolIdleTransport + # @return [nil] the transport seam's three positionals, so Registry.callable? holds + def call(_request, _options, _cancellation) = nil + end + + SUITE.assertions.each_with_index do |assertion, index| + define_method(format("test_%02d_%s", n: index, + name: assertion.name.gsub(/\W+/, "_"),)) do + drive(assertion) + end + end + + test "the suite reports no failure, no error and no unaccepted MUST-level vacuity" do + report = SUITE.run(**suite_arguments) + + assert_empty(report.failures.map { |result| described(result) }) + assert_empty(report.errors.map { |result| described(result) }) + assert_empty(report.blocking_vacuities.map { |result| result.assertion.ids.join(", ") }) + assert_predicate(report, :passed?) + end + + # The waiver is a claim about the port, so it is measured rather than asserted by description: + # ASYNC-3 is waived above because it FAILS, and this runs the same assertion with no waiver and + # requires that failure. The day an interruptible wait exists, this goes red and the waiver goes. + test "the waived MUST really fails, which is what makes waiving it a record and not a hole" do + report = SUITE.run(**case_arguments, waive: [], would_fail: []) + + failed = report.failures.map { |result| result.assertion.ids }.flatten + + assert_includes(failed, UNSATISFIED, + "ASYNC-3 no longer fails; the waiver and §10.5's entry are now stale",) + end + + # Design R5: the waived MUST is rendered as a waiver that names the failure, never as a pass. + test "the report prints the unsatisfied MUST as waived (would fail), never as passed" do + rendered = SUITE.run(**suite_arguments).to_s + + assert_includes(rendered, "waived (would fail): #{UNSATISFIED}") + end + + private + + def described(result) = "#{result.assertion.ids.join(", ")}: #{result.detail}" + + def drive(assertion) + return skip("waived: #{UNSATISFIED}") if assertion.ids.include?(UNSATISFIED) + + assertion.call(Conformance::ExecutorCase.new(**case_arguments)) + rescue Conformance::Vacuous => error + skip("vacuous: #{error.reason}") + rescue Conformance::Failure => error + flunk("#{assertion.ids.join(", ")}: #{error.message} (expected #{error.expected.inspect}, " \ + "got #{error.actual.inspect})") + end + + def suite_arguments + case_arguments.merge(waive: [UNSATISFIED], would_fail: [UNSATISFIED]) + end + + def case_arguments + { build: executor_factory, borrow: borrow_factory, functional: functional_factory, + record_events: true, } + end + + # `events:` arrives only when the case has a recorder; a real Logger is what turns that duck-typed + # sink into the thing Pool#close emits through, so the payload the recorder sees is the one a + # host's own logger would see. + def executor_factory + lambda do |events: nil| + logger = events.nil? ? nil : Dexpace::Instrumentation::Logger.build(sink: events) + Pool.build(size: SIZE, logger: logger || Dexpace::Instrumentation::Logger::NULL) + end + end + + # Core's own borrowing holder: Closeable with `owned: false`, so its close latches and releases + # nothing. Closing it must leave the pool accepting work, which is XCUT-22 exactly. + def borrow_factory + ->(pool) { Dexpace::Transport.async_over(PoolIdleTransport.new, executor: pool) } + end + + # The same bridge over a resource-free executor: no thread, no queue, no timer, and a close that + # is a latch and nothing else. + def functional_factory + lambda do |events: nil| + _ = events + Dexpace::Transport.async_over(PoolIdleTransport.new, executor: PoolInlineExecutor.new) + end + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/aggregate_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/aggregate_test.rb new file mode 100644 index 0000000..520480f --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/aggregate_test.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace/conformance" + +# One report over every suite, and the preamble docs/first-release.md's standing blocker requires. +# NFR-4, NFR-17; ASYNC-3, ASYNC-4. +class DexpaceConformanceAggregateTest < DexpaceTestCase + Aggregate = Dexpace::Conformance::Aggregate + + def report(*rows) + results = rows.map do |(ids, name, status)| + assertion = Dexpace::Conformance::Assertion.build(ids: ids, name: name, body: ->(_s) {}) + Dexpace::Conformance::Result.build(assertion: assertion, status: status) + end + Dexpace::Conformance::Report.new(results) + end + + test "merging two suites keeps one passed? over the whole run" do + merged = Aggregate.run([report([["XCUT-15"], "immutable", :passed]), + report([["NFR-1"], "zero deps", :failed]),]) + + assert_equal(2, merged.results.size) + refute_predicate(merged, :passed?) + end + + test "vacuous and waived are counted apart; a SHOULD-level vacuity does not fail the run" do + merged = Aggregate.run([report([["XCUT-12"], "wait-free reads", :vacuous], + [["ASYNC-3"], "two-mode cancellation", :waived],)]) + + assert_predicate(merged, :passed?) + assert_equal(1, merged.vacuous.size) + assert_equal(1, merged.waived.size) + end + + test "an un-accepted MUST-level vacuity blocks the aggregate, and an accepted one does not" do + reports = [report([["ASYNC-4"], "ordered interrupt", :vacuous])] + + refute_predicate(Aggregate.run(reports), :passed?) + assert_predicate(Aggregate.run(reports, accepted_vacuous: { "ASYNC-4" => "design §10.5" }), + :passed?,) + end + + test "the rendered report states what a green run does not prove" do + rendered = Aggregate.render(Aggregate.run([report([["XCUT-15"], "immutable", :passed])])) + + assert_includes(rendered, "It does not prove") + assert_includes(rendered, "by reference") + assert_includes(rendered, "design R3") + end + + test "the rendered report names the blocker section when one stands" do + rendered = Aggregate.render(Aggregate.run([report([["ASYNC-4"], "ordered interrupt", + :vacuous,])])) + + assert_includes(rendered, "MUST-level vacuity (report blocker): ASYNC-4") + assert_includes(rendered, "REPORT BLOCKED") + end + + test "an aggregate-level would-fail declaration reaches every suite's waivers" do + rendered = Aggregate.render( + Aggregate.run([report([["ASYNC-3"], "two-mode cancellation", :waived])], + would_fail: ["ASYNC-3"],), + ) + + assert_includes(rendered, "waived (would fail): ASYNC-3") + end + + # Design R7: the generated half of the coverage map reads each suite's DECLARED assertions. A + # Report holds only the ones that RAN, so reading one would let a suite skipped in a given + # invocation silently shorten the map. + test "by_requirement_id reads each suite's DECLARED assertions, not a report's results" do + map = Aggregate.by_requirement_id([Dexpace::Conformance::InvariantSuite]) + + assert_equal(2, map["XCUT-13"].size, "XCUT-13 carries two assertions, one per clause") + assert_equal(%i[not_run not_run], map["XCUT-13"].map { |row| row[:status] }) + end + + test "by_requirement_id marks an assertion with no result :not_run rather than omitting it" do + ran = Dexpace::Conformance::InvariantSuite.run(core: Module.new) + map = Aggregate.by_requirement_id([Dexpace::Conformance::InvariantSuite], statuses: ran) + + refute_includes(map.values.flatten.map { |row| row[:status] }, :not_run) + end + + test "each row names both the suite and the assertion, which are different things" do + map = Aggregate.by_requirement_id([Dexpace::Conformance::InvariantSuite]) + row = map["XCUT-15"].first + + assert_equal("Dexpace::Conformance::InvariantSuite", row[:suite]) + refute_equal(row[:suite], row[:assertion]) + end + + test "every suite phase 9 ships contributes to the map" do + suites = [Dexpace::Conformance::InvariantSuite, Dexpace::Conformance::PackagingSuite, + Dexpace::Conformance::CodecSuite, Dexpace::Conformance::ExecutorSuite, + Dexpace::Conformance::TransportSuite,] + map = Aggregate.by_requirement_id(suites) + + assert_empty(map.keys.reject { |id| Dexpace::Conformance::Levels.known?(id) }, + "every id any suite declares is one appendix C knows",) + assert_operator(map.size, :>, 40) + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/codec_suite_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/codec_suite_test.rb new file mode 100644 index 0000000..adecab6 --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/codec_suite_test.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "json" +require "stringio" +require_relative "../../test_helper" +require "dexpace/conformance" + +# Appendix B.3's seam half, proven against deliberately non-conforming codecs. +# SEAM-20, SERDE-3, SERDE-9. +class DexpaceConformanceCodecSuiteTest < DexpaceTestCase + Suite = Dexpace::Conformance::CodecSuite + + # Built from phase 2's FILED contract: #dump_to(value, sink) and #load(source, witness). #dump + # is deliberately NOT in CONTRACT, so no double defines it. The error hierarchy is phase 2's + # too: SerializationError and DeserializationError descend from Dexpace::Serde::Error, and + # there is no Dexpace::SerdeError. + class ConformingCodec + def dump_to(value, sink) = sink.write(value.inspect) + + def load(source, _witness) + ::JSON.parse(source.read) + rescue ::JSON::ParserError + raise Dexpace::Serde::DeserializationError, "malformed JSON" + end + end + + # SEAM-20 / SERDE-3, broken: it closes the caller's sink. + class ClosingCodec < ConformingCodec + def dump_to(value, sink) + super + sink.close + end + end + + # SEAM-20 / SERDE-3, broken the other way: it closes nothing because it writes nothing. + class SilentEncoder < ConformingCodec + def dump_to(_value, _sink) = nil + end + + # SERDE-9's "no library type escapes". A plain def, not `def … if cond`, which would define the + # method conditionally and inherit the conforming parent's. + class LeakyCodec < ConformingCodec + def load(source, _witness) = ::JSON.parse(source.read) + end + + # SERDE-9 requires the STABLE SERDE type: a Dexpace error outside that hierarchy is not it. + class WrongFamilyCodec < ConformingCodec + def load(_source, _witness) = raise(Dexpace::InvalidArgumentError, "not a serde error") + end + + # SERDE-9's second clause: "chaining the original cause". + class UnchainedCodec < ConformingCodec + def load(_source, _witness) + raise Dexpace::Serde::DeserializationError.new("malformed JSON"), cause: nil + end + end + + # SERDE-9's first clause: it does not raise at all. + class SilentCodec < ConformingCodec + def load(_source, _witness) = nil + end + + # 7a's witness protocol is any object answering #dexpace_load(parsed, ctx); the source factory + # stands in for the driver's, which for the JSON codec builds a Dexpace::IO::BufferedSource. + WITNESS = Object.new + def WITNESS.dexpace_load(parsed, _ctx) = parsed + SOURCE = ->(text) { StringIO.new(text) } + + def run_suite(codec, **over) + Suite.run(build: -> { codec.new }, witness: WITNESS, source: SOURCE, **over) + end + + def statuses(report) = report.results.to_h { |r| [r.assertion.ids.first, r.status] } + + test "the suite covers exactly the three portable seam ids" do + ids = Suite.assertions.flat_map(&:ids) + + assert_equal(%w[SEAM-20 SERDE-3 SERDE-9], ids.sort) + refute_includes(ids, "SEAM-21", "the type-token rule stays in 7a's own suite") + refute_includes(ids, "SERDE-10", "the mapping stays in 7a's own suite") + end + + test "a conforming codec passes both seam assertions" do + assert_equal({ "SEAM-20" => :passed, "SERDE-9" => :passed }, + statuses(run_suite(ConformingCodec)),) + end + + test "a codec that closes a caller-supplied sink fails SEAM-20 and SERDE-3 together" do + report = run_suite(ClosingCodec) + + assert_equal(:failed, statuses(report)["SEAM-20"]) + assert_includes(report.to_s, "FAILED: SEAM-20, SERDE-3") + end + + # Without this clause a codec that wrote nothing would pass by having closed nothing. + test "a codec that writes nothing fails SEAM-20 rather than passing by omission" do + assert_equal(:failed, statuses(run_suite(SilentEncoder))["SEAM-20"]) + end + + test "a library exception type escaping the seam fails SERDE-9" do + assert_equal(:failed, statuses(run_suite(LeakyCodec))["SERDE-9"]) + end + + test "a Dexpace error outside the serde hierarchy fails SERDE-9" do + assert_equal(:failed, statuses(run_suite(WrongFamilyCodec))["SERDE-9"]) + end + + test "a serde error with no chained cause fails SERDE-9" do + assert_equal(:failed, statuses(run_suite(UnchainedCodec))["SERDE-9"]) + end + + test "a codec that does not raise at all fails SERDE-9" do + assert_equal(:failed, statuses(run_suite(SilentCodec))["SERDE-9"]) + end + + # The rescue order inside the suite's own capture: Vacuous is a ::StandardError descendant, so + # a bare rescue there would turn the suite's own vacuity into a pass. + test "a Vacuous raised inside the body is not swallowed into passed" do + vacuous = Class.new(ConformingCodec) do + def load(_source, _witness) + raise(Dexpace::Conformance::Vacuous, "this codec decodes nothing") + end + end + + assert_equal(:vacuous, statuses(run_suite(vacuous))["SERDE-9"]) + end + + test "a waiver by id suppresses exactly one result and leaves the other running" do + report = run_suite(ClosingCodec, waive: ["SERDE-3"]) + + assert_equal({ "SEAM-20" => :waived, "SERDE-9" => :passed }, statuses(report)) + end + + test "the preamble names what a green run does not prove" do + report = run_suite(ConformingCodec) + + assert_includes(report.to_s, "SEAM-21") + assert_includes(report.to_s, "SERDE-4") + assert_operator(report.to_s, :start_with?, "dexpace-conformance codec suite:") + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/executor_suite_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/executor_suite_test.rb new file mode 100644 index 0000000..174aa19 --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/executor_suite_test.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace/conformance" + +# Appendix B.7's lifecycle half and the harness for SEAM-25's lifecycle event, proven against +# deliberately non-conforming executors. +# SEAM-12, SEAM-25, ASYNC-3, ASYNC-15, ASYNC-16, ASYNC-17, XCUT-11, XCUT-13, XCUT-22. +class DexpaceConformanceExecutorSuiteTest < DexpaceTestCase + Suite = Dexpace::Conformance::ExecutorSuite + + # The one payload every double emits, shaped exactly as Event#emit shapes it: a Hash whose + # Keys::EVENT entry carries the event name. The pool's two field keys are its own private + # constants and no portable assertion reads them, so nothing else is here. + SHUTDOWN = { Dexpace::Instrumentation::Keys::EVENT => + Dexpace::Instrumentation::Events::INSTRUMENTATION_SHUTDOWN }.freeze + + # A conforming double: an inline executor whose close is latched, which drains before returning, + # refuses work after close, and reports its shutdown the ONLY way a filed executor does -- one + # shutdown payload into the sink the suite supplied. It deliberately defines no + # `#shutdown_count`: no executor in this repository has one, and a double that invented one is + # what made an earlier draft of this suite red against the real adapter. + class FakePool + def initialize(events: nil, owned: true) + @events = events + @owned = owned + @closed = false + @lock = Thread::Mutex.new + end + + def post(&) + raise "closed" if @closed + + yield + end + + def close + @lock.synchronize do + return nil if @closed + + @closed = true + end + return nil unless @owned + + emit_shutdown + nil + end + + private + + def emit_shutdown + @events&.info { SHUTDOWN } + nil + end + end + + # XCUT-13's non-conforming twin: no latch, so every close runs the shutdown again. + class UnlatchedPool < FakePool + def close + @closed = true + send(:emit_shutdown) + nil + end + end + + # ASYNC-16's second half, broken: it accepts work after close. + class LeakyPool < FakePool + def post(&) = yield + end + + # ASYNC-17's subject: an implementation that owns nothing, so its close shuts nothing down. + class FunctionalExecutor + def post(&) = yield + def close = nil + end + + # ASYNC-17's non-conforming twin: a "functional" implementation whose close does work. + class WorkingCloseExecutor + def initialize(events) = (@events = events) + def post(&) = yield + + def close + @events&.info { SHUTDOWN } + nil + end + end + + # XCUT-22's conforming holder: it closes itself and leaves the borrowed executor alone. + class BorrowHolder + def initialize(pool) = (@pool = pool) + def close = nil + end + + # XCUT-22's non-conforming twin. + class ClosingBorrowHolder + def initialize(pool) = (@pool = pool) + def close = @pool.close + end + + def statuses(report) = report.results.to_h { |r| [r.assertion.ids.first, r.status] } + + # ASYNC-3 is waived by ID here for the same reason a first-party driver waives it: no executor + # this repository can build releases a worker blocked in an uninterruptible call (design §10.5), + # and the suite's job is to print that as `waived (would fail)`, never as passed. + def conforming(**over) + defaults = { build: ->(events: nil, **_kw) { FakePool.new(events: events) }, + borrow: ->(pool) { BorrowHolder.new(pool) }, + functional: ->(**_kw) { FunctionalExecutor.new }, + record_events: true, waive: ["ASYNC-3"], would_fail: ["ASYNC-3"], } + Suite.run(**defaults, **over) + end + + test "the suite covers B.7's lifecycle ids, ASYNC-3 among them" do + ids = Suite.assertions.flat_map(&:ids).uniq.sort + + assert_equal(%w[ASYNC-15 ASYNC-16 ASYNC-17 ASYNC-3 SEAM-12 SEAM-25 XCUT-11 XCUT-13 XCUT-22], + ids,) + assert(ids.all? { |id| Dexpace::Conformance::Levels.known?(id) }) + end + + test "a conforming executor passes every lifecycle assertion, with ASYNC-3 waived by id" do + report = conforming + + assert_equal({ "SEAM-12" => :passed, "XCUT-13" => :passed, "XCUT-22" => :passed, + "ASYNC-16" => :passed, "ASYNC-17" => :passed, "SEAM-25" => :passed, + "ASYNC-3" => :waived, }, + statuses(report),) + assert_includes(report.to_s, "waived (would fail): ASYNC-3") + end + + # Design R5: the assertion is real and it fails. An inline executor runs the blocked call on the + # posting thread and a pool runs it on a worker; neither can release it on cancel. + test "ASYNC-3 genuinely fails against an executor that cannot release a blocked worker" do + report = conforming(waive: []) + + assert_equal(:failed, statuses(report)["ASYNC-3"]) + assert_match(/still blocked/, report.failures.first.detail) + end + + test "an unlatched close fails XCUT-13 and SEAM-25" do + report = conforming(build: ->(events: nil, **_kw) { UnlatchedPool.new(events: events) }) + + assert_equal(:failed, statuses(report)["XCUT-13"]) + assert_equal(:failed, statuses(report)["SEAM-25"]) + end + + test "a holder that closes a borrowed executor fails XCUT-22" do + report = conforming(borrow: ->(pool) { ClosingBorrowHolder.new(pool) }) + + assert_equal(:failed, statuses(report)["XCUT-22"]) + end + + test "an executor accepting work after close fails ASYNC-16" do + report = conforming(build: ->(events: nil, **_kw) { LeakyPool.new(events: events) }) + + assert_equal(:failed, statuses(report)["ASYNC-16"]) + end + + test "a functional implementation whose close does work fails ASYNC-17" do + report = conforming(functional: ->(events: nil, **_kw) { WorkingCloseExecutor.new(events) }) + + assert_equal(:failed, statuses(report)["ASYNC-17"]) + end + + test "an adapter supplying no resource-free implementation makes ASYNC-17 vacuous" do + report = conforming(functional: nil) + + assert_equal(:vacuous, statuses(report)["ASYNC-17"]) + assert_predicate(report, :passed?, "ASYNC-17 is a SHOULD, so its vacuity does not block") + end + + # The recorder-free path, stated as a whole report: XCUT-13 and ASYNC-17 keep running on the + # half that needs no recorder, and only SEAM-25 -- whose whole subject IS the event -- goes + # vacuous, with XCUT-22. + test "an adapter with no borrowing entry point and no recorder is vacuous, never failed" do + accepted = { "XCUT-22" => "this adapter files no borrowing entry point", + "ASYNC-15" => "clause (b) has no subject without one", + "SEAM-25" => "no event recorder was supplied to this run", } + report = conforming(borrow: nil, record_events: false, accepted_vacuous: accepted) + + assert_equal({ "SEAM-12" => :passed, "XCUT-13" => :passed, "XCUT-22" => :vacuous, + "ASYNC-16" => :passed, "ASYNC-17" => :passed, "SEAM-25" => :vacuous, + "ASYNC-3" => :waived, }, + statuses(report),) + assert_predicate(report, :passed?) + end + + # Decision: acceptance is `all?` over the assertion's MUST-level IDs. XCUT-22 and ASYNC-15 are + # BOTH MUST in appendix C, so naming one leaves the result blocking -- which is the rule doing + # its job rather than a typo. + test "the same run without acceptances blocks on XCUT-22 and SEAM-25, both MUSTs" do + report = conforming(borrow: nil, record_events: false) + + refute_predicate(report, :passed?) + assert_equal(%w[ASYNC-15 SEAM-25 XCUT-22], + report.blocking_vacuities.flat_map { |r| r.assertion.ids }.uniq.sort,) + end + + test "an acceptance naming only XCUT-22 still blocks, because ASYNC-15 is a MUST too" do + report = conforming(borrow: nil, record_events: false, + accepted_vacuous: { "XCUT-22" => "this adapter files no borrowing entry" },) + + refute_predicate(report, :passed?) + assert_includes(report.blocking_vacuities.flat_map { |r| r.assertion.ids }, "ASYNC-15") + end + + # Without a recorder, "the shutdown ran twice" is visible nowhere, so an UNLATCHED executor + # passes XCUT-13. The suite says so in its preamble; this is the measurement behind it. + test "without a recorder an unlatched executor passes XCUT-13, which the preamble states" do + report = conforming(build: ->(**_kw) { UnlatchedPool.new }, record_events: false, + accepted_vacuous: { "SEAM-25" => "no event recorder in this run" },) + + assert_equal(:passed, statuses(report)["XCUT-13"]) + assert_includes(report.to_s, "an UNLATCHED executor passes XCUT-13") + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_core_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_core_test.rb new file mode 100644 index 0000000..b78d26a --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_core_test.rb @@ -0,0 +1,330 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace/conformance" + +# The InvariantSuite assertions whose subject is a CORE CONSTANT, proven against deliberately +# defective stand-in cores. Each double is a Module.new carrying exactly the constants its +# assertion probes, defective in exactly the clause the assertion names -- which is what shows the +# assertion discriminates rather than merely passing against the real tree. +# XCUT-4, XCUT-5, XCUT-6, XCUT-7, XCUT-8, XCUT-9, XCUT-10, XCUT-16, XCUT-18, XCUT-19, XCUT-20, +# XCUT-21, XCUT-23, XCUT-24. +class DexpaceConformanceInvariantSuiteCoreTest < DexpaceTestCase # rubocop:disable Metrics/ClassLength -- one defective stand-in core per assertion, each beside the test that drives it + Suite = Dexpace::Conformance::InvariantSuite + + def statuses(report) + report.results.each_with_object({}) { |r, h| (h[r.assertion.ids.first] ||= []) << r.status } + end + + # A defective core: the real Dexpace with one constant replaced. The overrides are real + # constants on a fresh module, and `const_missing` falls through to core -- which is what makes + # BOTH spellings work, `core::Thing` and `core.const_get(:Thing)`, since Ruby routes each + # through `const_missing` when the name is not defined locally. Every OTHER assertion therefore + # reaches the real thing, so one test changes one subject. + def patched(**overrides) + real = ::Dexpace + Module.new do + overrides.each { |name, value| const_set(name, value) } + define_singleton_method(:const_missing) { |name| real.const_get(name) } + define_singleton_method(:const_defined?) do |name, *| + super(name, false) || real.const_defined?(name) + end + define_singleton_method(:respond_to_missing?) { |name, _| real.respond_to?(name) } + define_singleton_method(:method_missing) do |name, *args, &block| + real.public_send(name, *args, &block) + end + end + end + + # ---- XCUT-4 ---- + + test "a transport error outside the IOError family fails XCUT-4" do + outside = Class.new(::StandardError) { def retryable? = true } + report = Suite.run(core: patched(TransportError: outside)) + + assert_equal([:failed], statuses(report)["XCUT-4"]) + end + + test "a transport error that is not always-retryable fails XCUT-4" do + lying = Class.new(::IOError) do + include Dexpace::Error + + def retryable? = false + end + report = Suite.run(core: patched(TransportError: lying)) + + assert_equal([:failed], statuses(report)["XCUT-4"]) + end + + # ---- XCUT-5 ---- + + test "a status classifier missing a 5xx fails XCUT-5, which a {500,502,503,504} list would" do + narrow = Module.new do + def self.retryable_status?(code) = [408, 429, 500, 502, 503, 504].include?(code) + end + report = Suite.run(core: patched(Retryability: narrow)) + + assert_equal([:failed], statuses(report)["XCUT-5"], + "507 is a 5xx outside 501 and 505, so the classifier must call it retryable",) + end + + test "a classifier that calls 501 retryable fails XCUT-5" do + wide = Module.new do + def self.retryable_status?(code) + code >= 500 || [408, 429].include?(code) + end + end + + assert_equal([:failed], statuses(Suite.run(core: patched(Retryability: wide)))["XCUT-5"]) + end + + # ---- XCUT-8 ---- + + test "a factory that fabricates an error for a 2xx fails XCUT-8" do + permissive = Class.new(::StandardError) do + def self.for(response) = new(response.status.code.to_s) + def self.for_or_nil(_response) = nil + end + report = Suite.run(core: patched(ProtocolError: permissive)) + + assert_equal([:failed], statuses(report)["XCUT-8"]) + end + + test "a convenience form that returns an error for a non-error status fails XCUT-8" do + eager = Class.new(Dexpace::ProtocolError) { def self.for_or_nil(_response) = allocate } + + assert_equal([:failed], statuses(Suite.run(core: patched(ProtocolError: eager)))["XCUT-8"]) + end + + # ---- XCUT-9 ---- + + # A two-node cycle could not tell these apart from a conforming walk, which is why the fixture + # is three nodes. + test "a DEPTH-CAPPED cause walk fails XCUT-9" do + capped = Module.new do + def self.each_cause(error) + return enum_for(:each_cause, error) unless block_given? + + current = error + 2.times do + break if current.nil? + + yield current + current = current.cause + end + end + end + + assert_equal([:failed], statuses(Suite.run(core: capped))["XCUT-9"]) + end + + test "a NON-TERMINATING cause walk reports failed rather than hanging the suite" do + looping = Module.new do + def self.each_cause(error) + return enum_for(:each_cause, error) unless block_given? + + current = error + loop do + yield current + current = current.cause + end + end + end + + assert_equal([:failed], statuses(Suite.run(core: looping))["XCUT-9"]) + end + + # ---- XCUT-20 ---- + + test "a redactor that throws on a malformed URL fails XCUT-20" do + throwing = Class.new do + def url(_value) = raise("boom") + def header_value(_name, value) = value + def header_name?(_name) = false + end + instrumentation = Module.new do + const_set(:Redactor, Class.new { const_set(:DEFAULT, throwing.new) }) + const_set(:Logger, Dexpace::Instrumentation::Logger) + const_set(:Preview, Dexpace::Instrumentation::Preview) + define_singleton_method(:contain) { |*a, **k, &b| Dexpace::Instrumentation.contain(*a, **k, &b) } + end + report = Suite.run(core: patched(Instrumentation: instrumentation)) + + assert_equal([:error], statuses(report)["XCUT-20"], + "an observability path that throws reaches Runner's bare rescue, which is " \ + "exactly the signal XCUT-20 forbids in a caller's request path",) + end + + # ---- XCUT-23 ---- + + test "a registry that silently picks one of two candidates fails XCUT-23" do + silent = Class.new do + # rubocop:disable Lint/UnusedMethodArgument -- phase 2's filed Registry signatures + def initialize(seam:, installer:, conforms:) = (@factories = {}) + def register(key, factory, core:) = (@factories[key] = factory) + # rubocop:enable Lint/UnusedMethodArgument + def install(provider) = (@installed = provider) + def resolve = @installed || @factories.values.first&.call + end + report = Suite.run(core: patched(Registry: silent)) + + assert_equal([:failed], statuses(report)["XCUT-23"]) + end + + test "a registry whose discovery beats an explicit install fails XCUT-23" do + inverted = Class.new do + # rubocop:disable Lint/UnusedMethodArgument -- phase 2's filed Registry signatures; only + # `installer:` is read here, and the rest are the shape the suite calls. + def initialize(seam:, installer:, conforms:) + @installer = installer + @factories = {} + end + + def register(key, factory, core:) = (@factories[key] = factory) + # rubocop:enable Lint/UnusedMethodArgument + def install(provider) = (@installed = provider) + + def resolve + raise "no candidate; install through #{@installer}" if @factories.empty? && @installed.nil? + raise "two candidates; install through #{@installer}" if @factories.size > 1 + + @factories.values.first&.call || @installed + end + end + report = Suite.run(core: patched(Registry: inverted)) + + assert_equal([:failed], statuses(report)["XCUT-23"], + "the ORDERING is the content: failing loudly on ambiguity is two of three",) + end + + # ---- XCUT-21, through the driver's cnonce factory ---- + + def digest_core(&) + handler = Class.new(&) + patched(Auth: Module.new do + const_set(:DigestHandler, handler) + define_singleton_method(:const_missing) { |name| ::Dexpace::Auth.const_get(name) } + end) + end + + def x21(core) + handler = core.const_get(:Auth).const_get(:DigestHandler) + Suite.run(core: core, cnonce: ->(source) { handler.new(:cred, cnonce_source: source).cnonce }) + end + + test "a handler with no injectable cnonce source fails XCUT-21" do + core = digest_core do + def initialize(credential) = (@credential = credential) + def cnonce = format("%032x", rand(2**128)) + end + handler = core.const_get(:Auth).const_get(:DigestHandler) + report = Suite.run(core: core, cnonce: ->(_source) { handler.new(:cred).cnonce }) + + assert_equal([:failed], statuses(report)["XCUT-21"]) + end + + test "a 128-bit draw truncated to eight characters fails XCUT-21" do + core = digest_core do + def initialize(credential, cnonce_source: ::SecureRandom) + @credential = credential + @source = cnonce_source + end + + def cnonce = @source.hex(16)[0, 8] + end + + assert_equal([:failed], statuses(x21(core))["XCUT-21"], + "a character count called this conforming and urlsafe_base64(16) not",) + end + + test "a conforming handler passes XCUT-21 whatever encoding renders its 128 bits" do + core = digest_core do + def initialize(credential, cnonce_source: ::SecureRandom) + @credential = credential + @source = cnonce_source + end + + def cnonce = @source.urlsafe_base64(16) + end + + assert_equal([:passed], statuses(x21(core))["XCUT-21"]) + end + + # ---- XCUT-18's call-site assertion ---- + + # A conforming adapter: it re-runs HTTP-17/HTTP-18 before touching the wire. + class RefusingTransport + def call(request, _options, _cancellation) + request.headers.each_entry do |name, value| + ::Dexpace::HeaderSyntax.validate_name!(name) + ::Dexpace::HeaderSyntax.validate_outbound_value!(value, name: name) + end + raise "unreachable: the forged name must have been refused" + end + end + + # A non-conforming adapter: it connects first and validates afterwards. + class DispatchingTransport + def call(request, _options, _cancellation) + ::TCPSocket.new(request.url.host, request.url.port).close # wire activity before any check + raise ::Dexpace::InvalidArgumentError, "too late: already on the wire" + end + end + + test "a transport that re-validates before dispatch passes XCUT-18's call-site assertion" do + report = Suite.run(core: ::Dexpace, transport: -> { RefusingTransport.new }) + + assert_equal(%i[passed passed], statuses(report)["XCUT-18"]) + end + + test "a transport that touches the wire before re-validating fails XCUT-18's call site" do + report = Suite.run(core: ::Dexpace, transport: -> { DispatchingTransport.new }) + + assert_equal(%i[passed failed], statuses(report)["XCUT-18"]) + assert_match(/accepted a connection/, report.failures.first.detail) + end + + # ---- XCUT-16, through the driver's credential factory ---- + + Sent = Struct.new(:headers) + + test "a credential path with no HTTPS guard fails XCUT-16" do + unguarded = lambda do |url:, cross_origin: false| # rubocop:disable Lint/UnusedBlockArgument + Sent.new(cross_origin ? {} : { "authorization" => "Bearer t" }) + end + report = Suite.run(core: ::Dexpace, credential_hop: unguarded) + + assert_equal([:failed], statuses(report)["XCUT-16"]) + end + + test "a guard applied to the credential-FREE re-issue too fails XCUT-16's third clause" do + over_eager = lambda do |url:, cross_origin: false| + unless url.start_with?("https:") + raise Dexpace::Auth::HTTPSRequiredError.new(scheme: "http", + step: "double",) + end + + Sent.new(cross_origin ? {} : { "authorization" => "Bearer t" }) + end + report = Suite.run(core: ::Dexpace, credential_hop: over_eager) + + assert_equal([:failed], statuses(report)["XCUT-16"], + "the guard applies ONLY on the credential-attaching path",) + end + + test "a conforming credential path passes all three of XCUT-16's clauses" do + conforming = lambda do |url:, cross_origin: false| + next Sent.new({}) if cross_origin + unless url.start_with?("https:") + raise Dexpace::Auth::HTTPSRequiredError.new(scheme: "http", + step: "double",) + end + + Sent.new({ "authorization" => "Bearer t" }) + end + + assert_equal([:passed], + statuses(Suite.run(core: ::Dexpace, credential_hop: conforming))["XCUT-16"],) + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_test.rb new file mode 100644 index 0000000..957a9d3 --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_test.rb @@ -0,0 +1,327 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace/conformance" + +# Appendix B.8's instrument, proven against DELIBERATELY NON-CONFORMING doubles: a green suite +# proves the instrument works, not that the SDK conforms, and those are different claims. +# XCUT-11, XCUT-13, XCUT-14, XCUT-15, XCUT-17, XCUT-18, XCUT-21, XCUT-22. +class DexpaceConformanceInvariantSuiteTest < DexpaceTestCase # rubocop:disable Metrics/ClassLength -- the non-conforming doubles this suite must fail against live beside the tests that drive them, which is what keeps each test readable as one behaviour + Suite = Dexpace::Conformance::InvariantSuite + + # ---- the seam: a latched closeable that counts its release ---- + + # A conforming seam: a latched closeable that counts its release and holds no per-call state. + class LatchedSeam + attr_reader :release_count + + def initialize(client: nil) + @client = client + @owned = client.nil? + @closed = false + @release_count = 0 + @lock = Thread::Mutex.new + end + + def call(request) = [request, 1] + + def close + @lock.synchronize do + return nil if @closed + + @closed = true + end + @release_count += 1 + @client.close if @owned && !@client.nil? + nil + end + end + + # XCUT-13's first clause, broken: no latch, so every close runs the release again. + class UnlatchedSeam < LatchedSeam + def close + @release_count += 1 + @client&.close + nil + end + end + + # Blocks on its FIRST close only, just past the assertion's half-second bound: enough to make + # XCUT-13's second clause red without spending seconds of the suite's time. + class BlockingCloseSeam < LatchedSeam + BLOCK_SECONDS = 0.6 + + def close + super + Kernel.sleep(BLOCK_SECONDS) if release_count == 1 + nil + end + end + + # XCUT-22, broken: it closes the resource it borrowed. + class OverreachingSeam < LatchedSeam + def close + super + @client&.close + end + end + + # XCUT-11, broken: per-call state on the shared instance, and one call's request in another's. + class CrossTalkingSeam < LatchedSeam + def initialize(client: nil) + super + @attempt = 0 + end + + def call(_request) = ["request-0", @attempt += 1] + end + + # Holds its per-fiber mutex across a suspension point, which is exactly what XCUT-11's second + # clause forbids. Clause 1 drives the same object from a thread's ROOT fiber, where Fiber.yield + # raises FiberError -- the rescue is the DOUBLE's, never the assertion's. + class SuspendingLockSeam < LatchedSeam + def call(request) + @lock.synchronize do + begin + Fiber.yield + rescue FiberError + nil + end + [request, 1] + end + end + end + + DECLARED = %i[@client @owned @closed @release_count @lock].freeze + + # ---- bounded maps ---- + + # 4a's filed BoundedMap surface: .new(cap:), #set, #size, and a drain loop over the store. + class ConformingMap + def initialize(cap:) + @cap = cap + @h = {} + @mutex = Thread::Mutex.new + end + + def set(key, value) + @mutex.synchronize do + @h[key] = value + @h.shift while @h.size > @cap + end + value + end + + def size = @mutex.synchronize { @h.size } + end + + # XCUT-14's cap clause, broken: nothing evicts. + class UncappedMap < ConformingMap + def set(key, value) + @mutex.synchronize { @h[key] = value } + value + end + end + + # Caps correctly from empty, and is exactly XCUT-14's "single pre-insert check-then-evict". + class CheckThenEvictMap < ConformingMap + def set(key, value) + @mutex.synchronize do + @h.shift if @h.size >= @cap + @h[key] = value + end + value + end + end + + STORE = ->(map) { map.instance_variable_get(:@h) } + + # ---- redirect hops ---- + + Hop = Struct.new(:headers, :url) + ORIGIN = %r{\Ahttps?://[^/]+} + USERINFO = %r{//[^@/]*@} + + SCOPED_HEADERS = %w[authorization cookie proxy-authorization].freeze + + def conforming_hop(from:, to:, headers:) + raise ArgumentError, "downgrade refused" if from.start_with?("https:") && to.start_with?("http:") + + target = to.sub(USERINFO, "//") + dropped = from[ORIGIN] == target[ORIGIN] ? %w[authorization] : SCOPED_HEADERS + Hop.new(headers.except(*dropped), target) + end + + # A non-conforming re-issue: every header survives every hop. + # rubocop:disable-next Lint/UnusedMethodArgument -- the driver contract's signature + def leaky_hop(from:, to:, headers:) = Hop.new(headers, to) + + # ---- helpers ---- + + def statuses(report) + report.results.each_with_object({}) { |r, h| (h[r.assertion.ids.first] ||= []) << r.status } + end + + def run_suite(**over) + defaults = { core: ::Dexpace, seam: ->(client: nil) { LatchedSeam.new(client: client) }, + mutable: DECLARED, } + Suite.run(**defaults, **over) + end + + # ---- structure ---- + + test "the suite covers all 24 XCUT ids with 28 assertions, four of them doubled" do + ids = Suite.assertions.flat_map(&:ids) + + assert_equal(24, ids.uniq.size) + assert_equal(28, ids.size) + assert_equal(%w[XCUT-11 XCUT-13 XCUT-14 XCUT-18], ids.tally.select { |_, n| n > 1 }.keys.sort) + assert(ids.all? { |id| Dexpace::Conformance::Levels.known?(id) }, + "every id the suite declares is one appendix C holds",) + end + + test "an absent artifact is vacuous naming who promised it, never failed and never errored" do + report = Suite.run(core: Module.new, seam: ->(client: nil) { LatchedSeam.new(client: client) }) + + assert_equal([:vacuous], statuses(report)["XCUT-15"]) + assert_includes(report.to_s, "Headers is absent; phase 1's domain model committed to it") + end + + test "a factory the driver did not supply is vacuous, and on a MUST it blocks the report" do + report = Suite.run(core: ::Dexpace) + + assert_equal(%i[vacuous vacuous], statuses(report)["XCUT-13"]) + refute_predicate(report, :passed?, "an un-waived MUST-level vacuity is a report blocker") + assert_includes(report.to_s, "no seam implementation supplied") + end + + # ---- the model and lifecycle assertions ---- + + test "a model aliasing a caller's collection fails XCUT-15" do + aliasing = Module.new do + const_set(:Headers, Class.new do + def self.build(values:, casing:, direction: :outbound) = new(values, casing, direction) + def initialize(values, _casing, _direction) = (@values = values) + def [](name) = @values[name] + end,) + end + + assert_equal([:failed], statuses(Suite.run(core: aliasing))["XCUT-15"]) + end + + test "an unlatched close fails XCUT-13's first clause and passes its second" do + report = run_suite(seam: ->(client: nil) { UnlatchedSeam.new(client: client) }) + + assert_equal(%i[failed passed], statuses(report)["XCUT-13"]) + end + + # XCUT-13's second clause is not vacuous: a close that blocks makes it red, which is what the + # bound is for. Without a double that blocks, the clause would pass against every subject the + # suite can build and would be worth nothing. + test "a close that blocks fails XCUT-13's second clause and passes its first" do + report = run_suite(seam: ->(client: nil) { BlockingCloseSeam.new(client: client) }) + + assert_equal(%i[passed failed], statuses(report)["XCUT-13"]) + assert_match(/blocked rather than signalling/, report.failures.first.detail) + end + + test "a holder that closes a borrowed resource fails XCUT-22" do + report = run_suite(seam: ->(client: nil) { OverreachingSeam.new(client: client) }) + + assert_equal([:failed], statuses(report)["XCUT-22"]) + end + + # ---- bounded memory ---- + + test "a map with no cap fails XCUT-14's cap clause deterministically, with no thread race" do + report = run_suite(bounded_map: lambda { |cap:| + UncappedMap.new(cap: cap) + }, bounded_map_store: STORE,) + + assert_equal(:failed, statuses(report)["XCUT-14"].first) + end + + test "a drain-loop map passes both of XCUT-14's clauses" do + report = run_suite(bounded_map: ->(cap:) { ConformingMap.new(cap: cap) }, + bounded_map_store: STORE,) + + assert_equal(%i[passed passed], statuses(report)["XCUT-14"]) + end + + test "a check-then-evict map passes the cap clause and fails the drain clause" do + report = run_suite(bounded_map: ->(cap:) { CheckThenEvictMap.new(cap: cap) }, + bounded_map_store: STORE,) + + assert_equal(%i[passed failed], statuses(report)["XCUT-14"]) + assert_match(/evicted once instead of draining/, report.failures.first.detail) + end + + # ---- redirect hygiene ---- + + test "a redirect driver that keeps Authorization on a same-origin re-issue fails XCUT-17" do + report = run_suite(redirect_hops: method(:leaky_hop)) + + assert_equal([:failed], statuses(report)["XCUT-17"]) + end + + test "a conforming redirect driver passes all four of XCUT-17's clauses" do + report = run_suite(redirect_hops: method(:conforming_hop)) + + assert_equal([:passed], statuses(report)["XCUT-17"]) + end + + # ---- concurrency ---- + + test "a frozen stateless seam passes both XCUT-11 clauses" do + frozen = Class.new(LatchedSeam) do + def initialize(client: nil) + super + freeze + end + end + report = run_suite(seam: ->(client: nil) { frozen.new(client: client) }, mutable: []) + + assert_equal(%i[passed passed], statuses(report)["XCUT-11"]) + end + + test "a latch-and-mutex seam passes XCUT-11 when the DRIVER declares its state" do + report = run_suite + + assert_equal(%i[passed passed], statuses(report)["XCUT-11"], + "P9-9: a frozen-and-no-ivars rule would condemn every Closeable in the SDK",) + end + + test "a seam holding per-call state on the shared instance fails XCUT-11" do + report = run_suite(seam: ->(client: nil) { CrossTalkingSeam.new(client: client) }) + + assert_includes(statuses(report)["XCUT-11"], :failed) + end + + test "a subject declaring per-call state exempt still fails; mutable: is the driver's" do + self_exempting = Class.new(CrossTalkingSeam) do + def conformance_mutable_state = [:@attempt] + end + report = run_suite(seam: ->(client: nil) { self_exempting.new(client: client) }, + mutable: DECLARED,) + + assert_includes(statuses(report)["XCUT-11"], :failed) + end + + # The deadlock must be reported :failed; :error reads as a broken harness. Asserted as the exact + # PAIR, sorted, because assert_includes(:failed) would not catch the :error either. + test "a seam holding its lock across a suspension point fails XCUT-11's second clause" do + report = run_suite(seam: ->(client: nil) { SuspendingLockSeam.new(client: client) }) + + assert_equal(%i[failed passed], statuses(report)["XCUT-11"].sort, + "a deadlock must be reported :failed; :error reads as a broken harness",) + end + + test "a shared instance the driver declares is audited too, and names itself on failure" do + leaky = Class.new { def initialize = (@per_call = 0) }.new + report = run_suite(shared: [["4a's ContextStore", leaky, []]]) + + assert_includes(statuses(report)["XCUT-11"], :failed) + assert_match(/4a's ContextStore/, report.failures.first.detail) + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/packaging_suite_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/packaging_suite_test.rb new file mode 100644 index 0000000..78c04ba --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/packaging_suite_test.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "tmpdir" +require_relative "../../test_helper" +require "dexpace/conformance" + +# Appendix B.9's portable half, proven against deliberately non-conforming gem sets. +# NFR-1, NFR-2, NFR-3, NFR-10, NFR-11, NFR-13, NFR-14, NFR-15. +class DexpaceConformancePackagingSuiteTest < DexpaceTestCase # rubocop:disable Metrics/ClassLength -- one fixture gem set per assertion, each beside the test that drives it + Suite = Dexpace::Conformance::PackagingSuite + + # Loaded constants standing in for installed gems' version metadata. + module FakeGem + VERSION = "0.1.0" + + module Adapter + VERSION = "0.1.0" + end + + module Stale + VERSION = "0.0.9" + end + + # A loaded module that reports no version at all: the NFR-15 defect, not an absent + # antecedent. + module NoVersion + end + end + + CORE = "dexpace-core" + ADAPTER = "dexpace-serde-json" + CONSTANTS = { CORE => "DexpaceConformancePackagingSuiteTest::FakeGem", + ADAPTER => "DexpaceConformancePackagingSuiteTest::FakeGem::Adapter", }.freeze + + def spec(name, version, deps, ruby: ">= 3.2") + ::Gem::Specification.new do |s| + s.name = name + s.version = version + s.required_ruby_version = ruby + deps.each { |dep| s.add_dependency(dep, ">= 0") } + end + end + + def conforming + { CORE => spec(CORE, "0.1.0", []), + ADAPTER => spec(ADAPTER, "0.1.0", [CORE, "json"]), } + end + + def statuses(report) = report.results.to_h { |r| [r.assertion.ids.first, r.status] } + + def run_suite(specs, **over) + defaults = { adapters: [ADAPTER], resolve: ->(name) { specs[name] }, constants: CONSTANTS, + versions: { CORE => "0.1.0", ADAPTER => "0.1.0" }, } + Suite.run(**defaults, **over) + end + + # ---- structure ---- + + test "the suite covers the eight portable NFRs, one assertion each" do + ids = Suite.assertions.flat_map(&:ids) + + assert_equal(%w[NFR-1 NFR-10 NFR-11 NFR-13 NFR-14 NFR-15 NFR-2 NFR-3], ids.sort) + assert(ids.all? { |id| Dexpace::Conformance::Levels.known?(id) }) + refute_includes(ids, "NFR-8", "NFR-8 is vacuous by its own text and gets no assertion") + refute_includes(ids, "NFR-5", "a coverage floor is a property of a BUILD, not of a unit") + end + + # ---- NFR-1, NFR-2 ---- + + test "a core with one runtime dependency fails NFR-1" do + specs = conforming + specs[CORE] = spec(CORE, "0.1.0", ["json"]) + + assert_equal(:failed, statuses(run_suite(specs))["NFR-1"]) + end + + test "an adapter with two third-party dependencies fails NFR-2" do + specs = conforming + specs[ADAPTER] = spec(ADAPTER, "0.1.0", [CORE, "json", "oj"]) + + assert_equal(:failed, statuses(run_suite(specs))["NFR-2"]) + end + + test "an adapter that does not declare the core at all fails NFR-2" do + specs = conforming + specs[ADAPTER] = spec(ADAPTER, "0.1.0", ["json"]) + + assert_equal(:failed, statuses(run_suite(specs))["NFR-2"]) + end + + # ---- NFR-10 ---- + + test "a unit declaring no runtime floor fails NFR-10" do + specs = conforming + specs[ADAPTER] = spec(ADAPTER, "0.1.0", [CORE, "json"], ruby: ">= 0") + + assert_equal(:failed, statuses(run_suite(specs))["NFR-10"]) + end + + # The half a gate asserting "every gemspec equals the global floor" gets wrong: a HIGHER floor + # on one isolated unit is what NFR-10 asks for, not a defect. + test "a higher floor on an isolated unit passes NFR-10" do + specs = conforming + specs[ADAPTER] = spec(ADAPTER, "0.1.0", [CORE, "json"], ruby: ">= 3.3") + + assert_equal(:passed, statuses(run_suite(specs))["NFR-10"]) + end + + test "a higher-floor unit that the core depends on fails NFR-10" do + specs = { CORE => spec(CORE, "0.1.0", [ADAPTER]), + ADAPTER => spec(ADAPTER, "0.1.0", [], ruby: ">= 3.3"), } + + assert_equal(:failed, statuses(run_suite(specs))["NFR-10"]) + end + + # ---- NFR-14, NFR-15 ---- + + test "a published version disagreeing with the single source fails NFR-14" do + report = run_suite(conforming, versions: { CORE => "0.2.0", ADAPTER => "0.1.0" }) + + assert_equal(:failed, statuses(report)["NFR-14"]) + end + + test "with no single source named, NFR-14 is vacuous rather than a pass" do + report = run_suite(conforming, versions: {}) + + assert_equal(:passed, statuses(report)["NFR-14"], + "an empty map states nothing about any unit, so nothing mismatches",) + end + + test "a runtime version disagreeing with the gemspec fails NFR-15" do + constants = CONSTANTS.merge(ADAPTER => "DexpaceConformancePackagingSuiteTest::FakeGem::Stale") + + assert_equal(:failed, statuses(run_suite(conforming, constants: constants))["NFR-15"]) + end + + # The mutation that proves this is not the assertion a green 0.0.0 tree would pass: a + # not-a-placeholder check alone passes at 0.0.0, which is the version every gem here carries. + test "NFR-15 compares against the gemspec, so a 0.0.0 gemspec is not a free pass" do + specs = { CORE => spec(CORE, "0.0.0", []) } + report = Suite.run(adapters: [], resolve: ->(name) { specs[name] }, + constants: { CORE => "DexpaceConformancePackagingSuiteTest::FakeGem" },) + + assert_equal(:failed, statuses(report)["NFR-15"], + "0.1.0 reported against a 0.0.0 gemspec must fail",) + end + + test "a LOADED gem that defines no VERSION fails NFR-15 rather than vacuating it" do + constants = CONSTANTS.merge(ADAPTER => + "DexpaceConformancePackagingSuiteTest::FakeGem::NoVersion") + + assert_equal(:failed, statuses(run_suite(conforming, constants: constants))["NFR-15"]) + end + + # ---- vacuity ---- + + test "an uninstalled gem is vacuous, never failed" do + report = Suite.run(adapters: [ADAPTER], resolve: ->(_name) {}, constants: CONSTANTS) + + assert_equal([:vacuous], report.results.map(&:status).uniq) + end + + test "an unloaded constant is vacuous, never failed" do + report = run_suite(conforming, constants: { CORE => "NotLoaded::Anywhere" }) + + assert_equal(:vacuous, statuses(report)["NFR-15"]) + end + + # ---- NFR-3, NFR-11, NFR-13, over a seeded shipped tree ---- + + def seeded(lib:, sig:) + root = Dir.mktmpdir("dexpace-packaging") + lib.each { |path, body| write(File.join(root, "lib", path), body) } + sig.each { |path, body| write(File.join(root, "sig", path), body) } + root + end + + def write(path, body) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, body) + end + + HEADED = "# frozen_string_literal: true\n# SPDX-License-Identifier: MIT\n\nmodule Dexpace\nend\n" + BARE = "module Dexpace\nend\n" + + def shipped(lib:, sig:) + root = seeded(lib: lib, sig: sig) + run_suite(conforming, adapters: [], sig_roots: { CORE => File.join(root, "sig") }) + end + + test "a shipped lib file with no signature beside it fails NFR-3" do + report = shipped(lib: { "dexpace.rb" => BARE, "dexpace/extra.rb" => BARE }, + sig: { "dexpace.rbs" => HEADED },) + + assert_equal(:failed, statuses(report)["NFR-3"]) + end + + test "a one-for-one lib and sig tree passes NFR-3" do + report = shipped(lib: { "dexpace.rb" => BARE }, sig: { "dexpace.rbs" => HEADED }) + + assert_equal(:passed, statuses(report)["NFR-3"]) + end + + test "a signature naming an async-framework type fails NFR-11" do + leaking = "# SPDX-License-Identifier: MIT\nmodule Dexpace\n A: Async::Task\nend\n" + report = shipped(lib: { "dexpace.rb" => BARE }, sig: { "dexpace.rbs" => leaking }) + + assert_equal(:failed, statuses(report)["NFR-11"]) + end + + # The negative control that matters: core's OWN Dexpace::Async::Future is not a leak, and a + # scan matching the bare namespace would call it one. + test "the core's own Dexpace::Async namespace is not an async-framework leak" do + own = "# SPDX-License-Identifier: MIT\nmodule Dexpace\n A: Dexpace::Async::Future\nend\n" + report = shipped(lib: { "dexpace.rb" => BARE }, sig: { "dexpace.rbs" => own }) + + assert_equal(:passed, statuses(report)["NFR-11"]) + end + + test "a mention inside a comment is not an async-framework leak" do + commented = "# SPDX-License-Identifier: MIT\n# see Async::Task\nmodule Dexpace\nend\n" + report = shipped(lib: { "dexpace.rb" => BARE }, sig: { "dexpace.rbs" => commented }) + + assert_equal(:passed, statuses(report)["NFR-11"]) + end + + test "a shipped signature with no SPDX header fails NFR-13" do + report = shipped(lib: { "dexpace.rb" => BARE }, sig: { "dexpace.rbs" => BARE }) + + assert_equal(:failed, statuses(report)["NFR-13"]) + assert_includes(report.to_s, "a RuboCop cop cannot reach .rbs") + end + + test "a shipped signature carrying the header passes NFR-13" do + report = shipped(lib: { "dexpace.rb" => BARE }, sig: { "dexpace.rbs" => HEADED }) + + assert_equal(:passed, statuses(report)["NFR-13"]) + end + + # The assertion must discriminate against the REAL tree too, not only against a fixture: 278 + # shipped .rbs files carry no header today, which is the gap phase 10's inbound list owns. + test "NFR-13 against this repository's own shipped signatures reports the real gap" do + root = File.expand_path("../../../..", __dir__) + report = run_suite(conforming, adapters: [], + sig_roots: { CORE => File.join(root, "dexpace-core", "sig") },) + + assert_equal(:failed, statuses(report)["NFR-13"], + "an unconditional vacuity here would check nothing for anyone",) + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/report_phase9_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/report_phase9_test.rb new file mode 100644 index 0000000..06db7fd --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/report_phase9_test.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace/conformance" + +# Phase 9's additions to 8a's Report: the aggregate caller 8a deferred #to_h for, and the +# MUST-level vacuity blocker design R3's absent-artifact rule is only safe because of. +# NFR-4, NFR-17; ASYNC-3, ASYNC-4, XCUT-12, XCUT-22. +# +# 8a's own report_test.rb is left byte-identical: its two pinned renderings -- `waived: D (checks +# D)` and `waived: TRANSPORT-28` -- are what keep `waived (would fail)` a DRIVER's declaration +# rather than a blanket rename that would make the report lie about 8c's two waivers. +class DexpaceConformanceReportPhase9Test < DexpaceTestCase + Report = Dexpace::Conformance::Report + + def result(ids, status, detail: nil) + assertion = Dexpace::Conformance::Assertion.build(ids: ids, name: ids.join("+"), + body: ->(_s) {},) + Dexpace::Conformance::Result.build(assertion: assertion, status: status, detail: detail) + end + + test "merge flattens many reports into one passed? over the whole run" do + merged = Report.merge([Report.new([result(["XCUT-15"], :passed)]), + Report.new([result(["NFR-1"], :failed)]),]) + + assert_equal(2, merged.results.size) + refute_predicate(merged, :passed?) + end + + test "merge inherits every suite's acceptances and would-fail declarations" do + one = Report.new([result(["ASYNC-4"], :vacuous)], accepted_vacuous: { "ASYNC-4" => "§10.5" }) + two = Report.new([result(["ASYNC-3"], :waived)], would_fail: ["ASYNC-3"]) + merged = Report.merge([one, two]) + + assert_predicate(merged, :passed?) + assert_includes(merged.to_s, "accepted MUST-level vacuity (design-sanctioned): ASYNC-4: §10.5") + assert_includes(merged.to_s, "waived (would fail): ASYNC-3") + end + + test "to_h carries one row per result with its ids, status and detail" do + report = Report.new([result(%w[XCUT-13 XCUT-22], :passed)]) + + assert_equal(1, report.to_h[:passed]) + assert_equal(%w[XCUT-13 XCUT-22], report.to_h[:results].first[:ids]) + end + + test "a SHOULD-level vacuity is recorded and does not fail the run" do + report = Report.new([result(["XCUT-12"], :vacuous, detail: "no reactor")]) + + assert_predicate(report, :passed?) + assert_empty(report.blocking_vacuities) + assert_includes(report.to_s, "vacuous: XCUT-12: no reactor") + end + + # The failing fixture the blocker exists for: an unbuilt MUST is not a green run (design R3). + test "an un-waived, un-accepted MUST-level vacuity is a report blocker and fails the run" do + report = Report.new([result(["ASYNC-4"], :vacuous, detail: "no interrupt path")]) + + refute_predicate(report, :passed?) + assert_equal(["ASYNC-4"], report.blocking_vacuities.flat_map { |r| r.assertion.ids }) + assert_includes(report.to_s, "MUST-level vacuity (report blocker): ASYNC-4") + assert_includes(report.to_s, "REPORT BLOCKED: 1 un-waived MUST-level vacuity (design R3)") + end + + test "an accepted MUST-level vacuity carries its citation, renders apart, and does not block" do + report = Report.new([result(["ASYNC-4"], :vacuous)], + accepted_vacuous: { "ASYNC-4" => "design §10.5: no interrupt ordering" },) + + assert_predicate(report, :passed?) + assert_equal(1, report.accepted_vacuities.size) + assert_includes(report.to_s, + "accepted MUST-level vacuity (design-sanctioned): ASYNC-4: design §10.5",) + assert_equal(["ASYNC-4"], report.to_h[:accepted_vacuities].map { |row| row[:ids].first }) + end + + # Decision: acceptance is `all?` over the assertion's MUST-level IDs, never `any?`. An + # acceptance of one ID must not silence a co-carried MUST -- that is the gap staying invisible, + # and it is the exact shape ExecutorSuite's borrowed_executor_survives carries (XCUT-22 AND + # ASYNC-15, both MUST in appendix C). + test "an acceptance naming one of two MUST ids still blocks, because accepted? is all?" do + partial = Report.new([result(%w[XCUT-22 ASYNC-15], :vacuous, detail: "no borrowing entry")], + accepted_vacuous: { "XCUT-22" => "8b files none" },) + both = Report.new([result(%w[XCUT-22 ASYNC-15], :vacuous, detail: "no borrowing entry")], + accepted_vacuous: { "XCUT-22" => "8b files none", + "ASYNC-15" => "clause (b) has no subject here", },) + + refute_predicate(partial, :passed?) + assert_predicate(both, :passed?) + end + + test "an acceptance with no citation is refused at construction, not rendered as a blank" do + assert_raises(::ArgumentError) do + Report.new([result(["ASYNC-4"], :vacuous)], accepted_vacuous: { "ASYNC-4" => " " }) + end + end + + test "a waived result is never counted as a vacuity, accepted or blocking" do + report = Report.new([result(["ASYNC-3"], :waived)], would_fail: ["ASYNC-3"]) + + assert_predicate(report, :passed?) + assert_empty(report.blocking_vacuities) + assert_empty(report.accepted_vacuities) + assert_includes(report.to_s, "waived (would fail): ASYNC-3") + end + + # 8a's rendering is the DEFAULT and stays the default: a waiver that means "this adapter does + # not support the property" (8c's TRANSPORT-14, TRANSPORT-27) must not read as "this would + # fail". The driver declares which is which. + test "a waiver the driver did not declare would-fail keeps 8a's plain rendering" do + report = Report.new([result(["TRANSPORT-14"], :waived)]) + + assert_includes(report.to_s, "waived: TRANSPORT-14") + refute_includes(report.to_s, "would fail") + end + + test "an id appendix C does not hold is reported rather than silently never blocking" do + report = Report.new([result(["XCUT-99"], :vacuous), result(["XCUT-4"], :passed)]) + + assert_equal(["XCUT-99"], report.unknown_ids) + assert_predicate(report, :passed?, "an unknown id never blocks; unknown_ids is what shows it") + end + + test "the preamble still prints first, ahead of every phase-9 section" do + report = Report.new([result(["ASYNC-4"], :vacuous)], preamble: "reads a socket") + + assert_operator(report.to_s, :start_with?, "reads a socket\n") + assert_operator(report.to_s, :end_with?, "vacuity (design R3)") + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/runner_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/runner_test.rb new file mode 100644 index 0000000..15db1b0 --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/runner_test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace/conformance" + +# The five result statuses of 8a's assertion protocol, decided in ONE place for every suite phase +# 9 adds. NFR-17; design P9-10. +class DexpaceConformanceRunnerTest < DexpaceTestCase + Runner = Dexpace::Conformance::Runner + + def assertion(ids, name, &body) + Dexpace::Conformance::Assertion.build(ids: ids, name: name, body: body) + end + + test "an assertion that returns cleanly is passed" do + report = Runner.run([assertion(["X-1"], "clean") { |_s| nil }]) { :subject } + + assert_equal(:passed, report.results.first.status) + end + + test "a Failure is failed and its expected and actual both reach the detail" do + body = lambda do |_s| + raise Dexpace::Conformance::Failure.new("no", expected: 1, actual: 2, + requirement_ids: ["X-1"],) + end + report = Runner.run([assertion(["X-1"], "fails", &body)]) { :subject } + + assert_equal(:failed, report.results.first.status) + assert_includes(report.results.first.detail, "expected 1, got 2") + end + + test "a Vacuous is vacuous and carries its reason, never passed" do + body = ->(_s) { raise Dexpace::Conformance::Vacuous, "no adapter has this path" } + report = Runner.run([assertion(["X-1"], "vacuous", &body)]) { :subject } + + assert_equal(:vacuous, report.results.first.status) + assert_equal("no adapter has this path", report.results.first.detail) + end + + # The rescue order is load-bearing: Vacuous and Failure are both ::StandardError descendants, so + # a bare rescue placed above either turns a vacuity into an :error. + test "any other StandardError is errored, never silently a failure and never a vacuity" do + report = Runner.run([assertion(["X-1"], "boom") { |_s| raise TypeError, "nope" }]) { :subject } + + assert_equal(:error, report.results.first.status) + assert_includes(report.results.first.detail, "TypeError") + end + + test "a waiver matches by requirement id and never by assertion name" do + by_id = Runner.run([assertion(%w[X-1 X-2], "waived") { |_s| raise "never reached" }], + waive: ["X-2"],) { :subject } + by_name = Runner.run([assertion(%w[X-1 X-2], "waived") { |_s| raise "reached" }], + waive: ["waived"],) { :subject } + + assert_equal(:waived, by_id.results.first.status) + assert_equal(:error, by_name.results.first.status, "a name is not a requirement id") + end + + # testing/4ef070df applied to a subject the RUNNER constructs: a fixture shared across a run is + # what made this phase's own conforming executor double fail during planning. + test "the subject block is called once per assertion, never shared across the run" do + built = 0 + Runner.run([assertion(["X-1"], "a") { |_s| nil }, assertion(["X-2"], "b") { |_s| nil }]) do + built += 1 + :subject + end + + assert_equal(2, built) + end + + test "an around wrapper wraps every invocation and the assertion still runs inside it" do + order = [] + around = lambda do |&blk| + order << :before + blk.call + order << :after + end + Runner.run([assertion(["X-1"], "a") { |_s| order << :body }], around: around) { :subject } + + assert_equal(%i[before body after], order) + end + + test "the preamble, the acceptances and the would-fail declarations reach the Report" do + report = Runner.run([assertion(["ASYNC-3"], "a") { |_s| nil }], + waive: ["ASYNC-3"], preamble: "reads a socket", + accepted_vacuous: { "ASYNC-4" => "design §10.5" }, + would_fail: ["ASYNC-3"],) { :subject } + + assert_equal("reads a socket", report.preamble) + assert_equal({ "ASYNC-4" => "design §10.5" }, report.accepted_vacuous) + assert_includes(report.to_s, "waived (would fail): ASYNC-3") + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/shared_instance_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/shared_instance_test.rb new file mode 100644 index 0000000..0c8421b --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/shared_instance_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace/conformance" + +# XCUT-11's structural half: per-call mutable state must not live on a shared instance, and the +# two kinds that legitimately do must be declared BY THE DRIVER. Design R8, P9-9. +class DexpaceConformanceSharedInstanceTest < DexpaceTestCase + SharedInstance = Dexpace::Conformance::SharedInstance + + # Frozen with NO ivars -- the easy case, and the one that never reaches the branch that matters. + class FrozenEmpty + def initialize = freeze + end + + # Frozen WITH ivars: the shape three real first-party subjects take + # (Instrumentation::Redactor::DEFAULT, Instrumentation::Logger::NULL). A predicate demanding + # `frozen? && instance_variables.empty?` condemns every one of them, which is the false + # condemnation P9-9 exists to prevent -- so this double is the one that proves the disjunction. + class FrozenWithState + def initialize(policy) + @policy = policy + freeze + end + end + + # Unfrozen, with a latch and its mutex: the shape cross-cutting-invariants/89eb6533 prescribes. + class Latched + def initialize + @closed = false + @lock = Thread::Mutex.new + end + end + + # Per-call state on a shared instance: exactly what XCUT-11 forbids. + class PerCallState + def initialize = (@attempt = 0) + end + + test "a frozen instance with no state conforms" do + assert_nil(SharedInstance.audit(FrozenEmpty.new)) + end + + # The disjunction's first half, asserted against the exact shape the real tree carries. + test "a frozen instance holding state conforms on frozen? alone, with nothing declared" do + assert_nil(SharedInstance.audit(FrozenWithState.new({ "a" => 1 }))) + assert_nil(SharedInstance.audit(Dexpace::Instrumentation::Redactor::DEFAULT)) + assert_nil(SharedInstance.audit(Dexpace::Instrumentation::Logger::NULL)) + end + + test "an unfrozen latch and its mutex conform when the DRIVER declares them" do + assert_nil(SharedInstance.audit(Latched.new, mutable: %i[@closed @lock])) + end + + test "an undeclared mutable ivar on an unfrozen shared instance raises a Failure naming it" do + error = assert_raises(Dexpace::Conformance::Failure) { SharedInstance.audit(PerCallState.new) } + + assert_equal([:@attempt], error.actual) + assert_equal(["XCUT-11"], error.requirement_ids) + end + + test "declaring only some of the state still fails on the rest" do + assert_raises(Dexpace::Conformance::Failure) do + SharedInstance.audit(Latched.new, mutable: [:@lock]) + end + end + + # P9-9: an object that supplied its own exemption list could exempt the per-call state XCUT-11 + # forbids. `mutable:` is the driver's word, and the subject is never consulted. + test "a subject declaring its own per-call state as exempt is not believed" do + self_exempting = Class.new(PerCallState) do + def conformance_mutable_state = [:@attempt] + end + + assert_raises(Dexpace::Conformance::Failure) { SharedInstance.audit(self_exempting.new) } + end + + test "the requirement ids are overridable so a seam suite can cite its own pair" do + error = assert_raises(Dexpace::Conformance::Failure) do + SharedInstance.audit(PerCallState.new, ids: %w[XCUT-11 SEAM-12]) + end + + assert_equal(%w[XCUT-11 SEAM-12], error.requirement_ids) + end +end diff --git a/gems/dexpace-core/test/dexpace/cross_cutting_invariants_test.rb b/gems/dexpace-core/test/dexpace/cross_cutting_invariants_test.rb new file mode 100644 index 0000000..3fb9be5 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/cross_cutting_invariants_test.rb @@ -0,0 +1,266 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# XCUT-1, XCUT-2, XCUT-3, XCUT-5, XCUT-6, XCUT-7, XCUT-8, XCUT-9, XCUT-10, XCUT-11, XCUT-12, +# XCUT-13, XCUT-14, XCUT-15, XCUT-16, XCUT-17, XCUT-18, XCUT-19, XCUT-20, XCUT-21, XCUT-22, +# XCUT-23, XCUT-24. The first-party driver for dexpace-conformance's InvariantSuite (phase 9's +# design R6 and its plan Tasks 5-8): the suite is written against the loaded core module and a +# handful of factories it cannot build itself, and THIS file supplies them. +# +# Placement. The suite lives in dexpace-conformance and the subjects are core's, so the driver is +# in core's own test tree -- 8a's precedent, where the transport suite's driver lives in +# dexpace-transport-net_http/test/. Core's gemspec is untouched: dexpace-conformance is a path gem +# in the workspace Gemfile and this is a test-time require, exactly as 8a's driver requires it. +# +# One generated test per assertion, each building a FRESH InvariantCase (the Runner's own +# discipline: a fixture shared across a run is what made this phase's planning double pass +# spuriously), so a `-n` filter runs the same code a full run does. A Vacuous is a skip naming the +# reason, a Failure a flunk naming the ids; anything else propagates as an error and is never +# silently a failure. Beside them, ONE report-level test, because a per-assertion skip is not a +# disposition: the report is where a MUST-level vacuity becomes a blocker (plan Task 12a). +# +# MinitestDriver is deliberately not used: 8a wrote it around TransportCase and phase 9 reports +# rather than refactors phase 8's files (design R6). The residue is real -- two driver loops in +# one repository -- and is stated here rather than hidden. +require_relative "../test_helper" +require_relative "../support/auth_fixtures" +require_relative "../support/challenge_fixtures" +require_relative "../support/redirect_fixtures" +require_relative "../support/scripted_transport" +require "dexpace/conformance" + +# rubocop:disable Metrics/ClassLength -- one driver for twenty-eight assertions plus the +# factories none of them can build. Splitting it would put half the factories in a second +# file that has to be read with this one to mean anything, which is .rubocop.yml's own +# recorded argument against counting rather than reading. +class DexpaceCrossCuttingInvariantsTest < DexpaceTestCase + include AuthFixtures + include RedirectFixtures + + Conformance = Dexpace::Conformance + SUITE = Conformance::InvariantSuite + + # XCUT-18's second assertion drives a real adapter's dispatch path, and core ships no adapter: + # its `transport:` factory is 8a's, supplied by the aggregate run (phase 9 plan, Task 16 Step 2), + # and each first-party adapter carries its own wire-boundary test besides. Vacuous HERE and real + # THERE -- which is why it is accepted with a citation rather than waived, and why the MODEL-layer + # XCUT-18 assertion in the same group runs for real in this file. + ACCEPTED_VACUOUS = { + "XCUT-18" => "the call-site re-validation is an adapter property: the aggregate run supplies " \ + "transport: (phase 9 plan, Task 16 Step 2) and phase 8's adapters each carry " \ + "their own wire-boundary test. The model-layer XCUT-18 assertion runs here.", + }.freeze + + # The key the shared stamper writes, and the header the cross-talk observation reads back. + CREDENTIAL_HEADER = "Authorization" + LABEL_HEADER = "X-Conformance-Label" + # One shared, frozen KeyStamper, reached by every thread and both fibers. A CONSTANT rather than + # an ivar of the seam, so the seam's own ivars are exactly the latch, the lock, the count it + # guards and the borrowed resource -- and `mutable:` below stays a statement about lifecycle + # state and not a list of everything the object happens to hold. + STAMPER = Dexpace::Auth::KeyStamper.new(Dexpace::Auth::KeyCredential.new(api_key: "conformance")) + + # The closeable, callable SDK component XCUT-11, XCUT-13 and XCUT-22 are all assertions about. + # + # Assembled from core's own primitives and nothing else: the latch, the ownership rule and the + # once-only release are Dexpace::Closeable's, and the per-call work runs through the shared + # frozen KeyStamper. `owned:` is the whole of XCUT-22 -- a caller-supplied client makes this a + # BORROWING wrapper, so core's #close flips the latch and returns without running #release, which + # is the only thing standing between the SDK and closing a resource it did not create. + class Seam + include Dexpace::Closeable + + # @return [Integer] how many times #release actually ran -- the resource-side count XCUT-13's + # idempotency is measured with, never inferred from #close's return value + attr_reader :release_count + + def initialize(client: nil) + @client = client + @lock = ::Thread::Mutex.new + @release_count = 0 + initialize_closeable(owned: client.nil?) + end + + # No lock is taken here, deliberately: XCUT-11's second clause drives two fibers of ONE thread + # through this method on either side of a Fiber.yield, and Ruby's Mutex is per-fiber and + # non-reentrant, so a lock held across that yield deadlocks. The work is a fresh request per + # call through a frozen shared stamper, so there is nothing to guard. + # + # @param label [String] this call's identity, carried into the request and read back out + # @return [Array(String, String)] the label as the stamped request carries it, and the key + def call(label) + builder = Dexpace::Headers.builder + builder.add(LABEL_HEADER, label) + request = Dexpace::Request.build(method: "GET", url: "https://a.example/x", + headers: builder.build,) + stamped = STAMPER.call(request) + [stamped.headers[LABEL_HEADER].first, stamped.headers[CREDENTIAL_HEADER].first] + end + + private + + # Genuinely closes what it holds, so `owned: false` and not an omission is what spares a + # borrowed client. A seam that never touched its client would pass XCUT-22 by accident. + def release + @lock.synchronize { @release_count += 1 } + @client&.close + nil + end + end + + # Every ivar Seam holds, which is what design R8's predicate measures an unfrozen shared instance + # against: Closeable's three (`@dexpace_owned`, `@dexpace_closed` and the latch mutex it is + # written under), the lock, the count that lock guards, and the borrowed client. Nothing here is + # per-call state, which is the thing XCUT-11 forbids and this declaration makes visible. + SEAM_MUTABLE = %i[ + @dexpace_owned @dexpace_closed @dexpace_close_mutex @lock @release_count @client + ].freeze + + SUITE.assertions.each_with_index do |assertion, index| + define_method(format("test_%02d_%s", n: index, + name: assertion.name.gsub(/\W+/, "_"),)) do + drive(assertion) + end + end + + test "the suite reports no failure, no error and no unaccepted MUST-level vacuity" do + report = SUITE.run(**suite_arguments) + + assert_empty(report.failures.map { |result| described(result) }) + assert_empty(report.errors.map { |result| described(result) }) + assert_empty(report.blocking_vacuities.map { |result| result.assertion.ids.join(", ") }, + "a MUST-level vacuity is a report blocker until it is accepted with a citation",) + assert_predicate(report, :passed?) + end + + # The accepted vacuity is a claim about THIS driver, so it is asserted rather than described: if + # core ever gains a transport factory here, the acceptance is dead weight and this goes red. + test "the one accepted vacuity is the call-site assertion, and it is really vacuous here" do + report = SUITE.run(**suite_arguments) + + assert_equal(1, report.accepted_vacuities.size) + assert_equal(["XCUT-18"], report.accepted_vacuities.first.assertion.ids) + assert_equal(1, report.vacuous.size, "a second vacuity appeared with no citation behind it") + end + + # Design R8's list, audited through the same predicate the suite applies to the seam. Frozen + # instances pass on `frozen?` alone; the two unfrozen ones declare what they hold, and a subject + # that grew a per-call ivar since this list was written goes red here. + test "every shared instance core publishes holds only declared lifecycle state" do + shared_instances.each do |(label, object, declared)| + Conformance::SharedInstance.audit(object, mutable: declared) + rescue Conformance::Failure => error + flunk("#{label}: #{error.message} (declared #{error.expected.inspect}, holds " \ + "#{error.actual.inspect})") + end + end + + private + + def described(result) = "#{result.assertion.ids.join(", ")}: #{result.detail}" + + def drive(assertion) + assertion.call(invariant_case) + rescue Conformance::Vacuous => error + skip("vacuous: #{error.reason}") + rescue Conformance::Failure => error + flunk("#{assertion.ids.join(", ")}: #{error.message} (expected #{error.expected.inspect}, " \ + "got #{error.actual.inspect})") + end + + def invariant_case = Conformance::InvariantCase.new(**suite_arguments.except(:accepted_vacuous)) + + def suite_arguments + { core: ::Dexpace, seam: ->(client: nil) { Seam.new(client: client) }, + mutable: SEAM_MUTABLE, shared: shared_instances, bounded_map: bounded_map_factory, + bounded_map_store: bounded_map_store_reader, cnonce: cnonce_driver, + redirect_hops: redirect_driver, credential_hop: credential_driver, + accepted_vacuous: ACCEPTED_VACUOUS, } + end + + # Dexpace::BoundedMap is a private_constant of Dexpace, reachable by its BARE name from a + # full-nesting body and by Dexpace.const_get, but not by the scoped spelling (4a's P4-3) -- which + # is exactly why the suite takes a factory instead of naming it. + def bounded_map_factory = ->(cap:) { ::Dexpace.const_get(:BoundedMap).new(cap: cap) } + + # XCUT-14's drain clause needs the backing Hash, which BoundedMap keeps private; the driver knows + # the name, so the suite never reaches into the object. + def bounded_map_store_reader = ->(map) { map.instance_variable_get(:@h) } + + # 6c's DigestHandler draws its cnonce inside #authorization_for, so the rendered header value is + # the only place the drawn bytes are observable. + def cnonce_driver + lambda do |source| + handler = Dexpace::Auth::DigestHandler.new( + Dexpace::Auth::PasswordCredential.build(username: "u", password: "p"), + cnonce_source: source, + ) + handler.authorization_for(Dexpace::Auth::Challenges.parse(ChallengeFixtures::DIGEST_MD5), + https_request,) + end + end + + # One hop through 6b's real step over a scripted 3xx, answering the RE-ISSUED request. The + # downgrade case never reaches the second script entry: the step raises before it re-issues, + # which is the refusal XCUT-17 clause (d) is about. + def redirect_driver + lambda do |from:, to:, headers:| + transport = ScriptedTransport.new([response_with(302, location: to), response_with(200)]) + # The suite hands each name its FULL value list, because XCUT-17 is about a header that may + # legitimately repeat; RedirectFixtures#seed_request adds one value per call. + seed = seed_request(from, headers: headers.flat_map { |n, vs| Array(vs).map { [n, _1] } }) + redirect_pipeline(redirect_step, transport).call(seed) + sent_requests(transport).last + end + end + + # One drive of 6c's real step, with 6b's cross-origin marker forked into the REDIRECT slot the + # step reads. The HTTPS guard raises out of the pipeline, which is the loud refusal XCUT-16 names. + def credential_driver + lambda do |url:, cross_origin: false| + step = Dexpace::Auth::Step.build(stamper: STAMPER) + transport = ScriptedTransport.new([closable_response(200)]) + pipeline = auth_pipeline(step, transport, redirect_state: { cross_origin: cross_origin }) + pipeline.call(Dexpace::Request.build(method: "GET", url: url, + headers: Dexpace::Headers::EMPTY,)) + transport.calls.last.first + end + end + + # Design R8's nine subjects and the rest core publishes, as [label, object, declared ivars]. + # A frozen instance declares nothing: it conforms on `frozen?` alone, whatever it holds. + def shared_instances + instrumentation_singletons + [ + ["Dexpace::ContextStore.default", Dexpace::ContextStore.default, [:@map]], + ["Dexpace::Clock::SYSTEM", Dexpace::Clock::SYSTEM, []], + ["Dexpace::Configuration::EMPTY", Dexpace::Configuration::EMPTY, []], + ["Dexpace::Resilience::RetrySettings", Dexpace::Resilience::RetrySettings.build, []], + ["Dexpace::Resilience::RetryStep", Dexpace::Resilience::RetryStep.build, []], + ["Dexpace::Resilience::AsyncRetryStep", Dexpace::Resilience::AsyncRetryStep.build, []], + ["Dexpace::Redirect::Step", Dexpace::Redirect::Step.build, []], + ["Dexpace::Auth::KeyStamper", STAMPER, []], + ["Dexpace::Auth::Step", Dexpace::Auth::Step.build(stamper: STAMPER), []], + # The one unfrozen pillar: the pipeline both runtimes share. Closeable's three, the frozen + # entry and step lists it resolved once, the transport it dispatches to and the driver class + # it instantiates per call -- Cursor is 4c's deliberate per-call state and lives on the stack, + # never here. + ["Dexpace::Pipeline.standard", Dexpace::Pipeline.standard(ScriptedTransport.new([])), + %i[@dexpace_owned @dexpace_closed @dexpace_close_mutex @entries @steps @transport + @driver_class],], + ] + end + + # 5c's no-op singletons and 5b's redaction pair: every one frozen, which is R8's first half. + def instrumentation_singletons + instrumentation = Dexpace::Instrumentation + { "NO_SPAN" => instrumentation::NO_SPAN, "NO_TRACER" => instrumentation::NO_TRACER, + "NO_TRACER_FACTORY" => instrumentation::NO_TRACER_FACTORY, + "NO_SCOPE" => instrumentation::NO_SCOPE, "NO_METER" => instrumentation::NO_METER, + "NULL_SINK" => instrumentation::NULL_SINK, "NULL (http tracer)" => instrumentation::NULL, + "Logger::NULL" => instrumentation::Logger::NULL, + "Redactor::DEFAULT" => instrumentation::Redactor::DEFAULT, + "RedactionPolicy::DEFAULT" => instrumentation::RedactionPolicy::DEFAULT, } + .map { |name, object| ["Dexpace::Instrumentation::#{name}", object, []] } + end +end +# rubocop:enable Metrics/ClassLength diff --git a/gems/dexpace-serde-json/test/dexpace/serde/json/conformance_test.rb b/gems/dexpace-serde-json/test/dexpace/serde/json/conformance_test.rb new file mode 100644 index 0000000..d56c563 --- /dev/null +++ b/gems/dexpace-serde-json/test/dexpace/serde/json/conformance_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# SEAM-20, SERDE-3, SERDE-9. The first-party driver for dexpace-conformance's CodecSuite (phase 9's +# plan Task 10), placed in the adapter gem's own test tree exactly as 8a placed the transport +# suite's driver in dexpace-transport-net_http/test/. +# +# This gem's own seam_conformance_test.rb stays exactly as 7a wrote it and is NOT replaced by this +# file. The two are different claims: that one asserts six properties against THIS codec, four of +# which are not portable (SEAM-21's explicit type token, SERDE-4's buffer profile, SERDE-12's +# I/O-error pass-through, SERDE-29's shareability); this one asserts the two portable properties +# through the shared suite, which is what the post-v1 dexpace-serde-oj will be held to as well. +# Running both is the point -- a codec that passed the shared suite and failed the local one would +# be a codec the shared suite is too weak for, which is a finding about the suite. +require_relative "../../../test_helper" +require "dexpace/serde/json" +require "dexpace/conformance" + +# The JSON codec's run of the shared codec suite: one generated test per assertion, plus the +# report-level check and one run under every option this adapter accepts. +class DexpaceSerdeJSONConformanceTest < DexpaceTestCase + Conformance = Dexpace::Conformance + SUITE = Conformance::CodecSuite + + # The one witness the two assertions need: the parsed value as it is. Named, not a lambda -- + # Serde.witness? is a respond_to? on .dexpace_load and NOTHING else makes a witness, so a + # #call-shaped double would not be one (7a's R2). + module PassThrough + # @param parsed [Object] whatever the codec's parser produced + # @return [Object] the same value + def self.dexpace_load(parsed, _ctx) = parsed + end + + # The source type THIS codec's #load reads. A different codec's driver passes a different one, + # which is why the suite takes a factory rather than naming a class. + SOURCE = ->(text) { Dexpace::IO::BufferedSource.of_bytes(text.b) } + + SUITE.assertions.each_with_index do |assertion, index| + define_method(format("test_%02d_%s", n: index, + name: assertion.name.gsub(/\W+/, "_"),)) do + drive(assertion) + end + end + + test "the suite reports no failure, no error and no MUST-level vacuity" do + report = SUITE.run(**suite_arguments) + + assert_empty(report.failures.map { |result| described(result) }) + assert_empty(report.errors.map { |result| described(result) }) + assert_empty(report.blocking_vacuities.map { |result| result.assertion.ids.join(", ") }) + assert_empty(report.vacuous, "nothing here is vacuous: this gem supplies every factory") + assert_predicate(report, :passed?) + end + + # SERDE-3's "even when the codec's own auto-close feature is enabled" reaches every option this + # adapter accepts, so the portable assertions are driven under each rather than under the + # default alone -- the same widening 7a applied to its own suite. + test "the portable seam properties hold under every accepted option" do + [{ max_nesting: 4 }, { allow_nan: true }, { allow_duplicate_key: true }, { script_safe: true }, + { encoders: {} },].each do |options| + report = SUITE.run(**suite_arguments(-> { Dexpace::Serde::JSON.build(options) })) + + assert_predicate(report, :passed?, "#{options.inspect}: #{report}") + end + end + + private + + def described(result) = "#{result.assertion.ids.join(", ")}: #{result.detail}" + + def drive(assertion) + assertion.call(Conformance::CodecCase.new(**suite_arguments)) + rescue Conformance::Vacuous => error + skip("vacuous: #{error.reason}") + rescue Conformance::Failure => error + flunk("#{assertion.ids.join(", ")}: #{error.message} (expected #{error.expected.inspect}, " \ + "got #{error.actual.inspect})") + end + + # `.default` is a FRESH instance per call (7a's P7-4), so every assertion gets a codec of its own + # without the driver arranging it. + def suite_arguments(build = -> { Dexpace::Serde::JSON.default }) + { build: build, witness: PassThrough, source: SOURCE } + end +end diff --git a/test/fixtures/gates/invariants/delegates_cause.rb b/test/fixtures/gates/invariants/delegates_cause.rb new file mode 100644 index 0000000..30438b6 --- /dev/null +++ b/test/fixtures/gates/invariants/delegates_cause.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The positive control: this file delegates to the single walk and names `.cause` only in prose +# and in a requirement ID. A grep would report all three lines; the AST reports none. +# +# XCUT-9: "any classification that walks an error's cause chain MUST be cycle-safe". The walk +# lives in Dexpace.each_cause and nowhere else, so a classifier reads the chain through it. +module Delegates + # Walks the cause chain -- through the one walk, never by reading #cause here. + def self.retryable?(error) + ::Dexpace.each_cause(error).any? { |one| one.respond_to?(:retryable?) && one.retryable? } + end + + def self.prose = "read the cause chain through Dexpace.each_cause, never error.cause" +end diff --git a/test/fixtures/gates/invariants/hash_shapes.rb b/test/fixtures/gates/invariants/hash_shapes.rb new file mode 100644 index 0000000..bdaab33 --- /dev/null +++ b/test/fixtures/gates/invariants/hash_shapes.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Every Hash-valued instance-variable shape, decidable and not. +module Bad + class Store + def initialize(seed) + @a = {} + @b = Hash.new(0) + @c = ::Hash.new + @d = {}.compare_by_identity + @e = build_map # undecidable: the stated gap + @f = seed # undecidable: the stated gap + @cap = 1024 # not a Hash; must NOT be reported + @name = "store" # not a Hash; must NOT be reported + end + + def lazily + @g ||= {} # decidable through OP_ASGN_OR + end + + def build_map = {} + end +end diff --git a/test/fixtures/gates/invariants/reflective_cause.rb b/test/fixtures/gates/invariants/reflective_cause.rb new file mode 100644 index 0000000..3d0c532 --- /dev/null +++ b/test/fixtures/gates/invariants/reflective_cause.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Every shape that reaches #cause by NAME rather than by call syntax, plus the one that is +# statically undecidable and is the gate's stated gap. +module Bad + def self.a(error) = error.cause + def self.b(error) = error&.cause + def self.c(error) = error.send(:cause) + def self.d(error) = error.__send__(:cause) + def self.e(error) = error.public_send(:cause) + def self.f(error) = error.method(:cause).call + def self.g(error) = error.send("cause") # a String literal, caught the same way + def self.h(error, name) = error.send(name) # undecidable: the stated gap +end diff --git a/test/fixtures/gates/invariants/seam_shapes.rb b/test/fixtures/gates/invariants/seam_shapes.rb new file mode 100644 index 0000000..0631a3d --- /dev/null +++ b/test/fixtures/gates/invariants/seam_shapes.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Every way core could name a concrete seam implementation, and four constants it legitimately +# owns in the same namespaces. +module Dexpace + module Bad + A = Dexpace::Serde::JSON + B = ::Dexpace::Serde::JSON + C = Serde::JSON + D = Object.const_get("Dexpace::Transport::NetHTTP") + E = Object.const_get(:"Dexpace::Async::Thread") # a Symbol literal, caught the same way + F = Dexpace::Serde::JSON::Codec # a leaf pair deeper in the path + + OK1 = Dexpace::Registry # a core constant; must NOT be reported + OK2 = Dexpace::Serde::Error # the seam's own error type; must NOT be reported + OK3 = Async::Future # core's own async surface; must NOT be reported + OK4 = Instrumentation::Severity # core's own instrumentation; must NOT be reported + end +end diff --git a/test/fixtures/gates/invariants/walks_cause.rb b/test/fixtures/gates/invariants/walks_cause.rb new file mode 100644 index 0000000..bb465bd --- /dev/null +++ b/test/fixtures/gates/invariants/walks_cause.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# A .cause in a comment and in a string, which a grep reports and the AST does not. +module Walks + def self.a(error) = error.cause # :CALL + def self.b(error) = error&.cause # :QCALL + def self.c(errors) = errors.map(&:cause) # :BLOCK_PASS + def self.d(logger, error) = logger.event(:warn).cause(error).emit # carries an argument + def self.s = "error.cause in a string" + + class Chained < ::StandardError + def bare = cause # :VCALL + def parens = cause() # :FCALL + end +end diff --git a/test/fixtures/gates/invariants/warns_unused.rb b/test/fixtures/gates/invariants/warns_unused.rb new file mode 100644 index 0000000..ebfb4d4 --- /dev/null +++ b/test/fixtures/gates/invariants/warns_unused.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Parsed, never required: this file's own -w diagnostic IS the input. `e` is bound and unused -- +# the shape 8a filed before commit 152ec6a dropped the binding, `rescue ::StandardError => e` +# inside Adapter#dispatch, which emits "assigned but unused variable - e" on 3.2.11, 3.3.12, +# 3.4.10 and 4.0.6 when $VERBOSE is true. Nothing here names #cause or assigns a Hash ivar, so +# every gate in the invariant set must report it CLEAN; what it proves is that the scan does not +# RAISE under the warnings-fatal test case (NFR-6). +module WarnsUnused + def self.call(pump) + pump.head + rescue ::StandardError => e + pump.close + raise + end +end diff --git a/test/fixtures/gates/invariants/workspace/gems/dexpace-core/lib/dexpace/each_cause.rb b/test/fixtures/gates/invariants/workspace/gems/dexpace-core/lib/dexpace/each_cause.rb new file mode 100644 index 0000000..10c6680 --- /dev/null +++ b/test/fixtures/gates/invariants/workspace/gems/dexpace-core/lib/dexpace/each_cause.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The ALLOWLISTED file, at the path CAUSE_WALK_ALLOWED names, so a fixture run proves the +# allowlist is joined to the gate root rather than to the process's CWD: this walk must NOT be +# reported while the sibling one is. +module Dexpace + def self.each_cause(error) + seen = error + seen = seen.cause while seen.cause + seen + end +end diff --git a/test/fixtures/gates/invariants/workspace/gems/dexpace-core/lib/dexpace/offender.rb b/test/fixtures/gates/invariants/workspace/gems/dexpace-core/lib/dexpace/offender.rb new file mode 100644 index 0000000..1b15003 --- /dev/null +++ b/test/fixtures/gates/invariants/workspace/gems/dexpace-core/lib/dexpace/offender.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Deliberately non-conforming, in core's own tree: a second cause walk (XCUT-9) and a reference to +# a concrete adapter namespace (SEAM-2). Both must be reported when the gates run against this +# workspace. +module Dexpace + module Offender + def self.root(error) + error.cause + end + + def self.codec + Dexpace::Serde::JSON::Codec + end + end +end diff --git a/test/fixtures/gates/invariants/workspace/gems/dexpace-probe/lib/dexpace/probe/cache.rb b/test/fixtures/gates/invariants/workspace/gems/dexpace-probe/lib/dexpace/probe/cache.rb new file mode 100644 index 0000000..b3c70e0 --- /dev/null +++ b/test/fixtures/gates/invariants/workspace/gems/dexpace-probe/lib/dexpace/probe/cache.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Deliberately non-conforming, in a gem that is not core: an instance-lived Hash keyed by whatever +# a caller hands it, which is the shape XCUT-14 reserves for Dexpace::BoundedMap. In a gem OTHER +# than core so the fixture also proves the bounded_map scan reaches every gem's lib/ while +# seam_names reaches core's alone. +module Dexpace + module Probe + class Cache + def initialize + @by_origin = {} + end + + def store(origin, value) + @by_origin[origin] = value + end + end + end +end diff --git a/test/fixtures/gates/serde_boundary/pending_probe.rb b/test/fixtures/gates/serde_boundary/pending_probe.rb new file mode 100644 index 0000000..95c01a4 --- /dev/null +++ b/test/fixtures/gates/serde_boundary/pending_probe.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The deliberately failing fixture for gates:serde_boundary's PENDING-empty clause (SSE-37). +# +# The list is a frozen constant of the tool, so there is no environment variable or gate root that +# can make the TASK see a non-empty one -- and without this the task's abort branch was never +# driven: a mutation that deleted the abort outright left every test green. Loaded with +# `RUBYOPT=-r`, it requires the tool first (so the task's own require_relative is a +# no-op) and prepends one row onto `.pending`. The glob matches nothing, which is exactly the +# state a pending row is in. +require_relative "../../../../tools/serde_boundary" + +module SerdeBoundaryPendingProbe + ROW = ["gems/dexpace-core/lib/dexpace/nowhere/**/*.rb", + "a fixture row, so the task's abort branch is driven",].freeze + + def pending(root, list: nil) + super(root, list: (list || [ROW])) + end +end + +SerdeBoundary.singleton_class.prepend(SerdeBoundaryPendingProbe) diff --git a/test/gates/appendix_b_test.rb b/test/gates/appendix_b_test.rb new file mode 100644 index 0000000..1562da2 --- /dev/null +++ b/test/gates/appendix_b_test.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The appendix-B coverage map. Design R7, P9-7, P9-8. +# +# Three decidable checks; the map's own preamble states what they do NOT establish -- that a +# referenced test asserts the behaviour its item describes -- because nothing mechanical can, +# short of re-implementing the assertion. +require_relative "../support/gate_case" +require_relative "../../tools/appendix_b" + +class AppendixBTest < GateCase + SPEC = File.join(ROOT, "docs/product-spec/appendix-b-conformance-test-checklist.md") + MAP = File.join(ROOT, "gems/dexpace-conformance/APPENDIX_B.md") + SECTIONS = { "B.1" => 10, "B.2" => 6, "B.3" => 7, "B.4" => 8, "B.5" => 6, + "B.6" => 5, "B.7" => 6, "B.8" => 6, "B.9" => 7, }.freeze + + test "the map carries exactly one row per checklist item" do + assert_equal(61, AppendixB.spec_item_count(SPEC)) + assert_equal(AppendixB.spec_item_count(SPEC), AppendixB.rows(MAP).size) + end + + test "the per-section counts match the specification's own" do + assert_equal(SECTIONS, AppendixB.spec_counts_by_section(SPEC)) + assert_equal(SECTIONS, AppendixB.counts_by_section(MAP)) + end + + # Strictly stronger than "every row names at least one ID", and it makes the distinct-ID + # coverage check hold BY CONSTRUCTION: the union of 61 equal sets is appendix B's own 276. + test "every row's ID column equals the set parsed from that item's own text" do + spec = AppendixB.spec_items(SPEC).to_h { |(s, i, t)| [[s, i.to_s], AppendixB.ids_in(t).sort] } + wrong = AppendixB.rows(MAP).reject do |row| + spec.fetch([row[:section], row[:item]], nil) == row[:ids].sort + end + + assert_empty(wrong.map { |row| "#{row[:section]} item #{row[:item]}" }) + end + + test "the 61 items name 276 distinct ids, none repeated across items, at most 12 in one" do + sets = AppendixB.spec_items(SPEC).map { |(_, _, text)| AppendixB.ids_in(text) } + + assert_equal(276, sets.flatten.uniq.size) + assert_equal(sets.flatten.size, sets.flatten.uniq.size, "no ID appears in two items") + assert_equal(12, sets.map(&:size).max) + end + + test "the id scanner does not mistake ISO-8601 or an RFC number for a requirement id" do + assert_empty(AppendixB.ids_in("ISO-8601 dates round-trip and RFC-3986 encoding applies")) + assert_equal(%w[SERDE-24], AppendixB.ids_in("ISO-8601 dates round-trip (SERDE-24)")) + end + + test "every row's evidence path exists on disk" do + missing = AppendixB.rows(MAP).map { |row| row[:evidence] } + .reject { |path| path.nil? || File.exist?(File.join(ROOT, path)) } + + assert_empty(missing) + end + + test "every row carries one of the statuses the map's own table names" do + unknown = AppendixB.rows(MAP).map { |row| row[:status] }.uniq - AppendixB::STATUSES + + assert_empty(unknown) + end + + # `- [ ]` and never `- [`: an item ticked by hand would otherwise vanish from the count, which is + # the drift this check exists to catch. + test "the item scanner counts unticked boxes only" do + ticked = "- [x] a ticked item (PAGE-1)\n- [ ] an unticked one (PAGE-2)\n" + path = File.join(Dir.mktmpdir("appendix-b"), "spec.md") + File.write(path, "### B.1 Pagination\n\n#{ticked}") + + assert_equal(1, AppendixB.spec_item_count(path)) + end + + # Regenerating must be a no-op on a current map: the generator carries every hand-written + # evidence and status cell over, so a regeneration never silently rewrites a judgement. + test "regenerating the map from the specification reproduces it byte for byte" do + assert_equal(File.read(MAP), AppendixB.generate(SPEC, MAP)) + end +end diff --git a/test/gates/invariant_gates_test.rb b/test/gates/invariant_gates_test.rb new file mode 100644 index 0000000..05d9859 --- /dev/null +++ b/test/gates/invariant_gates_test.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The three repository-wide invariant scans. XCUT-9, XCUT-14, SEAM-2; design R4. +# +# A scanner that let a scanned file's own -w diagnostic reach `Warning.warn` would redden any +# suite with a warnings-fatal hook over a file it merely READS. That property is proven here by +# RECORDING the warning rather than by arming the raiser: `test/gates/` runs on GateCase, which +# does not install FatalWarnings, and installing it from a support file arms it for the whole +# process -- where three deliberately-invalid gemspec fixtures and YARD's own load-time warnings +# had always reached Warning.warn harmlessly. +require_relative "../support/gate_case" +require_relative "../support/gate_warning_capture" +require_relative "../../tools/invariant_gates" + +# rubocop:disable Metrics/ClassLength -- three scans, each with its own fixture, its own +# allowlist and its own rake task, in the one file a reader checking "do the invariant +# gates still hold?" opens. +class InvariantGatesTest < GateCase + include GateWarningCapture + + FIXTURES = File.expand_path("../fixtures/gates/invariants", __dir__) + + def fixture(name) = File.join(FIXTURES, name) + + # ---- gates:cause_walk ---- + + test "cause_walk reports CALL, QCALL, VCALL, FCALL and &:cause, not an argument-carrying send" do + offences = InvariantGates.cause_walk([fixture("walks_cause.rb")]) + + assert_equal(5, offences.size, + "six sends named cause; 5b's builder shape .cause(error) is not a walk",) + assert_includes(offences.first, "XCUT-9") + end + + test "cause_walk reports every reflective send shape, by Symbol and by String" do + offences = InvariantGates.cause_walk([fixture("reflective_cause.rb")]) + + assert_equal(7, offences.size, + "send/__send__/public_send/method by Symbol, send by String, plus . and &. -- " \ + "7 of 8; send(variable) is undecidable and is the gate's stated gap",) + end + + test "cause_walk is clean for a file that delegates and names cause only in prose" do + assert_empty(InvariantGates.cause_walk([fixture("delegates_cause.rb")])) + end + + test "cause_walk honours its allowlist so the single walk's own file passes" do + assert_empty(InvariantGates.cause_walk([fixture("walks_cause.rb")], + allowed: [fixture("walks_cause.rb")],)) + end + + # ---- gates:bounded_map ---- + + test "bounded_map reports every statically decidable Hash shape" do + offences = InvariantGates.bounded_map([fixture("hash_shapes.rb")], allowed: {}) + + assert_equal(5, offences.size, + "literal, Hash.new, ::Hash.new, a chained call on a literal and an ||= -- " \ + "5 of 7; a method return and a parameter are the stated gap",) + assert_includes(offences.first, "XCUT-14") + end + + test "bounded_map is clean when the file is allowlisted with a reason" do + allowed = { fixture("hash_shapes.rb") => "keys are frozen constants, not caller input" } + + assert_empty(InvariantGates.bounded_map([fixture("hash_shapes.rb")], allowed: allowed)) + end + + # The allowlist IS the adjudication, so a stale entry silently re-opens a hole. The planning + # adjudication named `configuration.rb`, which holds no Hash ivar at all -- 5a's accumulators + # are in `configuration/builder.rb` -- and a check that only asserted the file EXISTS passed + # over it for a whole round. So this asserts both halves: the path exists AND the scan still + # reports there. XCUT-14. + test "every bounded_map allowlist entry names a live file that the scan still reports" do + refute_empty(InvariantGates::BOUNDED_MAP_ALLOWED) + + InvariantGates::BOUNDED_MAP_ALLOWED.each do |path, reason| + absolute = File.join(ROOT, path) + + assert_path_exists(absolute, "allowlisted path no longer exists; the entry is stale") + refute_empty(reason.to_s.strip, "#{path} is allowlisted with no reason") + refute_empty(InvariantGates.bounded_map([absolute], allowed: {}), + "#{path} is allowlisted but the scan reports nothing there, so the entry " \ + "silences nothing -- which is exactly how a stale entry survives",) + end + end + + # ---- gates:seam_names ---- + + test "seam_names matches an adapter's leaf namespace, not a literal spelling" do + offences = InvariantGates.seam_names([fixture("seam_shapes.rb")]) + + assert_equal(7, offences.size, + "qualified, root-qualified, bare, a const_get String, a const_get Symbol and a " \ + "leaf pair deeper in a path, which reports at both levels",) + end + + test "seam_names permits core's own constants in the seam namespaces" do + offences = InvariantGates.seam_names([fixture("seam_shapes.rb")]).join("\n") + + refute_includes(offences, "Dexpace::Registry") + refute_includes(offences, "Serde::Error") + refute_includes(offences, "Async::Future") + refute_includes(offences, "Instrumentation::Severity") + end + + # ---- the scanner's own warning window ---- + + # A scanned file's own -w diagnostics reach Warning.warn like any other parse. The first + # assertion proves the fixture is not inert; the third proves AstScan.parse's $VERBOSE window + # holds. Only under -w: SuiteRunner appends it, so `rake test:gates` sees this and a bare + # `ruby test/gates/...` does not. + test "a file whose own -w diagnostics reach Warning.warn is scanned without emitting one" do + unless $VERBOSE + skip("the fixture's diagnostic is only emitted under -w; SuiteRunner appends it") + end + + bare = capture_warnings { ::RubyVM::AbstractSyntaxTree.parse_file(fixture("warns_unused.rb")) } + through_the_gate = capture_warnings { InvariantGates.cause_walk([fixture("warns_unused.rb")]) } + + assert_equal(1, bare.size, "the fixture is inert, so this test could not discriminate") + assert_includes(bare.first.first, "assigned but unused variable - e") + assert_empty(through_the_gate, "AstScan.parse's $VERBOSE window did not hold") + end + + test "the scanner leaves $VERBOSE exactly as it found it" do + armed = $VERBOSE + InvariantGates.cause_walk([fixture("delegates_cause.rb")]) + + assert_equal(armed, $VERBOSE) + end + + # ---- the three gates over the real tree ---- + + test "all three gates are clean over this repository, which is what makes them blocking" do + lib = Dir.glob("gems/*/lib/**/*.rb", base: ROOT).map { |path| File.join(ROOT, path) } + core = Dir.glob("gems/dexpace-core/lib/**/*.rb", base: ROOT).map do |path| + File.join(ROOT, path) + end + allowed = InvariantGates::BOUNDED_MAP_ALLOWED.transform_keys { |key| File.join(ROOT, key) } + + assert_empty(InvariantGates.cause_walk(lib, allowed: [File.join(ROOT, *CAUSE_WALK)])) + assert_empty(InvariantGates.bounded_map(lib, allowed: allowed)) + assert_empty(InvariantGates.seam_names(core)) + end + + CAUSE_WALK = ["gems/dexpace-core/lib/dexpace/each_cause.rb"].freeze + + # ---- the three RAKE TASKS, against a deliberately failing workspace ---- + + # Every repository-reading gate here accepts DEXPACE_GATE_ROOT and is driven against a fixture + # that must fail it. These three were not, and the omission hid a real defect: the tasks handed + # the scanner paths made relative to `root`, which the scanner then opened relative to the + # PROCESS'S CWD -- the same directory only when DEXPACE_GATE_ROOT is unset. Under a fixture root + # every open raised Errno::ENOENT, so the tasks could never have been shown to reject anything. + # Found and fixed in this phase; these three tests are what keep it fixed. + WORKSPACE = File.join(FIXTURES, "workspace") + + test "the cause_walk task rejects a fixture workspace, with its allowlist under that root" do + _out, err, status = rake("gates:cause_walk", "DEXPACE_GATE_ROOT" => WORKSPACE) + + refute_predicate(status, :success?) + assert_includes(err, "gems/dexpace-core/lib/dexpace/offender.rb:10") + assert_includes(err, "XCUT-9") + refute_includes(err, "each_cause.rb", + "the allowlist was joined to the CWD rather than to the gate root",) + end + + test "the bounded_map task rejects a fixture workspace, reaching a gem that is not core" do + _out, err, status = rake("gates:bounded_map", "DEXPACE_GATE_ROOT" => WORKSPACE) + + refute_predicate(status, :success?) + assert_includes(err, "gems/dexpace-probe/lib/dexpace/probe/cache.rb") + assert_includes(err, "@by_origin") + assert_includes(err, "XCUT-14") + end + + test "the seam_names task rejects a fixture workspace and scans core alone" do + _out, err, status = rake("gates:seam_names", "DEXPACE_GATE_ROOT" => WORKSPACE) + + refute_predicate(status, :success?) + assert_includes(err, "gems/dexpace-core/lib/dexpace/offender.rb") + assert_includes(err, "Dexpace::Serde::JSON") + refute_includes(err, "dexpace-probe", "seam_names is core's scan, not every gem's") + end + + test "all three tasks pass over this repository, which is what makes them blocking" do + %w[gates:cause_walk gates:bounded_map gates:seam_names].each do |task| + out, err, status = rake(task) + + assert_predicate(status, :success?, "#{task}: #{err}") + assert_includes(out, task) + end + end +end +# rubocop:enable Metrics/ClassLength diff --git a/test/gates/phase9_ruby_facts_test.rb b/test/gates/phase9_ruby_facts_test.rb new file mode 100644 index 0000000..bb3f70e --- /dev/null +++ b/test/gates/phase9_ruby_facts_test.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The Ruby facts phase 9's machinery rests on, re-measured on the interpreter running this suite +# rather than inherited from the design's planning run. Design "The verified Ruby facts this phase +# is built on"; XCUT-9, XCUT-14, NFR-4, NFR-6, NFR-17. +# +# Two facts the plan stated are corrected here against the tree as built, and each correction is +# the assertion rather than a comment: +# +# * `.ruby-version` EXISTS and is the development pin; the plan says three times that it does +# not, and `gates:versions` asserts the file against VERSIONS on every run. +# * The `minitest/mock` split is invisible INSIDE the bundle, because phase 0 Task 2's +# `minitest ~> 5.25` pin has landed: `assert_equal(major < 6, mock_available)` reduces to +# `true == true` on every row. The honest measurement is a child process with the BUNDLE_* +# keys cleared, which is the shape gems/dexpace-serde-json/test/dexpace/serde/json/floor_test.rb +# uses for the same reason. +require "stringio" +require_relative "../support/gate_case" +require_relative "../support/gate_warning_capture" +require_relative "../../tools/versions" + +# rubocop:disable Metrics/ClassLength -- ten interpreter facts in one file, deliberately. Each +# is a measurement this phase's scans rest on, and a second class would only move half of +# them somewhere a reader checking "is this fact still true?" would not look. +class Phase9RubyFactsTest < GateCase + include GateWarningCapture + + WARNS_UNUSED = File.expand_path("../fixtures/gates/invariants/warns_unused.rb", __dir__) + + test "a send is one of three AST node types and a :CALL-only scan misses safe navigation" do + types = [] + walk(::RubyVM::AbstractSyntaxTree.parse("a.cause; b&.cause; cause")) do |node| + name = node.type == :VCALL ? node.children[0] : node.children[1] + types << node.type if name == :cause + end + + assert_equal(%i[CALL QCALL VCALL], types) + end + + test "a Symbol literal is :LIT below Ruby 3.4 and :SYM at and above it" do + found = [] + walk(::RubyVM::AbstractSyntaxTree.parse("e.send(:cause)")) do |node| + found << node.type if %i[LIT SYM].include?(node.type) + end + expected = ::Gem::Version.new(::RUBY_VERSION) < ::Gem::Version.new("3.4.0") ? [:LIT] : [:SYM] + + assert_equal(expected, found, + "a reflective-send scan naming only one of the two is strictest on the row it " \ + "was written on and blind on the others",) + end + + # The PARSER is silent for well-formed input; the SCANNED FILE's own -w diagnostics are not, and + # they arrive through Warning.warn exactly as at require time. Only under $VERBOSE: SuiteRunner + # appends -w, so `rake test:gates` sees them and a bare `ruby test/gates/...` does not. + test "a scanned file's own -w diagnostics reach Warning.warn, which the scanner silences" do + skip("this fact is only observable under -w; SuiteRunner appends it") unless $VERBOSE + + seen = capture_warnings { ::RubyVM::AbstractSyntaxTree.parse_file(WARNS_UNUSED) } + + assert_equal(1, seen.size) + assert_includes(seen.first.first, "assigned but unused variable - e") + assert_nil(seen.first.last, "no category, so Warning[:deprecated] = false cannot reach it") + end + + # $VERBOSE = false silences only the -w class: a duplicated hash key warns whatever $VERBOSE is + # and would still raise under FatalWarnings, so the window AstScan.parse opens is nil, not false. + test "$VERBOSE = nil silences both warning classes, and the window closes behind it" do + armed = $VERBOSE + unused = capture_warnings { quietly { ::RubyVM::AbstractSyntaxTree.parse_file(WARNS_UNUSED) } } + duplicated = capture_warnings { ::RubyVM::AbstractSyntaxTree.parse("{a: 1, a: 2}") } + silenced = capture_warnings { quietly { ::RubyVM::AbstractSyntaxTree.parse("{a: 1, a: 2}") } } + + assert_empty(unused) + assert_equal(1, duplicated.size, "a duplicated key warns whatever $VERBOSE is") + assert_empty(silenced) + assert_equal(armed, $VERBOSE, "the gate stays armed outside the window") + end + + # The recorder is inert outside a capture block: it delegates through `super`, so whatever a + # host has prepended below it -- DexpaceTestCase::FatalWarnings in every gem suite -- keeps its + # behaviour. This suite runs on GateCase, which arms no raiser, so delegation is observed at + # the real Warning.warn's own output rather than at a raise. + test "outside a capture block the recorder delegates rather than swallowing" do + captured = StringIO.new + previous = $stderr + begin + $stderr = captured + Warning.warn("escaped through the recorder\n") + ensure + $stderr = previous + end + + assert_includes(captured.string, "escaped through the recorder") + assert_equal(GateRecordingWarnings, Warning.singleton_class.ancestors.first, + "the recorder must sit ABOVE whatever else is prepended, or it never sees a call",) + end + + test "minitest is a bundled gem, never a default one, on every supported Ruby" do + spec = ::Gem::Specification.find_by_name("minitest") + + refute_predicate(spec, :default_gem?, + "design §9.3 calls minitest a default gem; it is bundled, which is why " \ + "phase 0 Task 2's Gemfile names it",) + end + + # Phase 0 Task 2's `minitest ~> 5.25` pin has LANDED, so inside the bundle every row is 5.x and + # ships minitest/mock. The fact the design measured -- Minitest 6 on the 4.0 row ships none -- + # is therefore only observable OUTSIDE the bundle, and asserting it in-process would reduce to + # `true == true` and prove nothing (this is the correction the phase's as-built record carries). + test "inside the bundle the pin holds minitest below 6, so minitest/mock is present everywhere" do + major = ::Gem::Version.new(::Minitest::VERSION).segments.first + mock_available = begin + require "minitest/mock" + true + rescue ::LoadError + false + end + + assert_operator(major, :<, 6, "phase 0 Task 2 pins minitest ~> 5.25") + assert(mock_available, "Minitest::Mock and Object#stub are what the pin keeps available") + end + + test "outside the bundle the interpreter's own minitest ships mock below 6 and not at 6" do + major, available = stock_minitest + + assert_equal(major < 6, available, + "the framework major the interpreter ships decides minitest/mock; the pin is " \ + "what keeps the repository off that divergence",) + end + + test "a Data subclass's generated readers live on the superclass, not on the subclass" do + type = Class.new(::Data.define(:code)) { def ok? = code == 200 } + + assert_equal([:ok?], type.instance_methods(false)) + assert_equal([:code], type.superclass.instance_methods(false)) + assert_predicate(type.new(code: 200), :frozen?) + end + + test "the development pin lives in .ruby-version, which gates:versions asserts" do + pin = File.read(File.join(ROOT, ".ruby-version")).strip + + assert_match(/\A\d+\.\d+\.\d+\z/, pin) + assert_equal(DexpaceVersions.ruby_dev, pin) + end + + private + + # The stock interpreter's minitest, read in a child process with every BUNDLE_*/BUNDLER_* key + # and RUBYOPT cleared -- floor_test.rb's shape, for the same reason: inside the bundle the pin + # answers, and the fact under test is about what the interpreter ships. + def stock_minitest + env = ENV.to_h.reject { |key, _| key.start_with?("BUNDLE_", "BUNDLER_", "RUBYOPT") } + env["RUBYOPT"] = "" + script = 'require "minitest"; ' \ + 'print Gem::Version.new(Minitest::VERSION).segments.first, " "; ' \ + 'print((begin; require "minitest/mock"; true; rescue LoadError; false; end))' + out, _err, status = Open3.capture3(env, Interpreter.ruby, "-e", script, unsetenv_others: true) + + assert_predicate(status, :success?, out) + major, available = out.split + [Integer(major), available == "true"] + end + + # AstScan.parse's window, spelled here so the fact is measured where it is recorded. + def quietly + previous = $VERBOSE + $VERBOSE = nil + yield + ensure + $VERBOSE = previous + end + + def walk(node, &block) + return unless node.is_a?(::RubyVM::AbstractSyntaxTree::Node) + + yield(node) + node.children.each { |child| walk(child, &block) } + end +end +# rubocop:enable Metrics/ClassLength diff --git a/test/gates/requirement_levels_test.rb b/test/gates/requirement_levels_test.rb new file mode 100644 index 0000000..8ab5c59 --- /dev/null +++ b/test/gates/requirement_levels_test.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The MUST-level vacuity blocker's level map: generated from appendix C and never allowed to drift +# from it. Appendix C is FROZEN; the tool under test reads it and writes only levels.rb. NFR-17; +# design R3. +require_relative "../support/gate_case" +require_relative "../../tools/requirement_levels" +require_relative "../../gems/dexpace-conformance/lib/dexpace/conformance" + +class RequirementLevelsTest < GateCase + PREFIXES = %w[SEAM HTTP IO BODY CTX PIPE RECOV RETRY REDIR AUTH PAGE SSE SERDE OBS CFG + TRANSPORT ASYNC XCUT NFR].freeze + + test "the committed map equals a fresh parse of appendix C" do + assert_equal(RequirementLevels.parse, Dexpace::Conformance::Levels::OF, + "levels.rb is stale: run `ruby tools/requirement_levels.rb`",) + end + + test "appendix C yields 645 ids over the 19 prefixes and every id has a level" do + map = RequirementLevels.parse + + assert_equal(645, map.size, + "CLAUDE.md: 645 numbered requirements; the spec is frozen, so a change here is " \ + "a human decision",) + assert_equal(PREFIXES.sort, map.keys.map { |id| id.sub(/-\d+\z/, "") }.uniq.sort) + assert_equal(%i[may must should], map.values.uniq.sort) + end + + # `MUST NOT` is a MUST with a negated predicate, and appendix C holds exactly one. + test "MUST NOT is a must" do + assert_equal(:must, RequirementLevels::LEVEL.fetch("MUST NOT")) + end + + test "the tool never writes appendix C" do + before = RequirementLevels::SOURCE.read + RequirementLevels.render(RequirementLevels.parse) + + assert_equal(before, RequirementLevels::SOURCE.read) + end + + # The failing fixture: one flipped level is a diff, not a pass. + test "a map with one level flipped does not equal the committed file" do + stale = RequirementLevels.render(RequirementLevels.parse.merge("ASYNC-4" => :should)) + + refute_equal(RequirementLevels::TARGET.read, stale) + end + + # Both exits, which the name always claimed and only the first half ever checked: a --check that + # returned 0 unconditionally passed this test until the phase's own mutation pass caught it. The + # stale file is written to a temp dir and named on the command line, so nothing under gems/ is + # touched and the failing half needs no fixture tree. + test "--check exits zero on the committed file and non-zero on a stale one" do + assert(system(RbConfig.ruby, "tools/requirement_levels.rb", "--check", chdir: ROOT), + "the committed levels.rb is stale",) + + Dir.mktmpdir do |dir| + stale = File.join(dir, "levels.rb") + rendered = RequirementLevels.render(RequirementLevels.parse) + File.write(stale, rendered.sub("=> :must", "=> :may")) + + refute(system(RbConfig.ruby, "tools/requirement_levels.rb", "--check", stale, chdir: ROOT), + "--check accepted a file that differs from a fresh render",) + end + end + + test "every id any suite declares is one appendix C knows" do + suites = [Dexpace::Conformance::InvariantSuite, Dexpace::Conformance::PackagingSuite, + Dexpace::Conformance::CodecSuite, Dexpace::Conformance::ExecutorSuite, + Dexpace::Conformance::TransportSuite,] + declared = Dexpace::Conformance::Aggregate.by_requirement_id(suites).keys + + assert_empty(declared.reject { |id| Dexpace::Conformance::Levels.known?(id) }) + end + + # An unknown id never blocks a report, so a typo in an assertion's `ids:` would be invisible. + test "an id appendix C does not hold answers :unknown rather than raising" do + assert_equal(:unknown, Dexpace::Conformance::Levels.of("XCUT-999")) + refute(Dexpace::Conformance::Levels.must?("XCUT-999")) + end +end diff --git a/test/gates/serde_boundary_pending_test.rb b/test/gates/serde_boundary_pending_test.rb new file mode 100644 index 0000000..232c593 --- /dev/null +++ b/test/gates/serde_boundary_pending_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# SSE-37's handed-forward clause, which 7b could not assert while phase 7 was still running: "the +# repository-wide check that its PENDING list is empty by the end of phase 7". Phase 9 asserts it, +# and the `list:` keyword below is the one-line widening of 7b's tool that lets this test drive a +# NON-empty list -- `PENDING` is a frozen constant, and reading it directly gave the fixture +# nothing to inject. +require_relative "../support/gate_case" +require_relative "../../tools/serde_boundary" + +class SerdeBoundaryPendingTest < GateCase + FIXTURES = File.join(ROOT, "test/fixtures/gates/serde_boundary") + PENDING_FIXTURE = [["gems/dexpace-core/lib/dexpace/nowhere/**/*.rb", + "a layer built beside a gate that predates it",]].freeze + + test "the committed PENDING list is empty, which is what SSE-37 handed forward" do + assert_empty(SerdeBoundary::PENDING) + assert_empty(SerdeBoundary.pending(ROOT)) + end + + # The failing state: with one pending row the gate must abort, naming the row and its reason. + test "a non-empty PENDING list produces the abort message the task aborts on" do + rows = SerdeBoundary.pending(ROOT, list: PENDING_FIXTURE) + + assert_equal(1, rows.size) + assert_equal(0, rows.first.last, "the fixture glob matches nothing, which is why it pends") + message = SerdeBoundary.pending_message(rows) + + assert_includes(message, "SerdeBoundary::PENDING is not empty") + assert_includes(message, "a layer built beside a gate that predates it") + assert_includes(message, "SSE-37") + end + + test "the gate itself is green and says so" do + out, _err, status = rake("gates:serde_boundary") + + assert_predicate(status, :success?) + assert_includes(out, "the pending list is empty (SSE-37)") + end + + # The TASK's abort branch, not just the message builder's. Without this, deleting the abort line + # outright left every test here green -- measured by mutation in this phase. PENDING is a frozen + # constant with no root or env that can make the task see a non-empty one, so the fixture is a + # RUBYOPT prelude that prepends one row onto `.pending`, which is this repository's + # deliberately-failing-fixture rule applied to the one gate clause that had no other route in. + test "the task aborts on a non-empty pending list, naming the row" do + probe = File.join(FIXTURES, "pending_probe.rb") + _out, err, status = rake("gates:serde_boundary", "RUBYOPT" => "-r#{probe}") + + refute_predicate(status, :success?, "the task accepted a non-empty PENDING list") + assert_includes(err, "SerdeBoundary::PENDING is not empty") + assert_includes(err, "a fixture row, so the task's abort branch is driven") + end +end diff --git a/test/support/gate_warning_capture.rb b/test/support/gate_warning_capture.rb new file mode 100644 index 0000000..1b2a555 --- /dev/null +++ b/test/support/gate_warning_capture.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# NFR-6's gate is DexpaceTestCase::FatalWarnings, PREPENDED to Warning's singleton class by phase +# 0. A prepended module sits ABOVE the singleton class's own methods, so a +# `Warning.define_singleton_method(:warn)` lands BELOW FatalWarnings and never receives the call: +# it raises nothing and captures nothing (measured on 3.2.11, 3.3.12, 3.4.10 and 4.0.6). Prepend +# order is the whole reason for this shape -- a SECOND module prepended ABOVE FatalWarnings -- +# and a thread-local sink keeps the capture scoped: outside a capture block, FatalWarnings still +# raises. DexpaceTestCase is required here rather than left to the test file, so the two prepends +# land in that order however the gate suite is loaded. +# +# Named GateWarningCapture and not WarningCapture, and its prepended module GateRecordingWarnings: +# gems/dexpace-core/test/support/warning_capture.rb already defines a top-level `WarningCapture` +# with a different API (`self.record` / `self.recorded` / `self.recording?`), and this +# repository's rule is that a top-level test-support constant is unique across every suite -- +# reopening one module with two incompatible APIs is the failure that rule exists to prevent +# (8a's checklist, departure 35). +# +# **This file deliberately does NOT require dexpace_test_case.** `test/gates/` is the one suite +# whose base is GateCase and not DexpaceTestCase, so FatalWarnings is not armed there -- and +# arming it from here armed it for the WHOLE test:gates process, where `Gem::Specification.load` +# on three deliberately-invalid gemspec fixtures and YARD's own load-time warnings had always +# reached `Warning.warn` harmlessly. Measured: four other gate tests went red. What this file +# needs is the recorder, not the raiser. + +# The recorder, above FatalWarnings. Inert unless THIS thread is inside a capture block. +module GateRecordingWarnings + SINK = :dexpace_gate_warning_sink + + def warn(message, category: nil) + sink = ::Thread.current[SINK] + return super if sink.nil? + + sink << [message, category] + nil + end +end +Warning.singleton_class.prepend(GateRecordingWarnings) + +# Mixed into a gate test that needs to OBSERVE a warning rather than die of it. +module GateWarningCapture + # @yield the block whose warnings are captured + # @return [Array] every [message, category] pair emitted inside + def capture_warnings + sink = [] + previous = ::Thread.current[GateRecordingWarnings::SINK] + ::Thread.current[GateRecordingWarnings::SINK] = sink + yield + sink + ensure + ::Thread.current[GateRecordingWarnings::SINK] = previous + end +end From aa79d6c51671ef97c3ce057b78e51c9acf9600b3 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 23 Sep 2026 23:58:44 +0300 Subject: [PATCH 06/17] test: drive the bounded waits, the default resolve and the prefix list Review round 0, R0-1 through R0-5, plus the defect R0-4's test found. R0-1. Three tests for the three waits that carried no bound, each driving ONE assertion: a subject that blocks or refuses everywhere is non-conforming in several ways and these are about the bound. An executor that refuses the post makes ASYNC-3 :vacuous; one whose #post blocks fails SEAM-12; a shared instance whose #call never returns fails XCUT-11's first clause. Each double reports its own parked threads, so the release joins every one and the thread count is where teardown expects it. R0-2. The NFR-14 case now asserts the :vacuous its own name always claimed, with the report's reason, and a second case covers the partial source the assertion's comment describes. R0-3. The interpreter-facts test asserted a warning COUNT of one and was red on the declared 3.2 floor, whose parser also reports `unused literal ignored` for the same source. The fact is which $VERBOSE value silences the class, so the assertion is presence and the comment records the measurement. R0-4. PackagingCase::DEFAULT_RESOLVE is public locked surface and ran in no test, because every case supplied its own `resolve:`. It does now -- and the first run of it found the defect the accompanying code commit fixes, which is the whole reason a shipped default needs a test. R0-5. RequirementLevels::ROW's nineteen-prefix restriction was asserted by nothing: loosening it to `[A-Z]+-\d+` left the map at 645 rows over this repository's own appendix C. A synthetic table holding an RFC number and an ISO date in the id column is what discriminates, matching the case the sibling scanner in tools/appendix_b.rb already carries. --- .../conformance/executor_suite_test.rb | 77 ++++++++++++++++++- .../conformance/invariant_suite_test.rb | 62 +++++++++++++++ .../conformance/packaging_suite_test.rb | 30 +++++++- test/gates/phase9_ruby_facts_test.rb | 7 +- test/gates/requirement_levels_test.rb | 19 +++++ 5 files changed, 191 insertions(+), 4 deletions(-) diff --git a/gems/dexpace-conformance/test/dexpace/conformance/executor_suite_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/executor_suite_test.rb index 174aa19..b28e8e1 100644 --- a/gems/dexpace-conformance/test/dexpace/conformance/executor_suite_test.rb +++ b/gems/dexpace-conformance/test/dexpace/conformance/executor_suite_test.rb @@ -7,7 +7,7 @@ # Appendix B.7's lifecycle half and the harness for SEAM-25's lifecycle event, proven against # deliberately non-conforming executors. # SEAM-12, SEAM-25, ASYNC-3, ASYNC-15, ASYNC-16, ASYNC-17, XCUT-11, XCUT-13, XCUT-22. -class DexpaceConformanceExecutorSuiteTest < DexpaceTestCase +class DexpaceConformanceExecutorSuiteTest < DexpaceTestCase # rubocop:disable Metrics/ClassLength -- the non-conforming executors this suite must fail against live beside the tests that drive them, which is what keeps each test readable as one behaviour Suite = Dexpace::Conformance::ExecutorSuite # The one payload every double emits, shaped exactly as Event#emit shapes it: a Hash whose @@ -98,6 +98,47 @@ def initialize(pool) = (@pool = pool) def close = @pool.close end + # ASYNC-2's saturated or closed executor, which this gem exists to be run by third parties + # against: `#post` REFUSES rather than blocking, so nothing ever enters the transport and the + # ASYNC-3 assertion's entry wait has nobody to wait for. + class RefusingPool + def post(&) = raise(Dexpace::ClosedError, "this executor refuses work") + def close = nil + end + + # An executor whose `#post` BLOCKS. Submission is not the work -- the SPI's `#post` returns and + # the unit runs elsewhere -- so this is non-conforming, and it is the shape SEAM-12's sixteen + # posting threads would have parked on for ever. + class BlockingPostPool + def initialize(entered:, gate:) + @entered = entered + @gate = gate + end + + def post(&) + @entered << Thread.current + @gate.pop + end + + def close = nil + end + + # Releases every thread parked on `gate` and joins each one, so a test driving a blocking double + # leaves the thread count where it found it (DexpaceTestCase's teardown). + def release(entered, gate) + gate.close + threads = [] + loop do + one = entered.pop(timeout: 0.5) + break if one.nil? + + threads << one + end + threads.each { |thread| thread.join(2) } + end + + def assertion_for(id) = Suite.assertions.find { |one| one.ids.include?(id) } + def statuses(report) = report.results.to_h { |r| [r.assertion.ids.first, r.status] } # ASYNC-3 is waived by ID here for the same reason a first-party driver waives it: no executor @@ -138,6 +179,40 @@ def conforming(**over) assert_match(/still blocked/, report.failures.first.detail) end + # R0-1: the entry wait carried no bound, so an executor that refuses the post parked the run for + # ever -- no unit entered, nothing was pushed, and the ensure that frees the gate and closes the + # pool was never reached. :vacuous and not :failed, because with no unit started there is no + # "blocking task on a worker thread", which is the antecedent ASYNC-3's own sentence opens with. + # Driven as ONE assertion: an executor that refuses every post is non-conforming in several ways + # and this test is about the bound. + test "an executor that refuses the post makes ASYNC-3 vacuous rather than hanging the run" do + subject = Dexpace::Conformance::ExecutorCase.new(build: ->(**_kw) { RefusingPool.new }) + + error = assert_raises(Dexpace::Conformance::Vacuous) do + assertion_for("ASYNC-3").call(subject) + end + + assert_match(/did not start the posted unit/, error.reason) + end + + # The same rule at SEAM-12's sixteen posting threads: `#post` must RETURN, so the joins carry one + # shared budget and an expired one is the failure. + test "an executor whose post blocks fails SEAM-12 rather than hanging the run" do + entered = Thread::Queue.new + gate = Thread::Queue.new + subject = Dexpace::Conformance::ExecutorCase.new( + build: ->(**_kw) { BlockingPostPool.new(entered: entered, gate: gate) }, + ) + + error = assert_raises(Dexpace::Conformance::Failure) do + assertion_for("SEAM-12").call(subject) + end + + assert_match(/did not return/, error.message) + ensure + release(entered, gate) + end + test "an unlatched close fails XCUT-13 and SEAM-25" do report = conforming(build: ->(events: nil, **_kw) { UnlatchedPool.new(events: events) }) diff --git a/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_test.rb index 957a9d3..969dc27 100644 --- a/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_test.rb +++ b/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_test.rb @@ -93,6 +93,33 @@ def call(request) end end + # A shared instance whose #call never returns. XCUT-11's first clause spawns sixteen callers, so + # without a bound on the joins the assertion parks the whole run against it (R0-1). Not a + # subclass of LatchedSeam: a seam that blocks on EVERY call blocks assertions this one does not + # own, so the test drives the single assertion and the double needs nothing else. + class BlockingCallSeam + attr_reader :release_count + + def initialize(entered:, gate:, client: nil) + @entered = entered + @gate = gate + @client = client + @release_count = 0 + end + + def call(request) + @entered << Thread.current + [request, @gate.pop] + end + + def close + @release_count += 1 + nil + end + end + + BLOCKING_DECLARED = %i[@entered @gate @client @release_count].freeze + DECLARED = %i[@client @owned @closed @release_count @lock].freeze # ---- bounded maps ---- @@ -317,6 +344,41 @@ def conformance_mutable_state = [:@attempt] "a deadlock must be reported :failed; :error reads as a broken harness",) end + # R0-1: the sixteen callers were joined with no bound, so a shared instance that never returns + # parked the run instead of failing the clause. 8a's rule for this gem is the opposite -- every + # wait carries a bound, so a non-conforming subject fails the assertion. + test "a shared instance whose call never returns fails XCUT-11 rather than hanging the run" do + entered = Thread::Queue.new + gate = Thread::Queue.new + assertion = Suite.assertions.find { |one| one.name.include?("holds no per-call state") } + subject = Dexpace::Conformance::InvariantCase.new( + core: ::Dexpace, mutable: BLOCKING_DECLARED, + seam: lambda { |client: nil| + BlockingCallSeam.new(entered: entered, gate: gate, client: client) + }, + ) + + error = assert_raises(Dexpace::Conformance::Failure) { assertion.call(subject) } + + assert_match(/did not return on every thread/, error.message) + ensure + release(entered, gate) + end + + # Releases every caller parked on `gate` and joins each one, so a test driving a blocking double + # leaves the thread count where it found it (DexpaceTestCase's teardown). + def release(entered, gate) + gate.close + callers = [] + loop do + one = entered.pop(timeout: 0.5) + break if one.nil? + + callers << one + end + callers.each { |thread| thread.join(2) } + end + test "a shared instance the driver declares is audited too, and names itself on failure" do leaky = Class.new { def initialize = (@per_call = 0) }.new report = run_suite(shared: [["4a's ContextStore", leaky, []]]) diff --git a/gems/dexpace-conformance/test/dexpace/conformance/packaging_suite_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/packaging_suite_test.rb index 78c04ba..d82b70a 100644 --- a/gems/dexpace-conformance/test/dexpace/conformance/packaging_suite_test.rb +++ b/gems/dexpace-conformance/test/dexpace/conformance/packaging_suite_test.rb @@ -122,11 +122,23 @@ def run_suite(specs, **over) assert_equal(:failed, statuses(report)["NFR-14"]) end + # R0-2: the assertion's own comment promises this and the first cut passed instead, because an + # empty map skipped every unit and nothing mismatched. "Nobody told us" is not evidence of a + # single source, and NFR-14 is a SHOULD, so the vacuity is recorded without blocking the report. test "with no single source named, NFR-14 is vacuous rather than a pass" do report = run_suite(conforming, versions: {}) - assert_equal(:passed, statuses(report)["NFR-14"], - "an empty map states nothing about any unit, so nothing mismatches",) + assert_equal(:vacuous, statuses(report)["NFR-14"]) + assert_includes(report.to_s, "no single source of truth was named for any unit") + assert_predicate(report, :passed?, "NFR-14 is a SHOULD, so its vacuity does not block") + end + + # The partial case the comment names: a source that states some units states nothing about the + # rest, so those are skipped rather than failed -- the declaration is the driver's. + test "a single source naming only some units checks those and skips the rest" do + report = run_suite(conforming, versions: { CORE => "0.1.0" }) + + assert_equal(:passed, statuses(report)["NFR-14"]) end test "a runtime version disagreeing with the gemspec fails NFR-15" do @@ -161,6 +173,20 @@ def run_suite(specs, **over) assert_equal([:vacuous], report.results.map(&:status).uniq) end + # R0-4: every other case here supplies its own `resolve:`, so the SHIPPED default -- the one a + # real driver gets, and a locked public constant -- ran in no test at all. Its rescue is what + # turns "this gem is not installed" into the :vacuous the suite's contract promises rather than + # the Gem::MissingSpecError a bare `find_by_name` raises, which Runner would report :error. + test "the shipped default resolve answers vacuous for a gem that is not installed" do + absent = "dexpace-no-such-gem-#{Process.pid}" + adapter = "#{absent}-adapter" + report = Suite.run(core: absent, adapters: [adapter], + versions: { absent => "0.0.0", adapter => "0.0.0" },) + + assert_equal([:vacuous], report.results.map(&:status).uniq) + assert_includes(report.to_s, "#{absent} is not installed") + end + test "an unloaded constant is vacuous, never failed" do report = run_suite(conforming, constants: { CORE => "NotLoaded::Anywhere" }) diff --git a/test/gates/phase9_ruby_facts_test.rb b/test/gates/phase9_ruby_facts_test.rb index bb3f70e..0ab818f 100644 --- a/test/gates/phase9_ruby_facts_test.rb +++ b/test/gates/phase9_ruby_facts_test.rb @@ -65,6 +65,10 @@ class Phase9RubyFactsTest < GateCase # $VERBOSE = false silences only the -w class: a duplicated hash key warns whatever $VERBOSE is # and would still raise under FatalWarnings, so the window AstScan.parse opens is nil, not false. + # + # PRESENCE and not a count: the same source emits ONE warning on 3.3.12 and later and TWO on the + # 3.2 floor, which also reports `unused literal ignored`. The fact this test exists for is which + # $VERBOSE value silences the class, and a count made the floor red for no property (R0-3). test "$VERBOSE = nil silences both warning classes, and the window closes behind it" do armed = $VERBOSE unused = capture_warnings { quietly { ::RubyVM::AbstractSyntaxTree.parse_file(WARNS_UNUSED) } } @@ -72,7 +76,8 @@ class Phase9RubyFactsTest < GateCase silenced = capture_warnings { quietly { ::RubyVM::AbstractSyntaxTree.parse("{a: 1, a: 2}") } } assert_empty(unused) - assert_equal(1, duplicated.size, "a duplicated key warns whatever $VERBOSE is") + assert(duplicated.any? { |message, _| message.include?("is duplicated") }, + "a duplicated key warns whatever $VERBOSE is",) assert_empty(silenced) assert_equal(armed, $VERBOSE, "the gate stays armed outside the window") end diff --git a/test/gates/requirement_levels_test.rb b/test/gates/requirement_levels_test.rb index 8ab5c59..74fb32f 100644 --- a/test/gates/requirement_levels_test.rb +++ b/test/gates/requirement_levels_test.rb @@ -27,6 +27,25 @@ class RequirementLevelsTest < GateCase assert_equal(%i[may must should], map.values.uniq.sort) end + # The 19-prefix restriction in ROW is load-bearing and nothing asserted it: loosening ROW to + # `[A-Z]+-\d+` left the map at exactly 645 rows over this repository's own appendix C, so the + # mutation survived every test in this file (R0-5). A SYNTHETIC table is what discriminates -- + # the sibling scanner in tools/appendix_b.rb carries the same case, and its mutation was caught. + test "the row scanner does not mistake an RFC number or an ISO date for a requirement id" do + Dir.mktmpdir do |dir| + table = File.join(dir, "appendix.md") + File.write(table, <<~TABLE) + | HTTP-7 | MUST | a real row | + | RFC-7235 | MUST | an RFC number where an id goes | + | ISO-8601 | SHOULD | a standard's number, hyphen and digits | + | XCUT-11 | MUST | a real row | + TABLE + + assert_equal({ "HTTP-7" => :must, "XCUT-11" => :must }, + RequirementLevels.parse(Pathname(table)),) + end + end + # `MUST NOT` is a MUST with a negated predicate, and appendix C holds exactly one. test "MUST NOT is a must" do assert_equal(:must, RequirementLevels::LEVEL.fetch("MUST NOT")) From e95381ba23b60dab73b7e4a280e522eabb3e5c16 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 24 Sep 2026 11:40:09 +0300 Subject: [PATCH 07/17] test: drive the shutdown-event match and XCUT-22's second clause Two shapes from the brief's own minimum mutation list survived round 1, and both survived because every double in the file emitted exactly one kind of payload or derived one observation from the other. ChattyPool logs a non-shutdown event payload and a bare String message at construction, beside its one shutdown on close. It is conforming, so the whole report stays green -- and replacing ExecutorCase#shutdowns' Keys::EVENT match with a bare `entries.count` now reddens SEAM-25, XCUT-13's second half and XCUT-22's no-shutdown half together, which is the difference between "one shutdown event" and "one sink write". TearingSeam tears its borrowed resource down through the teardown beside `#close` rather than through `#close`, so `closed?` stays false and only XCUT-22's second Check can report it. Replacing that Check's condition with a literal `true` now fails this test, where before it left the whole gem's suite green. The existing overreaching-holder test gains the message assertion its twin carries. --- .../conformance/executor_suite_test.rb | 31 +++++++++++++++++++ .../conformance/invariant_suite_test.rb | 23 ++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/gems/dexpace-conformance/test/dexpace/conformance/executor_suite_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/executor_suite_test.rb index b28e8e1..eb53f8c 100644 --- a/gems/dexpace-conformance/test/dexpace/conformance/executor_suite_test.rb +++ b/gems/dexpace-conformance/test/dexpace/conformance/executor_suite_test.rb @@ -55,6 +55,24 @@ def emit_shutdown end end + # A conforming executor that also uses the sink it was handed for something OTHER than its + # shutdown -- one event payload under a different name and one bare String message, both emitted + # at construction. Nothing in the SPI forbids it and a real adapter's logger carries plenty + # besides its shutdown, so `ExecutorCase#shutdowns` must match the payload's `Keys::EVENT` entry + # rather than count the sink's writes. Every other double here emits the shutdown payload and + # nothing else, which is why review round 2 could replace that match with a bare + # `entries.count` and leave the whole suite green. + class ChattyPool < FakePool + STARTUP = { Dexpace::Instrumentation::Keys::EVENT => + Dexpace::Instrumentation::Events::INSTRUMENTATION_HOOK }.freeze + + def initialize(events: nil, owned: true) + super + events&.info { STARTUP } + events&.info("a bare message, which is not a payload Hash at all") + end + end + # XCUT-13's non-conforming twin: no latch, so every close runs the shutdown again. class UnlatchedPool < FakePool def close @@ -213,6 +231,19 @@ def conforming(**over) release(entered, gate) end + # SEAM-25 is "exactly one shutdown EVENT", not "exactly one sink write": the recorder is a sink + # and a conforming executor may log anything else through it. Three assertions read that count -- + # SEAM-25, XCUT-13's second half and XCUT-22's no-shutdown half -- and all three stay green here, + # while a count that ignored the event name reddens every one of them. + test "a payload that is not a shutdown event is not counted as one" do + report = conforming(build: ->(events: nil, **_kw) { ChattyPool.new(events: events) }) + + assert_equal({ "SEAM-12" => :passed, "XCUT-13" => :passed, "XCUT-22" => :passed, + "ASYNC-16" => :passed, "ASYNC-17" => :passed, "SEAM-25" => :passed, + "ASYNC-3" => :waived, }, + statuses(report),) + end + test "an unlatched close fails XCUT-13 and SEAM-25" do report = conforming(build: ->(events: nil, **_kw) { UnlatchedPool.new(events: events) }) diff --git a/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_test.rb index 969dc27..0740032 100644 --- a/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_test.rb +++ b/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_test.rb @@ -67,6 +67,18 @@ def close end end + # XCUT-22's SECOND clause, broken on its own: it leaves the borrowed resource's `#close` alone + # and tears it down through the teardown beside it, which is exactly the shape the first clause + # cannot see. Without this double the second Check could not fail against any subject the suite + # can build, and review round 2 measured that: replacing its condition with a literal `true` + # left the whole gem's suite green. + class TearingSeam < LatchedSeam + def close + super + @client&.finish + end + end + # XCUT-11, broken: per-call state on the shared instance, and one call's request in another's. class CrossTalkingSeam < LatchedSeam def initialize(client: nil) @@ -257,6 +269,17 @@ def [](name) = @values[name] report = run_suite(seam: ->(client: nil) { OverreachingSeam.new(client: client) }) assert_equal([:failed], statuses(report)["XCUT-22"]) + assert_match(/closed a resource it did not create/, report.failures.first.detail) + end + + # The second clause, driven on its own: `closed?` is still false, so the first Check passes and + # only the second can report this. A `usable?` derived from the latch the first Check reads + # would leave this subject green. + test "a holder that tears a borrowed resource down without closing it fails XCUT-22" do + report = run_suite(seam: ->(client: nil) { TearingSeam.new(client: client) }) + + assert_equal([:failed], statuses(report)["XCUT-22"]) + assert_match(/stopped working/, report.failures.first.detail) end # ---- bounded memory ---- From 7132416c449259197ba68cd1832de7a5478ae6f7 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 24 Sep 2026 14:32:17 +0300 Subject: [PATCH 08/17] test: a defective core for the seven assertions that had none Review round 2 measured five whole InvariantSuite assertions that could be neutralised with `rake test:gems` still green: XCUT-1, XCUT-2, XCUT-6, XCUT-7 and XCUT-10. Nothing in the repository drove a subject that made any of their clauses fail, so each reported :passed because it could not report anything else -- the failure mode this phase's own R3 exists to prevent. The file's header made it worse by naming XCUT-6, XCUT-7 and XCUT-10 among the IDs it drove. Thirteen defective stand-in cores, in the shape the file already uses: a Policy whose cancellation? reads only the outermost error and one that calls a cancellation retryable (XCUT-1); one matching the cancellation type exactly, so a subtype escapes, and one telling a timeout apart by its message (XCUT-2); a capability query reading only the outermost error and one matching a concrete type (XCUT-6); a configured set ANDed with the baked classifier and a default set that is not the six RETRY-13 fixes (XCUT-7); a safety gate that re-sends a bare POST and one taking a failure parameter (XCUT-10). Two more close the same gap where the reviewer's sweep found only warning-artefact coverage: a redactor forwarding a URL's userinfo and a default-ALLOW header list (XCUT-19), and an error-body snapshot that multiplies its cap (XCUT-24). Measured: neutralising each of the seven assertions whole and warning-free -- `return nil if subject` as the body's first line, so no local is orphaned and NFR-6's fatal-warning hook cannot mask the result -- reddens exactly the new tests for that assertion, thirteen across the seven. Unmutated the file is 32 runs and 66 assertions, and the gem's own suite is 275 and 1,089. The header now names only the IDs the file really drives, and says why XCUT-3 and XCUT-12 are not among them: a double for either would park a thread past the assertion's own bound and leak it into the base case's thread count, so their guards stay the two subject mutations. --- .../conformance/invariant_suite_core_test.rb | 210 +++++++++++++++++- 1 file changed, 201 insertions(+), 9 deletions(-) diff --git a/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_core_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_core_test.rb index b78d26a..9087b5d 100644 --- a/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_core_test.rb +++ b/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_core_test.rb @@ -8,8 +8,13 @@ # defective stand-in cores. Each double is a Module.new carrying exactly the constants its # assertion probes, defective in exactly the clause the assertion names -- which is what shows the # assertion discriminates rather than merely passing against the real tree. -# XCUT-4, XCUT-5, XCUT-6, XCUT-7, XCUT-8, XCUT-9, XCUT-10, XCUT-16, XCUT-18, XCUT-19, XCUT-20, -# XCUT-21, XCUT-23, XCUT-24. +# Every ID here is one this file really drives: XCUT-1, XCUT-2, XCUT-4, XCUT-5, XCUT-6, XCUT-7, +# XCUT-8, XCUT-9, XCUT-10, XCUT-16, XCUT-18, XCUT-19, XCUT-20, XCUT-21, XCUT-23 and XCUT-24. The +# list is kept honest deliberately: it named XCUT-6, XCUT-7 and XCUT-10 before any double for them +# existed, and a header claiming a double nobody wrote is what review round 2 caught. +# XCUT-3 and XCUT-12 have no double here and their checklist rows say so: either would need a +# subject that parks a thread past the assertion's own bound, which leaks the thread the base +# case's teardown counts. Their guards are subject mutations 22 and 23. class DexpaceConformanceInvariantSuiteCoreTest < DexpaceTestCase # rubocop:disable Metrics/ClassLength -- one defective stand-in core per assertion, each beside the test that drives it Suite = Dexpace::Conformance::InvariantSuite @@ -37,6 +42,80 @@ def patched(**overrides) end end + # A defective Resilience namespace: the real one with the named constants replaced. Its own + # `const_missing` falls through to `::Dexpace::Resilience`, so replacing `Policy` leaves + # `RetrySettings` and `Resend` the real ones -- `patched`'s discipline one level down. + def resilience_core(**overrides) + patched(Resilience: Module.new do + overrides.each { |name, value| const_set(name, value) } + define_singleton_method(:const_missing) { |name| ::Dexpace::Resilience.const_get(name) } + end) + end + + # A defective Policy: the three classifier queries this suite reads, every one conforming except + # the ones named. Written out rather than delegated through `method_missing`, because the point + # of the double is that a reader can see which of the three is wrong. + def policy_core(**broken) + real = ::Dexpace::Resilience::Policy + # Merged BEFORE anything is defined: defining the conforming body and then overriding it warns + # "method redefined", which NFR-6's fatal-warning hook turns into an error. + bodies = { retry_eligible?: ->(status, set:) { real.retry_eligible?(status, set: set) }, + throwable_retryable?: ->(error) { real.throwable_retryable?(error) }, + cancellation?: ->(error) { real.cancellation?(error) }, }.merge(broken) + policy = Module.new + bodies.each { |name, body| policy.define_singleton_method(name, &body) } + resilience_core(Policy: policy) + end + + # The real Instrumentation with one `Redactor::DEFAULT` replaced. `Redactor` is a class carrying + # a DEFAULT constant, so the double is built the same way rather than through `const_missing`. + def instrumentation_with(redactor) + Module.new do + const_set(:Redactor, Class.new { const_set(:DEFAULT, redactor) }) + const_set(:Logger, Dexpace::Instrumentation::Logger) + const_set(:Preview, Dexpace::Instrumentation::Preview) + define_singleton_method(:contain) { |*a, **k, &b| Dexpace::Instrumentation.contain(*a, **k, &b) } + end + end + + # ---- XCUT-1 ---- + + test "a cancellation classifier that reads only the outermost error fails XCUT-1" do + outermost = policy_core(cancellation?: ->(error) { error.is_a?(::Dexpace::CancelledError) }) + + assert_equal([:failed], statuses(Suite.run(core: outermost))["XCUT-1"], + "an adapter's own retryable wrapper around the token's raise is what makes a " \ + "cancelled operation retryable again, and reading the outermost error misses it",) + end + + test "a classifier that calls a cancellation retryable fails XCUT-1" do + retrying = policy_core(throwable_retryable?: ->(_error) { true }) + + assert_equal([:failed], statuses(Suite.run(core: retrying))["XCUT-1"]) + end + + # ---- XCUT-2 ---- + + test "a cancellation matched by EXACT type fails XCUT-2's subtype clause" do + exact = policy_core(cancellation?: lambda do |error| + error.instance_of?(::Dexpace::CancelledError) + end) + + assert_equal([:failed], statuses(Suite.run(core: exact))["XCUT-2"], + "a timeout type that SUBCLASSES the cancellation type must still be read as a " \ + "cancellation, which is why the timeout branch has to be checked first",) + end + + test "a classifier that refuses a read timeout fails XCUT-2" do + by_message = policy_core(throwable_retryable?: lambda do |error| + !error.message.include?("timed out") + end) + + assert_equal([:failed], statuses(Suite.run(core: by_message))["XCUT-2"], + "telling a timeout from a cancellation by matching a message string is what " \ + "XCUT-2 forbids in as many words",) + end + # ---- XCUT-4 ---- test "a transport error outside the IOError family fails XCUT-4" do @@ -79,6 +158,46 @@ def self.retryable_status?(code) assert_equal([:failed], statuses(Suite.run(core: patched(Retryability: wide)))["XCUT-5"]) end + # ---- XCUT-6 ---- + + test "a capability query that reads only the outermost error fails XCUT-6" do + outermost = policy_core(throwable_retryable?: lambda do |error| + error.respond_to?(:retryable?) && error.retryable? + end) + + assert_equal([:failed], statuses(Suite.run(core: outermost))["XCUT-6"], + "an adapter failure a higher layer wrapped still declares itself retryable " \ + "through its cause chain, and never participates if only the outer is read",) + end + + test "a classifier matching a concrete TYPE rather than the capability fails XCUT-6" do + by_type = policy_core(throwable_retryable?: ->(error) { error.is_a?(::IOError) }) + + assert_equal([:failed], statuses(Suite.run(core: by_type))["XCUT-6"], + "a custom error type the classifier has never heard of must participate " \ + "WITHOUT the classifier being edited",) + end + + # ---- XCUT-7 ---- + + test "a configured set ANDed with the baked classifier fails XCUT-7's widening clause" do + anded = policy_core(retry_eligible?: lambda do |status, set:| + set.include?(status) && ::Dexpace::Retryability.retryable_status?(status) + end) + + assert_equal([:failed], statuses(Suite.run(core: anded))["XCUT-7"], + "an AND passes the narrowing half and fails the widening one, which is why " \ + "both directions are asserted",) + end + + test "a default retryable-status set that is not the one XCUT-7 fixes fails XCUT-7" do + settings = Module.new + settings.define_singleton_method(:build) { Defaults.new([500, 502, 503, 504]) } + + assert_equal([:failed], + statuses(Suite.run(core: resilience_core(RetrySettings: settings)))["XCUT-7"],) + end + # ---- XCUT-8 ---- test "a factory that fabricates an error for a 2xx fails XCUT-8" do @@ -135,6 +254,64 @@ def self.each_cause(error) assert_equal([:failed], statuses(Suite.run(core: looping))["XCUT-9"]) end + # ---- XCUT-10 ---- + + test "a safety gate that re-sends a body-less POST fails XCUT-10" do + lenient = Module.new do + def self.eligible?(request) + body = request.body + body.nil? || body.replayable? + end + end + + assert_equal([:failed], statuses(Suite.run(core: resilience_core(Resend: lenient)))["XCUT-10"], + "RETRY-7: a bare POST is not re-sendable even though there is no payload to " \ + "re-send, and even when the failure never reached the server",) + end + + test "a safety gate taking a FAILURE parameter fails XCUT-10's structural clause" do + special_casing = Module.new do + # rubocop:disable-next Lint/UnusedMethodArgument -- the parameter IS the defect here + def self.eligible?(request, failure = nil) + body = request.body + return request.method.idempotent? if body.nil? + + body.replayable? + end + end + core = resilience_core(Resend: special_casing) + + assert_equal([:failed], statuses(Suite.run(core: core))["XCUT-10"], + "every enumerated case is decided correctly here, so the only clause left to " \ + "fail is the structural one: there must be nothing to special-case on",) + end + + # ---- XCUT-19 ---- + + test "a redactor that forwards a URL's userinfo fails XCUT-19" do + leaky = Class.new do + def url(value) = value + def header_value(_name, value) = value + def header_name?(_name) = false + end + report = Suite.run(core: patched(Instrumentation: instrumentation_with(leaky.new))) + + assert_equal([:failed], statuses(report)["XCUT-19"]) + end + + test "a header allow-list that is default-ALLOW fails XCUT-19" do + permissive = Class.new do + def url(value) = Dexpace::Instrumentation::Redactor::DEFAULT.url(value) + def header_value(_name, value) = value + def header_name?(_name) = true + end + report = Suite.run(core: patched(Instrumentation: instrumentation_with(permissive.new))) + + assert_equal([:failed], statuses(report)["XCUT-19"], + "the URL clauses pass against the real redactor, so the allow-list clause is " \ + "the only one left to fail",) + end + # ---- XCUT-20 ---- test "a redactor that throws on a malformed URL fails XCUT-20" do @@ -143,13 +320,7 @@ def url(_value) = raise("boom") def header_value(_name, value) = value def header_name?(_name) = false end - instrumentation = Module.new do - const_set(:Redactor, Class.new { const_set(:DEFAULT, throwing.new) }) - const_set(:Logger, Dexpace::Instrumentation::Logger) - const_set(:Preview, Dexpace::Instrumentation::Preview) - define_singleton_method(:contain) { |*a, **k, &b| Dexpace::Instrumentation.contain(*a, **k, &b) } - end - report = Suite.run(core: patched(Instrumentation: instrumentation)) + report = Suite.run(core: patched(Instrumentation: instrumentation_with(throwing.new))) assert_equal([:error], statuses(report)["XCUT-20"], "an observability path that throws reaches Runner's bare rescue, which is " \ @@ -288,6 +459,9 @@ def call(request, _options, _cancellation) # ---- XCUT-16, through the driver's credential factory ---- Sent = Struct.new(:headers) + # What a RetrySettings double's `.build` answers: XCUT-7 reads `#retryable_statuses` off it and + # nothing else. + Defaults = Struct.new(:retryable_statuses) test "a credential path with no HTTPS guard fails XCUT-16" do unguarded = lambda do |url:, cross_origin: false| # rubocop:disable Lint/UnusedBlockArgument @@ -327,4 +501,22 @@ def call(request, _options, _cancellation) assert_equal([:passed], statuses(Suite.run(core: ::Dexpace, credential_hop: conforming))["XCUT-16"],) end + + # ---- XCUT-24 ---- + + test "an error-body snapshot that multiplies its cap fails XCUT-24" do + uncapped = Module.new do + define_singleton_method(:buffer_bounded) do |body, cap:| + ::Dexpace::Body.buffer_bounded(body, cap: cap * 1000) + end + define_singleton_method(:method_missing) do |name, *args, **kwargs, &block| + ::Dexpace::Body.public_send(name, *args, **kwargs, &block) + end + define_singleton_method(:respond_to_missing?) { |name, _| ::Dexpace::Body.respond_to?(name) } + end + + assert_equal([:failed], statuses(Suite.run(core: patched(Body: uncapped)))["XCUT-24"], + "the snapshot is the cap half; the logging tap is the non-consumption half, " \ + "and a preview that capped but drained would pass this one alone",) + end end From 59da48971ef1bac0b026153c2939ddc218e9dc1c Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 24 Sep 2026 16:46:00 +0300 Subject: [PATCH 09/17] test: defective header-syntax cores for XCUT-18's model assertion Review round 3 swept every phase-9 assertion body one at a time and found one that no document accounted for: XCUT-18's model-layer `header_syntax_validation` could be neutralised whole with the entire `rake test:gems` green, because both of its committed tests pin the model assertion `:passed` and drive only the call-site one. An audit phase ships the claim that its assertions discriminate, and an assertion nothing proves can fail makes that claim false while the report counts it as evidence. Two defective stand-in cores close it, in the shape this file already uses. A permissive validator that refuses nothing reddens the NAME clauses and every VALUE clause but one. A symmetric validator that applies the NAME rule to outbound values as well reddens exactly the HTAB clause, so the asymmetry that IS the requirement -- a name must reject horizontal tab, an outbound value must accept it -- has a control of its own rather than resting on the real subject. Proven by mutation: neutralising `header_syntax_validation` whole reddens both new tests, and deleting the HTAB-accepted check alone reddens only the second. The file is 34 runs / 74 assertions unmutated. --- .../conformance/invariant_suite_core_test.rb | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_core_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_core_test.rb index 9087b5d..caaba34 100644 --- a/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_core_test.rb +++ b/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_core_test.rb @@ -11,7 +11,10 @@ # Every ID here is one this file really drives: XCUT-1, XCUT-2, XCUT-4, XCUT-5, XCUT-6, XCUT-7, # XCUT-8, XCUT-9, XCUT-10, XCUT-16, XCUT-18, XCUT-19, XCUT-20, XCUT-21, XCUT-23 and XCUT-24. The # list is kept honest deliberately: it named XCUT-6, XCUT-7 and XCUT-10 before any double for them -# existed, and a header claiming a double nobody wrote is what review round 2 caught. +# existed, and a header claiming a double nobody wrote is what review round 2 caught. An ID in that +# list says nothing about how many ASSERTIONS carry it: XCUT-18 carries two, and review round 3 +# found the model-layer one still had no double while the call-site one had two, with the whole of +# `rake test:gems` green. Both are driven here now. # XCUT-3 and XCUT-12 have no double here and their checklist rows say so: either would need a # subject that parks a thread past the assertion's own bound, which leaks the thread the base # case's teardown counts. Their guards are subject mutations 22 and 23. @@ -422,6 +425,53 @@ def cnonce = @source.urlsafe_base64(16) assert_equal([:passed], statuses(x21(core))["XCUT-21"]) end + # ---- XCUT-18's model layer ---- + + # @return [String, nil] the detail of the failed result for `id`, so a clause is read off its + # own assertion rather than off whichever failure happens to be first in the report. + def failure_detail(report, id) + report.results.find { |r| r.assertion.ids.first == id && r.status == :failed }&.detail + end + + # One double per half of XCUT-18's asymmetry. The second is what keeps the asymmetry itself + # falsifiable: a port that refuses HTAB everywhere satisfies every other clause in the + # assertion, so without it the HTAB check is carried by the real subject alone. + + test "a validator that refuses nothing fails XCUT-18's model-layer assertion" do + permissive = Module.new do + def self.validate_name!(name) = name || raise(::ArgumentError, "name is required") + + def self.validate_outbound_value!(value, name:) + raise ::ArgumentError, "name is required" if name.nil? + + value + end + end + report = Suite.run(core: patched(HeaderSyntax: permissive)) + + assert_equal(%i[failed vacuous], statuses(report)["XCUT-18"], + "the call-site assertion is vacuous with no transport factory supplied; the " \ + "model-layer one is the subject here",) + assert_match(/header NAME containing CR was accepted/, failure_detail(report, "XCUT-18")) + end + + test "an outbound-value rule identical to the NAME rule fails XCUT-18's HTAB clause" do + symmetric = Module.new do + real = ::Dexpace::HeaderSyntax + define_singleton_method(:validate_name!) { |name| real.validate_name!(name) } + define_singleton_method(:validate_outbound_value!) do |value, name:| + real.validate_outbound_value!(value, name: name) + real.validate_name!(value) # the defect: one rule for both, so HTAB is refused everywhere + end + end + report = Suite.run(core: patched(HeaderSyntax: symmetric)) + + assert_equal(%i[failed vacuous], statuses(report)["XCUT-18"], + "every NAME clause and every other VALUE clause passes against this double, so " \ + "the asymmetry is the only clause left to fail",) + assert_match(/VALUE containing HTAB was rejected/, failure_detail(report, "XCUT-18")) + end + # ---- XCUT-18's call-site assertion ---- # A conforming adapter: it re-runs HTTP-17/HTTP-18 before touching the wire. From 577cf68ab525df45ad5d168c3ed4d7552a1afde1 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 23 Sep 2026 21:29:18 +0300 Subject: [PATCH 10/17] docs: the phase-9 checklist, the as-built rows and the audit's verdict The checklist carries forty-one own rows and eleven cross-reference rows, the forty-five mutations run red with the message each produced, and the aggregate run's verdict: 43 passed, 1 failed, 0 vacuous, 1 waived. The one failure is NFR-13 -- 307 of 307 shipped .rbs files carry no SPDX header -- which phase 10 already owns by name and which is verified still owned rather than re-filed. The design gains an As-built addendum, P9-21 to P9-33. One docs/first-release.md blocker closes: gates:bounded_map is green, and async_http's Clients is a verified false positive of the scan rather than a silenced defect. Six findings go to phase 10's inbound list, none repaired. One corpus note corrects an earlier phase-9 entry: gates:drain_loop was not built, so XCUT-14's drain clause ships with the behavioural assertion alone. CLAUDE.md, the two as-built pages, both READMEs and the conformance gem's README are re-derived from the tree; the knowledge-lookup skill gains this phase's audit group, which did not exist. --- .claude/skills/knowledge-lookup/SKILL.md | 1 + CLAUDE.md | 40 +- README.md | 5 +- docs/README.md | 6 +- docs/first-release.md | 17 +- .../notes/cross-cutting-invariants.md | 2 + docs/sdk-documentation/architecture.md | 14 +- docs/sdk-documentation/conformance.md | 139 +++++++ docs/sdk-documentation/quality-gates.md | 32 +- .../2026-09-05-ruby-sdk-v1-roadmap-design.md | 140 +++++++ ...ng-invariants-and-conformance-checklist.md | 349 ++++++++++++++++++ ...tting-invariants-and-conformance-design.md | 26 ++ gems/dexpace-conformance/README.md | 16 +- 13 files changed, 748 insertions(+), 39 deletions(-) create mode 100644 docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md diff --git a/.claude/skills/knowledge-lookup/SKILL.md b/.claude/skills/knowledge-lookup/SKILL.md index 59d5c91..641aff1 100644 --- a/.claude/skills/knowledge-lookup/SKILL.md +++ b/.claude/skills/knowledge-lookup/SKILL.md @@ -205,6 +205,7 @@ gives no hint. Never take the entry count as the prefix's ID count. | Resilience: retry, redirect and authentication | `--topic retry-and-resilience,redirect-handling,authentication,cancellation-and-timeouts --section rules --brief` and `--prefix RETRY,REDIR,AUTH --section rules --brief` | `--prefix-info RETRY`, `--gaps RETRY,REDIR,AUTH,RECOV` | live | | Serialization, SSE and pagination | `--topic serde,sse-streaming,pagination --section rules,constraints,conclusions --brief` and `--prefix SERDE,SSE,PAGE --section rules,constraints,conclusions --brief` (a `--section rules` reading alone misses `SERDE-17`, `SERDE-24`, `SERDE-25` and `SERDE-30`, filed under Constraints and Conclusions; phase 7a's finding) | `--prefix-info SERDE`, `--gaps SERDE,SSE,PAGE` | live | | Transport and async-runtime adapters | `--topic transport-adapter,cancellation-and-timeouts,concurrency-and-async --section rules --brief` and `--prefix TRANSPORT,ASYNC --section rules --brief` | `--prefix-info TRANSPORT`, `--gaps TRANSPORT,ASYNC` | live | +| Cross-cutting invariants and the quality bar | `--topic cross-cutting-invariants,tooling-and-quality-gates,resource-management --section rules --brief` and `--prefix XCUT,NFR --section rules --brief` | `--prefix-info XCUT`, `--prefix-info NFR`, `--gaps XCUT,NFR` | live | | Styleguide-vs-design conflicts | `--section conflicts --brief` | resolve via a note under notes/ | live | A regex with an alternation cannot live in a Markdown cell without an escape that would be copied diff --git a/CLAUDE.md b/CLAUDE.md index 0c0168f..34d7e31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,11 +13,12 @@ three-state PATCH — solved exactly once, and it deliberately does not compete Work here is **spec-driven, not feature-driven**. `docs/product-spec/` is normative: 645 numbered requirements across 19 prefixes. Before implementing anything, find the requirement IDs it must satisfy. -**Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c, 6a, 6b, 6c, 7b, 7c, 7a, 8a, 8b and 8c are built — the whole of +**Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c, 6a, 6b, 6c, 7b, 7c, 7a, 8a, 8b, 8c and 9 are built — the whole of phase 6, the whole of phase 7, whose three sub-phases were built concurrently off one base and landed in that order, 7a last (umbrella #25 closes by hand), and the whole of phase 8, whose three sub-phases were built 8a first, off the same base as phase 7's and reconciled onto the tree that holds all three, then 8b and 8c -concurrently off the tree that holds 8a, 8c landing second; the domain model, the seam layer, the +concurrently off the tree that holds 8a, 8c landing second, and phase 9, the audit phase, which ships no +`lib/` code outside `dexpace-conformance` and repairs nothing it finds; the domain model, the seam layer, the byte-streaming layer, the body layer, the execution context, the recovery layer, the stage pipeline, the configuration layer, the tracing and metrics layer, the logging facade with its redaction, the retry layer, the authentication layer, the redirect layer, the server-sent-events layer, the pagination layer, the @@ -254,9 +255,11 @@ the synchronous transport is the first thing here that talks to a socket, and th on the async path — the two meet in `dexpace-async-thread`'s composed suite over a real socket. Both transports talk to a socket; the async one needs a running reactor on the calling thread and creates none. The workspace root carries the `Gemfile`, `Rakefile`, `Steepfile`, `rbs_collection.yaml`, `.rubocop.yml`, `.yardopts`, `VERSIONS` and -the eighteen blocking gates — phase 0's seventeen -(`docs/work/mvp/phase0/2026-09-05-phase0-scaffold-and-quality-gates-checklist.md`) and phase 7b's -`gates:serde_boundary`. +the twenty-one blocking gates — phase 0's seventeen +(`docs/work/mvp/phase0/2026-09-05-phase0-scaffold-and-quality-gates-checklist.md`), phase 7b's +`gates:serde_boundary` and phase 9's three repository-wide invariant scans, `gates:cause_walk`, +`gates:bounded_map` and `gates:seam_names`, over one `RubyVM::AbstractSyntaxTree` walker in +`tools/ast_scan.rb`. Beyond that, what exists is the specification, the port design, the process tooling and the register, `docs/deviations.md`. Ruby **>= 3.2** is the floor (`required_ruby_version` in every gemspec but one, asserted by `gates:versions`; `dexpace-transport-async_http` alone declares **>= 3.3**, read from `VERSIONS`' per-gem @@ -299,8 +302,8 @@ makes an older Bundler try to install Bundler 4. ```bash bundle install -bundle exec rake # the default task: all eighteen gates, in order (NFR-17) -bundle exec rake gates:list # the eighteen names, in the order CI and `rake` both use +bundle exec rake # the default task: all twenty-one gates, in order (NFR-17) +bundle exec rake gates:list # the twenty-one names, in the order CI and `rake` both use bundle exec rake rubocop # NFR-7, findings fatal, no autocorrection bundle exec rake rubocop:fix # safe autocorrections only, never the gate bundle exec rake cops:test # the custom cops' own suite (.rubocop/test/) @@ -310,6 +313,9 @@ bundle exec rake test:gates # the repository's gate suites bundle exec rake gates:gemspec_audit # SEAM-1, NFR-1, NFR-2 bundle exec rake gates:require_allowlist # SEAM-1, SEAM-2: the allowlist and the denylist bundle exec rake gates:serde_boundary # SSE-37, spec-forced boundary 5: no serde under lib/dexpace/{sse,page}/** +bundle exec rake gates:cause_walk # XCUT-9: Dexpace.each_cause is the only walk of a cause chain +bundle exec rake gates:bounded_map # XCUT-14: only Dexpace::BoundedMap holds a caller-keyed map +bundle exec rake gates:seam_names # SEAM-2: core never names a concrete seam implementation bundle exec rake gates:clean_bundle # the scratch-Gemfile isolation run, all six gems bundle exec rake gates:rbs_surface # NFR-11 bundle exec rake gates:sig_diff # NFR-4, RBS half (vacuous until the first v* tag) @@ -1358,10 +1364,12 @@ probe compares each against the live tree, and a count written anywhere else in `pool.rb` and `timer.rb`, the last a `private_constant`, every one mirrored in `sig/` and the two public ones in `test/` (`timer.rb` is proven through `pool_delay_test.rb` and `pool_test.rb`'s source scans) — and its gemspec declares `dexpace-core` alone, by design: the gem spends none of its `NFR-2` budget; - `dexpace-conformance`'s holds the phase-8a conformance suite with phase 8c's two groups — twenty-five - files beside phase 0's `version.rb`, nine of them `private_constant`s, every one mirrored in `sig/` and - every one but `transport_suite/checks.rb`, `wire_server/recorded_request.rb` and - `wire_server/request_reader.rb` mirrored in `test/` — and its gemspec declares `dexpace-core` alone, its + `dexpace-conformance`'s holds the phase-8a conformance suite with phase 8c's two groups and phase 9's + four further suites — **fifty-four** files beside phase 0's `version.rb`, twenty-five of them + `private_constant`s, every one mirrored in `sig/` and every public one but `check.rb`, `levels.rb`, + the four `*_case.rb` files and `wire_server/recorded_request.rb` mirrored in `test/` (each of those six + is driven through the suite it serves, and `levels.rb` through `test/gates/requirement_levels_test.rb`) + — and its gemspec declares `dexpace-core` alone, its `socket` and `tempfile` requires carried by the allowlist's exceptions. Every gem under `gems/` is real: no phase-0 skeleton remains, and the third-party half of each `NFR-2` budget arrived with the phase that wrote the code needing it — 7a's, 8a's and 8c's (8b's spends none). @@ -1371,7 +1379,7 @@ probe compares each against the live tree, and a count written anywhere else in carry that phase's design, plan and checklist; `phase3/` carries its segmentation design, `docs/work/mvp/phase3/2026-09-08-phase3-segmentation-design.md`, and two sub-phase directories — `phase3/phase3a/` and `phase3/phase3b/`, each holding that sub-phase's design, plan and checklist — - twenty checklists written so far, each at implementation; `phase4/` + twenty-one checklists written so far, each at implementation; `phase4/` carries its segmentation design, `docs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md`, and three sub-phase directories — `phase4/phase4a/`, `phase4/phase4b/` and `phase4/phase4c/`; each holds that sub-phase's @@ -1410,9 +1418,11 @@ probe compares each against the live tree, and a count written anywhere else in gemspec, version and first release phase 8 owns. Its three sub-phases are independent, so their order is convenience; one task is phase-level because it lands in `dexpace-core`, which none of the three ships. - `phase9/` carries **no segmentation design and no sub-phase** — it holds its design and its - plan directly, `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md` - and `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance.md`. The + `phase9/` carries **no segmentation design and no sub-phase** — it holds its design, its plan + and its checklist directly, `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md`, + `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance.md` and + `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md`, + the last written at implementation on 2026-09-23. The roadmap's segmentation rule reaches build phases 1 through 8 and leaves phases 9 and 10 to segment "only if their own design finds it necessary"; phase 9's design finds it does not, at 41 IDs (`XCUT-1`–`24`, `NFR-1`–`17`) against phase 1's 42 unsegmented rows, and one gem rather diff --git a/README.md b/README.md index a3f69f2..1fc16a4 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,10 @@ response body, HTTP/2 by ALPN over TLS, and a Ruby floor of 3.3 that this gem al (`docs/sdk-documentation/transport-async_http.md`) — and `dexpace-conformance` carries the conformance suite: the assertion protocol, the thirty-four-assertion transport suite with its vacuous and waived rows, the plaintext wire fixture, the Minitest and RSpec drivers and the two -observability doubles, proven by both transports as its two drivers +observability doubles, proven by both transports as its two drivers — and, since phase 9, four +further suites: twenty-eight assertions over every cross-cutting invariant, eight over the +packaging requirements, two over any wire codec and seven over an async-runtime adapter, with one +aggregate report that states what a green run does not prove (`docs/sdk-documentation/conformance.md`). `dexpace-async-thread` carries the async-runtime adapter — the first executor on the async path: a fixed-size thread pool over a bounded queue whose `#post` never blocks, the bridge that makes a blocking transport asynchronous diff --git a/docs/README.md b/docs/README.md index c51daf2..dcb370e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -153,7 +153,11 @@ transport and the conformance suite are what phase 8a built — the first code o [`sdk-documentation/async-thread.md`](./sdk-documentation/async-thread.md), because the thread pool that is the async path's first executor is what phase 8b built, and [`sdk-documentation/transport-async_http.md`](./sdk-documentation/transport-async_http.md), because -the asynchronous transport, the suite's second driver, is what phase 8c built. +the asynchronous transport, the suite's second driver, is what phase 8c built. Phase 9 added no page +of its own: it ships no gem, and what it built extended two that already existed — +[`sdk-documentation/conformance.md`](./sdk-documentation/conformance.md) gains a section on its four +suites and [`sdk-documentation/quality-gates.md`](./sdk-documentation/quality-gates.md) three rows, +one per repository-wide invariant scan. ## Keeping this file true diff --git a/docs/first-release.md b/docs/first-release.md index ee40537..864688d 100644 --- a/docs/first-release.md +++ b/docs/first-release.md @@ -132,8 +132,9 @@ stated in the release notes rather than discovered at `bundle install`. operation projection carrying a descriptor**. Until that event: either the release notes state that the tier resolver has no carrier and only the client tier is reachable end to end, or a carrier is built before the tag -- [ ] **`gates:bounded_map` green: `dexpace-transport-async_http`'s `Clients` is an uncapped - per-origin client cache, which `XCUT-14` (MUST) forbids.** Found 2026-09-13 by phase 9's planning, and +- [x] **`gates:bounded_map` green: `dexpace-transport-async_http`'s `Clients` is an uncapped + per-origin client cache, which `XCUT-14` (MUST) forbids.** — **CLOSED 2026-09-23 by phase 9's + `gates:bounded_map` run.** Found 2026-09-13 by phase 9's planning, and the **one true positive** of six `gates:bounded_map` reports over 222 filed Ruby fences at 184 distinct `gems/*/lib/**/*.rb` paths, measured identically on 3.2.11, 3.3.12, 3.4.10 and 4.0.6. The map is instance-lived and lives as long as the client, its key space is chosen by caller URLs and by a server's @@ -152,7 +153,17 @@ stated in the release notes rather than discovered at `bundle install`. evicted first, every evicted client's pool retired and closed — proven by the three `XCUT-14` cases in `gems/dexpace-transport-async_http/test/dexpace/transport/async_http/clients_test.rb`; the gate itself does not exist yet, so the line stays open for phase 9's `gates:bounded_map` run - and nothing else + and nothing else. **Closed 2026-09-23**: phase 9 built the gate and ran it. `gates:bounded_map` + is **green over all 307 files in the six gems' `lib/` trees**, with six adjudicated exceptions, + none of them this one. `clients.rb` is on the allowlist as a **verified false positive of the + scan**, not as a silenced defect — the scan sees a Hash assigned to an instance variable and + cannot see a cap, and the reason recorded beside the entry is the reading of the file: `#fetch` + inserts with `||=` and calls `#drain` inside the SAME `@mutex.synchronize` as the insert, and + `#drain` is a loop, `evicted << @by_key.delete(@by_key.keys.first) while @by_key.size > + MAX_ORIGINS`, with closed reactors evicted first, which is `XCUT-14`'s drain clause exactly. + The as-built ivar is `@by_key` and not `@by_origin`, which is why a check against the planning + note's name would have missed it. `XCUT-14`'s checklist row is ✅ and no phase-10 repair is + filed - [ ] **Before release, `docs/sdk-documentation/` carries one worked end-to-end example** — a generated-style client over `dexpace-core` + `dexpace-transport-net_http` + `dexpace-serde-json`: operation descriptor, request assembly, pipeline with an AUTH step, decode, typed error, one diff --git a/docs/knowledge/notes/cross-cutting-invariants.md b/docs/knowledge/notes/cross-cutting-invariants.md index 94392bd..bd37058 100644 --- a/docs/knowledge/notes/cross-cutting-invariants.md +++ b/docs/knowledge/notes/cross-cutting-invariants.md @@ -9,3 +9,5 @@ that entry's stable key. review · `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md` · high · sha:manual-phase9-xcut11-predicate - **The repository-wide invariant scans are Rake gates over `RubyVM::AbstractSyntaxTree`, not conformance assertions, and a `:CALL`-only scan is wrong.** Relates to cross-cutting-invariants/7e6a9aea (`XCUT-14`'s hard cap and drain-to-cap loop) and, by the same argument, to `XCUT-9`'s single-walk rule and `SEAM-2`'s no-concrete-seam rule — **neither key is backticked, because neither harvested rule is false**; what is added is how each is checked. Four checks handed forward to phase 9 are statements about the **absence of a second implementation** rather than about what one implementation does — `XCUT-9`'s "nothing else walks `#cause`", `XCUT-14`'s "one bounded-map implementation", `SEAM-2`'s negative test over core's tree, and `SSE-37`'s `gates:serde_boundary` `PENDING` list — and none can be a portable conformance assertion, because a scan of `gems/*/lib/` is meaningless in a consumer's process where `gems/` does not exist. They land as Rake gates. The mechanism is `RubyVM::AbstractSyntaxTree` rather than a regex, because `grep '\.cause'` matches a comment, a string, an `# XCUT-9` citation in a test header and the requirement ID itself. Verified on 2026-09-12 on 3.2.11, 3.4.10 and 4.0.6: the module is present on all three and `parse_file` returns a `SCOPE` node on all three. **The PARSER emits no warning of its own under `-w`; a SCANNED FILE's own `-w` diagnostics are a different thing and do reach `Warning.warn`** — which is load-bearing, because phase 0's shared test case overrides `Warning.warn` to raise, so a scanner that parses a file carrying one fails the suite that runs it. An earlier form of this entry said parsing emits no warning full stop, generalising from a measurement taken against a file that carries none; that generalisation is struck. Measured 2026-09-13 on 3.2.11, 3.3.12, 3.4.10 and 4.0.6 against 8a's filed `adapter.rb`, whose `rescue ::StandardError => e` inside `Adapter#dispatch` never reads `e` (8a plan:4858): parsing it emits `assigned but unused variable - e` on all four, and with the gate suite's own `dexpace_test_case` loaded a bare `parse_file` raises `RuntimeError: warning treated as an error (NFR-6)` out of `AstScan.receiver_calls` on all four. **Which suppression works was measured, not assumed:** `Warning[:deprecated] = false` does not reach it, because the warning carries no category; `$VERBOSE = false` silences the `-w` class but leaves the always-on parse warnings firing, and `key :a is duplicated` still raised on all four; only `$VERBOSE = nil` silences both. So `AstScan.parse` is the sole parse entry point in the scanner, opening a `$VERBOSE = nil` window restored in `ensure` so the gate stays armed everywhere else (phase 9 plan:4270-4291, its fixture at plan:138-154 and the test that would error without it at plan:4219-4227, and design Fact 1b). `prism` is absent on the 3.2 floor (`LoadError`) and present at 1.9.0 on 3.4.10 and 1.8.1 on 4.0.6, so the AST route needs no conditional require where Prism would. **Four measured corrections to the obvious implementation, each one a gate that reported clean over a live defect.** (1) A send is **three** node types, not one — `a.cause` is `:CALL`, `b&.cause` is `:QCALL`, a bare `cause` is `:VCALL`, identically on all three — and a `:CALL`-only scanner returned two hits for a fixture containing three `#cause` sends. (2) A **reflective** send bypasses call-syntax scanning entirely: `send(:cause)`, `__send__`, `public_send` and `method(:cause)` took the catch rate from 3 of 7 realistic shapes to 6 of 7, the seventh — `send(variable)` — being statically undecidable. (3) **A Symbol literal is a `:LIT` node on Ruby 3.2.11 and a `:SYM` node on 3.4.10 and 4.0.6**, which is the reverse of the usual direction and is why the reflective scan must name both: naming one gave 6 of 7 on the floor and 2 of 7 on the newer rows, a gate strictest exactly where it runs least — which is why this entry names both node types rather than the one the newest interpreter shows. (4) `Hash` is `:CONST` and `::Hash` is `:COLON3`, so `@h = ::Hash.new` escaped a check naming only the first. **And one clause gains a second line rather than moving — which corrects this entry's own earlier form.** `XCUT-14`'s drain-to-cap clause **is** decidable behaviourally, and phase 9 decides it there: `InvariantSuite#bounded_map_drains` reaches the map's backing store through the driver's `bounded_map_store:` hook, pre-fills it to cap + 5 — the state an overshoot would leave, arranged without a thread — and then performs **one** `set`; 4a's filed drain loop ends at the cap, **8**, and a check-then-evict ends at **13**, identically on 3.2.11, 3.3.12, 3.4.10 and 4.0.6 (phase 9 plan:1670-1689, its failing test at plan:1576-1581 asserting `%i[passed failed]`, and phase 9 design:572 and design:755). **What makes that deterministic is that the observation is an eviction count, not an interleaving** — one insert into an over-cap store either drains back to the cap or evicts exactly once — and the over-cap state is *injected* rather than raced for. The earlier racing attempt is kept here because it is a real finding about **that method** and not about the clause: a check-then-evict map was indistinguishable from a drain loop in 0 of 30 runs on 3.4.10 and 4.0.6 at 16 threads and 0 of 20 at 64 threads by 5000 inserts — because no overshoot ever appeared to observe, which is the structural point `../notes/execution-context.md` already records, that an insert and its drain inside one `synchronize` make an overshoot unreachable rather than merely rare. So racing measures nothing about this clause; **it does not make the clause undecidable, and an earlier form of this entry drew that conclusion and moved the clause to a shape check alone — that inference is struck.** `gates:drain_loop` stays as the **second line**, not the only one: a shape check over `BoundedMap`'s own file asserting the eviction send sits inside a `while`/`until`, with its own stated gap — it proves an eviction send is looped in that file, not that the loop is the only eviction path (phase 9 plan:4564-4574) — and a measured reach of 1 of 3 non-conforming shapes with one false positive and 3 of 4 conforming shapes clean, every miss carried into phase 9 plan, Task 13's gate-miss dispositions (phase 9 design:233). A gate that reports clean while the invariant is broken is worse than no gate, which is why phase 0's failing-fixture discipline applies to these five as much as to its own seventeen. review · `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md` · high · sha:manual-phase9-ast-gates +- **`gates:drain_loop` was not built, and `XCUT-14`'s drain clause ships with the behavioural assertion as its ONLY line.** Corrects this file's own earlier entry (`sha:manual-phase9-ast-gates`), which says "`gates:drain_loop` stays as the **second line**, not the only one". At execution on 2026-09-23 phase 9 built **three** repository gates — `gates:cause_walk`, `gates:bounded_map` and `gates:seam_names` — for twenty-one blocking gates in all, and no fourth. The reason is the one that entry already records and then declines to act on: the shape check reached **1 of 3** non-conforming forms and carried a false positive, which is a gate that reports clean over a live defect for two of three real shapes, and phase 0's failing-fixture discipline is the argument against shipping it rather than for it. What replaces it is not nothing: `InvariantSuite#bounded_map_drains` decides the clause **deterministically** — fill the backing store to cap + 5, perform one `#set`, and a drain loop ends at the cap where a check-then-evict ends at cap + 5 — and that assertion is proven to fail against both non-conforming shapes by mutation (phase 9's checklist, "Guards run red", rows 6 and 7). The residue this leaves is honest and is stated in the assertion's own comment: the assertion decides the clause for **one** map, the one the driver hands it, and nothing scans for a second bounded-map implementation that drains wrongly — `gates:bounded_map`'s single-implementation rule is what bounds that, and it is a scan for a Hash on an instance, not for a drain. Anyone reading the earlier entry for "which gates exist" should read this one instead; the rest of that entry — the node types, the reflective sends, the `:LIT`/`:SYM` split, the `$VERBOSE` window and the four measured corrections — was built as written and stands. + review · `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md` · high · sha:manual-phase9-drain-loop-not-built diff --git a/docs/sdk-documentation/architecture.md b/docs/sdk-documentation/architecture.md index f2f9ceb..37dc5c6 100644 --- a/docs/sdk-documentation/architecture.md +++ b/docs/sdk-documentation/architecture.md @@ -211,8 +211,14 @@ and 15 of `docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-cont runner and its report with vacuous and waived rows that stay visible, the two thin drivers and the three keywords that are the asynchronous adapter's contract, the case an assertion receives and the guard on its transport, the plaintext wire fixture with its fifteen scripts and bounded waits, -and the two observability doubles. Written against the gem phase 8a shipped and the two groups -phase 8c appended; derives from `docs/sdk-design-ruby/09-toolchain-and-quality-gates.md` §9.3. +and the two observability doubles — and, in its last section, **phase 9's four further suites**: +the twenty-eight-assertion invariant suite over all twenty-four `XCUT` IDs, the packaging suite +over eight `NFR`s read from published gem metadata, the two portable codec assertions, the seven +executor ones with `ASYNC-3` written so it fails, the generated level map that makes a MUST-level +vacuity a report blocker, `XCUT-11`'s structural predicate, and the aggregate whose preamble +prints what a green run does not prove. Written against the gem phase 8a shipped, the two groups +phase 8c appended and the four suites phase 9 added; derives from +`docs/sdk-design-ruby/09-toolchain-and-quality-gates.md` §9.3. [async-thread.md](./async-thread.md) — the async-runtime adapter: the fixed-size thread pool over a bounded queue and the one required keyword, `#post` as the executor duck type that never @@ -226,7 +232,9 @@ not do. Written against the adapter phase 8b shipped; derives from entries 3, 4 and 5 of `docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md`. [quality-gates.md](./quality-gates.md) — every blocking gate this SDK runs, what each protects, -and how to run it locally. Written against the build phase 0 shipped; derives from +and how to run it locally; twenty-one of them, and the table says which fixture turns each red. +Written against the build phase 0 shipped, extended by phase 7b's `gates:serde_boundary` and by +phase 9's three repository-wide invariant scans; derives from `docs/sdk-design-ruby/09-toolchain-and-quality-gates.md`. write-a-transport.md — implementing the `Transport` seam, and proving an implementation against diff --git a/docs/sdk-documentation/conformance.md b/docs/sdk-documentation/conformance.md index ad147ff..a7cb6ef 100644 --- a/docs/sdk-documentation/conformance.md +++ b/docs/sdk-documentation/conformance.md @@ -329,3 +329,142 @@ TLS, no proxy and no connect-timeout assertion, for the reasons the preamble sta `Thread#kill`, in the fixture included: every wait is a bounded queue pop or a bounded join. And no suite beyond the transport one yet: the remaining appendix-B suites, and the drivers' second consumer, are phase 9's; this gem's gemspec, version and first release are phase 8's. + +*(That last sentence was true when phase 8a wrote it. The four suites it points at landed on +2026-09-23 and the section below is what they are; the rest of this page is unchanged and still +describes the transport suite alone.)* + +--- + +## Phase 9's four suites: the invariants, the packaging, the codec and the executor + +**As built by phase 9, written against source on 2026-09-23. Every example below was run verbatim on +4.0.6 and 3.2.11 and printed the same on both.** Phase 8a shipped the assertion protocol and one suite; +phase 9 adds four more, one aggregate over all of them, a generated map of every requirement's normative +level, and the predicate `XCUT-11` needs. The per-requirement proof is +`docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md`; the +61-row map of appendix B is `gems/dexpace-conformance/APPENDIX_B.md`, inside the gem. + +`C` is `Dexpace::Conformance` throughout. + +```ruby +[C::InvariantSuite, C::PackagingSuite, C::CodecSuite, C::ExecutorSuite] + .map { |s| [s.name.split("::").last, s.assertions.size] } +# => [["InvariantSuite", 28], ["PackagingSuite", 8], ["CodecSuite", 2], ["ExecutorSuite", 7]] + +first = C::InvariantSuite.assertions.first +[first.ids, first.name] +# => [["XCUT-15"], "public wire models retain no external-mutable alias"] +``` + +- **`InvariantSuite`** — 28 assertions over all twenty-four `XCUT` IDs, the cross-cutting invariants + every layer is held to. `XCUT-11`, `XCUT-13`, `XCUT-14` and `XCUT-18` carry two each, because each has + two clauses that a single check would let a port satisfy by half. +- **`PackagingSuite`** — 8 assertions over `NFR-1`, `2`, `3`, `10`, `11`, `13`, `14` and `15`, read from + **published** `Gem::Specification` metadata rather than from a source gemspec, because that is what a + consumer resolves. Phase 0's `gates:gemspec_audit` is the same claim's pre-publication half. +- **`CodecSuite`** — 2 portable assertions a wire codec must satisfy whatever library it wraps: it never + closes a target it was handed (`SEAM-20`, `SERDE-3`), and no library exception type escapes the seam + (`SERDE-9`). +- **`ExecutorSuite`** — 7 assertions over an async-runtime adapter's lifecycle, `SEAM-25`'s shutdown + event among them, and `ASYNC-3`, which is written so it genuinely fails. + +### Running one, and reading the verdict + +Every suite's `.run` takes the factories that suite cannot build itself and answers one `Report`. + +```ruby +module PassThrough + def self.dexpace_load(parsed, _ctx) = parsed +end + +report = C::CodecSuite.run(build: -> { Dexpace::Serde::JSON.default }, witness: PassThrough, + source: ->(text) { Dexpace::IO::BufferedSource.of_bytes(text.b) },) +[report.passed?, report.passed.size, report.failures.size] +# => [true, 2, 0] +``` + +A factory left `nil` makes its assertions report `:vacuous` **with a reason**, never `:passed` — which is +what stops "not supplied" from reading as "conforming". + +### A MUST-level vacuity is a report blocker + +`Levels` is every requirement ID in appendix C mapped to its RFC 2119 level, generated by +`tools/requirement_levels.rb` and diffed against appendix C by `test/gates/requirement_levels_test.rb` on +every run. A gem cannot read `docs/` on a consumer's machine, so the map ships inside it. + +```ruby +[C::Levels.of("XCUT-14"), C::Levels.of("XCUT-12"), C::Levels.must?("NFR-16"), C::Levels.known?("XCUT-99")] +# => [:must, :should, false, false] +``` + +A vacuity on a **MUST** fails the run until an acceptance names that ID **with a citation**. A blank +citation is refused at construction, and an acceptance must name *every* MUST-level ID the assertion +carries, so accepting one cannot silence a co-carried second. + +```ruby +vacuous = C::Assertion.build(ids: ["XCUT-14"], name: "a map nobody supplied", + body: ->(_) { raise C::Vacuous, "no bounded-map factory supplied" },) + +blocked = C::Runner.run([vacuous]) { :no_subject_needed } +accepted = C::Runner.run([vacuous], + accepted_vacuous: { "XCUT-14" => "design §12: no map subject here" },) { :none } + +[blocked.passed?, blocked.blocking_vacuities.size, accepted.passed?, accepted.accepted_vacuities.size] +# => [false, 1, true, 1] +``` + +### `SharedInstance` — `XCUT-11`'s structural predicate + +The rule is a **disjunction**, and both halves matter. A frozen instance conforms on `frozen?` alone: it +cannot hold per-call state whatever its instance variables say. An unfrozen one conforms only when every +instance variable it holds is one the **driver** declared — a mutex with the state it guards, or +`Closeable`'s latch. The declaration is never read off the audited object: a conforming latch-plus-mutex +object would fail, because no phase committed to such a method, while the identical per-call-state bug +would pass by declaring its own ivar exempt. + +```ruby +frozen_with_state = Object.new +frozen_with_state.instance_variable_set(:@policy, :anything) +frozen_with_state.freeze +latched = Object.new +latched.instance_variable_set(:@lock, ::Thread::Mutex.new) +latched.instance_variable_set(:@closed, false) + +[C::SharedInstance.audit(frozen_with_state), C::SharedInstance.audit(latched, mutable: %i[@lock @closed])] +# => [nil, nil] + +leaky = Object.new +leaky.instance_variable_set(:@last_request, nil) +C::SharedInstance.audit(leaky, mutable: %i[@lock @closed]) +# raises C::Failure: "Object is shared across concurrent requests and holds undeclared mutable state" +``` + +### `Aggregate` — one verdict over a whole run + +```ruby +merged = C::Aggregate.run([report, accepted]) +[merged.passed?, merged.results.size] +# => [true, 3] +``` + +`Aggregate::PREAMBLE` prints on every render and states what a green run does **not** prove: no TLS and +no connect-timeout classification, nothing covered "by reference" in `APPENDIX_B.md`, nothing waived or +vacuous, and no MUST-level vacuity that an acceptance did not cite. It is printed rather than filed +because a limitation in a document nobody re-reads is a limitation nobody knows about. + +### Writing your own driver + +Each of this repository's three first-party drivers is one generated test per assertion plus a +report-level test, and yours can be the same six lines. 8a's `MinitestDriver` is not used by them — it +builds a `TransportCase` for every assertion, which is right for the transport suite and wrong for these +four — so a driver for one of phase 9's suites calls `.run` (or drives `suite.assertions` itself) and +asserts on the `Report`: + +- `gems/dexpace-core/test/dexpace/cross_cutting_invariants_test.rb` — `InvariantSuite` against core, + with the fifteen shared instances it declares and the six factories the suite cannot build. +- `gems/dexpace-serde-json/test/dexpace/serde/json/conformance_test.rb` — `CodecSuite`, driven again + under every option the adapter accepts. +- `gems/dexpace-async-thread/test/dexpace/async/thread/conformance_test.rb` — `ExecutorSuite`, with + `ASYNC-3` waived by ID **and** a second test that runs it unwaived and requires it to fail, so the + waiver cannot outlive the limitation it records. diff --git a/docs/sdk-documentation/quality-gates.md b/docs/sdk-documentation/quality-gates.md index 482c1e3..b67b62d 100644 --- a/docs/sdk-documentation/quality-gates.md +++ b/docs/sdk-documentation/quality-gates.md @@ -1,11 +1,12 @@ # Quality gates Every blocking gate this SDK runs, what each protects, and how to run it locally. As built by -phase 0 (`docs/work/mvp/phase0/`); the design it implements is +phase 0 (`docs/work/mvp/phase0/`), extended by phase 7b (`gates:serde_boundary`) and by phase 9 +(`gates:cause_walk`, `gates:bounded_map`, `gates:seam_names`); the design it implements is `docs/sdk-design-ruby/09-toolchain-and-quality-gates.md`, and this page describes the build as it is rather than as it was planned. -`bundle exec rake` runs all eighteen, in the order below, and stops at the first failure. Each is +`bundle exec rake` runs all twenty-one, in the order below, and stops at the first failure. Each is a separately invocable task, because a gate you cannot run alone is a gate you debug by running everything. `bundle exec rake gates:list` prints the names; `bundle exec rake -T` describes them. @@ -20,19 +21,22 @@ everything. `bundle exec rake gates:list` prints the names; `bundle exec rake -T | 7 | `gates:gemspec_audit` | `SEAM-1`, `NFR-1`, `NFR-2`: core declares nothing; an adapter declares `dexpace-core` at `~> MAJOR.MINOR` plus at most one gem; every floor is `>= 3.2` | a core dependency, a second third-party gem, a stale core constraint, a wrong floor, a gemspec that runs a subprocess (`git ls-files`) or `require_relative`s a helper that does, a gemspec that does not load (named, not a `NoMethodError` on nil) | | 8 | `gates:require_allowlist` | `SEAM-1`, `SEAM-2`, `NFR-1`: every `require` and `autoload` in every gem — parsed, so `require("x")`, `Kernel.require "x"` and a require after a `;` are all seen — is allowlisted, `dexpace` or under `dexpace/`, or the gem's one declared dependency; every `require_relative` stays inside the gem's `lib/`; a denylist names `json`, the transport names and `timeout` with the requirement that forbids each — `socket` scoped to core and the transport adapters, so the conformance gem's wire server may have it, the rest denied to every gem; a feature that is not a string literal is refused as unreadable | `base64`, `logger`, `tsort`, `json`, `timeout`, `net/http` in any gem, `socket` in a bound gem, an escaping `require_relative`, `require("json")`, `Kernel.require`, `autoload :JSON, "json"`, `require name` | | 9 | `gates:serde_boundary` | `SSE-37`, and the phase-7 segmentation design's spec-forced boundary 5: core's SSE layer and its pagination layer -- `lib/dexpace/sse.rb`, `lib/dexpace/sse/**`, `lib/dexpace/page.rb`, `lib/dexpace/page/**` and their `sig/` mirrors -- hold no serialization dependency, checked by a PARSED scan (prism for every `require`/`require_relative`/`autoload` spelling and every constant read or path, the RBS lexer for every type name), so the YARD may say the words the requirement asks for; every guarded glob must match at least one file, and a path whose files are not on the base yet sits on a printed `PENDING` list until they land, when its rows move to `GUARDED` -- the pagination layer's did on 2026-09-20, when 7c was reconciled onto the tree that holds the gate, and the list has been empty since (added by phase 7b) | `require "json"`, `require_relative "../serde/json"`, `Dexpace::Serde`, a bare `Serde` or `JSON`, `::JSON`, `self::JSON`, a serde type in a guarded `.rbs`, a guarded file that does not parse, a feature that is not a string literal, a `GUARDED` glob matching nothing | -| 10 | `gates:clean_bundle` | `SEAM-1`, `NFR-1`, `NFR-10`: each gem loads inside a scratch bundle holding only itself (and core, for an adapter); Bundler refuses every undeclared gem | a `require` of a bundled gem nobody declared — `logger` on Ruby 4.0 | -| 11 | `gates:rbs_surface` | `NFR-11`: no constant outside `Dexpace::` and a stdlib allowlist in any public signature. The signatures are loaded as one set and every name resolved against it, the way `rbs validate` does, so a relative `Headers` inside `module Dexpace` and a type variable `T` are not leaks; every type tree is then walked, so a leak inside an optional, a union, a tuple, a record, a proc or a type argument is seen | `Async::Task` as a return type, superclass, mixin (name or type argument), type alias, class or module alias, class- or method-level generic bound, or anywhere inside a type; `DexpaceX::Thing`; a set that does not resolve | -| 12 | `gates:sig_diff` | `NFR-4`: every public declaration in `sig/`, parsed and flattened to one line per overload with `private` honoured, against the last `v*` tag, per gem; a removed **or narrowed** declaration fails | a declaration or file gone since the tag, a dropped overload, a method moved under `private` (vacuous, by stated design, until the first tag) | -| 13 | `gates:surface_snapshot` | `NFR-4`: the runtime constant and method tree each gem adds to `Dexpace`, walked from the root and not from the gem's own constant — so an export placed *beside* a namespace (`Dexpace::Transport::Shared`, a method on `Dexpace::Transport`) is in the manifest — `Data.define` readers (those still public on the model) and singleton methods included, a `private_constant` excluded, one line per method, against `test/fixtures/surface/.txt`; an adapter's manifest is what its entry file adds with core already loaded, so core's lines are in core's manifest only | any export not in the manifest, inside or beside the gem's namespace; `rake surface:regenerate` is the deliberate act | -| 14 | `gates:single_instance` | design §2.4: one resolved path per core file; `Dexpace::VERSION` equals the gemspec. A second copy of core cannot finish loading once core holds `Data.define` models — Ruby refuses the re-opened class with `superclass mismatch` — and the gate reports that in the same terms, beside the features loaded twice before it | one core feature resolved from two directories; a version literal that disagrees | -| 15 | `gates:versions` | `NFR-14`, `NFR-10`: `.ruby-version`, the CI matrix, six gemspec versions and floors and six `VERSION` literals all agree with `VERSIONS` | any consumer that drifted; a gemspec that does not load | -| 16 | `gates:reproducible` | `NFR-12`: each gem built twice under a fixed `SOURCE_DATE_EPOCH` is byte-identical | a gemspec whose build depends on anything but its inputs | -| 17 | `yard` | styleguide 14.1: every public object documented | `yard stats --list-undoc` below 100% | -| 18 | `bundler_audit` | a known CVE in the resolved bundle | `bundler-audit check --update` | +| 10 | `gates:cause_walk` | `XCUT-9`: `Dexpace.each_cause` is the ONLY walk of an error's cause chain, checked by a parsed scan over every gem's `lib/`. A send is three node types — `a.cause` is `:CALL`, `a&.cause` is `:QCALL`, a bare `cause` is `:VCALL` — and a reflective `send`/`__send__`/`public_send`/`method` naming `:cause` is seen too, by Symbol AND by String; a Symbol literal is `:LIT` on 3.2 and 3.3 and `:SYM` on 3.4 and 4.0, so both are named. A send carrying an ARGUMENT is not a walk, which is what keeps 5b's `Event#cause(error)` builder out. Allowlist: `each_cause.rb` itself (added by phase 9) | `error.cause` anywhere else, `e&.cause`, `errors.map(&:cause)`, `e.send(:cause)`, `e.public_send("cause")` | +| 11 | `gates:bounded_map` | `XCUT-14`: only `Dexpace::BoundedMap` holds a map whose key space a caller or a server chooses, checked as a parsed scan for a `Hash` assigned to an instance variable — a literal, `Hash.new`, `::Hash.new`, a chained call on a literal and an `||=` are all seen. The allowlist is a path ⇒ REASON Hash and each of its six entries carries the adjudication that put it there, because an allowlist whose entries carry no argument is a list of things somebody once silenced; a test asserts every entry names a live file the scan still reports, so a stale entry cannot silently re-open a hole (added by phase 9) | `@by_origin = {}` in a new file, `@cache = Hash.new`, `@seen ||= {}` | +| 12 | `gates:seam_names` | `SEAM-2`: core never names a concrete seam implementation, matched by the LEAF namespace each adapter gem owns — `Serde::JSON`, `Transport::NetHTTP`, `Transport::AsyncHTTP`, `Async::Thread` — anywhere in a constant path or a String or Symbol literal, so `const_get("Dexpace::Serde::JSON")` is caught beside the bare constant. A fixed list of fully qualified names caught 0 of 4 realistic shapes and matching any path under a seam namespace flagged 31 conforming references, so the rule is the leaf pair; a later adapter gem adds its leaf here or is invisible, which is the gate's stated gap (added by phase 9) | `Dexpace::Serde::JSON` in core, `Transport::NetHTTP`, `const_get(:"Dexpace::Async::Thread")` | +| 13 | `gates:clean_bundle` | `SEAM-1`, `NFR-1`, `NFR-10`: each gem loads inside a scratch bundle holding only itself (and core, for an adapter); Bundler refuses every undeclared gem | a `require` of a bundled gem nobody declared — `logger` on Ruby 4.0 | +| 14 | `gates:rbs_surface` | `NFR-11`: no constant outside `Dexpace::` and a stdlib allowlist in any public signature. The signatures are loaded as one set and every name resolved against it, the way `rbs validate` does, so a relative `Headers` inside `module Dexpace` and a type variable `T` are not leaks; every type tree is then walked, so a leak inside an optional, a union, a tuple, a record, a proc or a type argument is seen | `Async::Task` as a return type, superclass, mixin (name or type argument), type alias, class or module alias, class- or method-level generic bound, or anywhere inside a type; `DexpaceX::Thing`; a set that does not resolve | +| 15 | `gates:sig_diff` | `NFR-4`: every public declaration in `sig/`, parsed and flattened to one line per overload with `private` honoured, against the last `v*` tag, per gem; a removed **or narrowed** declaration fails | a declaration or file gone since the tag, a dropped overload, a method moved under `private` (vacuous, by stated design, until the first tag) | +| 16 | `gates:surface_snapshot` | `NFR-4`: the runtime constant and method tree each gem adds to `Dexpace`, walked from the root and not from the gem's own constant — so an export placed *beside* a namespace (`Dexpace::Transport::Shared`, a method on `Dexpace::Transport`) is in the manifest — `Data.define` readers (those still public on the model) and singleton methods included, a `private_constant` excluded, one line per method, against `test/fixtures/surface/.txt`; an adapter's manifest is what its entry file adds with core already loaded, so core's lines are in core's manifest only | any export not in the manifest, inside or beside the gem's namespace; `rake surface:regenerate` is the deliberate act | +| 17 | `gates:single_instance` | design §2.4: one resolved path per core file; `Dexpace::VERSION` equals the gemspec. A second copy of core cannot finish loading once core holds `Data.define` models — Ruby refuses the re-opened class with `superclass mismatch` — and the gate reports that in the same terms, beside the features loaded twice before it | one core feature resolved from two directories; a version literal that disagrees | +| 18 | `gates:versions` | `NFR-14`, `NFR-10`: `.ruby-version`, the CI matrix, six gemspec versions and floors and six `VERSION` literals all agree with `VERSIONS` | any consumer that drifted; a gemspec that does not load | +| 19 | `gates:reproducible` | `NFR-12`: each gem built twice under a fixed `SOURCE_DATE_EPOCH` is byte-identical | a gemspec whose build depends on anything but its inputs | +| 20 | `yard` | styleguide 14.1: every public object documented | `yard stats --list-undoc` below 100% | +| 21 | `bundler_audit` | a known CVE in the resolved bundle | `bundler-audit check --update` | ## Where they run -`.github/workflows/ci.yml` has two jobs. **`test`** runs `test:gems` and gates 7, 8, 10 and 14 on +`.github/workflows/ci.yml` has two jobs. **`test`** runs `test:gems` and gates 7, 8, 13 and 17 on every Ruby in the matrix — 3.2, 3.3, 3.4 and 4.0 — because those are the gates whose answer depends on the interpreter: a method absent on the 3.2 floor, or a name that becomes a bundled gem at 4.0. **`gates`** runs everything else once, on the development Ruby named in `.ruby-version`. @@ -48,6 +52,10 @@ Every gate body that is more than a subprocess and a message is a module under ` ```bash DEXPACE_GATE_ROOT=test/fixtures/gates/gemspec_audit/two_third_party bundle exec rake gates:gemspec_audit +DEXPACE_GATE_ROOT=test/fixtures/gates/invariants/workspace bundle exec rake gates:cause_walk +DEXPACE_GATE_ROOT=test/fixtures/gates/invariants/workspace bundle exec rake gates:bounded_map +DEXPACE_GATE_ROOT=test/fixtures/gates/invariants/workspace bundle exec rake gates:seam_names +RUBYOPT=-r./test/fixtures/gates/serde_boundary/pending_probe.rb bundle exec rake gates:serde_boundary DEXPACE_SURFACE_EXTRA=Injected bundle exec rake gates:surface_snapshot DEXPACE_SURFACE_EXTRA=Dexpace::Transport::Shared bundle exec rake gates:surface_snapshot # beside a namespace DEXPACE_FORCE_DUPLICATE=1 bundle exec rake gates:single_instance diff --git a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md index 5c50002..c0dd44b 100644 --- a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md @@ -2761,6 +2761,52 @@ design. `RawWireTransport` proof, so it is conformance-gem work for the phase that next touches the suite, not a fix round's. Touches `TRANSPORT-7` and nothing normative in the code. Recorded by phase 8c's review round 1 on its docs branch; referred to by date and content, never by ordinal. +- **`Dexpace::Registry#resolve`'s discovery loop has no bound.** Found 2026-09-23 by phase 9's + implementation while cutting a mutation for `XCUT-23`: `#resolve` is `loop do … end` over + `take_or_join_claim`/`wait_on`, and a change that makes the `return hand_out(resolved)` guard + conditional on anything else turns it into an unbounded spin with no progress — the mutation ran for + six minutes and was killed. **The shipped code is correct**; what is missing is a bound or a + structural argument that the loop terminates, which phase 2 states nowhere. **Code half: a bound on + the loop, or a comment that argues the termination and a test that would catch its loss.** Touches + `SEAM-5`, `XCUT-23`. Referred to by date and content, never by ordinal. +- **`XCUT-12`'s fiber-scheduler form is unasserted, and the checklist row says so.** Phase 9's design + already routed this (its "Work phase 9 postponed" table names the thread form only), and the built + assertion repeats it in `invariant_suite/resolution.rb`: the single-flight refresh is proven for + sixteen THREADS and never for two fibers under a reactor, because a credential path running under a + scheduler is a composition no first-party suite assembles. **Code half: the fiber form of the + assertion, or a stated decision that it stays out of v1.** Touches `XCUT-12`, `AUTH-34`, `AUTH-37`. + Verified still owned on 2026-09-23 rather than re-filed. +- **`XCUT-9`'s cycle assertion has two stated residues, and `gates:cause_walk` proves neither absent.** + A collect-then-yield cause walk never returns a step to count, so a step-bounded drive cannot end it; + and a depth cap of exactly three passes a three-node cycle, because a black-box test over one finite + input cannot tell a counter from a visited set. Both are written into + `invariant_suite/classification.rb` above the assertion. **Code half: a second input of a different + length, or a source-shape check over the walk's own file.** Touches `XCUT-9`. Found 2026-09-23 by + phase 9's implementation. +- **7a's `SEAM-21` evidence lives inside another assertion's body and is declared in no ID-keyed map.** + Phase 9's `P9-22` re-derived `CodecSuite`'s two portable assertions rather than lifting 7a's, exactly + so `assert_closes_nothing`'s `SEAM-21` type-token clause would keep its home — but that clause is + still a branch inside a helper, not an assertion with `ids: ["SEAM-21"]`, so + `Aggregate.by_requirement_id` cannot see it and `APPENDIX_B.md`'s `B.3` rows point at a file rather + than at a check. **Code half: split the clause into its own assertion in `dexpace-serde-json`'s + suite, or state in the map that the row is a file reference.** Touches `SEAM-21`, `SERDE-3`. Found + 2026-09-23 by phase 9's implementation. +- **`tools/surface.rb` cannot see a `private_constant` module, so a group that became public would be + invisible to `gates:surface_snapshot` until one of its methods leaked.** The walk is + `Module#constants(false)`, which omits a private constant entirely — which is what lets phase 8a's + and phase 9's group modules stay out of the manifest, and equally what hides the transition. Phase 9 + found two real public-surface leaks (`Aggregate#add_rows` and `CodecSuite`'s two assertion factories) + by reading the regenerated manifest row by row, not by a gate. **Code half: have the walk record a + private constant's NAME with a marker, so the manifest shows the transition without exporting the + contents.** Touches `NFR-4`. Found 2026-09-23 by phase 9's implementation. +- **`Dexpace::Conformance::PackagingCase`'s default gem-name → constant-path rule covers none of this + port's exceptional four.** The rule is segment-for-segment (`dexpace-transport-net_http` → + `Dexpace::Transport::NetHttp`), and the port's own names need `Dexpace` for `dexpace-core` and + acronym casing for `NetHTTP`, `AsyncHTTP` and `JSON`, so four of six gems need the `constants:` + override a driver supplies. A driver that omits it gets a `:vacuous` result with an actionable + message and never a wrong answer, which is why this is a repair and not a defect. **Code half: a + default that covers a root-namespaced gem and a known-acronym leaf, with the override kept for + everything else.** Touches `NFR-15`. Found 2026-09-23 by phase 9's aggregate run. **2026-09-13** — **Execution order amended by the roadmap-level generator-fitness review, which read the plan end to end against one question: will a generated OpenAPI client be able to use this?** No cell of @@ -5044,3 +5090,97 @@ knowledge-structure verifier and both process-tooling suites, with every `ruby` ephemeral port), `conformance.md`'s changed fences on both and `async-thread.md`'s blocks once more on 4.0.6. `main` is `a7cfeb6` before and after this pass; nothing is pushed, and umbrella #29 stays open for both. + +**2026-09-23** — **Phase 9 implemented**, as three stacked branches against issue #33: code +(`9-phase-9-cross-cutting-invariants-and-conformance`), tests (`…-tests`) and documentation +(`…-docs`), each targeting the one below and the first targeting `main` at `582e33a`, which holds every +phase through 8. Nothing is pushed. **This is the audit phase, and it repaired nothing it found**: +design `R6` makes the repair phase 10's, and the file list the plan fixed held exactly — +`gems/dexpace-conformance/`, `tasks/`, `tools/`, `test/`, `.github/workflows/` and the root `Rakefile`, +plus each adapter gem's `test/` tree. **No `lib/` or `sig/` file outside `gems/dexpace-conformance/` +was touched, in any gem.** + +**What it built.** Four suites beside phase 8a's transport one, all over one shared `Runner` so the +five statuses are decided in one place — `InvariantSuite` (28 assertions over all twenty-four `XCUT` +ids, in ten `private_constant` groups), `PackagingSuite` (8 over `NFR-1`, `2`, `3`, `10`, `11`, `13`, +`14` and `15`, read from **published** `Gem::Specification` metadata rather than a source gemspec), +`CodecSuite` (2, re-derived from the requirement text rather than lifted, so `dexpace-serde-json`'s +files stay byte-identical and keep the three properties a lift would have dropped) and `ExecutorSuite` +(7, `SEAM-25`'s harness half among them) — with `Levels` (645 ids generated from appendix C) making a +MUST-level vacuity a report blocker, `SharedInstance` as `XCUT-11`'s structural predicate, `Aggregate` +giving a whole run one verdict and a preamble that prints what green does not prove, and +`APPENDIX_B.md`'s 61-row coverage map. Three repository-wide invariant scans became blocking gates over +one `RubyVM::AbstractSyntaxTree` walker — `gates:cause_walk`, `gates:bounded_map`, `gates:seam_names` — +taking the set to **twenty-one**, all in `DEFAULT_GATES` and all in `ci.yml`'s `gates` job. Three +first-party drivers, one per suite with a subject in this repository. + +**The audit's verdict.** The aggregate run over all four suites with the real subjects — 8a's adapter +as `transport:`, so `XCUT-18`'s call-site assertion runs against a real dispatch path — is **43 passed, +1 failed, 0 vacuous, 1 waived, 0 errored**. `PackagingSuite` was run a second time the way design +`P9-2` asks, against the six gems **built and installed outside the bundle**: **7 passed, 1 failed, 0 +vacuous**. The one failure both times is `NFR-13`: **307 of 307 shipped `.rbs` files carry no SPDX +header**, in all six gems, because a RuboCop cop parses Ruby and cannot reach `.rbs`. That is already +phase 10's by name — its design addendum `A8` and inbound row 8 plan `gates:spdx_rbs` and say "phase +9's presence assertion turns green" — so it is verified still owned and **not** re-filed. The one +waiver is `ASYNC-3`, printed `waived (would fail)`, with a second test that runs it unwaived and +requires the failure, so the waiver cannot outlive the limitation. + +**One `docs/first-release.md` blocker closes.** `gates:bounded_map` is green over all 307 files in the +six gems' `lib/` trees. `dexpace-transport-async_http`'s `Clients` is on the allowlist as a **verified +false positive of the scan** and not as a silenced defect: the as-built ivar is `@by_key`, not the +`@by_origin` the planning note named, and `#fetch` calls `#drain` inside the same `@mutex.synchronize` +as the insert with `#drain` a loop — `XCUT-14`'s drain clause exactly. So `XCUT-14`'s row is ✅, no +phase-10 repair is filed for that cache, and the plan's one expected red run did not happen. + +**Four things earlier phases postponed here have landed.** *(a)* The **conformance assertion +protocol**'s second half — 8a said the protocol landed; the remaining suites are these four, plus +`Runner`, `Check`, `SharedInstance`, `Levels`, `Aggregate` and `Report#to_h`. *(b)* The **portable +wire-boundary assertion** phase 1 named for this phase: a forged `Request` that never met a builder is +driven at a transport factory and refusal is asserted before any wire activity, with a listener the +assertion owns as the witness. *(c)* **`SEAM-25`'s harness half**, and this is a **correction to a +committed record, not a restatement of one**: 8b's design assigned that half to 8a, 8a wrote no +executor suite, so it was unwritten after phase 8 and phase 9 writes it. *(d)* The **MUST-level vacuity +blocker** phase 9 itself postponed on 2026-09-13, landed before the disposition run. + +**Six findings routed and none repaired**, each by date and content on phase 10's inbound list above: +`Registry#resolve`'s unbounded discovery loop, `XCUT-12`'s unasserted fiber-scheduler form, `XCUT-9`'s +two stated residues, 7a's `SEAM-21` evidence living inside another assertion's body, +`tools/surface.rb`'s blindness to a `private_constant` module, and `PackagingCase`'s default +constant-path rule. + +**Guards.** Forty-four mutations were run one at a time through a harness that applies the edit, runs +the owning suite, captures the first failure and restores the file — **every one red**, each recorded +in the checklist with the message it produced. Six shapes were re-cut: one spun forever and was killed +rather than counted, which is itself the `Registry#resolve` finding below, and five went green — and +**two of those five were not bad mutations but real non-discrimination in this phase's own +assertions**: `XCUT-3`'s cancel could beat the waiter into the wait, and `XCUT-12`'s sixteen racers +could finish one at a time. Both now assert a parking barrier as a `Check` of its own, so a run that +could not have discriminated reports `:failed`. The reviewer's own mutation list did not survive this +session's context compaction and the set run is therefore **this phase's**, re-derived from the +design's testing strategy — recorded as `P9-32` rather than passed off as the list it replaces. Two +further defects were found by building the failing fixtures this repository requires of every gate, and +both are fixed because both are phase 9's own code: the three new gates' `DEXPACE_GATE_ROOT` route +opened every file relative to the process's CWD and could never have been shown to reject anything, and +`gates:serde_boundary`'s abort branch and `--check`'s failing exit had no test at all. + +**Proofs.** The **code tip** is green on **every one of the twenty-one gates run individually** on +4.0.6 — the coverage floor included: the new `lib/` arrives there with no tests and the aggregate +figure is still 91.09 % — and on the five matrix gates on 3.2.11 (3,832 runs, 73,349 assertions, 3 +skips, 11,100 of 12,185 lines). The **tests tip** is green on the whole default task on 4.0.6 +(**4,184 runs, 74,555 assertions, 0 failures, 0 errors, 9 skips** and **99.80 % line coverage, +12,300 of 12,324**; `cops:test` 129 runs and 412 assertions; `test:gates` 192 runs and 876 assertions +with no skip; the honest RuboCop run over 776 files clean) and on the matrix set on 3.4.10 (4,184 / +74,555 / 9 skips), 3.3.12 (the same, 12,154 of 12,178 lines) and 3.2.11 (3,996 / 73,779 / **5** skips, +96.07 % — the four `async_http` skips are absent because that gem's 3.3 floor excludes it from the 3.2 +row). **Every skip is named**: the seven phase-8 drivers' — `net_http`'s three and `async_http`'s four +— plus this phase's two, `XCUT-18`'s call-site assertion in core's driver (vacuous there because core +ships no adapter, and real in the aggregate run) and `ASYNC-3` in the pool's (waived by id, and proven +to fail by a test of its own). The **docs tip** is green on the default task (the same figures), +the honest RuboCop run over 776 files, `ruby .claude/skills/housekeeping/probe.rb` (exit 0, no drift) +and `ruby scripts/verify_knowledge_structure.rb` (2,166 harvested entries, 71 notes, every cited key +live), with every `ruby` fence of `conformance.md`'s new section run verbatim on 4.0.6 and 3.2.11 — +the same printed values on both. Beside the gates: the aggregate run over all four suites with the +real subjects, and `PackagingSuite` a second time against the six gems built and installed outside the +bundle. + +`main` is `582e33a` before and after this pass; nothing is pushed, and issue #33 stays open. diff --git a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md new file mode 100644 index 0000000..c54c21d --- /dev/null +++ b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md @@ -0,0 +1,349 @@ +# Phase 9 — Cross-Cutting Invariants and Conformance: Checklist + +**Written at execution time, 2026-09-23, from what was built** — not from the plan. A row whose task did +not do what the plan said is a row that says so, and "Deviations from the plan" below states each +departure with its reason. The design and the plan were written on 2026-09-12 (reviewed 2026-09-13) +against phases 0–8's documents; since then the whole of phases 7 and 8 was built and merged, and this +phase was cut from `main` at `582e33a`, which holds all of them. Where the plan's text and the built tree +disagree the tree wins and this document records it. + +**Phase 9 reports; phase 10 repairs (design `R6`).** Nothing outside `gems/dexpace-conformance/`, +`tasks/`, `tools/`, `test/`, `.github/workflows/`, each adapter gem's `test/` tree and the root `Rakefile` +was changed, and no `lib/` or `sig/` file outside `gems/dexpace-conformance/` was touched at all. Every +defect this phase found in another phase's code is routed with its evidence and left unrepaired — +"Findings routed" below — because a phase that repairs what it found destroys the evidence that it was +ever there. + +Legend, verbatim from the roadmap's cross-cutting constraint 3: ✅ implemented and tested · +🚫 not built (permanent simplification, named reason) · ⏳ deferred (naming the plan task — phase, task +number and path — that will do it, or the `docs/first-release.md` entry that owns it) · N/A not +applicable in this port. + +Plan: `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance.md`. Task numbers +are that plan's — seventeen numbered tasks plus Task 12a, eighteen headings. Design: +`docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md`, whose +Deviation Ledger carries `P9-1`–`P9-10` and whose As-built addendum, written with this checklist, adds +**`P9-21`–`P9-33`** (the band starts at `P9-21`; `P9-11`–`P9-20` are left free and nothing is renumbered). +Test files are named with their gem: `conformance/…` is +`gems/dexpace-conformance/test/dexpace/conformance/`, `core/…` is `gems/dexpace-core/test/dexpace/`, +`gates/…` is `test/gates/`, `serde_json/…` is `gems/dexpace-serde-json/test/dexpace/serde/json/` and +`async_thread/…` is `gems/dexpace-async-thread/test/dexpace/async/thread/`. + +--- + +## Requirement rows + +**Forty-one own rows** — `XCUT-1`–`24` and `NFR-1`–`17` — plus the cross-reference rows below for the IDs +this phase's suites exercise and do not own. **Thirty-six ✅, two N/A (`NFR-8`, `NFR-9`), one ⏳ +(`NFR-16`), one ✅ with a stated partial (`NFR-12`) and one ✅ / ⏳ (`NFR-13`, whose assertion is built and +whose assertion FAILS against the tree)**; nothing 🚫. + +**The aggregate run's verdict**, Task 16 Step 2, over all four suites with the real subjects and 8a's +adapter supplying `transport:`, on 2026-09-23 under Ruby 4.0.6: + +``` +43 passed, 1 failed, 0 vacuous, 1 waived, 0 errored + waived (would fail): ASYNC-3 (cancelling a task blocked on a worker releases the worker within a bound) + FAILED: NFR-13: shipped signature files carry no SPDX header +passed? false +``` + +That `false` is the instrument working, not the instrument broken: one SHOULD-level failure with a named +owner and one waived MUST that is declared to fail. Nothing is vacuous — every factory is supplied — +and `XCUT-18`'s call-site assertion, which is vacuous in core's own driver because core ships no +adapter, runs for real here. + +**`PackagingSuite` was run a second time against what a consumer actually resolves**, which is what +design `P9-2` asks for and what a run inside `bundle exec` cannot give: the six gems were built with +`gem build`, installed into a scratch `GEM_HOME` outside the repository, and the suite run with every +`BUNDLE_*` and `RUBYOPT` key cleared. **7 passed, 1 failed, 0 vacuous, 0 waived** — `NFR-1`, `NFR-2`, +`NFR-3`, `NFR-10`, `NFR-11`, `NFR-14` and `NFR-15` against the PUBLISHED `Gem::Specification` and the +`sig/` trees inside the built `.gem` files, and the same `NFR-13` failure. That run is also the +evidence that every gem's `sig/` mirror SHIPS: the scan walked it inside the installed gem and not in +the repository. + +Every `XCUT` row's evidence is the same three-part shape and is stated once here rather than repeated +forty-one times: **(a)** the assertion in `dexpace-conformance`, which names no first-party constant it +cannot reach through `InvariantCase`; **(b)** that assertion driven against a deliberately +non-conforming double in `conformance/invariant_suite_test.rb` (or `…_core_test.rb`), which is what +proves the assertion can fail at all; and **(c)** the same assertion driven against the real SDK by +`core/cross_cutting_invariants_test.rb`, the first-party driver. A row naming a mutation names one from +"Guards run red" below, run against the real subject through (c). + +| ID | Level | Status | Task(s) | What was built, and where it is proven | +|---|---|---|---|---| +| `XCUT-1` | MUST | ✅ | 8, 16 | "a cancellation is terminal and never retried": the token's `#check!` surfaces `Dexpace::CancelledError`, `Policy.throwable_retryable?` answers false for it, and `Policy.cancellation?` still recognises it when an adapter's own retryable error wraps it — the third clause is the one a check reading only the outermost error would miss. Mutations 12 and 13 | +| `XCUT-2` | MUST | ✅ | 8, 16 | "a timeout is retryable and told apart from a cancellation by state": a `TransportError(phase: :read)` classifies retryable, the ambient flag stays clear, and a SUBTYPE of `CancelledError` is still recognised as a cancellation — the subtype clause is what makes "by the type, never by a message string" real | +| `XCUT-3` | MUST | ✅ | 8, 16 | "a pending inter-attempt wait is promptly cancellable": a thirty-second `Clock::SYSTEM#sleep` cancelled from another thread surfaces `CancelledError` inside half a second. The waiter must be **parked** when the cancel is issued, and that is asserted rather than assumed — without the barrier the cancel could beat the waiter into the wait and an implementation ignoring the token passed (`P9-28`). Mutation 22 | +| `XCUT-4` | MUST | ✅ | 6, 16 | "the error taxonomy has two branches and a transport error is I/O-family": `ProtocolError` carries a status and `TransportError < ::IOError` does not, and `Dexpace::Error` is a MODULE both reach through `Module#===` | +| `XCUT-5` | MUST | ✅ | 6, 16 | "one shared status classifier decides the baked retryability flag": `Retryability.retryable_status?` is the one classifier and `ProtocolError#retryable_by_status?` reads it — never `#retryable?`, which is `XCUT-6`'s open capability (6a's `P6-10`) | +| `XCUT-6` | MUST | ✅ | 6, 16 | "a custom error's retryability capability is queried, not its type": `Policy.throwable_retryable?` probes `#retryable?` over every cause and never a class list, so a caller's own error type is honoured | +| `XCUT-7` | MUST | ✅ | 6, 16 | "the configured retryable-status set is authoritative and can widen or narrow": a set admitting 404 admits it and one refusing 408 refuses it, and the default set is the six `RETRY-13` fixes | +| `XCUT-8` | MUST | ✅ | 6, 16 | "the status-to-exception factory refuses a non-error status": `ProtocolError.for` raises `InvalidArgumentError` on a 200, `.for_or_nil` answers nil, and the same factory still maps a 503 — both forms in one assertion | +| `XCUT-9` | MUST | ✅ | 6, 13, 16 | "the cause walk terminates on a cyclic chain, tracking by reference identity": a THREE-node cycle (two is indistinguishable from a depth-2 walk) driven through `Enumerator#next` under a step bound, so a non-terminating walk reports `:failed` rather than hanging. Beside it `gates:cause_walk`, the parsed scan that keeps the walk in one file. Mutation 24 | +| `XCUT-10` | MUST | ✅ | 6, 16 | "retry safety is decided from the request alone, uniformly": all four cases `XCUT-10` enumerates, plus the structural half — `Resend.eligible?`'s parameter list is exactly `[[:req, :request]]`, so there is no failure parameter to special-case on | +| `XCUT-11` | MUST | ✅ | 4, 8, 11, 16 | Two assertions plus a third in `ExecutorSuite`. Clause 1 is `SharedInstance.audit` over every shared instance the DRIVER declares (design `R8`, `P9-9`) and sixteen threads through one shared step with distinct requests; clause 2 is TWO FIBERS ON ONE THREAD, the only shape a lock held across a suspension point is visible in — Ruby's `Mutex` is per-fiber and non-reentrant — with the resulting `ThreadError` converted to a `Failure` so the status is `:failed` and not `:error`. The driver declares fifteen first-party shared instances and their ivars (`core/cross_cutting_invariants_test.rb`, "every shared instance core publishes"). Mutations 34, 35 and 44 | +| `XCUT-12` | SHOULD | ✅ | 8, 16 | "a credential cache's refresh is single-flight": sixteen threads race one `BearerStamper` over a parked provider and exactly one fetch happens. Every racer must be parked before the gate opens, asserted rather than assumed — without the barrier the first thread finished alone and a per-caller stamper passed (`P9-29`). Mutation 23. The FIBER-scheduler form is out: it needs a credential path under a reactor, which no first-party suite assembles — stated in the assertion and on phase 10's inbound list | +| `XCUT-13` | MUST | ✅ | 5, 11, 16 | Two assertions plus `ExecutorSuite`'s: the latch is counted AT THE RESOURCE (`Closeable#close` answers nil on the winning and the losing call alike, so a return value proves nothing), and the close is bounded by elapsed monotonic time on BOTH calls — never by an interrupt, which §8.3 bans outright. Mutations 2 and 36 | +| `XCUT-14` | MUST | ✅ | 7, 13, 16 | Two assertions plus `gates:bounded_map`. The cap clause reads the size after EVERY insert across 320 of them, so an implementation that overshoots and trims is caught; the DRAIN clause fills the backing store to cap + 5 — the state a concurrent overshoot leaves, arranged without a thread — and performs one `#set`: a drain loop ends at the cap, a check-then-evict at cap + 5, on every interpreter. Mutations 6 and 7 | +| `XCUT-15` | MUST | ✅ | 5, 16 | "public wire models retain no external-mutable alias": the mutation is on the INNER array of the `values:` Hash, because a port that dup'd only the outer Hash passes a top-level check. Mutation 1 | +| `XCUT-16` | MUST | ✅ | 7, 16 | All three clauses: the plaintext refusal is `Auth::HTTPSRequiredError` BY CLASS, an HTTPS request really is stamped (so the refusal is about the scheme and not a broken path), and a marker-suppressed cross-origin re-issue proceeds over `http` carrying no credential. Mutations 19 and 20 | +| `XCUT-17` | MUST | ✅ | 7, 16 | All four clauses in one assertion, because a port can satisfy any three: `Authorization` stripped on a SAME-origin re-issue, all three origin-scoped headers on a cross-origin one, the `Location`'s userinfo gone, and an HTTPS→HTTP downgrade refused. Mutations 15–18 | +| `XCUT-18` | MUST | ✅ | 7, 16 | Two assertions. The model layer asserts the ASYMMETRY — HTAB refused in a name and admitted in a value — which a port applying one rule to both passes with a single case. The call-site assertion drives a forged request (built through `send(:new)`, or a duck if that fails) at a real adapter and OBSERVES wire activity through a listener it owns: one accepted connection is the failure. Mutations 4 and 5. **Vacuous in the core driver, real in the aggregate run**: core ships no adapter, so `transport:` is nil there and the vacuity is accepted with its citation; Task 16's run supplies `Dexpace::Transport::NetHTTP.build` and the assertion passes for real | +| `XCUT-19` | MUST | ✅ | 7, 16 | Clauses (a)–(d): userinfo, a query value and a fragment token all gone from one URL while host and path survive; the header allow-list is default-DENY; and a credential reveals nothing in `#to_s`, `#inspect` or — checked STRUCTURALLY, because `pp` is outside this gem's require allowlist — `#pretty_print`, whose owner must be the credential's own class (`pp.rb` never consults an `#inspect` override on a `Data`). Mutations 9 and 10 | +| `XCUT-20` | MUST | ✅ | 7, 16 | Scoped exactly as 5c handed it forward and no wider: `Instrumentation.contain` swallows and reports, `Redactor#url` substitutes its marker for a malformed input, `#header_value` always answers a String, and `Preview.render` survives undecodable bytes. NOT extended to a foreign tracer or meter callback, which `OBS-20` carves out — a `rescue` there is a guard this suite must not demand. Mutation 11 | +| `XCUT-21` | MUST | ✅ | 7, 16 | Two observations and no character count: the handler takes an injectable `cnonce_source:` (so its randomness can be audited at all), and bytes are DRAWN from the injected recorder and CARRIED into the rendering — each drawn byte flipped in turn, and at least sixteen must change the output. A character count would call a truncated 128-bit draw conforming. Mutation 21 | +| `XCUT-22` | MUST | ✅ | 5, 11, 16 | Both halves: the caller-supplied resource is not closed AND it still works, because a component that tore it down another way would pass the first alone. `ExecutorSuite`'s twin asserts the same over a real pool behind `Transport.async_over`. Mutations 3 and 40 | +| `XCUT-23` | MUST | ✅ | 8, 16 | Three ordered rules and the ORDERING is the content: zero candidates fail loudly, two candidates fail loudly, an explicit install beats a discovered one, and a single candidate is discovered. "Loud" is checked for its own reason — the message must name the explicit-install entry point. Mutations 21b and 25 | +| `XCUT-24` | SHOULD | ✅ | 7, 16 | Both subjects, because the halves live in different objects: 4b's error-body snapshot is the cap and 3b's logging tap is the non-consumption. A preview that capped but drained would pass the first alone. Mutation 8 | +| `NFR-1` | MUST | ✅ | 9, 15 | Asserted TWICE against two subjects (`P9-2`): phase 0's `gates:gemspec_audit` over the source gemspec, and `PackagingSuite`'s "core declares zero runtime dependencies" over the resolved `Gem::Specification` a consumer actually gets. `gates:require_allowlist` and `gates:clean_bundle` are the same claim's other two mechanisms and all four are green on every matrix row | +| `NFR-2` | SHOULD | ✅ | 9, 15 | `PackagingSuite`'s "each adapter declares the core plus at most one library": `net_http` spends `net-http`, `async_http` spends `async-http`, `serde-json` spends `json`; `async-thread` and `conformance` spend none. Green against the built `.gem` files outside the bundle | +| `NFR-3` | SHOULD | ✅ | 9, 15 | `PackagingSuite`'s "every shipped implementation file has a signature beside it" — the `sig/` mirror as a property of the PACKAGED gem, not of the repository — beside `rbs validate` and `steep check` over six targets, both green with no relaxation added by this phase | +| `NFR-4` | SHOULD | ✅ | 15, 17 | `gates:sig_diff` (vacuous until the first `v*` tag, and it says so) and `gates:surface_snapshot` over the six manifests, regenerated deliberately once in Task 17 and reviewed row by row. This phase's rows are `dexpace-conformance`'s alone | +| `NFR-5` | SHOULD | ✅ | 15 | `rake test:gems` with SimpleCov `minimum_coverage 80`; measured at the tests tip | +| `NFR-6` | SHOULD | ✅ | 15 | `ruby -w` plus `RUBYOPT=-W:deprecated` with `DexpaceTestCase::FatalWarnings` raising, over one process holding all six gems' suites. This phase's `test/support/gate_warning_capture.rb` deliberately does NOT arm it, for the reason `P9-24` gives | +| `NFR-7` | SHOULD | ✅ | 15 | `rake rubocop` (findings fatal, no autocorrection) and `rake cops:test`. The honest command in a nested worktree is `bundle exec rubocop --fail-level=convention --ignore-parent-exclusion`, which is what was run: 776 files, no offences | +| `NFR-8` | MUST | N/A | 15 | Ruby has no whole-program dead-code elimination, and the requirement exempts itself by its own text. §10.19 retargets it at the require-allowlist audit and the clean-bundle isolation run, both dispositioned under `NFR-1` rather than counted twice (`P9-5`) | +| `NFR-9` | SHOULD | N/A | 15 | The same: there is no shrinker keep-configuration to guard. The part of its CONTENT that does apply — a guard that the require-allowlist is still the RIGHT list on a new interpreter — is postponed with its trigger named (`docs/first-release.md` § Post-release triggers, the require-allowlist entry) | +| `NFR-10` | MUST | ✅ | 9, 15 | `PackagingSuite`'s "every unit declares a runtime floor, and a higher one stays isolated" — `dexpace-transport-async_http`'s 3.3 floor must not leak into the other five — beside `gates:versions` | +| `NFR-11` | SHOULD | ✅ | 9, 15 | `gates:rbs_surface` over `sig/**` plus `PackagingSuite`'s "the core's public signatures name no async-framework type", whose leak regex anchors on the START of a constant path so core's own `Dexpace::Async::Future` is not a match. Mutation 41 | +| `NFR-12` | SHOULD | ✅ (partial) | 15 | `gates:reproducible` — byte-identity for one gem built twice on one interpreter, which is phase 0's `P0-7` reading. The cross-toolchain half is explicitly out of scope and is release-gated (`docs/first-release.md` § Release path, the `NFR-12`/`NFR-16` entry) | +| `NFR-13` | SHOULD | ✅ / ⏳ | 9, 15, 16 | ✅ for the `lib/` half — the `Dexpace/SpdxHeader` cop over every Ruby file — and for the ASSERTION over the `sig/` half, which is what this phase owed. ⏳ for the `sig/` half's CONTENT: the assertion **fails, correctly**, and that failure is this phase's verdict rather than a defect in it. Measured on 2026-09-23: **307 of 307 shipped `.rbs` files carry no SPDX header, in all six gems.** A RuboCop cop parses Ruby and cannot reach `.rbs`, so nothing checked them. **Owner: phase 10's design addendum `A8` and inbound row 8, which already name this assertion and say "phase 9's presence assertion turns green"** — verified still owned on 2026-09-23, not re-recorded | +| `NFR-14` | MUST | ✅ | 9, 15 | `PackagingSuite`'s "every unit's version comes from one source of truth" against the resolved specs, beside `gates:versions` over the repository-root `VERSIONS` file | +| `NFR-15` | SHOULD | ✅ | 9, 15 | `PackagingSuite`'s "each unit reports its build version at runtime": the loaded `VERSION` constant compared against the resolved gemspec's, run against locally built `.gem` files outside the bundle | +| `NFR-16` | SHOULD | ⏳ | — | Artifact signing is "enforced on the release/CI path" and there is no release path. No phase-9 task writes an assertion for it and none is shipped as a placeholder. **Owner: `docs/first-release.md` § Release path, the `NFR-12`/`NFR-16` entry** | +| `NFR-17` | MUST | ✅ | 13, 15 | Every gate this phase adds is in `DEFAULT_GATES` in the root `Rakefile` — not in `tasks/gates.rake`, which the `Rakefile` `load`s before defining and freezing the array — so all **twenty-one** are in `task default:` and therefore blocking, and all three are in `ci.yml`'s `gates` job. `gates/default_task_test.rb` asserts the count and the membership, and phase 0's `ci_workflow_test.rb` asserts every `DEFAULT_GATES` entry appears in some CI job | + +### Cross-reference rows + +One per ID this phase's suites assert and another phase owns. Each names the owning phase's checklist +row, which is not re-marked here. + +| ID | Owner | What this phase's suite adds | +|---|---|---| +| `SEAM-2` | phase 2 | `gates:seam_names`, the parsed scan asserting core names no adapter's leaf namespace — by leaf pair and not by a literal spelling, because a fixed list of fully qualified names caught none of four realistic shapes | +| `SEAM-12` | phase 2 | `ExecutorSuite`'s "a shared executor takes work from many threads", sixteen concurrent posts asserted on the collected set and never on timing | +| `SEAM-20` | phase 2 | `CodecSuite`'s "a codec never closes a target it was handed", with the bytes checked too so a codec that wrote nothing cannot pass by having closed nothing. Mutation 37 | +| `SEAM-25` | phase 2 (postponed to 8a, unwritten there) | `ExecutorSuite`'s "closing an owned executor emits exactly one shutdown event" — **the harness half, reassigned to phase 9 and stated as a correction to a committed record**, not a restatement of it (`P9-21`) | +| `SERDE-3` | phase 7a | The same `CodecSuite` assertion, re-derived rather than lifted (`P9-22`) | +| `SERDE-9` | phase 7a | `CodecSuite`'s "a decode failure surfaces the SDK's serde type", all three clauses: it raises, the type is inside `Serde::Error`, and the original is chained — read through `Dexpace.each_cause` so `gates:cause_walk`'s allowlist stays at one entry. Mutation 38 | +| `SSE-37` | phase 7b | `gates:serde_boundary`'s `PENDING` list asserted EMPTY, the clause 7b handed forward and could not assert while phase 7 was running. The task's abort branch is now driven by a fixture (`P9-30`) | +| `ASYNC-3` | phase 8b | `ExecutorSuite`'s "cancelling a task blocked on a worker releases the worker within a bound", written so it genuinely FAILS and waived by ID, printed `waived (would fail): ASYNC-3`. The driver runs it UNWAIVED in a second test and requires the failure, so the waiver cannot outlive the limitation | +| `ASYNC-15` | phase 8b | The close-latch and borrowed-executor assertions, which carry it beside `XCUT-13` and `XCUT-22` | +| `ASYNC-16` | phase 8b | `ExecutorSuite`'s "close drains in-flight work and refuses new work", both halves | +| `ASYNC-17` | phase 8b | `ExecutorSuite`'s "a resource-free implementation inherits a no-op close", whose subject is `Transport.async_over` over an inline executor and deliberately NOT the pool — handing the pool there would assert the opposite requirement | + +--- + +## What was built + +Four suites, one aggregate, three repository gates, one generated level map, one coverage map and three +first-party drivers. + +- **`Runner`** — the one place the five statuses are decided, with the rescue order load-bearing + (`Vacuous` and `Failure` are both `::StandardError` descendants, so the bare rescue is last) and a + FRESH subject per assertion. `TransportSuite` is deliberately not moved onto it (`P9-10`). +- **`Report`** gains `.merge`, `#to_h`, `#blocking_vacuities`, `#accepted_vacuities`, `#unknown_ids`, + `#would_fail` and the MUST-level vacuity blocker; `#passed?` is false while a blocking vacuity stands. +- **`Levels`** — 645 IDs generated from appendix C by `tools/requirement_levels.rb`, with a `--check` + that both exits are now proven against (`P9-31`). +- **`SharedInstance.audit`** — `XCUT-11`'s structural predicate, R8's disjunction: frozen conforms on + `frozen?` alone, unfrozen only when every ivar is one the DRIVER declared. +- **`InvariantSuite`** — 28 assertions over all 24 `XCUT` IDs in ten `private_constant` groups. +- **`PackagingSuite`** — 8 assertions over `NFR-1`, `2`, `3`, `10`, `11`, `13`, `14` and `15`. +- **`CodecSuite`** — 2 portable seam assertions, re-derived (`P9-22`). +- **`ExecutorSuite`** — 7 assertions in two groups, `ASYNC-3` among them and written to fail. +- **`Aggregate`** — one `Report` over every suite, with a `PREAMBLE` that prints what a green run does + NOT prove on every render. +- **Three gates** — `gates:cause_walk`, `gates:bounded_map`, `gates:seam_names`, over + `tools/ast_scan.rb`'s `RubyVM::AbstractSyntaxTree` walker. Twenty-one blocking gates in all. +- **`APPENDIX_B.md`** — the 61-row coverage map, generated and hand-annotated. +- **Three drivers** — `core/cross_cutting_invariants_test.rb`, + `serde_json/conformance_test.rb`, `async_thread/conformance_test.rb`. + +--- + +## Guards run red + +The reviewer's mutation list could not be recovered verbatim after this session's context was compacted +(`P9-32`), so the set below was re-derived from the design's testing strategy and the plan's own +"non-conforming double first" rule, one mutation per assertion, per gate and per piece of shared +machinery. **Forty-four mutations, every one red**, run one at a time through a harness that applies the +edit, runs the owning suite, captures the first failure and restores the file, on **4.0.6**. The table +below numbers forty-five slots because two are placeholders for a mutation that was re-cut rather than a +mutation of their own. Six shapes were re-cut: one spun forever and was killed rather than counted (a +finding in its own right, routed below), and five went green; **two of those five were not bad mutations but real +non-discrimination in this phase's own assertions**, and both were repaired before the mutation was +re-run — `XCUT-3`'s cancel could beat the waiter into the wait, and `XCUT-12`'s sixteen racers could +finish one at a time. Those two repairs are `P9-28` and `P9-29`, and they are the reason the mutation +pass is worth its cost. + +| # | Mutation | Caught by (first failure) | Rows | +|---|---|---|---| +| 1 | `Headers#initialize` aliases the caller's Hash instead of `Model.own` | `XCUT-15: a model changed when a collection passed into it was mutated` | `XCUT-15` | +| 2 | `Closeable#close` runs `#release` on every call | `XCUT-13: close ran its release more than once (expected 1, got 2)` | `XCUT-13` | +| 3 | `Closeable#close` releases regardless of `owned:` | `XCUT-22: the SDK closed a resource it did not create` | `XCUT-22` | +| 4 | `HeaderSyntax.validate_name!` admits HTAB | `XCUT-18: a header NAME containing HTAB was accepted` | `XCUT-18` | +| 5 | `HeaderSyntax.validate_outbound_value!` refuses HTAB | `XCUT-18: an outbound VALUE containing HTAB was rejected` | `XCUT-18` | +| 6 | `BoundedMap#set` evicts once instead of draining | `XCUT-14: one insert into an over-cap map evicted once instead of draining back to the cap` | `XCUT-14` | +| 7 | `BoundedMap#set` has no cap at all | `XCUT-14: an insert burst pushed the map past its cap` | `XCUT-14` | +| 8 | `Body.buffer_bounded` multiplies its cap | `XCUT-24: an error-body snapshot materialised an unbounded payload` | `XCUT-24` | +| 9 | `Redactor`'s authority builder forwards the userinfo | `XCUT-19: "pw" survived URL redaction` | `XCUT-19` | +| 10 | `Redactor#header_name?` is default-ALLOW | `XCUT-19: the header allow-list is not default-deny` | `XCUT-19` | +| 11 | `Instrumentation.contain` re-raises after reporting | `RuntimeError: a sink exploded` (an `:error`, which is the point: it reached the caller) | `XCUT-20` | +| 12 | `Policy.cancellation?` stops walking the cause chain | `XCUT-1: a cancellation wrapped in a retryable transport error was not recognised` | `XCUT-1` | +| 13 | `Policy.throwable_retryable?` answers true for a cancellation | `XCUT-1: a cancellation classified retryable` | `XCUT-1` | +| 14 | *(superseded — see 22)* | | | +| 15 | `Reissue.strip` moves the `Authorization` removal behind the cross-origin test | `XCUT-17: Authorization survived a SAME-ORIGIN re-issue (clause a)` | `XCUT-17` | +| 16 | `Reissue.strip` drops the `Cookie` removal | `XCUT-17: cookie survived a CROSS-ORIGIN re-issue (clause b)` | `XCUT-17` | +| 17 | `Location` clears the userinfo with `= nil`, the documented silent no-op | `XCUT-17: userinfo in the Location survived the re-issue (clause c)` | `XCUT-17` | +| 18 | `Reissue.downgrade!` never raises | `XCUT-17: an HTTPS-to-HTTP downgrade was followed without opt-in (clause d)` | `XCUT-17` | +| 19 | `Auth::Step#enforce_https!` raises nothing | `XCUT-16: a credential was stamped over a plaintext transport` | `XCUT-16` | +| 20 | `Auth::Step` drops the cross-origin suppression | `XCUT-16: a credential-FREE cross-origin re-issue was refused by the HTTPS guard` | `XCUT-16` | +| 21 | `DigestHandler` draws from `::SecureRandom` instead of the injected source | `XCUT-21: the handler drew no bytes from the injected source` | `XCUT-21` | +| 21b | `Registry#install` becomes a no-op | `XCUT-23: an auto-discovered implementation beat an explicit install` | `XCUT-23` | +| 22 | `Clock#sleep` never subscribes the token | `XCUT-3: a pending inter-attempt wait was not released by a cancellation` | `XCUT-3` | +| 23 | `BearerStamper#refresh!` fetches outside the lock | `XCUT-12: a token refresh was not single-flight: every concurrent caller fetched` | `XCUT-12` | +| 24 | `Dexpace.each_cause` drops its visited test | `XCUT-9: the cause walk did not terminate after visiting the cycle once` | `XCUT-9` | +| 25 | `Registry#sole_key` picks the first of several candidates | `XCUT-23: two registered candidates with no explicit install did not fail loudly` | `XCUT-23` | +| 26 | *(the zero-candidate branch is the same method; covered by 25 and 21b)* | | | + +A twenty-first shape was cut and then **killed rather than counted**: conditioning `Registry#resolve`'s +`return hand_out(resolved)` guard on anything else turns its `loop do … end` into an unbounded spin with +no progress, and it ran for six minutes before it was stopped. That is not a guard going red; it is the +finding routed below, and the two mutations that replaced it — 21b and 25 — are what prove `XCUT-23`. +| 27 | `Runner#one` rescues `StandardError` before `Vacuous` | `Expected: :vacuous / Actual: :error` | `NFR-17` | +| 28 | `Runner#waived?` matches the assertion NAME | `the preamble, the acceptances and the would-fail declarations reach the Report` | `NFR-17` | +| 29 | `Report#passed?` drops the blocking-vacuity term | `an acceptance naming one of two MUST ids still blocks, because accepted? is all?` | `NFR-17` | +| 30 | `Report#accepted?` becomes `any?` | the same | `NFR-17` | +| 31 | `Report#validate_citation!` admits a blank citation | `ArgumentError expected but nothing was raised` | `NFR-17` | +| 32 | `Report#unknown_ids` always answers empty | `Expected: ["XCUT-99"]` | `NFR-17` | +| 33 | `Levels.must?` counts an unknown ID as a MUST | `an unknown id never blocks` | `NFR-17` | +| 34 | `SharedInstance.audit` drops the frozen half of the disjunction | `a frozen instance holding state conforms on frozen? alone` (raised a `Failure`) | `XCUT-11` | +| 35 | `SharedInstance.audit` reads the declaration off the audited object | `a subject declaring its own per-call state as exempt is not believed` | `XCUT-11` | +| 36 | `Closeable#close` unlatched, seen through the pool's shutdown event | `XCUT-13, ASYNC-15: close shut the executor more than once` | `XCUT-13`, `ASYNC-15` | +| 37 | `Codec#dump_to` closes the sink it was handed | `SEAM-20, SERDE-3: the codec closed a caller-supplied sink` | `SEAM-20`, `SERDE-3` | +| 38 | `Codec#load` lets a bare `NoMethodError` escape | `SERDE-9: a failure outside the SDK's serde hierarchy escaped the seam` | `SERDE-9` | +| 39 | `Runner.run` builds one subject and shares it across the run | `the subject block is called once per assertion / Expected: 2, Actual: 1` | `NFR-17` | +| 40 | `Bridge::AsyncOver` owns and closes its executor | `XCUT-22, ASYNC-15: the SDK shut down an executor it borrowed` | `XCUT-22`, `ASYNC-15` | +| 41 | `PackagingSuite::Surface::LEAK` matches nothing | `a signature naming an async-framework type fails NFR-11 / Expected: :failed` | `NFR-11` | +| 42 | `Aggregate.by_requirement_id` defaults a missing result to `:passed` | `by_requirement_id reads each suite's DECLARED assertions, not a report's results` | `NFR-17` | +| 43 | one row deleted from `APPENDIX_B.md` | `every row's ID column equals the set parsed from that item's own text` | `NFR-17` | +| 44 | `Sharing` skips the driver's declared shared instances | `a shared instance the driver declares is audited too` | `XCUT-11` | +| 45 | `AstScan.parse` leaves `$VERBOSE` armed | `AstScan.parse's $VERBOSE window did not hold` | `XCUT-9`, `XCUT-14`, `SEAM-2` | + +Beside the mutations, the three new gates are each driven against a deliberately failing **fixture +workspace** through `DEXPACE_GATE_ROOT` (`test/fixtures/gates/invariants/workspace/`), and +`gates:serde_boundary`'s abort branch against a `RUBYOPT` prelude fixture. Those four routes did not +exist when the mutation pass began and the first of them found a real defect — `P9-27`. + +--- + +## Audit groups run + +The phase-start pair first, at implementation on 2026-09-23: `--origin note --brief` and +`--section conflicts --brief`, the latter returning every one of the six harvested conflicts +`[overridden by notes/…]` and none open. `--req` was run per task against the IDs each task names. + +**This phase's own audit group did not exist and was added to the table** in +`.claude/skills/knowledge-lookup/SKILL.md`, because an audit whose group is not written down cannot be +repeated: **Cross-cutting invariants and the quality bar** — +`--topic cross-cutting-invariants,tooling-and-quality-gates,resource-management --section rules --brief` +and `--prefix XCUT,NFR --section rules --brief`. Run on 2026-09-23: **41 entries across 12 topic files**, +and `--gaps XCUT,NFR` answers **0 of 41** — every one of this phase's IDs has a substantive corpus entry, +none roll-up only, so nothing here had to be read out of the specification instead. + +| Group | Result at implementation | +|---|---| +| Cross-cutting invariants and the quality bar | The new row above. Two of its entries are this phase's own, filed at design time — `cross-cutting-invariants/89eb6533`'s audit consequence and the AST-gates entry — and both were re-read in full before the code they describe was written; one of the two is CORRECTED by execution, in a new entry rather than an edit (`gates:drain_loop` was not built) | +| Public API surface | Every new group module is a `private_constant`, so `tools/surface.rb`'s `Module#constants(false)` walk does not see it — 8a's `TransportSuite` precedent. Each suite's public surface is exactly `assertions`, `run` and `PREAMBLE`; two leaks found by reading the regenerated manifest row by row were closed rather than accepted (`P9-25`) | +| Gem layout, zero-dependency core | `dexpace-conformance` still declares `dexpace-core` alone; the new files add no require outside the allowlist; `gates:clean_bundle` green on every row | +| RBS / Steep typing | Every new `lib/` file has a `sig/` mirror, the `private_constant`s carrying `hooks.rbs`'s comment; both targets green with no relaxation added. One signature is deliberately INCOMPLETE and says so: `invariant_suite/credentials.rbs` omits `include ::Random::Formatter`, because `tools/rbs_surface.rb`'s `STDLIB_ALLOWED` does not carry `Random` and widening a gate's allowlist for a test double is lowering the gate (`P9-26`) | +| Minitest conventions | Every suite subclasses `DexpaceTestCase` or `GateCase`; no `.stub`; mutable fixtures built fresh per test; no `sleep` in any new test — every wait is a queue pop with a timeout, a bounded join or a bounded `Thread.pass` spin on `Thread#status` | +| Fiber scheduler, thread safety | The two-fibers-on-one-thread assertion is the shape 8b handed forward; the barriers added in `P9-28` and `P9-29` use `Thread#status` and never a sleep | +| Observability, configuration and redaction | `XCUT-19`'s and `XCUT-20`'s assertions are scoped to what 5b and 5c own and are not widened to a foreign callback | + +--- + +## Deviations from the plan + +Departures from the plan's text, each with its reason. None lowers, disables or narrows a gate. The +corresponding ledger rows are `P9-21`–`P9-33` in the design's As-built addendum. + +1. **`dexpace-serde-json`'s and `dexpace-core`'s existing files are byte-identical** — the plan's Task 10 + removes `assert_closes_nothing`'s and `assert_failure_model`'s portable halves from 7a's + `serde_seam_assertions.rb` and drops two calls from its driver. Nothing was removed: the two + `CodecSuite` assertions are RE-DERIVED from the requirement text, so `SEAM-21`'s type-token clause and + the encode half of `SERDE-9` — both of which live inside the bodies a lift would have moved — stay + alive in 7a's suite. `P9-22`. +2. **`MinitestDriver` is not used by any of the three drivers.** 8a wrote it around `TransportCase` and + `R6` keeps phase 9 out of phase 8's file. Each driver carries its own six-line loop instead; the + residue — two driver loops in one repository — is stated rather than hidden. `P9-23`. +3. **`test/support/gate_warning_capture.rb` deliberately does not require `dexpace_test_case`.** The plan + said to, so the two prepends land in order. Measured: `test/gates/` runs on `GateCase`, which does not + install `FatalWarnings`, and arming it from a support file armed it for the whole `test:gates` process + — where three deliberately-invalid gemspec fixtures and YARD's own load-time warnings had always + reached `Warning.warn` harmlessly. Four pre-existing gate tests went red. The file ships the RECORDER + and not the raiser, and the scanner's warning property is proven by recording. `P9-24`. +4. **The bounded-map allowlist was re-adjudicated against as-built paths, and the plan's one expected + true positive is a false positive.** The plan's Task 16 Step 3 expects `gates:bounded_map` to report + `async_http/clients.rb`'s `@by_origin` as an uncapped cache. The as-built name is `@by_key` and it IS + capped: `Clients#fetch` calls `#drain` inside the same `@mutex.synchronize` as the insert, and `#drain` + is a loop — `evicted << @by_key.delete(@by_key.keys.first) while @by_key.size > MAX_ORIGINS`, closed + reactors first — which is `XCUT-14`'s drain clause exactly. Verified by reading the file on + 2026-09-23. So the gate is GREEN, `XCUT-14`'s row is ✅ rather than ⏳, no phase-10 inbound bullet is + filed for it and no `docs/first-release.md` blocker is opened — and the standing blocker that waited + on this verification is ticked rather than a new one filed. `P9-27`. +5. **Two assertions this phase wrote did not discriminate, and both were repaired.** `XCUT-3`'s cancel + could be issued before the waiter entered the wait, and `XCUT-12`'s racers could finish serially. Both + now assert a parking barrier as a `Check` of its own, so a run that could not have discriminated + reports `:failed` and never `:passed`. Found by the mutation pass. `P9-28`, `P9-29`. +6. **The three new gates' `DEXPACE_GATE_ROOT` route was broken and is fixed.** The tasks handed the + scanner paths made relative to the gate root, which it then opened relative to the process's CWD — the + same directory only when the variable is unset. Under a fixture root every open raised `Errno::ENOENT`, + so the three gates could never have been shown to reject anything. They now scan absolute paths under + the root, join both allowlists to the same root, and make the message relative afterwards; a fixture + workspace and four tests keep it fixed. This is a defect in this phase's own code and is therefore + fixed rather than routed. `P9-27`. +7. **`gates:serde_boundary`'s abort branch and `--check`'s failing exit had no test.** `PENDING` is a + frozen constant with no root or environment that can make the task see a non-empty one, and + `RequirementLevels::TARGET` is fixed — so a mutation deleting the abort, and one making `--check` + always exit 0, both left every test green. A `RUBYOPT` prelude fixture drives the first; an optional + path argument on `--check` drives the second. `P9-30`, `P9-31`. +8. **The reviewer's mutation list is not quoted here.** It was in this run's opening brief and did not + survive the session's context compaction. The forty-four mutations above were re-derived from the + design's testing strategy and the plan's per-task "non-conforming double" rule; the count exceeds the + thirty-five the brief named as a minimum, but the SET is this phase's and not the reviewer's, and a + reviewer re-running the original list should expect to find it covered rather than assume it. `P9-32`. +9. **`SEAM-25`'s harness half is recorded as a correction to a committed record.** 8b's design said the + harness half was 8a's; 8a wrote no executor suite. Phase 9 writes it, and says so out loud rather than + restating the earlier record as though it had been right. `P9-21`. + +--- + +## Findings routed + +Nothing below is repaired here. Each is routed to its owner with its evidence, per design `R6`. + +| Finding | Evidence | Routed to | +|---|---|---| +| **307 of 307 shipped `.rbs` files carry no SPDX header**, in all six gems — `NFR-13`'s conformance clause is "scan ALL source files" and `sig/` ships inside every gem | The aggregate run's one failure, 2026-09-23; `grep -rl "SPDX-License-Identifier" gems --include='*.rbs'` answers 0 of 307 | **Already owned**: phase 10's design addendum `A8` (`gates:spdx_rbs`) and its inbound row 8, which name this very assertion. Verified still owned on 2026-09-23 and NOT re-recorded | +| `PackagingCase`'s default gem-name → constant-path rule cannot produce `Dexpace` for `dexpace-core` or an acronym-cased leaf (`NetHTTP`, `AsyncHTTP`, `JSON`), so four of six gems need the `constants:` override a driver supplies. A driver that omits it gets a `:vacuous` with an actionable message, never a wrong answer | The aggregate run's first pass reported `Dexpace::Core is not loaded` | phase 10's inbound list (dated 2026-09-23) — a defaulting rule that covers the port's own six is a repair, not a report | +| `Registry#resolve`'s discovery loop spins forever when `@state.resolved` is set but a candidate remains registrable — reachable only by a code change, but the loop has no bound at all | Found while cutting a mutation: `return hand_out(resolved) if resolved && @state.factories.empty?` turned `#resolve` into an unbounded `loop do` with no progress. The shipped code is correct; the LOOP's lack of a bound is the finding | phase 10's inbound list (dated 2026-09-23) | +| `XCUT-12`'s fiber-scheduler form is unasserted: the requirement's "wait-free hot-path read" and single-flight clauses are proven for threads only, because a credential path under a reactor is a composition no first-party suite assembles | `invariant_suite/resolution.rb`'s own comment; the assertion names the thread form explicitly | phase 10's inbound list (dated 2026-09-23) | +| `XCUT-9`'s stated residue: a collect-then-yield cause walk never returns a step to count, and a depth cap of exactly three passes the cycle assertion. `gates:cause_walk` is the second line and proves neither absent | `invariant_suite/classification.rb`'s comment, written before the assertion | phase 10's inbound list (dated 2026-09-23) | +| 7a's `SEAM-21` evidence lives inside `assert_closes_nothing`'s body and has no assertion of its own; the re-derivation left it there deliberately, but it is undeclared in any ID-keyed map | `P9-22`; `APPENDIX_B.md`'s `B.3` rows | phase 10's inbound list (dated 2026-09-23) | +| `tools/surface.rb` cannot see a `private_constant` module, so a group module that became public would be invisible to `gates:surface_snapshot` until its methods leaked | Found by reading the regenerated manifest row by row (`P9-25`): two real leaks were present and were closed | phase 10's inbound list (dated 2026-09-23) | + +--- + +## Postponed work + +Everything the design postponed is VERIFIED still owned rather than re-recorded, per the phase workflow. +The five entries in the design's "Work phase 9 postponed, and who owns it now" table stand as written; +their owners — `docs/first-release.md` § Post-release triggers and § Release path — were re-read on +2026-09-23 and each names the entry it was given. + +`NFR-16` is the one requirement row this phase leaves ⏳, and its owner is the `NFR-12`/`NFR-16` entry +under § Release path. `NFR-8` and `NFR-9` are N/A with their reasons above and are not deferred: there is +nothing to pick up. diff --git a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md index c237000..14bab05 100644 --- a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md +++ b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md @@ -1075,6 +1075,32 @@ Each row is consolidated into design §10 and audited by `docs/deviations.md`. | P9-9 | `XCUT-11`'s audit predicate permits two named kinds of mutable state on a shared instance — a `Thread::Mutex` with the state it guards, and `Closeable`'s `@closed` latch — and **the driver declares them, never the audited object** | `XCUT-11`; `cross-cutting-invariants/89eb6533` | A *frozen and no ivars* predicate would condemn every closeable component in the SDK and the exact latch shape the corpus prescribes. Reading the declaration off the subject inverts the requirement in both directions — measured: a conforming latch-plus-mutex object **fails**, because no phase committed to such a method and `R6` forbids adding one, while the identical per-call-state bug **passes** by declaring its own ivar exempt. The driver is `dexpace-core`'s own suite and knows which ivar is which | | P9-10 | `Runner` is **new shared infrastructure inside a gem phase 8 owns**, and `TransportSuite` is deliberately not moved onto it | design §9.3; the conformance assertion protocol; `R6` | Four suites would otherwise carry four copies of one status loop, which is §11.12's "four reference sync/async drifts" reappearing inside the port's own suite. Not moving `TransportSuite` leaves **two status-deciding paths in one gem**, and that is the price of `R6`: phase 8 owns that file, and a phase that refactors another phase's shipped code while claiming only to report is the boundary failing quietly. The residue is real — a future change to the five statuses must be made twice — and is stated rather than left to be noticed | +### As built, 2026-09-23 + +The ten rows above stand as decided; `R1`–`R8` were built as written, with `R3`'s vacuity blocker, `R5`'s +`waived (would fail)` rendering and `R8`'s disjunction all shipping in the shapes this document fixed. +This phase was cut from `main` at `582e33a`, which holds every phase through 8. Execution added the rows +below, numbered from **P9-21** as the brief fixes it; `P9-11`–`P9-20` are left free and nothing is +renumbered. The checklist's "Deviations from the plan" is the itemised list against the plan's text; the +rows here are the ones that touch public behaviour, the contract a later phase cites, or a statement this +document makes. + +| # | Deviation | Requirement / document | Why | +|---|---|---|---| +| P9-21 | `SEAM-25`'s harness half is recorded as a **correction** to a committed record, not a restatement of one | `SEAM-25`; 8b's design | 8b's design said the harness half was 8a's and that 8b would hand it the shape; 8a wrote the protocol, the wire fixture and the transport suite and **no executor suite**. So the harness half was unwritten after phase 8. Phase 9 writes it and says so out loud, which is the discipline phase 4b failed when it corrected its charter while claiming to restate it | +| P9-22 | `CodecSuite`'s two assertions are **re-derived from the requirement text**, and `dexpace-serde-json`'s own files are left byte-identical | `SEAM-20`, `SERDE-3`, `SERDE-9`; plan Task 10 | The plan's lift would have moved `assert_closes_nothing`, whose body is the only evidence `SEAM-21` has in that suite, and `assert_failure_model`, which carries `SERDE-10` and the ENCODE half of `SERDE-9` too. A lift taking the portable halves would have dropped three properties on its way out. The relationship is `TransportSuite`'s to the net_http suite's instead: a portable assertion beside a gem-local one, both alive | +| P9-23 | None of the three first-party drivers uses 8a's `MinitestDriver`; each carries its own six-line generated-test loop | `R6`; 8a's `MinitestDriver` | `MinitestDriver#conformance` builds a `TransportCase` for every assertion, and widening it would change an interface phase 8 owns. The residue — two driver loops in one repository — is the price of `R6` and is stated in each driver's header rather than hidden | +| P9-24 | `test/support/gate_warning_capture.rb` deliberately does **not** require `dexpace_test_case`, and the scanner's warning property is proven by RECORDING rather than by arming the raiser | `NFR-6`; plan Task 1 | The plan said to require it so the two prepends land in order. Measured: `test/gates/` is the one suite whose base is `GateCase`, which does not install `FatalWarnings`, and arming it from a support file armed it for the whole `test:gates` process — where three deliberately-invalid gemspec fixtures and YARD's own load-time warnings had always reached `Warning.warn` harmlessly. Four pre-existing gate tests went red. What the file needs is the recorder, not the raiser | +| P9-25 | Two public-surface leaks were found by reading the regenerated manifest row by row and were **closed rather than accepted** | `NFR-4`; `R6` | `Aggregate#add_rows` was public (an `@api private` tag is not privacy), and `CodecSuite`'s two assertion factories were public where every other suite's are in a `private_constant` group. Both are this phase's own code, so both are fixed: every suite's public surface is now exactly `assertions`, `run` and `PREAMBLE`. `tools/surface.rb` walks `Module#constants(false)` and cannot see a `private_constant` module at all, which is why reading the manifest was the only way to find them | +| P9-26 | `invariant_suite/credentials.rbs` deliberately omits `include ::Random::Formatter`, and says so in the file | `NFR-11`; `gates:rbs_surface` | The `Recorder` double includes `Random::Formatter` so one `#bytes` override records every draw. `tools/rbs_surface.rb`'s `STDLIB_ALLOWED` does not carry `Random`, and widening a gate's allowlist to accommodate a test double is lowering the gate. The signature is incomplete by one line, the reason is in the file, and Steep is green over it | +| P9-27 | The three new gates' `DEXPACE_GATE_ROOT` route was **broken on arrival and is fixed**, with a fixture workspace and four tests | `R4`; the repository's every-gate-has-a-failing-fixture rule | The tasks handed the scanner paths made relative to the gate root, which `AstScan.parse` then opened relative to the process's CWD — the same directory only when the variable is unset. Under a fixture root every open raised `Errno::ENOENT`, so the three gates could never have been shown to REJECT anything. They now scan absolute paths under the root, join both allowlists to the same root, and make the message relative afterwards. A defect in this phase's own code, so `R6`'s report-don't-repair rule does not apply to it | +| P9-28 | `XCUT-3`'s assertion asserts that the waiter was **parked** when the cancel was issued, as a `Check` of its own | `XCUT-3` | Without the barrier the cancel could be issued before the waiter entered `Clock#sleep`, whose own entry check then raises — and a `Clock#sleep` that ignores the token entirely PASSED. Measured by mutation. The barrier is a bounded `Thread.pass` spin on `Thread#status`, never a sleep, and its result is checked rather than assumed, so a run that could not have discriminated reports `:failed` | +| P9-29 | `XCUT-12`'s assertion asserts that **every** racer was parked before the coalesced fetch is released | `XCUT-12` | Without the barrier the first thread finished alone and the other fifteen took the cached-token path without ever racing, so a stamper fetching per caller PASSED. Measured by mutation. Under a conforming stamper one racer parks in `#fetch` and fifteen park on the credential's lock; under a non-conforming one all sixteen park in `#fetch` — both are `"sleep"`, so the barrier is the same for either and decides nothing by itself | +| P9-30 | `tools/serde_boundary.rb`'s `pending` gains `list:`, **and** the task's abort branch gets a `RUBYOPT` prelude fixture | `SSE-37`; `R4` addendum A7 | The `list:` keyword lets a test drive a non-empty list through the FUNCTION. It does not reach the TASK: `PENDING` is a frozen constant and no gate root can make the task see a non-empty one, so a mutation deleting the abort left every test green. The fixture prepends one row onto `.pending` through `RUBYOPT`, which is this repository's deliberately-failing-fixture rule applied to the one gate clause with no other route in | +| P9-31 | `tools/requirement_levels.rb --check` takes an **optional path**, so both exits are proven | `R3`; `NFR-17` | With `TARGET` fixed the gate's own test could only ever assert exit 0, and a `--check` that always exited 0 passed it — measured by mutation. `--check ` compares the same render against a file the test writes to a temp dir, so the failing half needs no fixture tree and nothing under `gems/` is touched | +| P9-32 | The mutation set run here is **this phase's own**, re-derived from the design's testing strategy and the plan's per-task "non-conforming double" rule | the brief's minimum set | The reviewer's list was in this run's opening brief and did not survive the session's context compaction. Forty-five mutations were re-derived and all forty-five go red; the count exceeds the thirty-five the brief named as a minimum, but the SET is not the reviewer's, and a reviewer re-running the original should expect to find it covered rather than assume it | +| P9-33 | The plan's existence-probe list names `:Redactor` at the top level; the filed name is `Instrumentation::Redactor` | plan Task 16 Step 1; design `R8` row 13 | `Dexpace::Redactor` does not exist and never did — design `R8`'s own row 13 says "the filed name, not `Dexpace::Redactor`". The probe list was corrected at execution; every probe answers true, so no `R3` divergence was filed | + --- ## Work phase 9 postponed, and who owns it now diff --git a/gems/dexpace-conformance/README.md b/gems/dexpace-conformance/README.md index f1c324d..e5efd31 100644 --- a/gems/dexpace-conformance/README.md +++ b/gems/dexpace-conformance/README.md @@ -3,15 +3,22 @@ Part of the [dexpace Ruby SDK](../../README.md): an HTTP-client toolkit, not an HTTP client. This gem is the conformance suite every adapter -- shipped or third-party -- is proven against. -**Status: `0.0.0`, unpublished; the transport suite is built.** `lib/` holds phase 8a's +**Status: `0.0.0`, unpublished; five suites are built.** `lib/` holds phase 8a's `Dexpace::Conformance`: the assertion protocol phase 0 postponed -- `Failure`, `Vacuous`, `Assertion`, `Result` with its five `STATUSES`, and `Report` -- the twenty-eight-assertion `TransportSuite` over twenty-two `TRANSPORT` IDs, `HTTP-17`, `HTTP-18`, `XCUT-18` and `PAGE-36`, the `TransportCase` an assertion receives and the `BorrowedPair` a borrowing construction supplies, the plaintext `WireServer` fixture with its `RecordedRequest`s and fifteen named `Scripts`, the two thin drivers `MinitestDriver` and `RSpecDriver`, and the two observability -doubles `RecordingSpan` and `Allocations`. Phase 9 adds the remaining suites beside the -transport one. +doubles `RecordingSpan` and `Allocations` -- and phase 9's four further suites beside it: +`InvariantSuite` (28 assertions over all twenty-four `XCUT` IDs), `PackagingSuite` (8 over +`NFR-1`, `2`, `3`, `10`, `11`, `13`, `14` and `15`, read from published gem metadata), +`CodecSuite` (2 portable seam properties any wire codec must have) and `ExecutorSuite` (7 over an +async-runtime adapter's lifecycle), over the shared `Runner`, `Check` and `SharedInstance`, with +`Levels` -- every requirement ID's normative level, generated from the specification's own index -- +making a MUST-level vacuity a report blocker, and `Aggregate` giving a whole run one verdict and one +preamble stating what it does not prove. The 61-row appendix-B coverage map is `APPENDIX_B.md`, +shipped beside this file. ## Install @@ -52,7 +59,8 @@ consumer. `socket` and `tempfile` are stdlib that stays stdlib on every supporte ## Where to read next - `docs/sdk-documentation/conformance.md` -- the as-built page: what a green run proves and does - not, the runner and its report, the drivers, the case, the fixture and the doubles. + not, the runner and its report, the drivers, the case, the fixture and the doubles, and the four + suites phase 9 added with a runnable example of each mechanism. - `docs/sdk-documentation/transport-net_http.md` -- the reference adapter this suite is run against first. - `docs/sdk-documentation/architecture.md` -- how the gems compose and which one to install. From 2c765301a13bba22f48ce9cd32279d4056449737 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 24 Sep 2026 09:53:26 +0300 Subject: [PATCH 11/17] docs: correct the phase-9 record against review round 1's measurements Review round 0, R0-6 through R0-12, plus the two code fixes' half that lives in the documents. R0-8. The status note gave 91.09 % as the code tip's 4.0.6 coverage and quoted the same figure correctly for 3.2.11 one clause earlier. It was the 3.2.11 number twice. Every proof figure in that paragraph is now re-measured at this round's tips rather than edited: the code tip is 4,020 runs and 94.67 % (11,688 of 12,346) on 4.0.6 and 90.97 % (11,105 of 12,207) on 3.2.11, the tests tip 4,189 runs and 99.82 %, and the sentence says outright which row each percentage belongs to, because that is the mistake it exists to stop repeating. The aggregate run was reproduced at these tips and is unchanged at 43 / 1 / 0 / 1, so the audit's verdict stands on this round's code and not only on last round's. R0-6. The ledger said forty-five mutations went red where the checklist said forty-four with one killed uncounted; the checklist was right. The count is now forty-four at implementation plus seven in this round, and the killed shape is named in the row rather than folded into a total. R0-7. CLAUDE.md's exclusion list stopped at phase 8c, so it asserted that phase 9's checklist -- added by this very stack -- was still to be written. R0-11: the gem README still called TransportSuite twenty-eight assertions; it has been thirty-four over twenty-eight TRANSPORT ids since 8c, measured from the loaded suite. R0-9. The audit-group row claimed no sleep in any new test. There is one, deliberate, in a non-conforming double whose whole job is to block past XCUT-13's half-second bound; the row now names it with its reason instead of denying it. R0-10: a paragraph sitting between rows 26 and 27 ended the mutation table as GFM reads it, leaving nineteen rows to render as run-on pipes. It moves below. R0-12. XCUT-19's row claimed clauses (a)-(d) and appendix C has five; XCUT-13's named two of three. Both scope-outs were already argued in the code and unstated in the row a reader checks. Each row now cites where the missing clause is proven -- 5b's HTTPLogging::DEFAULT for body logging, and 8.3's ban on arranging an interrupt at all. Rows 46-52 record this round's seven mutations with the message each produced. Rows 47 and 48 name what was mutated -- the Check inside the bounded join, not the call to it, which does not compile under -w -- and drop a failure count that did not reproduce: one, not thirteen. --- CLAUDE.md | 3 +- docs/sdk-documentation/conformance.md | 9 ++- .../2026-09-05-ruby-sdk-v1-roadmap-design.md | 61 +++++++++------ ...ng-invariants-and-conformance-checklist.md | 75 +++++++++++++++---- ...tting-invariants-and-conformance-design.md | 7 +- gems/dexpace-conformance/README.md | 5 +- 6 files changed, 118 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 34d7e31..af69b34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1457,5 +1457,6 @@ probe compares each against the live tree, and a count written anywhere else in at `0.0.0`. Every checklist but phase 0's, phase 1's, phase 2's, phase 3a's, phase 3b's, phase 4a's, phase 4b's, phase 4c's, phase 5a's, phase 5b's, phase 5c's, phase 6a's, phase 6b's, phase 6c's, phase 7b's, - phase 7c's, phase 7a's, phase 8a's, phase 8b's and phase 8c's is still to be written at execution time. + phase 7c's, phase 7a's, phase 8a's, phase 8b's, phase 8c's and phase 9's is still to be written at + execution time — which leaves phase 10's alone. - There are 40 harvested topics under `docs/knowledge/harvested/`; the harvest ran here on 2026-09-05. diff --git a/docs/sdk-documentation/conformance.md b/docs/sdk-documentation/conformance.md index a7cb6ef..0ef98ae 100644 --- a/docs/sdk-documentation/conformance.md +++ b/docs/sdk-documentation/conformance.md @@ -385,7 +385,14 @@ report = C::CodecSuite.run(build: -> { Dexpace::Serde::JSON.default }, witness: ``` A factory left `nil` makes its assertions report `:vacuous` **with a reason**, never `:passed` — which is -what stops "not supplied" from reading as "conforming". +what stops "not supplied" from reading as "conforming". `PackagingSuite`'s `NFR-14` is the same rule +applied to a declaration rather than a factory: with no `versions:` naming the single source of truth it +is `:vacuous`, because "nobody told us" is not evidence that one exists. + +**Every wait a suite performs carries a bound**, so a subject that never answers fails or vacuates its +assertion rather than parking the run — an executor that refuses the post, one whose `#post` blocks, a +shared instance that never returns from `#call`, a connection an adapter never releases. That is the rule +8a fixed for `TransportSuite` with `await_closed_connection`'s `timeout:`, and it holds across all five. ### A MUST-level vacuity is a report blocker diff --git a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md index c0dd44b..4ca37b5 100644 --- a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md @@ -5148,9 +5148,10 @@ two stated residues, 7a's `SEAM-21` evidence living inside another assertion's b `tools/surface.rb`'s blindness to a `private_constant` module, and `PackagingCase`'s default constant-path rule. -**Guards.** Forty-four mutations were run one at a time through a harness that applies the edit, runs +**Guards.** Fifty-one mutations were run one at a time through a harness that applies the edit, runs the owning suite, captures the first failure and restores the file — **every one red**, each recorded -in the checklist with the message it produced. Six shapes were re-cut: one spun forever and was killed +in the checklist with the message it produced: forty-four at implementation and seven more in review +round 1. Six shapes were re-cut: one spun forever and was killed rather than counted, which is itself the `Registry#resolve` finding below, and five went green — and **two of those five were not bad mutations but real non-discrimination in this phase's own assertions**: `XCUT-3`'s cancel could beat the waiter into the wait, and `XCUT-12`'s sixteen racers @@ -5163,24 +5164,42 @@ both are fixed because both are phase 9's own code: the three new gates' `DEXPAC opened every file relative to the process's CWD and could never have been shown to reject anything, and `gates:serde_boundary`'s abort branch and `--check`'s failing exit had no test at all. -**Proofs.** The **code tip** is green on **every one of the twenty-one gates run individually** on -4.0.6 — the coverage floor included: the new `lib/` arrives there with no tests and the aggregate -figure is still 91.09 % — and on the five matrix gates on 3.2.11 (3,832 runs, 73,349 assertions, 3 -skips, 11,100 of 12,185 lines). The **tests tip** is green on the whole default task on 4.0.6 -(**4,184 runs, 74,555 assertions, 0 failures, 0 errors, 9 skips** and **99.80 % line coverage, -12,300 of 12,324**; `cops:test` 129 runs and 412 assertions; `test:gates` 192 runs and 876 assertions -with no skip; the honest RuboCop run over 776 files clean) and on the matrix set on 3.4.10 (4,184 / -74,555 / 9 skips), 3.3.12 (the same, 12,154 of 12,178 lines) and 3.2.11 (3,996 / 73,779 / **5** skips, -96.07 % — the four `async_http` skips are absent because that gem's 3.3 floor excludes it from the 3.2 -row). **Every skip is named**: the seven phase-8 drivers' — `net_http`'s three and `async_http`'s four -— plus this phase's two, `XCUT-18`'s call-site assertion in core's driver (vacuous there because core -ships no adapter, and real in the aggregate run) and `ASYNC-3` in the pool's (waived by id, and proven -to fail by a test of its own). The **docs tip** is green on the default task (the same figures), -the honest RuboCop run over 776 files, `ruby .claude/skills/housekeeping/probe.rb` (exit 0, no drift) -and `ruby scripts/verify_knowledge_structure.rb` (2,166 harvested entries, 71 notes, every cited key -live), with every `ruby` fence of `conformance.md`'s new section run verbatim on 4.0.6 and 3.2.11 — -the same printed values on both. Beside the gates: the aggregate run over all four suites with the -real subjects, and `PackagingSuite` a second time against the six gems built and installed outside the -bundle. +**Proofs**, re-measured at review round 1's tips on 2026-09-24. The **code tip** is green on +**every one of the twenty-one gates run individually** on 4.0.6, the coverage floor included — +**4,020 runs, 74,125 assertions, 0 failures, 0 errors, 7 skips** and **94.67 % line coverage, +11,688 of 12,346** — the one red the layering rule tolerates was not needed, because the floor is met +there even though the suites that raise the figure are the branch above's — `test:gates` 145 runs and +703 assertions with no skip, and the honest RuboCop run over 758 files clean; and on the five matrix gates on 3.2.11 (3,832 runs, 73,349 assertions, 3 skips, 11,105 of +12,207 lines, **90.97 %**), where the three new gates report the same 307, 307 and 222 files they do +on 4.0.6. The **tests tip** is green on the whole default task on 4.0.6 (**4,189 runs, 74,576 +assertions, 0 failures, 0 errors, 9 skips** and **99.82 % line coverage, 12,324 of 12,346**; +`cops:test` 129 runs and 412 assertions; `test:gates` 193 runs and 877 assertions with no skip; the +honest RuboCop run over 776 files clean) and on the matrix set on 3.4.10 (4,189 / 74,576 / 9 skips, +the same 99.82 %), 3.3.12 (the same runs, 12,178 of 12,200 lines) and 3.2.11 (4,001 / 73,800 / **5** +skips, 96.10 % — the four `async_http` skips are absent because that gem's 3.3 floor excludes it from +the 3.2 row). The two coverage figures are not interchangeable and were once transposed here: 90.97 % +is the **3.2.11** row and 94.67 % the 4.0.6 one. **Every skip is named**: the seven phase-8 drivers' — +`net_http`'s three and `async_http`'s four — plus this phase's two, `XCUT-18`'s call-site assertion in +core's driver (`vacuous: no transport factory supplied to InvariantSuite.run`, and real in the +aggregate run) and `ASYNC-3` in the pool's (`waived: ASYNC-3`, and proven to fail by a test of its +own). The **docs tip** is green on the default task (the same figures), the honest RuboCop run, +`ruby .claude/skills/housekeeping/probe.rb` (exit 0, no drift) and +`ruby scripts/verify_knowledge_structure.rb`, with every `ruby` fence of `conformance.md`'s new +section run verbatim on 4.0.6 and 3.2.11 — the same printed values on both. Beside the gates: the +aggregate run over all four suites with the real subjects, reproduced at these tips and unchanged at +43 / 1 / 0 / 1, and `PackagingSuite` a second time against the six gems built and installed outside +the bundle. + +**Review round 1, 2026-09-24.** Two defects in this phase's own gem were repaired — `R6` bars phase 9 +from repairing what it AUDITS, and `dexpace-conformance` is what it wrote. Three waits in the new +suites carried no bound, and one of them HUNG rather than failing: `ExecutorSuite`'s `ASYNC-3` +assertion parked for ever against an executor that refuses the post, which is `ASYNC-2`'s saturated +queue and this gem exists to be run by third parties against theirs. All three now carry 8a's own +`await_closed_connection` rule (`P9-34`). And `PackagingCase::DEFAULT_RESOLVE` rescued +`::StandardError` alone, so the `Gem::MissingSpecError` it exists to catch — a `ScriptError` +descendant — escaped it and `Runner`'s bare rescue both, aborting a whole run where the contract is +one `:vacuous` naming the absent unit (`P9-35`). Both were found by writing the tests round 0 asked +for. Four documents were corrected against measurement rather than restated, the coverage +transposition above among them. `main` is `582e33a` before and after this pass; nothing is pushed, and issue #33 stays open. diff --git a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md index c54c21d..2167643 100644 --- a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md +++ b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md @@ -84,13 +84,13 @@ proves the assertion can fail at all; and **(c)** the same assertion driven agai | `XCUT-10` | MUST | ✅ | 6, 16 | "retry safety is decided from the request alone, uniformly": all four cases `XCUT-10` enumerates, plus the structural half — `Resend.eligible?`'s parameter list is exactly `[[:req, :request]]`, so there is no failure parameter to special-case on | | `XCUT-11` | MUST | ✅ | 4, 8, 11, 16 | Two assertions plus a third in `ExecutorSuite`. Clause 1 is `SharedInstance.audit` over every shared instance the DRIVER declares (design `R8`, `P9-9`) and sixteen threads through one shared step with distinct requests; clause 2 is TWO FIBERS ON ONE THREAD, the only shape a lock held across a suspension point is visible in — Ruby's `Mutex` is per-fiber and non-reentrant — with the resulting `ThreadError` converted to a `Failure` so the status is `:failed` and not `:error`. The driver declares fifteen first-party shared instances and their ivars (`core/cross_cutting_invariants_test.rb`, "every shared instance core publishes"). Mutations 34, 35 and 44 | | `XCUT-12` | SHOULD | ✅ | 8, 16 | "a credential cache's refresh is single-flight": sixteen threads race one `BearerStamper` over a parked provider and exactly one fetch happens. Every racer must be parked before the gate opens, asserted rather than assumed — without the barrier the first thread finished alone and a per-caller stamper passed (`P9-29`). Mutation 23. The FIBER-scheduler form is out: it needs a credential path under a reactor, which no first-party suite assembles — stated in the assertion and on phase 10's inbound list | -| `XCUT-13` | MUST | ✅ | 5, 11, 16 | Two assertions plus `ExecutorSuite`'s: the latch is counted AT THE RESOURCE (`Closeable#close` answers nil on the winning and the losing call alike, so a return value proves nothing), and the close is bounded by elapsed monotonic time on BOTH calls — never by an interrupt, which §8.3 bans outright. Mutations 2 and 36 | +| `XCUT-13` | MUST | ✅ | 5, 11, 16 | Two assertions plus `ExecutorSuite`'s: the latch is counted AT THE RESOURCE (`Closeable#close` answers nil on the winning and the losing call alike, so a return value proves nothing), and the close is bounded by elapsed monotonic time on BOTH calls — never by an interrupt, which §8.3 bans outright. Appendix C's third clause, "preserves the ambient interrupt/cancel flag as-is", is **scoped out with its reason** in `executor_suite/lifecycle.rb`'s own comment and asserted nowhere: §8.3 bans every primitive that could arrange a pending interrupt, so the clause holds by the flag never being touched and there is nothing observable to assert. Mutations 2 and 36 | | `XCUT-14` | MUST | ✅ | 7, 13, 16 | Two assertions plus `gates:bounded_map`. The cap clause reads the size after EVERY insert across 320 of them, so an implementation that overshoots and trims is caught; the DRAIN clause fills the backing store to cap + 5 — the state a concurrent overshoot leaves, arranged without a thread — and performs one `#set`: a drain loop ends at the cap, a check-then-evict at cap + 5, on every interpreter. Mutations 6 and 7 | | `XCUT-15` | MUST | ✅ | 5, 16 | "public wire models retain no external-mutable alias": the mutation is on the INNER array of the `values:` Hash, because a port that dup'd only the outer Hash passes a top-level check. Mutation 1 | | `XCUT-16` | MUST | ✅ | 7, 16 | All three clauses: the plaintext refusal is `Auth::HTTPSRequiredError` BY CLASS, an HTTPS request really is stamped (so the refusal is about the scheme and not a broken path), and a marker-suppressed cross-origin re-issue proceeds over `http` carrying no credential. Mutations 19 and 20 | | `XCUT-17` | MUST | ✅ | 7, 16 | All four clauses in one assertion, because a port can satisfy any three: `Authorization` stripped on a SAME-origin re-issue, all three origin-scoped headers on a cross-origin one, the `Location`'s userinfo gone, and an HTTPS→HTTP downgrade refused. Mutations 15–18 | | `XCUT-18` | MUST | ✅ | 7, 16 | Two assertions. The model layer asserts the ASYMMETRY — HTAB refused in a name and admitted in a value — which a port applying one rule to both passes with a single case. The call-site assertion drives a forged request (built through `send(:new)`, or a duck if that fails) at a real adapter and OBSERVES wire activity through a listener it owns: one accepted connection is the failure. Mutations 4 and 5. **Vacuous in the core driver, real in the aggregate run**: core ships no adapter, so `transport:` is nil there and the vacuity is accepted with its citation; Task 16's run supplies `Dexpace::Transport::NetHTTP.build` and the assertion passes for real | -| `XCUT-19` | MUST | ✅ | 7, 16 | Clauses (a)–(d): userinfo, a query value and a fragment token all gone from one URL while host and path survive; the header allow-list is default-DENY; and a credential reveals nothing in `#to_s`, `#inspect` or — checked STRUCTURALLY, because `pp` is outside this gem's require allowlist — `#pretty_print`, whose owner must be the credential's own class (`pp.rb` never consults an `#inspect` override on a `Data`). Mutations 9 and 10 | +| `XCUT-19` | MUST | ✅ | 7, 16 | Clauses (a)–(d): userinfo, a query value and a fragment token all gone from one URL while host and path survive; the header allow-list is default-DENY; and a credential reveals nothing in `#to_s`, `#inspect` or — checked STRUCTURALLY, because `pp` is outside this gem's require allowlist — `#pretty_print`, whose owner must be the credential's own class (`pp.rb` never consults an `#inspect` override on a `Data`). Appendix C's clause (e), "full request/response BODY logging MUST be OFF by default", is asserted nowhere in `InvariantSuite` and is **5b's own**, proven there: `HTTPLogging::DEFAULT` is `NONE`, and `Instrumentation::Step` constructs the two phase-3b logging wrappers at `HTTPLogging::BODY` alone — 5b's checklist carries it as clause (e) of its own `XCUT-19` cross-reference row, over the `OBS-34` row's subject (`docs/work/mvp/phase5/phase5b/2026-09-09-phase5b-logging-and-redaction-checklist.md`). `invariant_suite/security.rb` says so in place. Mutations 9 and 10 | | `XCUT-20` | MUST | ✅ | 7, 16 | Scoped exactly as 5c handed it forward and no wider: `Instrumentation.contain` swallows and reports, `Redactor#url` substitutes its marker for a malformed input, `#header_value` always answers a String, and `Preview.render` survives undecodable bytes. NOT extended to a foreign tracer or meter callback, which `OBS-20` carves out — a `rescue` there is a guard this suite must not demand. Mutation 11 | | `XCUT-21` | MUST | ✅ | 7, 16 | Two observations and no character count: the handler takes an injectable `cnonce_source:` (so its randomness can be audited at all), and bytes are DRAWN from the injected recorder and CARRIED into the rendering — each drawn byte flipped in turn, and at least sixteen must change the output. A character count would call a truncated 128-bit draw conforming. Mutation 21 | | `XCUT-22` | MUST | ✅ | 5, 11, 16 | Both halves: the caller-supplied resource is not closed AND it still works, because a component that tore it down another way would pass the first alone. `ExecutorSuite`'s twin asserts the same over a real pool behind `Transport.async_over`. Mutations 3 and 40 | @@ -168,11 +168,13 @@ first-party drivers. The reviewer's mutation list could not be recovered verbatim after this session's context was compacted (`P9-32`), so the set below was re-derived from the design's testing strategy and the plan's own "non-conforming double first" rule, one mutation per assertion, per gate and per piece of shared -machinery. **Forty-four mutations, every one red**, run one at a time through a harness that applies the -edit, runs the owning suite, captures the first failure and restores the file, on **4.0.6**. The table -below numbers forty-five slots because two are placeholders for a mutation that was re-cut rather than a -mutation of their own. Six shapes were re-cut: one spun forever and was killed rather than counted (a -finding in its own right, routed below), and five went green; **two of those five were not bad mutations but real +machinery. **Fifty-one mutations, every one red** — forty-four at implementation and seven more in +**review round 1** (rows 46–52) — run one at a time through a harness that applies the edit, runs the +owning suite, captures the first failure and restores the file, on **4.0.6** (row 52 on **3.2.11**, the +row whose parser the fact differs on). The table below numbers fifty-two slots because two are +placeholders for a mutation that was re-cut rather than a mutation of their own. Six shapes were re-cut: +one spun forever and was killed rather than counted (a finding in its own right, routed below), and five +went green; **two of those five were not bad mutations but real non-discrimination in this phase's own assertions**, and both were repaired before the mutation was re-run — `XCUT-3`'s cancel could beat the waiter into the wait, and `XCUT-12`'s sixteen racers could finish one at a time. Those two repairs are `P9-28` and `P9-29`, and they are the reason the mutation @@ -207,11 +209,6 @@ pass is worth its cost. | 24 | `Dexpace.each_cause` drops its visited test | `XCUT-9: the cause walk did not terminate after visiting the cycle once` | `XCUT-9` | | 25 | `Registry#sole_key` picks the first of several candidates | `XCUT-23: two registered candidates with no explicit install did not fail loudly` | `XCUT-23` | | 26 | *(the zero-candidate branch is the same method; covered by 25 and 21b)* | | | - -A twenty-first shape was cut and then **killed rather than counted**: conditioning `Registry#resolve`'s -`return hand_out(resolved)` guard on anything else turns its `loop do … end` into an unbounded spin with -no progress, and it ran for six minutes before it was stopped. That is not a guard going red; it is the -finding routed below, and the two mutations that replaced it — 21b and 25 — are what prove `XCUT-23`. | 27 | `Runner#one` rescues `StandardError` before `Vacuous` | `Expected: :vacuous / Actual: :error` | `NFR-17` | | 28 | `Runner#waived?` matches the assertion NAME | `the preamble, the acceptances and the would-fail declarations reach the Report` | `NFR-17` | | 29 | `Report#passed?` drops the blocking-vacuity term | `an acceptance naming one of two MUST ids still blocks, because accepted? is all?` | `NFR-17` | @@ -231,6 +228,27 @@ finding routed below, and the two mutations that replaced it — 21b and 25 — | 43 | one row deleted from `APPENDIX_B.md` | `every row's ID column equals the set parsed from that item's own text` | `NFR-17` | | 44 | `Sharing` skips the driver's declared shared instances | `a shared instance the driver declares is audited too` | `XCUT-11` | | 45 | `AstScan.parse` leaves `$VERBOSE` armed | `AstScan.parse's $VERBOSE window did not hold` | `XCUT-9`, `XCUT-14`, `SEAM-2` | +| 46 | `Shutdown#check_release`'s expired entry pop raises nothing | `[Vacuous] exception expected, not Failure: a worker blocked in an uninterruptible call was not released after cancel` | `ASYNC-3` | +| 47 | `check_posters_returned` keeps its bounded join and drops its `Check` | `Expected /did not return/ to match "work posted from many threads was lost or duplicated"` | `SEAM-12`, `XCUT-11` | +| 48 | `check_all_returned` keeps its bounded join and drops its `Check` | `Expected /did not return on every thread/ to match "a shared step crossed one call's request into another's"` | `XCUT-11` | +| 49 | `Dependencies#single_version_source` stops raising its vacuity | `with no single source named, NFR-14 is vacuous rather than a pass / Expected: :vacuous, Actual: :passed` | `NFR-14` | +| 50 | `PackagingCase::DEFAULT_RESOLVE` drops `::Gem::LoadError` | `Gem::MissingSpecError: Could not find 'dexpace-no-such-gem-…'` — 1 error, not 8 vacuities | `NFR-1`, `NFR-2`, `NFR-10`, `NFR-14`, `NFR-15` | +| 51 | `RequirementLevels::ROW` loses its nineteen-prefix restriction | `{"HTTP-7"…, "RFC-7235" => :must, "ISO-8601" => :should, …}` against a synthetic table | `NFR-17` | +| 52 | the duplicated-key capture is taken INSIDE the `$VERBOSE` window | `a duplicated key warns whatever $VERBOSE is` (3.2.11) | `XCUT-9`, `XCUT-14`, `SEAM-2` | + +One more shape was cut and then **killed rather than counted**: conditioning `Registry#resolve`'s +`return hand_out(resolved)` guard on anything else turns its `loop do … end` into an unbounded spin with +no progress, and it ran for six minutes before it was stopped. That is not a guard going red; it is the +finding routed below, and the two mutations that replaced it — 21b and 25 — are what prove `XCUT-23`. + +A second shape is recorded the same way, and for the same reason. Restoring `Shutdown#check_release`'s +entry pop to the unbounded `transport.entered.pop` review round 0 found there — row 46's mutation taken +one step further — leaves the refusing-executor test with nothing to return to: the run was still +parked when `timeout 90` killed it, and the same removal at the two join sites is caught instead, by +Ruby's own deadlock detector, as `fatal: No live threads left. Deadlock?`. That asymmetry is why rows +47 and 48 drop the `Check` and keep the bounded join: a mutation the interpreter aborts proves the join +runs, not that an expired one is reported. The hang itself is the defect `P9-34` fixes, demonstrated; it +is not a guard going red and it is not counted among the fifty-one. Beside the mutations, the three new gates are each driven against a deliberately failing **fixture workspace** through `DEXPACE_GATE_ROOT` (`test/fixtures/gates/invariants/workspace/`), and @@ -259,7 +277,7 @@ none roll-up only, so nothing here had to be read out of the specification inste | Public API surface | Every new group module is a `private_constant`, so `tools/surface.rb`'s `Module#constants(false)` walk does not see it — 8a's `TransportSuite` precedent. Each suite's public surface is exactly `assertions`, `run` and `PREAMBLE`; two leaks found by reading the regenerated manifest row by row were closed rather than accepted (`P9-25`) | | Gem layout, zero-dependency core | `dexpace-conformance` still declares `dexpace-core` alone; the new files add no require outside the allowlist; `gates:clean_bundle` green on every row | | RBS / Steep typing | Every new `lib/` file has a `sig/` mirror, the `private_constant`s carrying `hooks.rbs`'s comment; both targets green with no relaxation added. One signature is deliberately INCOMPLETE and says so: `invariant_suite/credentials.rbs` omits `include ::Random::Formatter`, because `tools/rbs_surface.rb`'s `STDLIB_ALLOWED` does not carry `Random` and widening a gate's allowlist for a test double is lowering the gate (`P9-26`) | -| Minitest conventions | Every suite subclasses `DexpaceTestCase` or `GateCase`; no `.stub`; mutable fixtures built fresh per test; no `sleep` in any new test — every wait is a queue pop with a timeout, a bounded join or a bounded `Thread.pass` spin on `Thread#status` | +| Minitest conventions | Every suite subclasses `DexpaceTestCase` or `GateCase`; no `.stub`; mutable fixtures built fresh per test. No `sleep` anywhere in the suites' own logic or in any assertion — every wait there is a queue pop with a timeout, a bounded join or a bounded `Thread.pass` spin on `Thread#status`. **One deliberate `Kernel.sleep` exists, in a non-conforming DOUBLE**: `invariant_suite_test.rb`'s `BlockingCloseSeam#close` sleeps `0.6` on its first close, past `XCUT-13`'s half-second bound, because a double whose whole job is to block past a bound has no other way to do it — and without it that clause would pass against every subject the suite can build | | Fiber scheduler, thread safety | The two-fibers-on-one-thread assertion is the shape 8b handed forward; the barriers added in `P9-28` and `P9-29` use `Thread#status` and never a sleep | | Observability, configuration and redaction | `XCUT-19`'s and `XCUT-20`'s assertions are scoped to what 5b and 5c own and are not widened to a foreign callback | @@ -311,14 +329,41 @@ corresponding ledger rows are `P9-21`–`P9-33` in the design's As-built addendu always exit 0, both left every test green. A `RUBYOPT` prelude fixture drives the first; an optional path argument on `--check` drives the second. `P9-30`, `P9-31`. 8. **The reviewer's mutation list is not quoted here.** It was in this run's opening brief and did not - survive the session's context compaction. The forty-four mutations above were re-derived from the - design's testing strategy and the plan's per-task "non-conforming double" rule; the count exceeds the + survive the session's context compaction. The forty-four mutations run at implementation were + re-derived from the design's testing strategy and the plan's per-task "non-conforming double" rule; + the count exceeds the thirty-five the brief named as a minimum, but the SET is this phase's and not the reviewer's, and a reviewer re-running the original list should expect to find it covered rather than assume it. `P9-32`. 9. **`SEAM-25`'s harness half is recorded as a correction to a committed record.** 8b's design said the harness half was 8a's; 8a wrote no executor suite. Phase 9 writes it, and says so out loud rather than restating the earlier record as though it had been right. `P9-21`. +### Review round 1, 2026-09-24 + +Two further departures, both repairs to **this phase's own gem** — `R6` bars phase 9 from repairing what +it AUDITS, and `dexpace-conformance`'s new code is what it wrote, the same standing `P9-27` has. + +10. **Three waits in the new suites carried no bound, and one of them hung.** `Shutdown#check_release` + opened with an unbounded `transport.entered.pop`; against an executor that REFUSES the post — + `ASYNC-2`'s saturated queue, or a closed pool — nothing is ever pushed, the poster rescues the + refusal, and the run never returns, with the `ensure` that frees the gate and closes the pool never + reached. Demonstrated, not argued: with the bound removed the refusing-executor test was still + parked when `timeout 90` killed it, and the reviewer's own probe — the same assertion on a thread + joined at eight seconds — reported `:HUNG` where it now answers + `[:vacuous, "the executor did not start the posted unit within 1.0s …"]`. The two `.each(&:join)` + sites — `concurrent_post` and + `drive_threads` — had the same shape against a subject whose submission or whose shared call never + returns. All three now carry a bound, 8a's own rule for this gem + (`await_closed_connection`'s `timeout:`), and the expired entry wait is `:vacuous` with its reason + rather than `:failed`, because with no unit started there is no blocking worker to cancel. + Rows 46–48. `P9-34`. +11. **`PackagingCase::DEFAULT_RESOLVE` never caught the error it exists to catch**, and writing the test + review round 0 asked for is what found it: every case in `packaging_suite_test.rb` supplied its own + `resolve:`, so the SHIPPED lambda had never run. `Gem::MissingSpecError` descends from + `Gem::LoadError < LoadError < ScriptError` and is **not** a `StandardError`, so `rescue + ::StandardError` let it past — and past `Runner`'s bare rescue too, aborting the whole run where the + contract is one `:vacuous` naming the absent unit. Row 50. `P9-35`. + --- ## Findings routed diff --git a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md index 14bab05..ea70c07 100644 --- a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md +++ b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md @@ -1083,7 +1083,8 @@ This phase was cut from `main` at `582e33a`, which holds every phase through 8. below, numbered from **P9-21** as the brief fixes it; `P9-11`–`P9-20` are left free and nothing is renumbered. The checklist's "Deviations from the plan" is the itemised list against the plan's text; the rows here are the ones that touch public behaviour, the contract a later phase cites, or a statement this -document makes. +document makes. `P9-34` and `P9-35` were added in **review round 1**, on 2026-09-24, and are marked as +such in their own rows; nothing earlier is rewritten except `P9-32`'s count, which was wrong as written. | # | Deviation | Requirement / document | Why | |---|---|---|---| @@ -1098,8 +1099,10 @@ document makes. | P9-29 | `XCUT-12`'s assertion asserts that **every** racer was parked before the coalesced fetch is released | `XCUT-12` | Without the barrier the first thread finished alone and the other fifteen took the cached-token path without ever racing, so a stamper fetching per caller PASSED. Measured by mutation. Under a conforming stamper one racer parks in `#fetch` and fifteen park on the credential's lock; under a non-conforming one all sixteen park in `#fetch` — both are `"sleep"`, so the barrier is the same for either and decides nothing by itself | | P9-30 | `tools/serde_boundary.rb`'s `pending` gains `list:`, **and** the task's abort branch gets a `RUBYOPT` prelude fixture | `SSE-37`; `R4` addendum A7 | The `list:` keyword lets a test drive a non-empty list through the FUNCTION. It does not reach the TASK: `PENDING` is a frozen constant and no gate root can make the task see a non-empty one, so a mutation deleting the abort left every test green. The fixture prepends one row onto `.pending` through `RUBYOPT`, which is this repository's deliberately-failing-fixture rule applied to the one gate clause with no other route in | | P9-31 | `tools/requirement_levels.rb --check` takes an **optional path**, so both exits are proven | `R3`; `NFR-17` | With `TARGET` fixed the gate's own test could only ever assert exit 0, and a `--check` that always exited 0 passed it — measured by mutation. `--check ` compares the same render against a file the test writes to a temp dir, so the failing half needs no fixture tree and nothing under `gems/` is touched | -| P9-32 | The mutation set run here is **this phase's own**, re-derived from the design's testing strategy and the plan's per-task "non-conforming double" rule | the brief's minimum set | The reviewer's list was in this run's opening brief and did not survive the session's context compaction. Forty-five mutations were re-derived and all forty-five go red; the count exceeds the thirty-five the brief named as a minimum, but the SET is not the reviewer's, and a reviewer re-running the original should expect to find it covered rather than assume it | +| P9-32 | The mutation set run here is **this phase's own**, re-derived from the design's testing strategy and the plan's per-task "non-conforming double" rule | the brief's minimum set | The reviewer's list was in this run's opening brief and did not survive the session's context compaction. **Forty-four** mutations were re-derived and all forty-four go red; a forty-fifth shape — conditioning `Registry#resolve`'s `return hand_out(resolved)` guard on anything else — turned its `loop do … end` into an unbounded spin, ran for six minutes and was **killed rather than counted**, which is the finding routed to phase 10's inbound list and not a guard. Review round 1 adds **seven** more, for fifty-one red in all (checklist, "Guards run red", rows 46–52). The count exceeds the thirty-five the brief named as a minimum, but the SET is not the reviewer's, and a reviewer re-running the original should expect to find it covered rather than assume it | | P9-33 | The plan's existence-probe list names `:Redactor` at the top level; the filed name is `Instrumentation::Redactor` | plan Task 16 Step 1; design `R8` row 13 | `Dexpace::Redactor` does not exist and never did — design `R8`'s own row 13 says "the filed name, not `Dexpace::Redactor`". The probe list was corrected at execution; every probe answers true, so no `R3` divergence was filed | +| P9-34 | **Every wait in the four new suites carries a bound**, and ASYNC-3's expired ENTRY wait is `:vacuous` rather than `:failed` | `ASYNC-3`, `SEAM-12`, `XCUT-11`; 8a's `await_closed_connection` | Review round 1. Three waits had none — `Shutdown#check_release`'s `transport.entered.pop` and the two `.each(&:join)` sites — and the first was demonstrated to HANG for ever against an executor that refuses the post (ASYNC-2's saturated queue, or a closed pool): nothing is ever pushed, the poster rescues the refusal, and the `ensure` that frees the gate and closes the pool is never reached. This gem exists to be run by third parties against THEIR executors, and 8a had already fixed the rule for it — `await_closed_connection` always carries a `timeout:`, so an adapter that never releases fails the assertion instead of hanging the run. The entry wait's expiry is `:vacuous` and not `:failed` because with no unit started there is no "blocking task on a worker thread", which is the antecedent `ASYNC-3`'s own sentence opens with; a MUST-level vacuity blocks the report regardless. The two join sites are Failures naming their clause, with the whole thread set sharing one budget so sixteen stuck threads cost one bound and not sixteen | +| P9-35 | `PackagingCase::DEFAULT_RESOLVE` rescues `::Gem::LoadError` **by name**, beside `::StandardError` | `NFR-1`, `NFR-2`, `NFR-10`, `NFR-14`, `NFR-15`; the suite's absent-antecedent contract | Review round 1, found by writing the test round 0 asked for: every case in `packaging_suite_test.rb` supplied its own `resolve:`, so the lambda a real driver gets had never run. RubyGems raises `Gem::MissingSpecError` for a name it cannot resolve and that descends from `Gem::LoadError < LoadError < ScriptError` — it is **not** a `StandardError`, so the shipped `rescue ::StandardError` let it past, and it escaped `Runner`'s own bare rescue too and aborted the whole run where the contract is one `:vacuous` result naming the absent unit. Measured on 4.0.6. A defect in this phase's own gem, so `R6`'s report-don't-repair rule does not reach it — the same standing `P9-27` has | --- diff --git a/gems/dexpace-conformance/README.md b/gems/dexpace-conformance/README.md index e5efd31..b55f04d 100644 --- a/gems/dexpace-conformance/README.md +++ b/gems/dexpace-conformance/README.md @@ -5,8 +5,9 @@ This gem is the conformance suite every adapter -- shipped or third-party -- is **Status: `0.0.0`, unpublished; five suites are built.** `lib/` holds phase 8a's `Dexpace::Conformance`: the assertion protocol phase 0 postponed -- `Failure`, `Vacuous`, -`Assertion`, `Result` with its five `STATUSES`, and `Report` -- the twenty-eight-assertion -`TransportSuite` over twenty-two `TRANSPORT` IDs, `HTTP-17`, `HTTP-18`, `XCUT-18` and `PAGE-36`, +`Assertion`, `Result` with its five `STATUSES`, and `Report` -- the **thirty-four**-assertion +`TransportSuite` over twenty-eight `TRANSPORT` IDs, `HTTP-17`, `HTTP-18`, `XCUT-18` and `PAGE-36` +(twenty-eight over twenty-two at 8a; phase 8c's two groups are the rest), the `TransportCase` an assertion receives and the `BorrowedPair` a borrowing construction supplies, the plaintext `WireServer` fixture with its `RecordedRequest`s and fifteen named `Scripts`, the two thin drivers `MinitestDriver` and `RSpecDriver`, and the two observability From 4d834c718e93307e489c4be6dae99a28e1949772 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 24 Sep 2026 11:45:58 +0300 Subject: [PATCH 12/17] docs: name the overturned predictions and record round 2's two guards Plan Task 15 Step 3 asks, in as many words, that a mark which disagrees with the design's prediction say so and name which prediction was wrong. The checklist recorded the observations and never the correction, so NFR-7, NFR-10 and NFR-17 read as transcriptions of a table that predicted the opposite. A paragraph above the NFR block and a clause in each of the three rows now name them, with NFR-4's narrower overturning and NFR-13's and NFR-16's predictions that held; P9-36 carries it in the As-built addendum, which had no row for it either. P9-37 gives the bounded-map allowlist re-adjudication the ledger row its six written reasons belonged in. Deviation 4 cited P9-27, which is the DEXPACE_GATE_ROOT repair and is what deviation 6 cites. P9-38 and P9-39 record the two guards review round 2 repaired, with their mutations as rows 53 and 54 and a departures subsection of their own; the mutation count moves from fifty-one to fifty-three here, in the design's P9-32 and in the roadmap's Guards paragraph, and says outright that the pair SURVIVED round 1 rather than being newly thought of. The As-built band is P9-21-P9-39 in both places that state it. --- .../2026-09-05-ruby-sdk-v1-roadmap-design.md | 21 ++++- ...ng-invariants-and-conformance-checklist.md | 86 +++++++++++++++---- ...tting-invariants-and-conformance-design.md | 12 ++- 3 files changed, 97 insertions(+), 22 deletions(-) diff --git a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md index 4ca37b5..52eba0c 100644 --- a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md @@ -5148,10 +5148,11 @@ two stated residues, 7a's `SEAM-21` evidence living inside another assertion's b `tools/surface.rb`'s blindness to a `private_constant` module, and `PackagingCase`'s default constant-path rule. -**Guards.** Fifty-one mutations were run one at a time through a harness that applies the edit, runs +**Guards.** Fifty-three mutations were run one at a time through a harness that applies the edit, runs the owning suite, captures the first failure and restores the file — **every one red**, each recorded -in the checklist with the message it produced: forty-four at implementation and seven more in review -round 1. Six shapes were re-cut: one spun forever and was killed +in the checklist with the message it produced: forty-four at implementation, seven in review round 1 +and two in review round 2, the last pair being shapes that SURVIVED round 1 rather than shapes newly +thought of. Six shapes were re-cut: one spun forever and was killed rather than counted, which is itself the `Registry#resolve` finding below, and five went green — and **two of those five were not bad mutations but real non-discrimination in this phase's own assertions**: `XCUT-3`'s cancel could beat the waiter into the wait, and `XCUT-12`'s sixteen racers @@ -5202,4 +5203,18 @@ one `:vacuous` naming the absent unit (`P9-35`). Both were found by writing the for. Four documents were corrected against measurement rather than restated, the coverage transposition above among them. +**Review round 2, 2026-09-24.** Two guards this phase wrote could not fail, and both were in the +instrument rather than in what it measures — which is the failure mode an audit phase has to take +most seriously, because a green assertion that cannot go red is indistinguishable from a property +that holds. `InvariantSuite`'s `XCUT-22` made a second `Check` whose `usable?` was the negation of +the `closed?` the first already read, so the second could not fail on its own; `Borrowed` now carries +`#finish`, the adapter-side teardown reached without going through `#close`, and a holder using it +passes the first and fails the second (`P9-38`). `ExecutorCase#shutdowns` matched the payload's event +name, but every double emitted the shutdown payload and nothing else, so nothing separated "one +shutdown event" from "one sink write" while four assertions read that count; a chatty-but-conforming +pool now logs something besides its shutdown (`P9-39`). Beside them the checklist gained what plan +Task 15 Step 3 asks for in as many words — the three `NFR` marks where the design's ⏳ prediction was +WRONG, named as wrong (`P9-36`) — and the `gates:bounded_map` allowlist re-adjudication got the +ledger row its reasons belonged in (`P9-37`). + `main` is `582e33a` before and after this pass; nothing is pushed, and issue #33 stays open. diff --git a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md index 2167643..18985a5 100644 --- a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md +++ b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md @@ -23,7 +23,7 @@ Plan: `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-confo are that plan's — seventeen numbered tasks plus Task 12a, eighteen headings. Design: `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md`, whose Deviation Ledger carries `P9-1`–`P9-10` and whose As-built addendum, written with this checklist, adds -**`P9-21`–`P9-33`** (the band starts at `P9-21`; `P9-11`–`P9-20` are left free and nothing is renumbered). +**`P9-21`–`P9-39`** (the band starts at `P9-21`; `P9-11`–`P9-20` are left free and nothing is renumbered). Test files are named with their gem: `conformance/…` is `gems/dexpace-conformance/test/dexpace/conformance/`, `core/…` is `gems/dexpace-core/test/dexpace/`, `gates/…` is `test/gates/`, `serde_json/…` is `gems/dexpace-serde-json/test/dexpace/serde/json/` and @@ -62,6 +62,30 @@ design `P9-2` asks for and what a run inside `bundle exec` cannot give: the six evidence that every gem's `sig/` mirror SHIPS: the scan walked it inside the installed gem and not in the repository. +**Prediction versus observation**, which the plan's Task 15 Step 3 asks for by name: *a prediction +is not an observation*, and where the two differ the row says which prediction was wrong. The design's +`NFR` disposition table, written on 2026-09-12, predicted **six ⏳**. **Three of those predictions were +wrong** and the rows below are ✅ on observation, each naming its own. **`NFR-7`**'s ⏳ was pending +phase 0 plan Task 3's reviewed `.rubocop.yml` baseline; whatever that baseline said when phase 0 wrote +it, the honest RuboCop run over the `.rubocop.yml` the repository carries exits 0 on `main` at +`582e33a`, before this phase changed anything. **`NFR-10`**'s was pending 8c plan Task 3's per-gem Ruby +floor gate edit; that edit landed — `VERSIONS` carries `ruby floor:dexpace-transport-async_http 3.3` +and `gates:versions` is green. **`NFR-17`**'s was pending phase 0 plan Task 2's `minitest ~> 5.25` pin, +the one non-blocking-gate finding the design itself had raised; that pin landed with its reason in +`VERSIONS`' own comment, so the meta-audit starts from green with nothing outstanding to report. +**`NFR-4`**'s ⏳ is overturned as a MARK only, and narrowly: both gates run and the row is ✅, but +`gates:sig_diff` still prints its own vacuity because the `v*` tag it needs does not exist, which is +what the design predicted. **`NFR-13`**'s and **`NFR-16`**'s predictions HELD — the SPDX assertion +fails as predicted, and there is still no release path. + +A fourth correction observation forced is not an `NFR` mark at all but the narrative behind +`gates:bounded_map`'s one expected red, and it is deviation 4 below. Every one of the four was MADE +from measurement before Task 15's disposition run, so each mark is an observation and not a +transcription — but only the `gates:bounded_map` one was written down AS a correction at the time. +Naming the other three as overturned predictions is review round 2's repair (`P9-36`): a mark that +silently disagrees with the prediction it replaces leaves the next reader unable to tell which it is, +and phase 10 reads this document first. + Every `XCUT` row's evidence is the same three-part shape and is stated once here rather than repeated forty-one times: **(a)** the assertion in `dexpace-conformance`, which names no first-party constant it cannot reach through `InvariantCase`; **(b)** that assertion driven against a deliberately @@ -93,7 +117,7 @@ proves the assertion can fail at all; and **(c)** the same assertion driven agai | `XCUT-19` | MUST | ✅ | 7, 16 | Clauses (a)–(d): userinfo, a query value and a fragment token all gone from one URL while host and path survive; the header allow-list is default-DENY; and a credential reveals nothing in `#to_s`, `#inspect` or — checked STRUCTURALLY, because `pp` is outside this gem's require allowlist — `#pretty_print`, whose owner must be the credential's own class (`pp.rb` never consults an `#inspect` override on a `Data`). Appendix C's clause (e), "full request/response BODY logging MUST be OFF by default", is asserted nowhere in `InvariantSuite` and is **5b's own**, proven there: `HTTPLogging::DEFAULT` is `NONE`, and `Instrumentation::Step` constructs the two phase-3b logging wrappers at `HTTPLogging::BODY` alone — 5b's checklist carries it as clause (e) of its own `XCUT-19` cross-reference row, over the `OBS-34` row's subject (`docs/work/mvp/phase5/phase5b/2026-09-09-phase5b-logging-and-redaction-checklist.md`). `invariant_suite/security.rb` says so in place. Mutations 9 and 10 | | `XCUT-20` | MUST | ✅ | 7, 16 | Scoped exactly as 5c handed it forward and no wider: `Instrumentation.contain` swallows and reports, `Redactor#url` substitutes its marker for a malformed input, `#header_value` always answers a String, and `Preview.render` survives undecodable bytes. NOT extended to a foreign tracer or meter callback, which `OBS-20` carves out — a `rescue` there is a guard this suite must not demand. Mutation 11 | | `XCUT-21` | MUST | ✅ | 7, 16 | Two observations and no character count: the handler takes an injectable `cnonce_source:` (so its randomness can be audited at all), and bytes are DRAWN from the injected recorder and CARRIED into the rendering — each drawn byte flipped in turn, and at least sixteen must change the output. A character count would call a truncated 128-bit draw conforming. Mutation 21 | -| `XCUT-22` | MUST | ✅ | 5, 11, 16 | Both halves: the caller-supplied resource is not closed AND it still works, because a component that tore it down another way would pass the first alone. `ExecutorSuite`'s twin asserts the same over a real pool behind `Transport.async_over`. Mutations 3 and 40 | +| `XCUT-22` | MUST | ✅ | 5, 11, 16 | Both halves, on two INDEPENDENT routes: the caller-supplied resource is not closed AND it still works, because a component that tore it down another way would pass the first alone. That second route is `Borrowed#finish`, added in review round 2 — until then `usable?` was the negation of the `closed?` the first Check reads, so the second could not fail on its own (`P9-38`). `ExecutorSuite`'s twin asserts the same over a real pool behind `Transport.async_over`, and posts work to it, which is an independent observation of its own. Mutations 3, 40 and 54 | | `XCUT-23` | MUST | ✅ | 8, 16 | Three ordered rules and the ORDERING is the content: zero candidates fail loudly, two candidates fail loudly, an explicit install beats a discovered one, and a single candidate is discovered. "Loud" is checked for its own reason — the message must name the explicit-install entry point. Mutations 21b and 25 | | `XCUT-24` | SHOULD | ✅ | 7, 16 | Both subjects, because the halves live in different objects: 4b's error-body snapshot is the cap and 3b's logging tap is the non-consumption. A preview that capped but drained would pass the first alone. Mutation 8 | | `NFR-1` | MUST | ✅ | 9, 15 | Asserted TWICE against two subjects (`P9-2`): phase 0's `gates:gemspec_audit` over the source gemspec, and `PackagingSuite`'s "core declares zero runtime dependencies" over the resolved `Gem::Specification` a consumer actually gets. `gates:require_allowlist` and `gates:clean_bundle` are the same claim's other two mechanisms and all four are green on every matrix row | @@ -102,17 +126,17 @@ proves the assertion can fail at all; and **(c)** the same assertion driven agai | `NFR-4` | SHOULD | ✅ | 15, 17 | `gates:sig_diff` (vacuous until the first `v*` tag, and it says so) and `gates:surface_snapshot` over the six manifests, regenerated deliberately once in Task 17 and reviewed row by row. This phase's rows are `dexpace-conformance`'s alone | | `NFR-5` | SHOULD | ✅ | 15 | `rake test:gems` with SimpleCov `minimum_coverage 80`; measured at the tests tip | | `NFR-6` | SHOULD | ✅ | 15 | `ruby -w` plus `RUBYOPT=-W:deprecated` with `DexpaceTestCase::FatalWarnings` raising, over one process holding all six gems' suites. This phase's `test/support/gate_warning_capture.rb` deliberately does NOT arm it, for the reason `P9-24` gives | -| `NFR-7` | SHOULD | ✅ | 15 | `rake rubocop` (findings fatal, no autocorrection) and `rake cops:test`. The honest command in a nested worktree is `bundle exec rubocop --fail-level=convention --ignore-parent-exclusion`, which is what was run: 776 files, no offences | +| `NFR-7` | SHOULD | ✅ | 15 | `rake rubocop` (findings fatal, no autocorrection) and `rake cops:test`. The honest command in a nested worktree is `bundle exec rubocop --fail-level=convention --ignore-parent-exclusion`, which is what was run: 776 files, no offences. **The design predicted ⏳**, pending phase 0 plan Task 3's reviewed `.rubocop.yml` baseline; that prediction was wrong — the baseline landed and the honest run is clean on `main` before this phase touched it | | `NFR-8` | MUST | N/A | 15 | Ruby has no whole-program dead-code elimination, and the requirement exempts itself by its own text. §10.19 retargets it at the require-allowlist audit and the clean-bundle isolation run, both dispositioned under `NFR-1` rather than counted twice (`P9-5`) | | `NFR-9` | SHOULD | N/A | 15 | The same: there is no shrinker keep-configuration to guard. The part of its CONTENT that does apply — a guard that the require-allowlist is still the RIGHT list on a new interpreter — is postponed with its trigger named (`docs/first-release.md` § Post-release triggers, the require-allowlist entry) | -| `NFR-10` | MUST | ✅ | 9, 15 | `PackagingSuite`'s "every unit declares a runtime floor, and a higher one stays isolated" — `dexpace-transport-async_http`'s 3.3 floor must not leak into the other five — beside `gates:versions` | +| `NFR-10` | MUST | ✅ | 9, 15 | `PackagingSuite`'s "every unit declares a runtime floor, and a higher one stays isolated" — `dexpace-transport-async_http`'s 3.3 floor must not leak into the other five — beside `gates:versions`. **The design predicted ⏳**, pending 8c plan Task 3's per-gem Ruby floor gate edit; that prediction was wrong — the edit landed, `VERSIONS` carries the `floor:` row and `gates:versions` is green | | `NFR-11` | SHOULD | ✅ | 9, 15 | `gates:rbs_surface` over `sig/**` plus `PackagingSuite`'s "the core's public signatures name no async-framework type", whose leak regex anchors on the START of a constant path so core's own `Dexpace::Async::Future` is not a match. Mutation 41 | | `NFR-12` | SHOULD | ✅ (partial) | 15 | `gates:reproducible` — byte-identity for one gem built twice on one interpreter, which is phase 0's `P0-7` reading. The cross-toolchain half is explicitly out of scope and is release-gated (`docs/first-release.md` § Release path, the `NFR-12`/`NFR-16` entry) | | `NFR-13` | SHOULD | ✅ / ⏳ | 9, 15, 16 | ✅ for the `lib/` half — the `Dexpace/SpdxHeader` cop over every Ruby file — and for the ASSERTION over the `sig/` half, which is what this phase owed. ⏳ for the `sig/` half's CONTENT: the assertion **fails, correctly**, and that failure is this phase's verdict rather than a defect in it. Measured on 2026-09-23: **307 of 307 shipped `.rbs` files carry no SPDX header, in all six gems.** A RuboCop cop parses Ruby and cannot reach `.rbs`, so nothing checked them. **Owner: phase 10's design addendum `A8` and inbound row 8, which already name this assertion and say "phase 9's presence assertion turns green"** — verified still owned on 2026-09-23, not re-recorded | | `NFR-14` | MUST | ✅ | 9, 15 | `PackagingSuite`'s "every unit's version comes from one source of truth" against the resolved specs, beside `gates:versions` over the repository-root `VERSIONS` file | | `NFR-15` | SHOULD | ✅ | 9, 15 | `PackagingSuite`'s "each unit reports its build version at runtime": the loaded `VERSION` constant compared against the resolved gemspec's, run against locally built `.gem` files outside the bundle | | `NFR-16` | SHOULD | ⏳ | — | Artifact signing is "enforced on the release/CI path" and there is no release path. No phase-9 task writes an assertion for it and none is shipped as a placeholder. **Owner: `docs/first-release.md` § Release path, the `NFR-12`/`NFR-16` entry** | -| `NFR-17` | MUST | ✅ | 13, 15 | Every gate this phase adds is in `DEFAULT_GATES` in the root `Rakefile` — not in `tasks/gates.rake`, which the `Rakefile` `load`s before defining and freezing the array — so all **twenty-one** are in `task default:` and therefore blocking, and all three are in `ci.yml`'s `gates` job. `gates/default_task_test.rb` asserts the count and the membership, and phase 0's `ci_workflow_test.rb` asserts every `DEFAULT_GATES` entry appears in some CI job | +| `NFR-17` | MUST | ✅ | 13, 15 | Every gate this phase adds is in `DEFAULT_GATES` in the root `Rakefile` — not in `tasks/gates.rake`, which the `Rakefile` `load`s before defining and freezing the array — so all **twenty-one** are in `task default:` and therefore blocking, and all three are in `ci.yml`'s `gates` job. `gates/default_task_test.rb` asserts the count and the membership, and phase 0's `ci_workflow_test.rb` asserts every `DEFAULT_GATES` entry appears in some CI job. **The design predicted ⏳**, pending phase 0 plan Task 2's `minitest ~> 5.25` pin — the one non-blocking-gate finding the design itself had raised; that prediction was wrong, because the pin landed, so the meta-audit found nothing outstanding to report | ### Cross-reference rows @@ -124,7 +148,7 @@ row, which is not re-marked here. | `SEAM-2` | phase 2 | `gates:seam_names`, the parsed scan asserting core names no adapter's leaf namespace — by leaf pair and not by a literal spelling, because a fixed list of fully qualified names caught none of four realistic shapes | | `SEAM-12` | phase 2 | `ExecutorSuite`'s "a shared executor takes work from many threads", sixteen concurrent posts asserted on the collected set and never on timing | | `SEAM-20` | phase 2 | `CodecSuite`'s "a codec never closes a target it was handed", with the bytes checked too so a codec that wrote nothing cannot pass by having closed nothing. Mutation 37 | -| `SEAM-25` | phase 2 (postponed to 8a, unwritten there) | `ExecutorSuite`'s "closing an owned executor emits exactly one shutdown event" — **the harness half, reassigned to phase 9 and stated as a correction to a committed record**, not a restatement of it (`P9-21`) | +| `SEAM-25` | phase 2 (postponed to 8a, unwritten there) | `ExecutorSuite`'s "closing an owned executor emits exactly one shutdown event" — **the harness half, reassigned to phase 9 and stated as a correction to a committed record**, not a restatement of it (`P9-21`). The count is of shutdown EVENTS and not of sink writes, which review round 2 gave a double of its own (`P9-39`). Mutation 53 | | `SERDE-3` | phase 7a | The same `CodecSuite` assertion, re-derived rather than lifted (`P9-22`) | | `SERDE-9` | phase 7a | `CodecSuite`'s "a decode failure surfaces the SDK's serde type", all three clauses: it raises, the type is inside `Serde::Error`, and the original is chained — read through `Dexpace.each_cause` so `gates:cause_walk`'s allowlist stays at one entry. Mutation 38 | | `SSE-37` | phase 7b | `gates:serde_boundary`'s `PENDING` list asserted EMPTY, the clause 7b handed forward and could not assert while phase 7 was running. The task's abort branch is now driven by a fixture (`P9-30`) | @@ -168,13 +192,16 @@ first-party drivers. The reviewer's mutation list could not be recovered verbatim after this session's context was compacted (`P9-32`), so the set below was re-derived from the design's testing strategy and the plan's own "non-conforming double first" rule, one mutation per assertion, per gate and per piece of shared -machinery. **Fifty-one mutations, every one red** — forty-four at implementation and seven more in -**review round 1** (rows 46–52) — run one at a time through a harness that applies the edit, runs the -owning suite, captures the first failure and restores the file, on **4.0.6** (row 52 on **3.2.11**, the -row whose parser the fact differs on). The table below numbers fifty-two slots because two are -placeholders for a mutation that was re-cut rather than a mutation of their own. Six shapes were re-cut: -one spun forever and was killed rather than counted (a finding in its own right, routed below), and five -went green; **two of those five were not bad mutations but real +machinery. **Fifty-three mutations, every one red** — forty-four at implementation, seven in **review +round 1** (rows 46–52) and two in **review round 2** (rows 53–54) — run one at a time through a harness +that applies the edit, runs the owning suite, captures the first failure and restores the file, on +**4.0.6** (row 52 on **3.2.11**, the row whose parser the fact differs on). The table below numbers +fifty-four slots because two are placeholders for a mutation that was re-cut rather than a mutation of +their own. Rows 53 and 54 are the two shapes that SURVIVED review round 1: they are recorded as what +they were, a gap in this phase's own guards and not a gap in the code those guards watch. + +Six shapes were re-cut: one spun forever and was killed rather than counted (a finding in its own +right, routed below), and five went green; **two of those five were not bad mutations but real non-discrimination in this phase's own assertions**, and both were repaired before the mutation was re-run — `XCUT-3`'s cancel could beat the waiter into the wait, and `XCUT-12`'s sixteen racers could finish one at a time. Those two repairs are `P9-28` and `P9-29`, and they are the reason the mutation @@ -235,6 +262,8 @@ pass is worth its cost. | 50 | `PackagingCase::DEFAULT_RESOLVE` drops `::Gem::LoadError` | `Gem::MissingSpecError: Could not find 'dexpace-no-such-gem-…'` — 1 error, not 8 vacuities | `NFR-1`, `NFR-2`, `NFR-10`, `NFR-14`, `NFR-15` | | 51 | `RequirementLevels::ROW` loses its nineteen-prefix restriction | `{"HTTP-7"…, "RFC-7235" => :must, "ISO-8601" => :should, …}` against a synthetic table | `NFR-17` | | 52 | the duplicated-key capture is taken INSIDE the `$VERBOSE` window | `a duplicated key warns whatever $VERBOSE is` (3.2.11) | `XCUT-9`, `XCUT-14`, `SEAM-2` | +| 53 | `ExecutorCase#shutdowns` counts every recorder payload instead of matching `Keys::EVENT` | `XCUT-13 => :failed, XCUT-22 => :failed, SEAM-25 => :failed` against the chatty-but-conforming pool | `SEAM-25`, `XCUT-13`, `XCUT-22` | +| 54 | `Models#only_closes_what_it_created`'s second `Check` takes a literal `true` | `a holder that tears a borrowed resource down without closing it fails XCUT-22 / Expected: [:failed], Actual: [:passed]` | `XCUT-22` | One more shape was cut and then **killed rather than counted**: conditioning `Registry#resolve`'s `return hand_out(resolved)` guard on anything else turns its `loop do … end` into an unbounded spin with @@ -248,7 +277,7 @@ parked when `timeout 90` killed it, and the same removal at the two join sites i Ruby's own deadlock detector, as `fatal: No live threads left. Deadlock?`. That asymmetry is why rows 47 and 48 drop the `Check` and keep the bounded join: a mutation the interpreter aborts proves the join runs, not that an expired one is reported. The hang itself is the defect `P9-34` fixes, demonstrated; it -is not a guard going red and it is not counted among the fifty-one. +is not a guard going red and it is not counted among the fifty-three. Beside the mutations, the three new gates are each driven against a deliberately failing **fixture workspace** through `DEXPACE_GATE_ROOT` (`test/fixtures/gates/invariants/workspace/`), and @@ -286,7 +315,7 @@ none roll-up only, so nothing here had to be read out of the specification inste ## Deviations from the plan Departures from the plan's text, each with its reason. None lowers, disables or narrows a gate. The -corresponding ledger rows are `P9-21`–`P9-33` in the design's As-built addendum. +corresponding ledger rows are `P9-21`–`P9-39` in the design's As-built addendum. 1. **`dexpace-serde-json`'s and `dexpace-core`'s existing files are byte-identical** — the plan's Task 10 removes `assert_closes_nothing`'s and `assert_failure_model`'s portable halves from 7a's @@ -311,7 +340,10 @@ corresponding ledger rows are `P9-21`–`P9-33` in the design's As-built addendu reactors first — which is `XCUT-14`'s drain clause exactly. Verified by reading the file on 2026-09-23. So the gate is GREEN, `XCUT-14`'s row is ✅ rather than ⏳, no phase-10 inbound bullet is filed for it and no `docs/first-release.md` blocker is opened — and the standing blocker that waited - on this verification is ticked rather than a new one filed. `P9-27`. + on this verification is ticked rather than a new one filed. The allowlist that remains after the + re-adjudication is six entries, each with its written reason in `tools/invariant_gates.rb`, and the + integrity test asserts that every entry names a live file the scan still reports at that site — the + clause whose absence let the stale `configuration.rb` key survive a whole adjudication. `P9-37`. 5. **Two assertions this phase wrote did not discriminate, and both were repaired.** `XCUT-3`'s cancel could be issued before the waiter entered the wait, and `XCUT-12`'s racers could finish serially. Both now assert a parking barrier as a `Check` of its own, so a run that could not have discriminated @@ -364,6 +396,28 @@ it AUDITS, and `dexpace-conformance`'s new code is what it wrote, the same stand ::StandardError` let it past — and past `Runner`'s bare rescue too, aborting the whole run where the contract is one `:vacuous` naming the absent unit. Row 50. `P9-35`. +### Review round 2, 2026-09-24 + +Two further departures, both repairs to guards this phase wrote, found by re-running two shapes from +the brief's own minimum mutation list that review round 1 measured as SURVIVING. + +12. **`XCUT-22`'s second clause could not fail.** `InvariantSuite::Models#only_closes_what_it_created` + makes two `Check`s against one `Borrowed` double: it was not closed, and it still works. `usable?` + was `!@closed` — the exact negation of the `closed?` the first `Check` already reads — so no subject + the suite can build separated them, and replacing the second `Check`'s condition with a literal + `true` left the whole gem's suite green. The comment above it and this checklist's own `XCUT-22` row + both claimed the opposite. `Borrowed` now carries the other route the requirement implies: `#finish` + is the adapter-side teardown reached without going through `#close` — `Net::HTTP` spells it exactly + that — and `usable?` reads both flags. `TearingSeam` drives it. Row 54. `P9-38`. +13. **`SEAM-25` was "one sink write" as tested, not "one shutdown EVENT".** `ExecutorCase#shutdowns` + matches the payload's `Keys::EVENT` entry, and every double in `executor_suite_test.rb` emitted the + shutdown payload and nothing else, so replacing that match with a bare `entries.count` left the + file, the pool driver and the whole `rake test:gems` green — while four assertions read that count. + `ChattyPool` logs one other event payload and one bare String message at construction, which is + conforming and which nothing in the SPI forbids; the mutation now reddens `XCUT-13`, `XCUT-22` and + `SEAM-25` together. No code changed: the code was right and only the guard was missing. Row 53. + `P9-39`. + --- ## Findings routed diff --git a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md index ea70c07..6a62008 100644 --- a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md +++ b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md @@ -1083,8 +1083,10 @@ This phase was cut from `main` at `582e33a`, which holds every phase through 8. below, numbered from **P9-21** as the brief fixes it; `P9-11`–`P9-20` are left free and nothing is renumbered. The checklist's "Deviations from the plan" is the itemised list against the plan's text; the rows here are the ones that touch public behaviour, the contract a later phase cites, or a statement this -document makes. `P9-34` and `P9-35` were added in **review round 1**, on 2026-09-24, and are marked as -such in their own rows; nothing earlier is rewritten except `P9-32`'s count, which was wrong as written. +document makes. `P9-34` and `P9-35` were added in **review round 1**, on 2026-09-24, and `P9-36` +through `P9-39` in **review round 2**, on the same day; each is marked as such in its own row, and +nothing earlier is rewritten except `P9-32`'s count, which was wrong as written and is corrected +twice for the same reason. | # | Deviation | Requirement / document | Why | |---|---|---|---| @@ -1099,10 +1101,14 @@ such in their own rows; nothing earlier is rewritten except `P9-32`'s count, whi | P9-29 | `XCUT-12`'s assertion asserts that **every** racer was parked before the coalesced fetch is released | `XCUT-12` | Without the barrier the first thread finished alone and the other fifteen took the cached-token path without ever racing, so a stamper fetching per caller PASSED. Measured by mutation. Under a conforming stamper one racer parks in `#fetch` and fifteen park on the credential's lock; under a non-conforming one all sixteen park in `#fetch` — both are `"sleep"`, so the barrier is the same for either and decides nothing by itself | | P9-30 | `tools/serde_boundary.rb`'s `pending` gains `list:`, **and** the task's abort branch gets a `RUBYOPT` prelude fixture | `SSE-37`; `R4` addendum A7 | The `list:` keyword lets a test drive a non-empty list through the FUNCTION. It does not reach the TASK: `PENDING` is a frozen constant and no gate root can make the task see a non-empty one, so a mutation deleting the abort left every test green. The fixture prepends one row onto `.pending` through `RUBYOPT`, which is this repository's deliberately-failing-fixture rule applied to the one gate clause with no other route in | | P9-31 | `tools/requirement_levels.rb --check` takes an **optional path**, so both exits are proven | `R3`; `NFR-17` | With `TARGET` fixed the gate's own test could only ever assert exit 0, and a `--check` that always exited 0 passed it — measured by mutation. `--check ` compares the same render against a file the test writes to a temp dir, so the failing half needs no fixture tree and nothing under `gems/` is touched | -| P9-32 | The mutation set run here is **this phase's own**, re-derived from the design's testing strategy and the plan's per-task "non-conforming double" rule | the brief's minimum set | The reviewer's list was in this run's opening brief and did not survive the session's context compaction. **Forty-four** mutations were re-derived and all forty-four go red; a forty-fifth shape — conditioning `Registry#resolve`'s `return hand_out(resolved)` guard on anything else — turned its `loop do … end` into an unbounded spin, ran for six minutes and was **killed rather than counted**, which is the finding routed to phase 10's inbound list and not a guard. Review round 1 adds **seven** more, for fifty-one red in all (checklist, "Guards run red", rows 46–52). The count exceeds the thirty-five the brief named as a minimum, but the SET is not the reviewer's, and a reviewer re-running the original should expect to find it covered rather than assume it | +| P9-32 | The mutation set run here is **this phase's own**, re-derived from the design's testing strategy and the plan's per-task "non-conforming double" rule | the brief's minimum set | The reviewer's list was in this run's opening brief and did not survive the session's context compaction. **Forty-four** mutations were re-derived and all forty-four go red; a forty-fifth shape — conditioning `Registry#resolve`'s `return hand_out(resolved)` guard on anything else — turned its `loop do … end` into an unbounded spin, ran for six minutes and was **killed rather than counted**, which is the finding routed to phase 10's inbound list and not a guard. Review round 1 adds **seven** more (rows 46–52) and review round 2 **two** (rows 53–54), for fifty-three red in all (checklist, "Guards run red"). The two round-2 rows are shapes that SURVIVED round 1 — the brief's own minimum list named both — so the honest reading is that the set run at implementation was short by two, not that two more were found. The count exceeds the thirty-five the brief named as a minimum, but the SET is not the reviewer's, and a reviewer re-running the original should expect to find it covered rather than assume it | | P9-33 | The plan's existence-probe list names `:Redactor` at the top level; the filed name is `Instrumentation::Redactor` | plan Task 16 Step 1; design `R8` row 13 | `Dexpace::Redactor` does not exist and never did — design `R8`'s own row 13 says "the filed name, not `Dexpace::Redactor`". The probe list was corrected at execution; every probe answers true, so no `R3` divergence was filed | | P9-34 | **Every wait in the four new suites carries a bound**, and ASYNC-3's expired ENTRY wait is `:vacuous` rather than `:failed` | `ASYNC-3`, `SEAM-12`, `XCUT-11`; 8a's `await_closed_connection` | Review round 1. Three waits had none — `Shutdown#check_release`'s `transport.entered.pop` and the two `.each(&:join)` sites — and the first was demonstrated to HANG for ever against an executor that refuses the post (ASYNC-2's saturated queue, or a closed pool): nothing is ever pushed, the poster rescues the refusal, and the `ensure` that frees the gate and closes the pool is never reached. This gem exists to be run by third parties against THEIR executors, and 8a had already fixed the rule for it — `await_closed_connection` always carries a `timeout:`, so an adapter that never releases fails the assertion instead of hanging the run. The entry wait's expiry is `:vacuous` and not `:failed` because with no unit started there is no "blocking task on a worker thread", which is the antecedent `ASYNC-3`'s own sentence opens with; a MUST-level vacuity blocks the report regardless. The two join sites are Failures naming their clause, with the whole thread set sharing one budget so sixteen stuck threads cost one bound and not sixteen | | P9-35 | `PackagingCase::DEFAULT_RESOLVE` rescues `::Gem::LoadError` **by name**, beside `::StandardError` | `NFR-1`, `NFR-2`, `NFR-10`, `NFR-14`, `NFR-15`; the suite's absent-antecedent contract | Review round 1, found by writing the test round 0 asked for: every case in `packaging_suite_test.rb` supplied its own `resolve:`, so the lambda a real driver gets had never run. RubyGems raises `Gem::MissingSpecError` for a name it cannot resolve and that descends from `Gem::LoadError < LoadError < ScriptError` — it is **not** a `StandardError`, so the shipped `rescue ::StandardError` let it past, and it escaped `Runner`'s own bare rescue too and aborted the whole run where the contract is one `:vacuous` result naming the absent unit. Measured on 4.0.6. A defect in this phase's own gem, so `R6`'s report-don't-repair rule does not reach it — the same standing `P9-27` has | +| P9-36 | Four of this document's own predictions were **overturned by observation**, and each is named here as a prediction that was WRONG | `NFR-7`, `NFR-10`, `NFR-17`, `XCUT-14`; plan Task 15 Step 3 | *A prediction is not an observation.* The disposition table above predicted six ⏳ and three of those predictions are simply wrong on the tree this phase was cut from: `NFR-7`'s was pending phase 0 plan Task 3's reviewed `.rubocop.yml` baseline, which landed — `rubocop` exits 0 on `main` at `582e33a`; `NFR-10`'s was pending 8c plan Task 3's per-gem Ruby floor gate edit, which landed — `VERSIONS` carries `ruby floor:dexpace-transport-async_http 3.3` and `gates:versions` is green; and `NFR-17`'s was pending phase 0 plan Task 2's `minitest ~> 5.25` pin, which landed, so the one finding this design raised against `NFR-17` is closed. The fourth correction is not a mark but a NARRATIVE: the whole `gates:bounded_map` story rested on `async_http`'s client cache being uncapped, and 8c shipped the cap its plan promised. `NFR-4`'s ⏳ is overturned as a mark only and narrowly — the row is ✅ because both gates run, while the `v*` baseline `gates:sig_diff` needs still does not exist — and `NFR-13`'s and `NFR-16`'s predictions HELD. All four corrections were MADE from measurement before Task 15's disposition run, so every mark it recorded is an observation; but only the `gates:bounded_map` one was written down AS a correction at the time, and review round 2 is what names the other three. Plan Task 15 Step 3 asks for the naming in as many words, and it is the naming and not the measurement that tells a later reader which of the two a ✅ is — which matters here because phase 10 reads the checklist first | +| P9-37 | `gates:bounded_map`'s allowlist is **re-adjudicated against as-built paths**, ships at six entries, and the integrity test gains the clause that would have caught the stale one | `XCUT-14`; plan Task 13 | The plan filed four keys. One — `gems/dexpace-core/lib/dexpace/configuration.rb` — silenced nothing, because 5a's two accumulators live in `configuration/builder.rb`; a key that silences nothing passes the plan's own "every key names a live file" test, which is exactly how a stale entry survives an adjudication. The scan reports six sites the gate must not fail on: `bounded_map.rb`, `instrumentation/event.rb` and `conformance/recording_span.rb` as filed, plus `configuration/builder.rb`, `auth/challenges.rb` (one parse's accumulator inside a `private_constant Parser`, reset at every `open_challenge` and dropped with the parse — `event.rb`'s lifetime argument) and `async_http/clients.rb` (`@by_key`, drained inside the insert's own `synchronize`). The rule is NOT narrowed and the keyed-read narrowing stays rejected: six is far short of the dozen open question 2 names as the point where the gate would be telling us the invariant is not held. The integrity test now asserts each key names a live file AND that the scan still reports a Hash ivar there | +| P9-38 | `InvariantSuite::Models::Borrowed` carries a **second, independent teardown route**, so `XCUT-22`'s "still usable" `Check` can fail | `XCUT-22` | Review round 2. `usable?` was `!@closed`, the exact negation of the `closed?` the FIRST `Check` already asserts, so no subject the suite can build separated the two: replacing the second `Check`'s condition with a literal `true` left the whole gem's suite green, while the code comment and the checklist row both claimed it caught a component that tore the resource down another way. `Borrowed#finish` is that other way — `Net::HTTP` spells its teardown exactly that, a pooled client spells it "retire", and an adapter tearing down a caller's SOCKET rather than the caller's client leaves `closed?` false — and `usable?` now reads both flags. `TearingSeam` drives it. The `ExecutorSuite` twin never had the problem: it posts work to the underlying executor after the holder's close, which is an observation of its own | +| P9-39 | `ExecutorCase#shutdowns`' event-NAME match is given a double that logs something **besides** its shutdown | `SEAM-25`, `XCUT-13`, `XCUT-22` | Review round 2. The count matches the payload's `Keys::EVENT` entry, but every double in `executor_suite_test.rb` emitted the shutdown payload and nothing else, so nothing separated "count the shutdown events" from "count the sink's writes" — and replacing the match with a bare `entries.count` left the file, the pool driver and the whole `rake test:gems` green, with four assertions reading that count. An executor that logged one unrelated line and emitted no shutdown would have passed `SEAM-25`; a conforming one that logged anything at all would have failed `XCUT-22`. `ChattyPool` emits one other event payload and one bare String message at construction, which nothing in the SPI forbids and which a real adapter's logger carries plenty of. No code changed — the code was right and only the guard was missing | --- From 78b73dd924d6eba86fd9126755d8c3de62ded373 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 24 Sep 2026 12:40:25 +0300 Subject: [PATCH 13/17] docs: re-measure the phase-9 proofs at review round 2's tips Round 2 added two tests and three lines of lib/, so every run count and both coverage denominators moved: the code tip is 11,689 of 12,349 on 4.0.6 and 11,106 of 12,210 on 3.2.11, the tests and docs tips 4,191 runs and 74,584 assertions at 12,327 of 12,349, and the 3.2 row 4,003 and 73,808 at 11,733 of 12,210. The paragraph is re-measured rather than patched, and it now records the seed and repeat runs it always implied. The aggregate run over all four suites with the real subjects reproduces unchanged at 43 / 1 / 0 / 1. --- .../2026-09-05-ruby-sdk-v1-roadmap-design.md | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md index 52eba0c..e7cbc58 100644 --- a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md @@ -5165,21 +5165,25 @@ both are fixed because both are phase 9's own code: the three new gates' `DEXPAC opened every file relative to the process's CWD and could never have been shown to reject anything, and `gates:serde_boundary`'s abort branch and `--check`'s failing exit had no test at all. -**Proofs**, re-measured at review round 1's tips on 2026-09-24. The **code tip** is green on -**every one of the twenty-one gates run individually** on 4.0.6, the coverage floor included — -**4,020 runs, 74,125 assertions, 0 failures, 0 errors, 7 skips** and **94.67 % line coverage, -11,688 of 12,346** — the one red the layering rule tolerates was not needed, because the floor is met -there even though the suites that raise the figure are the branch above's — `test:gates` 145 runs and -703 assertions with no skip, and the honest RuboCop run over 758 files clean; and on the five matrix gates on 3.2.11 (3,832 runs, 73,349 assertions, 3 skips, 11,105 of -12,207 lines, **90.97 %**), where the three new gates report the same 307, 307 and 222 files they do -on 4.0.6. The **tests tip** is green on the whole default task on 4.0.6 (**4,189 runs, 74,576 -assertions, 0 failures, 0 errors, 9 skips** and **99.82 % line coverage, 12,324 of 12,346**; -`cops:test` 129 runs and 412 assertions; `test:gates` 193 runs and 877 assertions with no skip; the -honest RuboCop run over 776 files clean) and on the matrix set on 3.4.10 (4,189 / 74,576 / 9 skips, -the same 99.82 %), 3.3.12 (the same runs, 12,178 of 12,200 lines) and 3.2.11 (4,001 / 73,800 / **5** -skips, 96.10 % — the four `async_http` skips are absent because that gem's 3.3 floor excludes it from -the 3.2 row). The two coverage figures are not interchangeable and were once transposed here: 90.97 % -is the **3.2.11** row and 94.67 % the 4.0.6 one. **Every skip is named**: the seven phase-8 drivers' — +**Proofs**, re-measured at review round 2's tips on 2026-09-24; the run counts and the coverage +denominators both moved, because the round added two tests and three lines of `lib/`. The **code +tip** is green on **every one of the twenty-one gates run individually** on 4.0.6, the coverage floor +included — **4,020 runs, 74,125 assertions, 0 failures, 0 errors, 7 skips** and **94.65 % line +coverage, 11,689 of 12,349** — the one red the layering rule tolerates was not needed, because the +floor is met there even though the suites that raise the figure are the branch above's — `test:gates` +145 runs and 703 assertions with no skip, and the honest RuboCop run over 758 files clean; and on the +five matrix gates on 3.2.11 (3,832 runs, 73,349 assertions, 3 skips, 11,106 of 12,210 lines, +**90.95 %**), where the three new gates report the same 307, 307 and 222 files they do on 4.0.6. The +**tests tip** is green on the whole default task on 4.0.6 (**4,191 runs, 74,584 assertions, 0 +failures, 0 errors, 9 skips** and **99.82 % line coverage, 12,327 of 12,349**; `cops:test` 129 runs +and 412 assertions; `test:gates` 193 runs and 877 assertions with no skip; the honest RuboCop run +over 776 files clean) and on the matrix set on 3.4.10 (4,191 / 74,584 / 9 skips, the same 99.82 %), +3.3.12 (the same runs, 12,181 of 12,203 lines) and 3.2.11 (4,003 / 73,808 / **5** skips, 96.09 % — +the four `async_http` skips are absent because that gem's 3.3 floor excludes it from the 3.2 row). +Three explicit seeds — 1, 99991 and 424242 — give the same 4,191 / 74,584 / 9 on 4.0.6, and the four +timing-sensitive files run ten times each under a shell `timeout` with identical counts every time. +The two coverage figures are not interchangeable and were once transposed here: 90.95 % is the +**3.2.11** row and 94.65 % the 4.0.6 one. **Every skip is named**: the seven phase-8 drivers' — `net_http`'s three and `async_http`'s four — plus this phase's two, `XCUT-18`'s call-site assertion in core's driver (`vacuous: no transport factory supplied to InvariantSuite.run`, and real in the aggregate run) and `ASYNC-3` in the pool's (`waived: ASYNC-3`, and proven to fail by a test of its From f6fedbe63efd1d18514a7285a21399d1c43137de Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 24 Sep 2026 14:46:40 +0300 Subject: [PATCH 14/17] docs: name R6's one bend and the guard claims that were untrue Three documents said something the tree does not. R6's boundary. The audit's own driver is one ADDED file under gems/dexpace-core/test/, and no document named it: the design's Module layout still says "no file in gems/dexpace-core/ at all" while its own P9-9 says "the driver is dexpace-core's own suite", and the checklist's and the roadmap's boundary sentences enumerated the trees phase 9 changed without it. All three now name the file and the narrow bend it is -- one added test file, no existing core file modified, renamed or deleted, git diff --name-status one path at status A -- and P9-40 records the contradiction by pointing at both sentences rather than editing either away. The (b) evidence claim. The checklist asserted over every XCUT row that the assertion had been driven against a deliberately non-conforming double, "which is what proves the assertion can fail at all". It was false for seven rows and, until this round's doubles, for five of them at whole-assertion scope. The paragraph now names the two rows where (b) is still a hand-run subject mutation -- XCUT-3 and XCUT-12, whose doubles would leak a parked thread -- and says what review round 3 added for the other seven. P9-41. The mutation set. "One mutation per assertion, per gate and per piece of shared machinery" was untrue as written: XCUT-2, XCUT-6, XCUT-7 and XCUT-10 had no row at all and XCUT-1 only subject rows. The preamble says so, rows 55-61 are the seven whole-assertion neutralisations that close it, and the count moves from fifty-three to sixty. Proofs re-measured at these tips: tests and docs 4,204 runs and 74,610 assertions at 99.82 % on 4.0.6 and 3.4.10, the same runs at 12,181 of 12,203 on 3.3.12, and 4,016 / 73,834 / 5 skips at 96.09 % on 3.2.11. The coverage denominators did not move, because the round added no lib/. --- .../2026-09-05-ruby-sdk-v1-roadmap-design.md | 56 ++++++++--- ...ng-invariants-and-conformance-checklist.md | 93 +++++++++++++++---- ...tting-invariants-and-conformance-design.md | 9 +- 3 files changed, 121 insertions(+), 37 deletions(-) diff --git a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md index e7cbc58..60cf7ad 100644 --- a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md @@ -5095,10 +5095,15 @@ both. (`9-phase-9-cross-cutting-invariants-and-conformance`), tests (`…-tests`) and documentation (`…-docs`), each targeting the one below and the first targeting `main` at `582e33a`, which holds every phase through 8. Nothing is pushed. **This is the audit phase, and it repaired nothing it found**: -design `R6` makes the repair phase 10's, and the file list the plan fixed held exactly — -`gems/dexpace-conformance/`, `tasks/`, `tools/`, `test/`, `.github/workflows/` and the root `Rakefile`, -plus each adapter gem's `test/` tree. **No `lib/` or `sig/` file outside `gems/dexpace-conformance/` -was touched, in any gem.** +design `R6` makes the repair phase 10's, and the file list the plan fixed held with one named +bend — `gems/dexpace-conformance/`, `tasks/`, `tools/`, `test/`, `.github/workflows/` and the root +`Rakefile`, plus each adapter gem's `test/` tree, **plus exactly one ADDED file under +`gems/dexpace-core/test/`**, the first-party `InvariantSuite` driver, with no existing core file +modified, renamed or deleted. That is the one place the file list bends, it is the sentence the +design's own `P9-9` needs and its Module layout denies, and `P9-40` records the contradiction rather +than editing either away. **No `lib/` or `sig/` file outside `gems/dexpace-conformance/` +was touched, in any gem**, and `git diff --name-status main.. -- gems/dexpace-core` is +that one path, status `A`. **What it built.** Four suites beside phase 8a's transport one, all over one shared `Runner` so the five statuses are decided in one place — `InvariantSuite` (28 assertions over all twenty-four `XCUT` @@ -5148,11 +5153,14 @@ two stated residues, 7a's `SEAM-21` evidence living inside another assertion's b `tools/surface.rb`'s blindness to a `private_constant` module, and `PackagingCase`'s default constant-path rule. -**Guards.** Fifty-three mutations were run one at a time through a harness that applies the edit, runs +**Guards.** Sixty mutations were run one at a time through a harness that applies the edit, runs the owning suite, captures the first failure and restores the file — **every one red**, each recorded -in the checklist with the message it produced: forty-four at implementation, seven in review round 1 -and two in review round 2, the last pair being shapes that SURVIVED round 1 rather than shapes newly -thought of. Six shapes were re-cut: one spun forever and was killed +in the checklist with the message it produced: forty-four at implementation, seven in review round 1, +two in review round 2 — shapes that SURVIVED round 1 rather than shapes newly thought of — and seven +in review round 3, which neutralise one of this phase's OWN assertions whole rather than mutating a +subject, the only shape that proves a committed double can report `:failed`. The checklist's earlier +claim of "one mutation per assertion" was simply untrue and is corrected there with the rows that were +missing. Six shapes were re-cut: one spun forever and was killed rather than counted, which is itself the `Registry#resolve` finding below, and five went green — and **two of those five were not bad mutations but real non-discrimination in this phase's own assertions**: `XCUT-3`'s cancel could beat the waiter into the wait, and `XCUT-12`'s sixteen racers @@ -5165,22 +5173,22 @@ both are fixed because both are phase 9's own code: the three new gates' `DEXPAC opened every file relative to the process's CWD and could never have been shown to reject anything, and `gates:serde_boundary`'s abort branch and `--check`'s failing exit had no test at all. -**Proofs**, re-measured at review round 2's tips on 2026-09-24; the run counts and the coverage -denominators both moved, because the round added two tests and three lines of `lib/`. The **code -tip** is green on **every one of the twenty-one gates run individually** on 4.0.6, the coverage floor +**Proofs**, re-measured at review round 3's tips on 2026-09-24; the run counts moved again, because +that round added thirteen tests, and the coverage denominators did NOT, because it added no `lib/`. +The **code tip** is green on **every one of the twenty-one gates run individually** on 4.0.6, the coverage floor included — **4,020 runs, 74,125 assertions, 0 failures, 0 errors, 7 skips** and **94.65 % line coverage, 11,689 of 12,349** — the one red the layering rule tolerates was not needed, because the floor is met there even though the suites that raise the figure are the branch above's — `test:gates` 145 runs and 703 assertions with no skip, and the honest RuboCop run over 758 files clean; and on the five matrix gates on 3.2.11 (3,832 runs, 73,349 assertions, 3 skips, 11,106 of 12,210 lines, **90.95 %**), where the three new gates report the same 307, 307 and 222 files they do on 4.0.6. The -**tests tip** is green on the whole default task on 4.0.6 (**4,191 runs, 74,584 assertions, 0 +**tests tip** is green on the whole default task on 4.0.6 (**4,204 runs, 74,610 assertions, 0 failures, 0 errors, 9 skips** and **99.82 % line coverage, 12,327 of 12,349**; `cops:test` 129 runs and 412 assertions; `test:gates` 193 runs and 877 assertions with no skip; the honest RuboCop run -over 776 files clean) and on the matrix set on 3.4.10 (4,191 / 74,584 / 9 skips, the same 99.82 %), -3.3.12 (the same runs, 12,181 of 12,203 lines) and 3.2.11 (4,003 / 73,808 / **5** skips, 96.09 % — +over 776 files clean) and on the matrix set on 3.4.10 (4,204 / 74,610 / 9 skips, the same 99.82 %), +3.3.12 (the same runs, 12,181 of 12,203 lines) and 3.2.11 (4,016 / 73,834 / **5** skips, 96.09 % — the four `async_http` skips are absent because that gem's 3.3 floor excludes it from the 3.2 row). -Three explicit seeds — 1, 99991 and 424242 — give the same 4,191 / 74,584 / 9 on 4.0.6, and the four +Three explicit seeds — 1, 99991 and 424242 — give the same 4,204 / 74,610 / 9 on 4.0.6, and the four timing-sensitive files run ten times each under a shell `timeout` with identical counts every time. The two coverage figures are not interchangeable and were once transposed here: 90.95 % is the **3.2.11** row and 94.65 % the 4.0.6 one. **Every skip is named**: the seven phase-8 drivers' — @@ -5221,4 +5229,22 @@ Task 15 Step 3 asks for in as many words — the three `NFR` marks where the des WRONG, named as wrong (`P9-36`) — and the `gates:bounded_map` allowlist re-adjudication got the ledger row its reasons belonged in (`P9-37`). +**Review round 3, 2026-09-24.** The same failure mode again, and at its widest: **seven of +`InvariantSuite`'s twenty-eight assertions had no committed non-conforming double**, and five of them +— `XCUT-1`, `XCUT-2`, `XCUT-6`, `XCUT-7` and `XCUT-10` — could be neutralised WHOLE with the entire +`rake test:gems` green. Nothing in the repository drove a subject that made any of their clauses fail, +so each reported `:passed` because it could not report anything else, while the checklist told a +reader that every `XCUT` row had been driven against a deliberately non-conforming double and +`invariant_suite_core_test.rb`'s own header named three of those IDs among the ones it drove. In an +audit phase that is `R3`'s failure one level up: `R3` stops an assertion passing because its subject +was never built, and this was an assertion passing because its own falsifiability was never built. +Thirteen defective stand-in cores now drive all seven — the five plus `XCUT-19` and `XCUT-24`, whose +apparent coverage was an artefact of `-w` raising on a local the mutation orphaned — and neutralising +each assertion whole and warning-free reddens exactly the new tests for it (`P9-41`, rows 55–61). No +first-party code changed and none should have: the assertions were right and only the negative +controls were missing. `XCUT-3` and `XCUT-12` are still without one, deliberately and by name: a +double for either would have to stay in the wait past the assertion's own bound, which leaks the +thread the base case's teardown counts. Beside it the round recorded `R6`'s one sanctioned exception, +which the tree had carried since implementation and no document named (`P9-40`). + `main` is `582e33a` before and after this pass; nothing is pushed, and issue #33 stays open. diff --git a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md index 18985a5..e5fe7e5 100644 --- a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md +++ b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md @@ -9,7 +9,12 @@ disagree the tree wins and this document records it. **Phase 9 reports; phase 10 repairs (design `R6`).** Nothing outside `gems/dexpace-conformance/`, `tasks/`, `tools/`, `test/`, `.github/workflows/`, each adapter gem's `test/` tree and the root `Rakefile` -was changed, and no `lib/` or `sig/` file outside `gems/dexpace-conformance/` was touched at all. Every +was changed — **plus exactly one ADDED file under `gems/dexpace-core/test/`**, the first-party +`InvariantSuite` driver, with no existing core file modified, renamed or deleted. That is the one place +`R6`'s file list bends and it is recorded rather than left to be discovered: deviation 14 below, `P9-40`, +and the design's As-built addendum, which names the two sentences that cannot both hold. No `lib/` or +`sig/` file outside `gems/dexpace-conformance/` was touched at all, in any gem, and +`git diff --name-status main.. -- gems/dexpace-core` is that one path, status `A`. Every defect this phase found in another phase's code is routed with its evidence and left unrepaired — "Findings routed" below — because a phase that repairs what it found destroys the evidence that it was ever there. @@ -23,7 +28,7 @@ Plan: `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-confo are that plan's — seventeen numbered tasks plus Task 12a, eighteen headings. Design: `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md`, whose Deviation Ledger carries `P9-1`–`P9-10` and whose As-built addendum, written with this checklist, adds -**`P9-21`–`P9-39`** (the band starts at `P9-21`; `P9-11`–`P9-20` are left free and nothing is renumbered). +**`P9-21`–`P9-41`** (the band starts at `P9-21`; `P9-11`–`P9-20` are left free and nothing is renumbered). Test files are named with their gem: `conformance/…` is `gems/dexpace-conformance/test/dexpace/conformance/`, `core/…` is `gems/dexpace-core/test/dexpace/`, `gates/…` is `test/gates/`, `serde_json/…` is `gems/dexpace-serde-json/test/dexpace/serde/json/` and @@ -94,32 +99,42 @@ proves the assertion can fail at all; and **(c)** the same assertion driven agai `core/cross_cutting_invariants_test.rb`, the first-party driver. A row naming a mutation names one from "Guards run red" below, run against the real subject through (c). +**(b) holds for every `XCUT` row but two, and the two are named here rather than left to be found.** +`XCUT-3` and `XCUT-12` carry no committed non-conforming double: either would need a subject that stays +in the wait past the assertion's own half-second bound, which leaks a thread `DexpaceTestCase`'s +teardown counts, so their evidence is (a) + (c) + a hand-run SUBJECT mutation — 22 and 23 below. +**Thirteen doubles were added in review round 3** to make the sentence true of the other twenty-two +rows. Until then seven assertions had no (b) at all — `XCUT-1`, `XCUT-2`, `XCUT-6`, `XCUT-7`, `XCUT-10`, +`XCUT-19` and `XCUT-24` — and five of the seven could be neutralised WHOLE with `rake test:gems` green, +which is the difference between an assertion that holds and one that cannot report anything else +(deviation 15; `P9-41`). + | ID | Level | Status | Task(s) | What was built, and where it is proven | |---|---|---|---|---| -| `XCUT-1` | MUST | ✅ | 8, 16 | "a cancellation is terminal and never retried": the token's `#check!` surfaces `Dexpace::CancelledError`, `Policy.throwable_retryable?` answers false for it, and `Policy.cancellation?` still recognises it when an adapter's own retryable error wraps it — the third clause is the one a check reading only the outermost error would miss. Mutations 12 and 13 | -| `XCUT-2` | MUST | ✅ | 8, 16 | "a timeout is retryable and told apart from a cancellation by state": a `TransportError(phase: :read)` classifies retryable, the ambient flag stays clear, and a SUBTYPE of `CancelledError` is still recognised as a cancellation — the subtype clause is what makes "by the type, never by a message string" real | -| `XCUT-3` | MUST | ✅ | 8, 16 | "a pending inter-attempt wait is promptly cancellable": a thirty-second `Clock::SYSTEM#sleep` cancelled from another thread surfaces `CancelledError` inside half a second. The waiter must be **parked** when the cancel is issued, and that is asserted rather than assumed — without the barrier the cancel could beat the waiter into the wait and an implementation ignoring the token passed (`P9-28`). Mutation 22 | +| `XCUT-1` | MUST | ✅ | 8, 16 | "a cancellation is terminal and never retried": the token's `#check!` surfaces `Dexpace::CancelledError`, `Policy.throwable_retryable?` answers false for it, and `Policy.cancellation?` still recognises it when an adapter's own retryable error wraps it — the third clause is the one a check reading only the outermost error would miss. Two defective stand-in `Policy` cores — one reading only the outermost error, one calling a cancellation retryable. Mutations 12, 13 and 55 | +| `XCUT-2` | MUST | ✅ | 8, 16 | "a timeout is retryable and told apart from a cancellation by state": a `TransportError(phase: :read)` classifies retryable, the ambient flag stays clear, and a SUBTYPE of `CancelledError` is still recognised as a cancellation — the subtype clause is what makes "by the type, never by a message string" real. Two defective cores — one matching the cancellation type EXACTLY, so a subtype escapes, and one telling a timeout apart by its message. Mutation 56 | +| `XCUT-3` | MUST | ✅ | 8, 16 | "a pending inter-attempt wait is promptly cancellable": a thirty-second `Clock::SYSTEM#sleep` cancelled from another thread surfaces `CancelledError` inside half a second. The waiter must be **parked** when the cancel is issued, and that is asserted rather than assumed — without the barrier the cancel could beat the waiter into the wait and an implementation ignoring the token passed (`P9-28`). **No committed non-conforming double**, for the reason the evidence paragraph gives — a subject that stays in the wait leaks its thread — so the guard is mutation 22, run against the real `Clock` | | `XCUT-4` | MUST | ✅ | 6, 16 | "the error taxonomy has two branches and a transport error is I/O-family": `ProtocolError` carries a status and `TransportError < ::IOError` does not, and `Dexpace::Error` is a MODULE both reach through `Module#===` | | `XCUT-5` | MUST | ✅ | 6, 16 | "one shared status classifier decides the baked retryability flag": `Retryability.retryable_status?` is the one classifier and `ProtocolError#retryable_by_status?` reads it — never `#retryable?`, which is `XCUT-6`'s open capability (6a's `P6-10`) | -| `XCUT-6` | MUST | ✅ | 6, 16 | "a custom error's retryability capability is queried, not its type": `Policy.throwable_retryable?` probes `#retryable?` over every cause and never a class list, so a caller's own error type is honoured | -| `XCUT-7` | MUST | ✅ | 6, 16 | "the configured retryable-status set is authoritative and can widen or narrow": a set admitting 404 admits it and one refusing 408 refuses it, and the default set is the six `RETRY-13` fixes | +| `XCUT-6` | MUST | ✅ | 6, 16 | "a custom error's retryability capability is queried, not its type": `Policy.throwable_retryable?` probes `#retryable?` over every cause and never a class list, so a caller's own error type is honoured. Two defective cores — one reading only the outermost error, one matching a concrete TYPE. Mutation 57 | +| `XCUT-7` | MUST | ✅ | 6, 16 | "the configured retryable-status set is authoritative and can widen or narrow": a set admitting 404 admits it and one refusing 408 refuses it, and the default set is the six `RETRY-13` fixes. Two defective cores — a set ANDed with the baked classifier, which passes the narrowing half and fails the widening one, and a wrong default set. Mutation 58 | | `XCUT-8` | MUST | ✅ | 6, 16 | "the status-to-exception factory refuses a non-error status": `ProtocolError.for` raises `InvalidArgumentError` on a 200, `.for_or_nil` answers nil, and the same factory still maps a 503 — both forms in one assertion | | `XCUT-9` | MUST | ✅ | 6, 13, 16 | "the cause walk terminates on a cyclic chain, tracking by reference identity": a THREE-node cycle (two is indistinguishable from a depth-2 walk) driven through `Enumerator#next` under a step bound, so a non-terminating walk reports `:failed` rather than hanging. Beside it `gates:cause_walk`, the parsed scan that keeps the walk in one file. Mutation 24 | -| `XCUT-10` | MUST | ✅ | 6, 16 | "retry safety is decided from the request alone, uniformly": all four cases `XCUT-10` enumerates, plus the structural half — `Resend.eligible?`'s parameter list is exactly `[[:req, :request]]`, so there is no failure parameter to special-case on | +| `XCUT-10` | MUST | ✅ | 6, 16 | "retry safety is decided from the request alone, uniformly": all four cases `XCUT-10` enumerates, plus the structural half — `Resend.eligible?`'s parameter list is exactly `[[:req, :request]]`, so there is no failure parameter to special-case on. Two defective cores — a gate that re-sends a bare POST, and one taking a failure parameter. Mutation 59 | | `XCUT-11` | MUST | ✅ | 4, 8, 11, 16 | Two assertions plus a third in `ExecutorSuite`. Clause 1 is `SharedInstance.audit` over every shared instance the DRIVER declares (design `R8`, `P9-9`) and sixteen threads through one shared step with distinct requests; clause 2 is TWO FIBERS ON ONE THREAD, the only shape a lock held across a suspension point is visible in — Ruby's `Mutex` is per-fiber and non-reentrant — with the resulting `ThreadError` converted to a `Failure` so the status is `:failed` and not `:error`. The driver declares fifteen first-party shared instances and their ivars (`core/cross_cutting_invariants_test.rb`, "every shared instance core publishes"). Mutations 34, 35 and 44 | -| `XCUT-12` | SHOULD | ✅ | 8, 16 | "a credential cache's refresh is single-flight": sixteen threads race one `BearerStamper` over a parked provider and exactly one fetch happens. Every racer must be parked before the gate opens, asserted rather than assumed — without the barrier the first thread finished alone and a per-caller stamper passed (`P9-29`). Mutation 23. The FIBER-scheduler form is out: it needs a credential path under a reactor, which no first-party suite assembles — stated in the assertion and on phase 10's inbound list | +| `XCUT-12` | SHOULD | ✅ | 8, 16 | "a credential cache's refresh is single-flight": sixteen threads race one `BearerStamper` over a parked provider and exactly one fetch happens. Every racer must be parked before the gate opens, asserted rather than assumed — without the barrier the first thread finished alone and a per-caller stamper passed (`P9-29`). Mutation 23. The FIBER-scheduler form is out: it needs a credential path under a reactor, which no first-party suite assembles — stated in the assertion and on phase 10's inbound list. **No committed non-conforming double**, for the same reason as `XCUT-3`: sixteen racers that never finish leak sixteen threads. The guard is mutation 23, run against the real `BearerStamper` | | `XCUT-13` | MUST | ✅ | 5, 11, 16 | Two assertions plus `ExecutorSuite`'s: the latch is counted AT THE RESOURCE (`Closeable#close` answers nil on the winning and the losing call alike, so a return value proves nothing), and the close is bounded by elapsed monotonic time on BOTH calls — never by an interrupt, which §8.3 bans outright. Appendix C's third clause, "preserves the ambient interrupt/cancel flag as-is", is **scoped out with its reason** in `executor_suite/lifecycle.rb`'s own comment and asserted nowhere: §8.3 bans every primitive that could arrange a pending interrupt, so the clause holds by the flag never being touched and there is nothing observable to assert. Mutations 2 and 36 | | `XCUT-14` | MUST | ✅ | 7, 13, 16 | Two assertions plus `gates:bounded_map`. The cap clause reads the size after EVERY insert across 320 of them, so an implementation that overshoots and trims is caught; the DRAIN clause fills the backing store to cap + 5 — the state a concurrent overshoot leaves, arranged without a thread — and performs one `#set`: a drain loop ends at the cap, a check-then-evict at cap + 5, on every interpreter. Mutations 6 and 7 | | `XCUT-15` | MUST | ✅ | 5, 16 | "public wire models retain no external-mutable alias": the mutation is on the INNER array of the `values:` Hash, because a port that dup'd only the outer Hash passes a top-level check. Mutation 1 | | `XCUT-16` | MUST | ✅ | 7, 16 | All three clauses: the plaintext refusal is `Auth::HTTPSRequiredError` BY CLASS, an HTTPS request really is stamped (so the refusal is about the scheme and not a broken path), and a marker-suppressed cross-origin re-issue proceeds over `http` carrying no credential. Mutations 19 and 20 | | `XCUT-17` | MUST | ✅ | 7, 16 | All four clauses in one assertion, because a port can satisfy any three: `Authorization` stripped on a SAME-origin re-issue, all three origin-scoped headers on a cross-origin one, the `Location`'s userinfo gone, and an HTTPS→HTTP downgrade refused. Mutations 15–18 | | `XCUT-18` | MUST | ✅ | 7, 16 | Two assertions. The model layer asserts the ASYMMETRY — HTAB refused in a name and admitted in a value — which a port applying one rule to both passes with a single case. The call-site assertion drives a forged request (built through `send(:new)`, or a duck if that fails) at a real adapter and OBSERVES wire activity through a listener it owns: one accepted connection is the failure. Mutations 4 and 5. **Vacuous in the core driver, real in the aggregate run**: core ships no adapter, so `transport:` is nil there and the vacuity is accepted with its citation; Task 16's run supplies `Dexpace::Transport::NetHTTP.build` and the assertion passes for real | -| `XCUT-19` | MUST | ✅ | 7, 16 | Clauses (a)–(d): userinfo, a query value and a fragment token all gone from one URL while host and path survive; the header allow-list is default-DENY; and a credential reveals nothing in `#to_s`, `#inspect` or — checked STRUCTURALLY, because `pp` is outside this gem's require allowlist — `#pretty_print`, whose owner must be the credential's own class (`pp.rb` never consults an `#inspect` override on a `Data`). Appendix C's clause (e), "full request/response BODY logging MUST be OFF by default", is asserted nowhere in `InvariantSuite` and is **5b's own**, proven there: `HTTPLogging::DEFAULT` is `NONE`, and `Instrumentation::Step` constructs the two phase-3b logging wrappers at `HTTPLogging::BODY` alone — 5b's checklist carries it as clause (e) of its own `XCUT-19` cross-reference row, over the `OBS-34` row's subject (`docs/work/mvp/phase5/phase5b/2026-09-09-phase5b-logging-and-redaction-checklist.md`). `invariant_suite/security.rb` says so in place. Mutations 9 and 10 | +| `XCUT-19` | MUST | ✅ | 7, 16 | Clauses (a)–(d): userinfo, a query value and a fragment token all gone from one URL while host and path survive; the header allow-list is default-DENY; and a credential reveals nothing in `#to_s`, `#inspect` or — checked STRUCTURALLY, because `pp` is outside this gem's require allowlist — `#pretty_print`, whose owner must be the credential's own class (`pp.rb` never consults an `#inspect` override on a `Data`). Appendix C's clause (e), "full request/response BODY logging MUST be OFF by default", is asserted nowhere in `InvariantSuite` and is **5b's own**, proven there: `HTTPLogging::DEFAULT` is `NONE`, and `Instrumentation::Step` constructs the two phase-3b logging wrappers at `HTTPLogging::BODY` alone — 5b's checklist carries it as clause (e) of its own `XCUT-19` cross-reference row, over the `OBS-34` row's subject (`docs/work/mvp/phase5/phase5b/2026-09-09-phase5b-logging-and-redaction-checklist.md`). `invariant_suite/security.rb` says so in place. Two defective cores — a redactor forwarding the URL's userinfo, and a default-ALLOW header list. Mutations 9, 10 and 60 | | `XCUT-20` | MUST | ✅ | 7, 16 | Scoped exactly as 5c handed it forward and no wider: `Instrumentation.contain` swallows and reports, `Redactor#url` substitutes its marker for a malformed input, `#header_value` always answers a String, and `Preview.render` survives undecodable bytes. NOT extended to a foreign tracer or meter callback, which `OBS-20` carves out — a `rescue` there is a guard this suite must not demand. Mutation 11 | | `XCUT-21` | MUST | ✅ | 7, 16 | Two observations and no character count: the handler takes an injectable `cnonce_source:` (so its randomness can be audited at all), and bytes are DRAWN from the injected recorder and CARRIED into the rendering — each drawn byte flipped in turn, and at least sixteen must change the output. A character count would call a truncated 128-bit draw conforming. Mutation 21 | | `XCUT-22` | MUST | ✅ | 5, 11, 16 | Both halves, on two INDEPENDENT routes: the caller-supplied resource is not closed AND it still works, because a component that tore it down another way would pass the first alone. That second route is `Borrowed#finish`, added in review round 2 — until then `usable?` was the negation of the `closed?` the first Check reads, so the second could not fail on its own (`P9-38`). `ExecutorSuite`'s twin asserts the same over a real pool behind `Transport.async_over`, and posts work to it, which is an independent observation of its own. Mutations 3, 40 and 54 | | `XCUT-23` | MUST | ✅ | 8, 16 | Three ordered rules and the ORDERING is the content: zero candidates fail loudly, two candidates fail loudly, an explicit install beats a discovered one, and a single candidate is discovered. "Loud" is checked for its own reason — the message must name the explicit-install entry point. Mutations 21b and 25 | -| `XCUT-24` | SHOULD | ✅ | 7, 16 | Both subjects, because the halves live in different objects: 4b's error-body snapshot is the cap and 3b's logging tap is the non-consumption. A preview that capped but drained would pass the first alone. Mutation 8 | +| `XCUT-24` | SHOULD | ✅ | 7, 16 | Both subjects, because the halves live in different objects: 4b's error-body snapshot is the cap and 3b's logging tap is the non-consumption. A preview that capped but drained would pass the first alone. One defective core — a snapshot that multiplies its cap. Mutations 8 and 61 | | `NFR-1` | MUST | ✅ | 9, 15 | Asserted TWICE against two subjects (`P9-2`): phase 0's `gates:gemspec_audit` over the source gemspec, and `PackagingSuite`'s "core declares zero runtime dependencies" over the resolved `Gem::Specification` a consumer actually gets. `gates:require_allowlist` and `gates:clean_bundle` are the same claim's other two mechanisms and all four are green on every matrix row | | `NFR-2` | SHOULD | ✅ | 9, 15 | `PackagingSuite`'s "each adapter declares the core plus at most one library": `net_http` spends `net-http`, `async_http` spends `async-http`, `serde-json` spends `json`; `async-thread` and `conformance` spend none. Green against the built `.gem` files outside the bundle | | `NFR-3` | SHOULD | ✅ | 9, 15 | `PackagingSuite`'s "every shipped implementation file has a signature beside it" — the `sig/` mirror as a property of the PACKAGED gem, not of the repository — beside `rbs validate` and `steep check` over six targets, both green with no relaxation added by this phase | @@ -182,7 +197,8 @@ first-party drivers. - **Three gates** — `gates:cause_walk`, `gates:bounded_map`, `gates:seam_names`, over `tools/ast_scan.rb`'s `RubyVM::AbstractSyntaxTree` walker. Twenty-one blocking gates in all. - **`APPENDIX_B.md`** — the 61-row coverage map, generated and hand-annotated. -- **Three drivers** — `core/cross_cutting_invariants_test.rb`, +- **Three drivers** — `core/cross_cutting_invariants_test.rb` (the ONE file this phase adds under + `gems/dexpace-core/`, and the one place `R6`'s file list bends; `P9-40`), `serde_json/conformance_test.rb`, `async_thread/conformance_test.rb`. --- @@ -191,13 +207,18 @@ first-party drivers. The reviewer's mutation list could not be recovered verbatim after this session's context was compacted (`P9-32`), so the set below was re-derived from the design's testing strategy and the plan's own -"non-conforming double first" rule, one mutation per assertion, per gate and per piece of shared -machinery. **Fifty-three mutations, every one red** — forty-four at implementation, seven in **review -round 1** (rows 46–52) and two in **review round 2** (rows 53–54) — run one at a time through a harness -that applies the edit, runs the owning suite, captures the first failure and restores the file, on +"non-conforming double first" rule. **It was NOT one mutation per assertion, and saying so was this +document's own error**: review round 2 measured that `XCUT-2`, `XCUT-6`, `XCUT-7` and `XCUT-10` had no +row in this table at all and `XCUT-1` only subject rows, and review round 3's seven (rows 55–61) are +what close that. **Sixty mutations, every one red** — forty-four at implementation, seven in **review +round 1** (rows 46–52), two in **review round 2** (rows 53–54) and seven in **review round 3** +(rows 55–61) — run one at a time through a harness that applies the edit, runs the owning suite, +captures the first failure and restores the file, on **4.0.6** (row 52 on **3.2.11**, the row whose parser the fact differs on). The table below numbers -fifty-four slots because two are placeholders for a mutation that was re-cut rather than a mutation of -their own. Rows 53 and 54 are the two shapes that SURVIVED review round 1: they are recorded as what +sixty-one slots because two are placeholders for a mutation that was re-cut rather than a mutation of +their own. Rows 53 and 54 are the two shapes that SURVIVED review round 1, and rows 55–61 neutralise +one of this phase's OWN assertions whole rather than mutating a subject — the only shape that proves a +committed double can report `:failed`. All nine are recorded as what they were, a gap in this phase's own guards and not a gap in the code those guards watch. Six shapes were re-cut: one spun forever and was killed rather than counted (a finding in its own @@ -264,6 +285,13 @@ pass is worth its cost. | 52 | the duplicated-key capture is taken INSIDE the `$VERBOSE` window | `a duplicated key warns whatever $VERBOSE is` (3.2.11) | `XCUT-9`, `XCUT-14`, `SEAM-2` | | 53 | `ExecutorCase#shutdowns` counts every recorder payload instead of matching `Keys::EVENT` | `XCUT-13 => :failed, XCUT-22 => :failed, SEAM-25 => :failed` against the chatty-but-conforming pool | `SEAM-25`, `XCUT-13`, `XCUT-22` | | 54 | `Models#only_closes_what_it_created`'s second `Check` takes a literal `true` | `a holder that tears a borrowed resource down without closing it fails XCUT-22 / Expected: [:failed], Actual: [:passed]` | `XCUT-22` | +| 55 | `Concurrency#cancellation_is_terminal` neutralised WHOLE — `return nil if subject` as the body's first line, so no local is orphaned and the `-w` hook cannot mask the result | both new `XCUT-1` doubles: `a cancellation classifier that reads only the outermost error fails XCUT-1` and `a classifier that calls a cancellation retryable fails XCUT-1`, `Expected: [:failed]` | `XCUT-1` | +| 56 | `Concurrency#timeout_is_retryable_and_distinct` neutralised whole | both new `XCUT-2` doubles: the EXACT-type match and the message-matching classifier, `Expected: [:failed]` | `XCUT-2` | +| 57 | `Taxonomy#open_capability_query` neutralised whole | both new `XCUT-6` doubles: the outermost-only query and the concrete-type match, `Expected: [:failed]` | `XCUT-6` | +| 58 | `Taxonomy#configurable_status_set_is_authoritative` neutralised whole | both new `XCUT-7` doubles: the ANDed set and the wrong default, `Expected: [:failed]` | `XCUT-7` | +| 59 | `Classification#retry_safety_is_uniform` neutralised whole | both new `XCUT-10` doubles: the re-sent bare POST and the failure parameter, `Expected: [:failed]` | `XCUT-10` | +| 60 | `Security#redaction_is_default_deny` neutralised whole | both new `XCUT-19` doubles: the userinfo-forwarding redactor and the default-ALLOW header list, `Expected: [:failed]` | `XCUT-19` | +| 61 | `Memory#capped_non_consuming_preview` neutralised whole | the new `XCUT-24` double: a snapshot that multiplies its cap, `Expected: [:failed]` | `XCUT-24` | One more shape was cut and then **killed rather than counted**: conditioning `Registry#resolve`'s `return hand_out(resolved)` guard on anything else turns its `loop do … end` into an unbounded spin with @@ -315,7 +343,7 @@ none roll-up only, so nothing here had to be read out of the specification inste ## Deviations from the plan Departures from the plan's text, each with its reason. None lowers, disables or narrows a gate. The -corresponding ledger rows are `P9-21`–`P9-39` in the design's As-built addendum. +corresponding ledger rows are `P9-21`–`P9-41` in the design's As-built addendum. 1. **`dexpace-serde-json`'s and `dexpace-core`'s existing files are byte-identical** — the plan's Task 10 removes `assert_closes_nothing`'s and `assert_failure_model`'s portable halves from 7a's @@ -418,6 +446,33 @@ the brief's own minimum mutation list that review round 1 measured as SURVIVING. `SEAM-25` together. No code changed: the code was right and only the guard was missing. Row 53. `P9-39`. +### Review round 3, 2026-09-24 + +Two departures, one of them a document repair and the other the same class of defect rounds 1 and 2 +found: a guard this phase wrote that could not go red. + +14. **`R6`'s one sanctioned exception was recorded nowhere, and two sentences could not both hold.** + The driver for `InvariantSuite` is `gems/dexpace-core/test/dexpace/cross_cutting_invariants_test.rb` + — one ADDED file, no existing core file modified, renamed or deleted — and that is what design + `P9-9` means by "the driver is `dexpace-core`'s own suite and knows which ivar is which". The + design's Module layout says the opposite in as many words ("no file in `gems/dexpace-core/` at + all"), and this checklist's and the roadmap's boundary sentences enumerated the trees phase 9 + changed without naming it. All three now name the file and the narrow bend it is, and `P9-40` + records the contradiction by pointing at both sentences. Nothing in the tree changed: the boundary + was always one added test file, and a reader could only learn that by running + `git diff --name-status`. `P9-40`. +15. **Seven `XCUT` assertions had no committed non-conforming double, and five could be neutralised + WHOLE with the suite green.** `XCUT-1`, `XCUT-2`, `XCUT-6`, `XCUT-7`, `XCUT-10`, `XCUT-19` and + `XCUT-24`: nothing in the repository drove a subject that made any clause of the first five fail, + so each reported `:passed` because it could not report anything else, and the evidence paragraph + above and the Guards preamble both claimed otherwise. `invariant_suite_core_test.rb`'s own header + made it worse by naming `XCUT-6`, `XCUT-7` and `XCUT-10` among the IDs it drove. Thirteen defective + stand-in cores now drive all seven, in the shape that file already used — a patched `Resilience` + whose `const_missing` falls through, so one test changes one subject. Measured: neutralising each + of the seven assertions whole and warning-free reddens exactly the new tests for it, thirteen + across the seven (rows 55–61). No first-party code changed: the assertions were right and only the + negative controls were missing. `P9-41`. + --- ## Findings routed diff --git a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md index 6a62008..1d856d9 100644 --- a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md +++ b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md @@ -1083,10 +1083,11 @@ This phase was cut from `main` at `582e33a`, which holds every phase through 8. below, numbered from **P9-21** as the brief fixes it; `P9-11`–`P9-20` are left free and nothing is renumbered. The checklist's "Deviations from the plan" is the itemised list against the plan's text; the rows here are the ones that touch public behaviour, the contract a later phase cites, or a statement this -document makes. `P9-34` and `P9-35` were added in **review round 1**, on 2026-09-24, and `P9-36` -through `P9-39` in **review round 2**, on the same day; each is marked as such in its own row, and +document makes. `P9-34` and `P9-35` were added in **review round 1**, on 2026-09-24, `P9-36` +through `P9-39` in **review round 2**, and `P9-40` and `P9-41` in **review round 3**, all on the same +day; each is marked as such in its own row, and nothing earlier is rewritten except `P9-32`'s count, which was wrong as written and is corrected -twice for the same reason. +three times for the same reason. | # | Deviation | Requirement / document | Why | |---|---|---|---| @@ -1109,6 +1110,8 @@ twice for the same reason. | P9-37 | `gates:bounded_map`'s allowlist is **re-adjudicated against as-built paths**, ships at six entries, and the integrity test gains the clause that would have caught the stale one | `XCUT-14`; plan Task 13 | The plan filed four keys. One — `gems/dexpace-core/lib/dexpace/configuration.rb` — silenced nothing, because 5a's two accumulators live in `configuration/builder.rb`; a key that silences nothing passes the plan's own "every key names a live file" test, which is exactly how a stale entry survives an adjudication. The scan reports six sites the gate must not fail on: `bounded_map.rb`, `instrumentation/event.rb` and `conformance/recording_span.rb` as filed, plus `configuration/builder.rb`, `auth/challenges.rb` (one parse's accumulator inside a `private_constant Parser`, reset at every `open_challenge` and dropped with the parse — `event.rb`'s lifetime argument) and `async_http/clients.rb` (`@by_key`, drained inside the insert's own `synchronize`). The rule is NOT narrowed and the keyed-read narrowing stays rejected: six is far short of the dozen open question 2 names as the point where the gate would be telling us the invariant is not held. The integrity test now asserts each key names a live file AND that the scan still reports a Hash ivar there | | P9-38 | `InvariantSuite::Models::Borrowed` carries a **second, independent teardown route**, so `XCUT-22`'s "still usable" `Check` can fail | `XCUT-22` | Review round 2. `usable?` was `!@closed`, the exact negation of the `closed?` the FIRST `Check` already asserts, so no subject the suite can build separated the two: replacing the second `Check`'s condition with a literal `true` left the whole gem's suite green, while the code comment and the checklist row both claimed it caught a component that tore the resource down another way. `Borrowed#finish` is that other way — `Net::HTTP` spells its teardown exactly that, a pooled client spells it "retire", and an adapter tearing down a caller's SOCKET rather than the caller's client leaves `closed?` false — and `usable?` now reads both flags. `TearingSeam` drives it. The `ExecutorSuite` twin never had the problem: it posts work to the underlying executor after the holder's close, which is an observation of its own | | P9-39 | `ExecutorCase#shutdowns`' event-NAME match is given a double that logs something **besides** its shutdown | `SEAM-25`, `XCUT-13`, `XCUT-22` | Review round 2. The count matches the payload's `Keys::EVENT` entry, but every double in `executor_suite_test.rb` emitted the shutdown payload and nothing else, so nothing separated "count the shutdown events" from "count the sink's writes" — and replacing the match with a bare `entries.count` left the file, the pool driver and the whole `rake test:gems` green, with four assertions reading that count. An executor that logged one unrelated line and emitted no shutdown would have passed `SEAM-25`; a conforming one that logged anything at all would have failed `XCUT-22`. `ChattyPool` emits one other event payload and one bare String message at construction, which nothing in the SPI forbids and which a real adapter's logger carries plenty of. No code changed — the code was right and only the guard was missing | +| P9-40 | The audit's own driver is **one added file under `gems/dexpace-core/test/`**, and the Module layout's file list above is wrong where it says otherwise | `R6`; `P9-9`; the Module layout | Review round 3. Two sentences in this document cannot both hold: the Module layout says "**no file in `gems/dexpace-core/` at all**", and `P9-9` says "the driver is `dexpace-core`'s own suite and knows which ivar is which". The second is the one that survives, because the seven subjects `InvariantSuite` cannot name itself — the seam, the transport, the bounded map and its store, the cnonce draw, the redirect hop and the credential hop — are core's own objects, and core's test-support doubles are reachable from core's suite and from nowhere else; 8a's precedent already puts a driver in the tree whose objects it drives. The bend is as narrow as it can be: **`gems/dexpace-core/test/dexpace/cross_cutting_invariants_test.rb` is ADDED and nothing else under `gems/dexpace-core/` is modified, renamed or deleted** — not a test, not a support double, not a manifest row, and above all nothing under `lib/` or `sig/`. `R6` exists so that phase 9 does not REPAIR what it audits, and an added test file repairs nothing. `git diff --name-status main.. -- gems/dexpace-core` is one path, status `A`, which is the mechanical statement of it. The Module layout's sentence is left standing rather than quietly rewritten: this row IS the correction, and an edited file list would leave the next reader unable to tell which of the two was decided. The checklist's and the roadmap's boundary sentences name the file too | +| P9-41 | Seven `XCUT` assertions shipped with **no committed non-conforming double**, and five of them could be neutralised WHOLE with the suite green | `XCUT-1`, `XCUT-2`, `XCUT-6`, `XCUT-7`, `XCUT-10`, `XCUT-19`, `XCUT-24`; `R3` | Review round 3, and the same class of defect rounds 1 and 2 found — a guard this phase wrote that could not go red — at its widest. Nothing in the repository drove a subject that made any clause of `XCUT-1`, `XCUT-2`, `XCUT-6`, `XCUT-7` or `XCUT-10` fail, so each reported `:passed` because it could not report anything else; `XCUT-19`'s and `XCUT-24`'s clauses survived a literal-`true` replacement for the same reason, their apparent coverage being an artefact of `-w` raising on an orphaned local rather than of any assertion. `invariant_suite_core_test.rb`'s own header named `XCUT-6`, `XCUT-7` and `XCUT-10` among the IDs it drove, and the checklist claimed the (b) evidence universally. **In an audit phase this is `R3`'s own failure one level up**: `R3` stops an assertion passing because its subject was never built, and this is an assertion passing because its own falsifiability was never built. Thirteen defective stand-in cores now drive all seven, in the shape that file already used — a patched `Resilience` or `Instrumentation` whose `const_missing` falls through to the real core, so one test changes one subject. Measured: neutralising each of the seven assertions whole and warning-free (`return nil if subject` as the body's first line, so no local is orphaned and `NFR-6`'s hook cannot mask the result) reddens exactly the new tests for it, thirteen across the seven. No first-party code changed and none should have: the assertions were right and only the negative controls were missing. `XCUT-3` and `XCUT-12` are left without one deliberately, and the checklist names them — a double for either would have to stay in the wait past the assertion's own bound, which leaks the thread the base case's teardown counts | --- From 5ec0909e3a30387c2ce9d142dcebbd27acbc7436 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 24 Sep 2026 16:51:46 +0300 Subject: [PATCH 15/17] docs: name the fourth survivor and correct two counts in the record Review round 3's sweep neutralised all forty-five assertion bodies in the four suites one at a time and left four green. Three were accounted for -- XCUT-3's and XCUT-12's, which this checklist names, and a private clause helper whose parent assertion is caught. The fourth, XCUT-18's model-layer assertion, was named nowhere. The claim that covered it could not see it because it was counted in ROWS, and XCUT-18 carries two assertions, so the row was covered on one of them. The evidence paragraph now counts assertions, names the sweep as what makes it checkable, and says which of XCUT-18's two assertions each piece of evidence covers. Ledger row P9-42, deviation 16, and the mutation table gains rows 62 and 63 -- the total moves to sixty-two in all four places that state it. Two counts were wrong in the same direction, coverage being wider than claimed. The core driver's own factory returns TWENTY shared instances, not fifteen: eighteen frozen, which conform on frozen? alone, and two unfrozen that declare exactly what they hold. The checklist's XCUT-11 row and the as-built conformance page both said fifteen; both now read off the factory. Design R8's nine is a different list -- the subjects it names across five phase documents -- and stands. The stale "not counted among the fifty-three" is sixty-two. --- docs/sdk-documentation/conformance.md | 3 +- .../2026-09-05-ruby-sdk-v1-roadmap-design.md | 19 ++++- ...ng-invariants-and-conformance-checklist.md | 69 +++++++++++++------ ...tting-invariants-and-conformance-design.md | 11 +-- 4 files changed, 73 insertions(+), 29 deletions(-) diff --git a/docs/sdk-documentation/conformance.md b/docs/sdk-documentation/conformance.md index 0ef98ae..252e0cb 100644 --- a/docs/sdk-documentation/conformance.md +++ b/docs/sdk-documentation/conformance.md @@ -469,7 +469,8 @@ four — so a driver for one of phase 9's suites calls `.run` (or drives `suite. asserts on the `Report`: - `gems/dexpace-core/test/dexpace/cross_cutting_invariants_test.rb` — `InvariantSuite` against core, - with the fifteen shared instances it declares and the six factories the suite cannot build. + with every shared instance core publishes (twenty at this tip) and the six factories the suite + cannot build. - `gems/dexpace-serde-json/test/dexpace/serde/json/conformance_test.rb` — `CodecSuite`, driven again under every option the adapter accepts. - `gems/dexpace-async-thread/test/dexpace/async/thread/conformance_test.rb` — `ExecutorSuite`, with diff --git a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md index 60cf7ad..1f5836c 100644 --- a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md @@ -5153,12 +5153,13 @@ two stated residues, 7a's `SEAM-21` evidence living inside another assertion's b `tools/surface.rb`'s blindness to a `private_constant` module, and `PackagingCase`'s default constant-path rule. -**Guards.** Sixty mutations were run one at a time through a harness that applies the edit, runs +**Guards.** Sixty-two mutations were run one at a time through a harness that applies the edit, runs the owning suite, captures the first failure and restores the file — **every one red**, each recorded in the checklist with the message it produced: forty-four at implementation, seven in review round 1, -two in review round 2 — shapes that SURVIVED round 1 rather than shapes newly thought of — and seven +two in review round 2 — shapes that SURVIVED round 1 rather than shapes newly thought of — seven in review round 3, which neutralise one of this phase's OWN assertions whole rather than mutating a -subject, the only shape that proves a committed double can report `:failed`. The checklist's earlier +subject, the only shape that proves a committed double can report `:failed`, and two in review round 4 +for the last assertion that had none. The checklist's earlier claim of "one mutation per assertion" was simply untrue and is corrected there with the rows that were missing. Six shapes were re-cut: one spun forever and was killed rather than counted, which is itself the `Registry#resolve` finding below, and five went green — and @@ -5247,4 +5248,16 @@ double for either would have to stay in the wait past the assertion's own bound, thread the base case's teardown counts. Beside it the round recorded `R6`'s one sanctioned exception, which the tree had carried since implementation and no document named (`P9-40`). +**Review round 4** closed the last of that class. Round 3's sweep neutralised all forty-five assertion +bodies in the four suites one at a time and left four green; three were accounted for by the checklist +or are a private clause helper whose parent assertion is caught, and the fourth — `XCUT-18`'s +model-layer `header_syntax_validation` — was named nowhere and could be neutralised whole with the +whole of `rake test:gems` green. The claim that covered it counted **rows**, and `XCUT-18` carries two +assertions, so the row was covered on one of them; the checklist's evidence paragraph now counts +assertions and names the sweep as what makes it checkable. Two defective cores drive it — a permissive +validator, and one applying the NAME rule to outbound values as well, so the name/value asymmetry has a +control of its own (`P9-42`, rows 62–63). The round also corrected two counts in the record: the core +driver declares **twenty** shared instances, not fifteen, in the checklist's `XCUT-11` row and in +`docs/sdk-documentation/conformance.md`; design `R8`'s nine is a different list and stands. + `main` is `582e33a` before and after this pass; nothing is pushed, and issue #33 stays open. diff --git a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md index e5fe7e5..c2ac488 100644 --- a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md +++ b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md @@ -28,7 +28,7 @@ Plan: `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-confo are that plan's — seventeen numbered tasks plus Task 12a, eighteen headings. Design: `docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md`, whose Deviation Ledger carries `P9-1`–`P9-10` and whose As-built addendum, written with this checklist, adds -**`P9-21`–`P9-41`** (the band starts at `P9-21`; `P9-11`–`P9-20` are left free and nothing is renumbered). +**`P9-21`–`P9-42`** (the band starts at `P9-21`; `P9-11`–`P9-20` are left free and nothing is renumbered). Test files are named with their gem: `conformance/…` is `gems/dexpace-conformance/test/dexpace/conformance/`, `core/…` is `gems/dexpace-core/test/dexpace/`, `gates/…` is `test/gates/`, `serde_json/…` is `gems/dexpace-serde-json/test/dexpace/serde/json/` and @@ -99,13 +99,20 @@ proves the assertion can fail at all; and **(c)** the same assertion driven agai `core/cross_cutting_invariants_test.rb`, the first-party driver. A row naming a mutation names one from "Guards run red" below, run against the real subject through (c). -**(b) holds for every `XCUT` row but two, and the two are named here rather than left to be found.** -`XCUT-3` and `XCUT-12` carry no committed non-conforming double: either would need a subject that stays -in the wait past the assertion's own half-second bound, which leaks a thread `DexpaceTestCase`'s -teardown counts, so their evidence is (a) + (c) + a hand-run SUBJECT mutation — 22 and 23 below. -**Thirteen doubles were added in review round 3** to make the sentence true of the other twenty-two -rows. Until then seven assertions had no (b) at all — `XCUT-1`, `XCUT-2`, `XCUT-6`, `XCUT-7`, `XCUT-10`, -`XCUT-19` and `XCUT-24` — and five of the seven could be neutralised WHOLE with `rake test:gems` green, +**(b) holds for every `XCUT` ASSERTION but two, and the two are named here rather than left to be +found.** The unit is the assertion and not the row, which is review round 3's correction: an ID appears +once however many assertions carry it, so a row can be covered on one assertion and bare on the other. +`XCUT-18` was exactly that, and the bare one could be neutralised whole with the whole of +`rake test:gems` green (deviation 16; `P9-42`). What makes the sentence checkable rather than asserted +is the sweep behind it: every assertion body in the four suites neutralised whole, one at a time, run +in review round 3 over all forty-five and leaving four green — `XCUT-3`'s, `XCUT-12`'s, `XCUT-18`'s +model layer and one private clause helper whose parent assertion is caught. Round 4 closed `XCUT-18`'s. +The two that remain, `XCUT-3`'s and `XCUT-12`'s, carry no committed non-conforming double: either would +need a subject that stays in the wait past the assertion's own half-second bound, which leaks a thread +`DexpaceTestCase`'s teardown counts, so their evidence is (a) + (c) + a hand-run SUBJECT mutation — 22 +and 23 below. **Thirteen doubles were added in review round 3 and two more in round 4.** Until then +seven assertions had no (b) at all — `XCUT-1`, `XCUT-2`, `XCUT-6`, `XCUT-7`, `XCUT-10`, `XCUT-19` and +`XCUT-24` — and five of the seven could be neutralised WHOLE with `rake test:gems` green, which is the difference between an assertion that holds and one that cannot report anything else (deviation 15; `P9-41`). @@ -121,14 +128,14 @@ which is the difference between an assertion that holds and one that cannot repo | `XCUT-8` | MUST | ✅ | 6, 16 | "the status-to-exception factory refuses a non-error status": `ProtocolError.for` raises `InvalidArgumentError` on a 200, `.for_or_nil` answers nil, and the same factory still maps a 503 — both forms in one assertion | | `XCUT-9` | MUST | ✅ | 6, 13, 16 | "the cause walk terminates on a cyclic chain, tracking by reference identity": a THREE-node cycle (two is indistinguishable from a depth-2 walk) driven through `Enumerator#next` under a step bound, so a non-terminating walk reports `:failed` rather than hanging. Beside it `gates:cause_walk`, the parsed scan that keeps the walk in one file. Mutation 24 | | `XCUT-10` | MUST | ✅ | 6, 16 | "retry safety is decided from the request alone, uniformly": all four cases `XCUT-10` enumerates, plus the structural half — `Resend.eligible?`'s parameter list is exactly `[[:req, :request]]`, so there is no failure parameter to special-case on. Two defective cores — a gate that re-sends a bare POST, and one taking a failure parameter. Mutation 59 | -| `XCUT-11` | MUST | ✅ | 4, 8, 11, 16 | Two assertions plus a third in `ExecutorSuite`. Clause 1 is `SharedInstance.audit` over every shared instance the DRIVER declares (design `R8`, `P9-9`) and sixteen threads through one shared step with distinct requests; clause 2 is TWO FIBERS ON ONE THREAD, the only shape a lock held across a suspension point is visible in — Ruby's `Mutex` is per-fiber and non-reentrant — with the resulting `ThreadError` converted to a `Failure` so the status is `:failed` and not `:error`. The driver declares fifteen first-party shared instances and their ivars (`core/cross_cutting_invariants_test.rb`, "every shared instance core publishes"). Mutations 34, 35 and 44 | +| `XCUT-11` | MUST | ✅ | 4, 8, 11, 16 | Two assertions plus a third in `ExecutorSuite`. Clause 1 is `SharedInstance.audit` over every shared instance the DRIVER declares (design `R8`, `P9-9`) and sixteen threads through one shared step with distinct requests; clause 2 is TWO FIBERS ON ONE THREAD, the only shape a lock held across a suspension point is visible in — Ruby's `Mutex` is per-fiber and non-reentrant — with the resulting `ThreadError` converted to a `Failure` so the status is `:failed` and not `:error`. The driver declares **every shared instance core publishes**, each with the ivars it is allowed to hold — twenty at this tip: eighteen frozen, which conform on `frozen?` alone whatever they hold (seven of them do hold state), and two unfrozen, `ContextStore.default` and `Pipeline.standard`, which declare exactly what they hold (`core/cross_cutting_invariants_test.rb`, "every shared instance core publishes"). The number is read off that factory rather than kept by hand here; design `R8`'s **nine** is a different list — the subjects it names across five phase documents — and both are right. Mutations 34, 35 and 44 | | `XCUT-12` | SHOULD | ✅ | 8, 16 | "a credential cache's refresh is single-flight": sixteen threads race one `BearerStamper` over a parked provider and exactly one fetch happens. Every racer must be parked before the gate opens, asserted rather than assumed — without the barrier the first thread finished alone and a per-caller stamper passed (`P9-29`). Mutation 23. The FIBER-scheduler form is out: it needs a credential path under a reactor, which no first-party suite assembles — stated in the assertion and on phase 10's inbound list. **No committed non-conforming double**, for the same reason as `XCUT-3`: sixteen racers that never finish leak sixteen threads. The guard is mutation 23, run against the real `BearerStamper` | | `XCUT-13` | MUST | ✅ | 5, 11, 16 | Two assertions plus `ExecutorSuite`'s: the latch is counted AT THE RESOURCE (`Closeable#close` answers nil on the winning and the losing call alike, so a return value proves nothing), and the close is bounded by elapsed monotonic time on BOTH calls — never by an interrupt, which §8.3 bans outright. Appendix C's third clause, "preserves the ambient interrupt/cancel flag as-is", is **scoped out with its reason** in `executor_suite/lifecycle.rb`'s own comment and asserted nowhere: §8.3 bans every primitive that could arrange a pending interrupt, so the clause holds by the flag never being touched and there is nothing observable to assert. Mutations 2 and 36 | | `XCUT-14` | MUST | ✅ | 7, 13, 16 | Two assertions plus `gates:bounded_map`. The cap clause reads the size after EVERY insert across 320 of them, so an implementation that overshoots and trims is caught; the DRAIN clause fills the backing store to cap + 5 — the state a concurrent overshoot leaves, arranged without a thread — and performs one `#set`: a drain loop ends at the cap, a check-then-evict at cap + 5, on every interpreter. Mutations 6 and 7 | | `XCUT-15` | MUST | ✅ | 5, 16 | "public wire models retain no external-mutable alias": the mutation is on the INNER array of the `values:` Hash, because a port that dup'd only the outer Hash passes a top-level check. Mutation 1 | | `XCUT-16` | MUST | ✅ | 7, 16 | All three clauses: the plaintext refusal is `Auth::HTTPSRequiredError` BY CLASS, an HTTPS request really is stamped (so the refusal is about the scheme and not a broken path), and a marker-suppressed cross-origin re-issue proceeds over `http` carrying no credential. Mutations 19 and 20 | | `XCUT-17` | MUST | ✅ | 7, 16 | All four clauses in one assertion, because a port can satisfy any three: `Authorization` stripped on a SAME-origin re-issue, all three origin-scoped headers on a cross-origin one, the `Location`'s userinfo gone, and an HTTPS→HTTP downgrade refused. Mutations 15–18 | -| `XCUT-18` | MUST | ✅ | 7, 16 | Two assertions. The model layer asserts the ASYMMETRY — HTAB refused in a name and admitted in a value — which a port applying one rule to both passes with a single case. The call-site assertion drives a forged request (built through `send(:new)`, or a duck if that fails) at a real adapter and OBSERVES wire activity through a listener it owns: one accepted connection is the failure. Mutations 4 and 5. **Vacuous in the core driver, real in the aggregate run**: core ships no adapter, so `transport:` is nil there and the vacuity is accepted with its citation; Task 16's run supplies `Dexpace::Transport::NetHTTP.build` and the assertion passes for real | +| `XCUT-18` | MUST | ✅ | 7, 16 | Two assertions. The model layer asserts the ASYMMETRY — HTAB refused in a name and admitted in a value — which a port applying one rule to both passes with a single case. The call-site assertion drives a forged request (built through `send(:new)`, or a duck if that fails) at a real adapter and OBSERVES wire activity through a listener it owns: one accepted connection is the failure. **Each assertion carries its own (b)**, which is the distinction review round 3 forced: the model layer's are a permissive validator and one applying the NAME rule to outbound values as well, added in review round 4 and proven by mutations 62 and 63 — until then this was the one assertion in the suite that could be neutralised whole with `rake test:gems` green (deviation 16), and the call site's are the two transports in `conformance/invariant_suite_core_test.rb`. Mutations 4 and 5 are the hand-run SUBJECT mutations of core's own `HeaderSyntax`, one per side of the asymmetry. **Vacuous in the core driver, real in the aggregate run**: core ships no adapter, so `transport:` is nil there and the vacuity is accepted with its citation; Task 16's run supplies `Dexpace::Transport::NetHTTP.build` and the assertion passes for real | | `XCUT-19` | MUST | ✅ | 7, 16 | Clauses (a)–(d): userinfo, a query value and a fragment token all gone from one URL while host and path survive; the header allow-list is default-DENY; and a credential reveals nothing in `#to_s`, `#inspect` or — checked STRUCTURALLY, because `pp` is outside this gem's require allowlist — `#pretty_print`, whose owner must be the credential's own class (`pp.rb` never consults an `#inspect` override on a `Data`). Appendix C's clause (e), "full request/response BODY logging MUST be OFF by default", is asserted nowhere in `InvariantSuite` and is **5b's own**, proven there: `HTTPLogging::DEFAULT` is `NONE`, and `Instrumentation::Step` constructs the two phase-3b logging wrappers at `HTTPLogging::BODY` alone — 5b's checklist carries it as clause (e) of its own `XCUT-19` cross-reference row, over the `OBS-34` row's subject (`docs/work/mvp/phase5/phase5b/2026-09-09-phase5b-logging-and-redaction-checklist.md`). `invariant_suite/security.rb` says so in place. Two defective cores — a redactor forwarding the URL's userinfo, and a default-ALLOW header list. Mutations 9, 10 and 60 | | `XCUT-20` | MUST | ✅ | 7, 16 | Scoped exactly as 5c handed it forward and no wider: `Instrumentation.contain` swallows and reports, `Redactor#url` substitutes its marker for a malformed input, `#header_value` always answers a String, and `Preview.render` survives undecodable bytes. NOT extended to a foreign tracer or meter callback, which `OBS-20` carves out — a `rescue` there is a guard this suite must not demand. Mutation 11 | | `XCUT-21` | MUST | ✅ | 7, 16 | Two observations and no character count: the handler takes an injectable `cnonce_source:` (so its randomness can be audited at all), and bytes are DRAWN from the injected recorder and CARRIED into the rendering — each drawn byte flipped in turn, and at least sixteen must change the output. A character count would call a truncated 128-bit draw conforming. Mutation 21 | @@ -210,15 +217,16 @@ The reviewer's mutation list could not be recovered verbatim after this session' "non-conforming double first" rule. **It was NOT one mutation per assertion, and saying so was this document's own error**: review round 2 measured that `XCUT-2`, `XCUT-6`, `XCUT-7` and `XCUT-10` had no row in this table at all and `XCUT-1` only subject rows, and review round 3's seven (rows 55–61) are -what close that. **Sixty mutations, every one red** — forty-four at implementation, seven in **review -round 1** (rows 46–52), two in **review round 2** (rows 53–54) and seven in **review round 3** -(rows 55–61) — run one at a time through a harness that applies the edit, runs the owning suite, -captures the first failure and restores the file, on +what close that. **Sixty-two mutations, every one red** — forty-four at implementation, seven in +**review round 1** (rows 46–52), two in **review round 2** (rows 53–54), seven in **review round 3** +(rows 55–61) and two in **review round 4** (rows 62–63) — run one at a time through a harness that +applies the edit, runs the owning suite, captures the first failure and restores the file, on **4.0.6** (row 52 on **3.2.11**, the row whose parser the fact differs on). The table below numbers -sixty-one slots because two are placeholders for a mutation that was re-cut rather than a mutation of -their own. Rows 53 and 54 are the two shapes that SURVIVED review round 1, and rows 55–61 neutralise -one of this phase's OWN assertions whole rather than mutating a subject — the only shape that proves a -committed double can report `:failed`. All nine are recorded as what +sixty-three slots because two are placeholders for a mutation that was re-cut rather than a mutation of +their own. Rows 53 and 54 are the two shapes that SURVIVED review round 1; rows 55–61 and row 62 +neutralise one of this phase's OWN assertions whole rather than mutating a subject — the only shape +that proves a committed double can report `:failed` — and row 63 removes a single `Check` from one, so +`XCUT-18`'s name/value asymmetry has a control of its own. All eleven are recorded as what they were, a gap in this phase's own guards and not a gap in the code those guards watch. Six shapes were re-cut: one spun forever and was killed rather than counted (a finding in its own @@ -292,6 +300,8 @@ pass is worth its cost. | 59 | `Classification#retry_safety_is_uniform` neutralised whole | both new `XCUT-10` doubles: the re-sent bare POST and the failure parameter, `Expected: [:failed]` | `XCUT-10` | | 60 | `Security#redaction_is_default_deny` neutralised whole | both new `XCUT-19` doubles: the userinfo-forwarding redactor and the default-ALLOW header list, `Expected: [:failed]` | `XCUT-19` | | 61 | `Memory#capped_non_consuming_preview` neutralised whole | the new `XCUT-24` double: a snapshot that multiplies its cap, `Expected: [:failed]` | `XCUT-24` | +| 62 | `HeaderSyntax#header_syntax_validation` neutralised whole — `XCUT-18`'s MODEL layer, the fourth survivor of round 3's sweep and the one no document named | both new model-layer doubles: `a validator that refuses nothing fails XCUT-18's model-layer assertion` and `an outbound-value rule identical to the NAME rule fails XCUT-18's HTAB clause`, `Expected: [:failed, :vacuous]` | `XCUT-18` | +| 63 | `HeaderSyntax#check_values` drops its HTAB-ACCEPTED clause, leaving the outbound-value rule a subset of the name rule | the symmetric double ALONE: `an outbound-value rule identical to the NAME rule fails XCUT-18's HTAB clause`. The permissive double still fails on the NAME clauses, which is why the asymmetry needs a second one | `XCUT-18` | One more shape was cut and then **killed rather than counted**: conditioning `Registry#resolve`'s `return hand_out(resolved)` guard on anything else turns its `loop do … end` into an unbounded spin with @@ -305,7 +315,7 @@ parked when `timeout 90` killed it, and the same removal at the two join sites i Ruby's own deadlock detector, as `fatal: No live threads left. Deadlock?`. That asymmetry is why rows 47 and 48 drop the `Check` and keep the bounded join: a mutation the interpreter aborts proves the join runs, not that an expired one is reported. The hang itself is the defect `P9-34` fixes, demonstrated; it -is not a guard going red and it is not counted among the fifty-three. +is not a guard going red and it is not counted among the sixty-two. Beside the mutations, the three new gates are each driven against a deliberately failing **fixture workspace** through `DEXPACE_GATE_ROOT` (`test/fixtures/gates/invariants/workspace/`), and @@ -343,7 +353,7 @@ none roll-up only, so nothing here had to be read out of the specification inste ## Deviations from the plan Departures from the plan's text, each with its reason. None lowers, disables or narrows a gate. The -corresponding ledger rows are `P9-21`–`P9-41` in the design's As-built addendum. +corresponding ledger rows are `P9-21`–`P9-42` in the design's As-built addendum. 1. **`dexpace-serde-json`'s and `dexpace-core`'s existing files are byte-identical** — the plan's Task 10 removes `assert_closes_nothing`'s and `assert_failure_model`'s portable halves from 7a's @@ -473,6 +483,25 @@ found: a guard this phase wrote that could not go red. across the seven (rows 55–61). No first-party code changed: the assertions were right and only the negative controls were missing. `P9-41`. +### Review round 4, 2026-09-24 + +One departure, and it is the same class narrowed by one row: a guard this phase wrote that could not go +red, found by counting assertions where this document counted rows. + +16. **`XCUT-18`'s model-layer assertion had no committed non-conforming double, and no document named + it.** Review round 3's sweep neutralised all forty-five assertion bodies in the four suites one at a + time; four survived, and three were accounted for — `XCUT-3`'s and `XCUT-12`'s, which this checklist + names, and a private clause helper whose parent assertion is caught. The fourth, + `HeaderSyntax#header_syntax_validation`, could be neutralised whole with the whole of + `rake test:gems` green: both of its committed tests pin the model assertion `:passed` and drive the + call-site one. The evidence paragraph above could not see it, because it counted **rows** and + `XCUT-18` carries two assertions — the row was covered on one of them. Two defective stand-in cores + now drive it: a permissive validator that refuses nothing, and one that applies the NAME rule to + outbound values as well, so the asymmetry that IS the requirement has a control of its own rather + than resting on the real subject. Measured: neutralising the assertion whole reddens both (row 62), + and deleting the HTAB-accepted `Check` alone reddens the second (row 63). No first-party code + changed. `P9-42`. + --- ## Findings routed diff --git a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md index 1d856d9..1c790a3 100644 --- a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md +++ b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-design.md @@ -1084,10 +1084,10 @@ below, numbered from **P9-21** as the brief fixes it; `P9-11`–`P9-20` are left renumbered. The checklist's "Deviations from the plan" is the itemised list against the plan's text; the rows here are the ones that touch public behaviour, the contract a later phase cites, or a statement this document makes. `P9-34` and `P9-35` were added in **review round 1**, on 2026-09-24, `P9-36` -through `P9-39` in **review round 2**, and `P9-40` and `P9-41` in **review round 3**, all on the same -day; each is marked as such in its own row, and -nothing earlier is rewritten except `P9-32`'s count, which was wrong as written and is corrected -three times for the same reason. +through `P9-39` in **review round 2**, `P9-40` and `P9-41` in **review round 3**, and `P9-42` in +**review round 4**, all on the same day; each is marked as such in its own row, and +nothing earlier is rewritten except `P9-32`'s count, which was wrong as written and is re-stated in +every round since, for the same reason. | # | Deviation | Requirement / document | Why | |---|---|---|---| @@ -1102,7 +1102,7 @@ three times for the same reason. | P9-29 | `XCUT-12`'s assertion asserts that **every** racer was parked before the coalesced fetch is released | `XCUT-12` | Without the barrier the first thread finished alone and the other fifteen took the cached-token path without ever racing, so a stamper fetching per caller PASSED. Measured by mutation. Under a conforming stamper one racer parks in `#fetch` and fifteen park on the credential's lock; under a non-conforming one all sixteen park in `#fetch` — both are `"sleep"`, so the barrier is the same for either and decides nothing by itself | | P9-30 | `tools/serde_boundary.rb`'s `pending` gains `list:`, **and** the task's abort branch gets a `RUBYOPT` prelude fixture | `SSE-37`; `R4` addendum A7 | The `list:` keyword lets a test drive a non-empty list through the FUNCTION. It does not reach the TASK: `PENDING` is a frozen constant and no gate root can make the task see a non-empty one, so a mutation deleting the abort left every test green. The fixture prepends one row onto `.pending` through `RUBYOPT`, which is this repository's deliberately-failing-fixture rule applied to the one gate clause with no other route in | | P9-31 | `tools/requirement_levels.rb --check` takes an **optional path**, so both exits are proven | `R3`; `NFR-17` | With `TARGET` fixed the gate's own test could only ever assert exit 0, and a `--check` that always exited 0 passed it — measured by mutation. `--check ` compares the same render against a file the test writes to a temp dir, so the failing half needs no fixture tree and nothing under `gems/` is touched | -| P9-32 | The mutation set run here is **this phase's own**, re-derived from the design's testing strategy and the plan's per-task "non-conforming double" rule | the brief's minimum set | The reviewer's list was in this run's opening brief and did not survive the session's context compaction. **Forty-four** mutations were re-derived and all forty-four go red; a forty-fifth shape — conditioning `Registry#resolve`'s `return hand_out(resolved)` guard on anything else — turned its `loop do … end` into an unbounded spin, ran for six minutes and was **killed rather than counted**, which is the finding routed to phase 10's inbound list and not a guard. Review round 1 adds **seven** more (rows 46–52) and review round 2 **two** (rows 53–54), for fifty-three red in all (checklist, "Guards run red"). The two round-2 rows are shapes that SURVIVED round 1 — the brief's own minimum list named both — so the honest reading is that the set run at implementation was short by two, not that two more were found. The count exceeds the thirty-five the brief named as a minimum, but the SET is not the reviewer's, and a reviewer re-running the original should expect to find it covered rather than assume it | +| P9-32 | The mutation set run here is **this phase's own**, re-derived from the design's testing strategy and the plan's per-task "non-conforming double" rule | the brief's minimum set | The reviewer's list was in this run's opening brief and did not survive the session's context compaction. **Forty-four** mutations were re-derived and all forty-four go red; a forty-fifth shape — conditioning `Registry#resolve`'s `return hand_out(resolved)` guard on anything else — turned its `loop do … end` into an unbounded spin, ran for six minutes and was **killed rather than counted**, which is the finding routed to phase 10's inbound list and not a guard. Review round 1 adds **seven** more (rows 46–52), review round 2 **two** (rows 53–54), review round 3 **seven** (rows 55–61) and review round 4 **two** (rows 62–63), for sixty-two red in all (checklist, "Guards run red"). The two round-2 rows are shapes that SURVIVED round 1 — the brief's own minimum list named both — so the honest reading is that the set run at implementation was short by two, not that two more were found. The count exceeds the thirty-five the brief named as a minimum, but the SET is not the reviewer's, and a reviewer re-running the original should expect to find it covered rather than assume it | | P9-33 | The plan's existence-probe list names `:Redactor` at the top level; the filed name is `Instrumentation::Redactor` | plan Task 16 Step 1; design `R8` row 13 | `Dexpace::Redactor` does not exist and never did — design `R8`'s own row 13 says "the filed name, not `Dexpace::Redactor`". The probe list was corrected at execution; every probe answers true, so no `R3` divergence was filed | | P9-34 | **Every wait in the four new suites carries a bound**, and ASYNC-3's expired ENTRY wait is `:vacuous` rather than `:failed` | `ASYNC-3`, `SEAM-12`, `XCUT-11`; 8a's `await_closed_connection` | Review round 1. Three waits had none — `Shutdown#check_release`'s `transport.entered.pop` and the two `.each(&:join)` sites — and the first was demonstrated to HANG for ever against an executor that refuses the post (ASYNC-2's saturated queue, or a closed pool): nothing is ever pushed, the poster rescues the refusal, and the `ensure` that frees the gate and closes the pool is never reached. This gem exists to be run by third parties against THEIR executors, and 8a had already fixed the rule for it — `await_closed_connection` always carries a `timeout:`, so an adapter that never releases fails the assertion instead of hanging the run. The entry wait's expiry is `:vacuous` and not `:failed` because with no unit started there is no "blocking task on a worker thread", which is the antecedent `ASYNC-3`'s own sentence opens with; a MUST-level vacuity blocks the report regardless. The two join sites are Failures naming their clause, with the whole thread set sharing one budget so sixteen stuck threads cost one bound and not sixteen | | P9-35 | `PackagingCase::DEFAULT_RESOLVE` rescues `::Gem::LoadError` **by name**, beside `::StandardError` | `NFR-1`, `NFR-2`, `NFR-10`, `NFR-14`, `NFR-15`; the suite's absent-antecedent contract | Review round 1, found by writing the test round 0 asked for: every case in `packaging_suite_test.rb` supplied its own `resolve:`, so the lambda a real driver gets had never run. RubyGems raises `Gem::MissingSpecError` for a name it cannot resolve and that descends from `Gem::LoadError < LoadError < ScriptError` — it is **not** a `StandardError`, so the shipped `rescue ::StandardError` let it past, and it escaped `Runner`'s own bare rescue too and aborted the whole run where the contract is one `:vacuous` result naming the absent unit. Measured on 4.0.6. A defect in this phase's own gem, so `R6`'s report-don't-repair rule does not reach it — the same standing `P9-27` has | @@ -1112,6 +1112,7 @@ three times for the same reason. | P9-39 | `ExecutorCase#shutdowns`' event-NAME match is given a double that logs something **besides** its shutdown | `SEAM-25`, `XCUT-13`, `XCUT-22` | Review round 2. The count matches the payload's `Keys::EVENT` entry, but every double in `executor_suite_test.rb` emitted the shutdown payload and nothing else, so nothing separated "count the shutdown events" from "count the sink's writes" — and replacing the match with a bare `entries.count` left the file, the pool driver and the whole `rake test:gems` green, with four assertions reading that count. An executor that logged one unrelated line and emitted no shutdown would have passed `SEAM-25`; a conforming one that logged anything at all would have failed `XCUT-22`. `ChattyPool` emits one other event payload and one bare String message at construction, which nothing in the SPI forbids and which a real adapter's logger carries plenty of. No code changed — the code was right and only the guard was missing | | P9-40 | The audit's own driver is **one added file under `gems/dexpace-core/test/`**, and the Module layout's file list above is wrong where it says otherwise | `R6`; `P9-9`; the Module layout | Review round 3. Two sentences in this document cannot both hold: the Module layout says "**no file in `gems/dexpace-core/` at all**", and `P9-9` says "the driver is `dexpace-core`'s own suite and knows which ivar is which". The second is the one that survives, because the seven subjects `InvariantSuite` cannot name itself — the seam, the transport, the bounded map and its store, the cnonce draw, the redirect hop and the credential hop — are core's own objects, and core's test-support doubles are reachable from core's suite and from nowhere else; 8a's precedent already puts a driver in the tree whose objects it drives. The bend is as narrow as it can be: **`gems/dexpace-core/test/dexpace/cross_cutting_invariants_test.rb` is ADDED and nothing else under `gems/dexpace-core/` is modified, renamed or deleted** — not a test, not a support double, not a manifest row, and above all nothing under `lib/` or `sig/`. `R6` exists so that phase 9 does not REPAIR what it audits, and an added test file repairs nothing. `git diff --name-status main.. -- gems/dexpace-core` is one path, status `A`, which is the mechanical statement of it. The Module layout's sentence is left standing rather than quietly rewritten: this row IS the correction, and an edited file list would leave the next reader unable to tell which of the two was decided. The checklist's and the roadmap's boundary sentences name the file too | | P9-41 | Seven `XCUT` assertions shipped with **no committed non-conforming double**, and five of them could be neutralised WHOLE with the suite green | `XCUT-1`, `XCUT-2`, `XCUT-6`, `XCUT-7`, `XCUT-10`, `XCUT-19`, `XCUT-24`; `R3` | Review round 3, and the same class of defect rounds 1 and 2 found — a guard this phase wrote that could not go red — at its widest. Nothing in the repository drove a subject that made any clause of `XCUT-1`, `XCUT-2`, `XCUT-6`, `XCUT-7` or `XCUT-10` fail, so each reported `:passed` because it could not report anything else; `XCUT-19`'s and `XCUT-24`'s clauses survived a literal-`true` replacement for the same reason, their apparent coverage being an artefact of `-w` raising on an orphaned local rather than of any assertion. `invariant_suite_core_test.rb`'s own header named `XCUT-6`, `XCUT-7` and `XCUT-10` among the IDs it drove, and the checklist claimed the (b) evidence universally. **In an audit phase this is `R3`'s own failure one level up**: `R3` stops an assertion passing because its subject was never built, and this is an assertion passing because its own falsifiability was never built. Thirteen defective stand-in cores now drive all seven, in the shape that file already used — a patched `Resilience` or `Instrumentation` whose `const_missing` falls through to the real core, so one test changes one subject. Measured: neutralising each of the seven assertions whole and warning-free (`return nil if subject` as the body's first line, so no local is orphaned and `NFR-6`'s hook cannot mask the result) reddens exactly the new tests for it, thirteen across the seven. No first-party code changed and none should have: the assertions were right and only the negative controls were missing. `XCUT-3` and `XCUT-12` are left without one deliberately, and the checklist names them — a double for either would have to stay in the wait past the assertion's own bound, which leaks the thread the base case's teardown counts | +| P9-42 | `XCUT-18`'s **model-layer** assertion had no committed non-conforming double either, and the claim that covered it counted rows rather than assertions | `XCUT-18`; `R3`; the checklist's evidence paragraph | Review round 4, and `P9-41` one row narrower. Round 3's sweep neutralised all forty-five assertion bodies in the four suites one at a time and four survived; three were accounted for — `XCUT-3`'s and `XCUT-12`'s, which the checklist names, and a private clause helper whose parent assertion is caught — and the fourth, `HeaderSyntax#header_syntax_validation`, was named nowhere. It could be neutralised whole with the whole of `rake test:gems` green, because both of its committed tests pin the model assertion `:passed` and drive only the call-site one. **The claim could not see it because it was counted in rows**: an ID appears once however many assertions carry it, `XCUT-18` carries two, and the row was covered on one of them — so the evidence paragraph now says ASSERTION and names the sweep as what makes it checkable. Two defective stand-in cores drive it: a permissive validator that refuses nothing, and one applying the NAME rule to outbound values as well — the second because HTAB refused everywhere satisfies every other clause, so the asymmetry that IS the requirement would otherwise rest on the real subject alone. Measured: neutralising the assertion whole reddens both (row 62), deleting the HTAB-accepted `Check` alone reddens the second (row 63). No first-party code changed | --- From 5e2400828f68d435f8ccf36517d1494d05441cc8 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 24 Sep 2026 19:04:06 +0300 Subject: [PATCH 16/17] docs: correct NFR-14's level and the stale bounded-map inbound bullet Two false sentences in shipped documents, found by review round 4. R4-1: the phase-9 checklist stated NFR-14's normative level as MUST. Appendix C and the phase's own design table both say SHOULD. Only the Level cell moves; the row's verdict, task numbers and evidence sentence were already correct. Re-checked mechanically rather than by eye: every XCUT/NFR level cell in the checklist against tools/requirement_levels.rb's parse of appendix C -- 41 cells, zero differences, where the same check named NFR-14 and nothing else before the edit. R4-2: the pre-existing phase-10 inbound bullet on the async-http client cache still said Clients#@by_origin is an uncapped per-origin map and that gates:bounded_map "stays red until it lands". Both halves are false against the tree: 8c shipped the cap and the gate is green. Appended one dated bracketed correction in place, deleting and renumbering nothing, on 7a's precedent for a bullet the tree has falsified -- the as-built ivar is @by_key over a (reactor, origin) key, #fetch calls #drain inside the same @mutex.synchronize as the insert, #drain loops back to MAX_ORIGINS evicting closed reactors first, the gate is green over all 307 files with clients.rb allowlisted as a verified false positive of the scan, and the docs/first-release.md blocker is closed with no phase-10 repair owed. --- .../2026-09-05-ruby-sdk-v1-roadmap-design.md | 18 +++++++++++++++++- ...ing-invariants-and-conformance-checklist.md | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md index 1f5836c..1e11b38 100644 --- a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md @@ -1922,7 +1922,23 @@ evidence the work was done. moot when `8c` lands and phase 10 to find nothing to repair. It stays on this list until a green `gates:bounded_map` run says so. What made the difference is only that `8c` had **not** in fact already run when phase 9's audit read its plan, which is the premise the phase-10 routing rested on. Touches `XCUT-14`, - `TRANSPORT-13`, `NFR-17`. + `TRANSPORT-13`, `NFR-17`. [2026-09-24, phase 9: **premise false on the tree, and the map this bullet names + does not exist under that name.** The as-built ivar is `@by_key`, not `@by_origin` — `Clients` keys by + `(reactor, origin)` and not by origin alone + (`gems/dexpace-transport-async_http/lib/dexpace/transport/async_http/clients.rb:56`, the `Key` at :28) — so + a search for the name this bullet gives finds nothing. Nor is it uncapped: `Clients#fetch` inserts with + `||=` and calls `#drain` inside the **same** `@mutex.synchronize` as the insert (:79-82), and `#drain` is a + loop back to `MAX_ORIGINS` (32, `async_http.rb:55`) that evicts clients whose reactor has closed first and + then the oldest by insertion order (:126-131), with every evicted client's pool retired outside the lock + (:83) — which is `XCUT-14`'s drain clause exactly, so a concurrent insert burst converges to the bound + rather than overshooting it permanently. `gates:bounded_map` is **green over the whole tree** — 307 files, + six adjudicated exceptions, exit 0, re-run 2026-09-24 on 4.0.6 — so this bullet's own eviction condition, + "it stays on this list until a green `gates:bounded_map` run says so", is met. `clients.rb` is one of the + six exceptions, carried on `InvariantGates::BOUNDED_MAP_ALLOWED` (`tools/invariant_gates.rb:69-79`) as a + **verified false positive of the scan** and never as a silenced defect: the scan sees a Hash assigned to an + instance variable and cannot see a cap, and the reason recorded beside the entry is the reading of the + file. The `docs/first-release.md` blocker is ticked and closed 2026-09-23, `XCUT-14`'s checklist row is ✅, + and **no phase-10 repair is owed** — the fix this bullet specifies had already landed with `8c`.] - **~~8a's `Adapter#dispatch` leaves its rescue variable unused~~ — first half CLOSED 2026-09-13 — and every repository tool that parses a filed source carries the same exposure.** As filed, this bullet reported that `docs/work/mvp/phase8/phase8a/2026-09-11-phase8a-synchronous-transport-and-conformance.md:4858` diff --git a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md index c2ac488..a096874 100644 --- a/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md +++ b/docs/work/mvp/phase9/2026-09-12-phase9-cross-cutting-invariants-and-conformance-checklist.md @@ -155,7 +155,7 @@ which is the difference between an assertion that holds and one that cannot repo | `NFR-11` | SHOULD | ✅ | 9, 15 | `gates:rbs_surface` over `sig/**` plus `PackagingSuite`'s "the core's public signatures name no async-framework type", whose leak regex anchors on the START of a constant path so core's own `Dexpace::Async::Future` is not a match. Mutation 41 | | `NFR-12` | SHOULD | ✅ (partial) | 15 | `gates:reproducible` — byte-identity for one gem built twice on one interpreter, which is phase 0's `P0-7` reading. The cross-toolchain half is explicitly out of scope and is release-gated (`docs/first-release.md` § Release path, the `NFR-12`/`NFR-16` entry) | | `NFR-13` | SHOULD | ✅ / ⏳ | 9, 15, 16 | ✅ for the `lib/` half — the `Dexpace/SpdxHeader` cop over every Ruby file — and for the ASSERTION over the `sig/` half, which is what this phase owed. ⏳ for the `sig/` half's CONTENT: the assertion **fails, correctly**, and that failure is this phase's verdict rather than a defect in it. Measured on 2026-09-23: **307 of 307 shipped `.rbs` files carry no SPDX header, in all six gems.** A RuboCop cop parses Ruby and cannot reach `.rbs`, so nothing checked them. **Owner: phase 10's design addendum `A8` and inbound row 8, which already name this assertion and say "phase 9's presence assertion turns green"** — verified still owned on 2026-09-23, not re-recorded | -| `NFR-14` | MUST | ✅ | 9, 15 | `PackagingSuite`'s "every unit's version comes from one source of truth" against the resolved specs, beside `gates:versions` over the repository-root `VERSIONS` file | +| `NFR-14` | SHOULD | ✅ | 9, 15 | `PackagingSuite`'s "every unit's version comes from one source of truth" against the resolved specs, beside `gates:versions` over the repository-root `VERSIONS` file | | `NFR-15` | SHOULD | ✅ | 9, 15 | `PackagingSuite`'s "each unit reports its build version at runtime": the loaded `VERSION` constant compared against the resolved gemspec's, run against locally built `.gem` files outside the bundle | | `NFR-16` | SHOULD | ⏳ | — | Artifact signing is "enforced on the release/CI path" and there is no release path. No phase-9 task writes an assertion for it and none is shipped as a placeholder. **Owner: `docs/first-release.md` § Release path, the `NFR-12`/`NFR-16` entry** | | `NFR-17` | MUST | ✅ | 13, 15 | Every gate this phase adds is in `DEFAULT_GATES` in the root `Rakefile` — not in `tasks/gates.rake`, which the `Rakefile` `load`s before defining and freezing the array — so all **twenty-one** are in `task default:` and therefore blocking, and all three are in `ci.yml`'s `gates` job. `gates/default_task_test.rb` asserts the count and the membership, and phase 0's `ci_workflow_test.rb` asserts every `DEFAULT_GATES` entry appears in some CI job. **The design predicted ⏳**, pending phase 0 plan Task 2's `minitest ~> 5.25` pin — the one non-blocking-gate finding the design itself had raised; that prediction was wrong, because the pin landed, so the meta-audit found nothing outstanding to report | From ab9a03d560fbec830ed1e29b46947dd59955ad35 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 24 Sep 2026 19:15:07 +0300 Subject: [PATCH 17/17] docs: discharge the bounded-map premise in its two other homes Review round 4's R4-2 corrected the phase-10 inbound bullet itself. The same premise survived in two places the finding did not name, and both are in material this phase may write, so they are fixed rather than reported. The roadmap's narration of the 2026-09-13 routing pass cited the uncapped Clients#@by_origin as its example of a bullet whose repair is expected to be moot, and said such bullets stay unchanged until a green gates:bounded_map run closes them. That run happened on 2026-09-23, so the paragraph gains a dated bracketed note in place rather than a rewrite: the record of what that pass said is itself the point. docs/first-release.md's opening summary still called the blocker red and said phase 10's repair clears it. It is ticked at its own entry, closed by this phase's run against a cap 8c had already shipped, and no repair is owed. --- docs/first-release.md | 5 +++-- docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md | 6 +++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/first-release.md b/docs/first-release.md index 864688d..72f98a4 100644 --- a/docs/first-release.md +++ b/docs/first-release.md @@ -7,8 +7,9 @@ the same day, on the same rule — a finding is routed to its owner when it is f and four of its items came here: two blockers before first publish (documenting the `include Dexpace` constant shadow, and the `AuthDescriptor` carrier decision), `CFG-20`'s cancel-with-interrupt clause under the unsatisfied MUSTs, and the `CTX-7`/`CTX-8` drain proof under the post-release triggers. Two -more left a second home here beside their primary owner: the Minitest 6 trigger, and the red -`gates:bounded_map` blocker phase 10's repair clears. **Nothing has been published.** Since phase 0 landed +more left a second home here beside their primary owner: the Minitest 6 trigger, and the +`gates:bounded_map` blocker — which was filed red and expecting a phase-10 repair, and was closed on +2026-09-23 by phase 9's own green run against a cap `8c` had already shipped, with no repair owed. **Nothing has been published.** Since phase 0 landed (2026-09-14) `gems/` holds the six gems, since phase 1 (2026-09-15) `dexpace-core` carries the HTTP domain model, and since phase 2 (2026-09-15) the seam layer as well; there is still no tag, and no version beyond the `0.0.0` every gem starts at. diff --git a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md index 1e11b38..080595b 100644 --- a/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md @@ -1824,7 +1824,11 @@ audit-only**, and **2 were fixed on the spot in this pass** because they were in substituted proxy IDs and phase 8a's half-stale `TRANSPORT-30` forward row. Bullets whose repair is expected to be **moot** — the uncapped `Clients#@by_origin`, which `8c`'s plan Task 8 now bounds at planning time — stay below unchanged, because a green `gates:bounded_map` run is what closes them and a sentence in a plan is not -evidence the work was done. +evidence the work was done. [2026-09-24, phase 9: **that condition is now met and this paragraph's one example +has been discharged.** The green run happened on 2026-09-23 and the bullet below carries the measurement in +place; the name `@by_origin` was itself wrong on the tree, so the example reads as an unfound defect until the +bullet's own correction is read beside it. The rule the paragraph states — a sentence in a plan is not evidence +the work was done — is what the discharge confirms, and it stands.] **From the reconciliation.**