Skip to content

ADR-0002: strict TypeScript migration — gate + phases 1–5 (generated schema types, state, contract spine, param pipeline, dashboard runtime, tests) - #265

Merged
BorisTyshkevich merged 14 commits into
mainfrom
chore/typescript-gate-262
Jul 16, 2026
Merged

ADR-0002: strict TypeScript migration — gate + phases 1–5 (generated schema types, state, contract spine, param pipeline, dashboard runtime, tests)#265
BorisTyshkevich merged 14 commits into
mainfrom
chore/typescript-gate-262

Conversation

@BorisTyshkevich

@BorisTyshkevich BorisTyshkevich commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Implements all of ADR-0002 (docs/ADR-0002-static-typing.md) in one PR — phase 0 (the gate) plus the five follow-up phases from the migration proposal pack, per maintainer decision to avoid per-phase issue/PR churn. Closes #262.

Phase 0 — TypeScript gate (original scope)

  • tsconfig.json (strict, allowJs, checkJs: false, noEmit, erasableSyntaxOnly, bundler resolution); typescript devDependency; check:types wired into pretestnpm test → CI.
  • Type-only seam interfaces: EditorPort, SpecEditorPort, CodeViewer, CreateAppEnv/BootstrapEnv, and the App controller surface.

Phase 1 — generated persisted-data types

  • build/emit-schema-types.mjs: a hand-rolled deterministic emitter (json-schema-to-typescript was evaluated and rejected — Ajv-$id-registry ref resolution, 2020-12 unevaluatedProperties/not gaps, unstable title-derived names; recorded as an ADR-0002 addendum). Emits src/generated/json-schema.types.ts (QuerySpecV1, SavedQueryV2, LibraryV2, and a usable PanelCfg discriminated union with a FuturePanelCfg { type: string & {} } forward-compat member). Joins generatedSources() (4→5 artifacts) so generate:schemas/check:schemas staleness covers it; unhandled shape keywords throw. src/schema-contract.types.ts pins semantic invariants (spec open / library+saved-query closed / Extract<> narrowing) at check:types time.

Phases 2–5 — 19 modules converted to strict TS (zero behavior change)

  • State model: src/state.ts — signals typed by value, persisted SavedQueryV2 vs runtime QueryTab separated, localStorage ingress narrowed honestly (decodeStoredSavedQueries discriminated result; visible commented assertions on the six undecoded prefs). app.types.ts placeholders became re-exports of the real types.
  • Contract spine: saved-query, panel-cfg, result-choice, panel-execution, core/dashboard — panel-type ids derive from the generated union; validation boundaries keep cfg: unknown.
  • Param/execution pipeline: param-type, param-validate, optional-blocks, query-params, param-pipeline, dashboard-filters, filter-execution — exported contracts for analysis/prepared-batch/field-control/validation-mode shapes.
  • Dashboard runtime: ui/dashboard, ui/dashboard-kpi-band, ui/panels — discriminated DashSlot = TileSlot | KpiSourceSlot lifecycle (generations, owned AbortControllers, PanelRenderResult.destroy), generic FavoriteSourceHooks<S> so tile/KPI handler mixups don't compile, progress callbacks typed label-only.
  • Still-.js imports are typed via local non-exported wrapper consts (no sibling .d.ts shims) that self-delete as those modules convert later. Coverage parity was verified branch-by-branch on the risky files; no test assertions changed.

Test second wave + tooling

  • 18 tests/unit/*.test.js specs → .ts (it() counts unchanged); tsconfig/vitest include widened.
  • tests/vitest.config.ts gains a mixed-tree resolveId shim (Vite only retries .js.ts from TS importers — tsc/esbuild/Node all resolve it natively).
  • build/e2e-serve.mjs replaces python3 -m http.server for the Playwright harness: the fixtures import raw /src/**/*.js as native ESM, so renamed modules 404'd — the new server falls back to the .ts sibling and type-strips via esbuild. Full e2e suite: 80/80 on chromium+webkit (~12s).

Verification

  • npm test: 101 files / 2850 tests, per-file coverage thresholds green, check:schemas + check:types in pretest.
  • npm run build: dist/sql.html unchanged in kind — four runtime deps, zero third-party requests, no new runtime dependency.
  • High-effort multi-agent code review (8 angles) ran over the full diff; all confirmed findings fixed in 7eb9eeb (e2e harness break, ADR self-contradiction, emitter required gap, one new uncovered branch). Semantic .js-vs-.ts deep-dives on state/dashboard/panels found zero behavior deltas.

Known follow-ups (deliberately not in this PR)

🤖 Generated with Claude Code

https://claude.ai/code/session_01XP1KN94N969SVMNFPdVtGg

tsc --noEmit joins the gate (strict, allowJs, checkJs:false, noEmit,
erasableSyntaxOnly), wired into pretest alongside check:schemas. Five
type-only .types.ts files, each co-located next to the runtime module it
describes, declare EditorPort, SpecEditorPort, CodeViewerHandle,
CreateAppEnv/BootstrapEnv, and the App controller surface as consumed by
render modules — no existing .js module converted, build/artifact unchanged.
editor-port.js's JSDoc EditorPort typedef now points at the .ts file instead
of duplicating it (comment-only).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@BorisTyshkevich BorisTyshkevich mentioned this pull request Jul 16, 2026
93 tasks
BorisTyshkevich and others added 12 commits July 16, 2026 12:47
…the canonical JSON Schemas

Hand-rolled deterministic emitter (build/emit-schema-types.mjs) instead of
json-schema-to-typescript: refs resolve via the Ajv $id registry (not the
filesystem), 2020-12 unevaluatedProperties/not are honored, names are pinned
in the manifest (QuerySpecV1/SavedQueryV2/LibraryV2) and derived from $defs
keys — never from prose titles. Unhandled shape keywords throw, so schema
evolution fails loudly. Output joins generatedSources() (4→5 artifacts) and
inherits generate:schemas/check:schemas staleness for free.

PanelCfg is a usable discriminated union; the forward-compat catch-all is
FuturePanelCfg { type: string & {} } with a guard asserting not.enum equals
the sibling consts. src/schema-contract.types.ts pins the semantic invariants
(spec open, library/saved-query closed, Extract<> narrowing) at check:types
time. ADR-0002 addendum records the emitter decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP1KN94N969SVMNFPdVtGg
… strict TS; vitest mixed-tree resolver

First leaf-up conversions (ADR-0002 phase 4 order): zero behavior change,
strict-clean, unconverted .js imports typed via local non-exported wrapper
consts (no sibling .d.ts shims). Exported contracts gain named types
(ParsedParamType, MaterializedBlock/MaterializeResult, ParamDeclaration).

tests/vitest.config.ts gains a small resolveId plugin: Vite only retries
.js→.ts when the importer is itself TS, so a still-.js module or test
importing a converted module failed to resolve under vitest (tsc, esbuild,
and Node all resolve it natively). The shim maps a relative .js specifier to
the sibling .ts only when the .js file no longer exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP1KN94N969SVMNFPdVtGg
…rict TS

saved-query.ts is the first consumer of the generated persisted-data types
(Panel/Dashboard/QuerySpecV1); builder results are typed as QueryRoot rather
than SavedQueryV2 because id/spec completeness is caller-supplied, unvalidated
data. dashboard.ts and param-validate.ts convert mechanically; param-validate
exports the ParamValidationResult seam shape its pipeline/filter consumers
destructure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP1KN94N969SVMNFPdVtGg
…pipeline to strict TS

Panel spine (panel-cfg, panel-execution, result-choice): panel-type ids and
chart families derive from the generated PanelCfg union — no hand-duplicated
literals; validation boundaries (panelCfgValid/panelCfgStaticValid) honestly
keep cfg: unknown; auto vs resolved vs explicit panel states are distinct
exported types.

Param pipeline (param-pipeline, dashboard-filters, filter-execution): the
analysis/prepare/field-control contracts the pipeline already passes around
get exported names (AnalyzedSource, BoundParamSnapshot readonly snapshots,
PreparedBatch, FieldControl, ValidationMode 'input'|'execute', ...); still-.js
imports typed via local wrapper consts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP1KN94N969SVMNFPdVtGg
…Script

src/state.ts types every signal by its actual value, separates persisted
SavedQueryV2 data from runtime QueryTab drafts, and narrows localStorage
ingress honestly: savedQueries flows through decodeStoredSavedQueries's
discriminated result; the six undecoded prefs carry visible commented
assertions instead of a lying generic loadJSON<T>. app.types.ts's Tab/State
placeholders become re-exports of the real QueryTab/AppState (one placeholder
falsehood fixed: ResultSort.col is a number|null column index, not a string).
tests/types/state.test-d.ts pins compile-time invariants via the widened
tsconfig include.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP1KN94N969SVMNFPdVtGg
The panel registry gets typed arms (PanelArm/PanelRenderArgs) and an exported
PanelRenderResult {node, destroy?} contract; KPI band slots get exported
KpiBand/KpiSourceSlot lifecycle types. Still-.js DOM helpers (dom.js h(),
chart/grid/logs/kpi renderers) stay behind local typed wrapper consts; h()
gains a keyof HTMLElementTagNameMap overload so element props type without
per-call casts. Coverage parity with the pre-conversion files verified
branch-by-branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP1KN94N969SVMNFPdVtGg
…t TypeScript

src/ui/dashboard.ts types the slot lifecycle the runtime already enforces:
DashSlot = TileSlot | KpiSourceSlot discriminated on kind; generation counters,
owned AbortControllers, and PanelRenderResult.destroy have explicit owners;
FavoriteSourceHooks<S extends DashSlot> makes tile/KPI handler mixups
uncompilable; progress callbacks are typed as label-only so streaming updates
cannot deliver results. Execution waves consume the exported PreparedBatch/
PreparedSource contracts. Coverage parity with the .js original verified
branch-by-branch (zero new uncovered branches).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP1KN94N969SVMNFPdVtGg
…wave)

Fourteen tests/unit specs rename to .ts and type their fixtures against the
modules' exported contracts; assertions and it() counts are unchanged.
vitest test.include widens to *.test.{js,ts}; tsconfig include gains
tests/unit and tests/helpers. Deliberately partial/invalid fixtures keep
their values behind targeted single-level casts. Two fixture adjustments
verified behavior-neutral: placeholder message fields on spec-diagnostic
stubs, and KPI keyed-object row fixtures rewritten positionally (readKpiFields
output byte-identical; the keyed-row variant is a known type-vs-runtime gap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP1KN94N969SVMNFPdVtGg
…aced

applyResultChoice gets its explicit QueryRoot return type (was inferring
unknown, forcing caller-side wrapper casts); ResultLike/KpiSourceResult accept
serverVersion/error null like the AppState/stream shapes that feed them;
FilterDiagnostic names sourceId (the Retry affordance dispatches on it);
App.specValidators is the real SpecValidationService instead of unknown.
The panels.ts and dashboard.ts wrapper casts those gaps forced are removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP1KN94N969SVMNFPdVtGg
The 1985-line spec covering ui/dashboard.ts and core/dashboard.ts converts
with all 142 it() cases unchanged. fake-app.js bridges to the App contract
via a typed withApp() merge; the createApp() glue tests keep the one real
object reference (closures mutate their captured app, so a spread copy reads
stale state — asApp is a pure type reinterpretation instead).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP1KN94N969SVMNFPdVtGg
…for phases 1-5

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP1KN94N969SVMNFPdVtGg
The Playwright fixtures import raw /src/**/*.js as native ESM with no bundler,
so the python http.server 404'd every module renamed to .ts (all four editor
fixture pages — 36 e2e tests were failing). build/e2e-serve.mjs replaces it:
same static serving plus the ADR-0002 mixed-tree rule every other tool already
applies — a missing .js falls back to its .ts sibling, type-stripped in-process
by esbuild (browsers don't execute TS). Full suite green again (80/80 on
chromium+webkit, ~12s).

Also from the review pass: ADR-0002's Decision §3/Consequences reconciled with
the emitter addendum (they still named json-schema-to-typescript as the tool);
the emitter now fails loud on `required` in a properties-less object instead of
silently dropping the constraint (+ guard test); saved-query.ts's andGet gains
the missing garbage-ingress branch test (restores pre-conversion branch
coverage).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP1KN94N969SVMNFPdVtGg
@BorisTyshkevich BorisTyshkevich changed the title ADR-0002 phase 0: TypeScript gate — tsconfig, tsc --noEmit, typed seam interfaces ADR-0002: strict TypeScript migration — gate + phases 1–5 (generated schema types, state, contract spine, param pipeline, dashboard runtime, tests) Jul 16, 2026
…uplicated wrappers

The three tiny leaves whose typed wrapper consts were copy-pasted across the
converted slice become strict .ts themselves: dom.ts gives h() its honest
overloads (TagNameMap + function components) once, param-scan.ts owns
ParamDeclaration/ParamOccurrence, diagnostics.ts owns the Diagnostic factory
shape. Eight wrapper blocks across panels/dashboard/dashboard-kpi-band/
dashboard-filters/filter-execution/param-pipeline/optional-blocks/query-params
are deleted (query-params re-exports ParamDeclaration so its API is
unchanged), plus dashboard.ts's now-dead FilterSqlDiagnosticView bridge.
Their tests convert too. Remaining conversion inventory tracked in #267.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP1KN94N969SVMNFPdVtGg
@BorisTyshkevich
BorisTyshkevich merged commit dcbf0a7 into main Jul 16, 2026
6 checks passed
@BorisTyshkevich
BorisTyshkevich deleted the chore/typescript-gate-262 branch August 6, 2026 15:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0002 phase 0: TypeScript gate — tsconfig, tsc --noEmit in the test gate, typed seam interfaces

1 participant