From 2d3475d42e1da6beea4a19c237a16f7c86f72126 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 11:38:38 +0000 Subject: [PATCH 1/4] test(qa): make downstream-contract read spec source, not spec dist (#7991) --- .../test/source-resolution.pin.test.ts | 59 +++++++++++++++++ .../qa/downstream-contract/vitest.config.ts | 66 +++++++++++++++++++ scripts/check-test-source-alias.mjs | 1 - 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 packages/qa/downstream-contract/test/source-resolution.pin.test.ts create mode 100644 packages/qa/downstream-contract/vitest.config.ts 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..01c48d38df --- /dev/null +++ b/packages/qa/downstream-contract/vitest.config.ts @@ -0,0 +1,66 @@ +// 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/…')` + // and NOT as the template literal `${path.resolve(…, '../../spec/src')}/$1/ + // index.ts` that `service-knowledge` and `plugin-audit` use, because + // `check:test-source-alias` reads an alias replacement by taking the LAST + // string literal in the expression: from the template form that is the + // whole template body, which contains no `/src/` segment, so a config that + // aliases every namespace correctly still reads to the gate as aliasing + // nothing. Filed as # — it is why both of those packages still + // carry a `['@objectstack/objectql', '@objectstack/spec']` registry entry + // they have in fact already half-fixed. In this spelling the gate's + // simulated resolution lands on `spec/src//index.ts`, which is what + // Vite really produces — the gate's verdict here is true, not lucky. + 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', ], From 272ba256fe546e467958c59dbd40d300311ccdc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 11:45:38 +0000 Subject: [PATCH 2/4] docs(changeset): record the downstream-contract source-alias fix (#7991) --- .../downstream-contract-reads-spec-source.md | 54 +++++++++++++++++++ .../qa/downstream-contract/vitest.config.ts | 2 +- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 .changeset/downstream-contract-reads-spec-source.md diff --git a/.changeset/downstream-contract-reads-spec-source.md b/.changeset/downstream-contract-reads-spec-source.md new file mode 100644 index 0000000000..4924187e5c --- /dev/null +++ b/.changeset/downstream-contract-reads-spec-source.md @@ -0,0 +1,54 @@ +--- +--- + +test(qa): the `@objectstack/spec` backward-compatibility gate now reads spec source instead of `spec/dist` (#7991) + +Release-nothing: adds `packages/qa/downstream-contract/vitest.config.ts` and a pin test, +and removes that package's entry from `KNOWN_UNALIASED_TEST_IMPORTS` in +`scripts/check-test-source-alias.mjs`. No package code changes. + +`packages/qa/downstream-contract` is the frozen third-party consumer fixture — the +backward-compatibility gate for `@objectstack/spec`, whose README states that a failure +there means a spec change has narrowed something a published-spec consumer already +relies on. It shipped no `vitest.config.*`, so every `@objectstack/spec` import resolved +through `exports` to `packages/spec/dist`, a build artifact: the one suite built to +answer "is this spec change breaking?" was rendering a verdict about build state. + +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: + +Counts below are `test/contract.test.ts` — the 14 frozen-fixture cases — so that the +before and after columns describe the same 14 assertions. (The suite as a whole is 16 +once the 2 new pin cases are counted, and is green: `Tests 16 passed (16)`.) + +| tree | before this change (no alias) | after (aliased) | +|---|---|---| +| spec source == dist | 14 passed (14) | 14 passed (14) | +| required field injected into `ConnectorSchema` source, no rebuild | **14 passed (14)** | **1 failed \| 13 passed (14)**, naming `osProbeRequiredField` | + +The two runs in the second row are the same checkout with the same stale `dist`; the only +difference is whether `vitest.config.ts` is present. + +A green here is consumed as evidence of backward compatibility, and it was not evidence +of that. 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, a tree built at an older commit) — the +paths this gate is re-run on while someone is changing the spec. + +Two cases in a new `test/source-resolution.pin.test.ts` keep the demonstration +executable rather than historical: one imports `@objectstack/spec/conversions`, a source +directory the `exports` map deliberately does not publish, so it resolves only through +the alias; the other pins `defineConnector` from the package root and from +`@objectstack/spec/integration` to one object, which is both the missing-alias guard and +the standing guard on a dual instance. + +The alias uses the anchored regex / array form (the object form matches by prefix and +would resolve `@objectstack/spec/ui` to `spec/src/index.ts/ui`, ENOTDIR), with the +replacement spelled `path.join(, 'spec/src/$1/index.ts')` rather than as a +template literal — `check:test-source-alias` reads a replacement by its last string +literal and cannot see through the template form (#8020). + +Adjacent exposure found while measuring and deliberately left for its own card: the +package's other half, `typecheck`, still resolves spec types through `dist/*.d.ts`, so a +narrowed export type reads green there the same way (#8021). diff --git a/packages/qa/downstream-contract/vitest.config.ts b/packages/qa/downstream-contract/vitest.config.ts index 01c48d38df..cc83d62327 100644 --- a/packages/qa/downstream-contract/vitest.config.ts +++ b/packages/qa/downstream-contract/vitest.config.ts @@ -50,7 +50,7 @@ export default defineConfig({ // string literal in the expression: from the template form that is the // whole template body, which contains no `/src/` segment, so a config that // aliases every namespace correctly still reads to the gate as aliasing - // nothing. Filed as # — it is why both of those packages still + // nothing. Filed as #8020 — it is why both of those packages still // carry a `['@objectstack/objectql', '@objectstack/spec']` registry entry // they have in fact already half-fixed. In this spelling the gate's // simulated resolution lands on `spec/src//index.ts`, which is what From 212226f50824508fc0d273f7d35f95bc62a2836c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 17:12:16 +0000 Subject: [PATCH 3/4] =?UTF-8?q?docs(qa):=20correct=20the=20#8020=20note=20?= =?UTF-8?q?=E2=80=94=20the=20gate=20now=20reads=20template-literal=20repla?= =?UTF-8?q?cements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #8107 landed while this card was in flight and taught `check:test-source-alias` to read a template-literal alias replacement, so the warning this config and its changeset carried described a limitation that no longer exists. Restated in the past tense; the `path.join` spelling is kept because it states the produced path directly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V --- .../downstream-contract-reads-spec-source.md | 5 +++-- .../qa/downstream-contract/vitest.config.ts | 22 +++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/.changeset/downstream-contract-reads-spec-source.md b/.changeset/downstream-contract-reads-spec-source.md index 4924187e5c..3fcc3b77e5 100644 --- a/.changeset/downstream-contract-reads-spec-source.md +++ b/.changeset/downstream-contract-reads-spec-source.md @@ -46,8 +46,9 @@ the standing guard on a dual instance. The alias uses the anchored regex / array form (the object form matches by prefix and would resolve `@objectstack/spec/ui` to `spec/src/index.ts/ui`, ENOTDIR), with the replacement spelled `path.join(, 'spec/src/$1/index.ts')` rather than as a -template literal — `check:test-source-alias` reads a replacement by its last string -literal and cannot see through the template form (#8020). +template literal. Both spellings read correctly to `check:test-source-alias` as of +\#8020 / PR #8107, which was found under this card and fixed on its own ahead of this +one landing. Adjacent exposure found while measuring and deliberately left for its own card: the package's other half, `typecheck`, still resolves spec types through `dist/*.d.ts`, so a diff --git a/packages/qa/downstream-contract/vitest.config.ts b/packages/qa/downstream-contract/vitest.config.ts index cc83d62327..7898efa662 100644 --- a/packages/qa/downstream-contract/vitest.config.ts +++ b/packages/qa/downstream-contract/vitest.config.ts @@ -43,18 +43,16 @@ export default defineConfig({ // `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/…')` - // and NOT as the template literal `${path.resolve(…, '../../spec/src')}/$1/ - // index.ts` that `service-knowledge` and `plugin-audit` use, because - // `check:test-source-alias` reads an alias replacement by taking the LAST - // string literal in the expression: from the template form that is the - // whole template body, which contains no `/src/` segment, so a config that - // aliases every namespace correctly still reads to the gate as aliasing - // nothing. Filed as #8020 — it is why both of those packages still - // carry a `['@objectstack/objectql', '@objectstack/spec']` registry entry - // they have in fact already half-fixed. In this spelling the gate's - // simulated resolution lands on `spec/src//index.ts`, which is what - // Vite really produces — the gate's verdict here is true, not lucky. + // 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-]+)$/, From 32d3f69da09c896a307322a190f6632e38997ab5 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:28:13 +0800 Subject: [PATCH 4/4] chore: drop the empty-frontmatter changeset, use skip-changeset instead (#7991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Check Changeset gate refuses an empty-frontmatter changeset: it is a real input to changesets/action, and an all-empty set stalls the release silently and greenly (#4898). This PR releases nothing — @objectstack/downstream-contract is private and never published, and no published package's code changes — so the gate's route 2 applies: delete the changeset and carry the skip-changeset label, which is a gate-level exemption producing no input for changesets/action. --- .../downstream-contract-reads-spec-source.md | 55 ------------------- 1 file changed, 55 deletions(-) delete mode 100644 .changeset/downstream-contract-reads-spec-source.md diff --git a/.changeset/downstream-contract-reads-spec-source.md b/.changeset/downstream-contract-reads-spec-source.md deleted file mode 100644 index 3fcc3b77e5..0000000000 --- a/.changeset/downstream-contract-reads-spec-source.md +++ /dev/null @@ -1,55 +0,0 @@ ---- ---- - -test(qa): the `@objectstack/spec` backward-compatibility gate now reads spec source instead of `spec/dist` (#7991) - -Release-nothing: adds `packages/qa/downstream-contract/vitest.config.ts` and a pin test, -and removes that package's entry from `KNOWN_UNALIASED_TEST_IMPORTS` in -`scripts/check-test-source-alias.mjs`. No package code changes. - -`packages/qa/downstream-contract` is the frozen third-party consumer fixture — the -backward-compatibility gate for `@objectstack/spec`, whose README states that a failure -there means a spec change has narrowed something a published-spec consumer already -relies on. It shipped no `vitest.config.*`, so every `@objectstack/spec` import resolved -through `exports` to `packages/spec/dist`, a build artifact: the one suite built to -answer "is this spec change breaking?" was rendering a verdict about build state. - -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: - -Counts below are `test/contract.test.ts` — the 14 frozen-fixture cases — so that the -before and after columns describe the same 14 assertions. (The suite as a whole is 16 -once the 2 new pin cases are counted, and is green: `Tests 16 passed (16)`.) - -| tree | before this change (no alias) | after (aliased) | -|---|---|---| -| spec source == dist | 14 passed (14) | 14 passed (14) | -| required field injected into `ConnectorSchema` source, no rebuild | **14 passed (14)** | **1 failed \| 13 passed (14)**, naming `osProbeRequiredField` | - -The two runs in the second row are the same checkout with the same stale `dist`; the only -difference is whether `vitest.config.ts` is present. - -A green here is consumed as evidence of backward compatibility, and it was not evidence -of that. 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, a tree built at an older commit) — the -paths this gate is re-run on while someone is changing the spec. - -Two cases in a new `test/source-resolution.pin.test.ts` keep the demonstration -executable rather than historical: one imports `@objectstack/spec/conversions`, a source -directory the `exports` map deliberately does not publish, so it resolves only through -the alias; the other pins `defineConnector` from the package root and from -`@objectstack/spec/integration` to one object, which is both the missing-alias guard and -the standing guard on a dual instance. - -The alias uses the anchored regex / array form (the object form matches by prefix and -would resolve `@objectstack/spec/ui` to `spec/src/index.ts/ui`, ENOTDIR), with the -replacement spelled `path.join(, 'spec/src/$1/index.ts')` rather than as a -template literal. Both spellings read correctly to `check:test-source-alias` as of -\#8020 / PR #8107, which was found under this card and fixed on its own ahead of this -one landing. - -Adjacent exposure found while measuring and deliberately left for its own card: the -package's other half, `typecheck`, still resolves spec types through `dist/*.d.ts`, so a -narrowed export type reads green there the same way (#8021).