Skip to content

requestCount is an exact count in every runner, not a lower bound (#573) - #596

Merged
jeremy merged 1 commit into
mainfrom
fix/573-exact-request-count
Aug 3, 2026
Merged

requestCount is an exact count in every runner, not a lower bound (#573)#596
jeremy merged 1 commit into
mainfrom
fix/573-exact-request-count

Conversation

@jeremy

@jeremy jeremy commented Aug 3, 2026

Copy link
Copy Markdown
Member

Closes #573. Third of a three-PR stack. Stacked on #595 (which is stacked on #594) — merge in order; the five unit tests added here are discovered automatically because of #594. Review this PR's own commit only.

The defect

Go, Python, Ruby, TypeScript and Kotlin all evaluated the requestCount assertion as a lower bound whenever any mock response carried Link: rel="next":

if (autoPaginates) {
    if (requestCount < expected) { fail }
} else if (requestCount != expected) { fail }

That is backwards for the fixtures it covered. conformance/tests/pagination.json queues more pages than it expects requests in two cases, because stopping early is the behavior under test:

Fixture Queued pages Expected requests
Pagination stops at maxPages safety cap 3 2
maxItems caps results across pages 3 2
Auto-pagination follows Link headers across multiple pages 3 3

An SDK that ignored the cap and walked all three passed 3 >= 2. The third is the exposed case: its only assertions are requestCount and noError, so an over-fetch had nothing else to catch it — the other two happen to carry a responseMeta truncated assertion that fires instead, coverage by luck.

The fix

The relaxation was protecting exactly one fixture, "List operation returns first page with Link header" (tagged link-header), whose requestCount: 1 counts first-page requests only and so cannot apply to an auto-paginating SDK. With that one fixture's count taken out of scope, nothing reaching the evaluator needs a lower bound, and >= becomes != everywhere. Swift took this in #558 and its shape is what the five now match.

The MockEngine / httptest / MSW / respx / WebMock auto-pagination tolerance stays: answering an over-walk with a terminal empty page rather than an error is what lets the tightened assertion report a clean count mismatch instead of an opaque transport error.

Each predicate moves into that language's SDK-free support module alongside delay_gapsrequest_count.go, check_request_count in runner.py, RequestCount in runner.rb, request-count.ts, RequestCount.kt — with a unit test per language. Before #594, three of those five test files would have been executed by nothing.

Take the ASSERTION out of scope, not the CASE

This is the review finding that reshaped the PR. An earlier revision did it the blunt way: skip the whole link-header case in Go, Python, Ruby and TypeScript, copying what Kotlin and Swift already did. That is wrong, and wrong in this stack's own signature shape.

The fixture carries three assertions, not one:

Assertion Applicable to an auto-paginating SDK?
requestCount: 1 No — the SDK follows the Link header
statusCode: 200 Yes
noError Yes

Kotlin and Swift had always skipped the case, so the moment the other four joined them the fixture was skipped by all six. Its statusCode and noError assertions had been running in four runners; they then ran in zero. And nothing reports that: the fixture still sits in conformance/tests/pagination.json, still passes conformance-fixtures-check and check-fixture-coverage, so the build stays green over a fixture no runner executes. That is precisely #572's defect — present in the tree, run by nothing — one layer down, committed inside the stack that exists to close it.

So the exclusion is now one assertion wide. requestCountApplies(tags) (and its per-language spellings) returns false for link-header, the evaluator skips that one assertion, and the case runs.

Verbatim before/after from the TypeScript runner, the arm where a skip is directly observable. Pre-narrowing files taken from this stack's earlier pushed head a378d8987, everything else identical:

$ npx vitest run --reporter=verbose -t "List operation returns first page with Link header"
↓ runner.test.ts > conformance/pagination.json > List operation returns first page with Link header (TS SDK auto-paginates; follows Link headers by design)
[...]
      Tests  182 skipped (182)
REAL_EXIT=0

$ npx vitest run --reporter=verbose -t "List operation returns first page with Link header"
✓ runner.test.ts > conformance/pagination.json > List operation returns first page with Link header 19ms
[...]
      Tests  1 passed | 184 skipped (185)
REAL_EXIT=0

[...] elides vitest's per-file listing of the other 180-odd cases the -t filter skipped, plus its Test Files / Duration lines. The two quoted lines and the Tests summaries are verbatim. Zero passed becomes one passed: the case was executed by nothing and now runs.

Kotlin and Swift keep the whole-case skip, deliberately

They are not narrowed, and this is a stated exception rather than an oversight. Both derive the response status from the last mock response the SDK consumed, and an auto-paginating SDK walks past the end of a one-response queue, so statusCode has nothing to report. Narrowing them was tried; both fail:

$ make conformance-kotlin
  FAIL: List operation returns first page with Link header
        Expected status code 200, but got no response
[...]
Passed: 142, Failed: 1, Skipped: 0, Total: 143
REAL_EXIT=2

$ make conformance-swift
  FAIL: List operation returns first page with Link header
        Expected status code 200, but got no response
[...]
Passed: 142, Failed: 1, Skipped: 0, Total: 143
REAL_EXIT=2

[...] elides the other 142 result lines of each run. Note the exit code: through make a recipe failure is 2, not the runner binary's own 1.

Widening those two runners' status model is separate work, not a skip to delete here. Both call sites now carry that reasoning in a comment, so the asymmetry does not read as drift to be "aligned" away.

Net effect on coverage: the fixture goes from run by four runners (before this stack) to run by four runners (after), instead of by zero. This PR changes no skip count in any runner — see the two verification blocks below.

The general gap remains: nothing in the build detects a fixture that every runner skips. Filed as #602 and referenced from SPEC §19. This PR fixes the instance, not the class.

Red proof 1 — the runners, on the real fixture

Mutating "Auto-pagination follows Link headers across multiple pages" to expect 2 while the SDK makes 3 is exactly the over-fetch the lower bound waves through. Base is 07482b9ba, this stack's #553 tip, byte-identical to origin/main for every runner file involved.

$ make conformance-<lang>          # base 07482b9ba (lower bound)
  PASS: Auto-pagination follows Link headers across multiple pages
REAL_EXIT[base-conformance-go]=0
REAL_EXIT[base-conformance-python]=0
REAL_EXIT[base-conformance-ruby]=0
REAL_EXIT[base-conformance-typescript]=0
REAL_EXIT[base-conformance-kotlin]=0

$ make conformance-<lang>          # this PR (exact count)
  FAIL: Auto-pagination follows Link headers across multiple pages
        Expected 2 requests, got 3
REAL_EXIT[branch-conformance-go]=2
REAL_EXIT[branch-conformance-python]=2
REAL_EXIT[branch-conformance-ruby]=2
REAL_EXIT[branch-conformance-typescript]=2
REAL_EXIT[branch-conformance-kotlin]=2

The PASS/FAIL lines are one per language and identical in each; TS prints its as FAIL runner.test.ts > conformance/pagination.json > Auto-pagination follows Link headers across multiple pages with Error: [Auto-pagination follows Link headers across multiple pages] Expected 2 requests, got 3. Every exit code is real and from make, which reports a recipe failure as 2 — an earlier revision of this PR reported these as 1, which is the bare runner binary's code, not the one the stated command produces.

The fixture edit was reverted; conformance/tests/pagination.json is unchanged in this PR.

Red proof 2 — the new unit tests, against the old predicate

Restoring the lower bound in each support module fails the same three cases in every language. These are the bare test binaries, so the exit code is 1, not make's 2:

Go       --- FAIL: TestRequestCountRejectsAnOverFetch
             request_count_test.go:33: 3 requests where 2 were expected should fail;
                                       a lower bound would accept it
         --- FAIL: TestRequestCountMessageNamesBothCounts
         --- FAIL: TestRequestCountZeroExpectedRequiresZeroActual
         FAIL github.com/basecamp/basecamp-sdk/conformance/runner/go   REAL_EXIT=1
Python   3 failed, 6 passed in 0.10s                                   REAL_EXIT=1
Ruby     9 runs, 13 assertions, 3 failures, 0 errors, 0 skips          REAL_EXIT=1
TS       Tests  3 failed | 6 passed (9)                                REAL_EXIT=1
Kotlin   23 tests completed, 3 failed                                  REAL_EXIT=1
         RequestCountTest: 6 tests, 3 failures — "an over-fetch fails",
         "the failure message names both counts",
         "zero expected requires zero actual"

The three new requestCountApplies tests per language are not part of that red: they pin the SCOPE of the exclusion, asserting that a link-header fixture keeps its statusCode and noError assertions live. If the suppression ever widens back to the whole case, those fail.

Verification, exact-count code (real exit codes, measured on this commit)

make conformance-go         Passed: 141, Failed: 0, Skipped: 2   REAL_EXIT=0
make conformance-python     143 passed, 0 failed, 0 skipped      REAL_EXIT=0
make conformance-ruby       132 passed, 0 failed, 11 skipped     REAL_EXIT=0
make conformance-typescript 183 passed | 2 skipped (185)         REAL_EXIT=0
make conformance-kotlin     Passed: 142, Failed: 0, Skipped: 1   REAL_EXIT=0
make conformance-swift      Passed: 142, Failed: 0, Skipped: 1   REAL_EXIT=0
make conformance-runner-tests-go      ok (cached)                REAL_EXIT=0
make conformance-runner-tests-python  29 passed, 31 subtests     REAL_EXIT=0
make conformance-runner-tests-ruby    11 + 6 + 9 runs, 0 fail    REAL_EXIT=0
make conformance-runner-tests-kotlin  (--quiet, no output)       REAL_EXIT=0
make conformance-runner-tests-swift   39 tests, 0 failures       REAL_EXIT=0
./scripts/check-runner-test-reachability   9 checks passed       REAL_EXIT=0
./scripts/check-runner-test-reachability --self-test  6 cases    REAL_EXIT=0
./scripts/check-replay-decoder-parity  5 checks, 31 operations   REAL_EXIT=0
cd conformance/runner/go && go build ./... && go vet ./...       REAL_EXIT=0
make lint-actions           No findings to report                REAL_EXIT=0

And the same suites at base 07482b9ba — the figures the "no skip count changes" claim rests on:

base conformance-go         Passed: 141, Failed: 0, Skipped: 2
base conformance-python     143 passed, 0 failed, 0 skipped
base conformance-ruby       132 passed, 0 failed, 11 skipped
base conformance-typescript 174 passed | 2 skipped (176)
base conformance-kotlin     Passed: 142, Failed: 0, Skipped: 1

Identical skip counts throughout. TypeScript's total rises 176 → 185 because this PR adds request-count.test.ts's 9 tests; its skipped count is 2 on both sides. (Those base figures come from the red-proof-1 base runs, which carry the mutated expectation — a changed expectation moves no skip.)

Local figures — cite the CI job's own numbers where they differ.

Also in this PR

The Go runner's hasTag helper is deleted (thread). It existed only for the whole-case branch this PR no longer has, and it had been inserted between goSDKSkips' doc comment and goSDKSkips itself, so godoc read that comment as documenting hasTag.

Docs

SPEC §19's link-header entry is rewritten. Previously it was a per-runner repeat that claimed Swift's skip was "identical to Kotlin and TypeScript" — untrue, TypeScript had no link-header handling at all. It is now one block stating what each runner excludes, why Kotlin and Swift differ, and what the all-six-skipped shape would cost.

No spec files touched (spec/basecamp.smithy, openapi.json unchanged).

Copilot AI review requested due to automatic review settings August 3, 2026 06:23
@jeremy jeremy added the bug Something isn't working label Aug 3, 2026
@github-actions github-actions Bot added kotlin conformance Conformance test suite labels Aug 3, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a false-green in the conformance harness: five of the six runners (Go, Python, Ruby, TypeScript, Kotlin) evaluated the requestCount assertion as a lower bound whenever any mock response carried Link: rel="next". That is backwards for the pagination fixtures it covered — they queue more pages than they expect requests because stopping early (at a maxPages/maxItems cap) is the behavior under test, so an over-fetching SDK could pass 3 >= 2. The fix makes requestCount an exact-equality check everywhere, matching what Swift already did in #558. The one fixture where a first-page-only count genuinely doesn't apply ("List operation returns first page with Link header") is now excluded via a link-header tag branch in all six runners, rather than special-casing the assertion.

Changes:

  • Replace lower-bound requestCount logic with exact equality, extracted into per-language SDK-free helpers (request_count.go, check_request_count, RequestCount, request-count.ts, RequestCount.kt), each with a unit test.
  • Add a link-header tag-based skip to the Go, Python, Ruby, and TypeScript runners (Kotlin/Swift already had it).
  • Restructure SPEC §19 so the link-header exclusion is documented once as a shared architectural skip across all six runners.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
conformance/runner/go/main.go Adds hasTag + link-header skip loop; delegates requestCount to checkRequestCount.
conformance/runner/go/request_count.go New exact-match checkRequestCount helper.
conformance/runner/go/request_count_test.go Unit tests for the bounds contract.
conformance/runner/python/runner.py Adds check_request_count, tag skip; removes lower-bound branch.
conformance/runner/python/test_request_count.py Unit tests.
conformance/runner/ruby/runner.rb Adds RequestCount module, tag skip; removes lower-bound branch.
conformance/runner/ruby/request_count_test.rb Unit tests.
conformance/runner/typescript/runner.test.ts Adds tag skip; delegates to checkRequestCount; drops hasLinkNextHeader in assertions.
conformance/runner/typescript/request-count.ts New helper.
conformance/runner/typescript/request-count.test.ts Unit tests.
kotlin/.../conformance/Main.kt Delegates requestCount to checkRequestCount.
kotlin/.../conformance/RequestCount.kt New helper.
kotlin/.../conformance/RequestCountTest.kt Unit tests.
SPEC.md Documents the shared link-header architectural skip across all six runners.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread conformance/runner/go/main.go Outdated
Copilot AI review requested due to automatic review settings August 3, 2026 06:29
@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from 1e6f840 to 784c15b Compare August 3, 2026 06:29
@jeremy
jeremy force-pushed the fix/573-exact-request-count branch from c239c0c to a378d89 Compare August 3, 2026 06:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (1)

conformance/runner/go/main.go:210

  • The new hasTag function was inserted between the // Tests where the Go SDK's behavior intentionally differs. comment and the goSDKSkips declaration it documents. As a result, that comment now reads as hasTag's doc comment (in Go, a comment immediately preceding a declaration is its godoc), while goSDKSkips is left undocumented. Moving hasTag above the comment keeps the comment attached to goSDKSkips.
// Tests where the Go SDK's behavior intentionally differs.
// hasTag reports whether the fixture carries the given tag.
func hasTag(tc TestCase, tag string) bool {

@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from 784c15b to 46992cb Compare August 3, 2026 09:00
Copilot AI review requested due to automatic review settings August 3, 2026 09:00
@jeremy
jeremy force-pushed the fix/573-exact-request-count branch from a378d89 to 9d4cf25 Compare August 3, 2026 09:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from 46992cb to 46eb2aa Compare August 3, 2026 09:04
Copilot AI review requested due to automatic review settings August 3, 2026 09:05
@jeremy
jeremy force-pushed the fix/573-exact-request-count branch from 9d4cf25 to 11ae7e4 Compare August 3, 2026 09:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from 46eb2aa to 13e6bae Compare August 3, 2026 09:13
Copilot AI review requested due to automatic review settings August 3, 2026 09:14
@jeremy
jeremy force-pushed the fix/573-exact-request-count branch from 11ae7e4 to bdae816 Compare August 3, 2026 09:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from 13e6bae to 4656e78 Compare August 3, 2026 09:18
@jeremy
jeremy force-pushed the fix/573-exact-request-count branch from bdae816 to 806ff90 Compare August 3, 2026 09:18
Copilot AI review requested due to automatic review settings August 3, 2026 09:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from 1ff2025 to 40099b1 Compare August 3, 2026 09:57
Copilot AI review requested due to automatic review settings August 3, 2026 09:57
@jeremy
jeremy force-pushed the fix/573-exact-request-count branch from d67f706 to 211acbc Compare August 3, 2026 09:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from 40099b1 to 2bbc7a1 Compare August 3, 2026 10:06
Copilot AI review requested due to automatic review settings August 3, 2026 10:06
@jeremy
jeremy force-pushed the fix/573-exact-request-count branch from 211acbc to 2b63ad2 Compare August 3, 2026 10:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.

@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from 2bbc7a1 to db00e40 Compare August 3, 2026 10:24
@jeremy
jeremy force-pushed the fix/573-exact-request-count branch from 2b63ad2 to b398be3 Compare August 3, 2026 10:24
Copilot AI review requested due to automatic review settings August 3, 2026 10:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.

@jeremy
jeremy force-pushed the fix/553-replay-decoder-parity branch from db00e40 to 9e5eb36 Compare August 3, 2026 12:06
Base automatically changed from fix/553-replay-decoder-parity to main August 3, 2026 12:10
Go, Python, Ruby, TypeScript and Kotlin all evaluated the `requestCount`
assertion as a LOWER bound whenever any mock response carried
`Link: rel="next"`:

    if autoPaginates {
        if requestCount < expected { fail }
    } else if requestCount != expected { fail }

That is backwards for the fixtures it covered. conformance/tests/pagination.json
queues MORE pages than it expects requests in two cases, because stopping early
is the behavior under test:

  Pagination stops at maxPages safety cap        3 pages queued, 2 expected
  maxItems caps results across pages             3 pages queued, 2 expected
  Auto-pagination follows Link headers ...       3 pages queued, 3 expected

An SDK that ignored the cap and walked all three passed `3 >= 2`. The third is
the exposed case: its only assertions are requestCount and noError, so an
over-fetch had nothing else to catch it — the other two happen to carry a
`responseMeta` truncated assertion that fires instead, coverage by luck.

The relaxation was protecting exactly one fixture, "List operation returns
first page with Link header" (tagged `link-header`), whose `requestCount: 1`
counts FIRST-PAGE requests only and so cannot apply to an auto-paginating SDK.
With that one fixture's count taken out of scope, nothing reaching the
evaluator needs a lower bound, and `>=` becomes `!=` everywhere. Swift took
this in #558 and its shape is what the five now match. The
MockEngine/httptest/MSW/respx/WebMock auto-pagination tolerance stays:
answering an over-walk with a terminal empty page rather than an error is what
lets the tightened assertion report a clean count mismatch.

Each runner's predicate moves into that language's SDK-free support module
alongside `delay_gaps` — request_count.go, `check_request_count` in runner.py,
`RequestCount` in runner.rb, request-count.ts, RequestCount.kt — with a unit
test per language. Those five test files are discovered automatically thanks to
#572; before it, three of them would have been executed by nothing.

Take the ASSERTION out of scope, not the CASE
-----------------------------------------------------------------------

An earlier revision of this commit did it the blunt way: it skipped the whole
`link-header` CASE in Go, Python, Ruby and TypeScript, copying what Kotlin and
Swift already did. That is wrong, and wrong in this stack's own signature
shape.

The fixture carries THREE assertions, not one:

  requestCount: 1     inapplicable — the SDK follows the Link header
  statusCode:   200   perfectly applicable
  noError             perfectly applicable

Kotlin and Swift had always skipped the case, so the moment the other four
joined them the fixture was skipped by ALL SIX. Its `statusCode` and `noError`
assertions had been running in four runners; they then ran in zero. And nothing
reports that: the fixture still sits in `conformance/tests/pagination.json`,
still passes `conformance-fixtures-check` and `check-fixture-coverage`, so the
build stays green over a fixture no runner executes. That is precisely #572's
defect — present in the tree, run by nothing — one layer down, committed inside
the stack that exists to close it.

So the exclusion is now one assertion wide. `requestCountApplies(tags)` (and
its per-language spellings) returns false for `link-header`, the evaluator
skips that one assertion, and the case runs. Verbatim before/after from the
TypeScript runner, which is the arm where a skip is directly observable —
pre-narrowing files taken from this stack's pushed head a378d89, everything
else identical:

  $ npx vitest run --reporter=verbose -t "List operation returns first page with Link header"
  ↓ runner.test.ts > conformance/pagination.json > List operation returns first page with Link header (TS SDK auto-paginates; follows Link headers by design)
  [...]
        Tests  182 skipped (182)
  REAL_EXIT=0

  $ npx vitest run --reporter=verbose -t "List operation returns first page with Link header"
  ✓ runner.test.ts > conformance/pagination.json > List operation returns first page with Link header 19ms
  [...]
        Tests  1 passed | 184 skipped (185)
  REAL_EXIT=0

  ([...] elides vitest's per-file listing of the other 180-odd cases the `-t`
  filter skipped, and its Test Files / Duration lines. The two quoted lines and
  the Tests summary are verbatim. Zero passed becomes one passed: the case was
  executed by nothing and now runs.)

Kotlin and Swift keep the whole-case skip, deliberately
-----------------------------------------------------------------------

They are not narrowed, and this is a stated exception rather than an oversight.
Both derive the response status from the last mock response the SDK consumed,
and an auto-paginating SDK walks past the end of a one-response queue, so
`statusCode` has nothing to report. Narrowing them was tried; both fail.
Verbatim, one line each from the pagination.json section plus the run tail:

  $ make conformance-kotlin
    FAIL: List operation returns first page with Link header
          Expected status code 200, but got no response
  [...]
  Passed: 142, Failed: 1, Skipped: 0, Total: 143
  REAL_EXIT=2

  $ make conformance-swift
    FAIL: List operation returns first page with Link header
          Expected status code 200, but got no response
  [...]
  Passed: 142, Failed: 1, Skipped: 0, Total: 143
  REAL_EXIT=2

  ([...] elides the other 142 result lines of each run. Note the exit code:
  through `make` a recipe failure is 2, not the runner binary's own 1.)

Widening those two runners' status model is separate work, not a skip to delete
here. Both call sites now carry that reasoning in a comment, so the asymmetry
does not read as drift to be "aligned" away.

Net effect on coverage: the fixture goes from run by four runners (before this
stack) to run by four runners (after), instead of by zero. THIS COMMIT CHANGES
NO SKIP COUNT IN ANY RUNNER — see the identical figures in the two verification
blocks below.

The general gap remains: nothing in the build detects a fixture that every
runner skips. That is filed as #602 and referenced from SPEC §19; this commit
fixes the instance, not the class.

Red proof 1 — the runners on the real fixture
-----------------------------------------------------------------------

Mutating "Auto-pagination follows Link headers across multiple pages" to expect
2 while the SDK makes 3 is exactly the over-fetch the lower bound waves
through. Base is 07482b9ba, this stack's #553 tip, byte-identical to
origin/main for every runner file involved. Each line is the fixture's own
result line plus that command's real exit status:

  $ make conformance-<lang>          # base 07482b9ba (lower bound)
    PASS: Auto-pagination follows Link headers across multiple pages
  REAL_EXIT[base-conformance-go]=0
  REAL_EXIT[base-conformance-python]=0
  REAL_EXIT[base-conformance-ruby]=0
  REAL_EXIT[base-conformance-typescript]=0
  REAL_EXIT[base-conformance-kotlin]=0

  $ make conformance-<lang>          # this commit (exact count)
    FAIL: Auto-pagination follows Link headers across multiple pages
          Expected 2 requests, got 3
  REAL_EXIT[branch-conformance-go]=2
  REAL_EXIT[branch-conformance-python]=2
  REAL_EXIT[branch-conformance-ruby]=2
  REAL_EXIT[branch-conformance-typescript]=2
  REAL_EXIT[branch-conformance-kotlin]=2

  (The PASS/FAIL lines above are one per language and identical in each; TS
  prints its as `FAIL runner.test.ts > conformance/pagination.json >
  Auto-pagination follows Link headers across multiple pages` with `Error:
  [Auto-pagination follows Link headers across multiple pages] Expected 2
  requests, got 3`. Every exit code is real and from `make`, which reports a
  recipe failure as 2. The fixture edit was reverted; pagination.json is
  unchanged in this commit.)

Red proof 2 — the new unit tests against the old predicate
-----------------------------------------------------------------------

Restoring the lower bound in each support module fails the same three cases in
every language. These are the bare test binaries, so the exit code is 1, not
make's 2:

  Go       --- FAIL: TestRequestCountRejectsAnOverFetch
           --- FAIL: TestRequestCountMessageNamesBothCounts
           --- FAIL: TestRequestCountZeroExpectedRequiresZeroActual
           FAIL github.com/basecamp/basecamp-sdk/conformance/runner/go  REAL_EXIT=1
  Python   3 failed, 6 passed in 0.10s                                  REAL_EXIT=1
  Ruby     9 runs, 13 assertions, 3 failures, 0 errors, 0 skips         REAL_EXIT=1
  TS       Tests  3 failed | 6 passed (9)                               REAL_EXIT=1
  Kotlin   23 tests completed, 3 failed  (RequestCountTest: 6 tests,
           3 failures — "an over-fetch fails", "the failure message names
           both counts", "zero expected requires zero actual")           REAL_EXIT=1

The three new `requestCountApplies` tests per language are not part of that
red: they pin the SCOPE of the exclusion, asserting that a `link-header`
fixture keeps its statusCode and noError assertions live. If the suppression
ever widens back to the whole case, those fail.

Verification, exact-count code, real exit codes, measured on this commit
-----------------------------------------------------------------------

  make conformance-go         Passed: 141, Failed: 0, Skipped: 2   REAL_EXIT=0
  make conformance-python     143 passed, 0 failed, 0 skipped      REAL_EXIT=0
  make conformance-ruby       132 passed, 0 failed, 11 skipped     REAL_EXIT=0
  make conformance-typescript 183 passed | 2 skipped (185)         REAL_EXIT=0
  make conformance-kotlin     Passed: 142, Failed: 0, Skipped: 1   REAL_EXIT=0
  make conformance-swift      Passed: 142, Failed: 0, Skipped: 1   REAL_EXIT=0
  make conformance-runner-tests-go      ok (cached)                REAL_EXIT=0
  make conformance-runner-tests-python  29 passed, 31 subtests     REAL_EXIT=0
  make conformance-runner-tests-ruby    11 + 6 + 9 runs, 0 fail    REAL_EXIT=0
  make conformance-runner-tests-kotlin  (--quiet, no output)       REAL_EXIT=0
  make conformance-runner-tests-swift   39 tests, 0 failures       REAL_EXIT=0
  ./scripts/check-runner-test-reachability   9 checks passed       REAL_EXIT=0
  ./scripts/check-runner-test-reachability --self-test  6 cases    REAL_EXIT=0
  ./scripts/check-replay-decoder-parity  5 checks, 31 operations   REAL_EXIT=0
  cd conformance/runner/go && go build ./... && go vet ./...       REAL_EXIT=0
  make lint-actions           No findings to report                REAL_EXIT=0

And the same six suites at base 07482b9ba, for the skip-count comparison — the
figures the "no skip count changes" claim above rests on:

  base conformance-go         Passed: 141, Failed: 0, Skipped: 2
  base conformance-python     143 passed, 0 failed, 0 skipped
  base conformance-ruby       132 passed, 0 failed, 11 skipped
  base conformance-typescript 174 passed | 2 skipped (176)
  base conformance-kotlin     Passed: 142, Failed: 0, Skipped: 1

Identical skip counts throughout. TypeScript's total rises 176 → 185 because
this commit adds request-count.test.ts's 9 tests; its SKIPPED count is 2 on
both sides. (Those base figures come from the red-proof-1 base runs, which
carry the mutated expectation — a changed expectation moves no skip.)

The Go runner's `hasTag` helper is deleted. It existed only for the whole-case
branch this commit no longer has, and it had been inserted between
`goSDKSkips`' doc comment and `goSDKSkips` itself, so godoc read that comment
as documenting `hasTag`.

SPEC §19: the `link-header` entry is rewritten. Previously it was a per-runner
repeat that claimed Swift's skip was "identical to Kotlin and TypeScript" —
untrue, TypeScript had no `link-header` handling at all. It is now one block
that states what each runner excludes, why Kotlin and Swift differ, and what
the all-six-skipped shape would cost.

Local figures. Cite the CI job's own numbers when they differ.

Closes #573
Copilot AI review requested due to automatic review settings August 3, 2026 12:12
@jeremy
jeremy force-pushed the fix/573-exact-request-count branch from b398be3 to 68a70c2 Compare August 3, 2026 12:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jeremy
jeremy merged commit 9b66619 into main Aug 3, 2026
43 of 44 checks passed
@jeremy
jeremy deleted the fix/573-exact-request-count branch August 3, 2026 12:16
jeremy added a commit that referenced this pull request Aug 14, 2026
…743)

* Detect a fixture case that every runner excludes (#602)

Each runner's case census (#742) answers "did THIS runner account for every
case". It cannot answer the question #602 actually asks — is any case executed
by NO runner — because a case every runner excludes leaves all six censuses
green: each one counted its own skip. Only a comparison across runners sees it.

Every runner now writes the cases it did not execute, with reasons, to
conformance/manifests/<runner>.json, and scripts/check-fixture-execution.rb
fails when a case appears in all six.

Manifests rather than parsed output because TypeScript prints no `SKIP:` line —
a skip there is `it.skip`, reported in vitest's own format — so a gate scraping
stdout would be blind to exactly one runner, in the silent direction:
TypeScript would contribute an empty exclusion set and no case could reach
all-six. Each manifest also records `executed`, asserted against the census
total by both writer and reader: without it a case a runner silently dropped is
simply absent from its exclusion set, and absent reads identically to "ran
fine".

THE ABSENCE RULE IS THE DESIGN. FULL mode requires all six manifests and fails
if any is missing — a missing manifest must never read as "that runner executed
everything", which is exactly what makes an all-six case invisible. Swift's
runner is macOS-only, so a Linux run produces five and uses PARTIAL mode: an
exclusion shared by every VISIBLE runner is a warning, never a failure, because
five-of-six is not the all-six claim and a warning cannot false-fail. Both
modes fail on zero manifests.

CI resolves it properly rather than living with the partial answer: the six
language jobs each upload their manifest and the existing fan-in job runs FULL
mode over all six. That step is ordered AFTER the results check, because the
job is `if: always()` — a language job that died before its upload would
otherwise be reported as a missing manifest, burying the real cause.

The local target depends on `conformance` rather than trusting whatever
manifests are on disk, which would let it validate last week's exclusion sets.

Maximum overlap today is 2 of 6 (#596 narrowed it), so the gate is green on
arrival and a live run only proves it can say yes.
scripts/test-check-fixture-execution.rb crafts the all-six state and every
absence, integrity and disagreement case; it already found one defect (a
non-object manifest crashed with a backtrace instead of naming the file).
Verified end-to-end too: adding one shared exclusion to all six real manifests
makes the gate fail and name every runner's reason.

SPEC §19 now describes the gate that exists, closing the gap #740's P1 found —
where the prose promised `make check-fixture-execution` after the source-text
parser it referred to had been withdrawn.

* Gate the Ruby and Python manifest uploads to the leg that runs conformance

Both jobs are matrixed and run their conformance suite on ONE version —
`matrix.ruby == '3.3'`, `matrix.python == '3.13'`. The upload steps went in
unconditionally, so on every other leg the conformance step was skipped, no
manifest was written, and `if-no-files-found: error` failed the job. Seven red
checks, all of them this.

Mirroring the condition is the fix rather than relaxing if-no-files-found: a
leg that DOES run conformance and produces no manifest is a real defect, and
that is exactly what the fan-in gate's absence rule depends on being loud.

Go, TypeScript, Kotlin and Swift have no matrix, so their uploads stay
unconditional and each artifact name remains unique.

* Keep --partial from softening a complete manifest set, and wire the target in

Two review findings from Copilot, both correct.

`--partial` describes the INPUT — "this run cannot produce all six" — not a
licence to soften the verdict. When every expected runner reported anyway (a
macOS developer passing the flag out of habit, or a CI step keeping it for
safety), "excluded by all present runners" IS the all-six claim, and the
warning path let the one state this gate exists to reject exit 0. Partial
handling now applies only when a manifest is genuinely absent. The self-test
case for it was shown to fail against the un-narrowed code first.

`check-fixture-execution` was also absent from `check-targets`: the edit that
was supposed to add it matched the first occurrence of its anchor, which was
the .PHONY line, so the target existed and `make check` never ran it. Now in
the list, verified by reading the check-targets line itself rather than
grepping the file.

* Give exclusions a real identity, own a fresh run, and self-test in CI

Four review findings, all real, two of them defects in the gate's core claim.

CASE IDENTITY IS [file, name], NOT name. Codex is right that fixture case
names are not unique: verified, "replace-omission-clears: sparse replace sends
the request verbatim with no GET" appears in THREE fixtures and the
non-idempotent POST retry name in two, while names are unique within a file.
Keyed on name alone, a runner excluding one of those collapsed two entries
into one — its own `executed + excluded` integrity check would then fail
spuriously, and worse, a name excluded by three runners in one file and three
in another would read as excluded by all six. A false failure on cases that
all run. Every runner now records the fixture file, and the gate keys on the
pair; it also rejects the same case excluded twice by one runner, which would
otherwise add up while the comparison saw a single entry.

STALE MANIFESTS. Both bots found this, and it was created by the --partial
narrowing in the previous commit. On Linux `conformance-swift` is a no-op, so
a swift.json left by an earlier macOS run over the same checkout survives
while the other five refresh; the gate then sees six manifests, stops treating
the run as partial, and compares five current exclusion sets against a stale
sixth. Silent-wrong, in a gate whose whole claim is about what the runners
actually did. The target now wipes conformance/manifests and runs the suite
itself, so "six manifests present" means "six runners reported in this run".
The reset is in the recipe, not a prerequisite: prerequisite order is not
guaranteed under `make -j`, and a reset racing the runners would delete the
output it exists to protect. check-targets therefore lists this target INSTEAD
of `conformance`, so the suite still runs exactly once per `make check`.

SELF-TEST IN CI. The fan-in job ran only the live gate, whose inputs are built
to pass; the self-test was reachable only through the Make target the workflow
deliberately bypasses. A regression making the gate accept a missing manifest
or an all-six exclusion would have left CI green — the check-targets-is-not-CI
-coverage shape, in the PR that adds the gate. It now runs as its own step.

New self-test cases: one name in two files is two cases and must NOT fire; the
same name in one file excluded everywhere still must; an exclusion without its
fixture file is rejected; one case excluded twice in a manifest is rejected.

* Reset manifests via an order-only prerequisite, not a sub-make

The previous commit had check-fixture-execution run `$(MAKE) conformance`
itself so it could wipe the manifest directory first. That worked, and cost
something I did not price: with `conformance` no longer listed in
check-targets, `conformance-kotlin` stopped being reachable from
check-targets in the graph check-gradle-serialization walks — so that gate
could no longer see one of the two Gradle invocations it exists to keep off
each other. Its self-test caught it exactly as designed ("conformance-kotlin
edge removed: expected the gate to FAIL, it passed"), which is a mutation
case earning its keep on a change nobody wrote it for.

Restructured so both properties hold. `conformance` is back in check-targets
and check-fixture-execution depends on it, restoring reachability. The reset
is now a phony target that all six language targets take as an ORDER-ONLY
prerequisite (`|`), which is what makes it correct under `make -j`: make
builds a prerequisite to completion before any dependent starts, and builds
the phony target once per invocation, so the reset cannot race the runners it
protects. A plain prerequisite of the aggregate target would not do that —
siblings may run concurrently.

It fires for a single-language run too, deliberately: `make conformance-go`
clears the directory, so it holds only what that invocation produced and the
gate goes partial rather than silently mixing runs across machines.

Verified both directions: a planted stale swift.json is wiped and regenerated,
and `make conformance-go` alone leaves only go.json.

* Let a re-run replace its own manifest artifact

Copilot flagged this on all six uploads. Re-running a job creates a new
ATTEMPT within the same run, and upload-artifact v4+ artifacts are immutable
per run — so the second attempt fails on a name conflict with the first
attempt's manifest, before the fan-in gate can run. Reachable exactly when
someone is re-running to get a red PR green, which is the worst time for the
gate to become unreachable.

`overwrite: true` is also right on the merits, not just as conflict avoidance:
the collecting gate must read THIS attempt's exclusion set. An artifact left
by a previous attempt is the CI-side version of the stale-manifest bug fixed
two commits ago, and the same answer applies — six manifests present must mean
six runners reported in this run.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working conformance Conformance test suite kotlin

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Kotlin conformance runner accepts an over-fetch on auto-paginating fixtures

2 participants