|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Root-entry nameability pin (#11350) — the consumer shape, verbatim. |
| 5 | + * |
| 6 | + * ## The invariant this pins |
| 7 | + * |
| 8 | + * A type that appears structurally in an entry's public declarations must be |
| 9 | + * nameable from that same entry (maintainer ruling 2026-08-23, recorded on |
| 10 | + * #11350). The measured breakage: `defineStack` returns |
| 11 | + * `ObjectStackDefinition`, declared `z.input<typeof |
| 12 | + * ObjectStackDefinitionSchema>` — a generic instantiation the declaration |
| 13 | + * emitter does not preserve as an alias — so an un-annotated |
| 14 | + * `export default defineStack(...)` is emitted as the STRUCTURAL expansion. |
| 15 | + * That expansion mentions `FormFieldInput` / `NavigationItemInput` / |
| 16 | + * `StateNodeConfig`, and until #11350 the root entry did not re-export them, |
| 17 | + * so tsc could only name them through the hash-named internal dist chunk that |
| 18 | + * physically declares them — unaddressable through the package's `exports` |
| 19 | + * map → TS2883 ("likely not portable") in every consumer inferring a type |
| 20 | + * through a root-entry function. Nine build-time configs hit it before the |
| 21 | + * first one was diagnosed (#10868). |
| 22 | + * |
| 23 | + * ## What each program proves |
| 24 | + * |
| 25 | + * - **consumer** — the repro's exact shape: an un-annotated |
| 26 | + * `export default defineStack(...)`, compiled with `declaration: true` |
| 27 | + * against the BUILT root entry, resolved the way a real consumer resolves it |
| 28 | + * (a `node_modules/@objectstack/spec` symlink + the package's own `exports` |
| 29 | + * map — the same physical resolution a pnpm workspace consumer performs; |
| 30 | + * measured on #11350: this program produced exactly 3 × TS2883 against the |
| 31 | + * pre-fix dist and 0 diagnostics against the fixed one). Asserted green. |
| 32 | + * |
| 33 | + * The program is two files on purpose, mirroring the real consumers: every |
| 34 | + * one of the nine i18n-extract configs' programs also contains its object |
| 35 | + * modules, which import `@objectstack/spec/data` — and #11350's control |
| 36 | + * measured that a program file importing a subpath entry makes that entry's |
| 37 | + * names NAMEABLE program-wide. `context.ts` reproduces that, which is what |
| 38 | + * scopes this pin to the ruled three (ui/automation names, reachable only |
| 39 | + * via the root re-exports under pin). Measured against this same dist: the |
| 40 | + * MINIMAL one-file program leaks two MORE names through `/data` |
| 41 | + * (`BaseValidationRuleShape`, `FilterCondition`) that the fixed root entry |
| 42 | + * still cannot name — deliberately NOT pinned here; that is #11350's |
| 43 | + * recorded premise delta, filed as #11709 for its own ruling. For the same |
| 44 | + * reason the program contains no `@objectstack/spec/ui` or `/automation` |
| 45 | + * import and no direct `import type { FormFieldInput, … }` — any of those |
| 46 | + * would mask the very symptom under pin. Direct existence of the three root |
| 47 | + * exports is owned by `api-surface/root.json` + `check:api-surface` instead. |
| 48 | + * |
| 49 | + * - **canary** — the anti-phantom probe. TS2883 is a DECLARATION-EMIT |
| 50 | + * diagnostic: drop `declaration: true` from the harness profile and the |
| 51 | + * consumer program goes green forever, regression or no regression — a gate |
| 52 | + * only ever observed green is indistinguishable from one that matches |
| 53 | + * nothing. The canary is a hermetic fixture whose only error is also |
| 54 | + * declaration-emit-only — TS4094, a private member on an exported anonymous |
| 55 | + * class type (measured: exit 2 with `declaration: true`, exit 0 without) — |
| 56 | + * so it stays red exactly as long as the harness keeps checking the axis |
| 57 | + * the pin lives on. Asserted red. |
| 58 | + * |
| 59 | + * ## Dist freshness — an environment-gated measurement, the live-dialect-cell |
| 60 | + * shape |
| 61 | + * |
| 62 | + * The subject under test is `dist/index.d.ts`, not `src/` — the same artifact |
| 63 | + * `check:api-surface` reads, judged by the same staleness rule (#7122/#7181): |
| 64 | + * a stale dist would let a root re-export removed from `src/index.ts` sit |
| 65 | + * green here until the next rebuild. |
| 66 | + * |
| 67 | + * But absence of that artifact is an ENVIRONMENT fact, not a defect: turbo's |
| 68 | + * `test` task depends on `^build` (dependencies only), so the Test Core lane |
| 69 | + * deliberately runs spec's own suite with spec's own dist unbuilt — a throw |
| 70 | + * here reds a whole CI shard for a measurement that lane was never equipped |
| 71 | + * to make (measured on PR #11716's first round: 420/421 files passed, only |
| 72 | + * this file failed, at the old beforeAll throw). So the pin follows |
| 73 | + * `live-dialect-matrix.testkit.ts`'s discipline — REPORTED, never omitted, |
| 74 | + * with no third outcome: |
| 75 | + * |
| 76 | + * - dist fresh → the two programs run, both modes. |
| 77 | + * - dist missing/stale, default → a NAMED SKIP whose title carries the |
| 78 | + * refusal reason ("it was not run" stays readable in the output). Never a |
| 79 | + * silent pass, never a throw. |
| 80 | + * - dist missing/stale under `OS_EXPECT_ROOT_NAMEABILITY=1` → a FAILURE |
| 81 | + * quoting the freshness refusal: that flag is set only by a runner that |
| 82 | + * declared it builds spec's dts first, so a skip there would be the pin |
| 83 | + * quietly degrading to never-measured — the #4690 shape. |
| 84 | + * |
| 85 | + * Where it runs for real in CI: the `Type Check · consumer gates` lane |
| 86 | + * (lint.yml `typecheck-consumers`) sets the flag right after its full |
| 87 | + * packages-closure builds, beside `check:api-surface` / `check:skill-examples` |
| 88 | + * — the other consumer-shaped gates that read the built dist. |
| 89 | + */ |
| 90 | + |
| 91 | +import { spawnSync } from 'node:child_process'; |
| 92 | +import fs from 'node:fs'; |
| 93 | +import { createRequire } from 'node:module'; |
| 94 | +import os from 'node:os'; |
| 95 | +import path from 'node:path'; |
| 96 | +import { fileURLToPath } from 'node:url'; |
| 97 | + |
| 98 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; |
| 99 | + |
| 100 | +import { inspectDistFreshness } from './lib/dist-freshness'; |
| 101 | + |
| 102 | +const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 103 | +const PKG_DIR = path.resolve(HERE, '..'); |
| 104 | +const RERUN = |
| 105 | + 'pnpm --filter @objectstack/spec test scripts/root-entry-type-nameability.pin.test.ts'; |
| 106 | + |
| 107 | +/** One tsc program = its fixture files + one tsconfig, in a shared sandbox. */ |
| 108 | +interface Program { |
| 109 | + files: Record<string, string>; |
| 110 | + tsconfigName: string; |
| 111 | +} |
| 112 | + |
| 113 | +const CONSUMER: Program = { |
| 114 | + files: { |
| 115 | + // The repro's shape, verbatim: un-annotated default export of a |
| 116 | + // root-entry inference. Any structural mention the declaration emitter |
| 117 | + // cannot name from within this program turns it red with the leaked name |
| 118 | + // in the output. |
| 119 | + 'consumer.ts': `import { defineStack } from '@objectstack/spec'; |
| 120 | +
|
| 121 | +export default defineStack({ objects: [] }); |
| 122 | +`, |
| 123 | + // The real programs' shape: the configs' object modules import |
| 124 | + // `@objectstack/spec/data`, making /data's names nameable in-program |
| 125 | + // (#11350's control) — see the docblock for why this scopes the pin. |
| 126 | + 'context.ts': `import type { Field } from '@objectstack/spec/data'; |
| 127 | +
|
| 128 | +export type AuditObjectShape = { fields: Record<string, Field> }; |
| 129 | +`, |
| 130 | + }, |
| 131 | + tsconfigName: 'tsconfig.consumer.json', |
| 132 | +}; |
| 133 | + |
| 134 | +const CANARY: Program = { |
| 135 | + files: { |
| 136 | + // Declaration-emit-only error: TS4094, private member on an exported |
| 137 | + // anonymous class type. Runs the same compiler profile as the consumer |
| 138 | + // program; red here proves the profile still checks declaration emit. |
| 139 | + 'canary.ts': `export const probe = new (class { private x = 1; })(); |
| 140 | +`, |
| 141 | + }, |
| 142 | + tsconfigName: 'tsconfig.canary.json', |
| 143 | +}; |
| 144 | + |
| 145 | +let sandbox = ''; |
| 146 | + |
| 147 | +function writeProgram(program: Program): void { |
| 148 | + for (const [name, source] of Object.entries(program.files)) { |
| 149 | + fs.writeFileSync(path.join(sandbox, name), source); |
| 150 | + } |
| 151 | + const tsconfig = { |
| 152 | + compilerOptions: { |
| 153 | + target: 'ES2022', |
| 154 | + module: 'NodeNext', |
| 155 | + moduleResolution: 'NodeNext', |
| 156 | + strict: true, |
| 157 | + // Load-bearing: TS2883 (and the canary's TS4094) exist only on the |
| 158 | + // declaration-emit axis. `noEmit` keeps the sandbox clean; tsc still |
| 159 | + // runs the declaration emitter's checks when `declaration` is on. |
| 160 | + declaration: true, |
| 161 | + noEmit: true, |
| 162 | + skipLibCheck: true, |
| 163 | + types: [], |
| 164 | + }, |
| 165 | + include: Object.keys(program.files), |
| 166 | + }; |
| 167 | + fs.writeFileSync( |
| 168 | + path.join(sandbox, program.tsconfigName), |
| 169 | + JSON.stringify(tsconfig, null, 2), |
| 170 | + ); |
| 171 | +} |
| 172 | + |
| 173 | +function runTsc(program: Program): { code: number; output: string } { |
| 174 | + const require = createRequire(import.meta.url); |
| 175 | + const tscBin = require.resolve('typescript/bin/tsc'); |
| 176 | + const res = spawnSync( |
| 177 | + process.execPath, |
| 178 | + [tscBin, '--pretty', 'false', '-p', path.join(sandbox, program.tsconfigName)], |
| 179 | + { cwd: sandbox, encoding: 'utf-8' }, |
| 180 | + ); |
| 181 | + return { code: res.status ?? 1, output: `${res.stdout ?? ''}${res.stderr ?? ''}` }; |
| 182 | +} |
| 183 | + |
| 184 | +/** |
| 185 | + * Read the environment ONCE, at collection time, exactly as |
| 186 | + * `live-dialect-matrix.testkit.ts` reads its cell URLs: the branch below is |
| 187 | + * total — measured when the dist is readable, a named skip or an expected-mode |
| 188 | + * failure when it is not — so there is no third outcome and no throw that |
| 189 | + * could red a lane never equipped to measure this. |
| 190 | + */ |
| 191 | +const EXPECT_BUILT_DIST = process.env.OS_EXPECT_ROOT_NAMEABILITY === '1'; |
| 192 | +const FRESHNESS = inspectDistFreshness(PKG_DIR, 'check', RERUN); |
| 193 | + |
| 194 | +if (!FRESHNESS.fresh) { |
| 195 | + describe('root-entry type nameability (#11350)', () => { |
| 196 | + it.skipIf(!EXPECT_BUILT_DIST)( |
| 197 | + `spec's dist declarations are ${FRESHNESS.state} — this pin reads the BUILT root entry; ` + |
| 198 | + `build @objectstack/spec first, then: ${RERUN} (skipped by default; ` + |
| 199 | + `OS_EXPECT_ROOT_NAMEABILITY=1 turns this into a failure)`, |
| 200 | + () => { |
| 201 | + expect.fail( |
| 202 | + `OS_EXPECT_ROOT_NAMEABILITY=1 while spec's dist declarations are ${FRESHNESS.state}: ` + |
| 203 | + `this runner declared it builds spec's dts before the suite, so this pin must not ` + |
| 204 | + `be skipped (a skip here would be the pin quietly degrading to never-measured, #4690).\n` + |
| 205 | + FRESHNESS.message, |
| 206 | + ); |
| 207 | + }, |
| 208 | + ); |
| 209 | + }); |
| 210 | +} else { |
| 211 | + describe('root-entry type nameability (#11350)', () => { |
| 212 | + beforeAll(() => { |
| 213 | + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'os-root-nameability-')); |
| 214 | + // A real consumer's resolution, physically: a node_modules symlink into |
| 215 | + // the built package, so tsc walks the package's own `exports` map and |
| 216 | + // lands on `dist/index.d.ts` — the same realpath a pnpm workspace |
| 217 | + // symlink produces (the #11350 measurement fired TS2883 through exactly |
| 218 | + // this layout). |
| 219 | + const scope = path.join(sandbox, 'node_modules', '@objectstack'); |
| 220 | + fs.mkdirSync(scope, { recursive: true }); |
| 221 | + fs.symlinkSync(PKG_DIR, path.join(scope, 'spec'), 'dir'); |
| 222 | + |
| 223 | + writeProgram(CONSUMER); |
| 224 | + writeProgram(CANARY); |
| 225 | + }); |
| 226 | + |
| 227 | + afterAll(() => { |
| 228 | + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); |
| 229 | + }); |
| 230 | + |
| 231 | + it('an un-annotated `export default defineStack(...)` declaration-emits clean against the built root entry', () => { |
| 232 | + const { code, output } = runTsc(CONSUMER); |
| 233 | + expect( |
| 234 | + code, |
| 235 | + `expected 0 diagnostics; a TS2883 naming a dist chunk means a type the root entry's ` + |
| 236 | + `public declarations mention structurally is no longer nameable from the root entry ` + |
| 237 | + `(re-export it from src/index.ts — see #11350). tsc said:\n${output}`, |
| 238 | + ).toBe(0); |
| 239 | + expect(output).not.toMatch(/error TS\d+/); |
| 240 | + }); |
| 241 | + |
| 242 | + it('canary: the harness profile still checks the declaration-emit axis', () => { |
| 243 | + const { code, output } = runTsc(CANARY); |
| 244 | + expect( |
| 245 | + code, |
| 246 | + `the canary fixture's declaration-emit error disappeared — if the harness profile ` + |
| 247 | + `lost \`declaration: true\`, the consumer pin above is green no matter what leaks. ` + |
| 248 | + `tsc said:\n${output}`, |
| 249 | + ).not.toBe(0); |
| 250 | + // Measured: TS4094 ("Property 'x' of exported anonymous class type may |
| 251 | + // not be private or protected"). Pin the TS4xxx declaration-emit family + |
| 252 | + // the message's substance rather than the bare number, so a |
| 253 | + // compiler-version renumbering does not false-red this line. |
| 254 | + expect(output).toMatch(/error TS4\d{2,3}: .*private/); |
| 255 | + }); |
| 256 | + }); |
| 257 | +} |
0 commit comments