Skip to content

feat!: constrain stub<T> method names to keyof T - #135

Open
jamlen wants to merge 1 commit into
mainfrom
feat/127/stub-method-names
Open

feat!: constrain stub<T> method names to keyof T#135
jamlen wants to merge 1 commit into
mainfrom
feat/127/stub-method-names

Conversation

@jamlen

@jamlen jamlen commented Aug 12, 2026

Copy link
Copy Markdown
Member

Closes #127.

stub<T>(methodNames) looked like it constrained names to keyof T but didn't — two overloads were reachable and the second accepted any string[], 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:

export function stub<I extends object>(cls: new (...args: never[]) => I): Wrapped<I>
export function stub<T extends object = Record<string, unknown>>(
  methodNames: readonly (keyof T)[],
  properties?: StubPropertyDescriptor[],
  options?: StubOptions
): Wrapped<T>
export function stub<T extends object>(obj: T): Wrapped<T>

Three defects fixed

1. The reported one. Names are checked against keyof T:

Type '"sve"' is not assignable to type 'keyof AccountGateway'. Did you mean '"save"'?

2. Overload order — arguably the bigger bug. 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: you got setup.length and setup.push, not setup.query. The names now infer into the mock's shape, so setup.query and expect.close type-check. This was not in the issue.

3. readonly / as const name 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 wrap stub():

function makeMock<U extends object>(o: U) { return stub(o) }   // fails

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 tsc 6.0.3:

Call Before After
stub<Gw>(['get','sve']) compiles errors, suggests "save"
stub<Gw>(['get','save']) compiles compiles
stub<Gw>(['get','save'] as const) errors compiles
stub<Gw>(['get']) (subset) compiles compiles
stub(['query','close']) Wrapped<string[]> Wrapped<{query,close}>
stub<Gw>(dynamicStringArray) compiles errors (see below)
makeMock<U>(o) generic helper compiles compiles

Guards live in test/type-check.ts — the only test file tsconfig.json compiles, so a guard in test/types.test.ts would never run in CI. Plus 4 runtime tests in test/stub.test.ts.

pnpm lint, pnpm typecheck, 540 tests, coverage (95.33% statements / 88.93% branches, both above threshold), pnpm build, and pnpm docs:build all pass. Also fixed the internal stub.class call site the issue didn't mention.

What this does NOT fix

Exhaustiveness. The check is that supplied names exist on T, not that all of T is covered — stub<Gw>(['get']) still compiles while Wrapped<Gw> claims save exists. Partial stubs are a deliberate, supported pattern (test/type-check.ts:13 relies on one). Enforcing coverage would break far more than it fixes. This is documented instead, in common-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:

const names: string[] = readMethodsFromConfig()
stub<Database>(names as (keyof Database)[])

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 in guide/migrating.md.

Note guide/creating-mocks.md claimed "Every method on T must appear in the array; TypeScript checks it" — wrong before this change and still wrong after (it checks existence, not exhaustiveness). Corrected.

⚠️ This cannot release until #134 is resolvedNPM_TOKEN is invalid and verifyConditions will fail. This is a feat! so it would publish v3.0.0.

🤖 Generated with Claude Code

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>
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>
@jamlen

jamlen commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

Holding this pending the outcome of #142.

Merging this to main publishes v3.0.0 immediately — release.yml fires on every push to main, this squashes to a feat!: commit, and the OIDC path now works, so nothing gates it. The merge is the release.

#142 proposes gating breaking changes behind a next prerelease branch. If that's adopted, this PR retargets to next and ships as 3.0.0-next.1 on the next dist-tag first, which removes the one-shot risk. If it's declined, this merges to main and cuts v3.0.0 directly — in which case the docs and README work in #142's "alternative" section needs to land first.

No changes needed to the code here either way; it's green on all 13 checks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stub<T>(methodNames) does not constrain names to keyof T

1 participant