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-async-thread/test/dexpace/async/thread/conformance_test.rb b/gems/dexpace-async-thread/test/dexpace/async/thread/conformance_test.rb new file mode 100644 index 0000000..e16300d --- /dev/null +++ b/gems/dexpace-async-thread/test/dexpace/async/thread/conformance_test.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# SEAM-12, SEAM-25, XCUT-11, XCUT-13, XCUT-22, ASYNC-3, ASYNC-15, ASYNC-16, ASYNC-17. The +# first-party driver for dexpace-conformance's ExecutorSuite (phase 9's plan Task 11), in the +# adapter gem's own test tree following 8a's placement. +# +# Three of the four factories are real SDK objects and none is a stand-in for one: +# +# * `build:` is `Pool.build`, wired to the case's own recorder through a real +# `Instrumentation::Logger`, because SEAM-25's event is the ONLY channel a filed executor +# exposes a shutdown on -- without `record_events: true` an unlatched pool passes XCUT-13. +# * `borrow:` is core's `Transport.async_over` bridge, which is Closeable with `owned: false`: +# the SDK object that takes a caller's executor and must never shut it down (XCUT-22). +# * `functional:` is that same bridge over an INLINE executor -- a lightweight async transport +# that manages no lifecycle at all, which is exactly ASYNC-17's subject. The pool is not, and +# handing the pool there would assert the opposite requirement. +# +# ASYNC-3 is WAIVED BY ID and declared `would_fail:`. It is an unsatisfied MUST (design §10.5): +# a worker blocked in an uninterruptible call cannot be released, because §8.3 bans Thread#raise, +# Thread#kill and Timeout.timeout outright. The assertion is written to FAIL rather than to be +# absent, and the waiver prints `waived (would fail): ASYNC-3` -- never passed and never vacuous. +# `assertion_would_fail` below runs it UNWAIVED and asserts it really does fail, so the waiver +# cannot outlive the limitation it records. +require_relative "../../../test_helper" +require "dexpace/async/thread" +require "dexpace/conformance" + +# The pool's run of the shared executor suite: one generated test per assertion, plus the +# report-level checks the waiver rests on. +class DexpaceAsyncThreadConformanceTest < DexpaceTestCase + Conformance = Dexpace::Conformance + SUITE = Conformance::ExecutorSuite + Pool = Dexpace::Async::Thread::Pool + + # Four workers: enough for SEAM-12's sixteen concurrent posts to interleave, small enough that a + # leaked pool is obvious in the thread count DexpaceTestCase's teardown takes. + SIZE = 4 + # The one unsatisfied MUST this suite carries, waived by ID in the first-party build. + UNSATISFIED = "ASYNC-3" + + # A resource-free executor: it runs the unit on the caller's thread and owns nothing, so the + # bridge over it is the lightweight implementation ASYNC-17 says needs no lifecycle management. + class PoolInlineExecutor + # @yield the unit of work, run inline on the caller's own thread + # @return [nil] + def post + yield + nil + end + end + + # A transport the bridge will accept; nothing in ASYNC-17 reaches it, and it is never dispatched. + class PoolIdleTransport + # @return [nil] the transport seam's three positionals, so Registry.callable? holds + def call(_request, _options, _cancellation) = nil + end + + SUITE.assertions.each_with_index do |assertion, index| + define_method(format("test_%02d_%s", n: index, + name: assertion.name.gsub(/\W+/, "_"),)) do + drive(assertion) + end + end + + test "the suite reports no failure, no error and no unaccepted MUST-level vacuity" do + report = SUITE.run(**suite_arguments) + + assert_empty(report.failures.map { |result| described(result) }) + assert_empty(report.errors.map { |result| described(result) }) + assert_empty(report.blocking_vacuities.map { |result| result.assertion.ids.join(", ") }) + assert_predicate(report, :passed?) + end + + # The waiver is a claim about the port, so it is measured rather than asserted by description: + # ASYNC-3 is waived above because it FAILS, and this runs the same assertion with no waiver and + # requires that failure. The day an interruptible wait exists, this goes red and the waiver goes. + test "the waived MUST really fails, which is what makes waiving it a record and not a hole" do + report = SUITE.run(**case_arguments, waive: [], would_fail: []) + + failed = report.failures.map { |result| result.assertion.ids }.flatten + + assert_includes(failed, UNSATISFIED, + "ASYNC-3 no longer fails; the waiver and §10.5's entry are now stale",) + end + + # Design R5: the waived MUST is rendered as a waiver that names the failure, never as a pass. + test "the report prints the unsatisfied MUST as waived (would fail), never as passed" do + rendered = SUITE.run(**suite_arguments).to_s + + assert_includes(rendered, "waived (would fail): #{UNSATISFIED}") + end + + private + + def described(result) = "#{result.assertion.ids.join(", ")}: #{result.detail}" + + def drive(assertion) + return skip("waived: #{UNSATISFIED}") if assertion.ids.include?(UNSATISFIED) + + assertion.call(Conformance::ExecutorCase.new(**case_arguments)) + rescue Conformance::Vacuous => error + skip("vacuous: #{error.reason}") + rescue Conformance::Failure => error + flunk("#{assertion.ids.join(", ")}: #{error.message} (expected #{error.expected.inspect}, " \ + "got #{error.actual.inspect})") + end + + def suite_arguments + case_arguments.merge(waive: [UNSATISFIED], would_fail: [UNSATISFIED]) + end + + def case_arguments + { build: executor_factory, borrow: borrow_factory, functional: functional_factory, + record_events: true, } + end + + # `events:` arrives only when the case has a recorder; a real Logger is what turns that duck-typed + # sink into the thing Pool#close emits through, so the payload the recorder sees is the one a + # host's own logger would see. + def executor_factory + lambda do |events: nil| + logger = events.nil? ? nil : Dexpace::Instrumentation::Logger.build(sink: events) + Pool.build(size: SIZE, logger: logger || Dexpace::Instrumentation::Logger::NULL) + end + end + + # Core's own borrowing holder: Closeable with `owned: false`, so its close latches and releases + # nothing. Closing it must leave the pool accepting work, which is XCUT-22 exactly. + def borrow_factory + ->(pool) { Dexpace::Transport.async_over(PoolIdleTransport.new, executor: pool) } + end + + # The same bridge over a resource-free executor: no thread, no queue, no timer, and a close that + # is a latch and nothing else. + def functional_factory + lambda do |events: nil| + _ = events + Dexpace::Transport.async_over(PoolIdleTransport.new, executor: PoolInlineExecutor.new) + end + end +end diff --git a/gems/dexpace-conformance/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..0a23270 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/lifecycle.rb @@ -0,0 +1,177 @@ +# 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 + # 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 + # collected set, never on timing, so the test runs alone in any order. + def concurrent_post(subject) + pool = subject.executor + seen = ::Thread::Queue.new + posters = ::Array.new(ExecutorCase::THREADS) do |index| + ::Thread.new { pool.post { seen << index } } + end + check_posters_returned(posters) + 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 + + # 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 + # 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..ccb58b3 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/shutdown.rb @@ -0,0 +1,176 @@ +# 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. + # + # 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) + 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) + + 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..842e324 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/models.rb @@ -0,0 +1,129 @@ +# 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, 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) + 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 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 + @torn = false + end + + # @return [Boolean] the latch a holder flips when it closes what it borrowed + def close = (@closed = true) + def closed? = @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 + + 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..d159865 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/sharing.rb @@ -0,0 +1,152 @@ +# 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 + # 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 + # 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 + 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 + 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..5178ea9 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/packaging_case.rb @@ -0,0 +1,154 @@ +# 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. + # + # `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 ::Gem::LoadError, ::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..1c334ad --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb @@ -0,0 +1,144 @@ +# 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. 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] + 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..9b9efbf --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/executor_suite/lifecycle.rbs @@ -0,0 +1,25 @@ +# 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 + 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 + 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..0b033e9 --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/models.rbs @@ -0,0 +1,30 @@ +# 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 + @torn: bool + + def initialize: () -> void + def close: () -> bool + def closed?: () -> bool + def finish: () -> 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..e32a7ac --- /dev/null +++ b/gems/dexpace-conformance/sig/dexpace/conformance/invariant_suite/sharing.rbs @@ -0,0 +1,25 @@ +# 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 + CALL_BOUND: Float + 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?.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 + 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/aggregate_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/aggregate_test.rb new file mode 100644 index 0000000..520480f --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/aggregate_test.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace/conformance" + +# One report over every suite, and the preamble docs/first-release.md's standing blocker requires. +# NFR-4, NFR-17; ASYNC-3, ASYNC-4. +class DexpaceConformanceAggregateTest < DexpaceTestCase + Aggregate = Dexpace::Conformance::Aggregate + + def report(*rows) + results = rows.map do |(ids, name, status)| + assertion = Dexpace::Conformance::Assertion.build(ids: ids, name: name, body: ->(_s) {}) + Dexpace::Conformance::Result.build(assertion: assertion, status: status) + end + Dexpace::Conformance::Report.new(results) + end + + test "merging two suites keeps one passed? over the whole run" do + merged = Aggregate.run([report([["XCUT-15"], "immutable", :passed]), + report([["NFR-1"], "zero deps", :failed]),]) + + assert_equal(2, merged.results.size) + refute_predicate(merged, :passed?) + end + + test "vacuous and waived are counted apart; a SHOULD-level vacuity does not fail the run" do + merged = Aggregate.run([report([["XCUT-12"], "wait-free reads", :vacuous], + [["ASYNC-3"], "two-mode cancellation", :waived],)]) + + assert_predicate(merged, :passed?) + assert_equal(1, merged.vacuous.size) + assert_equal(1, merged.waived.size) + end + + test "an un-accepted MUST-level vacuity blocks the aggregate, and an accepted one does not" do + reports = [report([["ASYNC-4"], "ordered interrupt", :vacuous])] + + refute_predicate(Aggregate.run(reports), :passed?) + assert_predicate(Aggregate.run(reports, accepted_vacuous: { "ASYNC-4" => "design §10.5" }), + :passed?,) + end + + test "the rendered report states what a green run does not prove" do + rendered = Aggregate.render(Aggregate.run([report([["XCUT-15"], "immutable", :passed])])) + + assert_includes(rendered, "It does not prove") + assert_includes(rendered, "by reference") + assert_includes(rendered, "design R3") + end + + test "the rendered report names the blocker section when one stands" do + rendered = Aggregate.render(Aggregate.run([report([["ASYNC-4"], "ordered interrupt", + :vacuous,])])) + + assert_includes(rendered, "MUST-level vacuity (report blocker): ASYNC-4") + assert_includes(rendered, "REPORT BLOCKED") + end + + test "an aggregate-level would-fail declaration reaches every suite's waivers" do + rendered = Aggregate.render( + Aggregate.run([report([["ASYNC-3"], "two-mode cancellation", :waived])], + would_fail: ["ASYNC-3"],), + ) + + assert_includes(rendered, "waived (would fail): ASYNC-3") + end + + # Design R7: the generated half of the coverage map reads each suite's DECLARED assertions. A + # Report holds only the ones that RAN, so reading one would let a suite skipped in a given + # invocation silently shorten the map. + test "by_requirement_id reads each suite's DECLARED assertions, not a report's results" do + map = Aggregate.by_requirement_id([Dexpace::Conformance::InvariantSuite]) + + assert_equal(2, map["XCUT-13"].size, "XCUT-13 carries two assertions, one per clause") + assert_equal(%i[not_run not_run], map["XCUT-13"].map { |row| row[:status] }) + end + + test "by_requirement_id marks an assertion with no result :not_run rather than omitting it" do + ran = Dexpace::Conformance::InvariantSuite.run(core: Module.new) + map = Aggregate.by_requirement_id([Dexpace::Conformance::InvariantSuite], statuses: ran) + + refute_includes(map.values.flatten.map { |row| row[:status] }, :not_run) + end + + test "each row names both the suite and the assertion, which are different things" do + map = Aggregate.by_requirement_id([Dexpace::Conformance::InvariantSuite]) + row = map["XCUT-15"].first + + assert_equal("Dexpace::Conformance::InvariantSuite", row[:suite]) + refute_equal(row[:suite], row[:assertion]) + end + + test "every suite phase 9 ships contributes to the map" do + suites = [Dexpace::Conformance::InvariantSuite, Dexpace::Conformance::PackagingSuite, + Dexpace::Conformance::CodecSuite, Dexpace::Conformance::ExecutorSuite, + Dexpace::Conformance::TransportSuite,] + map = Aggregate.by_requirement_id(suites) + + assert_empty(map.keys.reject { |id| Dexpace::Conformance::Levels.known?(id) }, + "every id any suite declares is one appendix C knows",) + assert_operator(map.size, :>, 40) + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/codec_suite_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/codec_suite_test.rb new file mode 100644 index 0000000..adecab6 --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/codec_suite_test.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "json" +require "stringio" +require_relative "../../test_helper" +require "dexpace/conformance" + +# Appendix B.3's seam half, proven against deliberately non-conforming codecs. +# SEAM-20, SERDE-3, SERDE-9. +class DexpaceConformanceCodecSuiteTest < DexpaceTestCase + Suite = Dexpace::Conformance::CodecSuite + + # Built from phase 2's FILED contract: #dump_to(value, sink) and #load(source, witness). #dump + # is deliberately NOT in CONTRACT, so no double defines it. The error hierarchy is phase 2's + # too: SerializationError and DeserializationError descend from Dexpace::Serde::Error, and + # there is no Dexpace::SerdeError. + class ConformingCodec + def dump_to(value, sink) = sink.write(value.inspect) + + def load(source, _witness) + ::JSON.parse(source.read) + rescue ::JSON::ParserError + raise Dexpace::Serde::DeserializationError, "malformed JSON" + end + end + + # SEAM-20 / SERDE-3, broken: it closes the caller's sink. + class ClosingCodec < ConformingCodec + def dump_to(value, sink) + super + sink.close + end + end + + # SEAM-20 / SERDE-3, broken the other way: it closes nothing because it writes nothing. + class SilentEncoder < ConformingCodec + def dump_to(_value, _sink) = nil + end + + # SERDE-9's "no library type escapes". A plain def, not `def … if cond`, which would define the + # method conditionally and inherit the conforming parent's. + class LeakyCodec < ConformingCodec + def load(source, _witness) = ::JSON.parse(source.read) + end + + # SERDE-9 requires the STABLE SERDE type: a Dexpace error outside that hierarchy is not it. + class WrongFamilyCodec < ConformingCodec + def load(_source, _witness) = raise(Dexpace::InvalidArgumentError, "not a serde error") + end + + # SERDE-9's second clause: "chaining the original cause". + class UnchainedCodec < ConformingCodec + def load(_source, _witness) + raise Dexpace::Serde::DeserializationError.new("malformed JSON"), cause: nil + end + end + + # SERDE-9's first clause: it does not raise at all. + class SilentCodec < ConformingCodec + def load(_source, _witness) = nil + end + + # 7a's witness protocol is any object answering #dexpace_load(parsed, ctx); the source factory + # stands in for the driver's, which for the JSON codec builds a Dexpace::IO::BufferedSource. + WITNESS = Object.new + def WITNESS.dexpace_load(parsed, _ctx) = parsed + SOURCE = ->(text) { StringIO.new(text) } + + def run_suite(codec, **over) + Suite.run(build: -> { codec.new }, witness: WITNESS, source: SOURCE, **over) + end + + def statuses(report) = report.results.to_h { |r| [r.assertion.ids.first, r.status] } + + test "the suite covers exactly the three portable seam ids" do + ids = Suite.assertions.flat_map(&:ids) + + assert_equal(%w[SEAM-20 SERDE-3 SERDE-9], ids.sort) + refute_includes(ids, "SEAM-21", "the type-token rule stays in 7a's own suite") + refute_includes(ids, "SERDE-10", "the mapping stays in 7a's own suite") + end + + test "a conforming codec passes both seam assertions" do + assert_equal({ "SEAM-20" => :passed, "SERDE-9" => :passed }, + statuses(run_suite(ConformingCodec)),) + end + + test "a codec that closes a caller-supplied sink fails SEAM-20 and SERDE-3 together" do + report = run_suite(ClosingCodec) + + assert_equal(:failed, statuses(report)["SEAM-20"]) + assert_includes(report.to_s, "FAILED: SEAM-20, SERDE-3") + end + + # Without this clause a codec that wrote nothing would pass by having closed nothing. + test "a codec that writes nothing fails SEAM-20 rather than passing by omission" do + assert_equal(:failed, statuses(run_suite(SilentEncoder))["SEAM-20"]) + end + + test "a library exception type escaping the seam fails SERDE-9" do + assert_equal(:failed, statuses(run_suite(LeakyCodec))["SERDE-9"]) + end + + test "a Dexpace error outside the serde hierarchy fails SERDE-9" do + assert_equal(:failed, statuses(run_suite(WrongFamilyCodec))["SERDE-9"]) + end + + test "a serde error with no chained cause fails SERDE-9" do + assert_equal(:failed, statuses(run_suite(UnchainedCodec))["SERDE-9"]) + end + + test "a codec that does not raise at all fails SERDE-9" do + assert_equal(:failed, statuses(run_suite(SilentCodec))["SERDE-9"]) + end + + # The rescue order inside the suite's own capture: Vacuous is a ::StandardError descendant, so + # a bare rescue there would turn the suite's own vacuity into a pass. + test "a Vacuous raised inside the body is not swallowed into passed" do + vacuous = Class.new(ConformingCodec) do + def load(_source, _witness) + raise(Dexpace::Conformance::Vacuous, "this codec decodes nothing") + end + end + + assert_equal(:vacuous, statuses(run_suite(vacuous))["SERDE-9"]) + end + + test "a waiver by id suppresses exactly one result and leaves the other running" do + report = run_suite(ClosingCodec, waive: ["SERDE-3"]) + + assert_equal({ "SEAM-20" => :waived, "SERDE-9" => :passed }, statuses(report)) + end + + test "the preamble names what a green run does not prove" do + report = run_suite(ConformingCodec) + + assert_includes(report.to_s, "SEAM-21") + assert_includes(report.to_s, "SERDE-4") + assert_operator(report.to_s, :start_with?, "dexpace-conformance codec suite:") + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/executor_suite_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/executor_suite_test.rb new file mode 100644 index 0000000..eb53f8c --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/executor_suite_test.rb @@ -0,0 +1,323 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace/conformance" + +# Appendix B.7's lifecycle half and the harness for SEAM-25's lifecycle event, proven against +# deliberately non-conforming executors. +# SEAM-12, SEAM-25, ASYNC-3, ASYNC-15, ASYNC-16, ASYNC-17, XCUT-11, XCUT-13, XCUT-22. +class DexpaceConformanceExecutorSuiteTest < DexpaceTestCase # rubocop:disable Metrics/ClassLength -- the non-conforming executors this suite must fail against live beside the tests that drive them, which is what keeps each test readable as one behaviour + Suite = Dexpace::Conformance::ExecutorSuite + + # The one payload every double emits, shaped exactly as Event#emit shapes it: a Hash whose + # Keys::EVENT entry carries the event name. The pool's two field keys are its own private + # constants and no portable assertion reads them, so nothing else is here. + SHUTDOWN = { Dexpace::Instrumentation::Keys::EVENT => + Dexpace::Instrumentation::Events::INSTRUMENTATION_SHUTDOWN }.freeze + + # A conforming double: an inline executor whose close is latched, which drains before returning, + # refuses work after close, and reports its shutdown the ONLY way a filed executor does -- one + # shutdown payload into the sink the suite supplied. It deliberately defines no + # `#shutdown_count`: no executor in this repository has one, and a double that invented one is + # what made an earlier draft of this suite red against the real adapter. + class FakePool + def initialize(events: nil, owned: true) + @events = events + @owned = owned + @closed = false + @lock = Thread::Mutex.new + end + + def post(&) + raise "closed" if @closed + + yield + end + + def close + @lock.synchronize do + return nil if @closed + + @closed = true + end + return nil unless @owned + + emit_shutdown + nil + end + + private + + def emit_shutdown + @events&.info { SHUTDOWN } + nil + end + end + + # A conforming executor that also uses the sink it was handed for something OTHER than its + # shutdown -- one event payload under a different name and one bare String message, both emitted + # at construction. Nothing in the SPI forbids it and a real adapter's logger carries plenty + # besides its shutdown, so `ExecutorCase#shutdowns` must match the payload's `Keys::EVENT` entry + # rather than count the sink's writes. Every other double here emits the shutdown payload and + # nothing else, which is why review round 2 could replace that match with a bare + # `entries.count` and leave the whole suite green. + class ChattyPool < FakePool + STARTUP = { Dexpace::Instrumentation::Keys::EVENT => + Dexpace::Instrumentation::Events::INSTRUMENTATION_HOOK }.freeze + + def initialize(events: nil, owned: true) + super + events&.info { STARTUP } + events&.info("a bare message, which is not a payload Hash at all") + end + end + + # XCUT-13's non-conforming twin: no latch, so every close runs the shutdown again. + class UnlatchedPool < FakePool + def close + @closed = true + send(:emit_shutdown) + nil + end + end + + # ASYNC-16's second half, broken: it accepts work after close. + class LeakyPool < FakePool + def post(&) = yield + end + + # ASYNC-17's subject: an implementation that owns nothing, so its close shuts nothing down. + class FunctionalExecutor + def post(&) = yield + def close = nil + end + + # ASYNC-17's non-conforming twin: a "functional" implementation whose close does work. + class WorkingCloseExecutor + def initialize(events) = (@events = events) + def post(&) = yield + + def close + @events&.info { SHUTDOWN } + nil + end + end + + # XCUT-22's conforming holder: it closes itself and leaves the borrowed executor alone. + class BorrowHolder + def initialize(pool) = (@pool = pool) + def close = nil + end + + # XCUT-22's non-conforming twin. + class ClosingBorrowHolder + def initialize(pool) = (@pool = pool) + def close = @pool.close + end + + # ASYNC-2's saturated or closed executor, which this gem exists to be run by third parties + # against: `#post` REFUSES rather than blocking, so nothing ever enters the transport and the + # ASYNC-3 assertion's entry wait has nobody to wait for. + class RefusingPool + def post(&) = raise(Dexpace::ClosedError, "this executor refuses work") + def close = nil + end + + # An executor whose `#post` BLOCKS. Submission is not the work -- the SPI's `#post` returns and + # the unit runs elsewhere -- so this is non-conforming, and it is the shape SEAM-12's sixteen + # posting threads would have parked on for ever. + class BlockingPostPool + def initialize(entered:, gate:) + @entered = entered + @gate = gate + end + + def post(&) + @entered << Thread.current + @gate.pop + end + + def close = nil + end + + # Releases every thread parked on `gate` and joins each one, so a test driving a blocking double + # leaves the thread count where it found it (DexpaceTestCase's teardown). + def release(entered, gate) + gate.close + threads = [] + loop do + one = entered.pop(timeout: 0.5) + break if one.nil? + + threads << one + end + threads.each { |thread| thread.join(2) } + end + + def assertion_for(id) = Suite.assertions.find { |one| one.ids.include?(id) } + + def statuses(report) = report.results.to_h { |r| [r.assertion.ids.first, r.status] } + + # ASYNC-3 is waived by ID here for the same reason a first-party driver waives it: no executor + # this repository can build releases a worker blocked in an uninterruptible call (design §10.5), + # and the suite's job is to print that as `waived (would fail)`, never as passed. + def conforming(**over) + defaults = { build: ->(events: nil, **_kw) { FakePool.new(events: events) }, + borrow: ->(pool) { BorrowHolder.new(pool) }, + functional: ->(**_kw) { FunctionalExecutor.new }, + record_events: true, waive: ["ASYNC-3"], would_fail: ["ASYNC-3"], } + Suite.run(**defaults, **over) + end + + test "the suite covers B.7's lifecycle ids, ASYNC-3 among them" do + ids = Suite.assertions.flat_map(&:ids).uniq.sort + + assert_equal(%w[ASYNC-15 ASYNC-16 ASYNC-17 ASYNC-3 SEAM-12 SEAM-25 XCUT-11 XCUT-13 XCUT-22], + ids,) + assert(ids.all? { |id| Dexpace::Conformance::Levels.known?(id) }) + end + + test "a conforming executor passes every lifecycle assertion, with ASYNC-3 waived by id" do + report = conforming + + assert_equal({ "SEAM-12" => :passed, "XCUT-13" => :passed, "XCUT-22" => :passed, + "ASYNC-16" => :passed, "ASYNC-17" => :passed, "SEAM-25" => :passed, + "ASYNC-3" => :waived, }, + statuses(report),) + assert_includes(report.to_s, "waived (would fail): ASYNC-3") + end + + # Design R5: the assertion is real and it fails. An inline executor runs the blocked call on the + # posting thread and a pool runs it on a worker; neither can release it on cancel. + test "ASYNC-3 genuinely fails against an executor that cannot release a blocked worker" do + report = conforming(waive: []) + + assert_equal(:failed, statuses(report)["ASYNC-3"]) + assert_match(/still blocked/, report.failures.first.detail) + end + + # R0-1: the entry wait carried no bound, so an executor that refuses the post parked the run for + # ever -- no unit entered, nothing was pushed, and the ensure that frees the gate and closes the + # pool was never reached. :vacuous and not :failed, because with no unit started there is no + # "blocking task on a worker thread", which is the antecedent ASYNC-3's own sentence opens with. + # Driven as ONE assertion: an executor that refuses every post is non-conforming in several ways + # and this test is about the bound. + test "an executor that refuses the post makes ASYNC-3 vacuous rather than hanging the run" do + subject = Dexpace::Conformance::ExecutorCase.new(build: ->(**_kw) { RefusingPool.new }) + + error = assert_raises(Dexpace::Conformance::Vacuous) do + assertion_for("ASYNC-3").call(subject) + end + + assert_match(/did not start the posted unit/, error.reason) + end + + # The same rule at SEAM-12's sixteen posting threads: `#post` must RETURN, so the joins carry one + # shared budget and an expired one is the failure. + test "an executor whose post blocks fails SEAM-12 rather than hanging the run" do + entered = Thread::Queue.new + gate = Thread::Queue.new + subject = Dexpace::Conformance::ExecutorCase.new( + build: ->(**_kw) { BlockingPostPool.new(entered: entered, gate: gate) }, + ) + + error = assert_raises(Dexpace::Conformance::Failure) do + assertion_for("SEAM-12").call(subject) + end + + assert_match(/did not return/, error.message) + ensure + release(entered, gate) + end + + # SEAM-25 is "exactly one shutdown EVENT", not "exactly one sink write": the recorder is a sink + # and a conforming executor may log anything else through it. Three assertions read that count -- + # SEAM-25, XCUT-13's second half and XCUT-22's no-shutdown half -- and all three stay green here, + # while a count that ignored the event name reddens every one of them. + test "a payload that is not a shutdown event is not counted as one" do + report = conforming(build: ->(events: nil, **_kw) { ChattyPool.new(events: events) }) + + assert_equal({ "SEAM-12" => :passed, "XCUT-13" => :passed, "XCUT-22" => :passed, + "ASYNC-16" => :passed, "ASYNC-17" => :passed, "SEAM-25" => :passed, + "ASYNC-3" => :waived, }, + statuses(report),) + end + + test "an unlatched close fails XCUT-13 and SEAM-25" do + report = conforming(build: ->(events: nil, **_kw) { UnlatchedPool.new(events: events) }) + + assert_equal(:failed, statuses(report)["XCUT-13"]) + assert_equal(:failed, statuses(report)["SEAM-25"]) + end + + test "a holder that closes a borrowed executor fails XCUT-22" do + report = conforming(borrow: ->(pool) { ClosingBorrowHolder.new(pool) }) + + assert_equal(:failed, statuses(report)["XCUT-22"]) + end + + test "an executor accepting work after close fails ASYNC-16" do + report = conforming(build: ->(events: nil, **_kw) { LeakyPool.new(events: events) }) + + assert_equal(:failed, statuses(report)["ASYNC-16"]) + end + + test "a functional implementation whose close does work fails ASYNC-17" do + report = conforming(functional: ->(events: nil, **_kw) { WorkingCloseExecutor.new(events) }) + + assert_equal(:failed, statuses(report)["ASYNC-17"]) + end + + test "an adapter supplying no resource-free implementation makes ASYNC-17 vacuous" do + report = conforming(functional: nil) + + assert_equal(:vacuous, statuses(report)["ASYNC-17"]) + assert_predicate(report, :passed?, "ASYNC-17 is a SHOULD, so its vacuity does not block") + end + + # The recorder-free path, stated as a whole report: XCUT-13 and ASYNC-17 keep running on the + # half that needs no recorder, and only SEAM-25 -- whose whole subject IS the event -- goes + # vacuous, with XCUT-22. + test "an adapter with no borrowing entry point and no recorder is vacuous, never failed" do + accepted = { "XCUT-22" => "this adapter files no borrowing entry point", + "ASYNC-15" => "clause (b) has no subject without one", + "SEAM-25" => "no event recorder was supplied to this run", } + report = conforming(borrow: nil, record_events: false, accepted_vacuous: accepted) + + assert_equal({ "SEAM-12" => :passed, "XCUT-13" => :passed, "XCUT-22" => :vacuous, + "ASYNC-16" => :passed, "ASYNC-17" => :passed, "SEAM-25" => :vacuous, + "ASYNC-3" => :waived, }, + statuses(report),) + assert_predicate(report, :passed?) + end + + # Decision: acceptance is `all?` over the assertion's MUST-level IDs. XCUT-22 and ASYNC-15 are + # BOTH MUST in appendix C, so naming one leaves the result blocking -- which is the rule doing + # its job rather than a typo. + test "the same run without acceptances blocks on XCUT-22 and SEAM-25, both MUSTs" do + report = conforming(borrow: nil, record_events: false) + + refute_predicate(report, :passed?) + assert_equal(%w[ASYNC-15 SEAM-25 XCUT-22], + report.blocking_vacuities.flat_map { |r| r.assertion.ids }.uniq.sort,) + end + + test "an acceptance naming only XCUT-22 still blocks, because ASYNC-15 is a MUST too" do + report = conforming(borrow: nil, record_events: false, + accepted_vacuous: { "XCUT-22" => "this adapter files no borrowing entry" },) + + refute_predicate(report, :passed?) + assert_includes(report.blocking_vacuities.flat_map { |r| r.assertion.ids }, "ASYNC-15") + end + + # Without a recorder, "the shutdown ran twice" is visible nowhere, so an UNLATCHED executor + # passes XCUT-13. The suite says so in its preamble; this is the measurement behind it. + test "without a recorder an unlatched executor passes XCUT-13, which the preamble states" do + report = conforming(build: ->(**_kw) { UnlatchedPool.new }, record_events: false, + accepted_vacuous: { "SEAM-25" => "no event recorder in this run" },) + + assert_equal(:passed, statuses(report)["XCUT-13"]) + assert_includes(report.to_s, "an UNLATCHED executor passes XCUT-13") + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_core_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_core_test.rb new file mode 100644 index 0000000..caaba34 --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_core_test.rb @@ -0,0 +1,572 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace/conformance" + +# The InvariantSuite assertions whose subject is a CORE CONSTANT, proven against deliberately +# defective stand-in cores. Each double is a Module.new carrying exactly the constants its +# assertion probes, defective in exactly the clause the assertion names -- which is what shows the +# assertion discriminates rather than merely passing against the real tree. +# Every ID here is one this file really drives: XCUT-1, XCUT-2, XCUT-4, XCUT-5, XCUT-6, XCUT-7, +# XCUT-8, XCUT-9, XCUT-10, XCUT-16, XCUT-18, XCUT-19, XCUT-20, XCUT-21, XCUT-23 and XCUT-24. The +# list is kept honest deliberately: it named XCUT-6, XCUT-7 and XCUT-10 before any double for them +# existed, and a header claiming a double nobody wrote is what review round 2 caught. An ID in that +# list says nothing about how many ASSERTIONS carry it: XCUT-18 carries two, and review round 3 +# found the model-layer one still had no double while the call-site one had two, with the whole of +# `rake test:gems` green. Both are driven here now. +# XCUT-3 and XCUT-12 have no double here and their checklist rows say so: either would need a +# subject that parks a thread past the assertion's own bound, which leaks the thread the base +# case's teardown counts. Their guards are subject mutations 22 and 23. +class DexpaceConformanceInvariantSuiteCoreTest < DexpaceTestCase # rubocop:disable Metrics/ClassLength -- one defective stand-in core per assertion, each beside the test that drives it + Suite = Dexpace::Conformance::InvariantSuite + + def statuses(report) + report.results.each_with_object({}) { |r, h| (h[r.assertion.ids.first] ||= []) << r.status } + end + + # A defective core: the real Dexpace with one constant replaced. The overrides are real + # constants on a fresh module, and `const_missing` falls through to core -- which is what makes + # BOTH spellings work, `core::Thing` and `core.const_get(:Thing)`, since Ruby routes each + # through `const_missing` when the name is not defined locally. Every OTHER assertion therefore + # reaches the real thing, so one test changes one subject. + def patched(**overrides) + real = ::Dexpace + Module.new do + overrides.each { |name, value| const_set(name, value) } + define_singleton_method(:const_missing) { |name| real.const_get(name) } + define_singleton_method(:const_defined?) do |name, *| + super(name, false) || real.const_defined?(name) + end + define_singleton_method(:respond_to_missing?) { |name, _| real.respond_to?(name) } + define_singleton_method(:method_missing) do |name, *args, &block| + real.public_send(name, *args, &block) + end + end + end + + # A defective Resilience namespace: the real one with the named constants replaced. Its own + # `const_missing` falls through to `::Dexpace::Resilience`, so replacing `Policy` leaves + # `RetrySettings` and `Resend` the real ones -- `patched`'s discipline one level down. + def resilience_core(**overrides) + patched(Resilience: Module.new do + overrides.each { |name, value| const_set(name, value) } + define_singleton_method(:const_missing) { |name| ::Dexpace::Resilience.const_get(name) } + end) + end + + # A defective Policy: the three classifier queries this suite reads, every one conforming except + # the ones named. Written out rather than delegated through `method_missing`, because the point + # of the double is that a reader can see which of the three is wrong. + def policy_core(**broken) + real = ::Dexpace::Resilience::Policy + # Merged BEFORE anything is defined: defining the conforming body and then overriding it warns + # "method redefined", which NFR-6's fatal-warning hook turns into an error. + bodies = { retry_eligible?: ->(status, set:) { real.retry_eligible?(status, set: set) }, + throwable_retryable?: ->(error) { real.throwable_retryable?(error) }, + cancellation?: ->(error) { real.cancellation?(error) }, }.merge(broken) + policy = Module.new + bodies.each { |name, body| policy.define_singleton_method(name, &body) } + resilience_core(Policy: policy) + end + + # The real Instrumentation with one `Redactor::DEFAULT` replaced. `Redactor` is a class carrying + # a DEFAULT constant, so the double is built the same way rather than through `const_missing`. + def instrumentation_with(redactor) + Module.new do + const_set(:Redactor, Class.new { const_set(:DEFAULT, redactor) }) + const_set(:Logger, Dexpace::Instrumentation::Logger) + const_set(:Preview, Dexpace::Instrumentation::Preview) + define_singleton_method(:contain) { |*a, **k, &b| Dexpace::Instrumentation.contain(*a, **k, &b) } + end + end + + # ---- XCUT-1 ---- + + test "a cancellation classifier that reads only the outermost error fails XCUT-1" do + outermost = policy_core(cancellation?: ->(error) { error.is_a?(::Dexpace::CancelledError) }) + + assert_equal([:failed], statuses(Suite.run(core: outermost))["XCUT-1"], + "an adapter's own retryable wrapper around the token's raise is what makes a " \ + "cancelled operation retryable again, and reading the outermost error misses it",) + end + + test "a classifier that calls a cancellation retryable fails XCUT-1" do + retrying = policy_core(throwable_retryable?: ->(_error) { true }) + + assert_equal([:failed], statuses(Suite.run(core: retrying))["XCUT-1"]) + end + + # ---- XCUT-2 ---- + + test "a cancellation matched by EXACT type fails XCUT-2's subtype clause" do + exact = policy_core(cancellation?: lambda do |error| + error.instance_of?(::Dexpace::CancelledError) + end) + + assert_equal([:failed], statuses(Suite.run(core: exact))["XCUT-2"], + "a timeout type that SUBCLASSES the cancellation type must still be read as a " \ + "cancellation, which is why the timeout branch has to be checked first",) + end + + test "a classifier that refuses a read timeout fails XCUT-2" do + by_message = policy_core(throwable_retryable?: lambda do |error| + !error.message.include?("timed out") + end) + + assert_equal([:failed], statuses(Suite.run(core: by_message))["XCUT-2"], + "telling a timeout from a cancellation by matching a message string is what " \ + "XCUT-2 forbids in as many words",) + end + + # ---- XCUT-4 ---- + + test "a transport error outside the IOError family fails XCUT-4" do + outside = Class.new(::StandardError) { def retryable? = true } + report = Suite.run(core: patched(TransportError: outside)) + + assert_equal([:failed], statuses(report)["XCUT-4"]) + end + + test "a transport error that is not always-retryable fails XCUT-4" do + lying = Class.new(::IOError) do + include Dexpace::Error + + def retryable? = false + end + report = Suite.run(core: patched(TransportError: lying)) + + assert_equal([:failed], statuses(report)["XCUT-4"]) + end + + # ---- XCUT-5 ---- + + test "a status classifier missing a 5xx fails XCUT-5, which a {500,502,503,504} list would" do + narrow = Module.new do + def self.retryable_status?(code) = [408, 429, 500, 502, 503, 504].include?(code) + end + report = Suite.run(core: patched(Retryability: narrow)) + + assert_equal([:failed], statuses(report)["XCUT-5"], + "507 is a 5xx outside 501 and 505, so the classifier must call it retryable",) + end + + test "a classifier that calls 501 retryable fails XCUT-5" do + wide = Module.new do + def self.retryable_status?(code) + code >= 500 || [408, 429].include?(code) + end + end + + assert_equal([:failed], statuses(Suite.run(core: patched(Retryability: wide)))["XCUT-5"]) + end + + # ---- XCUT-6 ---- + + test "a capability query that reads only the outermost error fails XCUT-6" do + outermost = policy_core(throwable_retryable?: lambda do |error| + error.respond_to?(:retryable?) && error.retryable? + end) + + assert_equal([:failed], statuses(Suite.run(core: outermost))["XCUT-6"], + "an adapter failure a higher layer wrapped still declares itself retryable " \ + "through its cause chain, and never participates if only the outer is read",) + end + + test "a classifier matching a concrete TYPE rather than the capability fails XCUT-6" do + by_type = policy_core(throwable_retryable?: ->(error) { error.is_a?(::IOError) }) + + assert_equal([:failed], statuses(Suite.run(core: by_type))["XCUT-6"], + "a custom error type the classifier has never heard of must participate " \ + "WITHOUT the classifier being edited",) + end + + # ---- XCUT-7 ---- + + test "a configured set ANDed with the baked classifier fails XCUT-7's widening clause" do + anded = policy_core(retry_eligible?: lambda do |status, set:| + set.include?(status) && ::Dexpace::Retryability.retryable_status?(status) + end) + + assert_equal([:failed], statuses(Suite.run(core: anded))["XCUT-7"], + "an AND passes the narrowing half and fails the widening one, which is why " \ + "both directions are asserted",) + end + + test "a default retryable-status set that is not the one XCUT-7 fixes fails XCUT-7" do + settings = Module.new + settings.define_singleton_method(:build) { Defaults.new([500, 502, 503, 504]) } + + assert_equal([:failed], + statuses(Suite.run(core: resilience_core(RetrySettings: settings)))["XCUT-7"],) + end + + # ---- XCUT-8 ---- + + test "a factory that fabricates an error for a 2xx fails XCUT-8" do + permissive = Class.new(::StandardError) do + def self.for(response) = new(response.status.code.to_s) + def self.for_or_nil(_response) = nil + end + report = Suite.run(core: patched(ProtocolError: permissive)) + + assert_equal([:failed], statuses(report)["XCUT-8"]) + end + + test "a convenience form that returns an error for a non-error status fails XCUT-8" do + eager = Class.new(Dexpace::ProtocolError) { def self.for_or_nil(_response) = allocate } + + assert_equal([:failed], statuses(Suite.run(core: patched(ProtocolError: eager)))["XCUT-8"]) + end + + # ---- XCUT-9 ---- + + # A two-node cycle could not tell these apart from a conforming walk, which is why the fixture + # is three nodes. + test "a DEPTH-CAPPED cause walk fails XCUT-9" do + capped = Module.new do + def self.each_cause(error) + return enum_for(:each_cause, error) unless block_given? + + current = error + 2.times do + break if current.nil? + + yield current + current = current.cause + end + end + end + + assert_equal([:failed], statuses(Suite.run(core: capped))["XCUT-9"]) + end + + test "a NON-TERMINATING cause walk reports failed rather than hanging the suite" do + looping = Module.new do + def self.each_cause(error) + return enum_for(:each_cause, error) unless block_given? + + current = error + loop do + yield current + current = current.cause + end + end + end + + assert_equal([:failed], statuses(Suite.run(core: looping))["XCUT-9"]) + end + + # ---- XCUT-10 ---- + + test "a safety gate that re-sends a body-less POST fails XCUT-10" do + lenient = Module.new do + def self.eligible?(request) + body = request.body + body.nil? || body.replayable? + end + end + + assert_equal([:failed], statuses(Suite.run(core: resilience_core(Resend: lenient)))["XCUT-10"], + "RETRY-7: a bare POST is not re-sendable even though there is no payload to " \ + "re-send, and even when the failure never reached the server",) + end + + test "a safety gate taking a FAILURE parameter fails XCUT-10's structural clause" do + special_casing = Module.new do + # rubocop:disable-next Lint/UnusedMethodArgument -- the parameter IS the defect here + def self.eligible?(request, failure = nil) + body = request.body + return request.method.idempotent? if body.nil? + + body.replayable? + end + end + core = resilience_core(Resend: special_casing) + + assert_equal([:failed], statuses(Suite.run(core: core))["XCUT-10"], + "every enumerated case is decided correctly here, so the only clause left to " \ + "fail is the structural one: there must be nothing to special-case on",) + end + + # ---- XCUT-19 ---- + + test "a redactor that forwards a URL's userinfo fails XCUT-19" do + leaky = Class.new do + def url(value) = value + def header_value(_name, value) = value + def header_name?(_name) = false + end + report = Suite.run(core: patched(Instrumentation: instrumentation_with(leaky.new))) + + assert_equal([:failed], statuses(report)["XCUT-19"]) + end + + test "a header allow-list that is default-ALLOW fails XCUT-19" do + permissive = Class.new do + def url(value) = Dexpace::Instrumentation::Redactor::DEFAULT.url(value) + def header_value(_name, value) = value + def header_name?(_name) = true + end + report = Suite.run(core: patched(Instrumentation: instrumentation_with(permissive.new))) + + assert_equal([:failed], statuses(report)["XCUT-19"], + "the URL clauses pass against the real redactor, so the allow-list clause is " \ + "the only one left to fail",) + end + + # ---- XCUT-20 ---- + + test "a redactor that throws on a malformed URL fails XCUT-20" do + throwing = Class.new do + def url(_value) = raise("boom") + def header_value(_name, value) = value + def header_name?(_name) = false + end + report = Suite.run(core: patched(Instrumentation: instrumentation_with(throwing.new))) + + assert_equal([:error], statuses(report)["XCUT-20"], + "an observability path that throws reaches Runner's bare rescue, which is " \ + "exactly the signal XCUT-20 forbids in a caller's request path",) + end + + # ---- XCUT-23 ---- + + test "a registry that silently picks one of two candidates fails XCUT-23" do + silent = Class.new do + # rubocop:disable Lint/UnusedMethodArgument -- phase 2's filed Registry signatures + def initialize(seam:, installer:, conforms:) = (@factories = {}) + def register(key, factory, core:) = (@factories[key] = factory) + # rubocop:enable Lint/UnusedMethodArgument + def install(provider) = (@installed = provider) + def resolve = @installed || @factories.values.first&.call + end + report = Suite.run(core: patched(Registry: silent)) + + assert_equal([:failed], statuses(report)["XCUT-23"]) + end + + test "a registry whose discovery beats an explicit install fails XCUT-23" do + inverted = Class.new do + # rubocop:disable Lint/UnusedMethodArgument -- phase 2's filed Registry signatures; only + # `installer:` is read here, and the rest are the shape the suite calls. + def initialize(seam:, installer:, conforms:) + @installer = installer + @factories = {} + end + + def register(key, factory, core:) = (@factories[key] = factory) + # rubocop:enable Lint/UnusedMethodArgument + def install(provider) = (@installed = provider) + + def resolve + raise "no candidate; install through #{@installer}" if @factories.empty? && @installed.nil? + raise "two candidates; install through #{@installer}" if @factories.size > 1 + + @factories.values.first&.call || @installed + end + end + report = Suite.run(core: patched(Registry: inverted)) + + assert_equal([:failed], statuses(report)["XCUT-23"], + "the ORDERING is the content: failing loudly on ambiguity is two of three",) + end + + # ---- XCUT-21, through the driver's cnonce factory ---- + + def digest_core(&) + handler = Class.new(&) + patched(Auth: Module.new do + const_set(:DigestHandler, handler) + define_singleton_method(:const_missing) { |name| ::Dexpace::Auth.const_get(name) } + end) + end + + def x21(core) + handler = core.const_get(:Auth).const_get(:DigestHandler) + Suite.run(core: core, cnonce: ->(source) { handler.new(:cred, cnonce_source: source).cnonce }) + end + + test "a handler with no injectable cnonce source fails XCUT-21" do + core = digest_core do + def initialize(credential) = (@credential = credential) + def cnonce = format("%032x", rand(2**128)) + end + handler = core.const_get(:Auth).const_get(:DigestHandler) + report = Suite.run(core: core, cnonce: ->(_source) { handler.new(:cred).cnonce }) + + assert_equal([:failed], statuses(report)["XCUT-21"]) + end + + test "a 128-bit draw truncated to eight characters fails XCUT-21" do + core = digest_core do + def initialize(credential, cnonce_source: ::SecureRandom) + @credential = credential + @source = cnonce_source + end + + def cnonce = @source.hex(16)[0, 8] + end + + assert_equal([:failed], statuses(x21(core))["XCUT-21"], + "a character count called this conforming and urlsafe_base64(16) not",) + end + + test "a conforming handler passes XCUT-21 whatever encoding renders its 128 bits" do + core = digest_core do + def initialize(credential, cnonce_source: ::SecureRandom) + @credential = credential + @source = cnonce_source + end + + def cnonce = @source.urlsafe_base64(16) + end + + assert_equal([:passed], statuses(x21(core))["XCUT-21"]) + end + + # ---- XCUT-18's model layer ---- + + # @return [String, nil] the detail of the failed result for `id`, so a clause is read off its + # own assertion rather than off whichever failure happens to be first in the report. + def failure_detail(report, id) + report.results.find { |r| r.assertion.ids.first == id && r.status == :failed }&.detail + end + + # One double per half of XCUT-18's asymmetry. The second is what keeps the asymmetry itself + # falsifiable: a port that refuses HTAB everywhere satisfies every other clause in the + # assertion, so without it the HTAB check is carried by the real subject alone. + + test "a validator that refuses nothing fails XCUT-18's model-layer assertion" do + permissive = Module.new do + def self.validate_name!(name) = name || raise(::ArgumentError, "name is required") + + def self.validate_outbound_value!(value, name:) + raise ::ArgumentError, "name is required" if name.nil? + + value + end + end + report = Suite.run(core: patched(HeaderSyntax: permissive)) + + assert_equal(%i[failed vacuous], statuses(report)["XCUT-18"], + "the call-site assertion is vacuous with no transport factory supplied; the " \ + "model-layer one is the subject here",) + assert_match(/header NAME containing CR was accepted/, failure_detail(report, "XCUT-18")) + end + + test "an outbound-value rule identical to the NAME rule fails XCUT-18's HTAB clause" do + symmetric = Module.new do + real = ::Dexpace::HeaderSyntax + define_singleton_method(:validate_name!) { |name| real.validate_name!(name) } + define_singleton_method(:validate_outbound_value!) do |value, name:| + real.validate_outbound_value!(value, name: name) + real.validate_name!(value) # the defect: one rule for both, so HTAB is refused everywhere + end + end + report = Suite.run(core: patched(HeaderSyntax: symmetric)) + + assert_equal(%i[failed vacuous], statuses(report)["XCUT-18"], + "every NAME clause and every other VALUE clause passes against this double, so " \ + "the asymmetry is the only clause left to fail",) + assert_match(/VALUE containing HTAB was rejected/, failure_detail(report, "XCUT-18")) + end + + # ---- XCUT-18's call-site assertion ---- + + # A conforming adapter: it re-runs HTTP-17/HTTP-18 before touching the wire. + class RefusingTransport + def call(request, _options, _cancellation) + request.headers.each_entry do |name, value| + ::Dexpace::HeaderSyntax.validate_name!(name) + ::Dexpace::HeaderSyntax.validate_outbound_value!(value, name: name) + end + raise "unreachable: the forged name must have been refused" + end + end + + # A non-conforming adapter: it connects first and validates afterwards. + class DispatchingTransport + def call(request, _options, _cancellation) + ::TCPSocket.new(request.url.host, request.url.port).close # wire activity before any check + raise ::Dexpace::InvalidArgumentError, "too late: already on the wire" + end + end + + test "a transport that re-validates before dispatch passes XCUT-18's call-site assertion" do + report = Suite.run(core: ::Dexpace, transport: -> { RefusingTransport.new }) + + assert_equal(%i[passed passed], statuses(report)["XCUT-18"]) + end + + test "a transport that touches the wire before re-validating fails XCUT-18's call site" do + report = Suite.run(core: ::Dexpace, transport: -> { DispatchingTransport.new }) + + assert_equal(%i[passed failed], statuses(report)["XCUT-18"]) + assert_match(/accepted a connection/, report.failures.first.detail) + end + + # ---- XCUT-16, through the driver's credential factory ---- + + Sent = Struct.new(:headers) + # What a RetrySettings double's `.build` answers: XCUT-7 reads `#retryable_statuses` off it and + # nothing else. + Defaults = Struct.new(:retryable_statuses) + + test "a credential path with no HTTPS guard fails XCUT-16" do + unguarded = lambda do |url:, cross_origin: false| # rubocop:disable Lint/UnusedBlockArgument + Sent.new(cross_origin ? {} : { "authorization" => "Bearer t" }) + end + report = Suite.run(core: ::Dexpace, credential_hop: unguarded) + + assert_equal([:failed], statuses(report)["XCUT-16"]) + end + + test "a guard applied to the credential-FREE re-issue too fails XCUT-16's third clause" do + over_eager = lambda do |url:, cross_origin: false| + unless url.start_with?("https:") + raise Dexpace::Auth::HTTPSRequiredError.new(scheme: "http", + step: "double",) + end + + Sent.new(cross_origin ? {} : { "authorization" => "Bearer t" }) + end + report = Suite.run(core: ::Dexpace, credential_hop: over_eager) + + assert_equal([:failed], statuses(report)["XCUT-16"], + "the guard applies ONLY on the credential-attaching path",) + end + + test "a conforming credential path passes all three of XCUT-16's clauses" do + conforming = lambda do |url:, cross_origin: false| + next Sent.new({}) if cross_origin + unless url.start_with?("https:") + raise Dexpace::Auth::HTTPSRequiredError.new(scheme: "http", + step: "double",) + end + + Sent.new({ "authorization" => "Bearer t" }) + end + + assert_equal([:passed], + statuses(Suite.run(core: ::Dexpace, credential_hop: conforming))["XCUT-16"],) + end + + # ---- XCUT-24 ---- + + test "an error-body snapshot that multiplies its cap fails XCUT-24" do + uncapped = Module.new do + define_singleton_method(:buffer_bounded) do |body, cap:| + ::Dexpace::Body.buffer_bounded(body, cap: cap * 1000) + end + define_singleton_method(:method_missing) do |name, *args, **kwargs, &block| + ::Dexpace::Body.public_send(name, *args, **kwargs, &block) + end + define_singleton_method(:respond_to_missing?) { |name, _| ::Dexpace::Body.respond_to?(name) } + end + + assert_equal([:failed], statuses(Suite.run(core: patched(Body: uncapped)))["XCUT-24"], + "the snapshot is the cap half; the logging tap is the non-consumption half, " \ + "and a preview that capped but drained would pass this one alone",) + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_test.rb new file mode 100644 index 0000000..0740032 --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/invariant_suite_test.rb @@ -0,0 +1,412 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace/conformance" + +# Appendix B.8's instrument, proven against DELIBERATELY NON-CONFORMING doubles: a green suite +# proves the instrument works, not that the SDK conforms, and those are different claims. +# XCUT-11, XCUT-13, XCUT-14, XCUT-15, XCUT-17, XCUT-18, XCUT-21, XCUT-22. +class DexpaceConformanceInvariantSuiteTest < DexpaceTestCase # rubocop:disable Metrics/ClassLength -- the non-conforming doubles this suite must fail against live beside the tests that drive them, which is what keeps each test readable as one behaviour + Suite = Dexpace::Conformance::InvariantSuite + + # ---- the seam: a latched closeable that counts its release ---- + + # A conforming seam: a latched closeable that counts its release and holds no per-call state. + class LatchedSeam + attr_reader :release_count + + def initialize(client: nil) + @client = client + @owned = client.nil? + @closed = false + @release_count = 0 + @lock = Thread::Mutex.new + end + + def call(request) = [request, 1] + + def close + @lock.synchronize do + return nil if @closed + + @closed = true + end + @release_count += 1 + @client.close if @owned && !@client.nil? + nil + end + end + + # XCUT-13's first clause, broken: no latch, so every close runs the release again. + class UnlatchedSeam < LatchedSeam + def close + @release_count += 1 + @client&.close + nil + end + end + + # Blocks on its FIRST close only, just past the assertion's half-second bound: enough to make + # XCUT-13's second clause red without spending seconds of the suite's time. + class BlockingCloseSeam < LatchedSeam + BLOCK_SECONDS = 0.6 + + def close + super + Kernel.sleep(BLOCK_SECONDS) if release_count == 1 + nil + end + end + + # XCUT-22, broken: it closes the resource it borrowed. + class OverreachingSeam < LatchedSeam + def close + super + @client&.close + end + end + + # XCUT-22's SECOND clause, broken on its own: it leaves the borrowed resource's `#close` alone + # and tears it down through the teardown beside it, which is exactly the shape the first clause + # cannot see. Without this double the second Check could not fail against any subject the suite + # can build, and review round 2 measured that: replacing its condition with a literal `true` + # left the whole gem's suite green. + class TearingSeam < LatchedSeam + def close + super + @client&.finish + end + end + + # XCUT-11, broken: per-call state on the shared instance, and one call's request in another's. + class CrossTalkingSeam < LatchedSeam + def initialize(client: nil) + super + @attempt = 0 + end + + def call(_request) = ["request-0", @attempt += 1] + end + + # Holds its per-fiber mutex across a suspension point, which is exactly what XCUT-11's second + # clause forbids. Clause 1 drives the same object from a thread's ROOT fiber, where Fiber.yield + # raises FiberError -- the rescue is the DOUBLE's, never the assertion's. + class SuspendingLockSeam < LatchedSeam + def call(request) + @lock.synchronize do + begin + Fiber.yield + rescue FiberError + nil + end + [request, 1] + end + end + end + + # A shared instance whose #call never returns. XCUT-11's first clause spawns sixteen callers, so + # without a bound on the joins the assertion parks the whole run against it (R0-1). Not a + # subclass of LatchedSeam: a seam that blocks on EVERY call blocks assertions this one does not + # own, so the test drives the single assertion and the double needs nothing else. + class BlockingCallSeam + attr_reader :release_count + + def initialize(entered:, gate:, client: nil) + @entered = entered + @gate = gate + @client = client + @release_count = 0 + end + + def call(request) + @entered << Thread.current + [request, @gate.pop] + end + + def close + @release_count += 1 + nil + end + end + + BLOCKING_DECLARED = %i[@entered @gate @client @release_count].freeze + + DECLARED = %i[@client @owned @closed @release_count @lock].freeze + + # ---- bounded maps ---- + + # 4a's filed BoundedMap surface: .new(cap:), #set, #size, and a drain loop over the store. + class ConformingMap + def initialize(cap:) + @cap = cap + @h = {} + @mutex = Thread::Mutex.new + end + + def set(key, value) + @mutex.synchronize do + @h[key] = value + @h.shift while @h.size > @cap + end + value + end + + def size = @mutex.synchronize { @h.size } + end + + # XCUT-14's cap clause, broken: nothing evicts. + class UncappedMap < ConformingMap + def set(key, value) + @mutex.synchronize { @h[key] = value } + value + end + end + + # Caps correctly from empty, and is exactly XCUT-14's "single pre-insert check-then-evict". + class CheckThenEvictMap < ConformingMap + def set(key, value) + @mutex.synchronize do + @h.shift if @h.size >= @cap + @h[key] = value + end + value + end + end + + STORE = ->(map) { map.instance_variable_get(:@h) } + + # ---- redirect hops ---- + + Hop = Struct.new(:headers, :url) + ORIGIN = %r{\Ahttps?://[^/]+} + USERINFO = %r{//[^@/]*@} + + SCOPED_HEADERS = %w[authorization cookie proxy-authorization].freeze + + def conforming_hop(from:, to:, headers:) + raise ArgumentError, "downgrade refused" if from.start_with?("https:") && to.start_with?("http:") + + target = to.sub(USERINFO, "//") + dropped = from[ORIGIN] == target[ORIGIN] ? %w[authorization] : SCOPED_HEADERS + Hop.new(headers.except(*dropped), target) + end + + # A non-conforming re-issue: every header survives every hop. + # rubocop:disable-next Lint/UnusedMethodArgument -- the driver contract's signature + def leaky_hop(from:, to:, headers:) = Hop.new(headers, to) + + # ---- helpers ---- + + def statuses(report) + report.results.each_with_object({}) { |r, h| (h[r.assertion.ids.first] ||= []) << r.status } + end + + def run_suite(**over) + defaults = { core: ::Dexpace, seam: ->(client: nil) { LatchedSeam.new(client: client) }, + mutable: DECLARED, } + Suite.run(**defaults, **over) + end + + # ---- structure ---- + + test "the suite covers all 24 XCUT ids with 28 assertions, four of them doubled" do + ids = Suite.assertions.flat_map(&:ids) + + assert_equal(24, ids.uniq.size) + assert_equal(28, ids.size) + assert_equal(%w[XCUT-11 XCUT-13 XCUT-14 XCUT-18], ids.tally.select { |_, n| n > 1 }.keys.sort) + assert(ids.all? { |id| Dexpace::Conformance::Levels.known?(id) }, + "every id the suite declares is one appendix C holds",) + end + + test "an absent artifact is vacuous naming who promised it, never failed and never errored" do + report = Suite.run(core: Module.new, seam: ->(client: nil) { LatchedSeam.new(client: client) }) + + assert_equal([:vacuous], statuses(report)["XCUT-15"]) + assert_includes(report.to_s, "Headers is absent; phase 1's domain model committed to it") + end + + test "a factory the driver did not supply is vacuous, and on a MUST it blocks the report" do + report = Suite.run(core: ::Dexpace) + + assert_equal(%i[vacuous vacuous], statuses(report)["XCUT-13"]) + refute_predicate(report, :passed?, "an un-waived MUST-level vacuity is a report blocker") + assert_includes(report.to_s, "no seam implementation supplied") + end + + # ---- the model and lifecycle assertions ---- + + test "a model aliasing a caller's collection fails XCUT-15" do + aliasing = Module.new do + const_set(:Headers, Class.new do + def self.build(values:, casing:, direction: :outbound) = new(values, casing, direction) + def initialize(values, _casing, _direction) = (@values = values) + def [](name) = @values[name] + end,) + end + + assert_equal([:failed], statuses(Suite.run(core: aliasing))["XCUT-15"]) + end + + test "an unlatched close fails XCUT-13's first clause and passes its second" do + report = run_suite(seam: ->(client: nil) { UnlatchedSeam.new(client: client) }) + + assert_equal(%i[failed passed], statuses(report)["XCUT-13"]) + end + + # XCUT-13's second clause is not vacuous: a close that blocks makes it red, which is what the + # bound is for. Without a double that blocks, the clause would pass against every subject the + # suite can build and would be worth nothing. + test "a close that blocks fails XCUT-13's second clause and passes its first" do + report = run_suite(seam: ->(client: nil) { BlockingCloseSeam.new(client: client) }) + + assert_equal(%i[passed failed], statuses(report)["XCUT-13"]) + assert_match(/blocked rather than signalling/, report.failures.first.detail) + end + + test "a holder that closes a borrowed resource fails XCUT-22" do + report = run_suite(seam: ->(client: nil) { OverreachingSeam.new(client: client) }) + + assert_equal([:failed], statuses(report)["XCUT-22"]) + assert_match(/closed a resource it did not create/, report.failures.first.detail) + end + + # The second clause, driven on its own: `closed?` is still false, so the first Check passes and + # only the second can report this. A `usable?` derived from the latch the first Check reads + # would leave this subject green. + test "a holder that tears a borrowed resource down without closing it fails XCUT-22" do + report = run_suite(seam: ->(client: nil) { TearingSeam.new(client: client) }) + + assert_equal([:failed], statuses(report)["XCUT-22"]) + assert_match(/stopped working/, report.failures.first.detail) + end + + # ---- bounded memory ---- + + test "a map with no cap fails XCUT-14's cap clause deterministically, with no thread race" do + report = run_suite(bounded_map: lambda { |cap:| + UncappedMap.new(cap: cap) + }, bounded_map_store: STORE,) + + assert_equal(:failed, statuses(report)["XCUT-14"].first) + end + + test "a drain-loop map passes both of XCUT-14's clauses" do + report = run_suite(bounded_map: ->(cap:) { ConformingMap.new(cap: cap) }, + bounded_map_store: STORE,) + + assert_equal(%i[passed passed], statuses(report)["XCUT-14"]) + end + + test "a check-then-evict map passes the cap clause and fails the drain clause" do + report = run_suite(bounded_map: ->(cap:) { CheckThenEvictMap.new(cap: cap) }, + bounded_map_store: STORE,) + + assert_equal(%i[passed failed], statuses(report)["XCUT-14"]) + assert_match(/evicted once instead of draining/, report.failures.first.detail) + end + + # ---- redirect hygiene ---- + + test "a redirect driver that keeps Authorization on a same-origin re-issue fails XCUT-17" do + report = run_suite(redirect_hops: method(:leaky_hop)) + + assert_equal([:failed], statuses(report)["XCUT-17"]) + end + + test "a conforming redirect driver passes all four of XCUT-17's clauses" do + report = run_suite(redirect_hops: method(:conforming_hop)) + + assert_equal([:passed], statuses(report)["XCUT-17"]) + end + + # ---- concurrency ---- + + test "a frozen stateless seam passes both XCUT-11 clauses" do + frozen = Class.new(LatchedSeam) do + def initialize(client: nil) + super + freeze + end + end + report = run_suite(seam: ->(client: nil) { frozen.new(client: client) }, mutable: []) + + assert_equal(%i[passed passed], statuses(report)["XCUT-11"]) + end + + test "a latch-and-mutex seam passes XCUT-11 when the DRIVER declares its state" do + report = run_suite + + assert_equal(%i[passed passed], statuses(report)["XCUT-11"], + "P9-9: a frozen-and-no-ivars rule would condemn every Closeable in the SDK",) + end + + test "a seam holding per-call state on the shared instance fails XCUT-11" do + report = run_suite(seam: ->(client: nil) { CrossTalkingSeam.new(client: client) }) + + assert_includes(statuses(report)["XCUT-11"], :failed) + end + + test "a subject declaring per-call state exempt still fails; mutable: is the driver's" do + self_exempting = Class.new(CrossTalkingSeam) do + def conformance_mutable_state = [:@attempt] + end + report = run_suite(seam: ->(client: nil) { self_exempting.new(client: client) }, + mutable: DECLARED,) + + assert_includes(statuses(report)["XCUT-11"], :failed) + end + + # The deadlock must be reported :failed; :error reads as a broken harness. Asserted as the exact + # PAIR, sorted, because assert_includes(:failed) would not catch the :error either. + test "a seam holding its lock across a suspension point fails XCUT-11's second clause" do + report = run_suite(seam: ->(client: nil) { SuspendingLockSeam.new(client: client) }) + + assert_equal(%i[failed passed], statuses(report)["XCUT-11"].sort, + "a deadlock must be reported :failed; :error reads as a broken harness",) + end + + # R0-1: the sixteen callers were joined with no bound, so a shared instance that never returns + # parked the run instead of failing the clause. 8a's rule for this gem is the opposite -- every + # wait carries a bound, so a non-conforming subject fails the assertion. + test "a shared instance whose call never returns fails XCUT-11 rather than hanging the run" do + entered = Thread::Queue.new + gate = Thread::Queue.new + assertion = Suite.assertions.find { |one| one.name.include?("holds no per-call state") } + subject = Dexpace::Conformance::InvariantCase.new( + core: ::Dexpace, mutable: BLOCKING_DECLARED, + seam: lambda { |client: nil| + BlockingCallSeam.new(entered: entered, gate: gate, client: client) + }, + ) + + error = assert_raises(Dexpace::Conformance::Failure) { assertion.call(subject) } + + assert_match(/did not return on every thread/, error.message) + ensure + release(entered, gate) + end + + # Releases every caller parked on `gate` and joins each one, so a test driving a blocking double + # leaves the thread count where it found it (DexpaceTestCase's teardown). + def release(entered, gate) + gate.close + callers = [] + loop do + one = entered.pop(timeout: 0.5) + break if one.nil? + + callers << one + end + callers.each { |thread| thread.join(2) } + end + + test "a shared instance the driver declares is audited too, and names itself on failure" do + leaky = Class.new { def initialize = (@per_call = 0) }.new + report = run_suite(shared: [["4a's ContextStore", leaky, []]]) + + assert_includes(statuses(report)["XCUT-11"], :failed) + assert_match(/4a's ContextStore/, report.failures.first.detail) + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/packaging_suite_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/packaging_suite_test.rb new file mode 100644 index 0000000..d82b70a --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/packaging_suite_test.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "tmpdir" +require_relative "../../test_helper" +require "dexpace/conformance" + +# Appendix B.9's portable half, proven against deliberately non-conforming gem sets. +# NFR-1, NFR-2, NFR-3, NFR-10, NFR-11, NFR-13, NFR-14, NFR-15. +class DexpaceConformancePackagingSuiteTest < DexpaceTestCase # rubocop:disable Metrics/ClassLength -- one fixture gem set per assertion, each beside the test that drives it + Suite = Dexpace::Conformance::PackagingSuite + + # Loaded constants standing in for installed gems' version metadata. + module FakeGem + VERSION = "0.1.0" + + module Adapter + VERSION = "0.1.0" + end + + module Stale + VERSION = "0.0.9" + end + + # A loaded module that reports no version at all: the NFR-15 defect, not an absent + # antecedent. + module NoVersion + end + end + + CORE = "dexpace-core" + ADAPTER = "dexpace-serde-json" + CONSTANTS = { CORE => "DexpaceConformancePackagingSuiteTest::FakeGem", + ADAPTER => "DexpaceConformancePackagingSuiteTest::FakeGem::Adapter", }.freeze + + def spec(name, version, deps, ruby: ">= 3.2") + ::Gem::Specification.new do |s| + s.name = name + s.version = version + s.required_ruby_version = ruby + deps.each { |dep| s.add_dependency(dep, ">= 0") } + end + end + + def conforming + { CORE => spec(CORE, "0.1.0", []), + ADAPTER => spec(ADAPTER, "0.1.0", [CORE, "json"]), } + end + + def statuses(report) = report.results.to_h { |r| [r.assertion.ids.first, r.status] } + + def run_suite(specs, **over) + defaults = { adapters: [ADAPTER], resolve: ->(name) { specs[name] }, constants: CONSTANTS, + versions: { CORE => "0.1.0", ADAPTER => "0.1.0" }, } + Suite.run(**defaults, **over) + end + + # ---- structure ---- + + test "the suite covers the eight portable NFRs, one assertion each" do + ids = Suite.assertions.flat_map(&:ids) + + assert_equal(%w[NFR-1 NFR-10 NFR-11 NFR-13 NFR-14 NFR-15 NFR-2 NFR-3], ids.sort) + assert(ids.all? { |id| Dexpace::Conformance::Levels.known?(id) }) + refute_includes(ids, "NFR-8", "NFR-8 is vacuous by its own text and gets no assertion") + refute_includes(ids, "NFR-5", "a coverage floor is a property of a BUILD, not of a unit") + end + + # ---- NFR-1, NFR-2 ---- + + test "a core with one runtime dependency fails NFR-1" do + specs = conforming + specs[CORE] = spec(CORE, "0.1.0", ["json"]) + + assert_equal(:failed, statuses(run_suite(specs))["NFR-1"]) + end + + test "an adapter with two third-party dependencies fails NFR-2" do + specs = conforming + specs[ADAPTER] = spec(ADAPTER, "0.1.0", [CORE, "json", "oj"]) + + assert_equal(:failed, statuses(run_suite(specs))["NFR-2"]) + end + + test "an adapter that does not declare the core at all fails NFR-2" do + specs = conforming + specs[ADAPTER] = spec(ADAPTER, "0.1.0", ["json"]) + + assert_equal(:failed, statuses(run_suite(specs))["NFR-2"]) + end + + # ---- NFR-10 ---- + + test "a unit declaring no runtime floor fails NFR-10" do + specs = conforming + specs[ADAPTER] = spec(ADAPTER, "0.1.0", [CORE, "json"], ruby: ">= 0") + + assert_equal(:failed, statuses(run_suite(specs))["NFR-10"]) + end + + # The half a gate asserting "every gemspec equals the global floor" gets wrong: a HIGHER floor + # on one isolated unit is what NFR-10 asks for, not a defect. + test "a higher floor on an isolated unit passes NFR-10" do + specs = conforming + specs[ADAPTER] = spec(ADAPTER, "0.1.0", [CORE, "json"], ruby: ">= 3.3") + + assert_equal(:passed, statuses(run_suite(specs))["NFR-10"]) + end + + test "a higher-floor unit that the core depends on fails NFR-10" do + specs = { CORE => spec(CORE, "0.1.0", [ADAPTER]), + ADAPTER => spec(ADAPTER, "0.1.0", [], ruby: ">= 3.3"), } + + assert_equal(:failed, statuses(run_suite(specs))["NFR-10"]) + end + + # ---- NFR-14, NFR-15 ---- + + test "a published version disagreeing with the single source fails NFR-14" do + report = run_suite(conforming, versions: { CORE => "0.2.0", ADAPTER => "0.1.0" }) + + assert_equal(:failed, statuses(report)["NFR-14"]) + end + + # R0-2: the assertion's own comment promises this and the first cut passed instead, because an + # empty map skipped every unit and nothing mismatched. "Nobody told us" is not evidence of a + # single source, and NFR-14 is a SHOULD, so the vacuity is recorded without blocking the report. + test "with no single source named, NFR-14 is vacuous rather than a pass" do + report = run_suite(conforming, versions: {}) + + assert_equal(:vacuous, statuses(report)["NFR-14"]) + assert_includes(report.to_s, "no single source of truth was named for any unit") + assert_predicate(report, :passed?, "NFR-14 is a SHOULD, so its vacuity does not block") + end + + # The partial case the comment names: a source that states some units states nothing about the + # rest, so those are skipped rather than failed -- the declaration is the driver's. + test "a single source naming only some units checks those and skips the rest" do + report = run_suite(conforming, versions: { CORE => "0.1.0" }) + + assert_equal(:passed, statuses(report)["NFR-14"]) + end + + test "a runtime version disagreeing with the gemspec fails NFR-15" do + constants = CONSTANTS.merge(ADAPTER => "DexpaceConformancePackagingSuiteTest::FakeGem::Stale") + + assert_equal(:failed, statuses(run_suite(conforming, constants: constants))["NFR-15"]) + end + + # The mutation that proves this is not the assertion a green 0.0.0 tree would pass: a + # not-a-placeholder check alone passes at 0.0.0, which is the version every gem here carries. + test "NFR-15 compares against the gemspec, so a 0.0.0 gemspec is not a free pass" do + specs = { CORE => spec(CORE, "0.0.0", []) } + report = Suite.run(adapters: [], resolve: ->(name) { specs[name] }, + constants: { CORE => "DexpaceConformancePackagingSuiteTest::FakeGem" },) + + assert_equal(:failed, statuses(report)["NFR-15"], + "0.1.0 reported against a 0.0.0 gemspec must fail",) + end + + test "a LOADED gem that defines no VERSION fails NFR-15 rather than vacuating it" do + constants = CONSTANTS.merge(ADAPTER => + "DexpaceConformancePackagingSuiteTest::FakeGem::NoVersion") + + assert_equal(:failed, statuses(run_suite(conforming, constants: constants))["NFR-15"]) + end + + # ---- vacuity ---- + + test "an uninstalled gem is vacuous, never failed" do + report = Suite.run(adapters: [ADAPTER], resolve: ->(_name) {}, constants: CONSTANTS) + + assert_equal([:vacuous], report.results.map(&:status).uniq) + end + + # R0-4: every other case here supplies its own `resolve:`, so the SHIPPED default -- the one a + # real driver gets, and a locked public constant -- ran in no test at all. Its rescue is what + # turns "this gem is not installed" into the :vacuous the suite's contract promises rather than + # the Gem::MissingSpecError a bare `find_by_name` raises, which Runner would report :error. + test "the shipped default resolve answers vacuous for a gem that is not installed" do + absent = "dexpace-no-such-gem-#{Process.pid}" + adapter = "#{absent}-adapter" + report = Suite.run(core: absent, adapters: [adapter], + versions: { absent => "0.0.0", adapter => "0.0.0" },) + + assert_equal([:vacuous], report.results.map(&:status).uniq) + assert_includes(report.to_s, "#{absent} is not installed") + end + + test "an unloaded constant is vacuous, never failed" do + report = run_suite(conforming, constants: { CORE => "NotLoaded::Anywhere" }) + + assert_equal(:vacuous, statuses(report)["NFR-15"]) + end + + # ---- NFR-3, NFR-11, NFR-13, over a seeded shipped tree ---- + + def seeded(lib:, sig:) + root = Dir.mktmpdir("dexpace-packaging") + lib.each { |path, body| write(File.join(root, "lib", path), body) } + sig.each { |path, body| write(File.join(root, "sig", path), body) } + root + end + + def write(path, body) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, body) + end + + HEADED = "# frozen_string_literal: true\n# SPDX-License-Identifier: MIT\n\nmodule Dexpace\nend\n" + BARE = "module Dexpace\nend\n" + + def shipped(lib:, sig:) + root = seeded(lib: lib, sig: sig) + run_suite(conforming, adapters: [], sig_roots: { CORE => File.join(root, "sig") }) + end + + test "a shipped lib file with no signature beside it fails NFR-3" do + report = shipped(lib: { "dexpace.rb" => BARE, "dexpace/extra.rb" => BARE }, + sig: { "dexpace.rbs" => HEADED },) + + assert_equal(:failed, statuses(report)["NFR-3"]) + end + + test "a one-for-one lib and sig tree passes NFR-3" do + report = shipped(lib: { "dexpace.rb" => BARE }, sig: { "dexpace.rbs" => HEADED }) + + assert_equal(:passed, statuses(report)["NFR-3"]) + end + + test "a signature naming an async-framework type fails NFR-11" do + leaking = "# SPDX-License-Identifier: MIT\nmodule Dexpace\n A: Async::Task\nend\n" + report = shipped(lib: { "dexpace.rb" => BARE }, sig: { "dexpace.rbs" => leaking }) + + assert_equal(:failed, statuses(report)["NFR-11"]) + end + + # The negative control that matters: core's OWN Dexpace::Async::Future is not a leak, and a + # scan matching the bare namespace would call it one. + test "the core's own Dexpace::Async namespace is not an async-framework leak" do + own = "# SPDX-License-Identifier: MIT\nmodule Dexpace\n A: Dexpace::Async::Future\nend\n" + report = shipped(lib: { "dexpace.rb" => BARE }, sig: { "dexpace.rbs" => own }) + + assert_equal(:passed, statuses(report)["NFR-11"]) + end + + test "a mention inside a comment is not an async-framework leak" do + commented = "# SPDX-License-Identifier: MIT\n# see Async::Task\nmodule Dexpace\nend\n" + report = shipped(lib: { "dexpace.rb" => BARE }, sig: { "dexpace.rbs" => commented }) + + assert_equal(:passed, statuses(report)["NFR-11"]) + end + + test "a shipped signature with no SPDX header fails NFR-13" do + report = shipped(lib: { "dexpace.rb" => BARE }, sig: { "dexpace.rbs" => BARE }) + + assert_equal(:failed, statuses(report)["NFR-13"]) + assert_includes(report.to_s, "a RuboCop cop cannot reach .rbs") + end + + test "a shipped signature carrying the header passes NFR-13" do + report = shipped(lib: { "dexpace.rb" => BARE }, sig: { "dexpace.rbs" => HEADED }) + + assert_equal(:passed, statuses(report)["NFR-13"]) + end + + # The assertion must discriminate against the REAL tree too, not only against a fixture: 278 + # shipped .rbs files carry no header today, which is the gap phase 10's inbound list owns. + test "NFR-13 against this repository's own shipped signatures reports the real gap" do + root = File.expand_path("../../../..", __dir__) + report = run_suite(conforming, adapters: [], + sig_roots: { CORE => File.join(root, "dexpace-core", "sig") },) + + assert_equal(:failed, statuses(report)["NFR-13"], + "an unconditional vacuity here would check nothing for anyone",) + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/report_phase9_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/report_phase9_test.rb new file mode 100644 index 0000000..06db7fd --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/report_phase9_test.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace/conformance" + +# Phase 9's additions to 8a's Report: the aggregate caller 8a deferred #to_h for, and the +# MUST-level vacuity blocker design R3's absent-artifact rule is only safe because of. +# NFR-4, NFR-17; ASYNC-3, ASYNC-4, XCUT-12, XCUT-22. +# +# 8a's own report_test.rb is left byte-identical: its two pinned renderings -- `waived: D (checks +# D)` and `waived: TRANSPORT-28` -- are what keep `waived (would fail)` a DRIVER's declaration +# rather than a blanket rename that would make the report lie about 8c's two waivers. +class DexpaceConformanceReportPhase9Test < DexpaceTestCase + Report = Dexpace::Conformance::Report + + def result(ids, status, detail: nil) + assertion = Dexpace::Conformance::Assertion.build(ids: ids, name: ids.join("+"), + body: ->(_s) {},) + Dexpace::Conformance::Result.build(assertion: assertion, status: status, detail: detail) + end + + test "merge flattens many reports into one passed? over the whole run" do + merged = Report.merge([Report.new([result(["XCUT-15"], :passed)]), + Report.new([result(["NFR-1"], :failed)]),]) + + assert_equal(2, merged.results.size) + refute_predicate(merged, :passed?) + end + + test "merge inherits every suite's acceptances and would-fail declarations" do + one = Report.new([result(["ASYNC-4"], :vacuous)], accepted_vacuous: { "ASYNC-4" => "§10.5" }) + two = Report.new([result(["ASYNC-3"], :waived)], would_fail: ["ASYNC-3"]) + merged = Report.merge([one, two]) + + assert_predicate(merged, :passed?) + assert_includes(merged.to_s, "accepted MUST-level vacuity (design-sanctioned): ASYNC-4: §10.5") + assert_includes(merged.to_s, "waived (would fail): ASYNC-3") + end + + test "to_h carries one row per result with its ids, status and detail" do + report = Report.new([result(%w[XCUT-13 XCUT-22], :passed)]) + + assert_equal(1, report.to_h[:passed]) + assert_equal(%w[XCUT-13 XCUT-22], report.to_h[:results].first[:ids]) + end + + test "a SHOULD-level vacuity is recorded and does not fail the run" do + report = Report.new([result(["XCUT-12"], :vacuous, detail: "no reactor")]) + + assert_predicate(report, :passed?) + assert_empty(report.blocking_vacuities) + assert_includes(report.to_s, "vacuous: XCUT-12: no reactor") + end + + # The failing fixture the blocker exists for: an unbuilt MUST is not a green run (design R3). + test "an un-waived, un-accepted MUST-level vacuity is a report blocker and fails the run" do + report = Report.new([result(["ASYNC-4"], :vacuous, detail: "no interrupt path")]) + + refute_predicate(report, :passed?) + assert_equal(["ASYNC-4"], report.blocking_vacuities.flat_map { |r| r.assertion.ids }) + assert_includes(report.to_s, "MUST-level vacuity (report blocker): ASYNC-4") + assert_includes(report.to_s, "REPORT BLOCKED: 1 un-waived MUST-level vacuity (design R3)") + end + + test "an accepted MUST-level vacuity carries its citation, renders apart, and does not block" do + report = Report.new([result(["ASYNC-4"], :vacuous)], + accepted_vacuous: { "ASYNC-4" => "design §10.5: no interrupt ordering" },) + + assert_predicate(report, :passed?) + assert_equal(1, report.accepted_vacuities.size) + assert_includes(report.to_s, + "accepted MUST-level vacuity (design-sanctioned): ASYNC-4: design §10.5",) + assert_equal(["ASYNC-4"], report.to_h[:accepted_vacuities].map { |row| row[:ids].first }) + end + + # Decision: acceptance is `all?` over the assertion's MUST-level IDs, never `any?`. An + # acceptance of one ID must not silence a co-carried MUST -- that is the gap staying invisible, + # and it is the exact shape ExecutorSuite's borrowed_executor_survives carries (XCUT-22 AND + # ASYNC-15, both MUST in appendix C). + test "an acceptance naming one of two MUST ids still blocks, because accepted? is all?" do + partial = Report.new([result(%w[XCUT-22 ASYNC-15], :vacuous, detail: "no borrowing entry")], + accepted_vacuous: { "XCUT-22" => "8b files none" },) + both = Report.new([result(%w[XCUT-22 ASYNC-15], :vacuous, detail: "no borrowing entry")], + accepted_vacuous: { "XCUT-22" => "8b files none", + "ASYNC-15" => "clause (b) has no subject here", },) + + refute_predicate(partial, :passed?) + assert_predicate(both, :passed?) + end + + test "an acceptance with no citation is refused at construction, not rendered as a blank" do + assert_raises(::ArgumentError) do + Report.new([result(["ASYNC-4"], :vacuous)], accepted_vacuous: { "ASYNC-4" => " " }) + end + end + + test "a waived result is never counted as a vacuity, accepted or blocking" do + report = Report.new([result(["ASYNC-3"], :waived)], would_fail: ["ASYNC-3"]) + + assert_predicate(report, :passed?) + assert_empty(report.blocking_vacuities) + assert_empty(report.accepted_vacuities) + assert_includes(report.to_s, "waived (would fail): ASYNC-3") + end + + # 8a's rendering is the DEFAULT and stays the default: a waiver that means "this adapter does + # not support the property" (8c's TRANSPORT-14, TRANSPORT-27) must not read as "this would + # fail". The driver declares which is which. + test "a waiver the driver did not declare would-fail keeps 8a's plain rendering" do + report = Report.new([result(["TRANSPORT-14"], :waived)]) + + assert_includes(report.to_s, "waived: TRANSPORT-14") + refute_includes(report.to_s, "would fail") + end + + test "an id appendix C does not hold is reported rather than silently never blocking" do + report = Report.new([result(["XCUT-99"], :vacuous), result(["XCUT-4"], :passed)]) + + assert_equal(["XCUT-99"], report.unknown_ids) + assert_predicate(report, :passed?, "an unknown id never blocks; unknown_ids is what shows it") + end + + test "the preamble still prints first, ahead of every phase-9 section" do + report = Report.new([result(["ASYNC-4"], :vacuous)], preamble: "reads a socket") + + assert_operator(report.to_s, :start_with?, "reads a socket\n") + assert_operator(report.to_s, :end_with?, "vacuity (design R3)") + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/runner_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/runner_test.rb new file mode 100644 index 0000000..15db1b0 --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/runner_test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace/conformance" + +# The five result statuses of 8a's assertion protocol, decided in ONE place for every suite phase +# 9 adds. NFR-17; design P9-10. +class DexpaceConformanceRunnerTest < DexpaceTestCase + Runner = Dexpace::Conformance::Runner + + def assertion(ids, name, &body) + Dexpace::Conformance::Assertion.build(ids: ids, name: name, body: body) + end + + test "an assertion that returns cleanly is passed" do + report = Runner.run([assertion(["X-1"], "clean") { |_s| nil }]) { :subject } + + assert_equal(:passed, report.results.first.status) + end + + test "a Failure is failed and its expected and actual both reach the detail" do + body = lambda do |_s| + raise Dexpace::Conformance::Failure.new("no", expected: 1, actual: 2, + requirement_ids: ["X-1"],) + end + report = Runner.run([assertion(["X-1"], "fails", &body)]) { :subject } + + assert_equal(:failed, report.results.first.status) + assert_includes(report.results.first.detail, "expected 1, got 2") + end + + test "a Vacuous is vacuous and carries its reason, never passed" do + body = ->(_s) { raise Dexpace::Conformance::Vacuous, "no adapter has this path" } + report = Runner.run([assertion(["X-1"], "vacuous", &body)]) { :subject } + + assert_equal(:vacuous, report.results.first.status) + assert_equal("no adapter has this path", report.results.first.detail) + end + + # The rescue order is load-bearing: Vacuous and Failure are both ::StandardError descendants, so + # a bare rescue placed above either turns a vacuity into an :error. + test "any other StandardError is errored, never silently a failure and never a vacuity" do + report = Runner.run([assertion(["X-1"], "boom") { |_s| raise TypeError, "nope" }]) { :subject } + + assert_equal(:error, report.results.first.status) + assert_includes(report.results.first.detail, "TypeError") + end + + test "a waiver matches by requirement id and never by assertion name" do + by_id = Runner.run([assertion(%w[X-1 X-2], "waived") { |_s| raise "never reached" }], + waive: ["X-2"],) { :subject } + by_name = Runner.run([assertion(%w[X-1 X-2], "waived") { |_s| raise "reached" }], + waive: ["waived"],) { :subject } + + assert_equal(:waived, by_id.results.first.status) + assert_equal(:error, by_name.results.first.status, "a name is not a requirement id") + end + + # testing/4ef070df applied to a subject the RUNNER constructs: a fixture shared across a run is + # what made this phase's own conforming executor double fail during planning. + test "the subject block is called once per assertion, never shared across the run" do + built = 0 + Runner.run([assertion(["X-1"], "a") { |_s| nil }, assertion(["X-2"], "b") { |_s| nil }]) do + built += 1 + :subject + end + + assert_equal(2, built) + end + + test "an around wrapper wraps every invocation and the assertion still runs inside it" do + order = [] + around = lambda do |&blk| + order << :before + blk.call + order << :after + end + Runner.run([assertion(["X-1"], "a") { |_s| order << :body }], around: around) { :subject } + + assert_equal(%i[before body after], order) + end + + test "the preamble, the acceptances and the would-fail declarations reach the Report" do + report = Runner.run([assertion(["ASYNC-3"], "a") { |_s| nil }], + waive: ["ASYNC-3"], preamble: "reads a socket", + accepted_vacuous: { "ASYNC-4" => "design §10.5" }, + would_fail: ["ASYNC-3"],) { :subject } + + assert_equal("reads a socket", report.preamble) + assert_equal({ "ASYNC-4" => "design §10.5" }, report.accepted_vacuous) + assert_includes(report.to_s, "waived (would fail): ASYNC-3") + end +end diff --git a/gems/dexpace-conformance/test/dexpace/conformance/shared_instance_test.rb b/gems/dexpace-conformance/test/dexpace/conformance/shared_instance_test.rb new file mode 100644 index 0000000..0c8421b --- /dev/null +++ b/gems/dexpace-conformance/test/dexpace/conformance/shared_instance_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace/conformance" + +# XCUT-11's structural half: per-call mutable state must not live on a shared instance, and the +# two kinds that legitimately do must be declared BY THE DRIVER. Design R8, P9-9. +class DexpaceConformanceSharedInstanceTest < DexpaceTestCase + SharedInstance = Dexpace::Conformance::SharedInstance + + # Frozen with NO ivars -- the easy case, and the one that never reaches the branch that matters. + class FrozenEmpty + def initialize = freeze + end + + # Frozen WITH ivars: the shape three real first-party subjects take + # (Instrumentation::Redactor::DEFAULT, Instrumentation::Logger::NULL). A predicate demanding + # `frozen? && instance_variables.empty?` condemns every one of them, which is the false + # condemnation P9-9 exists to prevent -- so this double is the one that proves the disjunction. + class FrozenWithState + def initialize(policy) + @policy = policy + freeze + end + end + + # Unfrozen, with a latch and its mutex: the shape cross-cutting-invariants/89eb6533 prescribes. + class Latched + def initialize + @closed = false + @lock = Thread::Mutex.new + end + end + + # Per-call state on a shared instance: exactly what XCUT-11 forbids. + class PerCallState + def initialize = (@attempt = 0) + end + + test "a frozen instance with no state conforms" do + assert_nil(SharedInstance.audit(FrozenEmpty.new)) + end + + # The disjunction's first half, asserted against the exact shape the real tree carries. + test "a frozen instance holding state conforms on frozen? alone, with nothing declared" do + assert_nil(SharedInstance.audit(FrozenWithState.new({ "a" => 1 }))) + assert_nil(SharedInstance.audit(Dexpace::Instrumentation::Redactor::DEFAULT)) + assert_nil(SharedInstance.audit(Dexpace::Instrumentation::Logger::NULL)) + end + + test "an unfrozen latch and its mutex conform when the DRIVER declares them" do + assert_nil(SharedInstance.audit(Latched.new, mutable: %i[@closed @lock])) + end + + test "an undeclared mutable ivar on an unfrozen shared instance raises a Failure naming it" do + error = assert_raises(Dexpace::Conformance::Failure) { SharedInstance.audit(PerCallState.new) } + + assert_equal([:@attempt], error.actual) + assert_equal(["XCUT-11"], error.requirement_ids) + end + + test "declaring only some of the state still fails on the rest" do + assert_raises(Dexpace::Conformance::Failure) do + SharedInstance.audit(Latched.new, mutable: [:@lock]) + end + end + + # P9-9: an object that supplied its own exemption list could exempt the per-call state XCUT-11 + # forbids. `mutable:` is the driver's word, and the subject is never consulted. + test "a subject declaring its own per-call state as exempt is not believed" do + self_exempting = Class.new(PerCallState) do + def conformance_mutable_state = [:@attempt] + end + + assert_raises(Dexpace::Conformance::Failure) { SharedInstance.audit(self_exempting.new) } + end + + test "the requirement ids are overridable so a seam suite can cite its own pair" do + error = assert_raises(Dexpace::Conformance::Failure) do + SharedInstance.audit(PerCallState.new, ids: %w[XCUT-11 SEAM-12]) + end + + assert_equal(%w[XCUT-11 SEAM-12], error.requirement_ids) + end +end diff --git a/gems/dexpace-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/gems/dexpace-core/test/dexpace/cross_cutting_invariants_test.rb b/gems/dexpace-core/test/dexpace/cross_cutting_invariants_test.rb new file mode 100644 index 0000000..3fb9be5 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/cross_cutting_invariants_test.rb @@ -0,0 +1,266 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# XCUT-1, XCUT-2, XCUT-3, XCUT-5, XCUT-6, XCUT-7, XCUT-8, XCUT-9, XCUT-10, XCUT-11, XCUT-12, +# XCUT-13, XCUT-14, XCUT-15, XCUT-16, XCUT-17, XCUT-18, XCUT-19, XCUT-20, XCUT-21, XCUT-22, +# XCUT-23, XCUT-24. The first-party driver for dexpace-conformance's InvariantSuite (phase 9's +# design R6 and its plan Tasks 5-8): the suite is written against the loaded core module and a +# handful of factories it cannot build itself, and THIS file supplies them. +# +# Placement. The suite lives in dexpace-conformance and the subjects are core's, so the driver is +# in core's own test tree -- 8a's precedent, where the transport suite's driver lives in +# dexpace-transport-net_http/test/. Core's gemspec is untouched: dexpace-conformance is a path gem +# in the workspace Gemfile and this is a test-time require, exactly as 8a's driver requires it. +# +# One generated test per assertion, each building a FRESH InvariantCase (the Runner's own +# discipline: a fixture shared across a run is what made this phase's planning double pass +# spuriously), so a `-n` filter runs the same code a full run does. A Vacuous is a skip naming the +# reason, a Failure a flunk naming the ids; anything else propagates as an error and is never +# silently a failure. Beside them, ONE report-level test, because a per-assertion skip is not a +# disposition: the report is where a MUST-level vacuity becomes a blocker (plan Task 12a). +# +# MinitestDriver is deliberately not used: 8a wrote it around TransportCase and phase 9 reports +# rather than refactors phase 8's files (design R6). The residue is real -- two driver loops in +# one repository -- and is stated here rather than hidden. +require_relative "../test_helper" +require_relative "../support/auth_fixtures" +require_relative "../support/challenge_fixtures" +require_relative "../support/redirect_fixtures" +require_relative "../support/scripted_transport" +require "dexpace/conformance" + +# rubocop:disable Metrics/ClassLength -- one driver for twenty-eight assertions plus the +# factories none of them can build. Splitting it would put half the factories in a second +# file that has to be read with this one to mean anything, which is .rubocop.yml's own +# recorded argument against counting rather than reading. +class DexpaceCrossCuttingInvariantsTest < DexpaceTestCase + include AuthFixtures + include RedirectFixtures + + Conformance = Dexpace::Conformance + SUITE = Conformance::InvariantSuite + + # XCUT-18's second assertion drives a real adapter's dispatch path, and core ships no adapter: + # its `transport:` factory is 8a's, supplied by the aggregate run (phase 9 plan, Task 16 Step 2), + # and each first-party adapter carries its own wire-boundary test besides. Vacuous HERE and real + # THERE -- which is why it is accepted with a citation rather than waived, and why the MODEL-layer + # XCUT-18 assertion in the same group runs for real in this file. + ACCEPTED_VACUOUS = { + "XCUT-18" => "the call-site re-validation is an adapter property: the aggregate run supplies " \ + "transport: (phase 9 plan, Task 16 Step 2) and phase 8's adapters each carry " \ + "their own wire-boundary test. The model-layer XCUT-18 assertion runs here.", + }.freeze + + # The key the shared stamper writes, and the header the cross-talk observation reads back. + CREDENTIAL_HEADER = "Authorization" + LABEL_HEADER = "X-Conformance-Label" + # One shared, frozen KeyStamper, reached by every thread and both fibers. A CONSTANT rather than + # an ivar of the seam, so the seam's own ivars are exactly the latch, the lock, the count it + # guards and the borrowed resource -- and `mutable:` below stays a statement about lifecycle + # state and not a list of everything the object happens to hold. + STAMPER = Dexpace::Auth::KeyStamper.new(Dexpace::Auth::KeyCredential.new(api_key: "conformance")) + + # The closeable, callable SDK component XCUT-11, XCUT-13 and XCUT-22 are all assertions about. + # + # Assembled from core's own primitives and nothing else: the latch, the ownership rule and the + # once-only release are Dexpace::Closeable's, and the per-call work runs through the shared + # frozen KeyStamper. `owned:` is the whole of XCUT-22 -- a caller-supplied client makes this a + # BORROWING wrapper, so core's #close flips the latch and returns without running #release, which + # is the only thing standing between the SDK and closing a resource it did not create. + class Seam + include Dexpace::Closeable + + # @return [Integer] how many times #release actually ran -- the resource-side count XCUT-13's + # idempotency is measured with, never inferred from #close's return value + attr_reader :release_count + + def initialize(client: nil) + @client = client + @lock = ::Thread::Mutex.new + @release_count = 0 + initialize_closeable(owned: client.nil?) + end + + # No lock is taken here, deliberately: XCUT-11's second clause drives two fibers of ONE thread + # through this method on either side of a Fiber.yield, and Ruby's Mutex is per-fiber and + # non-reentrant, so a lock held across that yield deadlocks. The work is a fresh request per + # call through a frozen shared stamper, so there is nothing to guard. + # + # @param label [String] this call's identity, carried into the request and read back out + # @return [Array(String, String)] the label as the stamped request carries it, and the key + def call(label) + builder = Dexpace::Headers.builder + builder.add(LABEL_HEADER, label) + request = Dexpace::Request.build(method: "GET", url: "https://a.example/x", + headers: builder.build,) + stamped = STAMPER.call(request) + [stamped.headers[LABEL_HEADER].first, stamped.headers[CREDENTIAL_HEADER].first] + end + + private + + # Genuinely closes what it holds, so `owned: false` and not an omission is what spares a + # borrowed client. A seam that never touched its client would pass XCUT-22 by accident. + def release + @lock.synchronize { @release_count += 1 } + @client&.close + nil + end + end + + # Every ivar Seam holds, which is what design R8's predicate measures an unfrozen shared instance + # against: Closeable's three (`@dexpace_owned`, `@dexpace_closed` and the latch mutex it is + # written under), the lock, the count that lock guards, and the borrowed client. Nothing here is + # per-call state, which is the thing XCUT-11 forbids and this declaration makes visible. + SEAM_MUTABLE = %i[ + @dexpace_owned @dexpace_closed @dexpace_close_mutex @lock @release_count @client + ].freeze + + SUITE.assertions.each_with_index do |assertion, index| + define_method(format("test_%02d_%s", n: index, + name: assertion.name.gsub(/\W+/, "_"),)) do + drive(assertion) + end + end + + test "the suite reports no failure, no error and no unaccepted MUST-level vacuity" do + report = SUITE.run(**suite_arguments) + + assert_empty(report.failures.map { |result| described(result) }) + assert_empty(report.errors.map { |result| described(result) }) + assert_empty(report.blocking_vacuities.map { |result| result.assertion.ids.join(", ") }, + "a MUST-level vacuity is a report blocker until it is accepted with a citation",) + assert_predicate(report, :passed?) + end + + # The accepted vacuity is a claim about THIS driver, so it is asserted rather than described: if + # core ever gains a transport factory here, the acceptance is dead weight and this goes red. + test "the one accepted vacuity is the call-site assertion, and it is really vacuous here" do + report = SUITE.run(**suite_arguments) + + assert_equal(1, report.accepted_vacuities.size) + assert_equal(["XCUT-18"], report.accepted_vacuities.first.assertion.ids) + assert_equal(1, report.vacuous.size, "a second vacuity appeared with no citation behind it") + end + + # Design R8's list, audited through the same predicate the suite applies to the seam. Frozen + # instances pass on `frozen?` alone; the two unfrozen ones declare what they hold, and a subject + # that grew a per-call ivar since this list was written goes red here. + test "every shared instance core publishes holds only declared lifecycle state" do + shared_instances.each do |(label, object, declared)| + Conformance::SharedInstance.audit(object, mutable: declared) + rescue Conformance::Failure => error + flunk("#{label}: #{error.message} (declared #{error.expected.inspect}, holds " \ + "#{error.actual.inspect})") + end + end + + private + + def described(result) = "#{result.assertion.ids.join(", ")}: #{result.detail}" + + def drive(assertion) + assertion.call(invariant_case) + rescue Conformance::Vacuous => error + skip("vacuous: #{error.reason}") + rescue Conformance::Failure => error + flunk("#{assertion.ids.join(", ")}: #{error.message} (expected #{error.expected.inspect}, " \ + "got #{error.actual.inspect})") + end + + def invariant_case = Conformance::InvariantCase.new(**suite_arguments.except(:accepted_vacuous)) + + def suite_arguments + { core: ::Dexpace, seam: ->(client: nil) { Seam.new(client: client) }, + mutable: SEAM_MUTABLE, shared: shared_instances, bounded_map: bounded_map_factory, + bounded_map_store: bounded_map_store_reader, cnonce: cnonce_driver, + redirect_hops: redirect_driver, credential_hop: credential_driver, + accepted_vacuous: ACCEPTED_VACUOUS, } + end + + # Dexpace::BoundedMap is a private_constant of Dexpace, reachable by its BARE name from a + # full-nesting body and by Dexpace.const_get, but not by the scoped spelling (4a's P4-3) -- which + # is exactly why the suite takes a factory instead of naming it. + def bounded_map_factory = ->(cap:) { ::Dexpace.const_get(:BoundedMap).new(cap: cap) } + + # XCUT-14's drain clause needs the backing Hash, which BoundedMap keeps private; the driver knows + # the name, so the suite never reaches into the object. + def bounded_map_store_reader = ->(map) { map.instance_variable_get(:@h) } + + # 6c's DigestHandler draws its cnonce inside #authorization_for, so the rendered header value is + # the only place the drawn bytes are observable. + def cnonce_driver + lambda do |source| + handler = Dexpace::Auth::DigestHandler.new( + Dexpace::Auth::PasswordCredential.build(username: "u", password: "p"), + cnonce_source: source, + ) + handler.authorization_for(Dexpace::Auth::Challenges.parse(ChallengeFixtures::DIGEST_MD5), + https_request,) + end + end + + # One hop through 6b's real step over a scripted 3xx, answering the RE-ISSUED request. The + # downgrade case never reaches the second script entry: the step raises before it re-issues, + # which is the refusal XCUT-17 clause (d) is about. + def redirect_driver + lambda do |from:, to:, headers:| + transport = ScriptedTransport.new([response_with(302, location: to), response_with(200)]) + # The suite hands each name its FULL value list, because XCUT-17 is about a header that may + # legitimately repeat; RedirectFixtures#seed_request adds one value per call. + seed = seed_request(from, headers: headers.flat_map { |n, vs| Array(vs).map { [n, _1] } }) + redirect_pipeline(redirect_step, transport).call(seed) + sent_requests(transport).last + end + end + + # One drive of 6c's real step, with 6b's cross-origin marker forked into the REDIRECT slot the + # step reads. The HTTPS guard raises out of the pipeline, which is the loud refusal XCUT-16 names. + def credential_driver + lambda do |url:, cross_origin: false| + step = Dexpace::Auth::Step.build(stamper: STAMPER) + transport = ScriptedTransport.new([closable_response(200)]) + pipeline = auth_pipeline(step, transport, redirect_state: { cross_origin: cross_origin }) + pipeline.call(Dexpace::Request.build(method: "GET", url: url, + headers: Dexpace::Headers::EMPTY,)) + transport.calls.last.first + end + end + + # Design R8's nine subjects and the rest core publishes, as [label, object, declared ivars]. + # A frozen instance declares nothing: it conforms on `frozen?` alone, whatever it holds. + def shared_instances + instrumentation_singletons + [ + ["Dexpace::ContextStore.default", Dexpace::ContextStore.default, [:@map]], + ["Dexpace::Clock::SYSTEM", Dexpace::Clock::SYSTEM, []], + ["Dexpace::Configuration::EMPTY", Dexpace::Configuration::EMPTY, []], + ["Dexpace::Resilience::RetrySettings", Dexpace::Resilience::RetrySettings.build, []], + ["Dexpace::Resilience::RetryStep", Dexpace::Resilience::RetryStep.build, []], + ["Dexpace::Resilience::AsyncRetryStep", Dexpace::Resilience::AsyncRetryStep.build, []], + ["Dexpace::Redirect::Step", Dexpace::Redirect::Step.build, []], + ["Dexpace::Auth::KeyStamper", STAMPER, []], + ["Dexpace::Auth::Step", Dexpace::Auth::Step.build(stamper: STAMPER), []], + # The one unfrozen pillar: the pipeline both runtimes share. Closeable's three, the frozen + # entry and step lists it resolved once, the transport it dispatches to and the driver class + # it instantiates per call -- Cursor is 4c's deliberate per-call state and lives on the stack, + # never here. + ["Dexpace::Pipeline.standard", Dexpace::Pipeline.standard(ScriptedTransport.new([])), + %i[@dexpace_owned @dexpace_closed @dexpace_close_mutex @entries @steps @transport + @driver_class],], + ] + end + + # 5c's no-op singletons and 5b's redaction pair: every one frozen, which is R8's first half. + def instrumentation_singletons + instrumentation = Dexpace::Instrumentation + { "NO_SPAN" => instrumentation::NO_SPAN, "NO_TRACER" => instrumentation::NO_TRACER, + "NO_TRACER_FACTORY" => instrumentation::NO_TRACER_FACTORY, + "NO_SCOPE" => instrumentation::NO_SCOPE, "NO_METER" => instrumentation::NO_METER, + "NULL_SINK" => instrumentation::NULL_SINK, "NULL (http tracer)" => instrumentation::NULL, + "Logger::NULL" => instrumentation::Logger::NULL, + "Redactor::DEFAULT" => instrumentation::Redactor::DEFAULT, + "RedactionPolicy::DEFAULT" => instrumentation::RedactionPolicy::DEFAULT, } + .map { |name, object| ["Dexpace::Instrumentation::#{name}", object, []] } + end +end +# rubocop:enable Metrics/ClassLength diff --git a/gems/dexpace-serde-json/test/dexpace/serde/json/conformance_test.rb b/gems/dexpace-serde-json/test/dexpace/serde/json/conformance_test.rb new file mode 100644 index 0000000..d56c563 --- /dev/null +++ b/gems/dexpace-serde-json/test/dexpace/serde/json/conformance_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# SEAM-20, SERDE-3, SERDE-9. The first-party driver for dexpace-conformance's CodecSuite (phase 9's +# plan Task 10), placed in the adapter gem's own test tree exactly as 8a placed the transport +# suite's driver in dexpace-transport-net_http/test/. +# +# This gem's own seam_conformance_test.rb stays exactly as 7a wrote it and is NOT replaced by this +# file. The two are different claims: that one asserts six properties against THIS codec, four of +# which are not portable (SEAM-21's explicit type token, SERDE-4's buffer profile, SERDE-12's +# I/O-error pass-through, SERDE-29's shareability); this one asserts the two portable properties +# through the shared suite, which is what the post-v1 dexpace-serde-oj will be held to as well. +# Running both is the point -- a codec that passed the shared suite and failed the local one would +# be a codec the shared suite is too weak for, which is a finding about the suite. +require_relative "../../../test_helper" +require "dexpace/serde/json" +require "dexpace/conformance" + +# The JSON codec's run of the shared codec suite: one generated test per assertion, plus the +# report-level check and one run under every option this adapter accepts. +class DexpaceSerdeJSONConformanceTest < DexpaceTestCase + Conformance = Dexpace::Conformance + SUITE = Conformance::CodecSuite + + # The one witness the two assertions need: the parsed value as it is. Named, not a lambda -- + # Serde.witness? is a respond_to? on .dexpace_load and NOTHING else makes a witness, so a + # #call-shaped double would not be one (7a's R2). + module PassThrough + # @param parsed [Object] whatever the codec's parser produced + # @return [Object] the same value + def self.dexpace_load(parsed, _ctx) = parsed + end + + # The source type THIS codec's #load reads. A different codec's driver passes a different one, + # which is why the suite takes a factory rather than naming a class. + SOURCE = ->(text) { Dexpace::IO::BufferedSource.of_bytes(text.b) } + + SUITE.assertions.each_with_index do |assertion, index| + define_method(format("test_%02d_%s", n: index, + name: assertion.name.gsub(/\W+/, "_"),)) do + drive(assertion) + end + end + + test "the suite reports no failure, no error and no MUST-level vacuity" do + report = SUITE.run(**suite_arguments) + + assert_empty(report.failures.map { |result| described(result) }) + assert_empty(report.errors.map { |result| described(result) }) + assert_empty(report.blocking_vacuities.map { |result| result.assertion.ids.join(", ") }) + assert_empty(report.vacuous, "nothing here is vacuous: this gem supplies every factory") + assert_predicate(report, :passed?) + end + + # SERDE-3's "even when the codec's own auto-close feature is enabled" reaches every option this + # adapter accepts, so the portable assertions are driven under each rather than under the + # default alone -- the same widening 7a applied to its own suite. + test "the portable seam properties hold under every accepted option" do + [{ max_nesting: 4 }, { allow_nan: true }, { allow_duplicate_key: true }, { script_safe: true }, + { encoders: {} },].each do |options| + report = SUITE.run(**suite_arguments(-> { Dexpace::Serde::JSON.build(options) })) + + assert_predicate(report, :passed?, "#{options.inspect}: #{report}") + end + end + + private + + def described(result) = "#{result.assertion.ids.join(", ")}: #{result.detail}" + + def drive(assertion) + assertion.call(Conformance::CodecCase.new(**suite_arguments)) + rescue Conformance::Vacuous => error + skip("vacuous: #{error.reason}") + rescue Conformance::Failure => error + flunk("#{assertion.ids.join(", ")}: #{error.message} (expected #{error.expected.inspect}, " \ + "got #{error.actual.inspect})") + end + + # `.default` is a FRESH instance per call (7a's P7-4), so every assertion gets a codec of its own + # without the driver arranging it. + def suite_arguments(build = -> { Dexpace::Serde::JSON.default }) + { build: build, witness: PassThrough, source: SOURCE } + end +end diff --git a/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/gates/invariants/delegates_cause.rb b/test/fixtures/gates/invariants/delegates_cause.rb new file mode 100644 index 0000000..30438b6 --- /dev/null +++ b/test/fixtures/gates/invariants/delegates_cause.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The positive control: this file delegates to the single walk and names `.cause` only in prose +# and in a requirement ID. A grep would report all three lines; the AST reports none. +# +# XCUT-9: "any classification that walks an error's cause chain MUST be cycle-safe". The walk +# lives in Dexpace.each_cause and nowhere else, so a classifier reads the chain through it. +module Delegates + # Walks the cause chain -- through the one walk, never by reading #cause here. + def self.retryable?(error) + ::Dexpace.each_cause(error).any? { |one| one.respond_to?(:retryable?) && one.retryable? } + end + + def self.prose = "read the cause chain through Dexpace.each_cause, never error.cause" +end diff --git a/test/fixtures/gates/invariants/hash_shapes.rb b/test/fixtures/gates/invariants/hash_shapes.rb new file mode 100644 index 0000000..bdaab33 --- /dev/null +++ b/test/fixtures/gates/invariants/hash_shapes.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Every Hash-valued instance-variable shape, decidable and not. +module Bad + class Store + def initialize(seed) + @a = {} + @b = Hash.new(0) + @c = ::Hash.new + @d = {}.compare_by_identity + @e = build_map # undecidable: the stated gap + @f = seed # undecidable: the stated gap + @cap = 1024 # not a Hash; must NOT be reported + @name = "store" # not a Hash; must NOT be reported + end + + def lazily + @g ||= {} # decidable through OP_ASGN_OR + end + + def build_map = {} + end +end diff --git a/test/fixtures/gates/invariants/reflective_cause.rb b/test/fixtures/gates/invariants/reflective_cause.rb new file mode 100644 index 0000000..3d0c532 --- /dev/null +++ b/test/fixtures/gates/invariants/reflective_cause.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Every shape that reaches #cause by NAME rather than by call syntax, plus the one that is +# statically undecidable and is the gate's stated gap. +module Bad + def self.a(error) = error.cause + def self.b(error) = error&.cause + def self.c(error) = error.send(:cause) + def self.d(error) = error.__send__(:cause) + def self.e(error) = error.public_send(:cause) + def self.f(error) = error.method(:cause).call + def self.g(error) = error.send("cause") # a String literal, caught the same way + def self.h(error, name) = error.send(name) # undecidable: the stated gap +end diff --git a/test/fixtures/gates/invariants/seam_shapes.rb b/test/fixtures/gates/invariants/seam_shapes.rb new file mode 100644 index 0000000..0631a3d --- /dev/null +++ b/test/fixtures/gates/invariants/seam_shapes.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Every way core could name a concrete seam implementation, and four constants it legitimately +# owns in the same namespaces. +module Dexpace + module Bad + A = Dexpace::Serde::JSON + B = ::Dexpace::Serde::JSON + C = Serde::JSON + D = Object.const_get("Dexpace::Transport::NetHTTP") + E = Object.const_get(:"Dexpace::Async::Thread") # a Symbol literal, caught the same way + F = Dexpace::Serde::JSON::Codec # a leaf pair deeper in the path + + OK1 = Dexpace::Registry # a core constant; must NOT be reported + OK2 = Dexpace::Serde::Error # the seam's own error type; must NOT be reported + OK3 = Async::Future # core's own async surface; must NOT be reported + OK4 = Instrumentation::Severity # core's own instrumentation; must NOT be reported + end +end diff --git a/test/fixtures/gates/invariants/walks_cause.rb b/test/fixtures/gates/invariants/walks_cause.rb new file mode 100644 index 0000000..bb465bd --- /dev/null +++ b/test/fixtures/gates/invariants/walks_cause.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# A .cause in a comment and in a string, which a grep reports and the AST does not. +module Walks + def self.a(error) = error.cause # :CALL + def self.b(error) = error&.cause # :QCALL + def self.c(errors) = errors.map(&:cause) # :BLOCK_PASS + def self.d(logger, error) = logger.event(:warn).cause(error).emit # carries an argument + def self.s = "error.cause in a string" + + class Chained < ::StandardError + def bare = cause # :VCALL + def parens = cause() # :FCALL + end +end diff --git a/test/fixtures/gates/invariants/warns_unused.rb b/test/fixtures/gates/invariants/warns_unused.rb new file mode 100644 index 0000000..ebfb4d4 --- /dev/null +++ b/test/fixtures/gates/invariants/warns_unused.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Parsed, never required: this file's own -w diagnostic IS the input. `e` is bound and unused -- +# the shape 8a filed before commit 152ec6a dropped the binding, `rescue ::StandardError => e` +# inside Adapter#dispatch, which emits "assigned but unused variable - e" on 3.2.11, 3.3.12, +# 3.4.10 and 4.0.6 when $VERBOSE is true. Nothing here names #cause or assigns a Hash ivar, so +# every gate in the invariant set must report it CLEAN; what it proves is that the scan does not +# RAISE under the warnings-fatal test case (NFR-6). +module WarnsUnused + def self.call(pump) + pump.head + rescue ::StandardError => e + pump.close + raise + end +end diff --git a/test/fixtures/gates/invariants/workspace/gems/dexpace-core/lib/dexpace/each_cause.rb b/test/fixtures/gates/invariants/workspace/gems/dexpace-core/lib/dexpace/each_cause.rb new file mode 100644 index 0000000..10c6680 --- /dev/null +++ b/test/fixtures/gates/invariants/workspace/gems/dexpace-core/lib/dexpace/each_cause.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The ALLOWLISTED file, at the path CAUSE_WALK_ALLOWED names, so a fixture run proves the +# allowlist is joined to the gate root rather than to the process's CWD: this walk must NOT be +# reported while the sibling one is. +module Dexpace + def self.each_cause(error) + seen = error + seen = seen.cause while seen.cause + seen + end +end diff --git a/test/fixtures/gates/invariants/workspace/gems/dexpace-core/lib/dexpace/offender.rb b/test/fixtures/gates/invariants/workspace/gems/dexpace-core/lib/dexpace/offender.rb new file mode 100644 index 0000000..1b15003 --- /dev/null +++ b/test/fixtures/gates/invariants/workspace/gems/dexpace-core/lib/dexpace/offender.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Deliberately non-conforming, in core's own tree: a second cause walk (XCUT-9) and a reference to +# a concrete adapter namespace (SEAM-2). Both must be reported when the gates run against this +# workspace. +module Dexpace + module Offender + def self.root(error) + error.cause + end + + def self.codec + Dexpace::Serde::JSON::Codec + end + end +end diff --git a/test/fixtures/gates/invariants/workspace/gems/dexpace-probe/lib/dexpace/probe/cache.rb b/test/fixtures/gates/invariants/workspace/gems/dexpace-probe/lib/dexpace/probe/cache.rb new file mode 100644 index 0000000..b3c70e0 --- /dev/null +++ b/test/fixtures/gates/invariants/workspace/gems/dexpace-probe/lib/dexpace/probe/cache.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Deliberately non-conforming, in a gem that is not core: an instance-lived Hash keyed by whatever +# a caller hands it, which is the shape XCUT-14 reserves for Dexpace::BoundedMap. In a gem OTHER +# than core so the fixture also proves the bounded_map scan reaches every gem's lib/ while +# seam_names reaches core's alone. +module Dexpace + module Probe + class Cache + def initialize + @by_origin = {} + end + + def store(origin, value) + @by_origin[origin] = value + end + end + end +end diff --git a/test/fixtures/gates/serde_boundary/pending_probe.rb b/test/fixtures/gates/serde_boundary/pending_probe.rb new file mode 100644 index 0000000..95c01a4 --- /dev/null +++ b/test/fixtures/gates/serde_boundary/pending_probe.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The deliberately failing fixture for gates:serde_boundary's PENDING-empty clause (SSE-37). +# +# The list is a frozen constant of the tool, so there is no environment variable or gate root that +# can make the TASK see a non-empty one -- and without this the task's abort branch was never +# driven: a mutation that deleted the abort outright left every test green. Loaded with +# `RUBYOPT=-r`, it requires the tool first (so the task's own require_relative is a +# no-op) and prepends one row onto `.pending`. The glob matches nothing, which is exactly the +# state a pending row is in. +require_relative "../../../../tools/serde_boundary" + +module SerdeBoundaryPendingProbe + ROW = ["gems/dexpace-core/lib/dexpace/nowhere/**/*.rb", + "a fixture row, so the task's abort branch is driven",].freeze + + def pending(root, list: nil) + super(root, list: (list || [ROW])) + end +end + +SerdeBoundary.singleton_class.prepend(SerdeBoundaryPendingProbe) diff --git a/test/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/appendix_b_test.rb b/test/gates/appendix_b_test.rb new file mode 100644 index 0000000..1562da2 --- /dev/null +++ b/test/gates/appendix_b_test.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The appendix-B coverage map. Design R7, P9-7, P9-8. +# +# Three decidable checks; the map's own preamble states what they do NOT establish -- that a +# referenced test asserts the behaviour its item describes -- because nothing mechanical can, +# short of re-implementing the assertion. +require_relative "../support/gate_case" +require_relative "../../tools/appendix_b" + +class AppendixBTest < GateCase + SPEC = File.join(ROOT, "docs/product-spec/appendix-b-conformance-test-checklist.md") + MAP = File.join(ROOT, "gems/dexpace-conformance/APPENDIX_B.md") + SECTIONS = { "B.1" => 10, "B.2" => 6, "B.3" => 7, "B.4" => 8, "B.5" => 6, + "B.6" => 5, "B.7" => 6, "B.8" => 6, "B.9" => 7, }.freeze + + test "the map carries exactly one row per checklist item" do + assert_equal(61, AppendixB.spec_item_count(SPEC)) + assert_equal(AppendixB.spec_item_count(SPEC), AppendixB.rows(MAP).size) + end + + test "the per-section counts match the specification's own" do + assert_equal(SECTIONS, AppendixB.spec_counts_by_section(SPEC)) + assert_equal(SECTIONS, AppendixB.counts_by_section(MAP)) + end + + # Strictly stronger than "every row names at least one ID", and it makes the distinct-ID + # coverage check hold BY CONSTRUCTION: the union of 61 equal sets is appendix B's own 276. + test "every row's ID column equals the set parsed from that item's own text" do + spec = AppendixB.spec_items(SPEC).to_h { |(s, i, t)| [[s, i.to_s], AppendixB.ids_in(t).sort] } + wrong = AppendixB.rows(MAP).reject do |row| + spec.fetch([row[:section], row[:item]], nil) == row[:ids].sort + end + + assert_empty(wrong.map { |row| "#{row[:section]} item #{row[:item]}" }) + end + + test "the 61 items name 276 distinct ids, none repeated across items, at most 12 in one" do + sets = AppendixB.spec_items(SPEC).map { |(_, _, text)| AppendixB.ids_in(text) } + + assert_equal(276, sets.flatten.uniq.size) + assert_equal(sets.flatten.size, sets.flatten.uniq.size, "no ID appears in two items") + assert_equal(12, sets.map(&:size).max) + end + + test "the id scanner does not mistake ISO-8601 or an RFC number for a requirement id" do + assert_empty(AppendixB.ids_in("ISO-8601 dates round-trip and RFC-3986 encoding applies")) + assert_equal(%w[SERDE-24], AppendixB.ids_in("ISO-8601 dates round-trip (SERDE-24)")) + end + + test "every row's evidence path exists on disk" do + missing = AppendixB.rows(MAP).map { |row| row[:evidence] } + .reject { |path| path.nil? || File.exist?(File.join(ROOT, path)) } + + assert_empty(missing) + end + + test "every row carries one of the statuses the map's own table names" do + unknown = AppendixB.rows(MAP).map { |row| row[:status] }.uniq - AppendixB::STATUSES + + assert_empty(unknown) + end + + # `- [ ]` and never `- [`: an item ticked by hand would otherwise vanish from the count, which is + # the drift this check exists to catch. + test "the item scanner counts unticked boxes only" do + ticked = "- [x] a ticked item (PAGE-1)\n- [ ] an unticked one (PAGE-2)\n" + path = File.join(Dir.mktmpdir("appendix-b"), "spec.md") + File.write(path, "### B.1 Pagination\n\n#{ticked}") + + assert_equal(1, AppendixB.spec_item_count(path)) + end + + # Regenerating must be a no-op on a current map: the generator carries every hand-written + # evidence and status cell over, so a regeneration never silently rewrites a judgement. + test "regenerating the map from the specification reproduces it byte for byte" do + assert_equal(File.read(MAP), AppendixB.generate(SPEC, MAP)) + end +end diff --git a/test/gates/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/test/gates/invariant_gates_test.rb b/test/gates/invariant_gates_test.rb new file mode 100644 index 0000000..05d9859 --- /dev/null +++ b/test/gates/invariant_gates_test.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The three repository-wide invariant scans. XCUT-9, XCUT-14, SEAM-2; design R4. +# +# A scanner that let a scanned file's own -w diagnostic reach `Warning.warn` would redden any +# suite with a warnings-fatal hook over a file it merely READS. That property is proven here by +# RECORDING the warning rather than by arming the raiser: `test/gates/` runs on GateCase, which +# does not install FatalWarnings, and installing it from a support file arms it for the whole +# process -- where three deliberately-invalid gemspec fixtures and YARD's own load-time warnings +# had always reached Warning.warn harmlessly. +require_relative "../support/gate_case" +require_relative "../support/gate_warning_capture" +require_relative "../../tools/invariant_gates" + +# rubocop:disable Metrics/ClassLength -- three scans, each with its own fixture, its own +# allowlist and its own rake task, in the one file a reader checking "do the invariant +# gates still hold?" opens. +class InvariantGatesTest < GateCase + include GateWarningCapture + + FIXTURES = File.expand_path("../fixtures/gates/invariants", __dir__) + + def fixture(name) = File.join(FIXTURES, name) + + # ---- gates:cause_walk ---- + + test "cause_walk reports CALL, QCALL, VCALL, FCALL and &:cause, not an argument-carrying send" do + offences = InvariantGates.cause_walk([fixture("walks_cause.rb")]) + + assert_equal(5, offences.size, + "six sends named cause; 5b's builder shape .cause(error) is not a walk",) + assert_includes(offences.first, "XCUT-9") + end + + test "cause_walk reports every reflective send shape, by Symbol and by String" do + offences = InvariantGates.cause_walk([fixture("reflective_cause.rb")]) + + assert_equal(7, offences.size, + "send/__send__/public_send/method by Symbol, send by String, plus . and &. -- " \ + "7 of 8; send(variable) is undecidable and is the gate's stated gap",) + end + + test "cause_walk is clean for a file that delegates and names cause only in prose" do + assert_empty(InvariantGates.cause_walk([fixture("delegates_cause.rb")])) + end + + test "cause_walk honours its allowlist so the single walk's own file passes" do + assert_empty(InvariantGates.cause_walk([fixture("walks_cause.rb")], + allowed: [fixture("walks_cause.rb")],)) + end + + # ---- gates:bounded_map ---- + + test "bounded_map reports every statically decidable Hash shape" do + offences = InvariantGates.bounded_map([fixture("hash_shapes.rb")], allowed: {}) + + assert_equal(5, offences.size, + "literal, Hash.new, ::Hash.new, a chained call on a literal and an ||= -- " \ + "5 of 7; a method return and a parameter are the stated gap",) + assert_includes(offences.first, "XCUT-14") + end + + test "bounded_map is clean when the file is allowlisted with a reason" do + allowed = { fixture("hash_shapes.rb") => "keys are frozen constants, not caller input" } + + assert_empty(InvariantGates.bounded_map([fixture("hash_shapes.rb")], allowed: allowed)) + end + + # The allowlist IS the adjudication, so a stale entry silently re-opens a hole. The planning + # adjudication named `configuration.rb`, which holds no Hash ivar at all -- 5a's accumulators + # are in `configuration/builder.rb` -- and a check that only asserted the file EXISTS passed + # over it for a whole round. So this asserts both halves: the path exists AND the scan still + # reports there. XCUT-14. + test "every bounded_map allowlist entry names a live file that the scan still reports" do + refute_empty(InvariantGates::BOUNDED_MAP_ALLOWED) + + InvariantGates::BOUNDED_MAP_ALLOWED.each do |path, reason| + absolute = File.join(ROOT, path) + + assert_path_exists(absolute, "allowlisted path no longer exists; the entry is stale") + refute_empty(reason.to_s.strip, "#{path} is allowlisted with no reason") + refute_empty(InvariantGates.bounded_map([absolute], allowed: {}), + "#{path} is allowlisted but the scan reports nothing there, so the entry " \ + "silences nothing -- which is exactly how a stale entry survives",) + end + end + + # ---- gates:seam_names ---- + + test "seam_names matches an adapter's leaf namespace, not a literal spelling" do + offences = InvariantGates.seam_names([fixture("seam_shapes.rb")]) + + assert_equal(7, offences.size, + "qualified, root-qualified, bare, a const_get String, a const_get Symbol and a " \ + "leaf pair deeper in a path, which reports at both levels",) + end + + test "seam_names permits core's own constants in the seam namespaces" do + offences = InvariantGates.seam_names([fixture("seam_shapes.rb")]).join("\n") + + refute_includes(offences, "Dexpace::Registry") + refute_includes(offences, "Serde::Error") + refute_includes(offences, "Async::Future") + refute_includes(offences, "Instrumentation::Severity") + end + + # ---- the scanner's own warning window ---- + + # A scanned file's own -w diagnostics reach Warning.warn like any other parse. The first + # assertion proves the fixture is not inert; the third proves AstScan.parse's $VERBOSE window + # holds. Only under -w: SuiteRunner appends it, so `rake test:gates` sees this and a bare + # `ruby test/gates/...` does not. + test "a file whose own -w diagnostics reach Warning.warn is scanned without emitting one" do + unless $VERBOSE + skip("the fixture's diagnostic is only emitted under -w; SuiteRunner appends it") + end + + bare = capture_warnings { ::RubyVM::AbstractSyntaxTree.parse_file(fixture("warns_unused.rb")) } + through_the_gate = capture_warnings { InvariantGates.cause_walk([fixture("warns_unused.rb")]) } + + assert_equal(1, bare.size, "the fixture is inert, so this test could not discriminate") + assert_includes(bare.first.first, "assigned but unused variable - e") + assert_empty(through_the_gate, "AstScan.parse's $VERBOSE window did not hold") + end + + test "the scanner leaves $VERBOSE exactly as it found it" do + armed = $VERBOSE + InvariantGates.cause_walk([fixture("delegates_cause.rb")]) + + assert_equal(armed, $VERBOSE) + end + + # ---- the three gates over the real tree ---- + + test "all three gates are clean over this repository, which is what makes them blocking" do + lib = Dir.glob("gems/*/lib/**/*.rb", base: ROOT).map { |path| File.join(ROOT, path) } + core = Dir.glob("gems/dexpace-core/lib/**/*.rb", base: ROOT).map do |path| + File.join(ROOT, path) + end + allowed = InvariantGates::BOUNDED_MAP_ALLOWED.transform_keys { |key| File.join(ROOT, key) } + + assert_empty(InvariantGates.cause_walk(lib, allowed: [File.join(ROOT, *CAUSE_WALK)])) + assert_empty(InvariantGates.bounded_map(lib, allowed: allowed)) + assert_empty(InvariantGates.seam_names(core)) + end + + CAUSE_WALK = ["gems/dexpace-core/lib/dexpace/each_cause.rb"].freeze + + # ---- the three RAKE TASKS, against a deliberately failing workspace ---- + + # Every repository-reading gate here accepts DEXPACE_GATE_ROOT and is driven against a fixture + # that must fail it. These three were not, and the omission hid a real defect: the tasks handed + # the scanner paths made relative to `root`, which the scanner then opened relative to the + # PROCESS'S CWD -- the same directory only when DEXPACE_GATE_ROOT is unset. Under a fixture root + # every open raised Errno::ENOENT, so the tasks could never have been shown to reject anything. + # Found and fixed in this phase; these three tests are what keep it fixed. + WORKSPACE = File.join(FIXTURES, "workspace") + + test "the cause_walk task rejects a fixture workspace, with its allowlist under that root" do + _out, err, status = rake("gates:cause_walk", "DEXPACE_GATE_ROOT" => WORKSPACE) + + refute_predicate(status, :success?) + assert_includes(err, "gems/dexpace-core/lib/dexpace/offender.rb:10") + assert_includes(err, "XCUT-9") + refute_includes(err, "each_cause.rb", + "the allowlist was joined to the CWD rather than to the gate root",) + end + + test "the bounded_map task rejects a fixture workspace, reaching a gem that is not core" do + _out, err, status = rake("gates:bounded_map", "DEXPACE_GATE_ROOT" => WORKSPACE) + + refute_predicate(status, :success?) + assert_includes(err, "gems/dexpace-probe/lib/dexpace/probe/cache.rb") + assert_includes(err, "@by_origin") + assert_includes(err, "XCUT-14") + end + + test "the seam_names task rejects a fixture workspace and scans core alone" do + _out, err, status = rake("gates:seam_names", "DEXPACE_GATE_ROOT" => WORKSPACE) + + refute_predicate(status, :success?) + assert_includes(err, "gems/dexpace-core/lib/dexpace/offender.rb") + assert_includes(err, "Dexpace::Serde::JSON") + refute_includes(err, "dexpace-probe", "seam_names is core's scan, not every gem's") + end + + test "all three tasks pass over this repository, which is what makes them blocking" do + %w[gates:cause_walk gates:bounded_map gates:seam_names].each do |task| + out, err, status = rake(task) + + assert_predicate(status, :success?, "#{task}: #{err}") + assert_includes(out, task) + end + end +end +# rubocop:enable Metrics/ClassLength diff --git a/test/gates/phase9_ruby_facts_test.rb b/test/gates/phase9_ruby_facts_test.rb new file mode 100644 index 0000000..0ab818f --- /dev/null +++ b/test/gates/phase9_ruby_facts_test.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The Ruby facts phase 9's machinery rests on, re-measured on the interpreter running this suite +# rather than inherited from the design's planning run. Design "The verified Ruby facts this phase +# is built on"; XCUT-9, XCUT-14, NFR-4, NFR-6, NFR-17. +# +# Two facts the plan stated are corrected here against the tree as built, and each correction is +# the assertion rather than a comment: +# +# * `.ruby-version` EXISTS and is the development pin; the plan says three times that it does +# not, and `gates:versions` asserts the file against VERSIONS on every run. +# * The `minitest/mock` split is invisible INSIDE the bundle, because phase 0 Task 2's +# `minitest ~> 5.25` pin has landed: `assert_equal(major < 6, mock_available)` reduces to +# `true == true` on every row. The honest measurement is a child process with the BUNDLE_* +# keys cleared, which is the shape gems/dexpace-serde-json/test/dexpace/serde/json/floor_test.rb +# uses for the same reason. +require "stringio" +require_relative "../support/gate_case" +require_relative "../support/gate_warning_capture" +require_relative "../../tools/versions" + +# rubocop:disable Metrics/ClassLength -- ten interpreter facts in one file, deliberately. Each +# is a measurement this phase's scans rest on, and a second class would only move half of +# them somewhere a reader checking "is this fact still true?" would not look. +class Phase9RubyFactsTest < GateCase + include GateWarningCapture + + WARNS_UNUSED = File.expand_path("../fixtures/gates/invariants/warns_unused.rb", __dir__) + + test "a send is one of three AST node types and a :CALL-only scan misses safe navigation" do + types = [] + walk(::RubyVM::AbstractSyntaxTree.parse("a.cause; b&.cause; cause")) do |node| + name = node.type == :VCALL ? node.children[0] : node.children[1] + types << node.type if name == :cause + end + + assert_equal(%i[CALL QCALL VCALL], types) + end + + test "a Symbol literal is :LIT below Ruby 3.4 and :SYM at and above it" do + found = [] + walk(::RubyVM::AbstractSyntaxTree.parse("e.send(:cause)")) do |node| + found << node.type if %i[LIT SYM].include?(node.type) + end + expected = ::Gem::Version.new(::RUBY_VERSION) < ::Gem::Version.new("3.4.0") ? [:LIT] : [:SYM] + + assert_equal(expected, found, + "a reflective-send scan naming only one of the two is strictest on the row it " \ + "was written on and blind on the others",) + end + + # The PARSER is silent for well-formed input; the SCANNED FILE's own -w diagnostics are not, and + # they arrive through Warning.warn exactly as at require time. Only under $VERBOSE: SuiteRunner + # appends -w, so `rake test:gates` sees them and a bare `ruby test/gates/...` does not. + test "a scanned file's own -w diagnostics reach Warning.warn, which the scanner silences" do + skip("this fact is only observable under -w; SuiteRunner appends it") unless $VERBOSE + + seen = capture_warnings { ::RubyVM::AbstractSyntaxTree.parse_file(WARNS_UNUSED) } + + assert_equal(1, seen.size) + assert_includes(seen.first.first, "assigned but unused variable - e") + assert_nil(seen.first.last, "no category, so Warning[:deprecated] = false cannot reach it") + end + + # $VERBOSE = false silences only the -w class: a duplicated hash key warns whatever $VERBOSE is + # and would still raise under FatalWarnings, so the window AstScan.parse opens is nil, not false. + # + # PRESENCE and not a count: the same source emits ONE warning on 3.3.12 and later and TWO on the + # 3.2 floor, which also reports `unused literal ignored`. The fact this test exists for is which + # $VERBOSE value silences the class, and a count made the floor red for no property (R0-3). + test "$VERBOSE = nil silences both warning classes, and the window closes behind it" do + armed = $VERBOSE + unused = capture_warnings { quietly { ::RubyVM::AbstractSyntaxTree.parse_file(WARNS_UNUSED) } } + duplicated = capture_warnings { ::RubyVM::AbstractSyntaxTree.parse("{a: 1, a: 2}") } + silenced = capture_warnings { quietly { ::RubyVM::AbstractSyntaxTree.parse("{a: 1, a: 2}") } } + + assert_empty(unused) + assert(duplicated.any? { |message, _| message.include?("is duplicated") }, + "a duplicated key warns whatever $VERBOSE is",) + assert_empty(silenced) + assert_equal(armed, $VERBOSE, "the gate stays armed outside the window") + end + + # The recorder is inert outside a capture block: it delegates through `super`, so whatever a + # host has prepended below it -- DexpaceTestCase::FatalWarnings in every gem suite -- keeps its + # behaviour. This suite runs on GateCase, which arms no raiser, so delegation is observed at + # the real Warning.warn's own output rather than at a raise. + test "outside a capture block the recorder delegates rather than swallowing" do + captured = StringIO.new + previous = $stderr + begin + $stderr = captured + Warning.warn("escaped through the recorder\n") + ensure + $stderr = previous + end + + assert_includes(captured.string, "escaped through the recorder") + assert_equal(GateRecordingWarnings, Warning.singleton_class.ancestors.first, + "the recorder must sit ABOVE whatever else is prepended, or it never sees a call",) + end + + test "minitest is a bundled gem, never a default one, on every supported Ruby" do + spec = ::Gem::Specification.find_by_name("minitest") + + refute_predicate(spec, :default_gem?, + "design §9.3 calls minitest a default gem; it is bundled, which is why " \ + "phase 0 Task 2's Gemfile names it",) + end + + # Phase 0 Task 2's `minitest ~> 5.25` pin has LANDED, so inside the bundle every row is 5.x and + # ships minitest/mock. The fact the design measured -- Minitest 6 on the 4.0 row ships none -- + # is therefore only observable OUTSIDE the bundle, and asserting it in-process would reduce to + # `true == true` and prove nothing (this is the correction the phase's as-built record carries). + test "inside the bundle the pin holds minitest below 6, so minitest/mock is present everywhere" do + major = ::Gem::Version.new(::Minitest::VERSION).segments.first + mock_available = begin + require "minitest/mock" + true + rescue ::LoadError + false + end + + assert_operator(major, :<, 6, "phase 0 Task 2 pins minitest ~> 5.25") + assert(mock_available, "Minitest::Mock and Object#stub are what the pin keeps available") + end + + test "outside the bundle the interpreter's own minitest ships mock below 6 and not at 6" do + major, available = stock_minitest + + assert_equal(major < 6, available, + "the framework major the interpreter ships decides minitest/mock; the pin is " \ + "what keeps the repository off that divergence",) + end + + test "a Data subclass's generated readers live on the superclass, not on the subclass" do + type = Class.new(::Data.define(:code)) { def ok? = code == 200 } + + assert_equal([:ok?], type.instance_methods(false)) + assert_equal([:code], type.superclass.instance_methods(false)) + assert_predicate(type.new(code: 200), :frozen?) + end + + test "the development pin lives in .ruby-version, which gates:versions asserts" do + pin = File.read(File.join(ROOT, ".ruby-version")).strip + + assert_match(/\A\d+\.\d+\.\d+\z/, pin) + assert_equal(DexpaceVersions.ruby_dev, pin) + end + + private + + # The stock interpreter's minitest, read in a child process with every BUNDLE_*/BUNDLER_* key + # and RUBYOPT cleared -- floor_test.rb's shape, for the same reason: inside the bundle the pin + # answers, and the fact under test is about what the interpreter ships. + def stock_minitest + env = ENV.to_h.reject { |key, _| key.start_with?("BUNDLE_", "BUNDLER_", "RUBYOPT") } + env["RUBYOPT"] = "" + script = 'require "minitest"; ' \ + 'print Gem::Version.new(Minitest::VERSION).segments.first, " "; ' \ + 'print((begin; require "minitest/mock"; true; rescue LoadError; false; end))' + out, _err, status = Open3.capture3(env, Interpreter.ruby, "-e", script, unsetenv_others: true) + + assert_predicate(status, :success?, out) + major, available = out.split + [Integer(major), available == "true"] + end + + # AstScan.parse's window, spelled here so the fact is measured where it is recorded. + def quietly + previous = $VERBOSE + $VERBOSE = nil + yield + ensure + $VERBOSE = previous + end + + def walk(node, &block) + return unless node.is_a?(::RubyVM::AbstractSyntaxTree::Node) + + yield(node) + node.children.each { |child| walk(child, &block) } + end +end +# rubocop:enable Metrics/ClassLength diff --git a/test/gates/requirement_levels_test.rb b/test/gates/requirement_levels_test.rb new file mode 100644 index 0000000..74fb32f --- /dev/null +++ b/test/gates/requirement_levels_test.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The MUST-level vacuity blocker's level map: generated from appendix C and never allowed to drift +# from it. Appendix C is FROZEN; the tool under test reads it and writes only levels.rb. NFR-17; +# design R3. +require_relative "../support/gate_case" +require_relative "../../tools/requirement_levels" +require_relative "../../gems/dexpace-conformance/lib/dexpace/conformance" + +class RequirementLevelsTest < GateCase + PREFIXES = %w[SEAM HTTP IO BODY CTX PIPE RECOV RETRY REDIR AUTH PAGE SSE SERDE OBS CFG + TRANSPORT ASYNC XCUT NFR].freeze + + test "the committed map equals a fresh parse of appendix C" do + assert_equal(RequirementLevels.parse, Dexpace::Conformance::Levels::OF, + "levels.rb is stale: run `ruby tools/requirement_levels.rb`",) + end + + test "appendix C yields 645 ids over the 19 prefixes and every id has a level" do + map = RequirementLevels.parse + + assert_equal(645, map.size, + "CLAUDE.md: 645 numbered requirements; the spec is frozen, so a change here is " \ + "a human decision",) + assert_equal(PREFIXES.sort, map.keys.map { |id| id.sub(/-\d+\z/, "") }.uniq.sort) + assert_equal(%i[may must should], map.values.uniq.sort) + end + + # The 19-prefix restriction in ROW is load-bearing and nothing asserted it: loosening ROW to + # `[A-Z]+-\d+` left the map at exactly 645 rows over this repository's own appendix C, so the + # mutation survived every test in this file (R0-5). A SYNTHETIC table is what discriminates -- + # the sibling scanner in tools/appendix_b.rb carries the same case, and its mutation was caught. + test "the row scanner does not mistake an RFC number or an ISO date for a requirement id" do + Dir.mktmpdir do |dir| + table = File.join(dir, "appendix.md") + File.write(table, <<~TABLE) + | HTTP-7 | MUST | a real row | + | RFC-7235 | MUST | an RFC number where an id goes | + | ISO-8601 | SHOULD | a standard's number, hyphen and digits | + | XCUT-11 | MUST | a real row | + TABLE + + assert_equal({ "HTTP-7" => :must, "XCUT-11" => :must }, + RequirementLevels.parse(Pathname(table)),) + end + end + + # `MUST NOT` is a MUST with a negated predicate, and appendix C holds exactly one. + test "MUST NOT is a must" do + assert_equal(:must, RequirementLevels::LEVEL.fetch("MUST NOT")) + end + + test "the tool never writes appendix C" do + before = RequirementLevels::SOURCE.read + RequirementLevels.render(RequirementLevels.parse) + + assert_equal(before, RequirementLevels::SOURCE.read) + end + + # The failing fixture: one flipped level is a diff, not a pass. + test "a map with one level flipped does not equal the committed file" do + stale = RequirementLevels.render(RequirementLevels.parse.merge("ASYNC-4" => :should)) + + refute_equal(RequirementLevels::TARGET.read, stale) + end + + # Both exits, which the name always claimed and only the first half ever checked: a --check that + # returned 0 unconditionally passed this test until the phase's own mutation pass caught it. The + # stale file is written to a temp dir and named on the command line, so nothing under gems/ is + # touched and the failing half needs no fixture tree. + test "--check exits zero on the committed file and non-zero on a stale one" do + assert(system(RbConfig.ruby, "tools/requirement_levels.rb", "--check", chdir: ROOT), + "the committed levels.rb is stale",) + + Dir.mktmpdir do |dir| + stale = File.join(dir, "levels.rb") + rendered = RequirementLevels.render(RequirementLevels.parse) + File.write(stale, rendered.sub("=> :must", "=> :may")) + + refute(system(RbConfig.ruby, "tools/requirement_levels.rb", "--check", stale, chdir: ROOT), + "--check accepted a file that differs from a fresh render",) + end + end + + test "every id any suite declares is one appendix C knows" do + suites = [Dexpace::Conformance::InvariantSuite, Dexpace::Conformance::PackagingSuite, + Dexpace::Conformance::CodecSuite, Dexpace::Conformance::ExecutorSuite, + Dexpace::Conformance::TransportSuite,] + declared = Dexpace::Conformance::Aggregate.by_requirement_id(suites).keys + + assert_empty(declared.reject { |id| Dexpace::Conformance::Levels.known?(id) }) + end + + # An unknown id never blocks a report, so a typo in an assertion's `ids:` would be invisible. + test "an id appendix C does not hold answers :unknown rather than raising" do + assert_equal(:unknown, Dexpace::Conformance::Levels.of("XCUT-999")) + refute(Dexpace::Conformance::Levels.must?("XCUT-999")) + end +end diff --git a/test/gates/serde_boundary_pending_test.rb b/test/gates/serde_boundary_pending_test.rb new file mode 100644 index 0000000..232c593 --- /dev/null +++ b/test/gates/serde_boundary_pending_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# SSE-37's handed-forward clause, which 7b could not assert while phase 7 was still running: "the +# repository-wide check that its PENDING list is empty by the end of phase 7". Phase 9 asserts it, +# and the `list:` keyword below is the one-line widening of 7b's tool that lets this test drive a +# NON-empty list -- `PENDING` is a frozen constant, and reading it directly gave the fixture +# nothing to inject. +require_relative "../support/gate_case" +require_relative "../../tools/serde_boundary" + +class SerdeBoundaryPendingTest < GateCase + FIXTURES = File.join(ROOT, "test/fixtures/gates/serde_boundary") + PENDING_FIXTURE = [["gems/dexpace-core/lib/dexpace/nowhere/**/*.rb", + "a layer built beside a gate that predates it",]].freeze + + test "the committed PENDING list is empty, which is what SSE-37 handed forward" do + assert_empty(SerdeBoundary::PENDING) + assert_empty(SerdeBoundary.pending(ROOT)) + end + + # The failing state: with one pending row the gate must abort, naming the row and its reason. + test "a non-empty PENDING list produces the abort message the task aborts on" do + rows = SerdeBoundary.pending(ROOT, list: PENDING_FIXTURE) + + assert_equal(1, rows.size) + assert_equal(0, rows.first.last, "the fixture glob matches nothing, which is why it pends") + message = SerdeBoundary.pending_message(rows) + + assert_includes(message, "SerdeBoundary::PENDING is not empty") + assert_includes(message, "a layer built beside a gate that predates it") + assert_includes(message, "SSE-37") + end + + test "the gate itself is green and says so" do + out, _err, status = rake("gates:serde_boundary") + + assert_predicate(status, :success?) + assert_includes(out, "the pending list is empty (SSE-37)") + end + + # The TASK's abort branch, not just the message builder's. Without this, deleting the abort line + # outright left every test here green -- measured by mutation in this phase. PENDING is a frozen + # constant with no root or env that can make the task see a non-empty one, so the fixture is a + # RUBYOPT prelude that prepends one row onto `.pending`, which is this repository's + # deliberately-failing-fixture rule applied to the one gate clause that had no other route in. + test "the task aborts on a non-empty pending list, naming the row" do + probe = File.join(FIXTURES, "pending_probe.rb") + _out, err, status = rake("gates:serde_boundary", "RUBYOPT" => "-r#{probe}") + + refute_predicate(status, :success?, "the task accepted a non-empty PENDING list") + assert_includes(err, "SerdeBoundary::PENDING is not empty") + assert_includes(err, "a fixture row, so the task's abort branch is driven") + end +end diff --git a/test/support/gate_warning_capture.rb b/test/support/gate_warning_capture.rb new file mode 100644 index 0000000..1b2a555 --- /dev/null +++ b/test/support/gate_warning_capture.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# NFR-6's gate is DexpaceTestCase::FatalWarnings, PREPENDED to Warning's singleton class by phase +# 0. A prepended module sits ABOVE the singleton class's own methods, so a +# `Warning.define_singleton_method(:warn)` lands BELOW FatalWarnings and never receives the call: +# it raises nothing and captures nothing (measured on 3.2.11, 3.3.12, 3.4.10 and 4.0.6). Prepend +# order is the whole reason for this shape -- a SECOND module prepended ABOVE FatalWarnings -- +# and a thread-local sink keeps the capture scoped: outside a capture block, FatalWarnings still +# raises. DexpaceTestCase is required here rather than left to the test file, so the two prepends +# land in that order however the gate suite is loaded. +# +# Named GateWarningCapture and not WarningCapture, and its prepended module GateRecordingWarnings: +# gems/dexpace-core/test/support/warning_capture.rb already defines a top-level `WarningCapture` +# with a different API (`self.record` / `self.recorded` / `self.recording?`), and this +# repository's rule is that a top-level test-support constant is unique across every suite -- +# reopening one module with two incompatible APIs is the failure that rule exists to prevent +# (8a's checklist, departure 35). +# +# **This file deliberately does NOT require dexpace_test_case.** `test/gates/` is the one suite +# whose base is GateCase and not DexpaceTestCase, so FatalWarnings is not armed there -- and +# arming it from here armed it for the WHOLE test:gates process, where `Gem::Specification.load` +# on three deliberately-invalid gemspec fixtures and YARD's own load-time warnings had always +# reached `Warning.warn` harmlessly. Measured: four other gate tests went red. What this file +# needs is the recorder, not the raiser. + +# The recorder, above FatalWarnings. Inert unless THIS thread is inside a capture block. +module GateRecordingWarnings + SINK = :dexpace_gate_warning_sink + + def warn(message, category: nil) + sink = ::Thread.current[SINK] + return super if sink.nil? + + sink << [message, category] + nil + end +end +Warning.singleton_class.prepend(GateRecordingWarnings) + +# Mixed into a gate test that needs to OBSERVE a warning rather than die of it. +module GateWarningCapture + # @yield the block whose warnings are captured + # @return [Array] every [message, category] pair emitted inside + def capture_warnings + sink = [] + previous = ::Thread.current[GateRecordingWarnings::SINK] + ::Thread.current[GateRecordingWarnings::SINK] = sink + yield + sink + ensure + ::Thread.current[GateRecordingWarnings::SINK] = previous + end +end 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