Skip to content

Quality floor: type-aware lint, import validation, Electron hardening - #83

Merged
rebaserHEAD merged 12 commits into
mainfrom
chore/ts-quality-floor
Jul 28, 2026
Merged

Quality floor: type-aware lint, import validation, Electron hardening#83
rebaserHEAD merged 12 commits into
mainfrom
chore/ts-quality-floor

Conversation

@rebaserHEAD

Copy link
Copy Markdown
Owner

About

This raises the baseline for catching problems before they ship, and fixes everything the new tooling found on its first pass.

The centerpiece is ESLint with typescript-eslint's type-aware rules plus react-hooks. It wasn't just plumbing; day one it caught a conditional hook call in LightEditor that would crash on hook-order if the light info ever flipped while mounted, a stale closure in the CCW decal rotation handler (the CW twin had the dep, CCW didn't), a missing decalsDirty dep in buildYaml so a save right after a decal edit could serialize stale, and about 27 async click handlers and file reads that failed silently. Also removed onFocusGrid, which turned out to be dead wiring inherited from the original open-sourcing commit.

On top of that:

  • importMap now validates the structural shell of the document with a deliberately thin Zod schema before traversal. Pointing the editor at a prototype file gets a readable status-bar error instead of a TypeError three calls deep. Component bodies stay opaque and unknown keys pass through, so the round-trip contract is untouched.
  • An error boundary around the app. A bad prototype throwing during render used to whitescreen the editor with unsaved work; now it's a crash screen with the error, a reload button, and an issue link.
  • exactOptionalPropertyTypes is on. The exporter genuinely dispatches on key presence ('maps' in doc), so missing-vs-undefined is a distinction worth having the compiler police.
  • A noUncheckedIndexedAccess ratchet: the flag is ~750 errors repo-wide, so scripts/check-strict-indexing.mjs enforces it only in cleaned directories (algorithms, settings, validation, hooks so far) and CI keeps them clean. Remaining dirty counts are recorded in the script; state/ is the next slice worth doing.
  • Electron hardening: sandbox: true, openExternal gated to http(s), a will-navigate origin pin, and a CSP on the packaged app.
  • Dependabot (weekly, grouped), and docs caught up (README CI list and Node floor, import pipeline diagram, ErrorBoundary reference).

no-explicit-any stays a warning for now. The 186 remaining are almost all the YAML ingest boundary, and the honest fix there is extending schema coverage, not hand-typing casts.

Testing

  • Full suite: 967 passed, 9 skipped. Typecheck, lint, format, ratchet, and production build all green locally (same gauntlet CI now runs).
  • Parity sweep against the Triad corpus, this branch vs main in a clean worktree: byte-identical results (397 identical, 37 drifted, 1 import-failed on both). The one failure is pre-existing: test_teg.yml has a duplicated mapping key js-yaml refuses upstream of anything this branch touches. Worth its own issue.
  • I have not smoke-launched the desktop app since adding sandbox: true and the CSP. The preload only uses contextBridge/ipcRenderer so it should be sandbox-clean, but a npm run electron:dev run before merge is the right check.

Checklist

  • npm run test passes
  • npm run format:check is clean
  • Parity sweep run if the importer or exporter changed
  • CHANGELOG.md has a line under Unreleased (user-facing changes only)

Flat config with typescript-eslint recommendedTypeChecked plus
react-hooks, split across three surfaces: type-aware rules for src/,
plain recommended for the electron/ Node scripts and root configs.
Prettier config last so nothing fights the formatter. CI gains a Lint
step between Typecheck and Tests.

The notable catches, beyond the mechanical unused-import sweep:

- LightEditor called useCallback below an early return; if lightInfo
  flipped null <-> non-null while mounted, React would throw on hook
  order. Hook hoisted above the guard.
- handleCycleEntityRotationCCW was missing selectedPaletteItem in its
  deps; the CW twin had it. Stale closure, decal rotation only.
- buildYaml was missing state.decalsDirty in deps, so a save right
  after a decal-only edit could serialize without the decal rewrite.
- Every async click handler / fire-and-forget promise now either
  catches or is explicitly void with the reasoning commented.
- onFocusGrid/handleFocusGrid: dead since the original open-sourcing
  commit (prop wired, no UI trigger ever existed). Removed; git keeps
  the fitBounds handler if a focus-grid feature lands later.

no-explicit-any and the v7 React Compiler diagnostics stay warnings:
the any debt sits at the YAML ingest boundary where Zod is the real
fix, and the compiler rules flag documented-deliberate patterns.
importMap parsed straight into `as any` and trusted the traversal:
point it at a prototype file or half a merge conflict and it died
three calls deep with a TypeError. There is now a Zod schema
(mapSchema.ts) between yaml.load and the traversal.

The schema is deliberately the thinnest thing that guarantees what
the traversal assumes: doc is a mapping, entities is an array of
groups, uids are ints, components are mappings. Fields type-check
only when present, unknown keys pass through, component bodies stay
opaque. The round-trip contract is byte-exact and players hand-edit
these files, so anything the schema rejected would become an
unopenable file; when in doubt it stays loose.

Parity sweep against the Triad corpus: no change (the one pre-existing
failure, test_teg.yml, is a duplicated mapping key that js-yaml
refuses upstream of the schema; the engine parser is last-wins).
One bad prototype in a stranger fork could throw during render and
whitescreen the whole editor, unsaved work included, with nothing but
the devtools console to explain it. The boundary catches render-phase
crashes, shows the error with a trimmed stack, and offers reload (which
lands on the fork selector recent-files list) plus a link to the issue
tracker.
The round-trip exporter dispatches on key presence ('maps' in doc), so
missing-vs-undefined is a real distinction in this codebase and worth
having the compiler police. Fields that are genuinely assigned
undefined-as-a-value are widened to `?: T | undefined`; no conditional
spreads or guards, because changing which keys exist on an object is a
behavior change here.
- sandbox: true. The preload only touches contextBridge/ipcRenderer,
  so it runs unchanged in the OS sandbox; a compromised renderer can
  no longer reach Node.
- openExternal now gated to http(s). Deny-and-open was already the
  shape, but any URL reaching it went to the OS shell, file:// and
  custom protocol handlers included.
- will-navigate handler pins the window to the app origin.
- CSP on the packaged app (served with the HTML from the app://
  handler): everything 'self' except inline style attributes (React
  style={{}}) and blob:/data: images (sprite thumbnails). Verified no
  workers, no eval, no external fetch in src. Dev server stays exempt;
  Vite HMR needs its own policy.
The flag surfaces ~750 errors repo-wide; that is a migration, not a
config change. scripts/check-strict-indexing.mjs runs tsc with the flag
over the whole program and fails CI only for errors inside cleaned
directories (a per-directory tsconfig include cannot do this: tsc
reports errors across the transitive import graph, so the dirty core
would leak into any slice). algorithms/, settings/, validation/ and
hooks/ are cleaned and enforced; the dirty-directory error counts are
recorded in the script for whoever picks up the next slice. state/ is
the highest-value target (155 errors, and it is the reducer).

Source fixes are `as const` on the 4-neighbor offset tables; test fixes
are non-null assertions behind explicit length expectations.
ESLint types the tests without the ratchet flag (tsconfig has it off),
so the assertions read as unnecessary there while the ratchet run
requires them. Optional chaining satisfies both type universes and
fails the assertion cleanly instead of throwing if the index ever
comes back empty.
The script it references was removed when the static-deployment story
was carved out (2efe4e0) and public/resources/ no longer exists;
nothing in the tree generates it.
- README: CI gauntlet now lists lint and the strict-indexing ratchet;
  Node floor raised to 20.19+ (ESLint 10 requirement; CI runs 22).
- import-export.md: the Zod structural-shell gate is now part of the
  import pipeline diagram, with a note on why it stays loose.
- ui-components.md: ErrorBoundary reference section.
- CHANGELOG: Unreleased entries for the crash screen, the readable
  invalid-file import error, the CCW decal-rotation stale closure, and
  the stale decal-state save.
Merged main in (PR #81, chrome focus-steal fix) so the branch lints the
same tree CI does; the new test file had one `as string` on a value
already typed string, which the type-aware lint flags.
@rebaserHEAD
rebaserHEAD merged commit 6c999ec into main Jul 28, 2026
1 check passed
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