Skip to content

Gate every in-repo isMissingTableError call on naming the object it read - #14346

Merged
os-trump merged 4 commits into
mainfrom
claude/issue-13440-missing-table-arg-gate
Sep 2, 2026
Merged

Gate every in-repo isMissingTableError call on naming the object it read#14346
os-trump merged 4 commits into
mainfrom
claude/issue-13440-missing-table-arg-gate

Conversation

@os-trump

@os-trump os-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes #13440

What this closes

#13324 repaired isMissingTableError by giving it the object the caller was reading, so a driver fault naming a different relation can no longer be answered "this table is not provisioned yet". PR #13437 shipped that as an optional parameter, and optional was right: @objectstack/types is published (17.2.0, exports . and ./node) and re-exported again from @objectstack/metadata/errors, so a required parameter is a breaking change needing a major bump.

That left the in-repo half of the obligation stated only in JSDoc. isMissingTableError(err) still compiles, still type-checks, and still returns the pre-#13324 wide verdict — silently. On the authz path (packages/core/src/security/resolve-authz-context.ts:237) that verdict resolves a permission-store outage to [] permissions instead of failing loud, so the omission fails in the open direction. Prose is exactly what #13324 existed to prove insufficient.

This adds the enforcement half as an in-repo gate, which buys it without the major bump: it binds only callers inside this repository, and external consumers keep the optional form the published API promises them. The published signature is unchanged.

The mechanism

packages/types/src/driver-error-classification.callers.test.ts walks every .ts source under packages/ with the TypeScript compiler API, collects every CallExpression whose callee resolves to isMissingTableError, and fails naming each site file:line:col with the remedy. It flags two shapes, not one:

  • fewer than two arguments — the argument omitted outright;
  • a second argument written undefined / null / void 0 — the read object explicitly discarded, which passes an arity check and means the same wide verdict.

A line-oriented regex was not used and would not have been a census: calls span lines, and the callee appears both bare and as a property access.

The exemption axis, and why it is exactly this narrow

driver-error-classification.test.ts calls the one-argument form 30 times on purpose — those are the tests of the optional form, pinning that isMissingTableError(err) still behaves for the external consumers the optional parameter protects. A gate written to the naive rule would fail all 30, and the obvious "fix" of passing them a read object would delete the coverage of the published one-argument contract.

So the exemption is the defining package's own contract tests and nothing else: packages/types/src/driver-error-classification*.test.ts. Everything else under packages/ — production and test code alike — must pass the read object. The defining module itself is deliberately not exempt: the predicate delegates to matchesDriverError and never calls itself, so a self-referential one-argument call there would be a new fact worth failing on.

Measured population, on f60061a460

population count
.ts call sites scanned under packages/ 58
passing the read object (2+ args) 28
one-argument, all inside the exempt contract tests 30
one-argument outside the exemption (the violation set) 0
renamed import bindings of the symbol 0
calls in apps/, examples/, e2e/, scripts/ 0

The production sites confirmed passing the argument: resolve-authz-context.ts:237, metadata-protocol/src/protocol.ts x5, seed-loader.ts x2, sys-metadata-repository.ts, metadata/src/loaders/database-loader.ts x2, objectql/src/engine.ts x5, lifecycle-service.ts x2 — 18 across 4 packages (core, metadata, metadata-protocol, objectql). Two corrections to the card's own numbers, both re-measured here: protocol.ts has 5 such calls, not the 4 the R64 note lists; and the count is 18, not 17.

The extension boundary — .ts only, and why that is not a detail

The first shape of this PR scanned .ts / .tsx / .mts / .cts and declared globs: ['packages/**']. That reddened CI, and the failure is worth recording rather than just fixing.

A package's entry in CROSS_PACKAGE_TEST_INPUTS is inherited as watch hints by check:cross-package-test-inputs. The dispatch-gates self-test pins that no hint of that family reaches the realtime-hooks.test.tsx file in packages/client-react — its live specimen for "a test class the hint route cannot reach in principle". packages/** was the only entry in the whole table that covered a .tsx file, so it turned that case red:

✗ nor a .tsx test file inside it        (scripts/pm/dispatch-gates.mjs)
   1 of 1232 case(s) failed

That is a real red, not a nuisance: the specimen is how that tool proves its residue classes are non-empty. So the fix narrows both sides together, which is the only honest way to move either — the scanner now takes .ts alone through a named SOURCE_FILE predicate, and the declared glob and turbo inputs are packages/**/*.ts to match. Measured under packages/ on f60061a460:

extension tracked mention the predicate
.ts 5193 48 the scanned set
.tsx 8 0 excluded
.mts 17 0 excluded
.cts 0 0 excluded

The narrowing therefore loses no call site. Two pins keep that from decaying into a comment: the extension set is asserted directly (pure predicate, no I/O, so it adds nothing to the declared radius), and a filename-only walk asserts .tsx files really do exist under packages/, so the exclusion stays a decision about a real population rather than a statement about nothing. The filename-only spelling is deliberate — opening those files would create a content dependence on files outside the declared glob, which is the very #7802 shape the declaration table exists to prevent.

.mts and .cts are excluded for a second, independent reason: globHolderVerdict requires every declared glob to be rostered or witnessed, and this package's roster is exactly one literal. packages/**/*.mts matches nothing in it, so declaring it would red the gate as a glob nothing holds.

Why the green is not vacuous

A scanner that silently stops matching yields the same empty violation set as a clean repo, and the assertion cannot tell them apart. Each control was proven red by ablation — mutate, prove the mutation landed on disk by counting both the removed and the injected text, run, restore, prove the restore by comparing bytes against the HEAD blob:

leg mutation result
omitted argument isMissingTableError(err, object) to isMissingTableError(err) in resolve-authz-context.ts exit 1 · named packages/core/src/security/resolve-authz-context.ts:237:9 isMissingTableError(err)
discarded argument isMissingTableError(error, this.tableName) to isMissingTableError(error, undefined) in database-loader.ts exit 1 · named packages/metadata/src/loaders/database-loader.ts:803:9 isMissingTableError(error, undefined)
exemption ablated EXEMPT regex replaced with one that never matches exit 1 · 30 call site(s) of isMissingTableError
walk confined to the defining package SCANNED_TREE pointed at packages/types/src exit 1 · failing test: "the walk reaches packages other than the defining one"

Every leg restored to its exact HEAD blob hash with git diff HEAD empty. The ablation script carried a trap ... EXIT INT TERM restore on absolute paths and refused to report an exit code unless vitest had actually run — the first attempt returned 127 from a bad invocation and was rejected as measuring nothing rather than read as a red gate, and a later leg's substitution silently failed to land and was caught by the on-disk proof rather than by an exit code.

The gate needs no build: it reads source text and touches no dist/.

Deviation from the dispatched file surface

The card's surface was packages/types/ only. The commit also touches scripts/cross-package-test-inputs.mjs (one table entry) and turbo.json (one task). That is this repo's own mandatory registration for any test that reads outside its package, not scope drift. check:cross-package-test-inputs refused the new test until it was declared, in its own words:

- @objectstack/types has test(s) that read outside the package but declares no input radius.
    packages/types/src/driver-error-classification.callers.test.ts
  Add an entry to CROSS_PACKAGE_TEST_INPUTS in scripts/cross-package-test-inputs.mjs
  with the repo-relative globs those tests read, then run this gate again
  for the turbo.json inputs it requires.

No gate logic was authored or edited; nothing landed in packages/lint, no new root scripts/ gate, and scripts/pm/dispatch-gates.mjs was not touched. The gate now reports:

OK: 25 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.

coversDirectory turned out not to apply here, which is why a packages/**-class glob is not needed. packages/ has zero direct files, so coversDirectory('packages', ...) would be false for any glob set — but it is never asked: the roster's dirEntries is empty, because the recursive walk descends on a loop variable and yields no name. The roster holds exactly one literal, packages/core/src/security/resolve-authz-context.ts, and packages/**/*.ts both covers it and is held by it.

The deviation was actively kept to those two files. An earlier draft anchored the repo root on a workspace marker file, which named a root-level path; that forced a third and fourth file — an entry in .github/workflows/ci.yml's crosspkg: filter, and two hardcoded self-test counts in scripts/check-ci-filter-parity.mjs. Re-anchoring the repo root by arithmetic off this package's own manifest keeps the whole declared radius inside packages/, which ci.yml's core: filter already covers, so the cascade disappears and check-ci-filter-parity.mjs stays green with no ci.yml change. The arithmetic is not trusted on faith: the anchor test requires the walk to find this package's own defining module, which no wrong root can satisfy.

The comment registrar, twice

That registrar collects quoted whole paths out of comments, without parsing — so a path merely named in prose becomes a declared input. It bit this PR twice, and both are now written in halves:

  1. naming check-cross-package-test-inputs.mjs by full path put scripts/ on this package's roster;
  2. naming the .tsx specimen by full path put it on the roster and demanded the very .tsx glob this change exists to remove.

Both files carry a short note saying why the spelling is split, so the next author does not "tidy" them back.

Not chosen

Making the parameter required (the card's option B). @objectstack/types is published, so that is a major bump and a maintainer's decision; it stays available as a follow-up and is not foreclosed by this gate.

Verification

Head 69c4226d64, tree clean. f60061a460 (the previous head) is green on CI; 69c4226d64 adds only a comment correction on top of it.

  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on the actual change set: 35 families, identical set at both heads. All 35 run: 34 exit 0.
  • The one exception is node scripts/check-test-completeness.mjs, exit 3, that gate's own NOT MEASURED code when invoked with no log argument ("It is not a red, and there is nothing here to fix"). CI tees it a turbo run test log.
  • pnpm check:pm-dispatch-gates — the gate this patch round fixes — 1232 of 1232 cases pass, exit 0, with ✓ nor a .tsx test file inside it. Run on the merged tree; it exceeds the 10-minute foreground limit, so it was run to completion and polled rather than backgrounded and abandoned.
  • pnpm lint (the full repo-wide eslint . --no-inline-config, not a narrowed run): exit 0, re-run at the final head.
  • pnpm --filter @objectstack/types test: 17 files, 504 tests passed (502 plus the two boundary pins).
  • packages/types tsc --noEmit: exit 0, and --listFiles confirms the new test file is inside that program. An earlier reading showed 19 errors and was correctly discarded as NOT MEASURED — all 19 were TS2307 in other files, the unbuilt-closure cascade, and it went to 0 once @objectstack/types^... was built.
  • check:type-check-debt and check:dual-build-cjs-loads were refused as PREREQUISITE NOT MET until the closure was built; after turbo run build (70/70) both are exit 0.
  • Every gate exit code captured by redirecting to a file first, never read through a pipe.

Changeset

skip-changeset: nothing published changes. The new file is a test — packages/types publishes files: ["dist", "README.md", "CHANGELOG.md"] and tsup's entries are src/index.ts and src/node.ts, neither of which reaches it — and the only edit to shipped source is a JSDoc comment. The other two files are CI-internal build orchestration. check:published-files is green.


🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

…e object it read

#13324 gave `isMissingTableError` a `readObject` argument so a driver fault
naming a different relation can no longer be answered "this table is not
provisioned yet". The parameter had to ship optional -- `@objectstack/types` is
published (17.2.0) and re-exported from `@objectstack/metadata/errors`, so a
required parameter is a major bump -- which left the in-repo obligation stated
only in JSDoc. Prose is exactly what #13324 proved insufficient: a caller that
omits the argument silently receives the pre-#13324 wide verdict, and on the
authz path that resolves a permission-store outage to `[]` permissions.

`driver-error-classification.callers.test.ts` walks every TypeScript source
under `packages/` with the TypeScript compiler API and fails any call of the
predicate that omits `readObject` or passes it as `undefined`/`null`, naming
each site file:line with the remedy. The only exemption is this module's own
contract tests, which exercise the published one-argument form on purpose.

Two positive controls keep an empty violation set from being indistinguishable
from a broken scanner: with the exemption disabled the defining test file must
yield a substantial one-argument population (30 today), and the two-argument
population outside `packages/types` must be substantial (18 today, across 5
packages). A third check fails if a renamed import binding appears, since the
callee matcher is by name.

Registration, required by check:cross-package-test-inputs for any test that
reads outside its own package: one entry in the declaration table and one
turbo task, both scoped to `packages/**`. The repo root is reached by
arithmetic off this package's manifest rather than by a marker-file walk
precisely to keep that radius inside `packages/**`, which ci.yml's `core:`
filter already covers -- so no scheduler change is needed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions github-actions Bot added the size/m label Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 1 changed file(s) yielded no anchor (packages/types/src/driver-error-classification.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/types/src/driver-error-classification.ts) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 9c7d9d4b343f5ded5b51b757cb4251d1f68cd378packageMentionDocs.

…esidue specimen

The first shape declared `globs: ['packages/**']`. That entry's globs are
inherited as watch hints by `check:cross-package-test-inputs`, and it was the
only entry in the table covering a `.tsx` file, which turned one
dispatch-gates self-test case red: it pins that no hint of that family reaches
the `realtime-hooks.test.tsx` specimen in `packages/client-react` -- the live
member of "a test class the hint route cannot reach".

Narrowed on both sides at once, which is the only honest way to move either:
the scanner now takes `.ts` alone via a named `SOURCE_FILE` predicate, and the
declaration and turbo inputs are `packages/**/*.ts` to match. Measured under
`packages/` on this commit: 5181 `.ts` tracked and 48 mention the predicate;
`.tsx` 8 tracked / 0 mention; `.mts` 17 / 0; `.cts` 0 / 0 -- so the narrowing
loses no call site today.

`coversDirectory` never applied here: the roster for this package holds exactly
one literal and no directory entry, so `packages/**/*.ts` both covers the
roster and is held by it.

Two pins keep the boundary from decaying into a comment: the extension set is
asserted directly (pure predicate, no I/O, so it adds nothing to the declared
radius), and a filename-only walk asserts `.tsx` files really exist under
`packages/`, so the exclusion stays a decision about a real population. Both
the header and the table entry name that specimen file in two halves, because
the registrar collects quoted whole paths out of comments -- spelling it in
full put it on this package's roster and demanded the very `.tsx` glob the
change exists to avoid.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
The `.ts` total in that header was measured before this branch merged
`origin/main` and read 5181; the tree it now describes has 5193. Only the
three ZEROS in that table are load-bearing -- they are what makes "narrowing
to `.ts` loses no call site" true -- and the total moves with every merge, so
the reading now names the commit it belongs to rather than implying "now".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants