Skip to content

chore(pg-js): typecheck tests and scripts, not just src - #154

Merged
rubenhensen merged 6 commits into
mainfrom
chore/typecheck-tests
Jul 30, 2026
Merged

chore(pg-js): typecheck tests and scripts, not just src#154
rubenhensen merged 6 commits into
mainfrom
chore/typecheck-tests

Conversation

@rubenhensen

Copy link
Copy Markdown
Contributor

Stacked on #153base is feat/envelope-compat-gate, so merge that first. Retargets to main on its own once #153 lands.

pnpm typecheck only ever looked at src. A test could hand a public API a shape its own types do not declare and the check stayed green.

That is not hypothetical: it is how a Buffer reached an ExtractCiphertextOptions.attachments slot declared as ArrayBuffer, found by review on #153 rather than by any check. It matters concretely — for the small envelope fixtures that Buffer lands at a non-zero byteOffset inside Node's shared 8 KiB pool, so a reader written against the declared ArrayBuffer would work for real add-in callers and misbehave against the compat gate.

Why a second config rather than a wider include

tsconfig.json is the build config: tsdown reads it, and it sets rootDir: "src", which turns every file under tests/ into TS6059 instead of a checked file. So typecheck now runs tsc --noEmit -p tsconfig.typecheck.json, which extends it, drops rootDir, and covers src, tests and scripts. The build path is untouched.

Two settings there are load-bearing:

  • types: ["node"]. TypeScript 6 no longer pulls every node_modules/@types package into the program automatically. Without this, Buffer, process and every node:* import fail with TS2591 telling you to install @types/node — while @types/node is installed and sitting in node_modules. 29 of the 54 initial errors were this. Pinned to the major in engines (>=22) so a test cannot quietly use an API the floor lacks.
  • allowJs on, checkJs off, so tests/api-surface.test.ts can import scripts/lib/api-surface.mjs without holding that script to strict mode it was never written for.

The 18 real errors, all fixed rather than suppressed

file count what it was
tests/api.test.ts 15 FileState literals omitting the required recoveryToken, so storeChunk returned recoveryToken: undefined against a type promising a string. Two call sites in the same file already passed it; the rest now match.
tests/zip.test.ts 2 bare Uint8Array into new Blob([...]). Since TS 5.7 the array is generic over its backing buffer and BlobPart wants an ArrayBuffer-backed view, so the two helpers are typed Uint8Array<ArrayBuffer>.
tests/email-attributes.test.ts 1 reading .t off an AttrConItem, which is AttrReq | AttrDiscon and carries t on only one side. Narrowed with Array.isArray as types.ts documents, which also strengthens the assertion: the entry must be a single attribute, not a disjunction.

No @ts-expect-error, no any, no relaxed strict.

Verified by breaking it

Reintroducing the Buffer into the ArrayBuffer slot:

check result
tsc -p tsconfig.typecheck.json TS2322, exit 2
tsc -p tsconfig.json (the old, src-only check) exit 0
vitest run envelope-archival exit 0

The last two rows are the point: neither the previous typecheck nor the test suite catches this class, which is why it took a human reading the diff.

pnpm -r typecheck green across all 7 projects, pg-js suite 305 passing, build and API report clean.

Part of encryption4all/postguard#247.

…rpus

B6 (#129) rewrites the envelope, and envelope changes currently merge with
nothing checking them across versions. This class has already shipped twice:
pg-js >=1.1 silently stopped emitting body armor and broke Thunderbird
detection (postguard-tb-addon#85), and the data.bin zip asymmetry broke the
website's uuid decrypt path (#39).

COMPATIBILITY.md pins both directions, and they are not symmetric. The SDK
window is "the last two majors" of @e4a/pg-js, and it says email envelopes
"carry the same guarantee" -- so the forward reader set is the latest published
1.x and 2.x, 1.11.0 and 2.3.4, installed under aliases as devDependencies.
Archival is unconditional: "read support for stored artifacts is not part of
this window. It never drops."

Forward: an envelope built at HEAD, for each of the three tiers, must be read
by every published reader in the window. Archival: HEAD must read every fixture
in the committed corpus.

The corpus is append-only and enforced. A fixture records bytes a sender
actually emitted, so a failing archival test means the reader regressed --
editing the fixture to go green would erase the evidence instead. The check
compares against the MERGE BASE rather than the base tip, so a fixture added to
main after a branch starts is not blamed on that branch, and it fails rather
than skips when it cannot find a base.

Both directions assert their own wiring, per encryption4all/postguard#272: the
corpus must be non-empty and cover all three tiers, and the reader list must be
non-empty with the extractors present. Either could otherwise pass having
checked nothing.

Mutation-tested rather than trusted. Archival: renaming the attachment
extractCiphertext looks for -> 3 failures; breaking the uuid regex -> 2;
emptying the corpus -> the coverage assertion fires. Forward: renaming the
attachment HEAD emits -> 5 failures, with the published readers returning null,
which is the tb#85 signature exactly; changing the content type -> 1; changing
the uuid route -> 5. Baseline green in every case afterwards.

The gate also asserts the three things installed mail clients key on and that
no existing unit test covers: the postguard.encrypted attachment name, the
application/postguard content type, and the /decrypt|/download?uuid= routes.

Deliberately NOT path-filtered to src/email/**, though #131 asks for it. A
required status check that is path-filtered never reports on PRs which miss the
filter, and branch protection then blocks them indefinitely. It runs in
seconds.

Not covered here, and #129's third part: the x-postguard header. Four producers
disagree (pg-js writes "0.1", tb-addon writes "decrypted" after decrypt and a
version on encrypt, cryptify stamps a pg-core version), and detection is a bare
presence check, so every value passes and no test can tell them apart until the
meaning is decided.

Refs #131
Refs #129
pnpm envelope:check runs from packages/pg-js, so the repo-relative pathspec
resolved to packages/pg-js/packages/pg-js/... and matched nothing. The check
reported "none modified or removed" for every input, including a rewritten
fixture -- a guard that could not fail.

Found by mutation-testing it rather than by reading it, and it is the same
working-directory shape as the Baked URLs job in outlook-addon.yml.

Refs #131
The forward direction branched on `attachments.length === 0` and on
`result.uploadUuid`, both read off the output under test. A HEAD that silently
stopped emitting the tier-1 attachment therefore took the tier-3 branch and all
four per-reader tier-1 cases passed — the postguard-tb-addon#85 shape this gate
is named after. Both expectations now come from `result.tier`, which
src/email/envelope.ts makes a hard invariant, and the recovered ciphertext is
compared against the payload rather than against what was fed to the reader.

Also from the review on #153:

- Both tests pass an exact-length ArrayBuffer, which is what
  ExtractCiphertextOptions declares. `Buffer.from` landed at a non-zero
  byteOffset inside Node's shared pool for the small fixtures, so a conforming
  reader wrapping the whole buffer would have seen the pool. Invisible to
  `pnpm typecheck` because tsconfig.json has `include: ["src"]`.
- The archival markers case is relabelled as the corpus lint it is: every
  assertion reads a fixture field and none calls into HEAD.
- `producedBy` records a real version and commit instead of a constant string
  that answered nothing the fixture's shape did not. Rewriting the four
  existing fixtures is legitimate only while the corpus is still branch-local
  and reports as added; once this merges the append-only guard forbids it,
  which is why it happens now.
- integration.yml subscribes to `edited`, so retargeting a PR re-runs the two
  jobs whose verdict comes from a merge base: envelope-compat and api-surface.
- Reverted an unrelated — escape in package.json's description, dropped
  now-misleading "run this from the repository root" advice, and wrapped
  `git rev-parse --show-toplevel` so running outside a repository reports
  through `fail` instead of a raw execFileSync stack trace.

CLAUDE.md gains the append-only rule, the reader aliases, the derive-from-tier
invariant and the untypechecked-tests gotcha. Its "api:gate is NOT yet a CI
step" paragraph was stale — the api-surface job runs it.
`pnpm typecheck` only ever looked at `src`, so a test could hand a public API
a shape its types do not declare and stay green. That is how a `Buffer` reached
an `ExtractCiphertextOptions.attachments` slot declared as `ArrayBuffer`
(#153); for the small envelope fixtures that Buffer
lands at a non-zero byteOffset inside Node's shared pool, so a conforming reader
written against the declared type would have misbehaved against the gate.

Widening `tsconfig.json` is not the fix: tsdown reads it as the build config and
its `rootDir: "src"` turns every test file into TS6059. So `typecheck` now runs
against `tsconfig.typecheck.json`, which extends it, drops `rootDir` and covers
`src`, `tests` and `scripts`.

`types: ["node"]` is explicit because TypeScript 6 no longer pulls every
`node_modules/@types` package into the program automatically — without it,
`Buffer`, `process` and every `node:*` import report TS2591 asking for
`@types/node` while `@types/node` is installed. It is pinned to the major named
in `engines` (>=22) so a test cannot quietly use an API the floor lacks.
`allowJs` is on with `checkJs` off, so `tests/api-surface.test.ts` can import
`scripts/lib/api-surface.mjs` without holding that script to strict mode.

The 18 errors this surfaced, all real:

- 15 `FileState` literals in `api.test.ts` omitted the required `recoveryToken`,
  so `storeChunk` returned `recoveryToken: undefined` against a type promising
  a string. Two call sites in the same file already passed it; the rest now
  match.
- `zip.test.ts` passed a bare `Uint8Array` to `new Blob([...])`. Since TS 5.7
  the array is generic over its backing buffer and `BlobPart` wants an
  ArrayBuffer-backed view, so the two helpers are typed `Uint8Array<ArrayBuffer>`.
- `email-attributes.test.ts` read `.t` off an `AttrConItem`, which is
  `AttrReq | AttrDiscon` and only has `t` on one side. Narrowed with
  `Array.isArray` as types.ts documents, which also makes the assertion
  stronger: the entry must be a single attribute, not a disjunction.

Stacked on feat/envelope-compat-gate: it retires the CLAUDE.md gotcha that
branch adds rather than leaving it documented forever.
@dobby-coder

dobby-coder Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Thanks for the detailed writeup! I'm on it — starting a review of #154, keeping in mind it's stacked on #153 with feat/envelope-compat-gate as the base. I'll look at the new tsconfig.typecheck.json split and the types: ["node"] reasoning you laid out, then come back with a consolidated review.

@rubenhensen

Copy link
Copy Markdown
Contributor Author

Note on CI: this PR currently runs only Conventional Commit. integration.yml filters on branches: [main], which matches the PR's base, and this one is based on feat/envelope-compat-gate — so no typecheck, build or test job reports here while it is stacked.

That resolves itself: once #153 merges and this retargets to main, the full suite runs. It runs because #153 added edited to the trigger types — without that, retargeting fires an event integration.yml does not subscribe to, and this PR would sit with a stale, never-run check while branch protection waited. Unplanned, but it is the same failure mode the edited change was made for.

Verified locally in the meantime: pnpm -r typecheck green across all 7 projects, pg-js 305/305, build and API report clean.

Base automatically changed from feat/envelope-compat-gate to main July 30, 2026 15:55
@rubenhensen

Copy link
Copy Markdown
Contributor Author

/dobby solve conflicts

@dobby-coder dobby-coder Bot 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.

The premise is right and the second-config approach is the correct call: tsconfig.json really is the build config, rootDir: "src" really does turn every test file into TS6059, and the 18 errors were fixed rather than suppressed. Checked all of it against a real install on this branch (TypeScript 6.0.3).

One blocking problem, and it is the mirror image of the bug this PR fixes: widening the check narrowed two others. Details inline on tsconfig.typecheck.json. Short version — typecheck no longer runs the src-only config, and two settings in the new one are looser than the old one was:

  • types: ["node"] makes Buffer and process legal in src. Confirmed: adding both to src/util/identity.ts gives exit 0 under the new config, TS2591 under the old one. This is a browser SDK that reaches process through a deliberate globalThis cast, so that was a real guard.
  • declaration: false drops TS4023/TS4094/TS2742, which are only reported when declaration emit is on. Confirmed with a synthetic probe: exit 2 under tsconfig.json, exit 0 under tsconfig.typecheck.json. tsdown does not typecheck, so nothing catches these now, and this package publishes .d.ts.

Both are fixed by one line, and both passes are already green on this branch:

"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.typecheck.json"

Two stale comments in files this PR does not touch

Both say tests are outside the typecheck, which this PR makes false. GitHub will not take inline comments on unchanged files, so they are here:

  • tests/envelope-archival.test.ts:62 — "tsconfig.json's include: ["src"] keeps tests out of pnpm typecheck, so the mismatch would not surface there." It surfaces now. Verified: restoring the bare Buffer.from(...) in the data slot fails with TS2322 at line 73. This is the exact comment the PR body cites as the motivating case, so it is the one place a reader is most likely to trust it. Suggest pointing it at the new config instead.
  • tests/envelope-forward.test.ts:61 — "tsconfig.json's include: ["src"] means nothing here would flag the drift." It does now: swapping in Buffer.from(await result.attachment.arrayBuffer()) fails with TS2322 at line 113 against the local reader shim's ArrayBufferLike.

CLAUDE.md was updated in this PR; these two were not.

What checked out

  • Not a no-op against its base. Two-dot diff origin/feat/envelope-compat-gate..origin/chore/typecheck-tests is non-empty across all 7 files.
  • "green across all 7 projects" holds. All 7 workspace members declare a typecheck script, so pnpm -r typecheck in integration.yml skips none of them, and examples.yml already guards the examples against a missing or empty one.
  • No stale docs left behind. Grepped the repo for tsc --noEmit; the only pg-js references are the two CLAUDE.md lines this PR updates. The add-on hits are their own separate configs.
  • @types/node@^22.20.1 matches the >=22 floor in both the root and pg-js engines, as the comment claims.
  • types: ["node"] is genuinely required, not cargo-culted: with it omitted the node:* imports fail with TS2591 even though @types/node is installed.

Also: declarationMap and sourceMap at lines 22-23 are inert under noEmit and can go. declaration is the only one of the three that changes anything, and per the above it should go back to inheriting.

Comment thread packages/pg-js/tsconfig.typecheck.json
Comment thread packages/pg-js/tests/email-attributes.test.ts Outdated
Comment thread CLAUDE.md Outdated
@dobby-coder

dobby-coder Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Reviewed #154 and confirmed your approach is right — tsconfig.json is genuinely the build config, rootDir: "src" really does make every test file TS6059, and the errors were fixed, not suppressed. I posted a REQUEST_CHANGES review with 3 inline comments.

The blocking one: widening the check narrowed two settings from the old src-only config. types: ["node"] legalizes Buffer/process in src (a real regression — this is a browser SDK reaching process through a deliberate cast). And declaration: false drops TS4023/TS4094/TS2742, which only surface with declaration emit on — and this package publishes .d.ts, with nothing else typechecking it now. Both fix with one line:

"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.typecheck.json"

Also flagged: two now-false "tests are outside typecheck" comments (in the review body since those files are untouched), a stale Array.isArray narrowing comment that's actually a cast, and a CLAUDE.md claim that scripts is checked when checkJs: false means it isn't.

Everything else checks out — not a no-op, all 7 workspace typecheck scripts run, @types/node version matches the engines floor. Just remember #153 merges first since this is stacked on it.

Review: #154 (review)

@dobby-coder

dobby-coder Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Got it — I'm on it now. I'll take a look at PR #154, resolve the merge conflicts, and update the PR directly.

dobby-coder Bot added 2 commits July 30, 2026 16:01
#153 landed on main as a squash commit, so main and this branch carry the
same envelope-compat change under different shas. Resolutions, all three
the same shape (keep this branch's addition, main's side adds nothing new
in the conflicting region):

- CLAUDE.md: keep the rewritten Tests paragraph describing
  tsconfig.typecheck.json; main still had the pre-#154 'tests are never
  typechecked' text.
- packages/pg-js/package.json + pnpm-lock.yaml: keep the @types/node
  devDependency next to main's pg-js-reader-v1/v2 entries.
Addresses the three review comments on #154.

tsconfig.typecheck.json changes two program-wide settings, so running it
INSTEAD of tsconfig.json quietly dropped coverage src had before:

- types: ["node"] legalizes Buffer/process in src too, and this is a
  browser SDK that reaches process through an explicit globalThis cast.
- declaration: false silences TS4023/TS4094/TS2742, which are only
  reported while declaration emit is on. tsdown does not typecheck, so
  the tsconfig.json pass is the only guard on the published .d.mts.

typecheck now runs both passes. Verified by probe: appending a
process/Buffer expression to src/util/identity.ts and adding a module
exporting an anonymous class with a private member each exit 2 under the
combined script and exit 0 under tsconfig.typecheck.json alone.

Also:
- tests/email-attributes.test.ts narrows with Array.isArray for real
  instead of casting to AttrReq, which is what the comment above it
  already claimed; the now-unused type import goes with it.
- CLAUDE.md: say that scripts is in include for the import boundary
  only. With checkJs off and every script being .mjs, script bodies are
  not checked; what it buys is inferred shapes at the
  tests/api-surface.test.ts import of scripts/lib/api-surface.mjs.
@dobby-coder

dobby-coder Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Conflicts resolved in e3d0a7e, and the three review comments in 96574e4. mergeable: true, and the full suite reports for the first time now that the base is main: every check passes.

Where the conflicts came from

#153 merged to main as the squash commit eb02727, while this branch still carries its four original commits. Same content, different shas, so git had nothing to fast-forward and all three files it flagged were regions where both sides had appended near the same line. Main's side added nothing new in any of them, so all three resolutions keep this branch's version:

file resolution
CLAUDE.md keep the rewritten Tests paragraph; main still carried the pre-#154 "tests are never typechecked" text
packages/pg-js/package.json keep the @types/node devDependency alongside main's pg-js-reader-v1/v2
pnpm-lock.yaml the same, in the importer's devDependencies block

The merge commit turns out to change nothing at all against the pre-merge tip: git diff HEAD^1 HEAD is empty, because main's content was already on the branch under different commits. So it links the histories and nothing else, and no .github/workflows/ blob is involved.

Verified by patch-content identity rather than by reading hunks: git diff origin/main...HEAD is line-for-line identical to git diff 2af4089^..2af4089, this PR's own commit. Nothing from either side was dropped, in flagged files or auto-merged ones, and the diff is non-empty so the PR is not a no-op against main.

pnpm install --frozen-lockfile accepts the resolved lockfile. sha256sum of the three, if you want to confirm you have the same bytes:

3026a62ca268324c51d3a2734576f46fbd95abe87627a245cdc1834fe99189fd  CLAUDE.md
340fd5b4b9b84306befc1cb73e689c8147600b7459b69a4586314095ccce6bf0  packages/pg-js/package.json
fa77e8964c235954bcdec93476c18e3156721a5aa83d3f73d498ece8dd0d8d88  pnpm-lock.yaml

The review comments

All three are in 96574e4, each answered on its own thread, all three resolved. In short:

  • typecheck runs both configs now, tsc -p tsconfig.json && tsc -p tsconfig.typecheck.json. Both of the probes in that comment reproduce, and both fail the combined script where they passed the single widened pass.
  • tests/email-attributes.test.ts narrows with Array.isArray for real instead of casting. One correction there: the cast was not a rename hole, a rename of AttrReq.t fails the old line too with TS2339. The change stands on the other reason, which is that the property access rested on an assertion the type system had not made.
  • CLAUDE.md now says the scripts include buys the tests/api-surface.test.ts import boundary, not checked script bodies.

Also worth knowing

pnpm build from the root fails on a fresh clone with Missing required environment variables: PKG_URL, CRYPTIFY_URL, POSTGUARD_WEBSITE_URL from apps/tb-addon/build.mjs. Only .env.example is committed, so the app needs a local .env before the root build works; copying the example verbatim is enough. Not in CLAUDE.md, and unrelated to this PR, so I left it out of the diff. Say the word and it goes in as a one-liner, here or separately.

@rubenhensen
rubenhensen merged commit 21732d9 into main Jul 30, 2026
29 checks passed
@rubenhensen
rubenhensen deleted the chore/typecheck-tests branch July 30, 2026 16:14
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.

1 participant