feat!: constrain stub<T> method names to keyof T - #135
Open
jamlen wants to merge 1 commit into
Open
Conversation
Closes #127. stub<T>(methodNames) looked like it constrained names to keyof T but did not. Two overloads were reachable and the second accepted any string[], so a typo compiled and produced a mock silently missing that method: stub<AccountGateway>(['get', 'sve']) // compiled; no save() on the stub The name-list overload is now the single checked form, and is declared before the object overload rather than after it. Three defects fixed: 1. Names are checked against keyof T, so a typo is a compile error and TypeScript suggests the intended name. 2. Overload order. An array is also an object, so stub(['query','close']) with no type argument bound T to string[] and keyed the facades off Array members — setup.length, setup.push. The names now infer into the mock's shape, so setup.query and expect.close type-check. 3. readonly / as const name lists are accepted; they failed before. Uses overload reordering rather than excluding arrays from the object overload with a conditional type. Both constrain correctly, but the conditional type breaks generic helpers that wrap stub(): function makeMock<U extends object>(o: U) { return stub(o) } Verified against tsc 6.0.3. A regression guard lives in test/type-check.ts, which is the only test file tsconfig compiles — a guard in test/types.test.ts would never run in CI. Exhaustiveness is deliberately not enforced. The check is that supplied names exist on T, not that all of T is covered; partial stubs are a supported pattern used by test/type-check.ts itself. Runtime behaviour is unchanged. BREAKING CHANGE: stub<T>(methodNames) no longer accepts a value typed string[]. Correct literal lists are unaffected. Lists built at runtime must opt out explicitly with stub<T>(names as (keyof T)[]). Code relying on the old stub(['a','b']) inference of Wrapped<string[]> will surface type errors, since the mock is now typed from the names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 12, 2026
jamlen
added a commit
that referenced
this pull request
Aug 12, 2026
Part of #134. This is the **repo-side half only** — the trusted publisher must be configured at npmjs.com before it takes effect, which needs a login + 2FA and so can't be done from here. Steps below. ## Staged publishing vs trusted publishing Worth naming the distinction, since the two get conflated: - **Trusted publishing (OIDC)** — replaces the long-lived token with a short-lived, workflow-scoped credential. **This is what fixes #134.** - **Staged publishing** — a review gate: `npm stage publish`, then a maintainer approves with 2FA before it goes live. It *complements* trusted publishing and explicitly does **not** remove the need for a token. It also converts semantic-release from fully automated to human-gated on every release. So staged publishing is a reasonable thing to want, but it's a separate decision and it wouldn't fix the failing release. ## What this PR changes One step, plus a comment explaining why it can't be removed: ```yaml - name: Ensure npm supports trusted publishing run: | npm install -g npm@^11.5.1 npm --version ``` Trusted publishing requires **npm CLI >= 11.5.1**. The release job runs Node 22, which bundles **npm 10.9.8** (confirmed against `nodejs.org/dist/index.json`). Without this step the publish fails *after* OIDC is configured, in a way that's hard to diagnose. The reason is specific to how `@semantic-release/npm@13.1.5` works. `lib/verify-auth.js`: ```js if (await oidcContextEstablished(registry, pkg, context)) { return; // ← no token written to .npmrc } await setNpmrcAuth(npmrc, registry, context); await verifyTokenAuth(registry, npmrc, context, pkgRoot); ``` The exchanged token is used only as a boolean probe — `oidcContextEstablished` returns `!!token` and discards it. `lib/publish.js` then runs `npm publish --userconfig <npmrc>` against an npmrc containing no credentials, so **the npm CLI must perform its own OIDC exchange**. An npm that can't do that arrives with nothing at all. `id-token: write` was already granted, so no permissions change is needed. ## What you need to do at npmjs.com npmjs.com → **deride** → Settings → Trusted Publisher → GitHub Actions: | Field | Value | |---|---| | Organization or user | `guzzlerio` | | Repository | `deride` | | Workflow filename | `release.yml` | | Environment | *(leave empty)* | | Allowed actions | `npm publish` | `deride` already exists on the registry (2.2.0 is `latest`), so the "package must already exist" precondition is met.⚠️ The config pins the **workflow filename**. Renaming `release.yml` later breaks publishing with a 404 on the exchange. Noted in CLAUDE.md. ## Suggested rollout Merging this PR is itself a safe live test. `semantic-release` runs `verifyConditions` *before* analysing commits, so the Release workflow exercises the OIDC exchange on every push to `main` — but this is a `ci:` commit, so nothing is releasable and nothing publishes. 1. Configure the trusted publisher (above). 2. Merge this PR. Watch the Release run for `OIDC token exchange with the npm registry succeeded`. No publish happens. 3. Only then merge #135, which publishes **v3.0.0** over the now-proven path. 4. Once that succeeds, delete the `NPM_TOKEN` secret. `NPM_TOKEN` is deliberately **left in place** in this PR — the plugin falls back to it when the exchange fails, so it's the rollback until a real publish has gone through. It's currently invalid, so the fallback is a dead end, but removing it now would only change one failure mode for another. Note that until step 1 is done, every push to `main` produces a red Release run. That's pre-existing (#134), not introduced here. ## Bonus: provenance CLAUDE.md described `NPM_TOKEN` as needing "publish + provenance rights", and the workflow commented `id-token: write # npm provenance`. Provenance was **never actually enabled** — nothing passes `--provenance`, there's no `publishConfig`, and `deride@2.2.0` has no attestations. Trusted publishing generates provenance automatically for GitHub Actions, so this finally delivers what that line claimed. CLAUDE.md corrected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member
Author
|
Holding this pending the outcome of #142. Merging this to #142 proposes gating breaking changes behind a No changes needed to the code here either way; it's green on all 13 checks. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #127.
stub<T>(methodNames)looked like it constrained names tokeyof Tbut didn't — two overloads were reachable and the second accepted anystring[], so a typo compiled and produced a mock silently missing that method.What changed
The name-list overload is now the single checked form, and is declared before the object overload rather than after it:
Three defects fixed
1. The reported one. Names are checked against
keyof T:2. Overload order — arguably the bigger bug. An array is also an
object, sostub(['query','close'])with no type argument boundTtostring[]and keyed the facades offArraymembers: you gotsetup.lengthandsetup.push, notsetup.query. The names now infer into the mock's shape, sosetup.queryandexpect.closetype-check. This was not in the issue.3.
readonly/as constname lists are accepted; they failed both before and under the fix as #127 proposed it.Why reordering rather than the issue's proposal
I probed excluding arrays from the object overload with a conditional type (
T extends readonly unknown[] ? never : T). It constrains correctly but breaks generic helpers that wrapstub():Reordering achieves the same result with no conditional and no generic-context regression. There's a guard for this case in
test/type-check.ts.Verification
Verified against
tsc6.0.3:stub<Gw>(['get','sve'])"save"stub<Gw>(['get','save'])stub<Gw>(['get','save'] as const)stub<Gw>(['get'])(subset)stub(['query','close'])Wrapped<string[]>Wrapped<{query,close}>stub<Gw>(dynamicStringArray)makeMock<U>(o)generic helperGuards live in
test/type-check.ts— the only test filetsconfig.jsoncompiles, so a guard intest/types.test.tswould never run in CI. Plus 4 runtime tests intest/stub.test.ts.pnpm lint,pnpm typecheck, 540 tests, coverage (95.33% statements / 88.93% branches, both above threshold),pnpm build, andpnpm docs:buildall pass. Also fixed the internalstub.classcall site the issue didn't mention.What this does NOT fix
Exhaustiveness. The check is that supplied names exist on
T, not that all ofTis covered —stub<Gw>(['get'])still compiles whileWrapped<Gw>claimssaveexists. Partial stubs are a deliberate, supported pattern (test/type-check.ts:13relies on one). Enforcing coverage would break far more than it fixes. This is documented instead, incommon-mistakes.md§16.Breaking change
Values typed
string[]are rejected. Correct literal lists are unaffected — this only bites lists built at runtime, which opt out explicitly:Per the discussion on #127 this is a documented cast rather than new API surface. Code relying on the old
Wrapped<string[]>inference will surface type errors, since the mock is now typed from the names.Runtime behaviour is unchanged — this is type-level only.
Docs
Synced per CLAUDE.md's table:
ai/decision-tree.md(factory row + runtime-list row),ai/common-mistakes.md(two new entries),api/stub.md,guide/creating-mocks.md,guide/typescript.md, and a v2→v3 section inguide/migrating.md.Note
guide/creating-mocks.mdclaimed "Every method onTmust appear in the array; TypeScript checks it" — wrong before this change and still wrong after (it checks existence, not exhaustiveness). Corrected.NPM_TOKENis invalid andverifyConditionswill fail. This is afeat!so it would publish v3.0.0.🤖 Generated with Claude Code