ci(test-alias): gate that a unit test judges source, not a sibling's dist - #7966
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
hotlong
marked this pull request as ready for review
August 12, 2026 09:46
This was referenced Aug 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 existingESLintjob inlint.yml, so no new required context).For every workspace package that has test files, the gate:
import typeis dropped: it is erased before anything resolves, so it cannot read a stale artifact.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.findby prefix and regexfindthroughString.replace. A dep whose winning entry lands undersrc/is safe.Because step 3 performs the real replacement, the gate also sees the
ENOTDIRtrap PR #7778 documented: an object-form alias key matching by prefix resolves@objectstack/core/loggerto…/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-contractis 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/specresolves topackages/spec/dist.ConnectorSchemain source, do not rebuildSo the repo's backward-compatibility gate for
specreported green on a change that breaks its own fixture outright. Both edits were reverted;git statusclean.Ordering does not reach this:
turbo.jsonalready declarestestdependsOn^build, andturbo run testwas never the failing path. The failing paths are the ones turbo does not mediate —pnpm testinside 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@6ff179dThe dispatch census was approximate by its own account. Re-derived with the predicate above:
vitest.config.*@objectstack/coreThree corrections that change the shape of the work:
coreat all; the other 21 importspec,formula,metadata-core,objectqlorsdui-parser, whose dist goes stale identically — every one of the 67 packages that declares an entry point resolves throughexportstodist/, with no exceptions.spec, notcore, is the highest-churn one. Restricting the invariant tocorewould 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.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/clientandpackages/qa/dogfoodalias nothing at all. So of the 63 offenders, 42 have no config and 21 have one.plugin-auditandservice-knowledgeboth shipvitest.config.tswith anchored-regex@objectstack/core→core/src/index.tsaliases (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/corefrom source so a stale dist can't decide a pin (#7668) #7778 addedservice-storage, which makes eleven.The registry
KNOWN_UNALIASED_TEST_IMPORTSis the measured state, printed by--list— not curated. It is shrink-only, and audited in both directions likeUNRESOLVED_ADR_CITATIONSincheck-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:
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,#7778and#7668are referenced as context and all remain open — nothing here closes them.Verification
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, andeslint --no-inline-configon the new script.No changeset: the diff is a gate script, a root script entry and a CI step — nothing publishable changes.
skip-changesetapplied.Generated by Claude Code