From 22a9e0ce30e1fb03e8489534cdd0fd6bd7d94845 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 13:17:32 +0000 Subject: [PATCH 1/5] fix(spec): re-export the three types the root entry's own inferred types mention (#11350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An un-annotated `export default defineStack(...)` is emitted as the structural expansion of `ObjectStackDefinition` (declared `z.input<...>`, which the declaration emitter does not preserve as an alias). That expansion mentions `FormFieldInput` / `NavigationItemInput` / `StateNodeConfig`, which were public on their domain subpaths but not on the root entry — so tsc could only name them through a hash-named internal dist chunk (TS2883 in every consumer inferring through a root-entry function). Invariant recorded (maintainer ruling 2026-08-23): a type that appears structurally in an entry's public declarations must be nameable from that same entry. Measured: the plugin-audit i18n-extract repro goes 3 errors -> 0. api-surface/root.json regenerated via gen:api-surface (+3 rows). Pin test compiles the consumer shape against the built root dts, with a declaration-emit canary guarding the harness axis. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- packages/spec/api-surface/root.json | 3 + .../root-entry-type-nameability.pin.test.ts | 207 ++++++++++++++++++ packages/spec/src/index.ts | 17 ++ 3 files changed, 227 insertions(+) create mode 100644 packages/spec/scripts/root-entry-type-nameability.pin.test.ts diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index 2380440211..12dadaca03 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -58,6 +58,7 @@ "ExpressionSchema (const)", "F (const)", "FIELD_KEY_GUIDANCE (const)", + "FormFieldInput (type)", "GUEST_POSITION (const)", "LintableAuthoringCollection (interface)", "MAP_SUPPORTED_FIELDS (const)", @@ -78,6 +79,7 @@ "MigrationHopResult (interface)", "MigrationStep (interface)", "MigrationTodo (interface)", + "NavigationItemInput (type)", "NormalizeStackInputOptions (interface)", "OBJECT_KEY_GUIDANCE (const)", "ORGANIZATION_ADMIN (const)", @@ -117,6 +119,7 @@ "SpecSurfaceAddSchema (const)", "SpecSurfaceRemove (type)", "SpecSurfaceRemoveSchema (const)", + "StateNodeConfig (type)", "StoredConversionOptions (type)", "SurfaceDiff (interface)", "TemplateExpressionInputSchema (const)", diff --git a/packages/spec/scripts/root-entry-type-nameability.pin.test.ts b/packages/spec/scripts/root-entry-type-nameability.pin.test.ts new file mode 100644 index 0000000000..fc224d5e1f --- /dev/null +++ b/packages/spec/scripts/root-entry-type-nameability.pin.test.ts @@ -0,0 +1,207 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Root-entry nameability pin (#11350) — the consumer shape, verbatim. + * + * ## The invariant this pins + * + * A type that appears structurally in an entry's public declarations must be + * nameable from that same entry (maintainer ruling 2026-08-23, recorded on + * #11350). The measured breakage: `defineStack` returns + * `ObjectStackDefinition`, declared `z.input` — a generic instantiation the declaration + * emitter does not preserve as an alias — so an un-annotated + * `export default defineStack(...)` is emitted as the STRUCTURAL expansion. + * That expansion mentions `FormFieldInput` / `NavigationItemInput` / + * `StateNodeConfig`, and until #11350 the root entry did not re-export them, + * so tsc could only name them through the hash-named internal dist chunk that + * physically declares them — unaddressable through the package's `exports` + * map → TS2883 ("likely not portable") in every consumer inferring a type + * through a root-entry function. Nine build-time configs hit it before the + * first one was diagnosed (#10868). + * + * ## What each program proves + * + * - **consumer** — the repro's exact shape: an un-annotated + * `export default defineStack(...)`, compiled with `declaration: true` + * against the BUILT root entry, resolved the way a real consumer resolves it + * (a `node_modules/@objectstack/spec` symlink + the package's own `exports` + * map — the same physical resolution a pnpm workspace consumer performs; + * measured on #11350: this program produced exactly 3 × TS2883 against the + * pre-fix dist and 0 diagnostics against the fixed one). Asserted green. + * + * The program is two files on purpose, mirroring the real consumers: every + * one of the nine i18n-extract configs' programs also contains its object + * modules, which import `@objectstack/spec/data` — and #11350's control + * measured that a program file importing a subpath entry makes that entry's + * names NAMEABLE program-wide. `context.ts` reproduces that, which is what + * scopes this pin to the ruled three (ui/automation names, reachable only + * via the root re-exports under pin). Measured against this same dist: the + * MINIMAL one-file program leaks two MORE names through `/data` + * (`BaseValidationRuleShape`, `FilterCondition`) that the fixed root entry + * still cannot name — deliberately NOT pinned here; that is #11350's + * recorded premise delta, its repair is a separate ruling. For the same + * reason the program contains no `@objectstack/spec/ui` or `/automation` + * import and no direct `import type { FormFieldInput, … }` — any of those + * would mask the very symptom under pin. Direct existence of the three root + * exports is owned by `api-surface/root.json` + `check:api-surface` instead. + * + * - **canary** — the anti-phantom probe. TS2883 is a DECLARATION-EMIT + * diagnostic: drop `declaration: true` from the harness profile and the + * consumer program goes green forever, regression or no regression — a gate + * only ever observed green is indistinguishable from one that matches + * nothing. The canary is a hermetic fixture whose only error is also + * declaration-emit-only — TS4094, a private member on an exported anonymous + * class type (measured: exit 2 with `declaration: true`, exit 0 without) — + * so it stays red exactly as long as the harness keeps checking the axis + * the pin lives on. Asserted red. + * + * ## Dist freshness + * + * The subject under test is `dist/index.d.ts`, not `src/` — the same artifact + * `check:api-surface` reads, refused on the same staleness rule (#7122/#7181): + * a stale dist would let a root re-export removed from `src/index.ts` sit + * green here until the next rebuild. + */ + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { inspectDistFreshness } from './lib/dist-freshness'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PKG_DIR = path.resolve(HERE, '..'); +const RERUN = + 'pnpm --filter @objectstack/spec test scripts/root-entry-type-nameability.pin.test.ts'; + +/** One tsc program = its fixture files + one tsconfig, in a shared sandbox. */ +interface Program { + files: Record; + tsconfigName: string; +} + +const CONSUMER: Program = { + files: { + // The repro's shape, verbatim: un-annotated default export of a + // root-entry inference. Any structural mention the declaration emitter + // cannot name from within this program turns it red with the leaked name + // in the output. + 'consumer.ts': `import { defineStack } from '@objectstack/spec'; + +export default defineStack({ objects: [] }); +`, + // The real programs' shape: the configs' object modules import + // `@objectstack/spec/data`, making /data's names nameable in-program + // (#11350's control) — see the docblock for why this scopes the pin. + 'context.ts': `import type { Field } from '@objectstack/spec/data'; + +export type AuditObjectShape = { fields: Record }; +`, + }, + tsconfigName: 'tsconfig.consumer.json', +}; + +const CANARY: Program = { + files: { + // Declaration-emit-only error: TS4094, private member on an exported + // anonymous class type. Runs the same compiler profile as the consumer + // program; red here proves the profile still checks declaration emit. + 'canary.ts': `export const probe = new (class { private x = 1; })(); +`, + }, + tsconfigName: 'tsconfig.canary.json', +}; + +let sandbox = ''; + +function writeProgram(program: Program): void { + for (const [name, source] of Object.entries(program.files)) { + fs.writeFileSync(path.join(sandbox, name), source); + } + const tsconfig = { + compilerOptions: { + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + // Load-bearing: TS2883 (and the canary's TS4094) exist only on the + // declaration-emit axis. `noEmit` keeps the sandbox clean; tsc still + // runs the declaration emitter's checks when `declaration` is on. + declaration: true, + noEmit: true, + skipLibCheck: true, + types: [], + }, + include: Object.keys(program.files), + }; + fs.writeFileSync( + path.join(sandbox, program.tsconfigName), + JSON.stringify(tsconfig, null, 2), + ); +} + +function runTsc(program: Program): { code: number; output: string } { + const require = createRequire(import.meta.url); + const tscBin = require.resolve('typescript/bin/tsc'); + const res = spawnSync( + process.execPath, + [tscBin, '--pretty', 'false', '-p', path.join(sandbox, program.tsconfigName)], + { cwd: sandbox, encoding: 'utf-8' }, + ); + return { code: res.status ?? 1, output: `${res.stdout ?? ''}${res.stderr ?? ''}` }; +} + +beforeAll(() => { + const freshness = inspectDistFreshness(PKG_DIR, 'check', RERUN); + if (!freshness.fresh) throw new Error(freshness.message); + + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'os-root-nameability-')); + // A real consumer's resolution, physically: a node_modules symlink into the + // built package, so tsc walks the package's own `exports` map and lands on + // `dist/index.d.ts` — the same realpath a pnpm workspace symlink produces + // (the #11350 measurement fired TS2883 through exactly this layout). + const scope = path.join(sandbox, 'node_modules', '@objectstack'); + fs.mkdirSync(scope, { recursive: true }); + fs.symlinkSync(PKG_DIR, path.join(scope, 'spec'), 'dir'); + + writeProgram(CONSUMER); + writeProgram(CANARY); +}); + +afterAll(() => { + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); +}); + +describe('root-entry type nameability (#11350)', () => { + it('an un-annotated `export default defineStack(...)` declaration-emits clean against the built root entry', () => { + const { code, output } = runTsc(CONSUMER); + expect( + code, + `expected 0 diagnostics; a TS2883 naming a dist chunk means a type the root entry's ` + + `public declarations mention structurally is no longer nameable from the root entry ` + + `(re-export it from src/index.ts — see #11350). tsc said:\n${output}`, + ).toBe(0); + expect(output).not.toMatch(/error TS\d+/); + }); + + it('canary: the harness profile still checks the declaration-emit axis', () => { + const { code, output } = runTsc(CANARY); + expect( + code, + `the canary fixture's declaration-emit error disappeared — if the harness profile ` + + `lost \`declaration: true\`, the consumer pin above is green no matter what leaks. ` + + `tsc said:\n${output}`, + ).not.toBe(0); + // Measured: TS4094 ("Property 'x' of exported anonymous class type may + // not be private or protected"). Pin the TS4xxx declaration-emit family + + // the message's substance rather than the bare number, so a + // compiler-version renumbering does not false-red this line. + expect(output).toMatch(/error TS4\d{2,3}: .*private/); + }); +}); diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 001c445b89..8a3408a4b4 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -129,6 +129,23 @@ export { defineAgent } from './ai/agent.zod'; export { defineTool } from './ai/tool.zod'; export { defineSkill } from './ai/skill.zod'; +// [#11350] Root-entry nameability of the root's own inferred types. `defineStack` +// returns `ObjectStackDefinition`, which is declared `z.input` — a generic instantiation the declaration +// emitter does not preserve as an alias — so an un-annotated +// `export default defineStack(...)` is emitted as the STRUCTURAL expansion, +// and that expansion mentions these three types. Without root re-exports, tsc +// can only name them through the hash-named internal dist chunk that declares +// them (unaddressable through the package's `exports` map → TS2883 in every +// consumer inferring through a root-entry function). All three are already +// public on their domain subpaths (`/ui`, `/automation`); this block makes the +// root entry self-consistent. Invariant (maintainer ruling 2026-08-23, +// recorded on #11350): a type that appears structurally in an entry's public +// declarations must be nameable from that same entry. +export type { FormFieldInput } from './ui/view.zod'; +export type { NavigationItemInput } from './ui/app.zod'; +export type { StateNodeConfig } from './automation/state-machine.zod'; + // DX factories for the remaining authoring domains (issue #2035) — one type-safe // entry per writable domain, mirroring the 19 factories above. `defineX` is a // *value* import: a broken import hard-errors instead of silently degrading to From d62f9cc4251f01b785d89800216f1d74796912d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 13:29:15 +0000 Subject: [PATCH 2/5] chore(spec): regenerate export-origins for the three root re-exports; add changeset (#11350) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- .changeset/root-entry-type-reexports-11350.md | 5 +++++ packages/spec/export-origins/root.json | 3 +++ 2 files changed, 8 insertions(+) create mode 100644 .changeset/root-entry-type-reexports-11350.md diff --git a/.changeset/root-entry-type-reexports-11350.md b/.changeset/root-entry-type-reexports-11350.md new file mode 100644 index 0000000000..d825c914c7 --- /dev/null +++ b/.changeset/root-entry-type-reexports-11350.md @@ -0,0 +1,5 @@ +--- +"@objectstack/spec": minor +--- + +Re-export `FormFieldInput`, `NavigationItemInput` and `StateNodeConfig` from the package root entry (#11350). These types appear structurally in the root entry's own public declarations — `defineStack` returns `ObjectStackDefinition`, declared `z.input`, which the declaration emitter expands structurally rather than preserving as an alias — but they were previously nameable only via the `/ui` and `/automation` subpaths. Any consumer letting TypeScript infer a type through a root-entry function (an un-annotated `export default defineStack(...)`) therefore hit TS2883 naming a hash-named internal dist chunk. With the re-exports, that consumer shape declaration-emits cleanly, with no annotation required. Invariant recorded: a type that appears structurally in an entry's public declarations must be nameable from that same entry. diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index 310f4918e8..eb54b81728 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -58,6 +58,7 @@ "ExpressionSchema": "src/shared/expression.zod.ts#ExpressionSchema (const)", "F": "src/shared/expression.zod.ts#F (const)", "FIELD_KEY_GUIDANCE": "src/data/authoring-key-lint.ts#FIELD_KEY_GUIDANCE (const)", + "FormFieldInput": "src/ui/view.zod.ts#FormFieldInput (type)", "GUEST_POSITION": "src/identity/position.zod.ts#GUEST_POSITION (const)", "LintableAuthoringCollection": "src/kernel/metadata-authoring-lint.ts#LintableAuthoringCollection (interface)", "MAP_SUPPORTED_FIELDS": "src/shared/metadata-collection.zod.ts#MAP_SUPPORTED_FIELDS (const)", @@ -78,6 +79,7 @@ "MigrationHopResult": "src/migrations/types.ts#MigrationHopResult (interface)", "MigrationStep": "src/migrations/types.ts#MigrationStep (interface)", "MigrationTodo": "src/migrations/types.ts#MigrationTodo (interface)", + "NavigationItemInput": "src/ui/app.zod.ts#NavigationItemInput (type)", "NormalizeStackInputOptions": "src/shared/metadata-collection.zod.ts#NormalizeStackInputOptions (interface)", "OBJECT_KEY_GUIDANCE": "src/data/authoring-key-lint.ts#OBJECT_KEY_GUIDANCE (const)", "ORGANIZATION_ADMIN": "src/identity/eval-user.zod.ts#ORGANIZATION_ADMIN (const)", @@ -117,6 +119,7 @@ "SpecSurfaceAddSchema": "src/migrations/spec-changes.ts#SpecSurfaceAddSchema (const)", "SpecSurfaceRemove": "src/migrations/spec-changes.ts#SpecSurfaceRemove (type)", "SpecSurfaceRemoveSchema": "src/migrations/spec-changes.ts#SpecSurfaceRemoveSchema (const)", + "StateNodeConfig": "src/automation/state-machine.zod.ts#StateNodeConfig (type)", "StoredConversionOptions": "src/conversions/stored.ts#StoredConversionOptions (type)", "SurfaceDiff": "src/migrations/spec-changes.ts#SurfaceDiff (interface)", "TemplateExpressionInputSchema": "src/shared/expression.zod.ts#TemplateExpressionInputSchema (const)", From ddf0566b2195b6645cc0e07b9852d6a0d9056945 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 13:38:08 +0000 Subject: [PATCH 3/5] docs(spec): point the nameability pin's premise-delta note at #11709 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- packages/spec/scripts/root-entry-type-nameability.pin.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/scripts/root-entry-type-nameability.pin.test.ts b/packages/spec/scripts/root-entry-type-nameability.pin.test.ts index fc224d5e1f..45866f17f0 100644 --- a/packages/spec/scripts/root-entry-type-nameability.pin.test.ts +++ b/packages/spec/scripts/root-entry-type-nameability.pin.test.ts @@ -40,7 +40,7 @@ * MINIMAL one-file program leaks two MORE names through `/data` * (`BaseValidationRuleShape`, `FilterCondition`) that the fixed root entry * still cannot name — deliberately NOT pinned here; that is #11350's - * recorded premise delta, its repair is a separate ruling. For the same + * recorded premise delta, filed as #11709 for its own ruling. For the same * reason the program contains no `@objectstack/spec/ui` or `/automation` * import and no direct `import type { FormFieldInput, … }` — any of those * would mask the very symptom under pin. Direct existence of the three root From 45ed3a3393350a3c8048d67c4444ce66cf014124 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 16:21:07 +0000 Subject: [PATCH 4/5] fix(spec): make the nameability pin an environment-gated measurement (#11350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test Core deliberately never builds spec's own dist (turbo's test task depends on ^build, dependencies only), so the pin's beforeAll freshness throw redded a whole CI shard for a measurement that lane cannot make. The pin now follows the live-dialect-cell discipline — reported, never omitted, no third outcome: dist fresh = both programs run; dist absent by default = a NAMED skip carrying the refusal reason; dist absent under OS_EXPECT_ROOT_NAMEABILITY=1 = a failure quoting the refusal. The flag is set in lint.yml's 'Type Check - consumer gates' lane right after its full packages-closure builds, beside check:api-surface and check:skill-examples — the other consumer-shaped gates reading the built dist — so the pin still runs for real in CI and cannot quietly degrade to never-measured. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- .github/workflows/lint.yml | 18 +++ .../root-entry-type-nameability.pin.test.ts | 144 ++++++++++++------ 2 files changed, 115 insertions(+), 47 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3c16205b84..09175cf5a8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3591,6 +3591,24 @@ jobs: - name: Check @objectstack/spec public API surface run: pnpm --filter @objectstack/spec run check:api-surface + # [#11350] Consumer-shaped declaration-emit pin against the BUILT root + # entry (an un-annotated `export default defineStack(...)` must compile + # with `declaration: true` — the TS2883 class). The pin is environment- + # gated the way the live-dialect cells are: under Test Core, spec's own + # dist is deliberately never built (turbo's `test` depends on `^build`, + # dependencies only), so there it declares a named skip. THIS lane builds + # the full packages closure above, so here the built dist is guaranteed — + # the flag turns "dist absent/stale" from a skip into a failure, which is + # what stops the pin from quietly degrading to never-measured if the + # build steps above are ever dropped (#4690). Sits with its family: + # `check:api-surface` / `check:skill-examples`, the other gates that read + # the surface a consumer actually installs. Adds no required context — + # a step in an existing lane (#9325). + - name: Root-entry type nameability pin (built dist, declaration emit) + env: + OS_EXPECT_ROOT_NAMEABILITY: '1' + run: pnpm --filter @objectstack/spec exec vitest run scripts/root-entry-type-nameability.pin.test.ts + # Same surface, the other axis: api-surface/ records that an export # EXISTS, never what it resolves to — so four exported types sat at `any` # across a whole major with every gate green (#4171). #4115 tells consumers diff --git a/packages/spec/scripts/root-entry-type-nameability.pin.test.ts b/packages/spec/scripts/root-entry-type-nameability.pin.test.ts index 45866f17f0..ec3139f1cf 100644 --- a/packages/spec/scripts/root-entry-type-nameability.pin.test.ts +++ b/packages/spec/scripts/root-entry-type-nameability.pin.test.ts @@ -56,12 +56,36 @@ * so it stays red exactly as long as the harness keeps checking the axis * the pin lives on. Asserted red. * - * ## Dist freshness + * ## Dist freshness — an environment-gated measurement, the live-dialect-cell + * shape * * The subject under test is `dist/index.d.ts`, not `src/` — the same artifact - * `check:api-surface` reads, refused on the same staleness rule (#7122/#7181): + * `check:api-surface` reads, judged by the same staleness rule (#7122/#7181): * a stale dist would let a root re-export removed from `src/index.ts` sit * green here until the next rebuild. + * + * But absence of that artifact is an ENVIRONMENT fact, not a defect: turbo's + * `test` task depends on `^build` (dependencies only), so the Test Core lane + * deliberately runs spec's own suite with spec's own dist unbuilt — a throw + * here reds a whole CI shard for a measurement that lane was never equipped + * to make (measured on PR #11716's first round: 420/421 files passed, only + * this file failed, at the old beforeAll throw). So the pin follows + * `live-dialect-matrix.testkit.ts`'s discipline — REPORTED, never omitted, + * with no third outcome: + * + * - dist fresh → the two programs run, both modes. + * - dist missing/stale, default → a NAMED SKIP whose title carries the + * refusal reason ("it was not run" stays readable in the output). Never a + * silent pass, never a throw. + * - dist missing/stale under `OS_EXPECT_ROOT_NAMEABILITY=1` → a FAILURE + * quoting the freshness refusal: that flag is set only by a runner that + * declared it builds spec's dts first, so a skip there would be the pin + * quietly degrading to never-measured — the #4690 shape. + * + * Where it runs for real in CI: the `Type Check · consumer gates` lane + * (lint.yml `typecheck-consumers`) sets the flag right after its full + * packages-closure builds, beside `check:api-surface` / `check:skill-examples` + * — the other consumer-shaped gates that read the built dist. */ import { spawnSync } from 'node:child_process'; @@ -157,51 +181,77 @@ function runTsc(program: Program): { code: number; output: string } { return { code: res.status ?? 1, output: `${res.stdout ?? ''}${res.stderr ?? ''}` }; } -beforeAll(() => { - const freshness = inspectDistFreshness(PKG_DIR, 'check', RERUN); - if (!freshness.fresh) throw new Error(freshness.message); - - sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'os-root-nameability-')); - // A real consumer's resolution, physically: a node_modules symlink into the - // built package, so tsc walks the package's own `exports` map and lands on - // `dist/index.d.ts` — the same realpath a pnpm workspace symlink produces - // (the #11350 measurement fired TS2883 through exactly this layout). - const scope = path.join(sandbox, 'node_modules', '@objectstack'); - fs.mkdirSync(scope, { recursive: true }); - fs.symlinkSync(PKG_DIR, path.join(scope, 'spec'), 'dir'); - - writeProgram(CONSUMER); - writeProgram(CANARY); -}); - -afterAll(() => { - if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); -}); - -describe('root-entry type nameability (#11350)', () => { - it('an un-annotated `export default defineStack(...)` declaration-emits clean against the built root entry', () => { - const { code, output } = runTsc(CONSUMER); - expect( - code, - `expected 0 diagnostics; a TS2883 naming a dist chunk means a type the root entry's ` + - `public declarations mention structurally is no longer nameable from the root entry ` + - `(re-export it from src/index.ts — see #11350). tsc said:\n${output}`, - ).toBe(0); - expect(output).not.toMatch(/error TS\d+/); +/** + * Read the environment ONCE, at collection time, exactly as + * `live-dialect-matrix.testkit.ts` reads its cell URLs: the branch below is + * total — measured when the dist is readable, a named skip or an expected-mode + * failure when it is not — so there is no third outcome and no throw that + * could red a lane never equipped to measure this. + */ +const EXPECT_BUILT_DIST = process.env.OS_EXPECT_ROOT_NAMEABILITY === '1'; +const FRESHNESS = inspectDistFreshness(PKG_DIR, 'check', RERUN); + +if (!FRESHNESS.fresh) { + describe('root-entry type nameability (#11350)', () => { + it.skipIf(!EXPECT_BUILT_DIST)( + `spec's dist declarations are ${FRESHNESS.state} — this pin reads the BUILT root entry; ` + + `build @objectstack/spec first, then: ${RERUN} (skipped by default; ` + + `OS_EXPECT_ROOT_NAMEABILITY=1 turns this into a failure)`, + () => { + expect.fail( + `OS_EXPECT_ROOT_NAMEABILITY=1 while spec's dist declarations are ${FRESHNESS.state}: ` + + `this runner declared it builds spec's dts before the suite, so this pin must not ` + + `be skipped (a skip here would be the pin quietly degrading to never-measured, #4690).\n` + + FRESHNESS.message, + ); + }, + ); }); +} else { + describe('root-entry type nameability (#11350)', () => { + beforeAll(() => { + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'os-root-nameability-')); + // A real consumer's resolution, physically: a node_modules symlink into + // the built package, so tsc walks the package's own `exports` map and + // lands on `dist/index.d.ts` — the same realpath a pnpm workspace + // symlink produces (the #11350 measurement fired TS2883 through exactly + // this layout). + const scope = path.join(sandbox, 'node_modules', '@objectstack'); + fs.mkdirSync(scope, { recursive: true }); + fs.symlinkSync(PKG_DIR, path.join(scope, 'spec'), 'dir'); + + writeProgram(CONSUMER); + writeProgram(CANARY); + }); + + afterAll(() => { + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + }); - it('canary: the harness profile still checks the declaration-emit axis', () => { - const { code, output } = runTsc(CANARY); - expect( - code, - `the canary fixture's declaration-emit error disappeared — if the harness profile ` + - `lost \`declaration: true\`, the consumer pin above is green no matter what leaks. ` + - `tsc said:\n${output}`, - ).not.toBe(0); - // Measured: TS4094 ("Property 'x' of exported anonymous class type may - // not be private or protected"). Pin the TS4xxx declaration-emit family + - // the message's substance rather than the bare number, so a - // compiler-version renumbering does not false-red this line. - expect(output).toMatch(/error TS4\d{2,3}: .*private/); + it('an un-annotated `export default defineStack(...)` declaration-emits clean against the built root entry', () => { + const { code, output } = runTsc(CONSUMER); + expect( + code, + `expected 0 diagnostics; a TS2883 naming a dist chunk means a type the root entry's ` + + `public declarations mention structurally is no longer nameable from the root entry ` + + `(re-export it from src/index.ts — see #11350). tsc said:\n${output}`, + ).toBe(0); + expect(output).not.toMatch(/error TS\d+/); + }); + + it('canary: the harness profile still checks the declaration-emit axis', () => { + const { code, output } = runTsc(CANARY); + expect( + code, + `the canary fixture's declaration-emit error disappeared — if the harness profile ` + + `lost \`declaration: true\`, the consumer pin above is green no matter what leaks. ` + + `tsc said:\n${output}`, + ).not.toBe(0); + // Measured: TS4094 ("Property 'x' of exported anonymous class type may + // not be private or protected"). Pin the TS4xxx declaration-emit family + + // the message's substance rather than the bare number, so a + // compiler-version renumbering does not false-red this line. + expect(output).toMatch(/error TS4\d{2,3}: .*private/); + }); }); -}); +} From 0df450c1fd245c16b3553200b8f639b9498e4530 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 16:21:07 +0000 Subject: [PATCH 5/5] fix(metadata-protocol): pin the emitted specifier for FormFieldInput to the /ui entry (#11350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protocol.ts imports both the spec root (applyConversionsToStoredItem) and /ui, and its inferred public declarations structurally mention FormFieldInput. Once the root entry exported that name, tsc's declaration emitter switched its synthesized reference from /ui to the root — both portable, but the root specifier drags spec's entire root module graph into every downstream tsc program reading this package's dts (measured on the debt-ledger re-measure of @objectstack/http-conformance: +2 program files, +190k types, +805k instantiations, +~560MB — past a 4GB child heap, which is what redded Type Check - debt ledger on PR #11716). A local type-only import binding makes the emitter reuse it, keeping the reference on the narrow /ui entry. Erased at runtime: emitted JS byte-identical; the curated index.ts entry is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- .../metadata-protocol-specifier-pin-11350.md | 5 +++++ packages/metadata-protocol/src/protocol.ts | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 .changeset/metadata-protocol-specifier-pin-11350.md diff --git a/.changeset/metadata-protocol-specifier-pin-11350.md b/.changeset/metadata-protocol-specifier-pin-11350.md new file mode 100644 index 0000000000..d37a609494 --- /dev/null +++ b/.changeset/metadata-protocol-specifier-pin-11350.md @@ -0,0 +1,5 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +Pin the declaration emitter's module specifier for `FormFieldInput` to `@objectstack/spec/ui` (#11350). When #11350 made the three ui/automation input types nameable from `@objectstack/spec`'s root entry, tsc's declaration emitter for this package switched its synthesized reference for `FormFieldInput` from the `/ui` slice to the root entry — both portable, but the root specifier pulls spec's entire root module graph into every downstream TypeScript program that reads this package's declarations (measured: +190k types, +805k instantiations, roughly +560MB on one real program). A local type-only import binding keeps the emitted reference on the narrow `/ui` entry. Type-only and erased at runtime: every emitted JS file is byte-identical; the package's public export surface is unchanged. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 37ad56a444..1c88980cf9 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -72,6 +72,23 @@ import { import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSpellingRefusal, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared'; import { applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec'; import { type FormView, isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec/ui'; +// [#11350] Emitted-specifier pin. This module's inferred public declarations +// structurally mention `FormFieldInput` (FormView `sections[].fields`), and +// this file imports BOTH `@objectstack/spec` (root, for +// `applyConversionsToStoredItem` above) and `@objectstack/spec/ui`. Once +// #11350 made `FormFieldInput` nameable from the root entry, tsc's +// declaration emitter switched its synthesized reference from the `/ui` slice +// to the root — both are portable, but the root specifier drags spec's ENTIRE +// root module graph into every downstream tsc program that reads this +// package's dts (measured on PR #11716: +190k types, +805k instantiations, +// +~560MB on the debt-ledger re-measure of @objectstack/http-conformance — +// past a 4GB heap). An IMPORT (not a bare re-export — that creates no local +// binding) makes the emitter reuse this binding, keeping the emitted +// reference on the narrow `/ui` entry; the export statement is what keeps +// no-unused-locals green. index.ts deliberately does not re-export it +// (curated entry, unchanged). +import { type FormFieldInput } from '@objectstack/spec/ui'; +export type { FormFieldInput }; import { METADATA_FORM_REGISTRY, CORE_SERVICE_PROVIDER, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system'; import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema, getMetadataTypeActions, getMetadataCreateSeed, PROTOCOL_VERSION } from '@objectstack/spec/kernel'; import {