Skip to content

test(qa): make the backward-compatibility gate's TYPE half read spec source, not spec dist - #8176

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-8021-downstream-contract-typecheck-source
Aug 12, 2026
Merged

test(qa): make the backward-compatibility gate's TYPE half read spec source, not spec dist#8176
hotlong merged 1 commit into
mainfrom
claude/issue-8021-downstream-contract-typecheck-source

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#8021

packages/qa/downstream-contract is the repo's backward-compatibility gate for @objectstack/spec (#2035), and its README gives typecheck a job the runtime suite cannot do: "a removed or NARROWED export fails here". It declared no paths, so under NodeNext tsc resolved @objectstack/spec through the workspace link and the exports map — dist/index.d.mts, a build artifact. The type half was rendering a verdict about the last pnpm build, exactly as the runtime half was before #7991 / PR #8129 aliased vitest to source.

The flip, measured — one variable moved

Identical checkout, identical stale dist (.build-input-hash cf8635aa…, unchanged and unrebuilt across both runs), a narrowing injected into source only in packages/spec/src/integration/connector.zod.ts:

- label: z.string().describe('Display label'),+ label: z.number().describe('TEMPORARY TYPE PROBE — was z.string()'),

The frozen DcConnector fixture is declared const DcConnector: Connector and assigns label: 'DC HubSpot', so with source types this is a type error.

Before — tsconfig.json exactly as on main:

> @objectstack/downstream-contract@0.0.38-rc.5 typecheck
> tsc --noEmit
EXIT=0

After — same tree, same probe, same dist; only the paths block added:

> @objectstack/downstream-contract@0.0.38-rc.5 typecheck
> tsc --noEmit
src/additional-domains.fixtures.ts(35,3): error TS2322: Type 'string' is not assignable to type 'number'.
Exit status 2

Line 35 is the fixture's label: 'DC HubSpot' — the injected field, named. Probe reverted; packages/spec/src/integration/connector.zod.ts verified byte-identical afterwards (sha256 61e58c5e72fc403d…, matching its pre-probe value), and git diff origin/main -- packages/spec/ is empty.

Turbo orders typecheck after ^build, so turbo run typecheck was never the failing path and needs no change. What breaks is every path turbo does not mediate: pnpm --filter … typecheck in the package, an editor's TS server, or an agent in a tree built at an older commit — i.e. exactly the paths this gate is re-read on while someone is changing the spec.

No genuine narrowing surfaced

With the fix applied and no probe, typecheck is clean (exit 0) and the suite is green — so this is not the "the repo has been shipping a break" case. The only errors the switch surfaced were mechanical consequences of putting spec source into this program, and both are fixed here rather than absorbed:

  • 2 × TS2591: Cannot find name 'process' in spec/src/shared/lazy-schema.ts — spec source is written against spec's own environment, so lib and types now mirror packages/spec/tsconfig.json. This keeps every red the gate produces attributable to the spec contract, which is the only thing it may be read as.
  • 247 × TS6059: … is not under 'rootDir'rootDir: "." emits nothing under noEmit but is still enforced, and it drowned the one error the gate exists to print. Removed.

The demonstration is permanent, not historical

Both halves of the pin live in test/source-resolution.pin.test.ts, the file that already owns this invariant.

Type axis, direct. A literal import type { ConversionNotice } from '@objectstack/spec/conversions'. That namespace exists in the source tree and is deliberately absent from spec's exports map, so tsc can reach it only through the subpath rule. It also corrects a comment in that file which said a literal specifier could not compile — true before this card, false after it.

Bare-entry rule, simulated. It has no type-level discriminator: measured, with the subpath rule kept and only the bare rule deleted, tsc stayed clean while src/stack.ts's defineStack types came from dist. The obvious candidate — an identity check between typeof defineConnector reached through both entries, the type-level twin of the existing toBe case — was tried and rejected on measurement: with the two entries on different trees, tsc had not finished comparing spec's zod-derived types after nine minutes (the same 18-self-contained-entry-bundles duplication #8133 records, on the type axis). So the bare rule is pinned the way check:test-source-alias pins the Vite side: simulate tsc's paths resolution and assert where it lands, over the specifier set scanned out of this package's own files plus every namespace spec publishes.

Reverse verification — four directions, each red, each naming the defect

Config mutationResult
paths block deleted entirely2 failed / 18 passed — @objectstack/spec falls through 'paths' to node resolution, i.e. to dist
bare-entry rule deleted1 failed / 19 passed — same message, bare specifier
subpath rule deleted2 failed / 18 passed, andtsc: test/source-resolution.pin.test.ts(16,39): error TS2307: Cannot find module '@objectstack/spec/conversions'
key respelled @objectstack/spec*1 failed / 19 passed — paths key '@objectstack/spec*' matches namespaces by prefix

Anchoring discipline (the PR #7778 constraint, in its tsconfig spelling)

One subpath rule for every namespace, never an enumeration — a hand-maintained list goes stale silently the first time a fixture reaches a new namespace, silently because the failure mode is a green typecheck. A tsconfig paths key without a * is an exact match, so the bare entry cannot swallow @objectstack/spec/ui the way a Vite object-form alias does. The tsconfig-shaped version of that mistake is spelling the key @objectstack/spec* — star not preceded by a slash — which is worse than the Vite trap: it does not crash with ENOTDIR, it type-checks the fixtures against the wrong module and stays green. The fourth pin case refuses that spelling.

Registry coupling

None to delete. check:test-source-alias reads vitest.config.* only, and #8129 already removed this package's runtime registry entry; no typecheck-side registry exists (searched scripts/ — the only match for this package anywhere is a prose mention in a comment). That absence is filed as a finding, see below.

Verification

  • pnpm --filter @objectstack/downstream-contract typecheck — clean, exit 0
  • pnpm --filter @objectstack/downstream-contract testTest Files 2 passed (2) / Tests 20 passed (20) (16 before, +4 pin cases)
  • pnpm check:test-source-alias — OK, self-test OK
  • node scripts/check-type-check-coverage.mjs --self-test — OK; structural run OK, headline unmoved (the ledger is audited for set equality in both directions, so an unchanged green is the "did not rise" proof; this package carries no DEBT/TEST_DEBT entry, and its typecheck already includes test/**/*, so the new pin cases are read by the very check they guard)
  • node scripts/check-nul-bytes.mjs — OK, plus a wider self-scan of both changed files for control bytes
  • eslint on the changed test file — clean

Changeset

None, deliberately. @objectstack/downstream-contract is private: true and never published, and no published package's code changes — so per the gate that reddened #8129, this PR releases nothing: no empty-frontmatter changeset (a real input to changesets/action, and an all-empty set stalls a release silently and greenly, #4898), and the skip-changeset label instead.


Generated by Claude Code

…source, not spec dist (#8021)
`packages/qa/downstream-contract` is the repo's backward-compatibility gate for
`@objectstack/spec` (#2035), and its README gives `typecheck` a job the runtime
suite cannot do: "a removed or NARROWED export fails here". It declared no
`paths`, so under NodeNext tsc resolved `@objectstack/spec` through the
workspace link and the `exports` map -- `dist/index.d.mts`, a build artifact.
The type half was rendering a verdict about the last `pnpm build`, exactly as
the runtime half was before #7991/#8129 aliased vitest to source.
Measured, one variable moved, identical checkout and identical stale `dist`,
with `label: z.string()` narrowed to `z.number()` in
`spec/src/integration/connector.zod.ts` in SOURCE ONLY and no rebuild:
without `paths` tsc --noEmit -> exit 0, CLEAN
with `paths` src/additional-domains.fixtures.ts(35,3): error TS2322:
Type 'string' is not assignable to type 'number'.
`lib`/`types` are consequences of the switch, not preferences: spec source is
written against spec's own environment, and without them the first run reported
two TS2591 `Cannot find name 'process'`. `rootDir: "."` was dropped for the same
reason -- it emits nothing under `noEmit` but still produced 247 TS6059, drowning
the one error the gate exists to print.
The demonstration is made permanent rather than historical. The type axis is
pinned directly by a literal `import type` from `@objectstack/spec/conversions`,
a namespace the published `exports` map does not carry, so it compiles only
through the subpath rule. The bare-entry rule has no type-level discriminator --
measured, with the subpath rule kept and only the bare rule deleted tsc stayed
CLEAN while `src/stack.ts`'s `defineStack` types came from `dist` -- so it is
pinned the way `check:test-source-alias` pins the Vite side: simulate the
resolution and assert where it lands. Reverse-verified in four directions; each
goes red naming the exact defect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 6:45pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

No hand-written docs reference the 1 changed package(s). ✅

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

PM review — domain:cli seat (#6024): accepted. Enqueueing once CI is green.

The flip, with one variable moved

Identical checkout, identical stale dist (.build-input-hash cf8635aa…, unchanged and unrebuilt across both runs), narrowing injected into source only:

BEFORE tsconfig as on main → EXIT=0
AFTER only the paths block → src/additional-domains.fixtures.ts(35,3): error TS2322

Line 35 is the fixture's label: 'DC HubSpot' — the injected field, named. Probe reverted, source verified byte-identical by sha256, git diff origin/main -- packages/spec/ empty. That is the same standard #8129 set on the runtime axis, met on the type axis.

The scoping observation is the best thing in this PR

Turbo orders typecheck after ^build, so turbo run typecheck was never the failing path. What breaks is every path turbo does not mediate: pnpm --filter … typecheck, an editor's TS server, or an agent in a tree built at an older commit — i.e. exactly the paths this gate is re-read on while someone is changing the spec.

That explains why the defect survived: it is invisible on the one path CI takes and live on every path a human or agent actually uses when they have reason to consult it. Nobody asked for that analysis and it is the answer to "how did this last this long."

Two errors fixed rather than absorbed, with the right reason given

  • TS2591 Cannot find name 'process' ×2lib/types now mirror packages/spec/tsconfig.json, because spec source is written against spec's own environment. The reason matters more than the fix: "This keeps every red the gate produces attributable to the spec contract, which is the only thing it may be read as." A gate that can emit unrelated reds gets its reds discounted.
  • TS6059 rootDir ×247 — enforced even under noEmit, and it drowned the one error the gate exists to print. 247 lines of noise around one signal is functionally the same as no signal.

You tried the elegant pin, measured that it does not terminate, and said so

The obvious type-axis discriminator for the bare-entry rule — an identity check between typeof defineConnector reached through both entries, the twin of the existing runtime toBe case — was attempted and rejected on measurement: tsc had not finished comparing spec's zod-derived types after nine minutes, the same 18-self-contained-entry-bundle duplication #8133 records, now shown to bite on the type axis too.

Reporting a rejected approach with the measurement that killed it is worth more than the pin that replaced it. The fallback — simulate tsc's paths resolution and assert where it lands, over the specifier set scanned from this package's own files plus every namespace spec publishes — is the same shape check:test-source-alias uses for the Vite side, so it is a known-good idiom rather than an invention.

And the honest framing on the bare rule: "it has no type-level discriminator: measured, with the subpath rule kept and only the bare rule deleted, tsc stayed clean while src/stack.ts's defineStack types came from dist." That is a stated limit, not a gap papered over.

The tsconfig spelling of the anchoring trap is worse than the Vite one

A tsconfig paths key without a * is an exact match, so the bare entry cannot swallow @objectstack/spec/ui the way a Vite object-form alias does. The tsconfig-shaped version of that mistake is @objectstack/spec* — star not preceded by a slash — which is worse than the Vite trap: it does not crash with ENOTDIR, it type-checks the fixtures against the wrong module and stays green.

Carrying this to the seat post alongside the /plugin-ordering catch from #8128. Both are the same shape: a misconfiguration whose failure mode is a passing check. In Vite you get ENOTDIR and find out; in tsconfig you get silence. The fourth reverse-verification case refuses that spelling explicitly, which is the only way it stays refused.

Four reverse directions, each red, each naming its own defect — including tsc: Cannot find module '@objectstack/spec/conversions' when the subpath rule goes. The type-axis pin uses a namespace that exists in source and is deliberately absent from spec's exports map, so tsc can only reach it through the rule under test. And it corrects a comment in that file that said a literal specifier could not compile — true before this card, false after.

Registry absence filed as a finding is the right call: there is no typecheck-side registry at all, so nothing is tracking which packages still resolve types through dist. That is the same 63-package blind spot #7991 closed on the runtime axis, with no equivalent inventory on this one.

Changeset correctly omitted with the skip-changeset label, citing the gate that reddened #8129 — the lesson from two hours ago applied without being restated.

Flipping ready and enabling auto-merge once CI converges — both steps.


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review August 12, 2026 18:59
@hotlong
hotlong added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit a453130Aug 12, 2026
29 checks passed
@hotlong
hotlong deleted the claude/issue-8021-downstream-contract-typecheck-source branch August 12, 2026 19:11
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gateteststooling

Projects

None yet

2 participants

@hotlong@claude