Skip to content

Commit a453130

Browse files
hotlongclaude
andauthored
test(qa): make the backward-compatibility gate's TYPE half read spec source, not spec dist (#8021) (#8176)
`packages/qa/downstream-contract` is the repo's backward-compatibility gate for `@objectstack/spec` (#2035), and its README gives `typecheck` a job the runtime suite 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 -- `dist/index.d.mts`, a build artifact. The type half was rendering a verdict about the last `pnpm build`, exactly as the runtime half was before #7991/#8129 aliased vitest to source. 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` in SOURCE ONLY and no rebuild: without `paths` tsc --noEmit -> exit 0, CLEAN with `paths` src/additional-domains.fixtures.ts(35,3): error TS2322: Type 'string' is not assignable to type 'number'. `lib`/`types` are consequences of the switch, not preferences: spec source is written against spec's own environment, and without them the first run reported two TS2591 `Cannot find name 'process'`. `rootDir: "."` was dropped for the same reason -- it emits nothing under `noEmit` but still produced 247 TS6059, drowning the one error the gate exists to print. The demonstration is made permanent rather than historical. The type axis is pinned directly by a literal `import type` from `@objectstack/spec/conversions`, a namespace the published `exports` map does not carry, so it compiles only through the subpath rule. The bare-entry rule has no type-level discriminator -- 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` -- so it is pinned the way `check:test-source-alias` pins the Vite side: simulate the resolution and assert where it lands. Reverse-verified in four directions; each goes red naming the exact defect. Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V Co-authored-by: Claude <noreply@anthropic.com>
1 parent 95ef5c0 commit a453130

2 files changed

Lines changed: 248 additions & 6 deletions

File tree

packages/qa/downstream-contract/test/source-resolution.pin.test.ts

Lines changed: 170 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
5+
import { join, resolve, dirname, relative, sep } from 'node:path';
6+
import { fileURLToPath } from 'node:url';
47
import { defineConnector as fromPackageRoot } from '@objectstack/spec';
58
import { defineConnector as fromNamespace } from '@objectstack/spec/integration';
69

7-
// NOT frozen fixture material (see README) — these two cases assert something
10+
// This import is the TYPE-axis half of the pin, and it is load-bearing as
11+
// written — see the `tsc` block at the bottom of this file for why a LITERAL
12+
// specifier is the whole point. `@objectstack/spec/conversions` exists in the
13+
// source tree and is deliberately absent from spec's `exports` map, so `tsc`
14+
// can only reach it through the `paths` block in `tsconfig.json`. Delete or
15+
// misspell that block and this line is `TS2307: Cannot find module`.
16+
import type { ConversionNotice } from '@objectstack/spec/conversions';
17+
18+
/** Re-exported so the type-only import above can never read as unused. */
19+
export type ConversionsReachableFromSource = ConversionNotice;
20+
21+
// NOT frozen fixture material (see README) — these cases assert something
822
// about the HARNESS, not about the consumer contract: that `contract.test.ts`
923
// renders a verdict about `packages/spec/src`, the spec in this checkout, and
1024
// not about `packages/spec/dist`, a build artifact.
@@ -32,10 +46,20 @@ describe('the contract suite reads spec SOURCE, not spec dist (#7991)', () => {
3246
// is `ERR_PACKAGE_PATH_NOT_EXPORTED`. That asymmetry is what makes this a
3347
// real discriminator rather than a check that passes either way.
3448
//
35-
// The specifier is held in a const on purpose: spelled as a literal it
36-
// would fail `tsc`, which resolves it through the same exports map that
37-
// does not publish it. `import()` of a non-literal is `any` to tsc and
38-
// still resolves through Vite's alias at run time.
49+
// The specifier is held in a const, and #8021 CHANGED WHY. It used to be
50+
// the only spelling that compiled: `tsc` resolved this package through the
51+
// same `exports` map that does not publish `conversions`, so a literal was
52+
// `TS2307`. #8021 put `paths` in `tsconfig.json`, so the literal now
53+
// resolves — the file-header `import type` above is exactly that literal,
54+
// deliberately.
55+
//
56+
// The const stays because the two axes must be able to fail SEPARATELY. A
57+
// literal here would make this runtime case depend on `tsconfig.json` as
58+
// well as on `vitest.config.ts`, so a broken vitest alias could be masked,
59+
// or reported as a type error, by a config that has nothing to do with the
60+
// resolution this case is about. `import()` of a non-literal is `any` to
61+
// tsc and still resolves through Vite's alias at run time — one axis, one
62+
// case.
3963
const sourceOnlySubpath = '@objectstack/spec/conversions';
4064
const conversions = (await import(sourceOnlySubpath)) as { CONVERSION_NOTICE_CODE?: unknown };
4165

@@ -57,3 +81,144 @@ describe('the contract suite reads spec SOURCE, not spec dist (#7991)', () => {
5781
expect(fromPackageRoot).toBe(fromNamespace);
5882
});
5983
});
84+
85+
const HERE = dirname(fileURLToPath(import.meta.url));
86+
const PACKAGE_DIR = resolve(HERE, '..');
87+
const SPEC_SRC = resolve(PACKAGE_DIR, '..', '..', 'spec', 'src');
88+
89+
/** `tsconfig.json` is JSONC; strip whole-line `//` comments, as the repo's own gates do. */
90+
function readTsconfigPaths(): Record<string, string[]> {
91+
const raw = readFileSync(join(PACKAGE_DIR, 'tsconfig.json'), 'utf8').replace(/^\s*\/\/.*$/gm, '');
92+
return (JSON.parse(raw).compilerOptions?.paths ?? {}) as Record<string, string[]>;
93+
}
94+
95+
/**
96+
* Resolve `specifier` the way tsc resolves `paths`: an EXACT (star-free) key
97+
* wins outright, otherwise the pattern key with the longest matching prefix
98+
* wins and the captured text is substituted for the target's star.
99+
*
100+
* Returns the absolute target, or null when nothing matched — and null is a
101+
* real answer here, not an error case: it means tsc falls through to node
102+
* resolution, i.e. to `dist`.
103+
*/
104+
function resolveThroughPaths(specifier: string, paths: Record<string, string[]>): string | null {
105+
const exact = paths[specifier];
106+
if (exact && !specifier.includes('*')) return resolve(PACKAGE_DIR, exact[0]);
107+
108+
let best: { prefixLength: number; target: string } | null = null;
109+
for (const [key, targets] of Object.entries(paths)) {
110+
const star = key.indexOf('*');
111+
if (star === -1) continue;
112+
const prefix = key.slice(0, star);
113+
const suffix = key.slice(star + 1);
114+
if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue;
115+
if (specifier.length < prefix.length + suffix.length) continue;
116+
if (best && best.prefixLength >= prefix.length) continue;
117+
const captured = specifier.slice(prefix.length, specifier.length - suffix.length);
118+
best = { prefixLength: prefix.length, target: resolve(PACKAGE_DIR, targets[0].replace('*', captured)) };
119+
}
120+
return best?.target ?? null;
121+
}
122+
123+
/** Every `@objectstack/spec…` specifier this package imports with a literal, from its own files. */
124+
function literalSpecImports(): string[] {
125+
const found = new Set<string>();
126+
const walk = (dir: string): void => {
127+
for (const entry of readdirSync(dir)) {
128+
const full = join(dir, entry);
129+
if (statSync(full).isDirectory()) walk(full);
130+
else if (full.endsWith('.ts')) {
131+
for (const match of readFileSync(full, 'utf8').matchAll(/from\s+'(@objectstack\/spec(?:\/[^']+)?)'/g)) {
132+
found.add(match[1]);
133+
}
134+
}
135+
}
136+
};
137+
walk(join(PACKAGE_DIR, 'src'));
138+
walk(join(PACKAGE_DIR, 'test'));
139+
return [...found].sort();
140+
}
141+
142+
// The type axis of the same invariant (#8021). The README gives `typecheck` a
143+
// job the runtime suite cannot do — "a removed or NARROWED export fails here" —
144+
// and it was answering from `dist/*.d.ts` for the same reason the runtime half
145+
// was: no resolution config at all. Measured, one variable moved, identical
146+
// checkout and identical stale `dist`, with `label: z.string()` narrowed to
147+
// `z.number()` in `spec/src/integration/connector.zod.ts` and NO rebuild:
148+
// without `paths`, `tsc --noEmit` exited 0; with `paths`, it reported
149+
// `src/additional-domains.fixtures.ts(35,3): error TS2322: Type 'string' is not
150+
// assignable to type 'number'` — the frozen fixture's `label: 'DC HubSpot'`.
151+
//
152+
// The `import type { ConversionNotice }` at the top of this file is the direct
153+
// half of the pin: it compiles only through the subpath rule. These cases are
154+
// the half that a type-only import CANNOT express — the bare-entry rule has no
155+
// type-level discriminator at all. Measured: with the subpath rule kept and
156+
// only the bare rule deleted, tsc stayed CLEAN while `src/stack.ts`'s
157+
// `defineStack` types came from `dist`. The obvious candidate for a direct
158+
// assertion — an `Equal<>` identity check between `typeof defineConnector`
159+
// reached through both entries, the type-level twin of the `toBe` case above —
160+
// was tried and rejected on measurement: with the two entries on different
161+
// trees tsc had not finished comparing spec's zod-derived types after nine
162+
// minutes. So the bare rule is pinned the way `check:test-source-alias` pins
163+
// the Vite side: simulate the resolution and assert where it lands.
164+
describe('the contract TYPES read spec SOURCE, not spec dist (#8021)', () => {
165+
it('maps every spec specifier this package imports onto a real file under spec/src', () => {
166+
const paths = readTsconfigPaths();
167+
const specifiers = literalSpecImports();
168+
169+
// Guards the guard: if the scan ever stops finding the fixtures' imports,
170+
// the loop below would pass vacuously.
171+
expect(specifiers).toContain('@objectstack/spec');
172+
expect(specifiers.length).toBeGreaterThan(5);
173+
174+
for (const specifier of specifiers) {
175+
const target = resolveThroughPaths(specifier, paths);
176+
expect(target, `${specifier} falls through 'paths' to node resolution, i.e. to dist`).not.toBeNull();
177+
expect(relative(SPEC_SRC, target as string).startsWith('..'), `${specifier} resolves outside spec/src`).toBe(
178+
false,
179+
);
180+
expect(existsSync(target as string), `${specifier} resolves to a nonexistent ${target}`).toBe(true);
181+
}
182+
});
183+
184+
it('covers namespaces the fixtures have not reached yet, with one rule rather than a list', () => {
185+
// An enumeration would be green today and stale the first time a fixture
186+
// reaches a new namespace — silently, because the failure mode of a missing
187+
// rule is a PASSING typecheck. Every namespace spec publishes must already
188+
// resolve to source, whether or not a fixture imports it today.
189+
const paths = readTsconfigPaths();
190+
const published = Object.keys(
191+
JSON.parse(readFileSync(resolve(SPEC_SRC, '..', 'package.json'), 'utf8')).exports as Record<string, unknown>,
192+
)
193+
.filter((key) => key.startsWith('./') && !key.includes('.json'))
194+
.map((key) => `@objectstack/spec/${key.slice(2)}`);
195+
196+
expect(published.length).toBeGreaterThan(10);
197+
for (const specifier of published) {
198+
const target = resolveThroughPaths(specifier, paths);
199+
expect(target, `${specifier} is published but falls through to dist`).not.toBeNull();
200+
expect(existsSync(target as string), `${specifier} maps to a nonexistent ${target}`).toBe(true);
201+
}
202+
});
203+
204+
it('refuses the prefix-star spelling that folds every namespace onto one module', () => {
205+
// The tsconfig twin of the Vite object-form trap `vitest.config.ts`
206+
// records. A key spelled `@objectstack/spec*` — star NOT preceded by a
207+
// slash — matches every namespace. It is worse than the Vite version,
208+
// which crashes with ENOTDIR: `spec/src/index.ts` re-exports most of the
209+
// namespace surface, so this one type-checks the fixtures against the
210+
// wrong module and stays GREEN.
211+
for (const key of Object.keys(readTsconfigPaths())) {
212+
expect(/^@objectstack\/spec[^/]*\*/.test(key), `paths key '${key}' matches namespaces by prefix`).toBe(false);
213+
}
214+
});
215+
216+
it('keeps the targets inside the source tree, never inside dist', () => {
217+
for (const targets of Object.values(readTsconfigPaths())) {
218+
for (const target of targets) {
219+
expect(target.split('/').includes('dist'), `paths target '${target}' points into a build artifact`).toBe(false);
220+
expect(target.includes(`spec${sep}src`) || target.includes('spec/src')).toBe(true);
221+
}
222+
}
223+
});
224+
});

packages/qa/downstream-contract/tsconfig.json

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,89 @@
33
"target": "ES2022",
44
"module": "NodeNext",
55
"moduleResolution": "NodeNext",
6+
// `lib` and `types` are here as a CONSEQUENCE of the `paths` block below,
7+
// not as a preference. Putting `packages/spec/src` into this program means
8+
// tsc now checks spec's own source files, which are written against spec's
9+
// own environment (`packages/spec/tsconfig.json`: `lib` ES + DOM +
10+
// DOM.Iterable, `types` node). Without them the first run reported two
11+
// `TS2591: Cannot find name 'process'` in `spec/src/shared/lazy-schema.ts`
12+
// — a verdict about THIS package's compiler environment, not about spec's
13+
// backward compatibility. Mirroring spec's own environment keeps every red
14+
// this gate produces attributable to the spec contract, which is the only
15+
// thing it is allowed to be read as.
16+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
17+
"types": ["node"],
618
"strict": true,
719
"esModuleInterop": true,
820
"forceConsistentCasingInFileNames": true,
921
"skipLibCheck": true,
1022
"noEmit": true,
11-
"rootDir": "."
23+
// `rootDir: "."` was removed for the same reason. It emits nothing under
24+
// `noEmit`, but it is still enforced: with spec source in the program it
25+
// produced 247 x `TS6059: File '.../packages/spec/src/...' is not under
26+
// 'rootDir'`, drowning the one error the gate exists to print.
27+
//
28+
// -- Why `paths` at all (#8021) ----------------------------------------
29+
//
30+
// This package is the repo's backward-compatibility gate for
31+
// `@objectstack/spec` (#2035), and its README gives typecheck a job the
32+
// suite next door cannot do: "a removed or NARROWED export fails here".
33+
// It declared no `paths`, so under NodeNext tsc resolved
34+
// `@objectstack/spec` through the workspace link and the `exports` map --
35+
// i.e. `dist/index.d.mts`, A BUILD ARTIFACT. The type half was therefore
36+
// rendering a verdict about the last `pnpm build`, exactly as the runtime
37+
// half was before #7991 aliased vitest to source.
38+
//
39+
// Measured on this package, direction predicted before running, one
40+
// variable moved -- identical checkout, identical stale `dist`
41+
// (`.build-input-hash cf8635aa...`), a narrowing injected into SOURCE only
42+
// and no rebuild (`spec/src/integration/connector.zod.ts`, `label:
43+
// z.string()` narrowed to `z.number()`, which the frozen `DcConnector`
44+
// fixture assigns `'DC HubSpot'` to):
45+
//
46+
// without this `paths` block tsc --noEmit -> exit 0, CLEAN
47+
// with this `paths` block src/additional-domains.fixtures.ts(35,3):
48+
// error TS2322: Type 'string' is not
49+
// assignable to type 'number'.
50+
//
51+
// Turbo orders `typecheck` after `^build`, so `turbo run typecheck` was
52+
// never the failing path and needs no change. What breaks is every path
53+
// turbo does not mediate: `pnpm --filter ... typecheck` in the package, an
54+
// editor's TS server, or an agent in a tree built at an older commit --
55+
// i.e. exactly the paths this gate is re-read on WHILE someone is changing
56+
// the spec, when it most needs to be telling the truth.
57+
//
58+
// -- The shape of the two rules ----------------------------------------
59+
//
60+
// ONE subpath rule for every namespace, never an enumeration: the fixtures
61+
// reach `automation` / `data` / `identity` / `integration` / `security` /
62+
// `system` / `ui` today, and a hand-maintained list goes stale silently the
63+
// first time a fixture reaches a new one -- silently, because the failure
64+
// mode is a green typecheck.
65+
//
66+
// The anchoring discipline #7991 records for Vite's ALIAS applies here in
67+
// a different spelling, so it is worth stating exactly. A tsconfig `paths`
68+
// key WITHOUT a `*` is an EXACT match, so the bare `@objectstack/spec`
69+
// entry cannot swallow `@objectstack/spec/ui` the way a Vite object-form
70+
// alias does (that trap is `ENOTDIR` at run time; see `vitest.config.ts`).
71+
// The tsconfig-shaped version of the same mistake is spelling the key
72+
// `@objectstack/spec*` -- star NOT preceded by a slash -- which matches
73+
// every namespace and folds it onto whatever the target names. That one is
74+
// WORSE than the Vite trap, because `spec/src/index.ts` re-exports most of
75+
// the namespace surface, so it does not crash: it type-checks the fixtures
76+
// against the wrong module and stays green. The pin cases in
77+
// `test/source-resolution.pin.test.ts` refuse that spelling by simulating
78+
// this block's resolution.
79+
//
80+
// Both rules are load-bearing and they fail INDEPENDENTLY -- measured,
81+
// with the subpath rule left in place and only the bare rule deleted, tsc
82+
// was still CLEAN while `src/stack.ts`'s `defineStack` types came from
83+
// `dist`. A missing rule here does not announce itself; that is what the
84+
// pin cases are for.
85+
"paths": {
86+
"@objectstack/spec": ["../../spec/src/index.ts"],
87+
"@objectstack/spec/*": ["../../spec/src/*/index.ts"]
88+
}
1289
},
1390
"include": ["src/**/*", "test/**/*"]
1491
}

0 commit comments

Comments
 (0)