Skip to content

ci(test-alias): gate that a unit test judges source, not a sibling's dist - #7966

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-7849-stale-dist-alias-gate
Aug 12, 2026
Merged

ci(test-alias): gate that a unit test judges source, not a sibling's dist#7966
hotlong merged 1 commit into
mainfrom
claude/issue-7849-stale-dist-alias-gate

Conversation

@hotlong

@hotlong hotlong commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Part of #7849 — this lands the gate and the registry. Per the dispatch scope ruling it edits zero package files, so the per-package remediation the registry now sizes stays open as follow-up work.

What lands

scripts/check-test-source-alias.mjs (+ check:test-source-alias, wired as a step of the existing ESLint job in lint.yml, so no new required context).

For every workspace package that has test files, the gate:

  1. walks the imports reachable from its tests — the test files plus every intra-package file they pull in transitively. import type is dropped: it is erased before anything resolves, so it cannot read a stale artifact.
  2. keeps only the deps that can actually go stale — the dep's own entry point resolves under dist/. A dep whose exports already point at source (the example apps' objectstack.config.ts) is not an artifact, and counting it would be a false positive the registry then carries forever.
  3. resolves each surviving specifier through the package's vitest aliases the way Vite does — entries in order, first match wins, string find by prefix and regex find through String.replace. A dep whose winning entry lands under src/ is safe.
  4. requires anything left over to be registered, with exactly that set.

Because step 3 performs the real replacement, the gate also sees the ENOTDIR trap PR #7778 documented: an object-form alias key matching by prefix resolves @objectstack/core/logger to …/core/src/index.ts/logger. Any specifier whose resolution runs through a file extension fails, naming the anchored array-form entry to write instead. No package trips this today; it is pinned by fixture.

Why the gate, not the sweep — demonstrated on this repo

The loud half of the hazard is #7668. The half worth defending against is silent, and I reproduced it here before writing anything.

packages/qa/downstream-contract is the repo's frozen third-party fixture: its own README says a failure means a spec change narrowed something a published-spec consumer already uses, i.e. that the change is breaking. It has no vitest config, so @objectstack/spec resolves to packages/spec/dist.

step tree result
baseline spec built, source == dist 14/14 pass
inject a required field into ConnectorSchema in source, do not rebuild the frozen fixture no longer parses 14/14 pass
identical tree, spec aliased to source 1 failed / 13 passed, naming the injected field

So the repo's backward-compatibility gate for spec reported green on a change that breaks its own fixture outright. Both edits were reverted; git status clean.

Ordering does not reach this: turbo.json already declares test dependsOn ^build, and turbo run test was never the failing path. The failing paths are the ones turbo does not mediate — pnpm test inside a package, vitest run <file>, an editor runner, an agent in a tree built at an older commit — which are exactly the paths a pin is re-run on while someone is changing the dependency.

Corrected census — measured on origin/main @ 6ff179d

The dispatch census was approximate by its own account. Re-derived with the predicate above:

reading dispatch estimate measured
packages with unit tests 68 72
…shipping no vitest.config.* 42 45
…of those, value-importing @objectstack/core 35 22
packages aliasing core to source 9 11
packages with ≥1 unaliased artifact import not measured 63
package↔dependency pairs not measured 312

Three corrections that change the shape of the work:

  • The hazard is not core-shaped. Only 22 of the 45 config-less packages import core at all; the other 21 import spec, formula, metadata-core, objectql or sdui-parser, whose dist goes stale identically — every one of the 67 packages that declares an entry point resolves through exports to dist/, with no exceptions. spec, not core, is the highest-churn one. Restricting the invariant to core would have left the worst case unguarded, so the invariant covers any workspace package. That settles the open question the dispatch left open, on the evidence rather than by preference.
  • Shipping a vitest.config.* proves nothing — the naivety the dispatch flagged in its own predicate, now quantified. Of the 27 existing configs, 21 leave at least one test-reachable artifact import unaliased; packages/core, packages/rest, packages/client and packages/qa/dogfood alias nothing at all. So of the 63 offenders, 42 have no config and 21 have one.
  • The card's list of ten alias-to-source packages was right; the dispatch's correction of it was wrong. plugin-audit and service-knowledge both ship vitest.config.ts with anchored-regex @objectstack/corecore/src/index.ts aliases (packages/plugins/plugin-audit/vitest.config.ts:29, packages/services/service-knowledge/vitest.config.ts:20). All ten named packages check out; the list is simply from before PR test(service-storage): resolve @objectstack/core from source so a stale dist can't decide a pin (#7668) #7778 added service-storage, which makes eleven.

The registry

KNOWN_UNALIASED_TEST_IMPORTS is the measured state, printed by --list — not curated. It is shrink-only, and audited in both directions like UNRESOLVED_ADR_CITATIONS in check-adr-anchors.mjs, so it cannot rot into a permanent grandfather clause: an entry that is no longer needed fails and names itself for deletion.

One deliberate reading to flag, since it is a judgment call I made rather than one I was handed. The ruling is one entry per package with no numeric ceiling or headroom. Each entry here carries the exact set of dependencies currently unaliased, and the audit demands set equality. There is no count anywhere in the file and nothing to regress under — I read the dep set as implementing the no-headroom rule one level down rather than bending it, because a bare list of package names would license a listed package to acquire ten new artifact imports with nothing going red, which is the same silent-headroom shape #7888 records the type-check DEBT ledger paying for. If the PM prefers the literal bare-name form, it is a small edit to check() and I will take it.

Three guards on the gate itself:

  • Census guard. Zero workspace packages, zero artifact-resolving packages, or zero packages with tests each fail loudly — a scan that has quietly stopped matching reports a spotless repo.
  • An unreadable config is a failure, not an empty alias list. A config assembling its aliases elsewhere (a spread) fails naming itself, rather than being silently reported as aliasing nothing.
  • It never demands a config. A package with tests that imports no stale-able workspace dep needs no alias. The predicate is the import, not the file.

Remediation surface for follow-up cards

42 packages need a config from scratch; 21 need an existing config widened. The heaviest are @objectstack/cli (27 deps), @objectstack/dogfood (22), @objectstack/runtime (16), @objectstack/verify (14), @objectstack/example-showcase (15), @objectstack/rest (12). The full per-package sets are the registry itself, so a card can be sized straight off it. Worth flagging for whoever triages: aliasing a dep to source is not always a one-liner — some of these packages may surface dual-instance or circular-import problems the dist boundary is currently hiding, which is itself a finding rather than a reason to leave the artifact on the path.

#7888, #7778 and #7668 are referenced as context and all remain open — nothing here closes them.

Verification

$ pnpm check:test-source-alias
check-test-source-alias --self-test OK
check-test-source-alias OK — 72 packages with tests scanned; 63 registered as still resolving a workspace dep through `dist/`.

Self-test: 14 assertions over a temp fixture tree exercising both directions — violating package fails, compliant package passes, type-only import is not a hazard, an import no test can reach is not a hazard, the ENOTDIR prefix trap fails, a spread-alias config fails as unreadable, a correctly registered package is quiet, an entry for an already-fixed package fails as no-longer-needed, an entry naming a non-existent package fails, a new unaliased dep under an existing entry fails, an entry listing a dep that is no longer unaliased fails as stale, a dep already resolving to source is not reported, and an empty tree trips the census guard.

Both-directions audit also exercised live against the real tree, not only fixtures: deleting @objectstack/service-cache's entry → red naming the package; narrowing it → red on the new import; widening it → red as stale. Restored → green, exit 0.

Gates run locally, all green: check:nul-bytes, check:required-contexts, check:workflow-status-functions, check:node-version, check:shard-attestation, check:changeset-gate-self-tests, check-changeset-no-major, check:type-check-coverage, check:cross-package-test-inputs, and eslint --no-inline-config on the new script.

No changeset: the diff is a gate script, a root script entry and a CI step — nothing publishable changes. skip-changeset applied.


Generated by Claude Code

…dist

Every publishable package resolves through `exports` to `dist/`, so a package
whose tests import a workspace dependency with no vitest source alias is
reporting on build state rather than on the source in the checkout. #7668 is
the loud version of that: 17 cases erroring on a symbol its prebuilt core
predated. The version this gate is aimed at is silent — a dist merely BEHIND
the source runs GREEN against old behaviour, with nothing in the output saying
so.

Demonstrated on this repo before writing the gate: with `@objectstack/spec`
unaliased, `packages/qa/downstream-contract` — the frozen third-party fixture
that exists to catch breaking spec changes — reported 14/14 green against a
spec source that rejected its own fixture outright. Aliasing spec to source on
the identical tree turned it red naming the injected field. Both edits were
reverted.

The gate walks each package's test-reachable imports (type-only imports
excluded — they never resolve), keeps the deps whose own entry point is a
build artifact, and resolves each specifier through the package's vitest
aliases the way Vite does (in order, first match wins, string `find` by
prefix). It also fails the prefix/ENOTDIR trap #7778 documented, since it
performs the real replacement and can see a path running through a file.

`KNOWN_UNALIASED_TEST_IMPORTS` is the measured state: 63 of 72 packages with
tests, 312 package-dependency pairs. It is shrink-only and audited in both
directions, so an entry that is no longer needed fails and names itself for
deletion. Each entry carries its exact dependency set rather than a bare
package name, so a listed package cannot acquire new artifact imports with
nothing going red. No package's vitest config is touched here; per-package
remediation is filed separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CD4dmUPWszMro2K4Mwzpwj
@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 9:05am

Request Review

@hotlong hotlong added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 12, 2026 — with Claude
@github-actions github-actions Bot added ci/cd dependencies Pull requests that update a dependency file labels Aug 12, 2026
@hotlong
hotlong marked this pull request as ready for review August 12, 2026 09:46
@hotlong
hotlong added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 20d92e3 Aug 12, 2026
29 checks passed
@hotlong
hotlong deleted the claude/issue-7849-stale-dist-alias-gate branch August 12, 2026 10:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants