diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa042cf..799f440 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,9 @@ jobs: - run: bundle exec rake steep - run: bundle exec rake test:gates - run: bundle exec rake gates:serde_boundary + - run: bundle exec rake gates:cause_walk + - run: bundle exec rake gates:bounded_map + - run: bundle exec rake gates:seam_names - run: bundle exec rake gates:rbs_surface - run: bundle exec rake gates:sig_diff - run: bundle exec rake gates:surface_snapshot diff --git a/Rakefile b/Rakefile index aae4b95..1de1c93 100644 --- a/Rakefile +++ b/Rakefile @@ -24,6 +24,9 @@ DEFAULT_GATES = %w[ gates:gemspec_audit gates:require_allowlist gates:serde_boundary + gates:cause_walk + gates:bounded_map + gates:seam_names gates:clean_bundle gates:rbs_surface gates:sig_diff diff --git a/gems/dexpace-conformance/APPENDIX_B.md b/gems/dexpace-conformance/APPENDIX_B.md new file mode 100644 index 0000000..ef5c102 --- /dev/null +++ b/gems/dexpace-conformance/APPENDIX_B.md @@ -0,0 +1,116 @@ +# Appendix B coverage map + +One row per item of `docs/product-spec/appendix-b-conformance-test-checklist.md` — sixty-one, in +the specification's own order — naming the requirement IDs that item exercises, the evidence that +covers it, and a status. + +Regenerate the ID column and the row set with `ruby tools/appendix_b.rb`. The generator reads this +file first and carries every hand-written **Evidence** and **Status** cell over, so a row's +judgement survives a regeneration and a dropped or added checklist item does not. + +## The statuses + +| Status | What it means | +|---|---| +| `suite` | An assertion in `dexpace-conformance`: this gem's own, portable across implementations of one seam | +| `by reference` | A test file in the gem that owns the subsystem. **See the limit below.** | +| `restated per §9.3` | Design §9.3 restates the item for this port, and the restatement is what is checked | +| `scoped out` | This port declines the item or a clause of it, with the reason in the row | + +## What the checks over this map establish, and what they do not + +Three checks run in `test/gates/appendix_b_test.rb`: + +1. the table has **exactly** as many rows as appendix B has items, and each section's count matches + the count parsed from the specification's own text; +2. every row's **ID column equals** the ID set parsed from that item's own text — 276 distinct IDs + over the 61 items, no ID in two items, at most 12 in one — so no row can be wrong about which + requirements its item covers, and every ID appendix B names is in some row by construction; +3. every row's **evidence path exists** on disk. + +**What they do not establish: that a referenced test asserts the behaviour the item describes.** A +`by reference` row's evidence is a test file in a gem this phase does not own, and nothing makes a +test file announce which appendix-B item it covers. Such a row proves an ID is *claimed* and a file +*exists* — never more (design `P9-7`). `Aggregate::PREAMBLE` says the same thing about a green +suite run, so a reader who meets only one of the two documents is still told. + +**The closing condition, priced and deferred rather than overlooked.** The gap shrinks as the +`by reference` rows do, and the event that makes shrinking them worth its cost is the one +`docs/first-release.md` § Post-release triggers already records for lifting `B.1`, `B.2` and `B.5`: +**a second implementation** of the pagination engine, the SSE reader or the configuration chain. +Until that fires, the only other route is a per-section ID reconciliation done by hand once and then +checked — a piece of work rather than a line, which phase 9 does not take. + +## Why `B.1`, `B.2` and `B.5` are `by reference` + +§9.3's argument for shipping `dexpace-conformance` as a gem is portability across *implementations +of one seam*: "the *same* assertions could not run unchanged against `dexpace-transport-async_http` +or a future `httpx` adapter — which is the whole point." Pagination, SSE and the configuration chain +have exactly one implementation each, and `PAGE-8` makes the pagination engine stateless and +shareable rather than pluggable. A lifted assertion over a single subject is not more true for +having moved gems; it is a test with one subject living in a package whose purpose is many, and it +costs a second file to keep in step (`P9-1`). + +| Section | Item | IDs | Evidence | Status | +|---|---|---|---|---| +| B.1 | 1 | PAGE-1, PAGE-2, PAGE-3 | gems/dexpace-core/test/dexpace/page/paginator_test.rb | by reference | +| B.1 | 2 | PAGE-6, PAGE-7, PAGE-8 | gems/dexpace-core/test/dexpace/page/walk_test.rb | by reference | +| B.1 | 3 | PAGE-9, PAGE-10 | gems/dexpace-core/test/dexpace/page/walk_test.rb | by reference | +| B.1 | 4 | PAGE-11, PAGE-12, PAGE-14, PAGE-15 | gems/dexpace-core/test/dexpace/page/lifetime_test.rb | by reference | +| B.1 | 5 | PAGE-13 | gems/dexpace-core/test/dexpace/page/lifetime_test.rb | by reference | +| B.1 | 6 | PAGE-16, PAGE-17, PAGE-18, PAGE-20, PAGE-19 | gems/dexpace-core/test/dexpace/page/link_strategy_test.rb | by reference | +| B.1 | 7 | PAGE-21, PAGE-22, PAGE-23, PAGE-24 | gems/dexpace-core/test/dexpace/page/query_rewriter_test.rb | by reference | +| B.1 | 8 | PAGE-25, PAGE-26, PAGE-27, PAGE-28, PAGE-29, PAGE-30, PAGE-31, PAGE-32, PAGE-33 | gems/dexpace-core/test/dexpace/page/async_paginator_test.rb | by reference | +| B.1 | 9 | PAGE-34, PAGE-35 | gems/dexpace-core/test/dexpace/page/fetchers_test.rb | by reference | +| B.1 | 10 | PAGE-36 | gems/dexpace-core/test/dexpace/page/paginator_test.rb | by reference | +| B.2 | 1 | SSE-1, SSE-2, SSE-3, SSE-4, SSE-5, SSE-6, SSE-7, SSE-8, SSE-9, SSE-10, SSE-11, SSE-12 | gems/dexpace-core/test/dexpace/sse/reader_test.rb | by reference | +| B.2 | 2 | SSE-13, SSE-14, SSE-15, SSE-16, SSE-17, SSE-18, SSE-19 | gems/dexpace-core/test/dexpace/sse/boundaries_test.rb | by reference | +| B.2 | 3 | SSE-20, SSE-21, SSE-22 | gems/dexpace-core/test/dexpace/sse/event_test.rb | by reference | +| B.2 | 4 | SSE-23, SSE-24, SSE-25, SSE-26, SSE-27, SSE-28, SSE-29, SSE-30, SSE-31, SSE-32 | gems/dexpace-core/test/dexpace/sse/stream_test.rb | by reference | +| B.2 | 5 | SSE-33, SSE-34, SSE-35, SSE-36 | gems/dexpace-core/test/dexpace/sse/typed_stream_test.rb | by reference | +| B.2 | 6 | SSE-37, SSE-38, SSE-39, SSE-40, SSE-41 | tools/serde_boundary.rb | suite | +| B.3 | 1 | SERDE-1, SERDE-2 | gems/dexpace-serde-json/test/dexpace/serde/json/codec_test.rb | by reference | +| B.3 | 2 | SERDE-3, SERDE-4 | gems/dexpace-conformance/lib/dexpace/conformance/codec_suite.rb | suite | +| B.3 | 3 | SERDE-5, SERDE-6, SERDE-7, SERDE-8 | gems/dexpace-core/test/dexpace/serde/witness_test.rb | restated per §9.3 | +| B.3 | 4 | SERDE-9, SERDE-10, SERDE-11, SERDE-12, SERDE-13 | gems/dexpace-conformance/lib/dexpace/conformance/codec_suite.rb | suite | +| B.3 | 5 | SERDE-14, SERDE-15, SERDE-16, SERDE-17, SERDE-18, SERDE-19, SERDE-20, SERDE-30 | gems/dexpace-core/test/dexpace/serde/tristate_test.rb | by reference | +| B.3 | 6 | SERDE-21, SERDE-22, SERDE-23, SERDE-24, SERDE-25, SERDE-26, SERDE-29 | gems/dexpace-core/test/dexpace/serde/scalars_test.rb | by reference | +| B.3 | 7 | SERDE-27, SERDE-28 | gems/dexpace-serde-json/test/dexpace/serde/json/codec_load_test.rb | by reference | +| B.4 | 1 | OBS-1, OBS-2, OBS-3, OBS-4, OBS-5, OBS-6, OBS-7, OBS-8, OBS-9, OBS-40 | gems/dexpace-core/test/dexpace/instrumentation/event_test.rb | by reference | +| B.4 | 2 | OBS-10 | gems/dexpace-core/test/dexpace/instrumentation/diagnostics_test.rb | by reference | +| B.4 | 3 | OBS-11, OBS-12, OBS-13, OBS-14, OBS-15, OBS-16, OBS-17, OBS-18, OBS-19 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/security.rb | suite | +| B.4 | 4 | OBS-20 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/security.rb | suite | +| B.4 | 5 | OBS-21, OBS-22, OBS-23, OBS-24, OBS-25 | gems/dexpace-conformance/lib/dexpace/conformance/recording_span.rb | restated per §9.3 | +| B.4 | 6 | OBS-26, OBS-27, OBS-28, OBS-29, OBS-30 | gems/dexpace-core/test/dexpace/instrumentation/http_tracer_test.rb | by reference | +| B.4 | 7 | OBS-31, OBS-32, OBS-33 | gems/dexpace-core/test/dexpace/instrumentation/meter_test.rb | by reference | +| B.4 | 8 | OBS-34, OBS-35, OBS-36, OBS-37, OBS-38, OBS-39 | gems/dexpace-core/test/dexpace/instrumentation/step_test.rb | by reference | +| B.5 | 1 | CFG-1, CFG-2, CFG-3, CFG-4, CFG-38 | gems/dexpace-core/test/dexpace/configuration_test.rb | restated per §9.3 | +| B.5 | 2 | CFG-5, CFG-6, CFG-7 | gems/dexpace-core/test/dexpace/configuration_test.rb | by reference | +| B.5 | 3 | CFG-8, CFG-9, CFG-10, CFG-11, CFG-12, CFG-13, CFG-14, CFG-37 | gems/dexpace-core/test/dexpace/config_test.rb | by reference | +| B.5 | 4 | CFG-15, CFG-16, CFG-17, CFG-18, CFG-19, CFG-20, CFG-21 | gems/dexpace-core/test/dexpace/clock_test.rb | by reference | +| B.5 | 5 | CFG-22, CFG-23, CFG-24, CFG-25, CFG-26, CFG-27, CFG-28 | gems/dexpace-core/test/dexpace/proxy_test.rb | by reference | +| B.5 | 6 | CFG-29, CFG-30, CFG-31, CFG-32, CFG-33, CFG-34, CFG-35, CFG-36 | gems/dexpace-core/test/dexpace/http_date_test.rb | by reference | +| B.6 | 1 | TRANSPORT-1, TRANSPORT-2 | gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/outbound.rb | suite | +| B.6 | 2 | TRANSPORT-3, TRANSPORT-4, TRANSPORT-5, TRANSPORT-6, TRANSPORT-7, TRANSPORT-8, TRANSPORT-9 | gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/resilience.rb | suite | +| B.6 | 3 | TRANSPORT-10, TRANSPORT-11, TRANSPORT-12, TRANSPORT-13, TRANSPORT-14 | gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/header_drops.rb | suite | +| B.6 | 4 | TRANSPORT-15, TRANSPORT-16, TRANSPORT-17, TRANSPORT-18, TRANSPORT-19 | gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/lifecycle.rb | suite | +| B.6 | 5 | TRANSPORT-20, TRANSPORT-21, TRANSPORT-22, TRANSPORT-23, TRANSPORT-24, TRANSPORT-25, TRANSPORT-26, TRANSPORT-27, TRANSPORT-28, TRANSPORT-29, TRANSPORT-30 | gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/streaming.rb | suite | +| B.7 | 1 | ASYNC-1, ASYNC-2 | gems/dexpace-async-thread/test/dexpace/async/thread/pool_test.rb | by reference | +| B.7 | 2 | ASYNC-3, ASYNC-4, ASYNC-5, ASYNC-6, ASYNC-7 | gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/shutdown.rb | suite | +| B.7 | 3 | ASYNC-8, ASYNC-9, ASYNC-10, ASYNC-11, ASYNC-12 | gems/dexpace-async-thread/test/dexpace/async/thread/pool_diagnostics_test.rb | by reference | +| B.7 | 4 | ASYNC-13, ASYNC-14 | gems/dexpace-async-thread/test/dexpace/async/thread/bridge_test.rb | scoped out | +| B.7 | 5 | ASYNC-15, ASYNC-16, ASYNC-17 | gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/lifecycle.rb | suite | +| B.7 | 6 | ASYNC-18, ASYNC-19, ASYNC-20, ASYNC-21, ASYNC-22 | gems/dexpace-transport-async_http/test/dexpace/transport/async_http/adapter_test.rb | by reference | +| B.8 | 1 | XCUT-1, XCUT-2, XCUT-3 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/concurrency.rb | suite | +| B.8 | 2 | XCUT-4, XCUT-5, XCUT-6, XCUT-7, XCUT-8, XCUT-9 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/taxonomy.rb | suite | +| B.8 | 3 | XCUT-10 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/classification.rb | suite | +| B.8 | 4 | XCUT-11, XCUT-12, XCUT-13, XCUT-22, XCUT-23 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/sharing.rb | suite | +| B.8 | 5 | XCUT-14, XCUT-15 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/memory.rb | suite | +| B.8 | 6 | XCUT-16, XCUT-17, XCUT-18, XCUT-19, XCUT-20, XCUT-21, XCUT-24 | gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/credentials.rb | suite | +| B.9 | 1 | NFR-1, NFR-2 | gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb | suite | +| B.9 | 2 | NFR-3, NFR-4 | gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/surface.rb | suite | +| B.9 | 3 | NFR-5, NFR-6, NFR-7, NFR-17 | Rakefile | restated per §9.3 | +| B.9 | 4 | NFR-8, NFR-9 | docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md | scoped out | +| B.9 | 5 | NFR-10 | gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb | suite | +| B.9 | 6 | NFR-11 | gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/surface.rb | suite | +| B.9 | 7 | NFR-12, NFR-13, NFR-14, NFR-15, NFR-16 | gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/dependencies.rb | suite | diff --git a/gems/dexpace-conformance/lib/dexpace/conformance.rb b/gems/dexpace-conformance/lib/dexpace/conformance.rb index e493639..73c5f40 100644 --- a/gems/dexpace-conformance/lib/dexpace/conformance.rb +++ b/gems/dexpace-conformance/lib/dexpace/conformance.rb @@ -10,7 +10,24 @@ require_relative "conformance/vacuous" require_relative "conformance/assertion" require_relative "conformance/result" +# The requirement-level map a Report reads to tell a MUST-level vacuity from a SHOULD-level one +# (phase 9, design R3); generated from appendix C by tools/requirement_levels.rb. +require_relative "conformance/levels" require_relative "conformance/report" +# Phase 9's shared instrument: the one assertion primitive, the one status loop and XCUT-11's +# structural predicate, then the four suites it carries and the one aggregate over them. +require_relative "conformance/check" +require_relative "conformance/runner" +require_relative "conformance/shared_instance" +require_relative "conformance/invariant_case" +require_relative "conformance/invariant_suite" +require_relative "conformance/packaging_case" +require_relative "conformance/packaging_suite" +require_relative "conformance/codec_case" +require_relative "conformance/codec_suite" +require_relative "conformance/executor_case" +require_relative "conformance/executor_suite" +require_relative "conformance/aggregate" # The TCPServer fixture and its named scripts (design §9.3; `socket` is permitted to this gem # alone by the require allowlist's scoped denial, P8-14). require_relative "conformance/scripts" diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/aggregate.rb b/gems/dexpace-conformance/lib/dexpace/conformance/aggregate.rb new file mode 100644 index 0000000..b62d189 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/aggregate.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "report" + +module Dexpace + module Conformance + # One report over every suite in a run. This is the caller phase 8a deferred `Report#to_h` + # for, and the place `docs/first-release.md`'s standing blocker -- "before release, + # `docs/sdk-documentation/` must state what a green run does and does not prove, and the run's + # own report preamble must name the same omissions" -- is made mechanical: PREAMBLE prints on + # every render rather than sitting in a document nobody re-reads. + module Aggregate + # §12 distinguishes "not satisfied" from "holds vacuously", and §9.3 requires a waived gap + # to stay visible. So this states, on every run, what a green result does NOT prove. + PREAMBLE = [ + "A pass here proves the assertions below ran green. It does not prove:", + " - TLS verification or connect-timeout classification: the wire fixture speaks", + " plaintext only and there is no portable way to make a local listener accept slowly,", + " so both are asserted in dexpace-transport-net_http's own suite and not here (8a's", + " P8-9), as is TRANSPORT-8, whose antecedent only an adapter's own suite can name", + " - anything covered 'by reference' in APPENDIX_B.md, where the evidence is another", + " gem's test file: the row proves the ID is claimed and the file exists, never that", + " the behaviour is asserted (phase 9's P9-7)", + " - any requirement whose assertion is listed below as waived or vacuous", + " - a MUST-level requirement whose assertion is vacuous and that no accepted_vacuous:", + " citation names: such a result is listed below as a report blocker, fails this run,", + " and earns a docs/first-release.md line (design R3)", + ].freeze + + extend self + + # @param reports [Array] one per suite run + # @param accepted_vacuous [Hash{String => String}] acceptances that apply to the whole run, + # merged over each suite's own + # @param would_fail [Array] waived IDs whose assertion would have failed + # @return [Report] one report with ONE #passed? over the whole run + def run(reports, accepted_vacuous: {}, would_fail: []) + Report.merge(reports, preamble: PREAMBLE.join("\n"), + accepted_vacuous: accepted_vacuous, would_fail: would_fail,) + end + + # @param report [Report] + # @return [String] the preamble and the report, in that order + def render(report) + report.to_s + end + + # The generated half of the appendix-B coverage map: every assertion each suite DECLARES, + # keyed by requirement ID. + # + # It takes the SUITES and not a Report, and that is load-bearing: a Report holds only the + # assertions that actually RAN, so reading one would let a suite skipped in a given + # invocation silently shorten the map -- and the map's own row-count check would then fail + # for a reason that has nothing to do with a dropped row (design R7). + # + # `statuses:` is an optional Report: when given, each row carries the status that run + # produced, and an assertion with no result is marked `:not_run` rather than omitted. + # + # @param suites [Array<#assertions>] every suite whose declarations belong in the map + # @param statuses [Report, nil] one run's results + # @return [Hash{String => Array}] requirement ID -> its rows + def by_requirement_id(suites, statuses: nil) + ran = statuses.nil? ? [] : statuses.results #: Array[Result] + observed = ran.to_h { |result| [result.assertion.name, result.status] } + rows = {} #: Hash[String, Array[untyped]] + suites.each_with_object(rows) { |suite, map| add_rows(suite, observed, map) } + end + + private + + def add_rows(suite, observed, map) + suite.assertions.each do |assertion| + row = { suite: suite.name, assertion: assertion.name, + status: observed.fetch(assertion.name, :not_run), } + assertion.ids.each { |id| (map[id] ||= []) << row } + end + end + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/check.rb b/gems/dexpace-conformance/lib/dexpace/conformance/check.rb new file mode 100644 index 0000000..40f73cc --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/check.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "failure" + +module Dexpace + module Conformance + # The one assertion primitive every phase-9 suite body uses: a condition, the sentence that + # says what went wrong, the two values compared, and the requirement IDs the comparison is + # evidence for. Its own file because one public constant per file is this repository's rule + # and phase 9's own code sits inside the NFR-3 assertion phase 9 ships. + module Check + extend self + + # @param condition [Object] truthy when the property holds + # @param message [String] what went wrong, in words + # @param expected [Object] what the assertion expected + # @param actual [Object] what it found + # @param ids [Array] the requirement IDs this comparison is evidence for + # @return [nil] + # @raise [Failure] when `condition` is falsey + def that(condition, message, expected:, actual:, ids:) + return nil if condition + + raise Failure.new(message, expected: expected, actual: actual, requirement_ids: ids) + end + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/codec_case.rb b/gems/dexpace-conformance/lib/dexpace/conformance/codec_case.rb new file mode 100644 index 0000000..ff108da --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/codec_case.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module Dexpace + module Conformance + # What a CodecSuite assertion receives. + # + # The witness and the source factory are the DRIVER's: phase 2's contract is + # `#load(source, witness)` with no witness-less overload, and the source type is the adapter's + # (the JSON codec's is a `Dexpace::IO::BufferedSource`), so neither is the suite's to invent. + class CodecCase + # A sink that counts its own closes, so SERDE-3's "streaming/buffer targets not closed" is a + # COUNT and not an inference, and records what was written so an encode can be checked to + # have produced something at all. + class CountingSink + # @return [String] every byte written, in order + attr_reader :bytes + # @return [Integer] how many times #close was called + attr_reader :close_count + + def initialize + @bytes = +"" + @close_count = 0 + end + + # @param chunk [String] bytes the codec wrote + # @return [Integer] the running total written + def write(chunk) = (@bytes << chunk).bytesize + # @return [Integer] how many closes this sink has now seen -- SERDE-3's count + def close = @close_count += 1 + end + private_constant :CountingSink + + # @return [Object] the driver's witness, answering #dexpace_load(parsed, ctx) + attr_reader :witness + + # @param build [#call] a zero-argument codec factory + # @param witness [Object] a witness the codec's #load accepts + # @param source [#call] text -> the source type this codec's #load reads + def initialize(build:, witness:, source:) + @build = build + @witness = witness + @source = source + end + + # @return [Object] a fresh codec + def codec = @build.call + + # @return [CountingSink] a fresh counting sink + def sink = CountingSink.new + + # @param text [String] the bytes the source yields + def source(text) = @source.call(text) + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite.rb b/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite.rb new file mode 100644 index 0000000..6b732ed --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "codec_case" +require_relative "runner" +require_relative "codec_suite/seam" + +module Dexpace + module Conformance + # Appendix B.3's seam half: the two properties a wire codec must have whatever library it + # wraps -- SEAM-20 with SERDE-3 (it never closes a target it was handed) and SERDE-9 (no + # library exception type escapes the seam). + # + # **These assertions are RE-DERIVED from the requirement text, not lifted out of + # `dexpace-serde-json`'s own suite, and that gem's files are left byte-identical.** A lift + # would have moved `assert_closes_nothing`, whose body is the only evidence SEAM-21 has in + # that suite, and `assert_failure_model`, which carries SERDE-10 and the ENCODE half of + # SERDE-9 as well -- so a lift that took the portable halves would have dropped three + # properties on its way out. The relationship here is TransportSuite's to the net_http suite's + # instead: a portable assertion beside a gem-local one, both alive. + # + # The post-v1 `dexpace-serde-oj` is the second subject these two exist for. + module CodecSuite + extend self + + # What a green run of this suite does NOT prove. + PREAMBLE = "dexpace-conformance codec suite: two SEAM properties, re-derived from the " \ + "requirement text. The offset matrix (SERDE-4), the I/O-error pass-through " \ + "(SERDE-12), the explicit type token (SEAM-21), the encode half of SERDE-9 and " \ + "SERDE-10's mapping are asserted in the adapter's own suite and are NOT among " \ + "the things a green run here proves." + + # @return [Array] the suite's assertions, frozen and ordered + def assertions + ASSERTIONS + end + + # @param build [#call] a zero-argument codec factory + # @param witness [Object] a witness the codec's #load accepts + # @param source [#call] text -> the source type this codec's #load reads + # @param waive [Array] requirement IDs recorded :waived without running + # @param around [#call, nil] the suite contract's clause 9 wrapper + # @param accepted_vacuous [Hash{String => String}] sanctioned MUST-level vacuities + # @param would_fail [Array] waived IDs whose assertion would have failed + # @return [Report] + def run(build:, witness:, source:, waive: [], around: nil, accepted_vacuous: {}, + would_fail: []) + Runner.run(assertions, waive: waive, around: around, preamble: PREAMBLE, + accepted_vacuous: accepted_vacuous, would_fail: would_fail,) do + CodecCase.new(build: build, witness: witness, source: source) + end + end + + # The one group, a private module of its own file, so the assertion bodies are not public + # surface. + ASSERTIONS = Seam::ASSERTIONS + private_constant :ASSERTIONS + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite/seam.rb b/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite/seam.rb new file mode 100644 index 0000000..8ae2c50 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/codec_suite/seam.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../check" +require_relative "../failure" +require_relative "../runner" +require_relative "../vacuous" + +module Dexpace + module Conformance + module CodecSuite + # The two portable seam properties, in a group of their own so the assertion bodies are not + # public surface: every other suite's groups are private_constants and this one is too, which + # is what keeps each suite's public surface exactly `assertions`, `run` and `PREAMBLE`. + # A private_constant of CodecSuite. + module Seam + extend self + + # The value every encode assertion dumps: small, native, and encodable by any codec. + SAMPLE = { "a" => 1 }.freeze + # Input no codec can decode, which is SERDE-9's antecedent. + MALFORMED = "{not json" + + # SEAM-20 and SERDE-3: a codec MUST NOT close a target it was handed. `#dump_to` and never + # `#dump`: phase 2's CONTRACT names `#dump_to` and keeps the `#dump` shorthand out of it + # deliberately, so a conforming codec need not define `#dump` at all. The bytes are checked + # too, so a codec that wrote nothing cannot pass by having closed nothing. + def never_closes_its_target(subject) + sink = subject.sink + subject.codec.dump_to(SAMPLE, sink) + + Check.that(sink.close_count.zero?, "the codec closed a caller-supplied sink", + expected: 0, actual: sink.close_count, ids: %w[SEAM-20 SERDE-3],) + Check.that(!sink.bytes.empty?, + "the codec wrote nothing, so closing nothing proves nothing", + expected: "some bytes", actual: sink.bytes.bytesize, + ids: %w[SEAM-20 SERDE-3],) + end + + # SERDE-9: "failures surface the stable serde type CHAINING THE ORIGINAL CAUSE, no library + # type escapes." Three clauses and all three are asserted -- a Dexpace error OUTSIDE the + # serde hierarchy, or a serde error with NO cause chained, satisfies neither the "stable + # serde type" nor the "chaining the original cause" half. + def no_library_type_escapes(subject) + raised = capture_failure(subject) + + Check.that(!raised.nil?, "malformed input decoded without raising", + expected: "a Dexpace::Serde::Error", actual: "no error", ids: ["SERDE-9"],) + Check.that(serde_error?(raised), + "a failure outside the SDK's serde hierarchy escaped the seam", + expected: "Dexpace::Serde::Error", actual: raised.class.name, + ids: ["SERDE-9"],) + # The chain is read through core's ONE cause walk rather than through a bare + # `raised.cause`: XCUT-9 makes `Dexpace.each_cause` the single walk, `gates:cause_walk` + # keeps it single by scanning for any other `#cause` send, and that scan cannot tell a + # one-step read from a walk. Using the walk costs nothing and keeps the gate's allowlist + # at one entry. + Check.that(::Dexpace.each_cause(raised).count > 1, + "the serde failure chained no original cause", + expected: "a chained #cause", actual: 1, ids: ["SERDE-9"],) + end + + # Vacuous and Failure are re-RAISED rather than captured: both are ::StandardError + # descendants (8a), so a bare rescue would turn this suite's own vacuity into a PASS. + def capture_failure(subject) + subject.codec.load(subject.source(MALFORMED), subject.witness) + nil + rescue Vacuous, Failure + raise + rescue ::StandardError => error + error + end + + def serde_error?(error) + defined?(::Dexpace::Serde::Error) && error.is_a?(::Dexpace::Serde::Error) + end + + ROWS = [ + [%w[SEAM-20 SERDE-3], "a codec never closes a target it was handed", + :never_closes_its_target,], + ["SERDE-9", "a decode failure surfaces the SDK's serde type", :no_library_type_escapes], + ].freeze + private_constant :ROWS + + # @return [Array] this group's assertions, frozen + ASSERTIONS = Runner.registry(self, ROWS) + end + private_constant :Seam + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/executor_case.rb b/gems/dexpace-conformance/lib/dexpace/conformance/executor_case.rb new file mode 100644 index 0000000..9b2d45a --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/executor_case.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Keys::EVENT and Events::INSTRUMENTATION_SHUTDOWN both live in this one file. +require "dexpace/instrumentation/keys" +require_relative "vacuous" + +module Dexpace + module Conformance + # What an ExecutorSuite assertion receives. The executor is a FACTORY, never a constant -- + # dexpace-conformance declares dexpace-core and nothing else, so it can name no adapter. + # + # The event recorder is built ONCE PER CASE and passed into the factory as an `events:` + # setting, never shared across a run: Runner builds a fresh case per assertion, so an earlier + # assertion's shutdown event cannot be counted by a later one. That is testing/4ef070df + # applied to a fixture the SUITE constructs rather than one a test writes, and it is the + # defect this phase's own planning run hit -- one shared recorder made the CONFORMING double + # fail, because three earlier assertions had each built and closed a pool. + class ExecutorCase + # XCUT-11's own conformance shape: "invoke one shared instance from MANY threads". + THREADS = 16 + + # The recorder is a SINK, because a sink is where a shutdown is observable. No filed + # executor in this repository exposes a shutdown counter: the pool reports its shutdown + # exactly once, from its release, through the injected logger -- and `Event#emit` ends in + # `@sink.public_send(severity.sink_method) { rendered_record }` with the event tag under + # `Keys::EVENT`. So the portable observation is the payload this sink receives, and the + # adapter's `build:` lambda is what wires it into its own logger. + # + # Shaped on core's `_Sink` duck type; every predicate answers true, so an INFO-severity + # shutdown is never filtered by `Logger#enabled?` before it reaches here. + class EventRecorder + def initialize + @payloads = [] + @mutex = ::Thread::Mutex.new + end + + # @return [Array] the payloads this sink received, oldest first, as a dup -- a + # caller iterating cannot race a close still running on another thread + def entries = @mutex.synchronize { @payloads.dup } + + # @return [nil] core's `_Sink` duck type; every severity records the same way + def debug(message = nil, &) = record(message, &) + # @return [nil] + def info(message = nil, &) = record(message, &) + # @return [nil] + def warn(message = nil, &) = record(message, &) + # @return [nil] + def error(message = nil, &) = record(message, &) + + def debug? = true + def info? = true + def warn? = true + def error? = true + + private + + def record(message) + payload = block_given? ? yield : message + @mutex.synchronize { @payloads << payload } + nil + end + end + private_constant :EventRecorder + + # @param build [#call] keyword-taking executor factory; the suite passes `events:` when it + # has a recorder and nothing else + # @param borrow [#call, nil] pool -> a holder that borrows it, or nil when the adapter + # exposes no borrowing entry point + # @param functional [#call, nil] a RESOURCE-FREE implementation for ASYNC-17, or nil + # @param record_events [Boolean] whether this case builds its OWN event recorder and hands + # it to `build:` as `events:`. The recorder's class stays private -- a driver declares + # that its factory accepts the keyword rather than constructing one, which keeps a test + # double out of the gem's locked public surface. + def initialize(build:, borrow: nil, functional: nil, record_events: false) + @build = build + @borrow = borrow + @functional = functional + @recorder = record_events ? EventRecorder.new : nil + end + + # @return [Object] a fresh executor, wired to this case's recorder when there is one + def executor + return @build.call if @recorder.nil? + + @build.call(events: @recorder) + end + + # @return [Boolean] whether the adapter exposes a borrowing entry point + def borrowed? = !@borrow.nil? + + # @param pool [Object] the executor to borrow + # @return [Object] a holder that borrowed it + def borrowed(pool) + raise Vacuous, "this adapter exposes no borrowing entry point" if @borrow.nil? + + @borrow.call(pool) + end + + # @return [Boolean] whether a resource-free implementation was supplied + def functional? = !@functional.nil? + + # ASYNC-17's subject is a RESOURCE-FREE implementation, supplied separately: asserting the + # no-op default against a pool that owns a thread would assert the opposite requirement. + def functional + raise Vacuous, "no resource-free implementation supplied" if @functional.nil? + return @functional.call if @recorder.nil? + + @functional.call(events: @recorder) + end + + # @return [Boolean] whether an event recorder was supplied + def events? = !@recorder.nil? + + # SEAM-25's "close twice -> executor shut once, one event" needs this and nothing else: how + # many shutdown payloads the recorder saw. Only the event NAME is read -- the pool's two + # field keys are its own private constants and are deliberately absent from core's Keys, + # because the portable assertion needs the event name only. + # + # @return [Integer] + def shutdowns + recorder = @recorder + raise Vacuous, "no event recorder supplied to ExecutorSuite.run" if recorder.nil? + + recorder.entries.count { |payload| shutdown?(payload) } + end + + private + + def shutdown?(payload) + payload.is_a?(::Hash) && + payload[Dexpace::Instrumentation::Keys::EVENT] == + Dexpace::Instrumentation::Events::INSTRUMENTATION_SHUTDOWN + end + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite.rb b/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite.rb new file mode 100644 index 0000000..87be87e --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "executor_case" +require_relative "runner" +require_relative "executor_suite/lifecycle" +require_relative "executor_suite/shutdown" + +module Dexpace + module Conformance + # Appendix B.7's lifecycle half, and the HARNESS half of SEAM-25's lifecycle event. + # + # **That reassignment is a CORRECTION to a committed record, stated as one.** Phase 2 + # postponed the event; 8b's design said "the HARNESS half -- the assertion living in + # dexpace-conformance -- is 8a's, and 8b hands it the shape rather than writing it." 8a wrote + # the protocol, the wire fixture and the transport suite, and wrote NO executor suite; 8b + # supplied the shape and emits the event. So the harness half was unwritten after phase 8, + # phase 9 writes it, and the earlier record is wrong about which phase delivers it. + # + # Two clauses are SCOPED OUT with reasons rather than silently dropped: ASYNC-15's clause (c), + # interrupt-safety, needs a pending interrupt and §8.3 bans every primitive that could arrange + # one; and SEAM-18 is the executor seam's SHAPE rather than an implementation property, which + # 8b's own suite asserts. + # + # Every lifecycle observation here goes through `ExecutorCase#shutdowns`, the count of + # `Events::INSTRUMENTATION_SHUTDOWN` payloads the case's own recorder saw. That is the only + # channel a filed executor exposes a shutdown on; an assertion reading a `#shutdown_count` + # would be reading a method no adapter in this repository defines. + module ExecutorSuite + extend self + + # What a green run of this suite does NOT prove. + PREAMBLE = "dexpace-conformance executor suite: ASYNC-15's clause (c) and SEAM-18 are " \ + "scoped out with their reasons and are NOT among the things a green run " \ + "proves. Without `record_events: true` an UNLATCHED executor passes XCUT-13, " \ + "because \"the shutdown ran twice\" is visible only in the lifecycle event. " \ + "ASYNC-3 is an unsatisfied MUST (design §10.5): it fails against every executor " \ + "this repository can build, and a first-party run waives it by ID." + + # @return [Array] the suite's assertions, frozen and ordered + def assertions + ASSERTIONS + end + + # @param build [#call] keyword-taking executor factory; the suite passes `events:` when + # `record_events:` is true and nothing otherwise + # @param borrow [#call, nil] pool -> a holder that borrows it, or nil + # @param functional [#call, nil] a RESOURCE-FREE implementation for ASYNC-17, or nil + # @param record_events [Boolean] whether `build:` accepts an `events:` sink + # @param waive [Array] requirement IDs recorded :waived without running + # @param around [#call, nil] the suite contract's clause 9 wrapper + # @param accepted_vacuous [Hash{String => String}] sanctioned MUST-level vacuities + # @param would_fail [Array] waived IDs whose assertion would have failed + # @return [Report] + def run(build:, borrow: nil, functional: nil, record_events: false, waive: [], around: nil, + accepted_vacuous: {}, would_fail: []) + Runner.run(assertions, waive: waive, around: around, preamble: PREAMBLE, + accepted_vacuous: accepted_vacuous, would_fail: would_fail,) do + ExecutorCase.new(build: build, borrow: borrow, functional: functional, + record_events: record_events,) + end + end + + # The two groups, each a private module of its own file. + ASSERTIONS = (Lifecycle::ASSERTIONS + Shutdown::ASSERTIONS).freeze + private_constant :ASSERTIONS + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/lifecycle.rb b/gems/dexpace-conformance/lib/dexpace/conformance/executor_suite/lifecycle.rb new file mode 100644 index 0000000..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_test.rb b/gems/dexpace-conformance/test/dexpace/conformance_test.rb index 1b5a512..3351f10 100644 --- a/gems/dexpace-conformance/test/dexpace/conformance_test.rb +++ b/gems/dexpace-conformance/test/dexpace/conformance_test.rb @@ -33,11 +33,22 @@ class ConformanceTest < DexpaceTestCase # Minitest driver, the two observability doubles and the borrowed pair. RSpecDriver is defined # by a file the entry point deliberately does NOT require (an RSpec-only consumer opts in), so # it is absent here and its own test loads it. - PUBLIC = %i[ + PHASE_8A = %i[ VERSION Failure Vacuous Assertion Result Report Scripts WireServer BorrowedPair TransportCase TransportSuite MinitestDriver RecordingSpan Allocations ].freeze + # Phase 9's additions: the shared instrument (the assertion primitive, the status loop, the + # generated requirement-level map and XCUT-11's structural predicate), the four suites it ships + # with the case each hands its assertions, and the one aggregate over every suite in a run. + PHASE_9 = %i[ + Check Runner Levels SharedInstance Aggregate + InvariantCase InvariantSuite PackagingCase PackagingSuite + CodecCase CodecSuite ExecutorCase ExecutorSuite + ].freeze + + PUBLIC = (PHASE_8A + PHASE_9).freeze + test "defines a semver VERSION string" do assert_match(/\A\d+\.\d+\.\d+\z/, Dexpace::Conformance::VERSION) end diff --git a/tasks/gates.rake b/tasks/gates.rake index 8b0cabf..a3b05b8 100644 --- a/tasks/gates.rake +++ b/tasks/gates.rake @@ -334,9 +334,65 @@ namespace :gates do found = SerdeBoundary.violations(root) abort(found.join("\n")) unless found.empty? - SerdeBoundary.pending(root).each do |glob, reason, matched| + pending = SerdeBoundary.pending(root) + pending.each do |glob, reason, matched| puts "gates:serde_boundary: PENDING #{glob} (#{matched} file(s) today) -- #{reason}" end - puts "gates:serde_boundary: #{SerdeBoundary::GUARDED.size} guarded globs clean." + # SSE-37's handed-forward clause: 7b asked for "the repository-wide check that its PENDING + # list is empty by the end of phase 7", which it could not assert while phase 7 was still + # running. Phase 9 asserts it. + abort(SerdeBoundary.pending_message(pending)) unless pending.empty? + puts "gates:serde_boundary: #{SerdeBoundary::GUARDED.size} guarded globs clean; the " \ + "pending list is empty (SSE-37)." + end + + # The three repository-wide invariant scans phase 9 adds (design R4). Each is a scan of THIS + # repository's source tree, which is why it is a gate and not a portable assertion: a scan of + # `gems/*/lib/` would be meaningless in a consumer's process. + # Dir.glob sorts its own result on every supported Ruby, so neither reader sorts again. + # + # The scan reads ABSOLUTE paths and the message is made relative afterwards. The other + # direction -- handing the scanner paths relative to `root` -- reads them relative to the + # process's CWD instead, which is the same directory only when DEXPACE_GATE_ROOT is unset; with + # a fixture root every open raised Errno::ENOENT, so the gate could never have been driven + # against the deliberately failing fixture this repository requires of every gate. Both + # allowlists are joined to the same root for the same reason: an entry that no longer matches a + # scanned path silences nothing and is invisible. + def invariant_files(root) = Dir.glob(File.join(root, "gems/*/lib/**/*.rb")) + def core_files(root) = Dir.glob(File.join(root, "gems/dexpace-core/lib/**/*.rb")) + def under(root, paths) = paths.map { |path| File.join(root, path) } + def relative_to(root, lines) = lines.map { |line| line.delete_prefix("#{root}/") } + + desc "XCUT-9: Dexpace.each_cause is the only walk of an error's cause chain" + task :cause_walk do + require_relative "../tools/invariant_gates" + root = gate_root + found = InvariantGates.cause_walk(invariant_files(root), + allowed: under(root, InvariantGates::CAUSE_WALK_ALLOWED),) + abort(relative_to(root, found).join("\n")) unless found.empty? + + puts "gates:cause_walk: #{invariant_files(root).size} files; one cause walk (XCUT-9)." + end + + desc "XCUT-14: only Dexpace::BoundedMap holds a caller- or server-keyed map" + task :bounded_map do + require_relative "../tools/invariant_gates" + root = gate_root + allowed = InvariantGates::BOUNDED_MAP_ALLOWED.transform_keys { |key| File.join(root, key) } + found = InvariantGates.bounded_map(invariant_files(root), allowed: allowed) + abort(relative_to(root, found).join("\n")) unless found.empty? + + puts "gates:bounded_map: #{invariant_files(root).size} files; " \ + "#{InvariantGates::BOUNDED_MAP_ALLOWED.size} adjudicated exceptions (XCUT-14)." + end + + desc "SEAM-2: core never names a concrete seam implementation" + task :seam_names do + require_relative "../tools/invariant_gates" + root = gate_root + found = InvariantGates.seam_names(core_files(root)) + abort(relative_to(root, found).join("\n")) unless found.empty? + + puts "gates:seam_names: #{core_files(root).size} core files name no adapter (SEAM-2)." end end diff --git a/test/fixtures/surface/dexpace-conformance.txt b/test/fixtures/surface/dexpace-conformance.txt index 5b17750..f87ad86 100644 --- a/test/fixtures/surface/dexpace-conformance.txt +++ b/test/fixtures/surface/dexpace-conformance.txt @@ -1,4 +1,9 @@ Dexpace::Conformance +Dexpace::Conformance::Aggregate +Dexpace::Conformance::Aggregate#by_requirement_id +Dexpace::Conformance::Aggregate#render +Dexpace::Conformance::Aggregate#run +Dexpace::Conformance::Aggregate::PREAMBLE : Array Dexpace::Conformance::Allocations Dexpace::Conformance::Allocations#delta Dexpace::Conformance::Allocations::ATTEMPTS : Integer @@ -13,15 +18,77 @@ Dexpace::Conformance::BorrowedPair#probe Dexpace::Conformance::BorrowedPair#still_usable? Dexpace::Conformance::BorrowedPair#transport Dexpace::Conformance::BorrowedPair.build +Dexpace::Conformance::Check +Dexpace::Conformance::Check#that +Dexpace::Conformance::CodecCase +Dexpace::Conformance::CodecCase#codec +Dexpace::Conformance::CodecCase#sink +Dexpace::Conformance::CodecCase#source +Dexpace::Conformance::CodecCase#witness +Dexpace::Conformance::CodecSuite +Dexpace::Conformance::CodecSuite#assertions +Dexpace::Conformance::CodecSuite#run +Dexpace::Conformance::CodecSuite::PREAMBLE : String +Dexpace::Conformance::ExecutorCase +Dexpace::Conformance::ExecutorCase#borrowed +Dexpace::Conformance::ExecutorCase#borrowed? +Dexpace::Conformance::ExecutorCase#events? +Dexpace::Conformance::ExecutorCase#executor +Dexpace::Conformance::ExecutorCase#functional +Dexpace::Conformance::ExecutorCase#functional? +Dexpace::Conformance::ExecutorCase#shutdowns +Dexpace::Conformance::ExecutorCase::THREADS : Integer +Dexpace::Conformance::ExecutorSuite +Dexpace::Conformance::ExecutorSuite#assertions +Dexpace::Conformance::ExecutorSuite#run +Dexpace::Conformance::ExecutorSuite::PREAMBLE : String Dexpace::Conformance::Failure Dexpace::Conformance::Failure#actual Dexpace::Conformance::Failure#expected Dexpace::Conformance::Failure#requirement_ids +Dexpace::Conformance::InvariantCase +Dexpace::Conformance::InvariantCase#bounded_map +Dexpace::Conformance::InvariantCase#bounded_map_store +Dexpace::Conformance::InvariantCase#cnonce? +Dexpace::Conformance::InvariantCase#core +Dexpace::Conformance::InvariantCase#credential_hop +Dexpace::Conformance::InvariantCase#draw_cnonce +Dexpace::Conformance::InvariantCase#mutable +Dexpace::Conformance::InvariantCase#probe! +Dexpace::Conformance::InvariantCase#redirect_hop +Dexpace::Conformance::InvariantCase#seam +Dexpace::Conformance::InvariantCase#seam? +Dexpace::Conformance::InvariantCase#shared_instances +Dexpace::Conformance::InvariantCase#transport +Dexpace::Conformance::InvariantCase#transport? +Dexpace::Conformance::InvariantSuite +Dexpace::Conformance::InvariantSuite#assertions +Dexpace::Conformance::InvariantSuite#run +Dexpace::Conformance::InvariantSuite::PREAMBLE : String +Dexpace::Conformance::Levels +Dexpace::Conformance::Levels.known? +Dexpace::Conformance::Levels.must? +Dexpace::Conformance::Levels.of +Dexpace::Conformance::Levels::OF : Hash Dexpace::Conformance::MinitestDriver Dexpace::Conformance::MinitestDriver#conformance Dexpace::Conformance::MinitestDriver.build_case Dexpace::Conformance::MinitestDriver.drive Dexpace::Conformance::MinitestDriver.method_name_for +Dexpace::Conformance::PackagingCase +Dexpace::Conformance::PackagingCase#adapter_names +Dexpace::Conformance::PackagingCase#core_name +Dexpace::Conformance::PackagingCase#every_name +Dexpace::Conformance::PackagingCase#runtime_version +Dexpace::Conformance::PackagingCase#shipped_rbs_without_header +Dexpace::Conformance::PackagingCase#sig_root +Dexpace::Conformance::PackagingCase#spec +Dexpace::Conformance::PackagingCase#versions +Dexpace::Conformance::PackagingCase::DEFAULT_RESOLVE : Proc +Dexpace::Conformance::PackagingSuite +Dexpace::Conformance::PackagingSuite#assertions +Dexpace::Conformance::PackagingSuite#run +Dexpace::Conformance::PackagingSuite::PREAMBLE : String Dexpace::Conformance::RecordingSpan Dexpace::Conformance::RecordingSpan#add_event Dexpace::Conformance::RecordingSpan#attributes @@ -36,20 +103,31 @@ Dexpace::Conformance::RecordingSpan#set_attribute Dexpace::Conformance::RecordingSpan#status Dexpace::Conformance::RecordingSpan#status= Dexpace::Conformance::Report +Dexpace::Conformance::Report#accepted_vacuities +Dexpace::Conformance::Report#accepted_vacuous +Dexpace::Conformance::Report#blocking_vacuities Dexpace::Conformance::Report#errors Dexpace::Conformance::Report#failures +Dexpace::Conformance::Report#passed Dexpace::Conformance::Report#passed? Dexpace::Conformance::Report#preamble Dexpace::Conformance::Report#results +Dexpace::Conformance::Report#to_h Dexpace::Conformance::Report#to_s +Dexpace::Conformance::Report#unknown_ids Dexpace::Conformance::Report#vacuous Dexpace::Conformance::Report#waived +Dexpace::Conformance::Report#would_fail +Dexpace::Conformance::Report.merge Dexpace::Conformance::Result Dexpace::Conformance::Result#assertion Dexpace::Conformance::Result#detail Dexpace::Conformance::Result#status Dexpace::Conformance::Result.build Dexpace::Conformance::Result::STATUSES : Array +Dexpace::Conformance::Runner +Dexpace::Conformance::Runner#registry +Dexpace::Conformance::Runner#run Dexpace::Conformance::Scripts Dexpace::Conformance::Scripts#dribble Dexpace::Conformance::Scripts#echo_path @@ -66,6 +144,8 @@ Dexpace::Conformance::Scripts#sequenced Dexpace::Conformance::Scripts#truncated Dexpace::Conformance::Scripts#vendor_status Dexpace::Conformance::Scripts#write_response +Dexpace::Conformance::SharedInstance +Dexpace::Conformance::SharedInstance#audit Dexpace::Conformance::TransportCase Dexpace::Conformance::TransportCase#borrowed_transport Dexpace::Conformance::TransportCase#request diff --git a/test/gates/default_task_test.rb b/test/gates/default_task_test.rb index 8feb434..e6cbe69 100644 --- a/test/gates/default_task_test.rb +++ b/test/gates/default_task_test.rb @@ -5,17 +5,35 @@ # NFR-17: the gates are blocking and automatic, and the default rake task is what makes them so. # The second case -- that every listed name is a real, separately invocable task -- was added -# once the last gate existed (plan Task 19): asserted earlier it would have left `rake -# test:gates` red for eighteen tasks, and a suite that is expected to be red is a suite nobody +# once the last gate existed (phase 0's plan Task 19): asserted earlier it would have left `rake +# test:gates` red for seventeen tasks, and a suite that is expected to be red is a suite nobody # reads. class DefaultTaskTest < GateCase + # Phase 0's seventeen, 7b's gates:serde_boundary and phase 9's three repository-wide invariant + # scans -- twenty-one. A gate outside DEFAULT_GATES is off `task default:` and therefore + # advisory, which NFR-17 forbids, so this list is what makes the count a fact. EXPECTED = %w[ rubocop cops:test rbs:validate steep test:gems test:gates gates:gemspec_audit - gates:require_allowlist gates:serde_boundary gates:clean_bundle gates:rbs_surface + gates:require_allowlist gates:serde_boundary gates:cause_walk gates:bounded_map + gates:seam_names gates:clean_bundle gates:rbs_surface gates:sig_diff gates:surface_snapshot gates:single_instance gates:versions gates:reproducible yard bundler_audit ].freeze + # The COUNT, asserted against the repository and not only against the literal above: the three + # tasks phase 9 added are gates because they are in `task default:`, and a count read off + # EXPECTED alone would still pass if all three left DEFAULT_GATES together. + test "there are twenty-one gates, and every one of them is blocking" do + prerequisites = `bundle exec rake -s -P`.split("\n").drop_while { |l| l != "rake default" } + .drop(1).take_while { |line| line.start_with?(" ") }.map(&:strip) + + assert_equal(21, EXPECTED.size) + assert_equal(21, prerequisites.size, "the default task's prerequisite count drifted") + %w[gates:cause_walk gates:bounded_map gates:seam_names].each do |gate| + assert_includes(prerequisites, gate, "#{gate} is not blocking") + end + end + test "the default task lists every gate, in the design's order" do listed = `bundle exec rake -s gates:list`.split("\n") diff --git a/tools/appendix_b.rb b/tools/appendix_b.rb new file mode 100644 index 0000000..d9525fa --- /dev/null +++ b/tools/appendix_b.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Two Markdown files compared: the specification's appendix B, and the coverage map beside the +# conformance gem. Plain text operations -- no AST, no gem. +# +# The map is phase 9's answer to "appendix B is phase 9's scope" without re-owning seven phases' +# work: one row per checklist item, naming the section, the item's requirement IDs, the suite or +# test file that covers it, and a status. A `by reference` row proves an ID is CLAIMED and a file +# EXISTS, never that the behaviour is tested (design P9-7), and the map says so in its own voice. +module AppendixB + # CLAUDE.md's nineteen prefixes, in appendix-C order. A bare /[A-Z]+-\d+/ matches `ISO-8601`, + # which appears in B.3's real text, and RFC-3986-shaped tokens elsewhere. + PREFIXES = %w[SEAM HTTP IO BODY CTX PIPE RECOV RETRY REDIR AUTH PAGE SSE SERDE OBS CFG + TRANSPORT ASYNC XCUT NFR].freeze + ID = /\b(?:#{PREFIXES.join("|")})-\d+\b/ + # `- [ ]` and never `- [`: an item ticked by hand would otherwise vanish from the count. + ITEM = "- [ ]" + SECTION = /^### (B\.\d)/ + # The statuses a row may carry. `suite` is an assertion in this gem; `by reference` is another + # gem's test file; `restated per §9.3` is an item the design restates; `scoped out` is an item + # or clause this port declines with its reason in the row. + STATUSES = ["suite", "by reference", "restated per §9.3", "scoped out", "unmapped"].freeze + + extend self + + # @param text [String] + # @return [Array] every requirement ID in it, deduplicated, in order + def ids_in(text) = text.scan(ID).uniq + + # @param path [String] the specification's appendix B + # @return [Integer] how many checklist items it holds + def spec_item_count(path) = ::File.readlines(path).count { |line| line.start_with?(ITEM) } + + # @param path [String] the specification's appendix B + # @return [Hash{String => Integer}] items per section + def spec_counts_by_section(path) + spec_items(path).each_with_object({}) do |(section, _, _), counts| + counts[section] = counts.fetch(section, 0) + 1 + end + end + + # `[[section, 1-based item number, item line]]` in the specification's order. + # + # @param path [String] + # @return [Array] + def spec_items(path) + section = nil + ::File.readlines(path).each_with_object([]) do |line, items| + if (match = SECTION.match(line)) + section = match[1] + elsif line.start_with?(ITEM) && section + items << [section, items.count { |(existing, _, _)| existing == section } + 1, line] + end + end + end + + # The map's own table: `| section | item | ids | evidence | status |`. + # + # @param path [String] the coverage map + # @return [Array] one row per line, parsed + def rows(path) + ::File.readlines(path).filter_map do |line| + next unless line.start_with?("| B.") + + cells = line.split("|").map(&:strip).reject(&:empty?) + next if cells.size < 5 + + { section: cells[0], item: cells[1], ids: ids_in(cells[2]), + evidence: evidence(cells[3]), status: cells[4], } + end + end + + # @param path [String] the coverage map + # @return [Hash{String => Integer}] rows per section + def counts_by_section(path) + rows(path).each_with_object({}) do |row, counts| + counts[row[:section]] = counts.fetch(row[:section], 0) + 1 + end + end + + # One row per specification item, in the specification's order, with the ID column parsed from + # the item's own text. An item's evidence and status are HAND-WRITTEN and carried over from the + # existing map; an item with neither is emitted `unmapped` with `unmapped` in the evidence + # column -- not a path, so the map's own evidence check names it until someone writes the row. + # + # @param spec_path [String] the specification's appendix B + # @param map_path [String] the coverage map, read BEFORE it is written + # @return [String] the whole map + def generate(spec_path, map_path) + hand = hand_written(map_path) + body = spec_items(spec_path).map do |(section, item, text)| + evidence, status = hand.fetch([section, item.to_s], %w[unmapped unmapped]) + "| #{section} | #{item} | #{ids_in(text).join(", ")} | #{evidence} | #{status} |" + end + "#{header(map_path)}#{body.join("\n")}\n" + end + + # The map's hand-written evidence and status per row, keyed by `[section, item]`. Read BEFORE + # the caller writes the file, which is why the CLI takes the map's PATH rather than redirecting + # stdout into it: a shell redirect truncates the file before the generator reads it. + def hand_written(path) + return {} unless ::File.exist?(path) + + rows(path).to_h do |row| + [[row[:section], row[:item]], [row[:evidence] || "--", row[:status]]] + end + end + + # Everything above the table, preserved verbatim from the existing map so the preamble is + # hand-written prose the generator never rewrites. + def header(path) + return DEFAULT_HEADER unless ::File.exist?(path) + + lines = ::File.readlines(path) + cut = lines.index { |line| line.start_with?("| B.") } + cut.nil? ? DEFAULT_HEADER : lines.take(cut).join + end + + DEFAULT_HEADER = <<~MARKDOWN + # Appendix B coverage map + + | Section | Item | IDs | Evidence | Status | + |---|---|---|---|---| + MARKDOWN + + private + + def evidence(cell) + stripped = cell.delete("`") + stripped == "--" ? nil : stripped + end +end + +if $PROGRAM_NAME == __FILE__ + spec = ARGV.fetch(0, "docs/product-spec/appendix-b-conformance-test-checklist.md") + map = ARGV.fetch(1, "gems/dexpace-conformance/APPENDIX_B.md") + File.write(map, AppendixB.generate(spec, map)) + puts "wrote #{map} (#{AppendixB.spec_item_count(spec)} rows)" +end diff --git a/tools/ast_scan.rb b/tools/ast_scan.rb new file mode 100644 index 0000000..321ddfa --- /dev/null +++ b/tools/ast_scan.rb @@ -0,0 +1,257 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# The shared walker behind gates:cause_walk, gates:bounded_map and gates:seam_names. +# +# `RubyVM::AbstractSyntaxTree` rather than a regex, because `grep '\.cause'` matches a comment, a +# string, an `# XCUT-9` citation in a test header and the requirement ID itself. Verified present +# on 3.2.11, 3.3.12, 3.4.10 and 4.0.6. +# +# **Why not `prism`, since `tools/require_scan.rb` uses it and the bundle carries it on every +# row.** `prism` is a default gem only from 3.3 and the version the require gate was written +# against is the bundle's, not the interpreter's; `RubyVM::AbstractSyntaxTree` needs no gem at +# all, so these three gates run on any row with no bundle and no conditional require. That is the +# real reason, and it is not "consistency with the existing parsed scan" -- which, now that prism +# IS in the bundle on every row, would argue the other way. +# +# The PARSER emits no warning of its own for well-formed input; the SCANNED FILE's own `-w` +# diagnostics are a different thing and DO reach `Warning.warn`, which is load-bearing here. +# Every parse below goes through #parse, and nothing else in this file may call `parse_file`. +# rubocop:disable Metrics/ModuleLength -- one parsed-scan vocabulary over one parser. Every +# method here is knowledge about ONE thing, the shape of a `RubyVM::AbstractSyntaxTree` node on +# the four supported interpreters, and splitting it by line count would put half of that +# knowledge in a second file that has to be read with this one to mean anything. +module AstScan + # Node types a send can take. `:CALL` is `a.cause`, `:QCALL` is `a&.cause`, `:VCALL` is a bare + # `cause` and `:FCALL` is `cause()` or a receiverless `send(:cause)`. Measured identical on + # 3.2.11, 3.3.12, 3.4.10 and 4.0.6; a scan checking only `:CALL` misses every safe-navigated + # send. + SEND_TYPES = %i[CALL QCALL VCALL FCALL].freeze + + # A Symbol literal is a `:LIT` node on Ruby 3.2 and 3.3 and a `:SYM` node on 3.4 and 4.0. + # Measured on all four, and it is the reverse of the usual direction -- the NEWER interpreters + # diverge. A scan naming only `:LIT` caught 6 of 7 reflective shapes on the floor and 2 of 7 on + # the newer rows: strictest exactly where it runs least. + SYMBOL_TYPES = %i[LIT SYM].freeze + + # Sends that reach a method by NAME rather than by call syntax. All four bypass a scan that + # looks only at call syntax. + REFLECTIVE_SENDS = %i[send __send__ public_send method].freeze + + extend self + + # `parse_file` with the SCANNED FILE's own `-w` diagnostics suppressed, which is the whole + # reason this method exists rather than five direct `parse_file` calls. + # + # A file's `-w` diagnostics are emitted AT PARSE TIME and routed through `Warning.warn` exactly + # as they are at require time. Phase 0's shared test case prepends `FatalWarnings` to Warning's + # singleton class, and `test/gates/invariant_gates_test.rb` requires it, so a bare `parse_file` + # raises on the first such file. + # + # Which mechanism, measured on all four rows: `Warning[:deprecated] = false` does NOT reach it + # (the warning carries no category); `$VERBOSE = false` silences the `-w` class but not the + # always-on parse warnings, and `key :a is duplicated` still fires under it; only + # `$VERBOSE = nil` silences both. It is process-global, so the window is this one call and + # `ensure` closes it -- outside it, NFR-6's gate is armed exactly as before. + # + # @param path [String] + # @return [RubyVM::AbstractSyntaxTree::Node, nil] + def parse(path) + previous = $VERBOSE + $VERBOSE = nil + ::RubyVM::AbstractSyntaxTree.parse_file(path) + ensure + $VERBOSE = previous + end + + # Every send whose method name is in `names`, whatever the call syntax, plus every reflective + # send naming one of them as a Symbol literal, plus `&:name` block passes. + # + # @param path [String] + # @param names [Array] + # @return [Array] path, line, method name + def receiver_calls(path, names) + hits = [] #: Array[untyped] + walk(parse(path)) { |node| collect_call(node, names, path, hits) } + hits + end + + # Every instance-variable assignment whose value is a Hash by a statically decidable route: a + # literal `{}`, a `Hash.new` / `::Hash.new` call, a chained call on either + # (`{}.compare_by_identity`), or an `||=` of any of those. + # + # **Stated gap:** `@h = build_map`, `@h = OTHER.dup`, a Hash arriving through a parameter, a + # class variable, a bare constant, `instance_variable_set(:@h, {})` and a Hash held inside a + # value object rather than directly on the ivar are undecidable from one file and are NOT + # reported. The gate is a FLOOR on the invariant rather than proof of it. + # + # @param path [String] + # @return [Array] path, line, ivar name + def hash_ivar_assignments(path) + hits = [] #: Array[untyped] + walk(parse(path)) do |node| + next unless %i[IASGN OP_ASGN_OR].include?(node.type) + + name, value = ivar_assignment(node) + hits << [path, node.first_lineno, name] if !name.nil? && hash_valued?(value) + end + hits + end + + # Every constant path written in the file, rendered as `Dexpace::Serde::JSON`. + # + # @param path [String] + # @return [Array] path, line, constant path + def constant_paths(path) + hits = [] #: Array[untyped] + walk(parse(path)) do |node| + next unless %i[CONST COLON2 COLON3].include?(node.type) + + rendered = render_const(node) + hits << [path, node.first_lineno, rendered] unless rendered.nil? + end + hits + end + + # Every String and Symbol literal, so `const_get("Dexpace::Serde::JSON")` and + # `const_get(:"Dexpace::Serde::JSON")` are both reachable by the same match the constant scan + # uses. + # + # @param path [String] + # @return [Array] path, line, literal + def string_literals(path) + hits = [] #: Array[untyped] + walk(parse(path)) do |node| + text = literal_text(node) + hits << [path, node.first_lineno, text] unless text.nil? + end + hits + end + + def walk(node, &block) + return unless node.is_a?(::RubyVM::AbstractSyntaxTree::Node) + + yield(node) + node.children.each { |child| walk(child, &block) } + end + + private + + # The text of a String or Symbol literal node, nil for every other node. Both spellings answer + # here because `const_get("Dexpace::X")` and `const_get(:"Dexpace::X")` reach the same constant. + def literal_text(node) + return node.children.first if node.type == :STR + return nil unless SYMBOL_TYPES.include?(node.type) + + value = node.children.first + value.is_a?(::Symbol) ? value.to_s : nil + end + + def collect_call(node, names, path, hits) + if node.type == :BLOCK_PASS # `errors.map(&:cause)`: BLOCK_PASS(nil, SYM/LIT) + passed = symbol_value(node.children[1]) + hits << [path, node.first_lineno, passed] if names.include?(passed) + return + end + return unless SEND_TYPES.include?(node.type) + + record_send(node, names, path, hits) + end + + def record_send(node, names, path, hits) + called = method_name(node) + if names.include?(called) + # A walk sends #cause with NO argument; a send carrying one is some other #cause -- 5b's + # filed `Event#cause(error)` builder is the case that made this gate red over conforming + # code. + hits << [path, node.first_lineno, called] if arguments(node).nil? + return + end + return unless REFLECTIVE_SENDS.include?(called) + + reflected = named_argument(node) + hits << [path, node.first_lineno, reflected] if names.include?(reflected) + end + + # `:VCALL` and `:FCALL` carry the name first; `:CALL` and `:QCALL` carry the receiver first. + def method_name(node) = %i[VCALL FCALL].include?(node.type) ? node.children[0] : node.children[1] + + # The argument node, or nil when there is none (a `:VCALL` has no slot at all). + def arguments(node) + index = { CALL: 2, QCALL: 2, FCALL: 1 }[node.type] + index.nil? ? nil : node.children[index] + end + + # The SOLE argument of a send when it names a method: a Symbol literal OR a String literal, so + # `e.send("cause")` is caught beside `e.send(:cause)`. `nil` for anything dynamic -- + # `send(name)` where `name` is a variable is undecidable statically and is one of the shapes + # the stated gap covers. + def named_argument(node) + args = arguments(node) + return nil unless args.is_a?(::RubyVM::AbstractSyntaxTree::Node) && args.type == :LIST + + values = args.children.compact + values.size == 1 ? literal_name(values.first) : nil + end + + def literal_name(node) + return nil unless node.is_a?(::RubyVM::AbstractSyntaxTree::Node) + return node.children.first.to_sym if node.type == :STR && node.children.first.is_a?(::String) + + symbol_value(node) + end + + def symbol_value(node) + unless node.is_a?(::RubyVM::AbstractSyntaxTree::Node) && SYMBOL_TYPES.include?(node.type) + return nil + end + + value = node.children.first + value.is_a?(::Symbol) ? value : nil + end + + # `@h = …` is `:IASGN`; `@h ||= …` is `:OP_ASGN_OR` whose second child is the `:IASGN`. + def ivar_assignment(node) + return [node.children[0], node.children[1]] if node.type == :IASGN + + inner = node.children[1] + return [nil, nil] unless inner.is_a?(::RubyVM::AbstractSyntaxTree::Node) && inner.type == :IASGN + + [inner.children[0], inner.children[1]] + end + + def hash_valued?(node) + return false unless node.is_a?(::RubyVM::AbstractSyntaxTree::Node) + + case node.type + when :HASH then true + when :CALL, :QCALL + return true if node.children[1] == :new && const_name(node.children[0]) == :Hash + + hash_valued?(node.children[0]) + else false + end + end + + # `Hash` is a `:CONST` and `::Hash` is a `:COLON3`; a check naming only the first missed + # `@h = ::Hash.new` on every interpreter (measured). + def const_name(node) + return nil unless node.is_a?(::RubyVM::AbstractSyntaxTree::Node) + + %i[CONST COLON3].include?(node.type) ? node.children[0] : nil + end + + def render_const(node) + case node.type + when :CONST then node.children[0].to_s + when :COLON3 then "::#{node.children[0]}" + when :COLON2 + left = node.children[0] + return node.children[1].to_s if left.nil? + + inner = render_const(left) + inner.nil? ? nil : "#{inner}::#{node.children[1]}" + end + end +end +# rubocop:enable Metrics/ModuleLength diff --git a/tools/invariant_gates.rb b/tools/invariant_gates.rb new file mode 100644 index 0000000..4063540 --- /dev/null +++ b/tools/invariant_gates.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "ast_scan" + +# The repository-wide invariant scans (design R4). Each returns a list of offence strings; the +# Rake task prints them and exits non-zero when the list is non-empty. +# +# These are Rake GATES and not conformance assertions because their subject is THIS repository's +# source tree, which does not exist in a consumer's process. `dexpace-conformance` ships in `lib/` +# so a third party can run it against THEIR gem; a scan of `gems/*/lib/` would be meaningless +# there, and putting it in that gem would make it read a filesystem it does not own. +module InvariantGates + # XCUT-9: `Dexpace.each_cause` is the single cause walk (4b's hand-forward). + CAUSE_WALK_ALLOWED = [ + "gems/dexpace-core/lib/dexpace/each_cause.rb", # the single walk itself + ].freeze + + # SEAM-2, matched by the ADAPTER-OWNED LEAF namespace anywhere in the path. Measured twice + # during planning: a fixed list of fully qualified names caught 0 of 4 realistic shapes, and + # matching any path under a SEAM namespace (Serde, Transport, Async, Instrumentation) flagged 31 + # conforming references in 15 of core's own files -- `Async::Completer`, `Async::Future`, + # `Instrumentation::*` -- because core legitimately owns constants there. So the rule names the + # LEAF each MVP adapter gem owns (CLAUDE.md's gem table). + # + # **Stated gap:** a later adapter gem adds its leaf here or is invisible to this gate. + ADAPTER_NAMESPACES = [%w[Serde JSON], %w[Transport NetHTTP], %w[Transport AsyncHTTP], + %w[Async Thread],].freeze + + # XCUT-14: `Dexpace::BoundedMap` is the one bounded-keyed-map implementation (4a's + # hand-forward), so its own file is the first exclusion and every other entry is an ADJUDICATED + # false positive. A path => reason Hash, exactly as phase 0's require allowlist is, because an + # allowlist whose entries carry no argument is a list of things somebody once silenced. + # + # **Adjudicated 2026-09-23 against the tree AS BUILT, not against a filed plan fence.** With an + # empty allowlist the gate reports nine Hash ivars in nine files; six are here. XCUT-14 scopes + # itself to a "process/instance-lived map whose key space is influenced by callers or remote + # servers" and adds that "the cap is a memory backstop and MUST NOT be relied on as the + # primary cleanup mechanism". What every entry here has in common is that last clause: the + # map's primary cleanup is its OWNER being dropped after one operation, so a cap could never + # be the backstop. That property is a LIFETIME, and a lifetime is not decidable from one + # file -- which is why the allowlist is the mechanism and not a cleverer scan. + # + # The planning adjudication named `gems/dexpace-core/lib/dexpace/configuration.rb`, which holds + # no Hash ivar at all: 5a's two accumulators are in `configuration/builder.rb`. That entry + # silenced nothing and survived a whole adjudication, which is why the integrity test below + # asserts that every key still REPORTS at that path rather than only that the file exists. + BOUNDED_MAP_ALLOWED = { + "gems/dexpace-core/lib/dexpace/bounded_map.rb" => + "@h IS Dexpace::BoundedMap's own store -- the single implementation this gate exists to " \ + "keep single (4a's P4-3)", + "gems/dexpace-core/lib/dexpace/configuration/builder.rb" => + "@overrides and @properties are Configuration::Builder accumulators discarded at #build, " \ + "and the built Configuration is a frozen Data; the keys are the embedder's own CFG keys, " \ + "not caller or server input", + "gems/dexpace-core/lib/dexpace/instrumentation/event.rb" => + "@fields is ONE log event's field bag, allocated per call and dropped at #emit; its " \ + "lifetime is a single operation, so a cap could never be the memory backstop XCUT-14 " \ + "describes. The closest call of the five: http.response.header.* keys ARE " \ + "server-influenced, so it fails the key-space clause and passes on lifetime alone", + "gems/dexpace-core/lib/dexpace/auth/challenges.rb" => + "@params is ONE challenge parse's accumulator inside the private_constant Parser, reset at " \ + "every open_challenge/emit and dropped with the parse -- the same lifetime argument the " \ + "log event carries. The nonce COUNTER, which is caller- and server-keyed, is a real " \ + "BoundedMap in auth/digest_handler.rb", + "gems/dexpace-conformance/lib/dexpace/conformance/recording_span.rb" => + "@attributes is one RecordingSpan double's record, inert after #finish and dropped with " \ + "the span", + "gems/dexpace-transport-async_http/lib/dexpace/transport/async_http/clients.rb" => + "@by_key IS capped, and the scan cannot see a cap. VERIFIED by reading the file on " \ + "2026-09-23: Clients#fetch inserts with ||= and calls #drain inside the SAME " \ + "@mutex.synchronize as the insert, and #drain is a LOOP -- " \ + "`evicted << @by_key.delete(@by_key.keys.first) while @by_key.size > MAX_ORIGINS`, with " \ + "clients whose reactor has closed evicted first -- so a concurrent insert burst converges " \ + "to the bound instead of overshooting permanently, which is XCUT-14's drain clause " \ + "exactly; every evicted client's pool is retired outside the lock. Phase 9's planning " \ + "adjudicated this as the one TRUE positive of six, against 8c's filed plan fence rather " \ + "than its code, and 8c shipped the bound its Task 8 promised", + }.freeze + + extend self + + # @param files [Array] every file to scan + # @param allowed [Array] paths whose walk is the single walk itself + # @return [Array] one offence line per violation + def cause_walk(files, allowed: CAUSE_WALK_ALLOWED) + offences(files, allowed) do |path| + AstScan.receiver_calls(path, [:cause]).map do |(_, line, _)| + "#{path}:#{line}: walks #cause outside Dexpace.each_cause (XCUT-9)" + end + end + end + + # A Hash assigned to an instance variable is the shape a caller- or server-keyed map takes. + # + # @param files [Array] every file to scan + # @param allowed [Hash{String => String}] path => the reason it is not a caller-keyed map + # @return [Array] one offence line per violation + def bounded_map(files, allowed: BOUNDED_MAP_ALLOWED) + offences(files, allowed.keys) do |path| + AstScan.hash_ivar_assignments(path).map do |(_, line, ivar)| + "#{path}:#{line}: #{ivar} is a Hash on an instance; only Dexpace::BoundedMap may hold a " \ + "caller- or server-keyed map (XCUT-14)" + end + end + end + + # @param files [Array] core's own files + # @param namespaces [Array] each adapter's leaf pair + # @return [Array] one offence line per violation + def seam_names(files, namespaces: ADAPTER_NAMESPACES) + offences(files, []) do |path| + candidates = AstScan.constant_paths(path) + AstScan.string_literals(path) + candidates.filter_map do |(_, line, rendered)| + next unless concrete_seam?(rendered.to_s, namespaces) + + "#{path}:#{line}: core names the concrete seam implementation #{rendered} (SEAM-2)" + end + end + end + + # `Dexpace::Serde::JSON`, `::Dexpace::Serde::JSON`, bare `Serde::JSON` and + # `Dexpace::Serde::JSON::Codec` all contain an adapter's leaf pair as CONSECUTIVE segments; + # `Dexpace::Serde::Error`, `Dexpace::Async::Future` and `Instrumentation::Severity` contain + # none. + def concrete_seam?(rendered, namespaces) + rendered.delete_prefix("::").split("::").each_cons(2).any? { |pair| namespaces.include?(pair) } + end + + def offences(files, allowed, &) + (files - allowed).flat_map(&) + end +end diff --git a/tools/requirement_levels.rb b/tools/requirement_levels.rb new file mode 100644 index 0000000..ba5203f --- /dev/null +++ b/tools/requirement_levels.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Generates gems/dexpace-conformance/lib/dexpace/conformance/levels.rb from appendix C. +# +# Appendix C is normative and FROZEN (CLAUDE.md; Guard::FROZEN). This tool READS it and writes +# exactly one file, TARGET, which is not under docs/. There is no code path that writes SOURCE. +# +# A gem cannot read docs/ from a consumer's machine, so the map is committed inside the gem and +# test/gates/requirement_levels_test.rb regenerates it in memory and diffs it against the +# committed file -- the way the runtime surface snapshot is kept honest. `MUST NOT` maps to +# :must: it is a MUST with a negated predicate, and appendix C holds exactly one. +# +# ruby tools/requirement_levels.rb # regenerate levels.rb +# ruby tools/requirement_levels.rb --check # exit 1 if levels.rb is stale +require "pathname" + +# The appendix-C parser and the levels.rb renderer. +module RequirementLevels + ROOT = Pathname(__dir__).join("..").expand_path + SOURCE = ROOT.join("docs/product-spec/appendix-c-consolidated-normative-requirement-index.md") + TARGET = ROOT.join("gems/dexpace-conformance/lib/dexpace/conformance/levels.rb") + # CLAUDE.md's 19 prefixes, in appendix-C order. Restricting the regex is what keeps ISO-8601 and + # RFC-shaped tokens out of the map. + PREFIXES = %w[SEAM HTTP IO BODY CTX PIPE RECOV RETRY REDIR AUTH PAGE SSE SERDE OBS CFG + TRANSPORT ASYNC XCUT NFR].freeze + ROW = /\A\| ((?:#{PREFIXES.join("|")})-\d+) \| (MUST NOT|MUST|SHOULD|MAY) \|/ + LEVEL = { "MUST" => :must, "MUST NOT" => :must, "SHOULD" => :should, "MAY" => :may }.freeze + + extend self + + # @param source [Pathname] appendix C + # @return [Hash{String => Symbol}] every requirement ID's normative level + def parse(source = SOURCE) + source.each_line.filter_map { |line| (m = ROW.match(line)) && [m[1], LEVEL.fetch(m[2])] }.to_h + end + + # @param map [Hash{String => Symbol}] + # @return [String] the whole of levels.rb + def render(map) + rows = map.map { |id, level| " #{id.inspect} => #{level.inspect}," }.join("\n") + <<~RUBY + # frozen_string_literal: true + # SPDX-License-Identifier: MIT + + # GENERATED by tools/requirement_levels.rb from appendix C -- do not edit; re-run the tool. + module Dexpace + module Conformance + # The normative level of every requirement ID, so a Report can tell a MUST-level vacuity + # from a SHOULD-level one (design R3; NFR-17). A gem cannot read docs/ at runtime, so the + # map is committed here and test/gates/requirement_levels_test.rb diffs it against + # appendix C on every run. `MUST NOT` is :must -- a MUST with a negated predicate. + # + # rubocop:disable Metrics/ModuleLength, Metrics/CollectionLiteralLength -- a generated + # table of every requirement ID in the specification: the size IS the specification's, + # and "read the data from an external source" is what a gem cannot do, since docs/ is + # not on a consumer's machine. + module Levels + # Every requirement ID in appendix C mapped to its RFC 2119 level. + OF = { + #{rows} + }.freeze + + # @param id [String] a requirement ID + # @return [Boolean] true when appendix C holds it + def self.known?(id) = OF.key?(id) + + # @param id [String] a requirement ID + # @return [Symbol] :must, :should, :may, or :unknown for an ID appendix C does not hold + def self.of(id) = OF.fetch(id, :unknown) + + # @param id [String] a requirement ID + # @return [Boolean] true only for a MUST or a MUST NOT + def self.must?(id) = of(id) == :must + end + # rubocop:enable Metrics/ModuleLength, Metrics/CollectionLiteralLength + end + end + RUBY + end +end + +if $PROGRAM_NAME == __FILE__ + rendered = RequirementLevels.render(RequirementLevels.parse) + if ARGV.include?("--check") + # The optional path is what lets the gate's own test drive the FAILING case: with TARGET fixed + # the test could only ever assert exit 0, and a --check that always exited 0 would pass it -- + # measured, by mutation, during this phase. `--check ` compares the same render against + # a file the test wrote, so both exits are covered. + target = Pathname((ARGV - ["--check"]).first || RequirementLevels::TARGET) + exit(target.exist? && target.read == rendered ? 0 : 1) + else + RequirementLevels::TARGET.write(rendered) + puts "wrote #{RequirementLevels::TARGET} (#{RequirementLevels.parse.size} ids)" + end +end diff --git a/tools/serde_boundary.rb b/tools/serde_boundary.rb index d763bb9..0e73d3e 100644 --- a/tools/serde_boundary.rb +++ b/tools/serde_boundary.rb @@ -74,8 +74,27 @@ def violations(root) end # The PENDING rows with how many files each currently matches, for the task to print. - def pending(root) - PENDING.map { |glob, reason| [glob, reason, Dir.glob(File.join(root, glob)).size] } + # `list:` is a one-line widening phase 9 added so the gate's own test can drive a NON-empty + # pending list and watch the emptiness assertion fail: `PENDING` is a frozen constant and + # reading it directly gave the fixture nothing to inject. The default is that constant, so + # every caller outside the test is unchanged. + # + # @param root [String] the repository root, or a fixture standing in for it + # @param list [Array] glob and reason pairs + # @return [Array] glob, reason, how many files match today + def pending(root, list: PENDING) + list.map { |glob, reason| [glob, reason, Dir.glob(File.join(root, glob)).size] } + end + + # The abort message for a non-empty PENDING list. + # + # @param pending [Array] + # @return [String] + def pending_message(pending) + rows = pending.map { |glob, reason, _| " #{glob} -- #{reason}" } + "SerdeBoundary::PENDING is not empty, and SSE-37's handed-forward clause asks that it be " \ + "by the end of phase 7:\n#{rows.join("\n")}\nMove each row to GUARDED in the change " \ + "that lands its files, or delete it." end # One file's violations. A Ruby file that does not parse is a violation in itself: a file the