Skip to content

stash doctor: probe the native binding for real - #883

Merged
tobyhede merged 4 commits into
mainfrom
toby/cip-3720-stash-doctor-probe-the-native-binding-for-real
Aug 13, 2026
Merged

stash doctor: probe the native binding for real#883
tobyhede merged 4 commits into
mainfrom
toby/cip-3720-stash-doctor-probe-the-native-binding-for-real

Conversation

@tobyhede

Copy link
Copy Markdown
Contributor

Closes CIP-3720.

Making the protect-ffi native load lazy was a real perf win, but it cost stash doctor its only real check — and hid that a second one had never worked. Both rows were green with nothing installed.

What was broken

The encryption check never loaded anything. @neon-rs/load's proxy resolves the platform binary on first property access inside a wrapper body, so importing the package proves nothing: the probe passed with no binary present and the failure landed later, at the first encrypt.

It was also measuring the wrong package. The root entry re-exports the auth strategies, and @cipherstash/auth's binding is eager (module.exports = { ...native }, a spread that forces any loader), so the encryption row was a second auth check. Two rows, one signal — hiding auth's binary reddened both, hiding protect-ffi's reddened neither.

A missing auth binary printed a bare Fatal error from every command. napi's loader requires each candidate inside try { … } catch (_) {} and throws its own summary, so no code and no requireStack survive. The CLI's classifier keyed on MODULE_NOT_FOUND and could never match it — the recovery guidance was skipped everywhere, not just in doctor.

What this does

Adds @cipherstash/stack/diagnostics, one export, called rather than merely imported. There is no consumer-side alternative: protect-ffi's loader is not in its exports map (ERR_PACKAGE_PATH_NOT_EXPORTED), and reading an export never reaches the proxy. Its own tsup config with splitting: false, because what it must not reach is the point of it — a shared chunk importing @cipherstash/auth puts the probe back on auth's binary, and splitting: false makes that structural rather than a property of today's module graph.

doctor now names what to CALL per probe, classifies four failure modes distinctly, and exits non-zero when either platform package is missing.

Two more the tests found

"not installed" was a substring match. The probe's own import path contains the package name, so an installed-but-broken @cipherstash/stack was reported as one you had not installed yet — green, with nothing to suggest looking further. @cipherstash/stack-drizzle matched it too. Now keyed on the specifier Node failed to resolve.

The outro claimed every check passed after skipping one. A run that could not complete a check says so, and still exits 0 — an unrunnable check is not a diagnosis.

Two from review

The subpath re-exported an export that is not published. assertNativeBindingAvailable arrived with the lazy load, whose changeset is parked .deferred until the publishing cutover, so no released protect-ffi has it — verified against the tarball on npm. @cipherstash/stack depends on workspace:*, so it built here and would have shipped against 0.31.0: a link-time SyntaxError under ESM, undefined under CJS, neither classifiable, so a bare Fatal error for every user of a released install. It now probes through isEncrypted, published since 0.28.0 and the same call protect-ffi's own assert makes — pure, synchronous, no validation before the addon.

The stale-subpath arm was probe-agnostic while its advice was not. It keyed on ERR_PACKAGE_PATH_NOT_EXPORTED alone and saw every probe's failure, so an exports problem anywhere in auth's import graph was answered with "upgrade @cipherstash/stack" and an exit code of 0. A probe now declares the subpath it imports, and the arm requires the error to name that subpath of that package.

Tests

Coverage for all four classification arms, at two seams — the pure classifiers, and stash doctor as a spawned process.

Every error asserted on is one Node raised, against a package layout the fixture builds (an absent package, an older one, a hidden binary). Nothing is hand-built with the code and message pasted on: a fixture like that asserts on itself and keeps passing when Node changes the shape, which is exactly how the auth arm of isNativeBinaryMissing came to exist without ever being able to fire.

The one that matters most is non-vacuity: hiding protect-ffi's binary must redden exactly one row.

  • CLI unit 1243 passed
  • CLI e2e 108 passed (was 104)
  • stack diagnostics / bundle-isolation / cjs-require 33 passed
  • turbo typecheck 3/3, biome error-free

Notes for the reviewer

  • AGENTS.md's subpath-export list is updated, per its own rule 7.
  • No skill changes: the CLI's command and flag surface is unchanged, and skills/stash-cli describes doctor generically.
  • Both changesets are here; neither names an FFI package, so lint:ffi-changeset is satisfied.

Making the protect-ffi load lazy cost `stash doctor` its only real check:
`@neon-rs/load`'s proxy resolves the platform binary on first property
access inside a wrapper body, so importing the package proves nothing and
the failure lands later, at the first encrypt.

Add `@cipherstash/stack/diagnostics` — one export, `assertNativeBindingAvailable()`,
re-exported from protect-ffi. There is no consumer-side alternative: the
loader is not in protect-ffi's `exports` map, and reading an export never
reaches the proxy. Its own tsup config with `splitting: false`, because
what it must NOT reach is the point of it — a shared chunk importing
`@cipherstash/auth` would put the probe back on auth's binary.

That was the second bug: the root entry re-exports the auth strategies, and
auth's binding is eager, so the encryption row was a second auth check —
two rows, one signal. And a missing auth binary printed a bare `Fatal
error` from every command, not just doctor: napi's loader swallows the
resolver's MODULE_NOT_FOUND and throws a summary with no error `code`,
which the classifier could not recognise.

Two more the tests found:

* "not installed" was matched by looking for the package name anywhere in
  the message. The probe's own import path contains it, so an installed
  but broken `@cipherstash/stack` was reported as one you had not installed
  yet — green, with nothing to suggest looking further. Match on the
  specifier Node failed to resolve instead.
* A run that could not complete a check said every check passed. It now
  says so, and still exits 0: an unrunnable check is not a diagnosis.

Coverage for all four classification arms. The e2e fixtures make a binary
or a package genuinely unresolvable in the spawned CLI — `Module._load` for
the CJS binding, a `module.registerHooks` resolve hook for the ESM probe —
so every error asserted on is one Node raised. The one that matters most is
non-vacuity: hiding protect-ffi's binary must redden exactly one row.
Before this change, hiding auth's reddened both and hiding protect-ffi's
reddened neither.
…bpath arm

Two review findings, both real.

`@cipherstash/stack/diagnostics` re-exported protect-ffi's
`assertNativeBindingAvailable`, which is in no released version of that
package: it arrived with the lazy native load, whose changeset is parked
`.deferred` until the publishing cutover. `@cipherstash/stack` depends on
`workspace:*`, so it built against the sibling directory here and would
have shipped against 0.31.0 on npm — where the export does not exist.
Verified against the published tarball: under ESM the entry dies at import
with a link-time SyntaxError, under CJS it hands back `undefined`. `doctor`
classifies neither, so every user on a released install would have got a
bare `Fatal error` from the command whose job is to explain those.

It now calls `isEncrypted(null)`, published since 0.28.0 and the same call
protect-ffi's own assert makes, for the same reason: pure, synchronous, and
no validation before the addon. A stand-in carrying the published surface
covers it; the existing tests still cover the real package and a real
binding.

The too-old-to-probe arm keyed on `ERR_PACKAGE_PATH_NOT_EXPORTED` alone,
and every probe's failure was offered to it — including auth's, which asks
for no subpath. Any exports failure in a probe's import graph was answered
with "upgrade @cipherstash/stack" and an exit code of 0. A probe now
declares the subpath it imports, and the arm requires the error to name
that subpath of that package.

The e2e fixture moves the IMPORTER to a directory this test builds — absent
package, or an older one whose `exports` predates the subpath — rather than
rewriting the specifier. Redirecting produced the right error code against
the wrong subpath, a shape the real failure never has, which is precisely
what the tightened classifier rejects.

Also fixes a fixture that would have gone on lying: a CJS stand-in reached
through macOS's symlinked tmpdir loads twice, and the named exports bind
against the instance that never ran — indistinguishable from the bug under
test.
@tobyhede
tobyhede requested a review from a team as a code owner August 13, 2026 00:55
@changeset-bot

changeset-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bdf65fa

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@cipherstash/stack Minor
stash Minor
@cipherstash/bench Patch
@cipherstash/stack-drizzle Minor
@cipherstash/stack-prisma Minor
@cipherstash/stack-supabase Minor
@cipherstash/test-kit Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-example Patch
@cipherstash/e2e Patch
@cipherstash/wizard Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

…ment it

`typesVersions` and `exports` describe the same subpaths to two different
resolvers, and only one of them is exercised here. `./diagnostics` was in
`exports` and not in `typesVersions`, so a consumer on classic node10
resolution — still the default when `module` is `commonjs` — resolved it to
`any` with no error at the import site. Nothing type-checks that: the
package builds, publishes and installs clean.

Guarded now rather than just fixed. The parity test asserts every exported
subpath has a `typesVersions` entry pointing at the same declaration file,
so the next subpath fails here instead of shipping half-declared. It is
keyed on what `exports` publishes, which leaves the reverse drift alone —
`secrets` has an entry for a subpath that no longer exists, dead weight
rather than a consumer-visible gap, and not this branch's to remove.

`skills/stash-encryption` carries the canonical subpath table and ships
inside the `stash` tarball. `adapter-kit` is listed there with a "do not
import in application code" caveat, so a tooling-only entry belongs in it;
`./diagnostics` is documented the same way. The root AGENTS.md list was
updated when the subpath landed, the skill was not — the map in AGENTS.md
points `packages/stack` subpath exports at this skill precisely so that
does not happen.

Also from review:

* `isPackageMissing` and `isSubpathUnavailable` were exported from
  `commands/doctor` solely so a test could reach them, against this repo's
  rule about testing through the public API. They are module-resolution
  error classifiers, so they now live in `module-error.ts` next to
  `isModuleNotFound` and `moduleNotFoundSpecifier` — imported by doctor,
  tested where they are defined, and no wider a surface than before.
* Counting failing rows by splitting on the message rather than
  `new RegExp(message)`. The needle is copy from `messages.ts`, and copy is
  free to grow a `.` or `(` that a regex would reinterpret instead of
  failing on.
@tobyhede

Copy link
Copy Markdown
Contributor Author

Third round of review fixes pushed (12d1c0d2).

typesVersions gap — confirmed and guarded. Every other subpath had an entry and ./diagnostics had none, so node10 consumers resolved it to any with no error at the import site. Fixed, and packages/stack/__tests__/subpath-types-parity.test.ts now asserts every exported subpath has a typesVersions entry pointing at the same declaration file — written first, and it failed naming exactly diagnostics.

One thing the guard deliberately does not cover: typesVersions has a secrets entry for a subpath exports no longer publishes. Pre-existing, dead rather than consumer-visible, and not this branch's to remove — so the test is keyed on what exports publishes rather than failing on it.

Skill table — confirmed. skills/stash-encryption/SKILL.md is the canonical subpath table and ships in the stash tarball; adapter-kit sits there with a "do not import in application code" caveat, so a tooling-only entry belongs. ./diagnostics documented the same way. The existing stash patch changeset covers it.

Exported-for-tests internals — taken, not defended. isPackageMissing and isSubpathUnavailable are module-resolution error classifiers, so they moved to module-error.ts beside isModuleNotFound and moduleNotFoundSpecifier. Doctor imports them; the test targets the module that owns them; the surface is no wider than before.

Unescaped new RegExp(messages...) — fixed. Counted by splitting on the message instead. The needle is copy, and copy is free to grow a . or ( that a regex reinterprets rather than fails on.

Duplicated resolver-hiding preload — left as is, with a reason. The two copies are in different packages (packages/cli and packages/stack), and the only shared home is @cipherstash/test-kit, which depends on @cipherstash/stack — so stack cannot depend on it without a cycle, and it is an EQL-suite kit rather than a Node-loader-fixture home. Both copies already point at packages/protect-ffi/src/lintWiring.test.ts, the original. Happy to revisit if a third package needs it.

Verified: CLI unit 1243, CLI e2e 108, stack suites 34, test:types:dist clean, turbo typecheck 3/3, biome error-free.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the full diff plus the branch's surrounding code — protect-ffi's loader and isEncrypted, the classifier regexes, tests.yml, and the CLI's dependency graph. Approving.

Load-bearing claims verified:

  • isEncrypted(null) is safe on a healthy install: protect-ffi's own assertNativeBindingAvailable makes the identical call (packages/protect-ffi/src/index.cts:220), and nativeLoading.test.ts pins that it returns false rather than throwing against a real binding. So the probe can only throw what the loader raised — which is the whole contract.
  • Probing through isEncrypted instead of re-exporting protect-ffi's assertNativeBindingAvailable is the right call, and the publishedFfiSurfacePreload test is the right way to hold it: workspace:* genuinely hides the released-surface mismatch, and that stand-in is the only thing in the repo that can see it.
  • The napi code-less error shape is verified end-to-end, not just against the hand-mirrored napiLoadError fixture — doctor-missing-binary.e2e.test.ts hides the real auth binary in a spawned CLI and asserts the classified output, so if @cipherstash/auth's generated loader ever changes its message, the e2e fails even though the unit fixture wouldn't. That's exactly the failure mode the PR body describes, closed properly.
  • isPackageMissing keying on the quoted specifier (with the startsWith(${pkg}/) CJS case) fixes both false-positive directions — the installed-but-broken path and the stack-drizzle prefix collision — and both have Node-raised fixtures.
  • isSubpathUnavailable requiring the error to name the probe's own subpath and package is the correct narrowing; the pnpm .pnpm path layout still contains @cipherstash/stack/package.json, so the match survives real installs. The auth-probe test correctly asserts that an unclassifiable exports failure is rethrown (exit 1) rather than answered with unrelated upgrade advice.
  • The splitting: false isolated tsup config plus the assert-on-the-artifact specifier test makes "reaches protect-ffi and nothing else" structural, and asserting toEqual(['@cipherstash/protect-ffi']) rather than not.toContain('auth') is the right shape — a shared chunk arrives under a relative name.
  • Repo hygiene: both changesets present, neither names an FFI package; AGENTS.md subpath list updated per its rule 7; skills/stash-encryption row added; the new subpath-types-parity test closes a real node10-resolution gap that nothing else type-checks.

One note, non-blocking:

The healthy-path doctor.e2e.test.ts now genuinely requires a built protect-ffi binding. In CI that's fine — the Node 22/24 jobs run build-ffi-binding before the CLI e2e step, and a JS-only PR pays a cache restore. But locally, a contributor without a Rust toolchain running pnpm --filter stash test:e2e will now see the healthy doctor test fail with a red encryption row: @cipherstash/stack is a devDependency of the CLI, so the probe always finds it, and the workspace-linked platform packages only carry index.node after build:native. Before this PR the probe reached auth (whose binaries come prebuilt from npm) and passed Rust-free. Consider a line in packages/cli/AGENTS.md noting that test:e2e now needs the binding built (mise run build in packages/protect-ffi or a debug build), so the first person to hit it doesn't read it as a broken checkout.

Observed but fine:

  • An ERR_PACKAGE_PATH_NOT_EXPORTED from deeper in a probe's graph that doesn't match the declared subpath still surfaces as the launcher's bare Fatal error — but that's a genuinely unknown failure, and rethrowing beats misdiagnosing; the tests document this as intended.
  • messages.doctor.cannotProbe hard-codes @cipherstash/stack while the mechanism is generic — fine while only one probe declares a subpath.

`stash doctor`'s encryption probe now calls through
`@cipherstash/stack/diagnostics`, so the healthy-install E2E needs a real
`index.node`. `@cipherstash/stack` is a devDependency here, so the probe
always resolves the package, and the workspace-linked platform package
carries no binary until cargo has run — which no `build` does, protect-ffi's
being `tsc` by design.

Without one the run fails on a red encryption row and exit 1, and offers the
reinstall advice it has for the npm optional-dependency bug, which does not
fix this. That reads as a broken checkout. CI is unaffected: `run-tests`
builds the binding long before the CLI E2E step.
@tobyhede
tobyhede merged commit 86ee493 into main Aug 13, 2026
13 checks passed
@tobyhede
tobyhede deleted the toby/cip-3720-stash-doctor-probe-the-native-binding-for-real branch August 13, 2026 09:04
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.

2 participants