Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
64 changes: 64 additions & 0 deletions packages/qa/downstream-contract/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -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 <file>`, 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(<packages>, '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/<ns>/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') },
],
},
});
1 change: 0 additions & 1 deletion scripts/check-test-source-alias.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
],
Expand Down
Loading