Skip to content

Commit a1736e4

Browse files
committed
Merge origin/main (29c76e5) into claude/issue-10905-types-stale-docblocks
Picks up PR #10934 (objectui#10916), which clears the repo-wide Spec Main Shape Gate red. No conflict. Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude <noreply@anthropic.com>
2 parents 950bf64 + 29c76e5 commit a1736e4

3 files changed

Lines changed: 255 additions & 53 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
---
3+
4+
Rewrite the `JoinedReportBlock` pin in `report-chart-query-spec-parity.test.ts`
5+
(objectui#10916) so it compiles against both the installed `@objectstack/spec`
6+
17.4.0, which still erases the block type to `unknown`, and spec `main`, which
7+
objectstack#20369 typed. A test-time tripwire now fails at the first spec bump
8+
past 17.4.0, which is when the burn-down it records becomes possible. Test and
9+
ledger text only; no package is released by this change.

‎packages/types/src/__tests__/report-chart-query-spec-parity.test.ts‎

Lines changed: 229 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@
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

3132
import { describe, it, expect } from 'vitest';
@@ -40,6 +41,7 @@ import {
4041
DashboardWidgetSchema as SpecDashboardWidgetSchema,
4142
} from '@objectstack/spec/ui';
4243
import type { JoinedReportBlock as SpecJoinedReportBlock } from '@objectstack/spec/ui';
44+
import type { JoinedReportBlock as LocalJoinedReportBlock } from '../spec-report.js';
4345
import { AppContextSelectorSchema } from '../zod/app.zod.js';
4446
import { DashboardWidgetSchema, DashboardWidgetTypeSchema, GlobalFilterSchema } from '../zod/complex.zod.js';
4547
import { 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
*/
745813
type IsUnknown<T> = [unknown] extends [T] ? ([T] extends [unknown] ? true : false) : false;
746814
type 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';
748823
const _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;
750891
void _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

Comments
 (0)