diff --git a/packages/qa/downstream-contract/test/source-resolution.pin.test.ts b/packages/qa/downstream-contract/test/source-resolution.pin.test.ts new file mode 100644 index 0000000000..32d9159228 --- /dev/null +++ b/packages/qa/downstream-contract/test/source-resolution.pin.test.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { defineConnector as fromPackageRoot } from '@objectstack/spec'; +import { defineConnector as fromNamespace } from '@objectstack/spec/integration'; + +// NOT frozen fixture material (see README) — these two cases assert something +// about the HARNESS, not about the consumer contract: that `contract.test.ts` +// renders a verdict about `packages/spec/src`, the spec in this checkout, and +// not about `packages/spec/dist`, a build artifact. +// +// #7991 is why. This package shipped no vitest config, so every +// `@objectstack/spec` import resolved through `exports` to `dist/`. Measured, +// direction predicted before running: with a required field injected into +// `ConnectorSchema` in SOURCE only and no rebuild — a break the frozen +// `DcConnector` fixture cannot parse — the suite still reported **14/14 pass**; +// aliased to source, the identical tree reported **1 failed / 13 passed**, +// naming the injected field. The gate whose entire job is to notice breaking +// spec changes did not notice one, while its green was being consumed as +// evidence of backward compatibility. +// +// `pnpm check:test-source-alias` asserts the same invariant STATICALLY, by +// simulating Vite's resolution over `vitest.config.ts`. These cases assert it +// DYNAMICALLY, on the resolution the suite next to them actually performs. The +// gap between "the config looks right" and "the import landed on source" is the +// entire subject of this card, so it is worth pinning on both sides. +describe('the contract suite reads spec SOURCE, not spec dist (#7991)', () => { + it('resolves a namespace that the published `exports` map does not publish', async () => { + // `packages/spec/src/conversions/` exists in the source tree and is + // deliberately absent from spec's `exports` map, so this specifier can + // resolve ONLY through the source alias — through `exports` (i.e. dist) it + // is `ERR_PACKAGE_PATH_NOT_EXPORTED`. That asymmetry is what makes this a + // real discriminator rather than a check that passes either way. + // + // The specifier is held in a const on purpose: spelled as a literal it + // would fail `tsc`, which resolves it through the same exports map that + // does not publish it. `import()` of a non-literal is `any` to tsc and + // still resolves through Vite's alias at run time. + const sourceOnlySubpath = '@objectstack/spec/conversions'; + const conversions = (await import(sourceOnlySubpath)) as { CONVERSION_NOTICE_CODE?: unknown }; + + expect(conversions.CONVERSION_NOTICE_CODE).toBeTypeOf('string'); + }); + + it('serves the package root and the namespaces from ONE source tree', async () => { + // The case above pins the SUBPATH rule. This one carries it to the bare + // entry: `defineConnector` is exported by both `@objectstack/spec` and + // `@objectstack/spec/integration`, so if one of the two aliases were + // missing or stopped matching, the two specifiers would land on different + // trees (src and dist) and these would be different function objects. + // + // It is also the standing guard on the hazard #7991 flagged in advance: + // aliasing a dep to source can surface a DUAL INSTANCE that the `dist` + // boundary was hiding — two copies of the spec loaded at once, which makes + // every identity comparison downstream (schema instances, registry + // lookups) quietly wrong. One tree or red. + expect(fromPackageRoot).toBe(fromNamespace); + }); +}); diff --git a/packages/qa/downstream-contract/vitest.config.ts b/packages/qa/downstream-contract/vitest.config.ts new file mode 100644 index 0000000000..7898efa662 --- /dev/null +++ b/packages/qa/downstream-contract/vitest.config.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + environment: 'node', + }, + resolve: { + // #7991: this package is the repo's backward-compatibility gate for + // `@objectstack/spec` — its README's contract is "a change that turns this + // red is breaking for a published-spec third party". It shipped no vitest + // config, so every `@objectstack/spec` import resolved through `exports` to + // `packages/spec/dist` — A BUILD ARTIFACT. The verdict of the one suite + // built to answer "is this spec change breaking?" was therefore a function + // of build state rather than of the spec source in the checkout. + // + // Measured on this package, direction predicted before running: with a + // required field injected into `ConnectorSchema` in SOURCE only and no + // rebuild — a break the frozen `DcConnector` fixture cannot parse — the + // suite reported **14/14 pass**. Aliased to source, the identical tree + // reported **1 failed / 13 passed**, naming the injected field. A green + // here was being consumed as evidence of backward compatibility while it + // was reporting on the last `pnpm build` instead. + // + // Turbo already orders `test` after `^build`, so `turbo run test` was never + // the failing path. What breaks is every path turbo does not mediate: + // `pnpm test` inside the package, `vitest run `, an editor runner, or + // an agent in a tree built at an older commit — i.e. exactly the paths this + // gate is re-run on WHILE someone is changing the spec, when it most needs + // to be telling the truth. + // + // Array form with ANCHORED patterns, deliberately — the same correction + // `service-knowledge` and `plugin-audit` record. The object form matches by + // PREFIX, so a bare `@objectstack/spec` entry also swallows + // `@objectstack/spec/ui` and resolves it to `spec/src/index.ts/ui` + // (`ENOTDIR`). One rule for all namespaces cannot go stale as imports are + // added; `pnpm check:test-source-alias` enforces both halves. + // + // The subpath rule covers the namespaces the fixtures reach today + // (`automation` / `data` / `identity` / `integration` / `security` / + // `system` / `ui`) and every other one they might reach tomorrow. One rule + // for all namespaces cannot go stale the way a hand-maintained list does. + // + // The replacement is spelled `path.join(, 'spec/src/$1/…')` + // rather than as a template literal. Both spellings read correctly to + // `check:test-source-alias` as of #8020 / PR #8107 — before that fix the + // gate took an alias replacement to be the LAST string literal in the + // expression, which from a template body contains no `/src/` segment, so a + // config aliasing every namespace correctly still read to the gate as + // aliasing nothing. That was found under this card and fixed on its own, + // ahead of this one landing; the spelling here is kept because it states + // the produced path directly, and the gate's simulated resolution lands on + // `spec/src//index.ts` — what Vite really produces. + alias: [ + { + find: /^@objectstack\/spec\/([a-z-]+)$/, + replacement: path.join(path.resolve(__dirname, '..', '..'), 'spec/src/$1/index.ts'), + }, + { find: /^@objectstack\/spec$/, replacement: path.resolve(__dirname, '../../spec/src/index.ts') }, + ], + }, +}); diff --git a/scripts/check-test-source-alias.mjs b/scripts/check-test-source-alias.mjs index a3f3707796..3f36b40e78 100644 --- a/scripts/check-test-source-alias.mjs +++ b/scripts/check-test-source-alias.mjs @@ -172,7 +172,6 @@ const KNOWN_UNALIASED_TEST_IMPORTS = { '@objectstack/service-analytics', '@objectstack/service-messaging', '@objectstack/service-storage', '@objectstack/spec', '@objectstack/types', '@objectstack/verify', ], - '@objectstack/downstream-contract': ['@objectstack/spec'], '@objectstack/driver-mongodb': [ '@objectstack/core', '@objectstack/objectql', '@objectstack/spec', '@objectstack/types', ],