11// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
33import { 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' ;
47import { defineConnector as fromPackageRoot } from '@objectstack/spec' ;
58import { 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 ( / f r o m \s + ' ( @ o b j e c t s t a c k \/ s p e c (?: \/ [ ^ ' ] + ) ? ) ' / 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 ( / ^ @ o b j e c t s t a c k \/ s p e c [ ^ / ] * \* / . 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+ } ) ;
0 commit comments