Skip to content

Commit 502b2f1

Browse files
committed
test(cli): pin the SHAPES of the ratified hook-body surface, not only its names
`published-subpath-hook-body.pin.test.ts` read the packed `.d.ts` for exported names and star re-exports only, so three changes that break every consumer of the newly-public `@objectstack/cli/hook-body` surface left all four names in place and passed green: a signature change to any ratified export, a renamed field on `ExtractedBody`, and a member dropped from the `HookBodyRefusalKind` union. A conformance fixture is now compiled by a real `tsc` from the consumer directory, against the PACKED `.d.ts` reached through the `exports` map. It carries invariant type-identity assertions over all four exports plus a consumer limb that writes the ordinary thing, and a control per failure mode: `@ts-expect-error` directives that must fire, so an assertion that goes vacuous is reported as TS2578 rather than passing silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
1 parent 2024eca commit 502b2f1

1 file changed

Lines changed: 221 additions & 1 deletion

File tree

packages/cli/test/published-subpath-hook-body.pin.test.ts

Lines changed: 221 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,50 @@
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';
74112
import { tmpdir } from 'node:os';
75113
import { basename, join, resolve } from 'node:path';
76114
import { fileURLToPath } from 'node:url';
@@ -172,6 +210,149 @@ try {
172210
process.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+
175356
interface Resolution {
176357
ok: boolean;
177358
path?: string;
@@ -260,6 +441,7 @@ let scratch: string;
260441
let packedFiles: string[];
261442
let installedRoot: string;
262443
let probe: ProbeResult;
444+
let conformance: { status: number; output: string };
263445

264446
beforeAll(() => {
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

320527
afterAll(() => {
@@ -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+
389609
describe('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

Comments
 (0)