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