Skip to content

rules editor: TypeScript support with in-browser language service - #1202

Open
evgeny-boger wants to merge 27 commits into
masterfrom
rules-editor-typescript
Open

rules editor: TypeScript support with in-browser language service#1202
evgeny-boger wants to merge 27 commits into
masterfrom
rules-editor-typescript

Conversation

@evgeny-boger

@evgeny-boger evgeny-boger commented Aug 14, 2026

Copy link
Copy Markdown
Member

TypeScript support in the rules editor

Companion to wirenboard/wb-rules#221 (engine-side TypeScript rule support). With that engine installed, .ts files in /etc/wb-rules/ transpile and run natively — this PR makes the web editor a first-class place to write them.

What the user gets

  • .ts rule files can be created, renamed, copied and edited (extension preserved everywhere; syntax highlighting switches to TypeScript mode).
  • Validation in the editor: a browser-side TypeScript language service checks as you type — squiggles with messages, hover shows inferred types, completions are type-aware. It is seeded with the vendored wb-rules.d.ts builtin declarations, so defineRule, dev, PersistentStorage, timers etc. all carry real signatures.
  • Checker parity with the controller: same configuration as the engine-side background check (esnext libs, non-strict), so what the editor flags is what the engine logs. The engine additionally publishes its check results as retained JSON on /wbrules/ts-check/<file> for future in-UI display of the authoritative post-save verdict.
  • Zero cost for .js users: the language service (typescript ≈975 kB gzip + lib texts ≈74 kB gzip) lives in a lazy chunk loaded only when a .ts file is opened. The rule-page chunk stays at 16 kB.
  • Static completion list for .js files is now generated from wb-rules.d.ts (npm run generate:completions), so the two stay in sync.

Implementation notes

  • @valtown/codemirror-ts + @typescript/vfs drive CodeMirror integration; the virtual FS bundles lib.es*.d.ts/lib.decorators*.d.ts via import.meta.glob (no CDN — controllers are offline).
  • One shared language-service environment per file path, recreated on path change (ts-language-service.ts); tsSync() tracks edits after the initial seed.
  • Verification: tsc --noEmit clean, eslint clean, 2418 vitest tests green (2 new for the language service, 3 for .ts file handling in the rules store).

Update (typed API sync): wb-rules.d.ts resynced from wb-rules - per-control-type option unions (TypeMappings/ControlOptions), typed defineVirtualDevice/getControl, branded RuleId in the rule-management signatures, Notify/Alarms declarations, generic changed(). Completions regenerated (Notify, Alarms, __filename now offered); new language-service tests pin that idiomatic async code stays diagnostic-free while illegal option/type combinations and rule-names-as-ids are flagged in the editor.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru

.ts rule files get full editor support:

- Syntax: @codemirror/lang-javascript in typescript mode for .ts files;
  rules-store accepts/preserves the .ts extension (create, rename, copy).
- Validation in the editor: a browser-side TypeScript language service
  (typescript + @typescript/vfs + @valtown/codemirror-ts) checks as you
  type - squiggles, hover type info, type-aware completions - seeded
  with the vendored wb-rules.d.ts builtin declarations. Same settings as
  the engine-side tsgo check (esnext libs, non-strict), so editor and
  controller agree.
- Cost: everything heavy lives in a lazy chunk (~1 MB gzip: typescript
  975 kB + lib.*.d.ts texts 74 kB) loaded only when a .ts file is
  opened; the rule page chunk stays at 16 kB and .js-only users
  download nothing new.
- Static completions for .js files are generated from wb-rules.d.ts by
  scripts/generate-wb-rules-completions.mjs (npm run generate:completions).

Engine side (wirenboard/wb-rules#221) additionally publishes tsgo check
results as retained JSON on /wbrules/ts-check/<file> for future
integration and logs them to the rules console.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru
@evgeny-boger
evgeny-boger requested a review from a team as a code owner August 14, 2026 10:52
@codacy-production

codacy-production Bot commented Aug 14, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 155 complexity · 0 duplication

Metric Results
Complexity 155
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

evgeny-boger and others added 9 commits August 14, 2026 13:46
- Show the controller-side type-check verdict: subscribe to the retained
  /wbrules/ts-check/<file> topic while editing a .ts file and render each
  diagnostic as a page alert (danger/warn by severity, i18n en+ru). The
  in-editor language service stays the live check; this banner is the
  authoritative post-save result from the controller's own tsgo.

Review findings fixed (adversarially verified multi-agent review):
- language-service cache: reseed when the same file is reopened with
  different content (tsSync only tracks in-editor edits), and drop the
  cached promise on load failure instead of poisoning TS support forever
- completion: mergeSources awaits each source, so static completions
  still answer when the async TS source resolves to null
- unsaved .ts rules use a stable placeholder path - title keystrokes no
  longer rebuild the language service per key

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru
React escapes on render; i18next's own interpolation escaping showed
&#39; instead of quotes in the banner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru
The controller's tsgo diagnostics (retained /wbrules/ts-check JSON) now
render as regular lint entries at the reported lines - squiggles and
gutter markers merged with the local language service's own - with the
tooltip labeled 'controller (tsgo)' so the two checks stay
distinguishable. A mobx autorun re-triggers linting when new MQTT data
arrives. The page-top banner (and its i18n strings) is gone.

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

Both checkers usually flag the same line with the same message, which
doubled every squiggle. Controller entries matching a local language
service diagnostic (same line, same message) are now dropped; only
skew-only findings - the controller's unique value - render separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru
…s from Editor.GetTypes

Replaces the retained /wbrules/ts-check MQTT subscription (topic removed
engine-side; it had unsolvable retained-state lifecycle issues). The
controller verdict is now pulled on .ts file open and after each save.

The language service is seeded with the CONTROLLER's installed
wb-rules.d.ts (Editor.GetTypes) when reachable, so the editor validates
against the API of the engine it is actually talking to; the vendored
copy remains as offline fallback. This eliminates declaration version
skew between UI and engine releases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru
User-reported bug: fix an error in the editor and the controller's old
squiggle for that line stayed until the next save. Mechanism: while the
error existed the controller entry was hidden behind the identical local
one; fixing the line removed the local diagnostic and the stale
controller entry (describing the last-saved file) surfaced.

The verdict now carries the editor content it was computed for and
renders only while the document still matches it - the local language
service owns the screen while typing, and saving triggers a fresh
verdict. Covered by controllerDiagsForDoc tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru
- Check replies 'pending' while the controller's background check runs:
  poll briefly (700ms x 15), with a token so a newer check supersedes an
  in-flight poll loop. 'not-ts' (e.g. disabled files) and 'unsupported'
  clear the verdict instead of masquerading as clean.
- De-dup by message prefix: the controller carries only the head line
  of chained diagnostics while the local service flattens the whole
  chain, so equality matching let every elaborated error double-squiggle.
- Skip diagnostics that belong to another file (import/reference) - the
  reply now identifies them; anchoring them in the open file was wrong.
- Fix stale transport comment (retained MQTT -> Check RPC).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru
- H1/H2: the vendored wb-rules.d.ts and the completion list generated
  from it are excluded from eslint and tsconfig - the ambient engine
  globals (dev, log, require, ...) no longer leak into the whole app's
  type space (a typo'd log() call typechecked before), and the full-repo
  lint is clean again (0 errors, 0 warnings).
- H3: an empty completion result no longer shadows later sources - the
  TS service answers with zero entries inside dev["..."], where the
  device-list source has the real completions; merged sources now fall
  through past empty results.
- M1: snippets and generated globals are one static source (snippet
  variants first) - the generated signatures were unreachable behind the
  snippet source's catch-all match.
- M2: the poll-timeout clear is token-guarded (a stale loop could wipe
  a fresh verdict).
- M3: editor extensions are memoized on [isTypeScript, tsSupport] -
  rebuilding them per keystroke reconfigured CodeMirror and re-ran every
  lint source synchronously per character.
- M4: the completions generator emits repo-style single quotes;
  regeneration is byte-idempotent against the committed file.
- L1: controller types race a 3s deadline so firmware without
  Editor.GetTypes does not stall TS support for the 60s RPC timeout.
- L2: post-save checks capture the exact saved content.
- L3: leaving the page cancels the verdict poll loop.
- L5: ControllerVerdict lives in types.ts per repo convention.
- L7: new deps exact-pinned; typescript moved to runtime dependencies
  (dynamically imported in production); renaming a .ts rule to an
  extensionless title keeps .ts instead of silently becoming .js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru
Plain-JS rule files now get the same in-browser language service as .ts
(allowJs): completions and hover reflect the controller's installed API
via Editor.GetTypes instead of the build-time snapshot, plus live syntax
checking. checkJs stays off, so wild ES5 gets no type-error noise. The
generated static list remains as fallback when the service is
unavailable (offline, pre-GetTypes firmware). Cost: .js-only users now
lazy-load the language-service chunk on first editor open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru
evgeny-boger and others added 17 commits August 14, 2026 21:37
The first environment build parses every bundled lib.*.d.ts - ~2s
locally but 7s in a loaded sbuild chroot, past vitest's 5s default
(seen on Jenkins PR-1202 #15). 30s budget for the two cold-start
candidates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru
(The PR pipeline's version-bump check requires it; also gives the
experimental debs a proper feature version.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru
- H1: type-aware completions actually surface the wb-rules API now.
  valtown's completion filter drops every ambient global (sortText "15")
  not on its hardcoded standard-JS whitelist, so defineRule & co never
  appeared once the language service loaded - and its non-empty answer
  shadowed the static sources, a regression for .js files too. Pass
  keepLegacyLimitationForAutocompletionSymbols: false and merge the
  snippet templates into the service's answers (snippets replace the
  plain entry of the same label; member accesses stay service-only).
- M1: device/topic completions read the devices store at completion
  time instead of being snapshotted when the (memoized) extension array
  is built - devices arriving over MQTT after page load now show up in
  dev["..."], getDevice(...), publish(...) lists.
- M2: the rename uniqueness pre-check now tests the path the rename
  will actually target (a .ts rule keeps .ts; only a fresh save
  defaults to .js) - renaming foo.ts to an occupied extensionless name
  no longer slips past the check and silently fails in the engine.
- M3: typing the title of an unsaved rule no longer re-runs the
  language-service effect per keystroke (each run cost an
  Editor.GetTypes RPC); the effect is keyed on the stable service path.
- L1: controller verdict diagnostics survive CRLF rule files - compare
  the checked content LF-normalized, matching CodeMirror's own
  normalization on ingest.
- stale comments updated (the service runs for .js files too).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru
…nd, delay(), nextMqtt()

Sync the vendored wb-rules declarations with the engine's new
promise-native library: spawn()/runShellCommand() return
Promise<SpawnResult> (nonzero exit resolves; rejection only when the
process cannot start), delay(ms) is the async setTimeout, and
nextMqtt(topic[, timeoutMs]) resolves with the next live MQTT message
(MqttMessage gains retained/qos, matching what trackMqtt callbacks
always received). Completions regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru
Matches the engine-side rename; completions regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru
Bump the wb-mqtt-homeui Recommends floor from wb-rules 2.37.0 to
2.47.0~quickjs3 - the engine generation this editor's Editor.Check /
Editor.GetTypes integration and .ts file support target. Recommends
(not Depends) stays correct: with an older engine the editor degrades
gracefully (vendored type fallback, bounded verdict polling), and old
homeui keeps working against the new engine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru
Sync wb-rules.d.ts from wb-rules (engine-exact per-control-type option
unions via TypeMappings, typed defineVirtualDevice/getControl, branded
RuleId for enableRule/disableRule/runRule, Notify/Alarms declarations,
generic changed()) and regenerate the completion list: Notify, Alarms
and __filename appear, rule management signatures now show RuleId, and
getDevice/getControl show their real '| undefined'.

New language-service tests pin the behavior in the editor: the awaited
changed() arithmetic idiom stays diagnostic-free, while an option
illegal for the control type ({ type: 'switch', min: 0 }) and a rule
name passed as a rule id are flagged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sync the registry-based wb-rules.d.ts and turn it on in the editor:

- registry.ts builds a WbControls declaration from the controller's live
  device list (devicesStore.cells -> "device/control": type), skipping
  system and not-yet-typed controls. edit-rule.tsx snapshots it when the
  editor opens and feeds it to the language service as an extra .d.ts that
  declaration-merges into the (empty) shipped WbControls.
  Result: getControl("dev/ctrl").setValue(wrong) and dev["dev/ctrl"] = wrong
  are now flagged in the editor against the controls that really exist,
  with their real types; unlisted refs stay loose.

- enums.ts: vdev.getControl("...") (a method call on a device variable) now
  defers to the TS service, which offers only that device's declared
  control names, instead of dumping the global "device/control" list. The
  global getControl("...") and getDevice("X").getControl("...") forms are
  unchanged.

- index.ts: withStaticExtras no longer splices global snippets/identifiers
  into string-literal completions (quote-preceded), so the control-name
  list inside getControl("...") stays clean.

- regenerated globals-generated.ts.

Tests: registry.test.ts (generation, skips, escaping, empty), enums.test.ts
(the vdev.getControl deferral and the two global forms), and
ts-language-service.test.ts (registered refs flag wrong-typed writes;
unlisted refs stay loose).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Match the engine: the language service now runs with module: esnext and
moduleDetection: force, so a rule file may use top-level await without the
editor flagging "add an empty export {}". Rule files may await directly at
the top level (the engine wraps them in an async function).

Test: top-level await produces no diagnostics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirror the engine's typed .d.ts: changed("dev/ctrl") now resolves the
awaited value from the WbControls registry (built client-side from the
live device list) instead of defaulting to any, matching how
getControl()/dev[] are already typed. Unregistered references stay loose.

Regenerated globals-generated.ts; added a language-service test asserting
a registered numeric control types changed() as a number (wrong-typed
assignment flagged) while an unregistered reference stays any.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A rule like `while (sleep(1000)) {}` type-checks clean but hangs: sleep()
returns Promise<void>, so the condition is always truthy and never awaits -
a synchronous infinite loop that blocks the whole engine. TypeScript's
built-in TS2801 only covers `if` and is off outside strict mode, so the
editor stayed silent.

Add a custom warning in the language service: walk if/while/do-while/for and
ternary conditions, and when the condition's type is a Promise/thenable emit
a warning squiggle "...is a Promise. Did you forget 'await'?". Detection uses
the type checker (callable `then` on the apparent type, or a Promise-named
symbol) and deliberately ignores `any`/`unknown` so the loose rule codebase
isn't flooded with false positives; `await p` conditions are fine since their
type is the resolved value. Surfaced via getSemanticDiagnostics so it renders
as an editor squiggle and merges into getDiagnostics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
No code change. The previous commit body used backtick code-spans, which the
deb build CI captured via a git log --pretty style step and the shell then
expanded as command substitution, failing the build before any stage ran
(syntax error, unbalanced parens). This backtick-free commit lets the build
proceed and carries the same promise-in-condition editor warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The condition check missed the other common forgot-await shape: a
Promise-returning call whose result is discarded as a statement, e.g.
for (;;) { sleep(1000); log("x"); } - the loop never actually pauses because
the sleep Promise is thrown away.

Flag a floating Promise expression statement, but only inside a loop body
(for/for-in/for-of/while/do): elsewhere, fire-and-forget like scenario() or an
async IIFE is a legitimate way to start async work and must not be flagged
(that also keeps the existing top-level cases clean). await/void/assignment
capture or unwrap the value; IIFEs are excluded; a nested function is a new
scope so its statements do not count as pacing the loop.

Tests: warns on sleep() in a for(;;) loop (not on the log() beside it); no
warning for await/void/IIFE in the loop, nor for fire-and-forget outside one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… control

Two more forgot-await shapes tsc misses on loose rule code, both editor-only
warnings:

- await on a non-Promise (await-thenable): a no-op that usually means the user
  thinks a synchronous call is async, e.g. await getControl(x).getValue().
  Union-aware (only flagged when no constituent is a Promise) and any/unknown
  are skipped, so real awaits (sleep, changed, spawn, runShellCommand) and
  maybe-Promise unions are never flagged.

- a Promise written to a control: dev["d/c"] = sleep(1000) stores
  "[object Promise]" into the cell. tsc catches this for registry-typed
  controls but not for loose ones (any), so flag the assignment when the
  right-hand side is a Promise.

Refactored warn() into pushWarning() (unconditional) + warn() (thenable-gated)
so the await check can report the opposite condition. Tests cover both, plus
the negatives (awaited value, any-typed await, non-Promise assignment).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from the from-scratch review of the editor:

- The floating-Promise-in-loop warning fired on any loop, false-positiving on
  legitimate parallel dispatch (for (const z of zones) { runShellCommand(...); }).
  Narrow it to infinite loops only - for(;;), while(true), do...while(true) -
  which is the actual controller-hanging shape; bounded loops are left alone.

- The dev[...] Promise-to-control check matched the identifier text "dev", so a
  user local (const dev = []) that shadows the builtin was wrongly flagged.
  Resolve the symbol and skip when it is declared in the rule source itself.

- The await-non-Promise check flagged a bare generic type parameter (await v
  where v: T); T has no callable then but could resolve to a Promise once
  instantiated. Skip TypeFlags.TypeParameter.

- Wrap the custom diagnostic walk in try/catch: a throwing checker call must not
  make getSemanticDiagnostics throw and drop every squiggle (base + custom).

Tests: bounded-loop dispatch not flagged, while(true) still flagged, shadowed
dev not flagged, awaiting a generic T not flagged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enable checkJs in the editor language service so a wrong-typed write in a plain
.js rule (e.g. dev["buzzer/enabled"] = 123, a switch) is flagged in the editor,
just like in .ts. It is checked against the wb-rules types and the live-device
registry.

Safe because TypeScript parses each file by its extension: a .js file is parsed
as JavaScript (so a legacy a < b > (c) stays two comparisons, not a generic
call foo<b>(c)); checkJs only turns error reporting on, it does not change
parsing. Tests: a wrong-typed .js registry write is flagged, a correct/loose
one is not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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