diff --git a/packages/qa/downstream-contract/test/source-resolution.pin.test.ts b/packages/qa/downstream-contract/test/source-resolution.pin.test.ts index 32d9159228..61686c7230 100644 --- a/packages/qa/downstream-contract/test/source-resolution.pin.test.ts +++ b/packages/qa/downstream-contract/test/source-resolution.pin.test.ts @@ -1,10 +1,24 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; +import { join, resolve, dirname, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; 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 +// This import is the TYPE-axis half of the pin, and it is load-bearing as +// written — see the `tsc` block at the bottom of this file for why a LITERAL +// specifier is the whole point. `@objectstack/spec/conversions` exists in the +// source tree and is deliberately absent from spec's `exports` map, so `tsc` +// can only reach it through the `paths` block in `tsconfig.json`. Delete or +// misspell that block and this line is `TS2307: Cannot find module`. +import type { ConversionNotice } from '@objectstack/spec/conversions'; + +/** Re-exported so the type-only import above can never read as unused. */ +export type ConversionsReachableFromSource = ConversionNotice; + +// NOT frozen fixture material (see README) — these 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. @@ -32,10 +46,20 @@ describe('the contract suite reads spec SOURCE, not spec dist (#7991)', () => { // 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. + // The specifier is held in a const, and #8021 CHANGED WHY. It used to be + // the only spelling that compiled: `tsc` resolved this package through the + // same `exports` map that does not publish `conversions`, so a literal was + // `TS2307`. #8021 put `paths` in `tsconfig.json`, so the literal now + // resolves — the file-header `import type` above is exactly that literal, + // deliberately. + // + // The const stays because the two axes must be able to fail SEPARATELY. A + // literal here would make this runtime case depend on `tsconfig.json` as + // well as on `vitest.config.ts`, so a broken vitest alias could be masked, + // or reported as a type error, by a config that has nothing to do with the + // resolution this case is about. `import()` of a non-literal is `any` to + // tsc and still resolves through Vite's alias at run time — one axis, one + // case. const sourceOnlySubpath = '@objectstack/spec/conversions'; const conversions = (await import(sourceOnlySubpath)) as { CONVERSION_NOTICE_CODE?: unknown }; @@ -57,3 +81,144 @@ describe('the contract suite reads spec SOURCE, not spec dist (#7991)', () => { expect(fromPackageRoot).toBe(fromNamespace); }); }); + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PACKAGE_DIR = resolve(HERE, '..'); +const SPEC_SRC = resolve(PACKAGE_DIR, '..', '..', 'spec', 'src'); + +/** `tsconfig.json` is JSONC; strip whole-line `//` comments, as the repo's own gates do. */ +function readTsconfigPaths(): Record { + const raw = readFileSync(join(PACKAGE_DIR, 'tsconfig.json'), 'utf8').replace(/^\s*\/\/.*$/gm, ''); + return (JSON.parse(raw).compilerOptions?.paths ?? {}) as Record; +} + +/** + * Resolve `specifier` the way tsc resolves `paths`: an EXACT (star-free) key + * wins outright, otherwise the pattern key with the longest matching prefix + * wins and the captured text is substituted for the target's star. + * + * Returns the absolute target, or null when nothing matched — and null is a + * real answer here, not an error case: it means tsc falls through to node + * resolution, i.e. to `dist`. + */ +function resolveThroughPaths(specifier: string, paths: Record): string | null { + const exact = paths[specifier]; + if (exact && !specifier.includes('*')) return resolve(PACKAGE_DIR, exact[0]); + + let best: { prefixLength: number; target: string } | null = null; + for (const [key, targets] of Object.entries(paths)) { + const star = key.indexOf('*'); + if (star === -1) continue; + const prefix = key.slice(0, star); + const suffix = key.slice(star + 1); + if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue; + if (specifier.length < prefix.length + suffix.length) continue; + if (best && best.prefixLength >= prefix.length) continue; + const captured = specifier.slice(prefix.length, specifier.length - suffix.length); + best = { prefixLength: prefix.length, target: resolve(PACKAGE_DIR, targets[0].replace('*', captured)) }; + } + return best?.target ?? null; +} + +/** Every `@objectstack/spec…` specifier this package imports with a literal, from its own files. */ +function literalSpecImports(): string[] { + const found = new Set(); + const walk = (dir: string): void => { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) walk(full); + else if (full.endsWith('.ts')) { + for (const match of readFileSync(full, 'utf8').matchAll(/from\s+'(@objectstack\/spec(?:\/[^']+)?)'/g)) { + found.add(match[1]); + } + } + } + }; + walk(join(PACKAGE_DIR, 'src')); + walk(join(PACKAGE_DIR, 'test')); + return [...found].sort(); +} + +// The type axis of the same invariant (#8021). The README gives `typecheck` a +// job the runtime suite cannot do — "a removed or NARROWED export fails here" — +// and it was answering from `dist/*.d.ts` for the same reason the runtime half +// was: no resolution config at all. Measured, one variable moved, identical +// checkout and identical stale `dist`, with `label: z.string()` narrowed to +// `z.number()` in `spec/src/integration/connector.zod.ts` and NO rebuild: +// without `paths`, `tsc --noEmit` exited 0; with `paths`, it reported +// `src/additional-domains.fixtures.ts(35,3): error TS2322: Type 'string' is not +// assignable to type 'number'` — the frozen fixture's `label: 'DC HubSpot'`. +// +// The `import type { ConversionNotice }` at the top of this file is the direct +// half of the pin: it compiles only through the subpath rule. These cases are +// the half that a type-only import CANNOT express — the bare-entry rule has no +// type-level discriminator at all. Measured: with the subpath rule kept and +// only the bare rule deleted, tsc stayed CLEAN while `src/stack.ts`'s +// `defineStack` types came from `dist`. The obvious candidate for a direct +// assertion — an `Equal<>` identity check between `typeof defineConnector` +// reached through both entries, the type-level twin of the `toBe` case above — +// was tried and rejected on measurement: with the two entries on different +// trees tsc had not finished comparing spec's zod-derived types after nine +// minutes. So the bare rule is pinned the way `check:test-source-alias` pins +// the Vite side: simulate the resolution and assert where it lands. +describe('the contract TYPES read spec SOURCE, not spec dist (#8021)', () => { + it('maps every spec specifier this package imports onto a real file under spec/src', () => { + const paths = readTsconfigPaths(); + const specifiers = literalSpecImports(); + + // Guards the guard: if the scan ever stops finding the fixtures' imports, + // the loop below would pass vacuously. + expect(specifiers).toContain('@objectstack/spec'); + expect(specifiers.length).toBeGreaterThan(5); + + for (const specifier of specifiers) { + const target = resolveThroughPaths(specifier, paths); + expect(target, `${specifier} falls through 'paths' to node resolution, i.e. to dist`).not.toBeNull(); + expect(relative(SPEC_SRC, target as string).startsWith('..'), `${specifier} resolves outside spec/src`).toBe( + false, + ); + expect(existsSync(target as string), `${specifier} resolves to a nonexistent ${target}`).toBe(true); + } + }); + + it('covers namespaces the fixtures have not reached yet, with one rule rather than a list', () => { + // An enumeration would be green today and stale the first time a fixture + // reaches a new namespace — silently, because the failure mode of a missing + // rule is a PASSING typecheck. Every namespace spec publishes must already + // resolve to source, whether or not a fixture imports it today. + const paths = readTsconfigPaths(); + const published = Object.keys( + JSON.parse(readFileSync(resolve(SPEC_SRC, '..', 'package.json'), 'utf8')).exports as Record, + ) + .filter((key) => key.startsWith('./') && !key.includes('.json')) + .map((key) => `@objectstack/spec/${key.slice(2)}`); + + expect(published.length).toBeGreaterThan(10); + for (const specifier of published) { + const target = resolveThroughPaths(specifier, paths); + expect(target, `${specifier} is published but falls through to dist`).not.toBeNull(); + expect(existsSync(target as string), `${specifier} maps to a nonexistent ${target}`).toBe(true); + } + }); + + it('refuses the prefix-star spelling that folds every namespace onto one module', () => { + // The tsconfig twin of the Vite object-form trap `vitest.config.ts` + // records. A key spelled `@objectstack/spec*` — star NOT preceded by a + // slash — matches every namespace. It is worse than the Vite version, + // which crashes with ENOTDIR: `spec/src/index.ts` re-exports most of the + // namespace surface, so this one type-checks the fixtures against the + // wrong module and stays GREEN. + for (const key of Object.keys(readTsconfigPaths())) { + expect(/^@objectstack\/spec[^/]*\*/.test(key), `paths key '${key}' matches namespaces by prefix`).toBe(false); + } + }); + + it('keeps the targets inside the source tree, never inside dist', () => { + for (const targets of Object.values(readTsconfigPaths())) { + for (const target of targets) { + expect(target.split('/').includes('dist'), `paths target '${target}' points into a build artifact`).toBe(false); + expect(target.includes(`spec${sep}src`) || target.includes('spec/src')).toBe(true); + } + } + }); +}); diff --git a/packages/qa/downstream-contract/tsconfig.json b/packages/qa/downstream-contract/tsconfig.json index 2cf6eda84d..6c95184d4f 100644 --- a/packages/qa/downstream-contract/tsconfig.json +++ b/packages/qa/downstream-contract/tsconfig.json @@ -3,12 +3,89 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", + // `lib` and `types` are here as a CONSEQUENCE of the `paths` block below, + // not as a preference. Putting `packages/spec/src` into this program means + // tsc now checks spec's own source files, which are written against spec's + // own environment (`packages/spec/tsconfig.json`: `lib` ES + DOM + + // DOM.Iterable, `types` node). Without them the first run reported two + // `TS2591: Cannot find name 'process'` in `spec/src/shared/lazy-schema.ts` + // — a verdict about THIS package's compiler environment, not about spec's + // backward compatibility. Mirroring spec's own environment keeps every red + // this gate produces attributable to the spec contract, which is the only + // thing it is allowed to be read as. + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"], "strict": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "noEmit": true, - "rootDir": "." + // `rootDir: "."` was removed for the same reason. It emits nothing under + // `noEmit`, but it is still enforced: with spec source in the program it + // produced 247 x `TS6059: File '.../packages/spec/src/...' is not under + // 'rootDir'`, drowning the one error the gate exists to print. + // + // -- Why `paths` at all (#8021) ---------------------------------------- + // + // This package is the repo's backward-compatibility gate for + // `@objectstack/spec` (#2035), and its README gives typecheck a job the + // suite next door cannot do: "a removed or NARROWED export fails here". + // It declared no `paths`, so under NodeNext tsc resolved + // `@objectstack/spec` through the workspace link and the `exports` map -- + // i.e. `dist/index.d.mts`, A BUILD ARTIFACT. The type half was therefore + // rendering a verdict about the last `pnpm build`, exactly as the runtime + // half was before #7991 aliased vitest to source. + // + // Measured on this package, direction predicted before running, one + // variable moved -- identical checkout, identical stale `dist` + // (`.build-input-hash cf8635aa...`), a narrowing injected into SOURCE only + // and no rebuild (`spec/src/integration/connector.zod.ts`, `label: + // z.string()` narrowed to `z.number()`, which the frozen `DcConnector` + // fixture assigns `'DC HubSpot'` to): + // + // without this `paths` block tsc --noEmit -> exit 0, CLEAN + // with this `paths` block src/additional-domains.fixtures.ts(35,3): + // error TS2322: Type 'string' is not + // assignable to type 'number'. + // + // Turbo orders `typecheck` after `^build`, so `turbo run typecheck` was + // never the failing path and needs no change. What breaks is every path + // turbo does not mediate: `pnpm --filter ... typecheck` in the package, an + // editor's TS server, or an agent in a tree built at an older commit -- + // i.e. exactly the paths this gate is re-read on WHILE someone is changing + // the spec, when it most needs to be telling the truth. + // + // -- The shape of the two rules ---------------------------------------- + // + // ONE subpath rule for every namespace, never an enumeration: the fixtures + // reach `automation` / `data` / `identity` / `integration` / `security` / + // `system` / `ui` today, and a hand-maintained list goes stale silently the + // first time a fixture reaches a new one -- silently, because the failure + // mode is a green typecheck. + // + // The anchoring discipline #7991 records for Vite's ALIAS applies here in + // a different spelling, so it is worth stating exactly. A tsconfig `paths` + // key WITHOUT a `*` is an EXACT match, so the bare `@objectstack/spec` + // entry cannot swallow `@objectstack/spec/ui` the way a Vite object-form + // alias does (that trap is `ENOTDIR` at run time; see `vitest.config.ts`). + // The tsconfig-shaped version of the same mistake is spelling the key + // `@objectstack/spec*` -- star NOT preceded by a slash -- which matches + // every namespace and folds it onto whatever the target names. That one is + // WORSE than the Vite trap, because `spec/src/index.ts` re-exports most of + // the namespace surface, so it does not crash: it type-checks the fixtures + // against the wrong module and stays green. The pin cases in + // `test/source-resolution.pin.test.ts` refuse that spelling by simulating + // this block's resolution. + // + // Both rules are load-bearing and they fail INDEPENDENTLY -- measured, + // with the subpath rule left in place and only the bare rule deleted, tsc + // was still CLEAN while `src/stack.ts`'s `defineStack` types came from + // `dist`. A missing rule here does not announce itself; that is what the + // pin cases are for. + "paths": { + "@objectstack/spec": ["../../spec/src/index.ts"], + "@objectstack/spec/*": ["../../spec/src/*/index.ts"] + } }, "include": ["src/**/*", "test/**/*"] }