Skip to content

chore(ts): TypeScript hardening — strict flags, Go-native tsgo, noUncheckedIndexedAccess#207

Merged
srsholmes merged 5 commits into
mainfrom
claude/ts-hardening
Jul 9, 2026
Merged

chore(ts): TypeScript hardening — strict flags, Go-native tsgo, noUncheckedIndexedAccess#207
srsholmes merged 5 commits into
mainfrom
claude/ts-hardening

Conversation

@srsholmes

@srsholmes srsholmes commented Jul 8, 2026

Copy link
Copy Markdown
Owner

End-to-end TypeScript hardening. Dependencies untouched (deferred by request). Everything below applies equally to tsc and the Go-native tsgo; both are green, as are eslint (0 errors) and the full test suite (364 pass).

1. Stricter compiler flags

Enabled, with near-zero churn:

  • noImplicitReturns
  • noUnusedParameters (surfaced 6 vestigial state params in recomp/lib/state.ts_-prefixed)
  • noUncheckedIndexedAccess — the big one: indexed access (arr[i], obj[key], regex groups, .split() parts, array destructuring) now yields T | undefined.

Skipped noPropertyAccessFromIndexSignature (~261 errors, low value). exactOptionalPropertyTypes (~89) is deferred to a follow-up.

2. Broadened typecheck coverage

include previously only covered plugin entry files (backend.ts/app.tsx/panel.tsx), so plugin lib/, components/, games/, and scripts/ code was only checked transitively — and the shipped recomp install recipes (plugins/recomp/games/**/setup.ts, loaded via a runtime-computed dynamic import) weren't checked by anything. Now all of it is, with an explicit exclude for tests/build/.cache.

3. Go-native tsgo as the primary typecheck

Adopted @typescript/native-preview (tsgo), pinned exactly. ~9× faster than tsc (0.9s vs 8.6s) and slightly stricter — it caught a real generic-inference hole tsc missed (lsfg-vk/InstallCard; fixed with an explicit Select<LayerVersion>). typecheck runs tsgo everywhere (local, CI, release gate); tsc is retained as a typecheck:tsc safety-net step while tsgo is a -dev preview. Must be invoked via bun run (its bin shim needs node otherwise) — documented in the workflows.

4. The 323 noUncheckedIndexedAccess errors — fixed twice over

First pass fixed all 323 across ~90 files. Review feedback (correctly) flagged that ~159 of those were bare non-null ! assertions — compile-time-only claims that insert no runtime check. A second pass reworked them into runtime-safe patterns, preserving exact behavior:

  • Bitwise/boolean numeric reads?? 0 (behavior-identical: undefined & mask already coerces to 0 — HID report bytes, evdev bitmaps, gamepad axes).
  • Required regex capture groups?? "" (group always defined on a match; fallback is dead code).
  • "Must exist" elements (length-checked [0], findIndex/splice results, Promise.all index-aligned rows) → hoisted guarded locals that log and degrade with the same action the surrounding code already used, so an impossible-today miss becomes a diagnosable warning instead of a silent undefined.
  • Loop-bounded indexing → restructured to for...of / .entries() / paired iteration.
  • injector.ts: this.cdp! sites → a requireCdp() accessor; definite-assignment let tab! → explicit | undefined + post-loop guard.

Bare ! reads remaining vs main: 2 (DAY_NAMES[date.getDay()]!, provably 0–6 into a 7-element const, commented). No any/as any/@ts-ignore introduced anywhere.

Correctness review — two adversarial rounds

  • Round 1 (6 agents, post-nUIA): no behavioral regressions; every ! provably safe, every guard fires only where old code crashed.
  • Round 2 (6 agents, post-de-bang): behavior-identical everywhere — including a live A/B test of the vdf parser and a full catch-path trace of the requireCdp() rework (no error-type/message branching anywhere). One real finding: parseVdf had gone from throw to silently partial-parsing corrupt (brace-underflowed) VDF, which could have understated playtime instead of skipping a corrupt user file. Fixed: the lenient parse stays, but underflow now logs a one-shot warning so a partial result is diagnosable.

On-device verification (OneXPlayer APEX)

Built + installed: all 29 plugins load with zero errors; TDP boot-restore and the InputPlumber wake-button reload work; manual pass over the highest-churn areas (fan-curve editing, Steam CEF injection/badges, launch options, playtime, storage cleaner, flatpak, steamgriddb art, RGB) — all working. Known pre-existing issue #119 (right-stick scroll) unchanged by this PR.

Deferred (follow-ups)

  • exactOptionalPropertyTypes (~89 errors)
  • Dependency updates

Checks

tsgo 0 · tsc 0 · eslint 0 errors · 364 tests pass · bun install --frozen-lockfile clean · CI green.

🤖 Generated with Claude Code

srsholmes and others added 2 commits July 8, 2026 16:16
Pragmatic TS hardening (deps untouched):

- tsconfig: enable noImplicitReturns + noUnusedParameters (both strict,
  near-zero churn). Skipped the high-churn/low-value flags for now
  (noPropertyAccessFromIndexSignature ~261 errs); noUncheckedIndexedAccess
  (~234) and exactOptionalPropertyTypes (~89) are left for follow-up PRs.
- Broaden `include` to cover plugins' lib/ and components/ (+ top-level
  plugin files) — previously only backend.ts/app.tsx/panel.tsx entry files
  were checked, so lib code was only validated transitively. Add an explicit
  `exclude` for test/spec, build, and .cache so this stays production-focused.
- Adopt the Go-native TypeScript preview (@typescript/native-preview / tsgo),
  pinned exactly, as a second CI typecheck step. It's ~9x faster than tsc and
  slightly stricter — it caught a real generic-inference hole tsc missed. tsc
  stays the authoritative gate; tsgo adds fast coverage. New `typecheck:go`
  script for local use.

Fixes surfaced by the above:
- lsfg-vk InstallCard: `Select` inferred its generic as `string`, so the
  layer-version value flowed into a `LayerVersion` handler unchecked. Pin the
  generic explicitly (`Select<LayerVersion>`) — genuinely type-safe, no cast.
- recomp/lib/state.ts: 6 vestigial `state` params (the helpers now read fresh
  state via mutateState) flagged by noUnusedParameters — `_`-prefixed.

Audit note: the production `any`/suspect-cast surface is small (~51 across
~24 files) and almost entirely legitimate runtime/FFI boundaries (Electrobun
CEF imports resolved at runtime, Steam-webhelper patching). The 8 production
@ts-ignore are all on runtime-resolved electrobun/* imports.

tsc: 0 · tsgo: 0 · eslint: 0 errors · 364 tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…review

Per follow-up: promote the Go-native compiler from a CI-only extra to the
main typecheck everywhere, and address PR-review findings.

- `typecheck` script now runs tsgo (primary); `tsc` moves to `typecheck:tsc`
  and is kept as a CI/release safety-net step since tsgo is still a -dev
  preview (can have false negatives) — belt-and-braces until it GAs.
- ci.yml + release.yml: tsgo is the primary typecheck step, tsc the backstop.
  Documented that tsgo must be invoked via `bun run` (its bin shim is
  `#!/usr/bin/env node`; bun runs it with no node installed).

Review findings folded in:
- Coverage gap (Medium): the shipped recomp install recipes under
  plugins/*/games/**/*.ts were type-checked by nothing (loaded via a
  runtime-computed dynamic import, so not even transitively). Added
  plugins/*/games/** and plugins/*/scripts/** to `include` — both compilers
  pass with zero new errors.
- Symmetry (Low): added **/*.spec.ts to `exclude` (only *.spec.tsx was listed).

tsc: 0 · tsgo: 0 · frozen-lockfile clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Turn on `noUncheckedIndexedAccess` — indexed access (arr[i], obj[key],
regex groups, .split() parts, array destructuring) now yields `T | undefined`,
catching a large class of latent "possibly undefined" bugs.

Fixed all 323 resulting errors across ~90 files, properly (no suppressions):
- Real guards / early-returns / `continue` where a value can genuinely be
  missing (parser rows, device lookups, empty collections).
- `?? fallback` where a sensible default exists.
- Non-null `!` ONLY where provably in-bounds — after a `.length` check, inside
  `0..len-1` loops, on required regex capture groups, or on `findIndex`
  results — each annotated with a short justifying comment.

No `any` / `as any` / `as unknown as` / `@ts-ignore` introduced; the one
pre-existing React-fiber `as unknown as` in qam.ts was preserved as-is.
Runtime behavior unchanged (guards only fire on inputs that would previously
have thrown or produced undefined downstream); parser test suites still pass.

tsgo: 0 · tsc: 0 · eslint: 0 errors · 364 tests pass.

Co-authored-by: Claude Opus 4.8 <[email protected]>
@srsholmes srsholmes changed the title chore(ts): harden TypeScript checking + Go-native tsgo gate in CI chore(ts): TypeScript hardening — strict flags, Go-native tsgo, noUncheckedIndexedAccess Jul 9, 2026
srsholmes and others added 2 commits July 9, 2026 10:11
…allbacks

Review feedback on the noUncheckedIndexedAccess pass: ~159 of the fixes were
bare `!` assertions — compile-time-only claims that insert no runtime check,
so a wrong (or later-invalidated) assumption still flows `undefined` silently
downstream. Rework them into runtime-safe patterns, preserving exact behavior
for all real inputs:

- Bitwise/boolean numeric reads → `?? 0` (behavior-identical: `undefined &
  mask` already coerces to 0; HID report bytes, evdev bitmaps, capability
  bits, gamepad axes).
- Required regex capture groups → `?? ""` (group is always defined on a
  match; the fallback is dead code that removes the assertion).
- "Must exist" elements (length-checked [0], findIndex/splice results,
  Promise.all index-aligned rows) → hoisted guarded locals that log and
  degrade with the SAME action the surrounding code already used
  (continue/return/throw-the-same-error), so an impossible-today miss
  becomes a diagnosable warning instead of a silent undefined.
- Loop-bounded indexing → restructured to for...of / .entries() / paired
  iteration where it reads cleanly (removes the assertion entirely).
- injector.ts: `this.cdp!` sites → a `requireCdp()` accessor; definite-
  assignment `let tab!` → explicit `| undefined` with a post-loop guard.

Bare `!` reads remaining in the diff vs main: 2 (DAY_NAMES[date.getDay()]!,
provably 0-6 into a 7-element const, commented). Definite-assignment
declarations (`let x!: T`) kept where they're the standard sync-Promise-
executor pattern.

tsgo: 0 · tsc: 0 · eslint: 0 errors · 364 tests pass.

Co-Authored-By: Claude Fable 5 <[email protected]>
Review finding on the noUncheckedIndexedAccess pass: parseVdf used to throw
on brace-underflowed (corrupt/truncated) VDF, and callers like playtime used
that throw as their malformed-file signal (skip the user). The added guards
made it silently return a partial parse instead — indistinguishable from a
complete one.

Keep the lenient parse (more robust than crashing), but log a one-shot
warning when underflow is detected so a partial result is diagnosable.

Co-Authored-By: Claude Fable 5 <[email protected]>
@srsholmes srsholmes merged commit 494da1a into main Jul 9, 2026
2 checks passed
@srsholmes srsholmes deleted the claude/ts-hardening branch July 9, 2026 09:40
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.

1 participant