Skip to content

Commit 29c76e5

Browse files
test(types): the JoinedReportBlock pin compiles on the installed spec and on spec main, and trips at the bump (objectui#10916) (#10934)
Fixes #10916 Clause-②: no — a test-side tripwire and the ledger text beside it move to what spec `main` now says; no published type, no export and no accepted document changes (the local `JoinedReportBlock` interface and the `index.ts` exports are untouched). ⚠️ Generic parameters are written as UPPERCASE WORDS in this body (for example "IsUnknown OF SpecJoinedReportBlock"), never in angle brackets: GitHub's body sanitizer deletes tag-shaped fragments on save, and backticks do not protect them (AGENTS.md, "GitHub mutates body BYTES"). Diagnostics are quoted by code and text, without the file's line and column, per AGENTS.md #11. ## What was red, and why the pin was right to go red objectstack#20369 (`681868ca7`) removed the bare `z.ZodTypeAny` annotation from `JoinedReportBlockSchema` on objectstack `main`. The inverted pin `_specJoinedReportBlockIsStillUntyped` (`true satisfies` IsUnknown OF SpecJoinedReportBlock) in `packages/types/src/__tests__/report-chart-query-spec-parity.test.ts` then failed with TS1360 ("Type 'true' does not satisfy the expected type 'false'.") on the Spec Main Shape Gate, which compiles every head against spec built from objectstack `main`. Its docblock said that failure is the instruction: re-run the triage and burn it down. The pull-request type-check compiles the SAME file against the INSTALLED spec, the published 17.4.0 (the newest release on npm, published 2026-09-09, so before #20369). There the type is still `unknown`. Whatever stands in the file has to compile against both until a spec bump installs a release carrying #20369. ## The re-run triage: spec `main` against the local interface, member by member Read with the TypeScript checker off the source-built spec's `dist` (objectstack `df3ba164a588`); `JoinedReportBlock` is neither `any` nor `unknown` there and has no index signature. | member | local `JoinedReportBlock` (`spec-report.ts`) | spec `main` `JoinedReportBlock` (input) | verdict | | --- | --- | --- | --- | | `name` | `string`, required | `string`, required (snake_case at runtime) | agree | | `type` | optional `tabular` / `summary` / `matrix` | the same enum, optional (defaults to `tabular`) | agree | | `label`, `description` | string, or an object with `default` and `translations` | string, or an inline locale map whose every value is a string | diverge: the local object arm is not a spec label | | `columns` | REQUIRED array of column OBJECTS (`field`, `aggregate`, ...) | optional array of dimension NAMES (matrix across-axis) | one name, two concepts | | `objectName` | declared | not declared; the closed schema refuses it (its alias table points at `dataset`) | local-only | | `groupingsDown`, `groupingsAcross` | declared | not declared; refused | local-only | | `filter` | declared | not declared; refused (alias table points at `runtimeFilter`) | local-only | | `chart` | declared | retired by objectstack#20161, refused with the retirement prescription | local-only | | index signature | `[k: string]: unknown` | none (closed schema) | diverge | | `dataset`, `rows`, `values`, `runtimeFilter`, `order` | not declared | declared (the ADR-0021 dataset-bound selection) | spec-only | **Verdict.** The spec type is now precise, and it describes a DIFFERENT shape: guard case 2c on spec `main`, no longer 2b. The local interface is the legacy inline-query block, and the spec's closed schema refuses every one of its five local-only keys. So it is derivable, but not by a re-export beside the local interface: the burn-down REPLACES the published `JoinedReportBlock` (and the `blocks` of `JoinedSpecReport`) with the spec's type. That changes a published type, so it is its own slice, and it cannot land before the bump, because against the installed spec the derived type would be `unknown`. Nothing in this tree reads the local interface's legacy members (from a grep of the five local-only keys and a reading of the renderer, not a runtime probe): the joined branch of `DatasetReportRenderer` reads dataset-bound blocks, and the local type is only re-exported and used by `JoinedSpecReport`. ## The chosen shape, and the precedent it follows objectui#10080 (PR #10089) is the precedent for "the gate compiles against spec `main`, the PR check against the installed pin": it kept both jobs of the red declaration and changed its form so the file compiles against both specs, then proved both legs with a control. objectui#3177 (case 2c for `NavigationItem`) is the precedent for what replaces an `IsAny` / IsUnknown pin once the spec is typed: per-key probes that each stop compiling the day that one divergence moves. This PR does both. The guard now has two halves, because two different runs read two different specs: - **Compile time, both runs.** A state pin, `_specJoinedReportBlockTyping`, admits `erased` and `typed` and refuses `any`; the `IsAny` pin stays as it was. On a typed spec, seven divergence and agreement probes (`_localBlockIsNotTheSpecBlock`, `_specOnlyBlockKeys`, `_localOnlyBlockKeys`, `_blockColumnsAreTwoConcepts`, `_blockLabelObjectArmIsNotASpecLabel`, `_blockNameAgrees`, `_blockTypeAgrees`) record the triage above and stop compiling the day one of them moves, on the Spec Main Shape Gate first. On the erased spec they are not asked (`OnTypedSpec`), since an erased type declares no member to compare. - **Test time, the installed spec only.** The Spec Main Shape Gate type-checks and runs no test, so a vitest tripwire can act on the INSTALLED spec alone. It reads the installed spec's built declarations with the TypeScript checker, and it fails on the first bump past the published 17.4.0. If that release types the block, the failure is the burn-down's start signal: derive the published interface in its own slice, and delete the state pin's `erased` arm so that an erasure stops compiling again. If that release still erases the block, the spec erased it again, which is an upstream regression; the `erased` arm loses its licence either way. A lit control (`ReportSort` must read `typed`) keeps the reader from passing vacuously. ⚠️ **Declared limit.** An erasure on objectstack `main` BEFORE that bump cannot be told apart, at the type level, from the installed 17.4.0: both are the same bare `z.ZodTypeAny`. The state pin therefore admits it on the gate. The producer's own pin covers that window (`packages/spec/src/ui/joined-report-block-type.test.ts` in objectstack, landed with #20369), and the tripwire's version row catches it at the bump at the latest. Nothing was skipped, disabled or ts-ignored, and no workflow changed. The ledger row (the `JoinedReportBlock` ALLOW entry in `scripts/check-spec-symbol-derivation.mjs`, which is objectui#3162's ledger in machine-readable form) and that script's case-2b header now say what is true of both specs, and when the burn-down fires. ## Both compile legs, from committed state Legs taken with `pnpm run type-check` in `packages/types` (all three `tsc` programs, `tsconfig.test.json` included). The spec-`main` legs follow the gate's own procedure: `@objectstack/spec` built from objectstack `df3ba164a588` (a detached worktree, `pnpm install --frozen-lockfile --filter @objectstack/spec...`, `pnpm --filter @objectstack/spec build`), `npm pack --ignore-scripts`, then `node scripts/spec-main-shape-gate.mjs inject` into this worktree's install. The inject printed its consumer list and re-pointed `zod` to 4.6.1, as the gate does. The installed legs ran after a clean reinstall, with the gate's injection marker confirmed absent. | leg | spec under the compiler | tree | result | | --- | --- | --- | --- | | installed, before | published 17.4.0, block is `unknown` | `733fd5ac6` (base) | exit 0, 0 diagnostics | | spec `main`, before | source-built `df3ba164a588`, block typed | `733fd5ac6` | **exit 2, 1 diagnostic**: TS1360 "Type 'true' does not satisfy the expected type 'false'." at `_specJoinedReportBlockIsStillUntyped` (the same diagnostic the gate reports) | | spec `main`, after | same injected build | `8ce61bad3` | **exit 0, 0 diagnostics** | | installed, after | published 17.4.0 restored | `8ce61bad3`, and again at head `22226a8e9` | **exit 0, 0 diagnostics** | The two commits after `8ce61bad3` touch only `scripts/` and `.changeset/`, which no `packages/types` program reads. ⚠️ Narrowed on purpose: the gate runs `turbo run type-check` over every package. Locally only `@object-ui/types` was compiled against spec `main`, because it is the only package the gate reported and the only one this diff touches. The full farm is the gate's own run on this head. ## The tripwire, fired for real Under the injected spec-`main` install, `pnpm exec vitest run packages/types/src/__tests__/report-chart-query-spec-parity.test.ts` gives **1 failed, 46 passed (47)**. The one failure is the typing row, with its message: "the installed @objectstack/spec 17.4.0 now types JoinedReportBlock — this is the bump the burn-down is owed at. [...] expected 'typed' to be 'erased'". The lit control and the version row pass (a source-built `main` still reports version 17.4.0). Under the installed spec the same file gives **47 passed (47)**. ## Ablations, each proved on disk All through `ablation-replace.mjs` (objectstack `scripts/`) in wrap mode. For every leg the tool reported "mutation landed", and after it "restored: blob == HEAD (`a1b2c707461e`) and `git diff HEAD` is empty". Spec `main` injected, `tsc -p tsconfig.test.json`: | leg | mutation | result | | --- | --- | --- | | A1 | drop `order` from `_specOnlyBlockKeys`' expected set | TS1360 at that pin | | A2 | drop `chart` from `_localOnlyBlockKeys`' expected set | TS1360 at that pin | | A3 | spec `columns` expected as `number[]` | TS1360 at `_blockColumnsAreTwoConcepts` | | A4 | state pin without its `typed` arm | TS1360 "Type '"typed"' does not satisfy the expected type '"erased"'." | | A5 | state pin without its `erased` arm | 0 diagnostics (the burn-down's future edit is valid on a typed spec) | | A6 | `_blockTypeAgrees` compares `name` against `type` | TS1360 at that pin | | A7 | local label replaced by a plain string in `_blockLabelObjectArmIsNotASpecLabel` | TS1360 at that pin | Installed 17.4.0: | leg | mutation | result | | --- | --- | --- | | A5 | state pin without its `erased` arm | TS1360 "Type '"erased"' does not satisfy the expected type '"typed"'." (the arm is load-bearing today) | | A1 | as above | 0 diagnostics: the typed-spec probes are not asked of an erased spec, as declared | | V | tripwire version row expects `17.3.0` | vitest: that row alone red, 1 failed, 46 passed | ⚠️ The first attempt at A1, A2 and A5 on spec `main` was a no-op. The replacement text was a substring of the anchor, so `ablation-replace` refused it ("the replacement count moved 1 to 1", paraphrased) and restored. Those three legs were re-run in `--delete` mode, and the table shows the re-run. ## Tests and gates, on head `22226a8e9` - `pnpm exec vitest run --maxWorkers=2 packages/types/`: **264 files, 5856 tests, all passed** (installed spec). - Every test that references `scripts/check-spec-symbol-derivation.mjs` (31 files, `scripts/__tests__` and package suites): **31 files, 630 tests, all passed**. - `pnpm check:spec-symbols`: exit 0 ("0 untriaged collisions in 0 packages"). - `check-changeset-presence`: RED first ("1 source file(s) of 1 released package(s) changed, and this change adds no changeset"). That gate counts every file under a released package's `src/`, tests included. So this PR carries `.changeset/10916-joined-report-block-pin-two-specs.md` with an EMPTY frontmatter, and the gate then reads exit 0 ("declared as releasing nothing"). - Exit 0 as well: `check-changeset-no-major`, `check-changeset-fixed`, `check-changeset-overwrite`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:control-bytes`, `check:new-line-citations` (0 new citations), `check:test-path-roots`, `check:installed-pin-claims`, `check:shell-escape-residue`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`. - `check-governed-queue-guard --test` on the three paths: NOT GOVERNED. - eslint (`--no-inline-config`) on the two changed source files: 2 files linted, 0 errors, 0 warnings. Narrowed on purpose; the config enables no type-aware linting, so this diff cannot move the verdict on an untouched file. - NOT MEASURED, prerequisite: `check:spec-floors` needs every package built (it reports `no-artifact` for all 19 spec consumers in an unbuilt worktree); this diff moves no manifest or `dist`. ## The second slice this owes (not in this PR) At the first spec bump past 17.4.0, derive `JoinedReportBlock` from `@objectstack/spec/ui` and converge `JoinedSpecReport`'s `blocks`. Reading: **Clause-② yes (narrowing)**. The published local type accepts any key through its index signature and types the legacy members; the spec's is closed and dataset-bound. It should land WITH the bump: on a typed spec, `JoinedSpecReport` intersects the spec's `blocks` (from `ReportParsed`) with the local ones, which makes `columns` both names and objects (read from the two declarations, not compiled). ## Acceptance notes - objectui#3162 is CLOSED and its body still records the 17.2.0 verdict for this symbol. It is not edited here; the in-tree ledger row is. - `spec-derived-unions.test.ts` keeps its batch-8 note that this symbol was "STILL erased"; that note is a dated reading at spec 17.2.0 and is left as history. This change was made in Claude Code session `https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk`. --- _Generated by [Claude Code](https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 733fd5a commit 29c76e5

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)