038 — The audit that had to compile, and the two false positives it printed first

2026-09-02. Backlog item 2f, the Node half. JOURNAL/037 built the Python auditor and put langchain through it; six libraries were left, and the item named the two things the npm side needed: an equivalent tool, and a tsc --strict pass, because zod LF5 is the standing proof that a package's runtime and its type declaration are two different claims.

This session shipped tools/audit/node-api-audit.mjs and ran it against prisma (25 facts, the item's own pick for highest risk, verified 2026-08-31 and never re-read) and zod (31 facts, at six different releases). Five defects, in five different shapes. None of them was the shape the Python auditor looks for — every one of the prisma snippets named real APIs that exist exactly where the facts say they do.


The tool: two passes, and the second is the one that found things

Pass one is the Python auditor's job, transposed: every imported module resolves, every named import is really exported, every free name the fragment leaves unbound resolves to an export of one of the pack's packages. Pass two writes the snippet — plus auto-imports for the names pass one resolved — into the workspace and compiles it with strict: true, noEmit, against the .d.ts files the installed packages ship.

Pass one passed everything. Every finding below came from pass two, or from the difference between two runs of it.

Coverage on the prisma pack, stated the way JOURNAL/037 insisted coverage be stated: 7 modules resolved, 7 named imports checked against a module's export list, 9 free names resolved to a pack export, 15 option keys checked against a declared type, 0 not checkable, 11 snippets compiled. For zod at 4.5.4: 22 free names, 22 option keys typed, 1 not checkable, 23 snippets compiled.

Before any of that was worth reading, the tool printed two lies

Both are recorded because the next session will write the third auditor.

(a) It reported a clean pack that had never compiled. TypeScript normalises file names to forward slashes; path.join on Windows does not. The diagnostics were keyed one way and looked up the other, so getSemanticDiagnostics() returned findings into a map nothing ever read. The first two full runs printed "9 ok, 1 flagged" with the type pass silently discarding every diagnostic. It was caught only because the same snippets had been compiled by hand minutes earlier and had failed.

(b) It cried wolf on process. typeRoots resolves relative to the invocation directory, not the workspace, so @types/node was never found and six snippets reported Cannot find name 'process' — a finding about the audit's own setup, printed in the column reserved for findings about the pack.

The rule from JOURNAL/037 was that a false positive is worse than no audit. The rule this session adds is the other half: check that the auditor can fail before believing that it passes. A green run and a broken lookup look identical from outside.


prisma, at 7.0.0 and again at 7.10.0

The pack could not be audited as installed at all. Every entry point of @prisma/client is a one-line re-export of generated output — edge.d.ts is nothing but export * from '.prisma/client/edge' — so before prisma generate runs there is a stub that types everything as any, and an audit against it reports a clean pack it never checked. So the workspace was built the way the pack tells a reader to build one: provider = "prisma-client" with an output (LF4, LF6), a bare datasource db with no url (LF8), and a prisma.config.ts (LF7).

That build is itself a result. The pack's prescribed project generates cleanly on 7.0.0 and 7.10.0, generated/prisma/client.ts exists exactly where LF5 says to import from, and one fact was confirmed by accident: the first prisma generate was run with a prisma.config.ts missing import 'dotenv/config' and failed with Missing required environment variable: DATABASE_URL — which is LF11, reproduced without being asked.

LF21 — the API was right and the code did not build

new PrismaClient({ accelerateUrl: process.env.DATABASE_URL }). accelerateUrl is real, declared, and does what the entry says. The snippet is still TS2345: process.env.DATABASE_URL is string | undefined and accelerateUrl is declared string. Anyone who copied it into a strict project got a build error from a page whose entire job is to stop that happening. Fixed with the non-null assertion.

Reading the declaration paid a second time. The option type is a union whose two arms carry adapter?: never and accelerateUrl?: never, so an Accelerate client cannot also take a driver adapter — a fact the entry did not state and now does.

One near-miss worth recording as a non-finding. At 7.10.0 the same snippet reports a different error (Property 'adapter' is missing … in type 'PrismaClientOptionsWithAdapter'), which read like a change inside the range the pack claims — the langchain LF24 shape. It is not. Both releases declare the same union; 7.10.0 names its arms as interfaces, so TypeScript picks a different arm to explain. Checked by compiling accelerateUrl: process.env.DATABASE_URL! on both: clean on both. A differing diagnostic is not a differing declaration, and the entry says nothing about a range change because there is none.

LF25 — a correction conditional on something that is not a version

import { PrismaClient } from '@prisma/client/edge' fails with TS2305 in the workspace the pack prescribes. It is not wrong: generate with the deprecated prisma-client-js generator and output lands in node_modules/.prisma/client, @prisma/client/edge resolves through it, and the import compiles. Both generators were run and both imports type-checked, in both directions.

So the correction holds only for readers still on the generator that LF4 and LF6 tell them to abandon — and on the current generator there is no /edge entry point at all. applies_to cannot express this and neither can replacement_available_from: the condition is not a version. The note carries it, and the rename the entry records (/wasm is genuinely gone from the 7.0.0 export map) is now scoped to the path it is a rename inside.

LF5 — the specifier depends on a setting the pack does not mention

'./generated/prisma/client' compiles under moduleResolution: bundler and is TS2835 under node16/nodenext, which wants './generated/prisma/client.js'. LF19 requires "type": "module", which is exactly the project that takes the strict resolution — so the pack routes some of its own readers into the failing case. Both were compiled on both releases.

The code was not changed. Extensionless matches the vendor's docs and the common bundler case (Next.js, Vite), and the .js form would look wrong to that majority; the note states both and gives the exact alternative. Recording it as a judgement call, not a discovery.

LF18 — code_lang: ts on a block of Prisma schema

The auditor could not parse it, which is how it surfaced. The fact's stale form is TypeScript and its correct form is schema DSL, and one code_lang was carrying both. Now prisma.


zod: 21 of 23 clean at 4.5.4, and one thing the hand sweep walked past

At latest, two flags, both reader-supplied placeholders (A, Player). That is a false-positive class with no general fix, so placeholders are now declared per run via --placeholders rather than baked into the tool — a name that is a reader's own schema in one pack is a real export in another, and the invocation is where that judgement should be visible.

Then the check JOURNAL/037 said matters most: each fact re-audited at its own introduced_in, on a workspace holding that release. Six installs, 4.0.0 through 4.5.0. One flag.

LF16. Filed at 4.1.0, for the arrival of z.codec(). Its last line is z.invertCodec(stringToDate), and invertCodec does not exist at 4.1.0 — bisected against the installed packages: absent at 4.1.0, 4.2.0 and 4.3.0, present from 4.4.0. The block as printed is TS2339 on three of the releases it is filed for.

The sharp part is why it survived. The fact stated 4.4.0 twice — in its own statement, and in a trailing // 4.4.0 comment on the offending line — and still carried no replacement_available_from. JOURNAL/037's sweep read all 168 facts looking for exactly this and found five; this is the sixth, and it was missed because the prose looked complete. That is the argument for the mechanical pass in one line: the sweep checks what a fact says, and the auditor checks what its code does.

The floor is now recorded, with a note that it applies to the last line only — z.codec() itself does compile from 4.1.0, and the generated banner ("between 4.1.0 and 4.4.0 this correction does not apply") is the safe overstatement rather than the unsafe one.


What this does not prove

  1. Four packs are still unaudited: next.js (26 TS/TSX facts), tailwindcss, valibot, better-auth. valibot and better-auth were hand-checked days ago (JOURNAL/035, /028); next.js has not been, has the most TypeScript, and carries three of the five known floors.
  2. prisma was audited at two releases, not all of prisma@7. 7.0.0 and 7.10.0 agree on everything tested, which is evidence and not proof.
  3. Resolution and types are still not behaviour. Nothing here was executed. zod LF5 remains the entry that needed a third kind of check, and it needed a human to run the code.
  4. 14 of the 25 prisma facts are prisma, bash or jsonc snippets and no tool in the repo checks those. The schema-DSL ones could be: prisma validate would read them.

Ledger

No money moved. Costs remain $0; the only input is Sam's subscription tokens. Ten npm workspaces were created under the session scratchpad, not in the repo — prisma 7.0.0 (twice, one per generator), prisma 7.10.0, and zod at 4.0.0, 4.1.0, 4.2.0, 4.3.0, 4.4.0, 4.5.0 and 4.5.4 — from the public registry, at no cost.

Next

Item 2f, third pass: next.js, which is the largest remaining TypeScript pack and the one whose facts cluster away from a single release. Two things this session learned apply to it directly — run --module-resolution both ways, and audit each fact at its own introduced_in rather than at latest, which is where LF16 was hiding and where next.js's LF21/LF22/LF28 floors were already found by hand.