Keep the ListResult wrapper on paginated arrays whose entity has no alias, and typecheck the tests - #754
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes TypeScript pagination return types and expands typechecking to tests and scripts.
Changes:
- Preserves
ListResult<T>for unaliased paginated entities. - Adds a test-focused TypeScript configuration and type assertions.
- Resolves surfaced test typing issues and invalid endpoint paths.
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.
Reviewed changes
Copilot reviewed 29 out of 32 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
typescript/tsconfig.test.json |
Adds test and script typechecking. |
typescript/package.json |
Runs both TypeScript projects. |
typescript/README.md |
Documents expanded typechecking. |
typescript/scripts/generate-services.ts |
Preserves concrete paginated return types. |
typescript/src/generated/services/checkins.ts |
Corrects reminders return type. |
typescript/src/generated/services/gauges.ts |
Corrects gauge list return types. |
typescript/src/generated/services/search.ts |
Corrects search return type. |
typescript/tests/types/paginated-returns.test-d.ts |
Adds pagination type assertions. |
typescript/tests/auth-strategy.test.ts |
Makes request captures type-safe. |
typescript/tests/client.test.ts |
Fixes captures and todolist path. |
typescript/tests/hooks.test.ts |
Types console mocks. |
typescript/tests/integration.test.ts |
Uses the modeled todolist path. |
typescript/tests/middleware-lifecycle.test.ts |
Uses the modeled todo path. |
typescript/tests/security.test.ts |
Adds a typed page parser. |
typescript/tests/services/boosts.test.ts |
Narrows optional booster data. |
typescript/tests/services/cards.test.ts |
Adds JSON import attributes. |
typescript/tests/services/client-visibility.test.ts |
Adds JSON import attributes. |
typescript/tests/services/comments.test.ts |
Adds JSON import attributes. |
typescript/tests/services/documents.test.ts |
Fixes response and request typing. |
typescript/tests/services/gauges.test.ts |
Tests typed pagination metadata directly. |
typescript/tests/services/hill-charts.test.ts |
Narrows optional dot collections. |
typescript/tests/services/messages.test.ts |
Adds a JSON import attribute. |
typescript/tests/services/my-notifications.test.ts |
Removes response-erasing casts. |
typescript/tests/services/recordings.test.ts |
Makes URL captures type-safe. |
typescript/tests/services/schedules.test.ts |
Fixes response and request typing. |
typescript/tests/services/search.test.ts |
Adds a JSON import attribute. |
typescript/tests/services/subscriptions.test.ts |
Narrows optional subscribers. |
typescript/tests/services/todolists.test.ts |
Adds JSON import attributes. |
typescript/tests/services/todos.test.ts |
Uses MSW’s JSON body type. |
typescript/tests/services/tools.test.ts |
Adds JSON import attributes. |
typescript/tests/services/uploads.test.ts |
Improves request and rejection typing. |
typescript/tests/services/vaults.test.ts |
Makes body captures type-safe. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ef8e3ef0ba
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
d8c84d6 to
4d08c85
Compare
|
@codex review |
|
Codex Review: Didn't find any major issues. Swish! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
`typescript/tsconfig.json` is the build project: it emits declarations under `rootDir: "src"`, and it excludes `tests`. `npm run typecheck` -- the whole of `make ts-typecheck`, and a step in both the test and release workflows -- ran only that project, so no job had ever typechecked a single test file. vitest strips types without checking them, so the test tree was unchecked from both sides. That is not a hypothetical gap. It is what let issue #737 ship: four generated methods declared a return type the runtime contradicts, and a test that reads `.meta` on one of them compiles clean here either way. It is also why no `TS1543` import-attribute diagnostic has ever surfaced. Add `tsconfig.test.json`, a typecheck-only project over src + tests + scripts, and run it from `npm run typecheck` after the build project. Two of its settings are load-bearing and easy to get wrong: - `exclude` is reset. `extends` inherits it, and an inherited "tests" entry silently drops every test file from the program even when `include` names them -- the program compiles, reports nothing, and proves nothing. - `noUncheckedIndexedAccess` is off. The shipped surface keeps it (the build project still checks src/ with it); in test code it only buys `calls[0]!` ceremony, since an undefined index fails the next assertion anyway. Turning it on surfaced 269 pre-existing errors, 117 of them after the flag above. They fall into five classes, all fixed here rather than suppressed -- no `@ts-ignore`, no `@ts-expect-error`, no blanket `as any`: - CFA narrowing to `never`: a `let captured: T | null = null` assigned only inside an MSW handler closure narrows to `null` at the assertions. Held in an object instead, so absence still fails the test. - `TS1543`: JSON fixture imports need `with { type: "json" }` under `module: NodeNext`. vite honors the attribute; the suite is green with it. - vitest `Mock<Procedure | Constructable>` against `Pick<Console, ...>`: give `vi.fn()` its signature type argument. - optional schema fields read without a presence assertion: assert first, so a missing field fails with a clear message. - a `Record<string, unknown[]>` cast in my-notifications that erased the typing of the very fields under test. Three tests were driving paths that do not exist in the spec at all -- `/todolists/{todolistId}.json`, `/buckets/{projectId}/todolists/{todolistId}.json`, and a PUT to `/buckets/{bucketId}/todos/{todoId}.json`. openapi-fetch substitutes path strings blindly and the MSW stubs were written to match the fabricated URL, so both sides agreed and the tests proved client behavior at a URL the SDK will never emit. They now use the modelled paths (`/todolists/{id}`, `/todos/{todoId}`) with the stubs corrected to what the client actually sends; every assertion is unchanged. Three `as never` casts on `client.PUT` bodies went with them -- against a real path the bodies typecheck. `scripts/generate-services.ts` joins the program via the generator test that imports it. Its `serviceName` assignment is now a conditional expression rather than a `let` filled in from inside a loop, which is what TS could not prove definite. Output is unchanged: regenerating with this commit's generator reproduces the committed `src/generated/` tree byte for byte, and `make ts-check-drift` passes.
`buildReturnType()` resolved element type names only through the hand-maintained `TYPE_ALIASES` map. `Gauge`, `GaugeNeedle`, `SearchResult` and `QuestionReminder` are not in it, so `getEntityTypeName()` returned null and the function fell through to its "fallback to schema ref" line -- which returned the bare `*ResponseContent` array and dropped `ListResult<>` entirely, even with `op.returnsArray && op.hasPagination` both true. The runtime object is a `ListResult` in all four cases: `requestPaginated` builds one, and the JSDoc on all four already promises ".meta.totalCount". So the docs and the runtime agreed with each other and only the signature disagreed, making `result.meta.totalCount` a type error on four methods where every sibling allows it. Fixes #737. Adding the four missing aliases would have fixed four symptoms and left the mechanism intact, waiting for the next unaliased paginated entity. Instead the fallback is now pagination-aware: a paginated array keeps its wrapper and only the element name degrades, to the item's own schema ref -- which is a perfectly good element type, just not a friendly one. The same lookup had a second hole one level down. Wrapped pagination (a list under a key of an object response) spelled an unaliased entity `ListResult<unknown>`, in two places that have to agree: the declared return type and the `requestPaginatedWrapped<key, T>` type argument, each resolving the entity with its own copy of the expression. Both now call one `buildPaginationElementType()` helper, which resolves the array form and the wrapped form the same way and reaches `unknown` only for a schema that names no element at all. No generated output changes there today -- the one wrapped operation's entity is aliased -- but the two sites can no longer drift apart. `tests/types/paginated-returns.test-d.ts` pins the contract in the type system, where the previous commit's typecheck can see it: each of the four methods returns a `ListResult` carrying `.meta`, with a concrete element type. It covers the wrapped shape and the already-aliased `TodosService#list` too, and a negative control asserts a single-entity method does NOT satisfy the predicate, so the assertions cannot pass vacuously. Reverting this commit's generator change and regenerating fails it with 8 TS2344s; forcing the wrapped element back to `unknown` fails exactly the one assertion that covers it. `tests/services/gauges.test.ts` drops the `metaOf()` helper that asserted `instanceof ListResult` at runtime to reach `.meta` past the wrong signature. It now reads `gauges.meta` directly, with one explicit `toBeInstanceOf` assertion left in the first test to pin the runtime class behind the type.
…reach The type-level assertions pin the four real operations that hit the array-form alias miss. They cannot reach the wrapped form: the one wrapped-pagination operation in the spec carries an aliased entity, so no generated signature would move if that branch regressed to `unknown` again. Raised in review. Drive `buildReturnType` directly instead, following the generator-regression pattern `tests/generator/example-value.test.ts` already establishes (`setSchemas` + an exported function). Six cases: aliased and unaliased for both the array and the wrapped form, the unpaginated array that must still fall back to its schema ref, and the floor where the items name no schema at all and only `ListResult<unknown>` is left. The unaliased entity is a fictional `WidgetThing` rather than a real one, so a later TYPE_ALIASES addition cannot quietly turn a miss case into a hit case. Reverting the array fallback fails 2 of the 6; restoring the wrapped alias-miss to `unknown` fails exactly 1 -- the case that has no other cover.
Two review bots independently read `paginated-returns.test-d.ts` as a declaration file and concluded the assertions are inert under the inherited `skipLibCheck: true`. It ends in `-d.ts`, not `.d.ts`, so TypeScript checks it like any other source -- flipping an assertion to a knowingly false one reports TS2344 on that line, and the branch's red proof is 8 such errors in that file. Their proposed remedy is worse than the disease: `skipLibCheck: false` fails on 22 errors inside @mswjs/interceptors' browser `.d.mts` and adds ~20s per run. Write both facts where the next reader looks -- the file header and the config -- with the recipe to re-verify in one command, so the next reader checks instead of re-opening.
4d08c85 to
3a7a508
Compare
|
@codex review |
|
Codex Review: Didn't find any major issues. 👍 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 33 changed files in this pull request and generated no new comments.
Suppressed comments (1)
typescript/tests/generator/pagination-return-type.test.ts:160
- This regression case only calls
buildReturnType, so it verifies the declared wrapper but not the second changed call site ingenerateMethod. Replacing the newrequestPaginatedWrapped<..., buildPaginationElementType(op)>argument withunknownwould leave this suite andpaginated-returns.test-d.tsgreen: the current wrapped operation is aliased, and the generated method casts to the separately built return type. Exercise the emitted wrapped method and assert that its generic argument iscomponents["schemas"]["WidgetThing"]too, so the two sites are actually locked together.
it("uses the item's schema ref when the entity has no alias", () => {
const returnType = buildReturnType(
operation({
responseSchemaRef: "WidgetReportResponseContent",
returnsArray: false,
hasPagination: true,
paginationKey: "widgets",
}),
"Reports",
);
expect(returnType).toBe('{ person: Person; widgets: ListResult<components["schemas"]["WidgetThing"]> }');
|
Rebased onto That gate reads the TypeScript generated-services directory. This branch modifies exactly three files there (
Three dependabot bumps came in with the rebase, one of them the npm-dependencies group touching |
The claim that the declared return type and the `requestPaginatedWrapped` type argument can no longer drift apart was unenforced: both call the same `buildPaginationElementType`, but only the first is asserted anywhere. Regressing the second to `unknown` and regenerating leaves `make ts-check` entirely green — 1525 tests, both typecheck projects, and the drift check, which passes because the committed output was regenerated to match. The generated method casts its result to the separately built return type, so the disagreement never reaches the compiler; `paginated-returns.test-d.ts` cannot reach it either, since it pins the four operations that hit the array miss. Export `generateMethod` and assert the emitted method names the same element as the declared return type, for the aliased hit and the unaliased miss. Both cases fail against that mutation.
|
Answering the suppressed finding on the newest review — Confirmed, and it is worse than stated. Fixed in I mutated
Generated The mechanism is the one you identified: the generated method casts its result to the separately built return type, so the two never meet at the compiler. And the type assertions cannot reach it, since the single wrapped-pagination operation in the spec carries an aliased entity — the same reason the unit case exists in the first place. That makes the PR's claim that the two sites "can no longer drift apart" an unenforced one. Enforced now rather than restated:
Both cases fail against the Thanks — this was a real hole, and the suppressed block was the only place it surfaced. |
|
@codex review |
|
Codex Review: Didn't find any major issues. Can't wait for the next one! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Closes #737.
Four generated TypeScript methods declared a bare
*ResponseContentarray where the runtime returns aListResult—listGauges,listGaugeNeedles,search,reminders— soresult.meta.totalCountwas a type error on four methods whose own JSDoc promises it. Two commits: turn on the instrument that should have caught it, then fix the generator.The generator fix
buildReturnType()resolves element type names only through the hand-maintainedTYPE_ALIASESmap.Gauge,GaugeNeedle,SearchResultandQuestionReminderare not in it, sogetEntityTypeName()returned null and the function fell through to its "fallback to schema ref" line, which dropped theListResult<>wrapper entirely — even withop.returnsArray && op.hasPaginationboth true.Adding the four missing aliases would fix four symptoms and leave the mechanism waiting for the next unaliased paginated entity. Instead the fallback is pagination-aware: a paginated array keeps its wrapper and only the element name degrades, to the item's own schema ref.
The spelling is
ListResult<components["schemas"]["<Item>"]>, resolved from the response schema'sitems.$ref— the samecomponents["schemas"][...]idiom the surrounding code already uses for unaliased refs, rather than an indexed access like…ResponseContent[number].The second hole is covered too
Wrapped pagination (a list under a key of an object response) spelled an unaliased entity
ListResult<unknown>, in two places that must agree: the declared return type and therequestPaginatedWrapped<key, T>type argument — each resolving the entity with its own copy of the expression. Both now call onebuildPaginationElementType()helper that resolves the array form and the wrapped form identically, reachingunknownonly for a schema that names no element at all. No generated output changes there today (the one wrapped operation's entity is aliased), but the two sites can no longer drift apart — and that is asserted rather than asserted-by-construction. Sharing a helper is not a guarantee; nothing observed the second site at all. Regressing its type argument tounknownand regenerating leftmake ts-checkgreen end to end — 1525 tests, both typecheck projects, and the drift check, which passes once the output is regenerated to match. The generated method casts to its separately built return type, so the disagreement never reaches the compiler.generateMethodis now exported and the emitted method is asserted to name the same element as the declared return type, over the aliased hit and the unaliased miss; both cases fail against that mutation.Generated diff: 4 lines across 3 files.
make ts-check-drift(regenerate into a temp dir + diff) passes.The blind spot, not just the bug
typescript/tsconfig.jsonsets"exclude": [..., "tests"], sonpm run typecheck— all ofmake ts-typecheck, and a step in bothtest.ymlandrelease-typescript.yml— never typechecked a single test file, and vitest strips types without checking them. A test asserting a type proves nothing while no job typechecks tests, so fixing the generator without this leaves the same bug free to come back.tsconfig.jsonhas to stay build-only (rootDir: "src", declaration emit), so this addstypescript/tsconfig.test.json, a typecheck-only project over src + tests + scripts, run fromnpm run typecheckafter the build project. Two settings are load-bearing:excludeis reset.extendsinherits it, and an inherited"tests"entry silently drops every test file from the program even whenincludenames them — the first draft of this config compiled zero test files and reported success.--listFiles | grep -c /tests/said0.noUncheckedIndexedAccessis off. The shipped surface keeps it (the build project still checkssrc/with it); in test code it only buyscalls[0]!ceremony, since an undefined index fails the next assertion anyway. It accounts for 152 of the 269 errors.libisES2023, matching what these files actually run on (tsx on Node >= 22.12, perengines); the shipped surface keeps ES2022.Pre-existing errors this surfaced: 269 → 117 after the flag above, all fixed, none suppressed
No
@ts-ignore, no@ts-expect-error, no blanketas any. Five classes:never—let captured: T | null = nullassigned only inside an MSW handler closureTS1543JSON fixture imports undermodule: NodeNextwith { type: "json" }(vite honors it; suite green)Mock<Procedure | Constructable>vsPick<Console, …>vi.fn()its signature type argumenttoBeDefined()first, so a missing field fails with a clear messageRecord<string, unknown[]>cast in my-notificationsOne real finding. Three tests drove paths that do not exist in the spec at all:
/todolists/{todolistId}.json,/buckets/{projectId}/todolists/{todolistId}.json, and a PUT to/buckets/{bucketId}/todos/{todoId}.json.openapi-fetchsubstitutes path strings blindly and the MSW stubs were written to match the fabricated URL, so both sides agreed and the tests proved client behavior at a URL the SDK will never emit. They now use the modelled paths (/todolists/{id},/todos/{todoId}) with stubs corrected to what the client actually sends; every assertion is unchanged. Threeas nevercasts onclient.PUTbodies went with them — against a real path the bodies typecheck.scripts/generate-services.tsjoins the program via the generator test that imports it; itsserviceNameassignment is now a conditional expression rather than aletfilled in from inside a loop. Regenerating with that commit's generator reproduces the committed tree byte for byte.Red proofs
The bug's own test, against the un-fixed generator — full gate wired, generator at
main's behavior, regenerated output identical tomain:The element-type, wrapped-pagination,
TodosService#listand negative-control assertions all passed in that run — i.e. the failures are exactly the missing wrapper, not a blanket break.Per-case mutation, wrapped form: forcing
buildPaginationElementTypeback tounknownat the two wrapped sites (verified 2 sites matched, lines printed, checked on disk) and regenerating fails exactly one assertion —PersonProgressElement, line 89 —REAL_EXIT=2. Restored bycp+diff -q, regenerated, tree back to the 4-line diff,REAL_EXIT=0.Vacuity guard:
SingleGaugeNeedleIsNotListResultasserts a single-entity method does not satisfy the predicate, so the four positives can't pass because the predicate always saystrue.Verification
make ts-check(drift + typecheck + tests) andmake doc-constants-checkbothREAL_EXIT=0locally, exit codes read back from log files. Per-repo convention I'm not quoting local vitest counts — CI is the authority.Summary by cubic
Keeps
ListResult<T>in generated return types for paginated arrays even when the entity has no alias, adds a typecheck-only project for tests and scripts, and locks wrapped-pagination signatures so the declared element and therequestPaginatedWrapped<key, T>argument cannot drift. No runtime change.Generator:
buildReturnTypepreservesListResulton array pagination and usesbuildPaginationElementType(op)for both declared types andrequestPaginatedWrapped<key, T>. Service routing is now a single conditional expression. ExportsbuildReturnType,generateMethod, andParsedOperationfor tests.Updated signatures:
GaugesService#listGauges,GaugesService#listGaugeNeedles,SearchService#search, andCheckinsService#remindersreturnPromise<ListResult<components["schemas"]["..."]>>with typed.meta.Typecheck and tests: adds
tsconfig.test.jsonand runs it vianpm run typecheck; adds generator/unit assertions that the wrapped element matches in both places; fixes test typings (JSON import attributes, MSW captures, typedvi.fn, presence checks) and corrects a few test paths to modeled endpoints.Review notes
requestPaginatedWrappeduse the same element viabuildPaginationElementType(op)..metaand thatnpm run typecheckcovers src plus tests/scripts. No migration required.Written for commit 6908bb6. Summary will update on new commits.