Skip to content

No gate asserts the TYPE axis of the source-resolution invariant: check:test-source-alias reads vitest.config.* only, so every package's tsconfig can resolve workspace deps through dist with nothing saying so #8180

Description

@hotlong

Filing unassigned; found while implementing #8021 (PR #8176), and deliberately not fixed there — that card's scope is one package's tsconfig, and a repo-wide gate is its own blast radius.

The asymmetry

#7849 / PR #7966 landed check:test-source-alias, which makes the source-resolution invariant mechanical rather than per-package goodwill: for every workspace package with tests, walk the imports reachable from those tests, keep the deps that resolve under dist/, simulate Vite's resolution over vitest.config.*, and demand that anything left is registered. Its own header states why a sweep is the wrong terminal state — "it leaves the NEXT package unguarded, and the symptom of the omission is a green test, so nothing would report the gap".

That reasoning is axis-independent, but the gate is not. It reads vitest.config.* and nothing else. No gate reads tsconfig.json, so the identical exposure on the type axis is unguarded repo-wide, and its symptom is likewise a green typecheck.

Measured, on the one package that has now been fixed

#8021 measured it end to end on packages/qa/downstream-contract. Identical checkout, identical stale dist, a narrowing injected into packages/spec/src/integration/connector.zod.ts in source only with no rebuild:

  • without a paths block: tsc --noEmit exit 0, clean
  • with a paths block: src/additional-domains.fixtures.ts(35,3): error TS2322: Type 'string' is not assignable to type 'number'.

That package is the repo's backward-compatibility gate, whose README promises "a removed or NARROWED export fails here" — so it was the loudest instance, which is why it got a card. It is not the only instance. The mechanism is generic and is in fact documented as the expected state in .github/workflows/lint.yml, beside the type-check-debt re-measure step:

tsc resolves workspace imports through each dependency's built dist/*.d.ts — so it needs the build steps above

Ordering does not reach it, same as both prior cards: turbo.json orders typecheck after ^build, so turbo run typecheck and CI are not the failing paths. What breaks is every path turbo does not mediate — pnpm --filter PKG typecheck inside a package, an editor's TS server, an agent in a tree built at an older commit. Those are exactly the paths a type contract is re-read on while someone is changing the dependency.

What a gate would have to get right (from #8021's measurements)

Not a copy of the vitest one — the resolution algorithm and the trap are both different:

  1. tsc's paths algorithm, not Vite's. An exact (star-free) key wins outright; otherwise the pattern key with the longest matching prefix wins and the captured text substitutes into the target's star. Vite's is first-match-wins with prefix matching for string finds.
  2. The trap has a different spelling and is worse. A tsconfig key without a * is an exact match, so the bare @objectstack/spec entry cannot swallow @objectstack/spec/ui the way a Vite object-form alias does (ENOTDIR). The tsconfig-shaped mistake is @objectstack/spec* — star not preceded by a slash — which matches every namespace and folds it onto whatever the target names. It does not crash: spec/src/index.ts re-exports most of the namespace surface, so it type-checks against the wrong module and stays green.
  3. Rules fail independently and silently. Measured on downstream-contract's OTHER half — its typecheck — still resolves @objectstack/spec types through dist, so a narrowed export type reads green (measured) #8021: with the subpath rule kept and only the bare-entry rule deleted, tsc stayed clean while src/stack.ts's defineStack types came from dist.
  4. tsconfig.json is JSONC. Whole-line comment stripping before JSON.parse, as scripts/check-type-check-coverage.mjs already does.
  5. extends chains. Unlike vitest.config.*, tsconfigs inherit; a gate must resolve extends before reading paths.
  6. The switch is not free, and the cost is not the gate's to hide. Putting a dependency's source into a consumer's program makes tsc check that source under the consumer's compilerOptions. On downstream-contract's OTHER half — its typecheck — still resolves @objectstack/spec types through dist, so a narrowed export type reads green (measured) #8021 that surfaced 2 TS2591 (spec source needs types: ["node"]) and 247 TS6059 from a rootDir that emits nothing under noEmit but is still enforced. So remediation is per-package and lands as its own card — the gate's job is to make the list of cards finite and unable to grow behind anyone's back, exactly as the vitest gate's header argues. A registry of the measured state, shrink-only and audited for set equality in both directions, is the shape that worked there.

Prior art (searched before filing)

Nothing open covers the type axis.


Generated by Claude Code

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions