From 904297a1bba855d9225fb17e97e49ca0c45978a2 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 23 Sep 2026 21:29:18 +0300 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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.