2424 * wearing. The risk is picking a new name the spec ALREADY owns — the
2525 * `PageComponentSchema` mistake from objectui#3074 — so each new name is
2626 * asserted absent from the spec's export set, types and values alike.
27- * - **Not burnable** (`JoinedReportBlock`): kept in the ledger behind an
28- * inverted pin, see the bottom of this file.
27+ * - **Not burnable yet** (`JoinedReportBlock`): erased in the installed spec,
28+ * typed — as a different shape — on spec `main`; the burn-down is owed at the
29+ * bump that installs the typed one. See the bottom of this file.
2930 */
3031
3132import { describe , it , expect } from 'vitest' ;
@@ -40,6 +41,7 @@ import {
4041 DashboardWidgetSchema as SpecDashboardWidgetSchema ,
4142} from '@objectstack/spec/ui' ;
4243import type { JoinedReportBlock as SpecJoinedReportBlock } from '@objectstack/spec/ui' ;
44+ import type { JoinedReportBlock as LocalJoinedReportBlock } from '../spec-report.js' ;
4345import { AppContextSelectorSchema } from '../zod/app.zod.js' ;
4446import { DashboardWidgetSchema , DashboardWidgetTypeSchema , GlobalFilterSchema } from '../zod/complex.zod.js' ;
4547import { DASHBOARD_COMPONENT_WIDGET_TYPES , DASHBOARD_WIDGET_TYPE_EXTENSIONS } from '../complex.js' ;
@@ -628,18 +630,20 @@ describe('DashboardWidgetSchema pinned divergences', () => {
628630// ─────────────────────────────────────────────────────────────────────────────
629631
630632/**
631- * Every export name `@objectstack/spec` publishes, per subpath — TYPES included.
632- *
633- * A runtime `import()` would only see values, and five of the six names below
634- * are types, so a value-only check would pass vacuously on exactly the cases
635- * that matter. This reads the compiler's view of each subpath's `.d.ts`, which
636- * is the same source `scripts/check-spec-symbol-derivation.mjs` uses.
633+ * The compiler's view of the INSTALLED `@objectstack/spec`'s built `.d.ts`, per
634+ * subpath, with the version it was read from. Shared by the rename tripwires
635+ * below and by the `JoinedReportBlock` tripwire at the bottom of this file.
637636 */
638- function specExportNames ( subpaths : readonly string [ ] ) : Set < string > {
637+ function specDeclarations ( subpaths : readonly string [ ] ) : {
638+ version : string ;
639+ checker : ts . TypeChecker ;
640+ modules : ts . Symbol [ ] ;
641+ } {
639642 const require = createRequire ( import . meta. url ) ;
640643 const pkgPath = require . resolve ( '@objectstack/spec/package.json' ) ;
641644 const pkgDir = dirname ( pkgPath ) ;
642645 const pkg = JSON . parse ( readFileSync ( pkgPath , 'utf8' ) ) as {
646+ version : string ;
643647 exports ?: Record < string , { import ?: { types ?: string } ; require ?: { types ?: string } } > ;
644648 } ;
645649
@@ -664,11 +668,27 @@ function specExportNames(subpaths: readonly string[]): Set<string> {
664668 } ) ;
665669 const checker = program . getTypeChecker ( ) ;
666670
667- const names = new Set < string > ( ) ;
668- for ( const file of files ) {
671+ const modules = files . map ( ( file ) => {
669672 const sf = program . getSourceFile ( file ) ;
670673 const moduleSymbol = sf && checker . getSymbolAtLocation ( sf ) ;
671674 if ( ! moduleSymbol ) throw new Error ( `no module symbol for ${ file } ` ) ;
675+ return moduleSymbol ;
676+ } ) ;
677+ return { version : pkg . version , checker, modules } ;
678+ }
679+
680+ /**
681+ * Every export name `@objectstack/spec` publishes, per subpath — TYPES included.
682+ *
683+ * A runtime `import()` would only see values, and five of the six names below
684+ * are types, so a value-only check would pass vacuously on exactly the cases
685+ * that matter. This reads the compiler's view of each subpath's `.d.ts`, which
686+ * is the same source `scripts/check-spec-symbol-derivation.mjs` uses.
687+ */
688+ function specExportNames ( subpaths : readonly string [ ] ) : Set < string > {
689+ const { checker, modules } = specDeclarations ( subpaths ) ;
690+ const names = new Set < string > ( ) ;
691+ for ( const moduleSymbol of modules ) {
672692 for ( const exported of checker . getExportsOfModule ( moduleSymbol ) ) names . add ( exported . getName ( ) ) ;
673693 }
674694 return names ;
@@ -711,48 +731,215 @@ describe('renamed local dialects do not collide with a spec export (objectui#307
711731} ) ;
712732
713733// ─────────────────────────────────────────────────────────────────────────────
714- // JoinedReportBlock — inverted pin, NOT burnable here
734+ // JoinedReportBlock — erased in the INSTALLED spec, typed on spec `main`, and
735+ // not burnable until the bump that installs the typed one
715736// ─────────────────────────────────────────────────────────────────────────────
716737
717738/**
718- * `JoinedReportBlock` collides with a spec export that carries no type at all:
719- * the spec declares `JoinedReportBlockSchema` as `z.ZodTypeAny`, so
720- * `z.infer<typeof …>` — and therefore the exported `JoinedReportBlock` type —
721- * resolves to `unknown`. Re-exporting it would replace objectui's precise block
722- * interface (`name`/`columns`/`groupingsDown`/`groupingsAcross`/`filter`/`chart`)
723- * with nothing at all: a type-safety regression wearing a burn-down's clothes.
739+ * `JoinedReportBlock` collides with a spec export. Through the published
740+ * `@objectstack/spec` 17.4.0 the spec annotated `JoinedReportBlockSchema` as a
741+ * bare `z.ZodTypeAny`, so `z.input<typeof …>` — and therefore the exported
742+ * `JoinedReportBlock` type — resolves to `unknown`. Re-exporting that would
743+ * replace objectui's block interface with nothing at all: a type-safety
744+ * regression wearing a burn-down's clothes. It is the objectstack#4171 failure
745+ * mode one variant wider: `any` is caught by the `0 extends (1 & T)` probe,
746+ * `unknown` is not (that probe answers `false` for it), so a triage that only
747+ * screens for `any` concludes the symbol is derivable.
724748 *
725- * This is the objectstack#4171 failure mode, one variant wider than the three
726- * already pinned in `spec-derived-unions.test.ts`. Those erase to `any`, which
727- * the `0 extends (1 & T)` probe detects; this one erases to `unknown`, which
728- * that probe reports as `false` while being just as empty. Any triage that only
729- * screens for `any` will conclude this symbol is safely derivable. It is not.
749+ * ## The day the old pin was waiting for came, on spec `main` first
730750 *
731- * So it stays in the ledger with its local declaration intact. The day the spec
732- * types the schema properly, `IsUnknown<…>` flips to `false`, `true satisfies
733- * false` stops compiling, and the failure is the instruction: re-run the triage
734- * and burn it down.
751+ * This section used to hold ONE inverted pin, `true satisfies IsUnknown<…>`,
752+ * whose docblock said: "The day the spec types the schema properly,
753+ * `IsUnknown<…>` flips to `false`, `true satisfies false` stops compiling, and
754+ * the failure is the instruction: re-run the triage and burn it down."
755+ * objectstack#20369 (`681868ca7`) removed the annotation, and the pin fired
756+ * where it could first see that — the Spec Main Shape Gate, which compiles this
757+ * file against `@objectstack/spec` built from objectstack `main` (objectui#10916).
758+ * The pull-request type-check still compiles the SAME file against the INSTALLED
759+ * spec, the published 17.4.0, which predates #20369 and still erases the type.
760+ * So whatever stands here must compile against both, until a spec bump
761+ * installs a release that carries #20369.
735762 *
736- * ⚠️ objectstack#4171 is CLOSED (completed 2026-07-30) and this symbol is STILL
737- * erased — do NOT read that closure as this pin's release. #4171 typed the
738- * RECURSIVE schemas (`NavigationItem`, `FormField`); it never touched
739- * `JoinedReportBlockSchema`, whose erasure has a different cause — a bare
740- * `z.ZodTypeAny` annotation, not recursion. Re-measured in the built dist at
741- * spec 17.2.0 (objectui#3162 batch 8): `IsUnknown` is still `true`. The pin
742- * below, not the state of any upstream issue, is what says when this is
743- * burnable.
763+ * ## The re-run triage: precise now, and a DIFFERENT shape (guard case 2c, no longer 2b)
764+ *
765+ * Against spec `main`, member by member (the reading is on objectui#10916):
766+ * the spec's block is neither `any` nor `unknown`, and it is not the local
767+ * interface either. The spec types the ADR-0021 dataset-bound block — `dataset`,
768+ * `rows`, `columns` as dimension NAMES, `values`, `runtimeFilter`, `order` — in
769+ * a closed schema. The local interface types the legacy inline-query block —
770+ * `objectName`, `columns` as column OBJECTS, `groupingsDown`, `groupingsAcross`,
771+ * `filter`, `chart` — plus an index signature. The spec's schema refuses all
772+ * five of those local-only keys, `chart` by name since objectstack#20161
773+ * retired it. `name` and `type` agree; `label` / `description` do not quite
774+ * (the local object arm is not a spec inline locale map).
775+ *
776+ * ⇒ The burn-down is owed, and it is not a re-export beside the local
777+ * interface: it REPLACES the published `JoinedReportBlock` (and the `blocks` of
778+ * `JoinedSpecReport`) with the spec's type, which changes a published type and
779+ * is its own slice. It cannot land before the bump: against the installed spec
780+ * the derived type would be `unknown`, the exact regression this section exists
781+ * to stop.
782+ *
783+ * ## What guards it now: two halves, because two different runs read two different specs
784+ *
785+ * - COMPILE TIME, both runs. The state pin admits `erased` and `typed` and
786+ * refuses `any`. On a typed spec, the divergence lines under it each name one
787+ * difference and stop compiling the day that one moves — on the Spec Main
788+ * Shape Gate first. On the erased spec they are not asked (`OnTypedSpec`):
789+ * an erased type declares no member to compare.
790+ * - TEST TIME, the installed spec only (the Spec Main Shape Gate type-checks and
791+ * runs no test). The tripwire at the bottom reads the INSTALLED spec's built
792+ * declarations and fails on the first bump past the published 17.4.0. If
793+ * that release types the block, the failure is the burn-down's start signal:
794+ * derive the published interface from the spec in its own slice, and delete
795+ * the `erased` arm from the state pin so that an erasure stops compiling
796+ * again. If that release still erases it, the spec erased it again — an
797+ * upstream regression to report, and the `erased` arm loses its licence all
798+ * the same.
799+ *
800+ * ⚠️ NOT guardable from here: an erasure on objectstack `main` BEFORE that
801+ * bump. At the type level it is indistinguishable from the installed 17.4.0 —
802+ * both are the same bare `z.ZodTypeAny` — so the state pin admits it on the
803+ * gate. The producer's own pin covers that window
804+ * (`packages/spec/src/ui/joined-report-block-type.test.ts` in objectstack,
805+ * landed with #20369), and the tripwire's version row catches it at the bump
806+ * at the latest.
807+ *
808+ * ⚠️ objectstack#4171 (CLOSED 2026-07-30) was never this symbol's release: it
809+ * typed the RECURSIVE schemas, and this erasure's cause was the bare
810+ * annotation, not recursion. The state of an upstream issue says nothing here;
811+ * the pins and the tripwire below do.
744812 */
745813type IsUnknown < T > = [ unknown ] extends [ T ] ? ( [ T ] extends [ unknown ] ? true : false ) : false ;
746814type IsAny < T > = 0 extends 1 & T ? true : false ;
747- const _specJoinedReportBlockIsStillUntyped = true satisfies IsUnknown < SpecJoinedReportBlock > ;
815+ /** `any` is asked first: it passes `IsUnknown` as well. */
816+ type SpecTyping < T > = IsAny < T > extends true ? 'any' : IsUnknown < T > extends true ? 'erased' : 'typed' ;
817+
818+ // THE STATE PIN. `erased` = the installed, published 17.4.0; `typed` = spec
819+ // `main` since objectstack#20369. `any` is refused on both (case 2).
820+ const _specJoinedReportBlockTyping = null as unknown as SpecTyping < SpecJoinedReportBlock > satisfies
821+ | 'erased'
822+ | 'typed' ;
748823const _specJoinedReportBlockIsNotEvenAny = false satisfies IsAny < SpecJoinedReportBlock > ;
749- void _specJoinedReportBlockIsStillUntyped ;
824+
825+ /**
826+ * A divergence probe, asked of a TYPED spec only; on the erased spec the answer
827+ * is `true` by construction. The licence for that is the `erased` arm above,
828+ * which the test-time tripwire retires at the bump.
829+ */
830+ type OnTypedSpec < Probe extends boolean > = SpecTyping < SpecJoinedReportBlock > extends 'typed' ? Probe : true ;
831+ /** Keys a type DECLARES: the local `[k: string]: unknown` is not a member. */
832+ type DeclaredBlockKeys < T > = keyof {
833+ [ K in keyof T as string extends K ? never : number extends K ? never : K ] : T [ K ] ;
834+ } ;
835+ type SameType < A , B > = [ A ] extends [ B ] ? ( [ B ] extends [ A ] ? true : false ) : false ;
836+ /** `T[K]`, or `never` where `T` is the erased `unknown` (which declares no `K`). */
837+ type BlockMember < T , K extends PropertyKey > = K extends keyof T ? T [ K ] : never ;
838+
839+ // 0. Umbrella: neither block is the other, in either direction. The lines under
840+ // it say why, and are the ones to act on.
841+ const _localBlockIsNotTheSpecBlock = true satisfies OnTypedSpec <
842+ [ LocalJoinedReportBlock ] extends [ SpecJoinedReportBlock ]
843+ ? false
844+ : [ SpecJoinedReportBlock ] extends [ LocalJoinedReportBlock ]
845+ ? false
846+ : true
847+ > ;
848+ // 1. The ADR-0021 dataset-bound selection: declared by the spec, by the local
849+ // interface not at all.
850+ const _specOnlyBlockKeys = true satisfies OnTypedSpec <
851+ SameType <
852+ Exclude < DeclaredBlockKeys < SpecJoinedReportBlock > , DeclaredBlockKeys < LocalJoinedReportBlock > > ,
853+ 'dataset' | 'rows' | 'values' | 'runtimeFilter' | 'order'
854+ >
855+ > ;
856+ // 2. The legacy inline-query block: declared locally, refused by the spec's
857+ // closed schema — `chart` by name, retired by objectstack#20161.
858+ const _localOnlyBlockKeys = true satisfies OnTypedSpec <
859+ SameType <
860+ Exclude < DeclaredBlockKeys < LocalJoinedReportBlock > , DeclaredBlockKeys < SpecJoinedReportBlock > > ,
861+ 'objectName' | 'groupingsDown' | 'groupingsAcross' | 'filter' | 'chart'
862+ >
863+ > ;
864+ // 3. `columns`: one name, two concepts. The spec's are dimension NAMES across a
865+ // matrix; the local ones are column OBJECTS (`{ field, aggregate, … }`).
866+ type SpecBlockColumns = NonNullable < BlockMember < SpecJoinedReportBlock , 'columns' > > ;
867+ const _blockColumnsAreTwoConcepts = true satisfies OnTypedSpec <
868+ [ SpecBlockColumns ] extends [ string [ ] ]
869+ ? [ LocalJoinedReportBlock [ 'columns' ] ] extends [ SpecBlockColumns ]
870+ ? false
871+ : true
872+ : false
873+ > ;
874+ // 4. `label` / `description`: the local `{ default, translations }` arm is not a
875+ // spec inline locale map, every value of which is a string.
876+ const _blockLabelObjectArmIsNotASpecLabel = true satisfies OnTypedSpec <
877+ [ LocalJoinedReportBlock [ 'label' ] ] extends [ BlockMember < SpecJoinedReportBlock , 'label' > ]
878+ ? false
879+ : [ LocalJoinedReportBlock [ 'description' ] ] extends [ BlockMember < SpecJoinedReportBlock , 'description' > ]
880+ ? false
881+ : true
882+ > ;
883+ // 5. Agreement: `name` and `type` mean the same on both sides.
884+ const _blockNameAgrees = true satisfies OnTypedSpec <
885+ SameType < LocalJoinedReportBlock [ 'name' ] , BlockMember < SpecJoinedReportBlock , 'name' > >
886+ > ;
887+ const _blockTypeAgrees = true satisfies OnTypedSpec <
888+ SameType < LocalJoinedReportBlock [ 'type' ] , BlockMember < SpecJoinedReportBlock , 'type' > >
889+ > ;
890+ void _specJoinedReportBlockTyping ;
750891void _specJoinedReportBlockIsNotEvenAny ;
892+ void _localBlockIsNotTheSpecBlock ;
893+ void _specOnlyBlockKeys ;
894+ void _localOnlyBlockKeys ;
895+ void _blockColumnsAreTwoConcepts ;
896+ void _blockLabelObjectArmIsNotASpecLabel ;
897+ void _blockNameAgrees ;
898+ void _blockTypeAgrees ;
899+
900+ /**
901+ * How the INSTALLED spec declares an export: the compiler's reading of its
902+ * built `.d.ts`, the same reading `SpecTyping` takes at compile time — taken
903+ * here so that a TEST can act on it, which is the half the Spec Main Shape Gate
904+ * never runs.
905+ */
906+ function installedSpecTyping ( name : string ) : { version : string ; typing : 'any' | 'erased' | 'typed' } {
907+ const { version, checker, modules } = specDeclarations ( [ '@objectstack/spec/ui' ] ) ;
908+ const exported = checker . getExportsOfModule ( modules [ 0 ] ) . find ( ( s ) => s . getName ( ) === name ) ;
909+ if ( ! exported ) throw new Error ( `\`@objectstack/spec/ui\` does not export \`${ name } \` — re-run the triage` ) ;
910+ const declared = exported . flags & ts . SymbolFlags . Alias ? checker . getAliasedSymbol ( exported ) : exported ;
911+ const type = checker . getDeclaredTypeOfSymbol ( declared ) ;
912+ const typing = type . flags & ts . TypeFlags . Any ? 'any' : type . flags & ts . TypeFlags . Unknown ? 'erased' : 'typed' ;
913+ return { version, typing } ;
914+ }
915+
916+ describe ( 'JoinedReportBlock burn-down tripwire, read off the INSTALLED spec (objectui#10916)' , ( ) => {
917+ const installed = installedSpecTyping ( 'JoinedReportBlock' ) ;
918+
919+ it ( 'reads a typed export as `typed` (the lit control for the rows below)' , ( ) => {
920+ // A reader that answered `erased` for everything would keep the next row
921+ // green forever. `ReportSort` is a closed object in every spec this file
922+ // has compiled against.
923+ expect ( installedSpecTyping ( 'ReportSort' ) . typing ) . toBe ( 'typed' ) ;
924+ } ) ;
751925
752- describe ( 'JoinedReportBlock stays in the ledger — still untyped upstream (objectui#3162)' , ( ) => {
753- it ( 'documents why: the spec ships the schema untyped' , ( ) => {
754- // The compile-time pins above are the real guard; this keeps the reason
755- // visible in the test report rather than only in a comment.
756- expect ( true ) . toBe ( true ) ;
926+ it ( 'the installed spec still erases the block to `unknown`' , ( ) => {
927+ expect (
928+ installed . typing ,
929+ `the installed @objectstack/spec ${ installed . version } now types JoinedReportBlock — this is the ` +
930+ 'bump the burn-down is owed at. Re-run the triage against the divergence pins above, derive ' +
931+ "the published interface from the spec in its own slice (a published type changes), and delete " +
932+ "the state pin's `erased` arm so that an erasure stops compiling again" ,
933+ ) . toBe ( 'erased' ) ;
934+ } ) ;
935+
936+ it ( 'the installed spec is still the published 17.4.0, the newest release before objectstack#20369' , ( ) => {
937+ expect (
938+ installed . version ,
939+ `a newer @objectstack/spec is installed (block typing: ${ installed . typing } ). Every release after ` +
940+ '17.4.0 is cut from an objectstack `main` that carries #20369, so it should type the block: if ' +
941+ 'the row above is red too, burn down as it says; if it is green, the spec erased the block again ' +
942+ "— report that upstream. Either way the state pin's `erased` arm has lost its licence" ,
943+ ) . toBe ( '17.4.0' ) ;
757944 } ) ;
758945} ) ;
0 commit comments