4545 * still declares `ts-morph` under `dependencies` before symlinking, because the
4646 * copy this file hands over is a copy a real consumer would never receive.
4747 *
48+ * ## Names are not shapes — why a consumer-side `tsc` runs here too (#15630)
49+ *
50+ * `declaredExports()` below reads the packed `.d.ts` for exported NAMES and
51+ * star re-exports. That is the barrel question, and it is not the contract
52+ * question: three changes that break every consumer of this newly-public
53+ * surface leave all four names in place, so a name-only pin passes green
54+ * through each of them — a signature change to any ratified export, a renamed
55+ * field on `ExtractedBody`, and a member dropped from the `HookBodyRefusalKind`
56+ * union. The last is the sharpest: those members became a public type the
57+ * moment these subpaths were ratified, so removing one is a breaking change to
58+ * a published union that the pin existing to hold this surface would not
59+ * notice.
60+ *
61+ * So a fixture is compiled by a real `tsc` from the consumer directory,
62+ * against the PACKED `.d.ts` reached through the `exports` map — never the
63+ * source tree. The distinction is the same one this file already draws for
64+ * resolution, and it is not a formality: the source tree can be correct while
65+ * the shipped `.d.ts` is not, and a `types` condition that stops resolving is
66+ * invisible to every workspace-internal check. The fixture has two halves,
67+ * because a type-level pin that cannot fail is worth nothing:
68+ *
69+ * - **assertions** — invariant type identity (`Equals`) against the ratified
70+ * shape, so a widening reds exactly as loudly as a narrowing;
71+ * - **controls** — `@ts-expect-error` directives over deliberately wrong
72+ * expectations, one per failure mode above. Each MUST error; a directive
73+ * that stops firing is itself reported (TS2578). That is what keeps the
74+ * assertions from going vacuous should the packed types ever resolve to
75+ * `any`, and it carries this card's ablation into CI permanently rather
76+ * than leaving it in a PR body.
77+ *
78+ * ⛔ The fixture is a STRING written into the consumer directory, not a `.ts`
79+ * file under `test/`. A file there is compiled by this package's own
80+ * `tsconfig.test.json`, where the same import resolves through the workspace —
81+ * i.e. to a build artifact, which `check:type-source-resolution` refuses — so
82+ * checking it in would answer a different question under the same name.
83+ *
4884 * ## What this file deliberately does NOT do
4985 *
5086 * It does not assert the extractor's behaviour beyond one clean body and two
5187 * classified refusals — `test/extract-hook-body.test.ts` owns that, over the
5288 * source. What this file owns is the DOOR: that the ratified subpath resolves
5389 * under both `require` and `import` conditions, that it exposes exactly the
54- * four ratified names and nothing the internal module may grow next, that the
90+ * four ratified names and nothing the internal module may grow next, that
91+ * those four still carry the SHAPES a consumer compiles against, that the
5592 * deep `dist/` path STAYS sealed, and that the extractor which answers from the
5693 * packed copy is the platform's own (its refusal is a `HookBodyExtractionError`
5794 * carrying `kind`, not a bare `Error`). ⚠️ That refusal is a build-time class,
@@ -71,6 +108,7 @@ import {
71108 symlinkSync ,
72109 writeFileSync ,
73110} from 'node:fs' ;
111+ import { createRequire } from 'node:module' ;
74112import { tmpdir } from 'node:os' ;
75113import { basename , join , resolve } from 'node:path' ;
76114import { fileURLToPath } from 'node:url' ;
@@ -172,6 +210,149 @@ try {
172210process.stdout.write(JSON.stringify(out));
173211` ;
174212
213+ /**
214+ * The consumer's `tsconfig.json`. Three options carry the whole question:
215+ *
216+ * - `moduleResolution: nodenext` is what makes this a test of the PUBLISHED
217+ * door — it reads the `exports` map's `types` condition, so a condition
218+ * that stops resolving is `TS2307` here, exactly as it would be for a real
219+ * dependent. `bundler` would answer a laxer question under the same name.
220+ * - `strict` — a shape assertion under a non-strict program is a weaker
221+ * assertion, and `strictNullChecks` in particular is load-bearing for the
222+ * optional-parameter half of the constructor pin.
223+ * - `skipLibCheck` — the consumer directory installs this ONE tarball, so the
224+ * `.d.ts` files of the workspace dependencies it references are absent by
225+ * construction. Checking them would report their absence, which is a fact
226+ * about the fixture's cupboard and not about the ratified surface. It does
227+ * not weaken anything asserted below: `conformance.ts` is not a declaration
228+ * file, so every diagnostic in IT is still reported.
229+ *
230+ * `types: []` keeps `@types/node` out of the program — the fixture reaches for
231+ * no Node global, and a missing ambient package would otherwise red for a
232+ * reason that has nothing to do with this surface.
233+ */
234+ const CONSUMER_TSCONFIG = JSON . stringify (
235+ {
236+ compilerOptions : {
237+ strict : true ,
238+ noEmit : true ,
239+ skipLibCheck : true ,
240+ target : 'es2022' ,
241+ lib : [ 'ES2022' ] ,
242+ module : 'nodenext' ,
243+ moduleResolution : 'nodenext' ,
244+ types : [ ] ,
245+ } ,
246+ include : [ 'conformance.ts' ] ,
247+ } ,
248+ null ,
249+ 2 ,
250+ ) ;
251+
252+ /**
253+ * The consumer the `.d.ts` is compiled for (#15630). Written into the consumer
254+ * directory rather than checked in under `test/` — this file's header says why.
255+ *
256+ * `Equals` is the invariant identity check, not an assignability check: two
257+ * types satisfy it only when tsc considers them THE SAME, so a member added to
258+ * a union reds as loudly as one removed. An assignability pin would let every
259+ * widening through, and a widening on a published union is the half that breaks
260+ * an exhaustive `switch` in a dependent.
261+ *
262+ * ⛔ Every `@ts-expect-error` below is a CONTROL and must stay unsatisfiable-on
263+ * -purpose. If a real change makes one of them legal, the directive goes unused
264+ * and tsc reports TS2578 — which is the pin telling you the contract moved, not
265+ * a lint to silence.
266+ */
267+ const CONFORMANCE_FIXTURE = `
268+ import type { ExtractedBody, HookBodyRefusalKind } from '@objectstack/cli/hook-body';
269+ import { HookBodyExtractionError, extractHookBody } from '@objectstack/cli/hook-body';
270+
271+ type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
272+ type Expect<T extends true> = T;
273+
274+ // --- HookBodyRefusalKind: the exact union, member for member ---------------
275+ type RefusalKindIsExactlyTheThreeRatifiedMembers = Expect<Equals<HookBodyRefusalKind, 'unparseable' | 'forbidden-token' | 'free-identifiers'>>;
276+
277+ // --- ExtractedBody: the exact field set, then the exact whole shape --------
278+ // The keyof assertion is redundant against the whole-shape one and kept
279+ // anyway: a renamed field reds on BOTH, and the keyof diagnostic names the
280+ // field, which is the sentence a reader needs first.
281+ type ExtractedBodyHasExactlyTheseThreeFields = Expect<Equals<keyof ExtractedBody, 'source' | 'capabilities' | 'isExpression'>>;
282+ type ExtractedBodyMembersKeepTheirRatifiedTypes = Expect<
283+ Equals<
284+ ExtractedBody,
285+ { source: string; capabilities: Array<'api.read' | 'api.write' | 'crypto.uuid' | 'log'>; isExpression: boolean }
286+ >
287+ >;
288+
289+ // --- extractHookBody: the exact signature ---------------------------------
290+ type ExtractHookBodyKeepsItsRatifiedSignature = Expect<Equals<typeof extractHookBody, (fn: (...a: unknown[]) => unknown, originLabel: string) => ExtractedBody>>;
291+
292+ // --- HookBodyExtractionError: what it adds to Error, and how it is built ---
293+ type RefusalErrorAddsExactlyTheseFourMembers = Expect<Equals<Exclude<keyof HookBodyExtractionError, keyof Error>, 'kind' | 'originLabel' | 'freeIdentifiers' | 'nodeOnlyIdentifiers'>>;
294+ type RefusalErrorMembersKeepTheirRatifiedTypes = Expect<
295+ Equals<
296+ Pick<HookBodyExtractionError, 'kind' | 'originLabel' | 'freeIdentifiers' | 'nodeOnlyIdentifiers'>,
297+ {
298+ readonly kind: HookBodyRefusalKind;
299+ readonly originLabel: string;
300+ readonly freeIdentifiers: readonly string[];
301+ readonly nodeOnlyIdentifiers: readonly string[];
302+ }
303+ >
304+ >;
305+ type RefusalErrorIsStillAnError = Expect<HookBodyExtractionError extends Error ? true : false>;
306+ type RefusalErrorConstructorKeepsItsRatifiedParameters = Expect<Equals<ConstructorParameters<typeof HookBodyExtractionError>, [HookBodyRefusalKind, string, string, (readonly string[])?, (readonly string[])?]>>;
307+
308+ // --- The consumer limb: code a real dependent writes, compiled for real ----
309+ // The assertions above answer "did the shape move". This answers the question
310+ // the shape exists for — can a dependent still WRITE the ordinary thing.
311+ export function describeExtraction(fn: (...a: unknown[]) => unknown, label: string): string {
312+ try {
313+ const body: ExtractedBody = extractHookBody(fn, label);
314+ return body.isExpression ? 'expr:' + body.source : 'block:' + body.capabilities.join(',');
315+ } catch (err) {
316+ if (err instanceof HookBodyExtractionError) {
317+ const kind: HookBodyRefusalKind = err.kind;
318+ return kind + '@' + err.originLabel + ':' + err.freeIdentifiers.join(',') + '/' + err.nodeOnlyIdentifiers.join(',');
319+ }
320+ throw err;
321+ }
322+ }
323+
324+ // --- Controls: one per failure mode the name-only pin passed green through --
325+ // Each directive below MUST fire. An unused one is TS2578, so these are what
326+ // keep the assertions above from going vacuous — if the packed types ever
327+ // resolved to \`any\`, or \`Equals\` stopped discriminating, every control here
328+ // turns red at once.
329+
330+ // @ts-expect-error CONTROL — a member dropped from the union must not satisfy the identity check
331+ type MemberDroppedFromUnionMustRed = Expect<Equals<HookBodyRefusalKind, 'forbidden-token' | 'free-identifiers'>>;
332+ // @ts-expect-error CONTROL — a renamed field must not satisfy the identity check
333+ type FieldRenamedOnExtractedBodyMustRed = Expect<Equals<keyof ExtractedBody, 'source' | 'capabilities' | 'isExpr'>>;
334+ // @ts-expect-error CONTROL — a dropped parameter must not satisfy the identity check
335+ type SignatureChangeMustRed = Expect<Equals<typeof extractHookBody, (fn: (...a: unknown[]) => unknown) => ExtractedBody>>;
336+
337+ // The same three, met the way a dependent meets them rather than through a
338+ // type-level identity check — a value assignment, a call and a field read.
339+ // @ts-expect-error CONTROL — 'unparsable' is not a member of the ratified union
340+ const notAMemberOfTheUnion: HookBodyRefusalKind = 'unparsable';
341+ // @ts-expect-error CONTROL — originLabel is a REQUIRED second parameter
342+ const callWithoutOriginLabel = extractHookBody(() => undefined);
343+ // @ts-expect-error CONTROL — ExtractedBody declares isExpression, never isExpr
344+ type ReadOfARenamedField = ExtractedBody['isExpr'];
345+ // @ts-expect-error CONTROL — the refusal's identifier lists are readonly to a consumer
346+ const writeToAReadonlyMember = (e: HookBodyExtractionError): void => { e.freeIdentifiers = []; };
347+ ` ;
348+
349+ /** The fixture, line-numbered, so a tsc diagnostic's line points at something. */
350+ function numbered ( source : string ) : string {
351+ const lines = source . split ( '\n' ) ;
352+ const width = String ( lines . length ) . length ;
353+ return lines . map ( ( line , i ) => `${ String ( i + 1 ) . padStart ( width , ' ' ) } | ${ line } ` ) . join ( '\n' ) ;
354+ }
355+
175356interface Resolution {
176357 ok : boolean ;
177358 path ?: string ;
@@ -260,6 +441,7 @@ let scratch: string;
260441let packedFiles : string [ ] ;
261442let installedRoot : string ;
262443let probe : ProbeResult ;
444+ let conformance : { status : number ; output : string } ;
263445
264446beforeAll ( ( ) => {
265447 const rootEntry = MANIFEST . exports [ '.' ] ;
@@ -315,6 +497,31 @@ beforeAll(() => {
315497 } ) ;
316498 if ( run . status !== 0 ) throw new Error ( `probe exited ${ run . status } \n--- stderr ---\n${ run . stderr } \n--- stdout ---\n${ run . stdout } ` ) ;
317499 probe = JSON . parse ( run . stdout ) as ProbeResult ;
500+
501+ // #15630 — the SHAPE half. A sibling directory, not `consumer` itself: its
502+ // own `package.json` declares `type: module` so `nodenext` classifies the
503+ // fixture as ESM (this package IS ESM-only, and a CJS-classified fixture
504+ // would red with TS1479 — a fact about the fixture's own manifest, not about
505+ // the ratified surface). Nothing of the probe's environment changes.
506+ const typecheckDir = join ( consumer , 'typecheck' ) ;
507+ mkdirSync ( typecheckDir ) ;
508+ writeFileSync (
509+ join ( typecheckDir , 'package.json' ) ,
510+ JSON . stringify ( { name : 'objectstack-cli-hook-body-consumer' , private : true , type : 'module' } , null , 2 ) ,
511+ ) ;
512+ writeFileSync ( join ( typecheckDir , 'tsconfig.json' ) , CONSUMER_TSCONFIG ) ;
513+ writeFileSync ( join ( typecheckDir , 'conformance.ts' ) , CONFORMANCE_FIXTURE ) ;
514+ // The compiler is resolved from THIS package (a consumer brings its own tsc;
515+ // the version question is not what this file pins), but it is spawned with
516+ // the consumer directory as cwd, so what it RESOLVES it resolves from there.
517+ const tscEntry = createRequire ( import . meta. url ) . resolve ( 'typescript/lib/tsc.js' ) ;
518+ const tsc = spawnSync ( process . execPath , [ tscEntry , '--pretty' , 'false' , '-p' , 'tsconfig.json' ] , {
519+ cwd : typecheckDir ,
520+ encoding : 'utf8' ,
521+ env : childEnv ( ) ,
522+ } ) ;
523+ if ( tsc . error ) throw new Error ( `tsc could not start: ${ tsc . error . message } ` ) ;
524+ conformance = { status : tsc . status ?? - 1 , output : `${ tsc . stdout ?? '' } ${ tsc . stderr ?? '' } ` . trim ( ) } ;
318525} , 120_000 ) ;
319526
320527afterAll ( ( ) => {
@@ -386,6 +593,19 @@ describe('the ratified surface is exactly four names', () => {
386593 } ) ;
387594} ) ;
388595
596+ describe ( 'the ratified surface still has the SHAPES a consumer compiles against (#15630)' , ( ) => {
597+ it ( 'compiles a real consumer against the PACKED .d.ts, reached through the exports map' , ( ) => {
598+ expect (
599+ conformance . output ,
600+ 'tsc reported diagnostics compiling the conformance fixture against the packed .d.ts. Either the ratified ' +
601+ 'shape moved — in which case this is a BREAKING change to a published surface and the fixture is updated ' +
602+ 'deliberately, with a changeset — or a CONTROL stopped firing (TS2578), which says the same thing from the ' +
603+ `other side. The fixture, numbered:\n${ numbered ( CONFORMANCE_FIXTURE ) } ` ,
604+ ) . toBe ( '' ) ;
605+ expect ( conformance . status , 'tsc exited non-zero' ) . toBe ( 0 ) ;
606+ } ) ;
607+ } ) ;
608+
389609describe ( 'the extractor that answers from the packed copy is the platform\'s own' , ( ) => {
390610 it ( 'lowers a clean body to the metadata-only source os build ships' , ( ) => {
391611 const runtime = probe . runtime as Extract < ProbeResult [ 'runtime' ] , { lowered : unknown } > ;
0 commit comments