Skip to content

test: classify test backends and split backup inventory by backend - #81

Merged
tnunamak merged 5 commits into
mainfrom
test/backend-classifier-prep
Sep 10, 2026
Merged

test: classify test backends and split backup inventory by backend#81
tnunamak merged 5 commits into
mainfrom
test/backend-classifier-prep

Conversation

@tnunamak

@tnunamak tnunamak commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

A test meant to run against Postgres can skip when no database is configured, and nothing notices. To schedule tests by the database they need, each file has to state that requirement, and the statement has to be checked.

This adds the checker and the runtime guard, and splits one file as the first real case. Nothing is wired into CI yet and no repository-wide manifest ships here.

scripts/check-test-backends.ts compares a manifest listing each test file with its backend (none, sqlite, postgres, or sqlite+postgres) against the files that exist, and fails on a missing, stale, duplicate, or unknown entry. An entry claiming no database is checked against the file's own imports and rejected if it imports one.

scripts/test-unit-preload.mjs guards the unit lane. It rejects any load of pg, better-sqlite3, node:sqlite, or the repository's storage modules, identified by resolved real path or canonical builtin name rather than by spelling, so every form is covered: bare name, subpath, relative or absolute path, file URL, dynamic import, createRequire, a pre-resolved path, and process.getBuiltinModule under a literal or computed name. A test that catches the error still fails the lane. Not covered: a copy of a driver at a different path, which is a different file.

test/backup-table-inventory.test.ts held eleven tests with three different requirements. It is now three files: five that read only source and config, three on SQLite, three on Postgres. Names and assertions are unchanged.

Verify from reference-implementation/: node --test --import tsx scripts/check-test-backends.test.ts scripts/test-unit-preload.test.ts test/backup-table-inventory*.test.ts. The Postgres file runs with PDPP_TEST_POSTGRES_URL set.

Not covered: an import reached three modules deep without any denied form on the executed path. The guard fires on what actually loads, not on what a branch might load.

Assisted-by: AI

A reference-implementation test entry's backend requirement is implicit
today: it lives in whichever fixtures the file happens to import, so the
only way to learn whether a file needs Postgres, SQLite or no database
at all is to run it and watch what it connects to. Scheduling cannot see
that, which is why a Postgres case that silently skipped looks the same
as one that ran.

Add the two halves of a checkable answer, both inert until something
wires them up:

check-test-backends.ts declares the obligation in a manifest and
independently enumerates the tracked test entries, requiring exact set
equality -- missing, stale, duplicate and unknown-backend entries all
fail. The manifest is an array rather than a path-keyed map because a
map cannot represent a duplicate: a second entry for the same path
overwrites the first during parsing, so the check would never see it.
An entry claiming it needs no database is checked against its own
imports and rejected if it reaches storage, with no override.

test-unit-preload.mjs is the runtime counterpart, following the existing
scripts/hermetic/preload.ts wiring. Static analysis cannot see a
computed specifier or generated code, so this hook watches what the
process actually resolves. It rejects at resolve, before any module
evaluates, so an admissible test observes nothing different. Violations
are recorded outside the thrown error and forced onto the exit code,
which is what stops a file from swallowing its own denial in a
try/catch and reporting green.

Denial is per specifier form, not per file. A rule matching
"pg/lib/client" but not the bare "pg" reports zero violations on a file
that plainly imports Postgres -- a silent false pass, and the worst
thing a guard like this could do. Bare package, subpath and both
builtin spellings are matched, with a separator boundary so "pgvector"
is not mistaken for "pg". The classifier also recognises
createRequire(...)(...) because server/db.ts reaches better-sqlite3
that way and a scan for `require(` alone would miss it.

No production manifest ships here. Classifying the full inventory is
separate work, and set equality against a partial list would not be a
meaningful check.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
backup-table-inventory.test.ts mixed three backend requirements in one
file: five cases read only source files, docs and static policy exports,
three bootstrap a real SQLite database, and three need a live Postgres
server. Because the file imports server/db.ts and server/postgres-storage.ts
at the top level, every case carried every requirement, and the eight
that need no Postgres could not be scheduled apart from the three that do.

Split it into three files along the backend each case actually uses. All
eleven case names and their assertions are unchanged; the split moves
bodies between files and nothing else. The database imports and fixtures
travelled with the cases that use them, which is what lets the remaining
file run with no database at all.

The Postgres cases keep their existing skip-when-unconfigured guards.
Turning those skips into failures belongs to whichever change makes
Postgres a required backend, not to a split that is meant to preserve
behaviour.

Verified by running all three files: five plus three plus three cases
pass with no skips, the Postgres file against a real pgvector server.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
The storage guard for database-free tests matched the raw import specifier
only, so a test could resolve a driver itself and import the resulting
location:

    const url = pathToFileURL(createRequire(import.meta.url).resolve("pg")).href;
    await import(url);

No denied package name is spelled as a specifier there, so the guard did not
fire. Executed against the real driver, that ran to completion and exited 0
with PostgreSQL loaded -- the guard's one unacceptable failure, a silent pass
for a test that reached a database.

A denied driver is now also matched on its resolved package identity: the
`node_modules/<pkg>/` segment its resolved path must contain. The trailing
separator keeps unrelated packages whose names merely begin with a denied
name, such as pgvector and pg-boss, resolving as before. Builtins have no
package directory and stay with the specifier rule, which is exact for them.

The static checker had the same hole from the other side. It read import,
require and createRequire calls but not `resolve`, so the file above appeared
to have no storage dependency at all. Naming a denied driver in `resolve` now
counts as reaching it, since resolving a driver has no other purpose.

Also make the checker fail closed when run as a command. It exported `main`
but never invoked it, so `node scripts/check-test-backends.ts <bad-manifest>`
exited 0 and printed nothing while the exported function rejected the same
manifest -- a checker that passes silently when asked to check. A missing
argument now exits 2 rather than doing nothing.

Tests cover each denied route as a real child process asserting the actual
exit code, because the defect was an exit-0 run that loaded the driver and no
in-process assertion would have caught it: pre-resolved file URL, pre-resolved
absolute path, createRequire, require by resolved path, and each of those with
the error swallowed in a catch. Controls prove the rules are not overbroad: a
clean unit test still passes, sqlite-vec still loads through its own
node_modules path, and the checker still accepts a manifest covering every
tracked test entry. Removing either fix turns the corresponding controls red.

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
… access

A test could execute real SQL with the guard enabled:

    const { DatabaseSync } = process.getBuiltinModule("node:sqlite");
    new DatabaseSync(":memory:").prepare("select 42").get();

That printed a query result and exited 0. `process.getBuiltinModule` returns a
builtin without consulting module resolution, so the resolve hook never saw
it. Wrapping that function closes the route, and because the check runs on the
argument's runtime value it covers a computed name -- `"node:" + "sqlite"` --
which no scan of source text could.

The CJS `require("node:sqlite")` path needed nothing: `registerHooks`
intercepts CJS resolution too, so the resolve hook already denied it. Verified
by disabling the new function and re-probing that route, which still fails.
A second wrap of `Module._load` would have been dead code around a Node
internal, so it is not here.

The deeper problem was the shape of the rule rather than any single missing
form. Each round of review found one more spelling because the rule enumerated
spellings. It now states the invariant instead: a target is denied by WHAT IT
IS, whatever named it.

  on disk   the resolved real path, symlinks resolved, of the driver's own
            installed package root or of a denied storage module
  builtin   the canonical `node:` name, normalised from the runtime value

This also replaces the `node_modules/<pkg>/` substring test, which was an
installation-layout guess rather than proof of identity: it would have called
any file under a directory of that name a driver, and missed a driver
installed elsewhere. Comparing against the root that resolution itself reports
removes the guesswork, and the boundary that keeps pgvector and pg-boss
loading is now a real directory boundary rather than a string coincidence.

The static checker reads the literal `getBuiltinModule` form for the same
reason it reads the other loader routes; a computed name remains outside what
source reading can recover, and is recorded as such next to the existing
computed-specifier note.

What the boundary does not cover is now stated in the source rather than
implied: a copy of a driver at a different real path is a different file, and
identifying it would need content fingerprinting. Storage reached over a
socket by hand-rolled protocol code, and any unexecuted branch, are likewise
outside it.

Controls per form, each a real child process asserting the actual exit code:
literal builtin name with a real query, computed builtin name, the denial
caught and the body completed, and the builtin via createRequire. Against
those, controls that must keep working: node:path and node:fs through both
routes, the real installed sqlite-vec package, a clean unit test, and a path
that merely contains a denied package name. Disabling the builtin guard turns
three of the new controls red; the identity tests run against the real
installed drivers rather than synthetic path strings.

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
…se positives

The Postgres/SQLite backup-inventory split left the new Postgres file
unclassified in the template-eligibility registry and the old combined
file stale there, failing both inventory gate tests. The zero-
connector-knowledge conformance gate also flagged the classifier CLI's
own manifest-path argument as an unresolvable data load; it is an
operator-supplied test-backend manifest, never connector/provider
identity, so it is allowlisted the same way deploy-canary.ts's
--manifest argument already is. Also fixes a stale preload comment
that claimed a CJS require wrap exists for builtins; it does not, and
the doc explaining why (registerHooks already covers CJS resolution)
was already documented in installBuiltinGuard's own comment.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
@tnunamak
tnunamak merged commit 70e7f20 into main Sep 10, 2026
14 checks passed
@tnunamak
tnunamak deleted the test/backend-classifier-prep branch September 10, 2026 06:34
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