Files
astrolabe/docs/exploration/engineering-review-2026-07.md

28 KiB
Raw Permalink Blame History

Engineering Review — 2026-07-02

Point-in-time record. Full-project engineering review requested by the maintainer, with a specific question attached: "as we work session to session, we may forget to look back and see the bigger picture — the end-of-session skill checks aim to mitigate it, but I'm not sure to what extent it is successful."

Method: five independent clean-context reviewers (code quality, test suite, documentation, build/tooling/delivery, cross-session coherence), each required to cite file/line/commit evidence, followed by an adversarial verification wave that reproduced every load-bearing claim (rebuilt the bundle, re-ran greps and git archaeology, hand-walked the flagged logic, re-checked the live site). Findings below are only those that survived verification; where a reviewer's number was wrong, the corrected number is used. Reviewed state: working tree at c5e4c4c plus the uncommitted spec-params/editor WIP.

Verdict

The codebase itself is in excellent shape — the architecture contract holds under grep, not just in prose, and the per-session review machinery demonstrably works at the session scale. The problems are concentrated at two horizons the per-session view cannot see: delivery (one measurable production defect: the marketing landing executes 1.3 MB gzipped of Monaco + Vega it never uses) and look-back. The maintainer's fear is confirmed, mechanically: no instrument — on-demand or scheduled — owns the whole-project view, so coherence work happens only when a session happens to collide with it. (Maintainer clarification after the first draft: the eng-council sweep and the ux-second-pass batch were designed as on-demand session guardrails, not periodic instruments — which sharpens the finding rather than softening it: the on-demand instruments have each fired once, and nothing at all runs on a cadence.)

Scorecard

Dimension Grade One-line summary
Code quality A Zero any/suppressions, layering verifies by grep, error discipline is real
Test suite A 1,366 tests, uniform harness, deterministic; one committed coverage hole that matters
Documentation A Symbol-level accuracy at scale; drift is localized fossils plus one governance gap
Build / delivery B Strong local gates; landing bundle defect shipped because no gate measures output
Cross-session coherence B A for per-session machinery, D for look-back machinery

Critical findings (all independently verified)

C1. The landing and /learn/ eagerly load and execute Monaco (940 kB gz) and Vega (285 kB gz)

Verified on the live site and reproduced from a clean build. dist/index.html modulepreloads both chunks and the landing entry's static import graph executes them (main-*.js ends with bare import"./vega-*.js";import"./monaco-*.js"). /learn/ is worse: both arrive as direct <script type="module"> tags. Landing eager JS today: ~4.7 MB raw / ~1.30 MB gzip; the intended payload is ~255 kB raw / ~81 kB gzip — a 16× reduction available.

The source-level lazy-loading discipline is correct (LandingChart.tsx dynamic-imports chart-renderer; nothing in src/landing/src/learn mentions Monaco). Two bundling-level causes defeat it:

  • Preload-helper placement. The object-form manualChunks in vite.config.ts leads Rollup to emit Vite's __vitePreload helper inside the monaco chunk; LandingChart-*.js begins import{_ as h}from"./monaco-*.js", so every chunk that uses import() statically depends on all of Monaco. (Attribution to the object form specifically is plausible-but-untested; the observed mechanism is fully reproduced.)
  • vega-scale edge. Landing.tsx@core/vega-themestheme-controls.ts:27 import { scheme } from 'vega-scale' merges into the vega manual chunk, dragging all of Vega into the landing's static graph. The comment at theme-controls.ts:21-22 ("keeps the umbrella vega out of core") is true at source level and defeated by chunking. The examples chunk has a second, independent edge into the vega chunk.

Fix: break the vega-scale edge (lazy-import or move scheme resolution off the landing-reachable path); switch to function-form manualChunks and confirm the helper lands in a shared micro-chunk; and — the durable part — add a ~10-line post-build assertion that dist/index.html and dist/learn/index.html reference neither monaco-* nor vega-*. That assertion is the missing gate for this entire regression class.

C2. The project's operating regime is recorded nowhere the machinery reads

The 2026-06-10 phase shift — spec follows code; docs/spec is no longer the authoritative contract — appears in exactly one repo location: docs/exploration/chart-builder-enhancement-scope.md:673, inside the directory whose charter says nothing there is kept current. Meanwhile CLAUDE.md ("authoritative behavioral specification… This is the contract"), AGENTS.md ("Spec is the contract"), SOUL.md, arch 00 ("the spec wins"), and .claude/skills/alignment/SKILL.md rules #3/#17 all still assert the old regime. Two reviewers independently converged on this, and the verification wave reproduced the greps.

This is the worst coherence defect because it corrupts the corrective machinery itself: the wrap-up protocol's whole design is clean-context subagents judging against the recorded contract — and the recorded contract is wrong. Fix: one paragraph in CLAUDE.md/AGENTS.md stating the regime (spec is descriptive, kept current with code; on conflict fix the spec), softened phrasing in docs/spec/README.md and arch 00, and an update to the alignment skill.

C3. The spec stopped absorbing new behavior at a verifiable cutover (2026-06-21 → 06-25)

Adjudicated timeline, commit-dated: through d76a7a5 (06-21) every feature landed with its spec section in the same commit (Theme Builder, FontAsset, data inspector, onboarding, URL datasets — all specified at full fidelity). From c19857b (06-25) onward the discipline inverted: 12 of 13 feature commits through 06-30 updated docs/architecture/ in-commit and zero touched docs/spec/. Unspecified user-facing surfaces at HEAD:

  • the entire /learn/ section (grep -ril learn docs/spec/ → nothing);
  • the composition wireframe (drag reorder / pull-out / stack / Simplify — zero matches);
  • editor transform actions and CodeLens scaffolds (wrap/simplify/add-view, add-transform);
  • the multi-view Data Inspector view picker (docs/spec/04-live-preview.md:84 references "the chosen view's table" — the chooser it refers to is specified nowhere);
  • the current uncommitted params-scaffolding work continues the pattern (arch 08 only).

Even under the spec-follows-code regime this is debt: the "then amend the spec to match" half of the bargain has not happened for ten days of features. Root cause is structural, not negligence: /doc-update flushes what a session remembers and /alignment reviews diffs — no instrument owns "the spec describes the product." Alignment rule #17 covers removed/moved surfaces only, not never-specified additions.

Significant findings

S1. No instrument owns the big picture on any cadence

  • Eng-council sweep: once, 2026-06-12 (0e225d7). Since then non-test source grew ~+76% (16.8k → 29.7k ts/tsx LOC; ~21.3k → ~37k all-source). docs/codebase-metrics.md, whose stated purpose is the trend, holds two same-day rows. Specific unswept accretion: the six-module spec-* service family plus editor-snippet/editor-cursor-lens (~1,900 LOC, mostly post-sweep) — which alignment rule #16 already notes "has grown by copy-paste twice."
  • Batched council pass over docs/ux-second-pass.md: once, 2026-06-13 (92bfe88, 76→25 lines — the design worked). The file has regrown 25→48; oldest open item is from 06-16.
  • TODO gardening: never. Lifecycle is bimodal — 56 added / 43 removed over history (~7177% resolution, usually within 03 days, at least 10 by dedicated commits), but every TODO that survived its first week is still alive. Oldest two are from 06-12: src/app/modals/ModalCoordinator.ts:36 (a latent init-ordering bug, not polish) and ChartBuilderModal.tsx:524. The open set is increasingly the hard/ambiguous residue no session claims.

Fix direction (maintainer to ratify): give each instrument a trigger — re-sweep when source LOC grows ~25% past the last metrics row; batch council pass when ux-second-pass open items exceed a count or age past two weeks; a spec-reconciliation clause in /doc-update ("did this session add user-facing behavior? name the spec section or write it"); TODO gardening as a standing sweep agenda item.

S2. The scaffold insertion math is untested, duplicated, and validity-critical

Verified: runAddTransform (spec-transform-scaffold.ts:130-146, committed c5e4c4c) and runAddParam (spec-param-scaffold.ts:109-142, WIP) compute insert offsets, indentation, and the trailing-comma decision; a bug here writes invalid JSON into the user's editor. No test exercises the composition — no SpecEditor.test.tsx exists, so there is no indirect path either. The create-property block is a verbatim 7-line duplicate between the two files; the completion providers share ~35 structurally identical lines. Hand-walking the current logic found no live bug — this is a coverage hole, not a defect — but a future inversion of the ternary or offset drift ships silently. The repo's own TODOs (spec-param-scaffold.ts:33, spec-params.test.ts:6) already point at the fix: extract a neutral core module (working name spec-snippet) computing (text, host offset, existing-array?) → {insertOffset, snippetText} and table-test it over empty-host / host-with-following-key / compact-one-line / host-as-last-property.

Related: spec-transform-actions.ts (663 lines, largest service, no test file) carries pure unexported helpers with real branching — reindent, defaultFacet, defaultRepeat (:106, :123, :136) — one layer short of tested core, mitigated by buildNext being a thin dispatcher over tested core wrappers.

S3. Documentation drift cluster (6/6 claims verified)

All in otherwise highly accurate docs; each is a fossil a maintainer would act on:

  1. 5 MB budget fossil. docs/spec/09-data-model.md §E and 08-import-export.md describe a budget-with-fill-warnings storage monitor that spec 02/10, arch 02 §6, and the code all contradict (the monitor is a composition breakdown; the only 5 MB logic is the import pre-check in transfer.ts:42). Arch 02:445's "80% threshold" Do-line contradicts the same doc's line 425 and matches nothing in code.
  2. "Not installable" is false. Arch 10:625 claims the manifest ships no icons; four icons ship (vite.config.ts:85-89, files in public/), and manual-verification actively tests install.
  3. The ajv layer was never built. Arch 08 presents it as planned-M2 and claims "we deliberately do better: map ajv errors to editor positions" — no such module exists; ajv isn't even in package.json. The current WIP on arch 08 does not touch this.
  4. Wrong store name in three docs. Arch 01/03/04 name useSettingsPopoverStore / openSettingsPopover; the code is usePopoverStore / openPopover (PopoverStore.ts:22,30, called from EventRouter.ts:96). Grep by the documented name finds nothing.
  5. Arch 03's "closed union" is stale. Five modals listed; modals/types.ts has six (themeBuilder). Same doc's add-a-modal checklist instructs hasError/getError on ModalConfig — fields the real type doesn't have; following it fails typecheck.
  6. Spec 09's UserSettings omits ui.dataInspectorOpen/ui.dataInspectorHeight, which spec 04 mandates and settings-store.ts:158-183 persists (they're also absent from core/settings.ts's type — the code half of the same gap).

S4. Delivery gates never observe build output, and caching is misconfigured

  • All quality gates live in the (excellent) pre-commit hook — lint-staged + full typecheck + the whole test suite — but nothing anywhere runs or inspects vite build. Cloudflare's build fails safe on compile errors (previous deploy stays live) but silently; the escape class is green-but-wrong output, which is exactly how C1 shipped. Cheapest durable fix: the C1 post-build assertion in the build script; optionally a minimal CI workflow (typecheck + lint + test + build + assertion) to cover --no-verify and web edits.
  • Hashed /assets/* are served cache-control: max-age=14400, must-revalidate (Cloudflare Pages default; no public/_headers exists). Content-hashed files are the textbook max-age=31536000, immutable case; today every returning visitor revalidates a 3.6 MB chunk every 4 hours. HTML cache headers are correct.

S5. Module-size outliers in the chart builder

src/core/chart-builder.ts (1,851 lines: catalog + rules + smart defaults + assembly/parse) and ChartBuilderModal.tsx (1,653 lines, ~25 private subcomponents; 2.1× the next-largest component). Internally cohesive, well-documented — maintainability risk, not defect. Split on next substantive touch (chart-builder/{catalog,rules,defaults,assemble}.ts; hoist the modal's sections into sibling files).

S6. Smaller verified items

  • noUncheckedIndexedAccess is off in a parser-heavy codebase (jsonc-parser node walking in spec-params.ts, spec-cursor.ts, spec-insert.ts) that is exactly its target class. Enable and sweep; if parser noise proves disproportionate, record the rejection.
  • ajv is imported by spec-params.test.ts:1 but undeclared — a phantom transitive dependency; add it to devDependencies (found during verification).
  • npm audit: monaco 0.54.0 bundles a vulnerable DOMPurify (moderate; fix is 0.55.1, flagged breaking — plan deliberately); esbuild advisory is dev-only/Windows-only.
  • Landing/learn light-dark toggle duplicated since 06-25, TODO'd in both copies and reworded 06-28 instead of extracted — a breadcrumb treated as the deliverable.

Minor findings

  • Per-editor CodeLens providers register on the global 'json' language (editor-cursor-lens.ts:83) while closing over one editor's cursor — safe with today's single editor (verified: one monaco.editor.create), latent cross-wiring if a second JSON editor appears. One guard line: if (model !== editor.getModel()) return.
  • One avoidable non-null assertion in the WIP: spec-transform-scaffold.ts:176.
  • navigator.platform (deprecated) in EventRouter.ts:38.
  • Hooks are untested as a class; useFocusTrap (a11y-load-bearing, shared by all overlays) is the one worth a cheap happy-dom test. ModalCoordinator's open/close/replace sequencing likewise (~5 tests).
  • No coverage reporting exists; for a philosophy that is explicitly proportional ("core hardest"), nothing measures the proportion. @vitest/coverage-v8 scoped to src/core + src/app/{stores,services}, no thresholds, visibility only.
  • localStorage write failures are console-only (settings-store.ts:80, ux-prefs.ts:80) — judged an acceptable, explicit low-stakes swallow; noted so the asymmetry with the IndexedDB path stays a decision.
  • /learn/ is reachable only from the landing; nothing in the app links it. Deliberate or forgotten is unrecorded — which is itself the gap.
  • Doc/housekeeping nits: pre-commit hook is stronger than AGENTS.md documents (says eslint; runs typecheck + tests too — drift in the good direction); PWA precache carries ~150200 kB of landing/learn assets the /app/-scoped SW can never serve; spec 00 still calls settings a modal; spec 02 overstates the "imported" tag (only foreign/older shapes are tagged — spec 08 has it right); docs/embedding-vega-lite.md (447 lines) is orphaned — index it with a charter line or move it; core/settings.ts:64 and HeaderControls.tsx:2 cite the old pre-move path of the theming scope doc; IMPLEMENTATION-PLAN files a completed item under "Next"; empty ops/ directory; vega-themes pinned exact with no recorded rationale; manual-verification.md lacks checks for the shipped theming/export surface (Theme Builder gallery, font upload incl. variable fonts, PNG/SVG export, clipboard).

Strengths (verified, and worth keeping deliberate)

  1. The architecture contract verifies by grep, both directions. No browser APIs, React, or Monaco in src/core/; storage APIs confined to infrastructure/; landing/learn import no stores/orchestration/components.
  2. TypeScript rigor at the top of the distribution. Zero any, zero @ts-ignore/@ts-expect-error, exactly one non-null assertion across ~30k lines; type-aware ESLint (incl. no-floating-promises) passes clean; every intentionally unawaited promise is an explicit void with a comment.
  3. Error-handling discipline is real. Every swallowed catch inspected carries a rationale naming the sanctioned fallback; real failures route to notifications with actionable copy; quota errors are normalized and surfaced.
  4. Concurrency/resource engineering above typical app code. LivePreview's generation token + render mutex (with reasoning written down), full Monaco disposable cleanup, the finalize-before-unsubscribe edge handled in chart-renderer.
  5. The test suite is engineered, not accumulated. 1,366 tests / 8.5s, zero snowflake harnesses across all 17 component test files, injected clocks and fake timers keyed to exported constants, fresh IDBFactory per test (including interrupted-upgrade self-healing), tests that assert contracts with the why inline (spec-refs.test.ts, chart-renderer.test.ts).
  6. Session-scale coherence is solved. All 12 stores share one canonical shape and cite their canonical sibling in doc comments; modals go through one registry/coordinator/shell; subtraction happens (PreviewStore deleted when obsoleted). The "written by strangers" failure mode is absent.
  7. The breadcrumb→fix pipeline works. At least 10 TODOs resolved by dedicated commits (quota rollback, hash routing, splitter ARIA, field-name escaping…); the eng-council→law loop is real (sweep findings became alignment rules #15#17; skills keep evolving).
  8. Docs are accurate at symbol level at scale — of ~35 spot-checked claims (constants, record shapes, hash grammar, keyboard maps, API surfaces), nearly all exact — and the "shipped divergence" fencing discipline keeps pedagogical sketches from lying.
  9. PWA configuration is best-practice: registerType: 'prompt' properly consumed with a durable update toast, SW scope narrowed to /app/, sophisticated font precache strategy with reasoning in the config.
  1. Record the regime (C2): CLAUDE.md/AGENTS.md paragraph + alignment-skill update + soften spec README/arch 00. Smallest fix, unblocks every future clean-context review. Done same day: CLAUDE.md, AGENTS.md, SOUL.md, spec README, and alignment rules #3/#17 now state the spec-follows-code regime; #17 extended to cover never-specified additions.
  2. Fix the landing bundle and add the post-build assertion (C1): the only production-visible defect, and the assertion closes the gate gap (S4) for free. Add public/_headers for immutable assets while in there. Done same day: schemeColors split into core/scheme-colors.ts; function-form manualChunks with explicit homes for the preload helper and the shared light packages (vega-themes, vega-expression, vega-util, json-stringify-pretty-compact — Rollup was absorbing each into the nearest vendor chunk); scripts/check-light-entries.mjs gates every build; public/_headers ships immutable caching. Landing eager JS measured ~98 kB gzipped, down from ~1,300 kB; all three entries smoke-tested on the production build (charts + Monaco render, zero console errors).
  3. Spec back-fill sprint (C3): specify /learn/, the wireframe, editor actions/scaffolds, and the view picker; add the spec-reconciliation clause to /doc-update.
  4. Give the periodic instruments triggers (S1) and run the overdue ones: an eng-council re-sweep (which naturally absorbs S5's splits and the spec-* family consolidation), a ux-second-pass batch pass, and TODO gardening — starting with ModalCoordinator.ts:36.
  5. Extract and test the scaffold insertion core (S2) before the params WIP is committed — the TODOs already name the module.
  6. Doc drift batch (S3 + minors): one session, mostly deletions of fossils.

Addendum: subtraction audit (same day)

Follow-up requested by the maintainer: how much of the codebase is duplication, boilerplate, or unnecessary wrapping — with the explicit instruction that deliberate architecture layers are not exempt; "less LoC = less surface for bugs" outranks ceremony. Two auditors (a jscpd-quantified duplication pass and a wrapper/indirection pass with the contract layers on trial); the largest verbatim-copy claims were independently re-diffed before inclusion.

The numbers

  • Production duplication is low: ~0.9% by jscpd at min-tokens 50 (268 duplicated lines over ~29.5k production TS/TSX; roughly double counting renamed semantic twins). Test-file duplication is ~5.4% but is the documented harness convention — benign. CSS Modules are the highest-duplication format at 5.4% and deserve one deliberate pass.
  • Pure ceremony is ~1.5% of non-test LoC. The plumbing layers (infrastructure + orchestration + modals + hooks, ~2.8k lines, ~9% of src) are overwhelmingly load-bearing: db.ts's promise wrapping/self-healing/quota normalization, url-hash.ts's total-parse grammar, remote-data.ts's size-cap and error classification, the modal coordinator's snapshot/confirm/URL lifecycle all earn their lines on inspection. No generic machinery with a single instantiation was found — editor-cursor-lens has three real consumers, wireEntityWriteThrough four, and every modal-registry field varies across entries.
  • Confidently deletable today, behavior identical: ~450500 LoC, plus ~100150 more behind design decisions.

The diagnosis, verbatim from the audit because it generalizes: the abstractions are right; the residue is photocopied instantiation files and hand-unrolled adapter pairs that the abstractions should have absorbed. Nothing calls for architectural change — only tidying inside the architecture. Notably, one suspicion from the main review was stale: the orchestration wirers were already consolidated behind wireEntityWriteThrough; the leftover per-entity files are the residue that consolidation should have deleted.

The cut list (verified; ranked by value)

Do right away — all mechanical, most protected by existing tests:

  1. Per-preference persistence chain (~90110 LoC; the compounding win). Five near-identical load/save pairs in settings-store.ts:116-184, four identical 10-line init/wire pairs in orchestration/preferences.ts, same shape again in orchestration/settings.ts/snippet-sort.ts, ten sequential calls in main.tsx. Collapse: a uiSlicePref(key, validate, fallback) adapter factory + a wireStorePref(store, selector, save) orchestration helper. Every future preference then costs ~8 lines instead of ~30 across four files. theme.ts (FOUC ordering) and panes.ts (debounce) stay bespoke — they carry real variation.
  2. Entity-adapter photocopies (~5560 LoC). snippet-store.ts / dataset-store.ts / theme-store.ts / font-store.ts are the identical 28-line load/save/delete triple differing only in store name, migrate fn, and version constant (re-diffed: confirmed). Collapse: makeEntityAdapter<T>(storeName, version, migrate) in db.ts + four 3-line instantiations. Migration files stay — real per-entity logic.
  3. Wirer residue files (~55 LoC). dataset-persistence.ts / theme-persistence.ts / font-persistence.ts are 25-line modules whose body is one 6-line wireEntityWriteThrough call. Move the calls into entity-persistence.ts or startup.ts (their only caller). snippet-persistence.ts stays — the debounced autosave is real logic.
  4. editor.addAction ceremony (~55 LoC). Thirteen copies of the same 6-line registration object across spec-transform-actions.ts:524-585 and spec-config-actions.ts:210-235 → one table + .map(addAction).
  5. Scaffold-twin merge (~4050 LoC; overlaps main-review S2 and shares its fix). The completion prologue, suggestion mapping, and create-property block are verbatim between spec-transform-scaffold.ts and spec-param-scaffold.ts. Collapse into editor-snippet.ts helpers; the pure indent math moves to the TODO'd core module, where it also becomes testable.
  6. Core jsonc-helper copies (~2530 LoC). objectKeys byte-identical between spec-params.ts:85-93 and spec-data-transforms.ts:103-111 (re-diffed: confirmed); paramsArrayNode/transformArrayNode identical modulo key string; two more shared shapes. Extract/share — both sides have strong tests.
  7. localStorage adapter shell (~28 LoC). readRaw/writeRaw/available identical between settings-store.ts and ux-prefs.ts modulo type name and log tag (re-diffed: confirmed) → jsonLocalRecord<T>(key, tag) in the same layer.
  8. Batch of micro-cuts (~90 LoC): UrlStateSync's argument-ignoring syncModalToUrl/clearModalFromUrl wrappers (delete when fixing the ModalCoordinator.ts:36 ordering TODO they paper over); DatasetStore's thrice-repeated save epilogue; readTextFile/readBinaryFile 1:1 renames of File methods; initPersistentStorage pass-through; modals/types.ts folded into the registry; SpecEditor's seven subscribe/dispose pairs → array; the landing's four-times repeated shot block + TODO'd UiTheme redeclaration; Icon.tsx's four-times copied panel frame; one dead CSS rule (ChartBuilderModal.module.css:213).

Needs design thought (~100150 LoC more): the splitter triple (ResizeHandle / PaneSplitHandle / InspectorSplitHandle share a near-verbatim role="separator" block — worthwhile but untested a11y surface, do with manual keyboard verification); the theme-panel Color+Size+Weight row groups (60100 LoC vs greppability of accessible names); the modal master-detail CSS block; the landing/learn theme-toggle hook (blocked on where a shared React hook may live under the entry-isolation rule — needs a maintainer call).

Adjudicated and acquitted (challenged per the widened mandate, earn their lines): the modal registry/coordinator/shell trio (every registry field varies; flattest honest version saves only ~35 lines); the Zustand store shapes (a generic collection-slice helper would cost more in typing than the ~60 lines it saves and obscure real per-store invariants); core purity shims (minimal — the altitude problem runs the other way, app services holding pure logic); the non-photocopy infrastructure adapters (real normalization, migration, error mapping).

Wrap-up follow-ups (recorded from the params-feature review passes, same day)

The params WIP was closed through the full wrap-up protocol (alignment + eng-council, both clean-context). Fixed in that pass: an isUnitNode bug (nested layer containers offered schema-invalid selection params), the S2 extraction (core/spec-snippet.ts, insertion math now table-tested applied-and-reparsed), spec §03 scaffolding coverage, the transforms catalog upgraded to real-schema validation, ajv declared. Deferred with recorded homes:

  • src/core/lesson-parse.ts exports five unused types (knip) — verify and unexport. Same check for LensFactory in editor-cursor-lens.ts:24 (exported, consumed only in-module).
  • spec-transform-actions.ts has an internal 8-line clone (:269-276 vs ~:346-353) — fold into the existing 663-LOC consolidation item.
  • The Ajv compile boilerplate now lives in two test files; a third schema-validating catalog test should extract a shared helper — and at that point promote the rule "seeded editor catalogs validate against the bundled VL schema, not just JSON.parse" to an /alignment check.
  • A small knip config (ignore @fontsource/*, marked false positives) would make future dead-export sweeps one command.