Skip to content

feat(devx): gate the TYPE axis of the source-resolution invariant - #8248

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-8180-type-axis-source-resolution-gate
Aug 12, 2026
Merged

feat(devx): gate the TYPE axis of the source-resolution invariant#8248
hotlong merged 1 commit into
mainfrom
claude/issue-8180-type-axis-source-resolution-gate

Conversation

@hotlong

@hotlong hotlong commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #8180

check:test-source-alias made the source-resolution invariant mechanical on the runtime axis. It reads vitest.config.* and nothing else, so the identical exposure on the type axis was unguarded repo-wide — and its symptom is likewise a green typecheck.

This adds check:type-source-resolution, wired into the lint job beside its runtime sibling.

What it checks

For every workspace package with a tsconfig.json:

  1. Determine the files tsc puts in the program (files / include / exclude, with TS's defaults).
  2. Collect the workspace packages those files import — type-only imports included.
  3. Keep the deps whose declarations resolve under dist/.
  4. Resolve each specifier through the effective paths using tsc's algorithm, and demand that anything still landing on an artifact be registered in KNOWN_DIST_RESOLVED_TYPE_IMPORTS.

Specifiers are judged individually, never "does a paths block exist" — a block covering eight of nine specifiers is eight-ninths of a verdict, and the ninth is silent.

Deliberately not a port of the runtime gate

Four things differ, and each is a way a copied gate would be wrong:

runtime gate (Vite) this gate (tsc)
algorithm first match wins, prefix match exact star-free key wins outright; else longest matching prefix, order-independent
type-only imports erased before resolution ⇒ not counted exactly what tsc resolves ⇒ counted
config format plain TS module JSONC (55 of 76 carry comments) that inherits via extends (66 of 76), compilerOptions merging shallowly so a child's paths replaces the parent's
the trap bare object key swallows a subpath ⇒ ENOTDIR, loud, at run time @objectstack/spec* — star not after a separator — folds the bare name, every namespace and every prefix-sharing sibling onto one target, does not crash, and type-checks green against the wrong module

Also refused: a paths target that does not exist on disk. That is not a rule — tsc falls back to node resolution, i.e. to dist, silently — so it reads as coverage while providing none.

The registry is large, and that is the point

51 of 76 packages, 222 package-dependency pairs, 25 clean. Exactly one package declares a paths rule for a workspace dep today, so this gate finds no new offender. Its entire value is the ratchet: the remediation list is now finite, audited for set equality in both directions, and unable to grow behind anyone's back. Stating the number plainly so a large registry is not read as a large finding.

Remediation stays out of this PR by the card's own scope. It is not free: putting a dependency's source into a consumer's program makes tsc check that source under the consumer's compilerOptions, which on one package surfaced 2 TS2591 and 247 TS6059. No package tsconfig.json is touched here.

Verification

--self-test covers 16 fixtures. Because every reader of a resolution config this repo has written has been wrong at least once — always by validating against the spelling its author had in mind rather than the one the repo uses — each case pins the correct form as well as the wrong one.

The self-test passed on its first run, so it was mutation-tested to show it is not vacuous. Six mutations, each caught by its named assertion:

mutation assertion that fired
star-trap detector removed the `@pkg*` trap (star not preceded by a separator) was NOT flagged
Vite's algorithm substituted an EXACT key declared after a matching pattern key did not win, the LONGEST matching prefix did not winplus false reds on four compliant fixtures
type-only imports filtered a TYPE-ONLY import of a dist-resolving dep was not counted
JSONC stripping removed a CORRECT config was reported because its comments were not stripped
extends resolution removed a correct `paths` block inherited through `extends` was not seen
missing-target check removed the missing-target diagnostic did not say the target is missing

Reverse verification on real packages, direction predicted before running, every mutation restored (git status clean afterwards):

experiment result
downstream-contract: bare-entry rule deleted, subpath rule kept red, naming @objectstack/spec — the configuration tsc itself reports clean on
downstream-contract: subpath rule deleted, bare rule kept red, same dep — the two rules fail independently
downstream-contract: both rules replaced by @objectstack/spec* red on the trap, while every specifier still lands under src/ — a gate asking only "did it reach source" certifies this config
packages/core: a rule added for @objectstack/spec only entry narrowed, not cleared: Narrow the entry to exactly: ["@objectstack/metadata-core"]

The one package remediated on the type axis before this PR — packages/qa/downstream-contract — is the one package the gate reads as clean. That is the false-positive check on real data.

Cost and placement: full run is 1.2s and needs neither node_modules nor a build (verified: it was the only gate that passed in the fresh worktree before pnpm install). It is placed in the lint job, which has no build step — the same job as its runtime sibling.

Gates run locally

check:nul-bytes, check:changeset-gate-self-tests, check:filter-alias-parity, check:node-version, check:required-contexts, check:shard-attestation, check:workflow-status-functions, check:type-check-coverage, check:type-source-resolution, scripts/check-changeset-no-major.mjs — all green. The list is the re-derivation from scripts/pm/dispatch-gates.mjs against the actual changed paths.

check:type-check-debt was derived but not run locally: it refuses to measure without a built workspace closure (its own guard). No package source or tsconfig.json is touched here, so no ledger number can move; CI builds the closure before that step.

Releases nothing

Root tooling only — scripts/, a root package.json script, and lint.yml. No published package changes, so this takes the skip-changeset label rather than a changeset.


Generated by Claude Code

)

`check:test-source-alias` makes the source-resolution invariant mechanical
on the runtime axis, but it reads `vitest.config.*` and nothing else — so
the identical exposure on the type axis was unguarded repo-wide, and its
symptom is likewise a green `typecheck`.

`check:type-source-resolution` closes it: for every workspace package with
a `tsconfig.json`, determine the files tsc puts in the program, collect the
workspace deps they import (type-only imports included — the inversion of
the runtime gate's rule), keep the ones whose declarations resolve under
`dist/`, resolve each specifier through the effective `paths`, and demand
that anything still landing on an artifact be registered.

Deliberately not a port of the runtime gate. tsc's `paths` algorithm is
exact-key-wins then longest-matching-prefix; Vite's is first-match-wins,
and a resolver that used it reads correct configs as wrong. `tsconfig.json`
is JSONC (55 of 76 carry comments) and inherits through `extends` (66 of
76), with `compilerOptions` merging shallowly so a child's `paths` replaces
the parent's. The trap is different and worse: `@objectstack/spec*`, star
not after a separator, folds the bare name, every namespace and every
prefix-sharing sibling onto one target — and does not crash, it type-checks
green against the wrong module.

Also refused: a `paths` target that does not exist, which is not a rule at
all — tsc falls back to node resolution silently, so it reads as coverage
while providing none.

The registry is the measured state: 51 of 76 packages, 222 package-dep
pairs, 25 clean. It finds no new offender today. Its value is the ratchet —
the remediation list is now finite, audited for set equality in both
directions, and unable to grow behind anyone's back. Remediation is
per-package and stays out of this change: putting a dep's source into a
consumer's program makes tsc check that source under the consumer's
`compilerOptions`, which on one package surfaced 2 TS2591 and 247 TS6059.

No package `tsconfig.json` is touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018JWibNopHc8HYM18SSVqVA
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 12, 2026 10:57pm

Request Review

@github-actions github-actions Bot added size/xl ci/cd dependencies Pull requests that update a dependency file labels Aug 12, 2026
@hotlong hotlong added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed ci/cd dependencies Pull requests that update a dependency file labels Aug 12, 2026 — with Claude
@hotlong
hotlong marked this pull request as ready for review August 12, 2026 23:24
@hotlong
hotlong added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 53c0405 Aug 12, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-8180-type-axis-source-resolution-gate branch August 12, 2026 23:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants