From 55698eb677de99617a5aa82f922e09f81e7c5ee9 Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Fri, 14 Aug 2026 10:41:52 +0000 Subject: [PATCH 01/18] rules editor: TypeScript support with in-browser language service .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/ for future integration and logs them to the rules console. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- frontend/package-lock.json | 30 ++- frontend/package.json | 5 +- .../scripts/generate-wb-rules-completions.mjs | 76 ++++++++ frontend/src/custom.d.ts | 16 ++ frontend/src/pages/rules/[rule]/edit-rule.tsx | 33 +++- .../rules/autocomplete/globals-generated.ts | 37 ++++ .../src/stores/rules/autocomplete/index.ts | 18 +- .../autocomplete/ts-language-service.test.ts | 17 ++ .../rules/autocomplete/ts-language-service.ts | 80 ++++++++ .../src/stores/rules/autocomplete/types.ts | 7 + .../stores/rules/autocomplete/wb-rules.d.ts | 175 ++++++++++++++++++ .../rules/rules-store-typescript.test.ts | 23 +++ frontend/src/stores/rules/rules-store.ts | 9 +- 13 files changed, 517 insertions(+), 9 deletions(-) create mode 100644 frontend/scripts/generate-wb-rules-completions.mjs create mode 100644 frontend/src/stores/rules/autocomplete/globals-generated.ts create mode 100644 frontend/src/stores/rules/autocomplete/ts-language-service.test.ts create mode 100644 frontend/src/stores/rules/autocomplete/ts-language-service.ts create mode 100644 frontend/src/stores/rules/autocomplete/types.ts create mode 100644 frontend/src/stores/rules/autocomplete/wb-rules.d.ts create mode 100644 frontend/src/stores/rules/rules-store-typescript.test.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e9b8d457..7403e9a92 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,7 +20,9 @@ "@dnd-kit/utilities": "3.2.2", "@floating-ui/react": "0.27.19", "@rpldy/uploady": "1.13.0", + "@typescript/vfs": "^1.6.4", "@uiw/react-codemirror": "4.25.10", + "@valtown/codemirror-ts": "^2.3.1", "@wirenboard/json-editor": "2.5.3-wb19", "@xterm/addon-fit": "0.11.0", "@xterm/addon-web-links": "0.12.0", @@ -2466,6 +2468,18 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, "node_modules/@uiw/codemirror-extensions-basic-setup": { "version": "4.25.10", "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.10.tgz", @@ -2519,6 +2533,21 @@ "react-dom": ">=17.0.0" } }, + "node_modules/@valtown/codemirror-ts": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@valtown/codemirror-ts/-/codemirror-ts-2.3.1.tgz", + "integrity": "sha512-v5XiI4WA+bUy0XDgkrqZksqBWgIUeyLZuC94Px/rhXBph8ASmVXaimlGDtt0vH/9t8aDdIZYdr59r9H3oKMFOg==", + "license": "ISC", + "engines": { + "node": "*" + }, + "peerDependencies": { + "@codemirror/autocomplete": "^6", + "@codemirror/lint": "^6", + "@codemirror/state": "^6", + "@codemirror/view": "^6" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", @@ -11493,7 +11522,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/package.json b/frontend/package.json index 5309cf205..bd346a724 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ "test:watch": "vitest", "lint": "npx eslint --cache --cache-location .eslintcache", "lint:fix": "npx eslint --fix", - "check:types": "tsc --noEmit" + "check:types": "tsc --noEmit", + "generate:completions": "node scripts/generate-wb-rules-completions.mjs" }, "repository": { "type": "git", @@ -34,7 +35,9 @@ "@dnd-kit/utilities": "3.2.2", "@floating-ui/react": "0.27.19", "@rpldy/uploady": "1.13.0", + "@typescript/vfs": "^1.6.4", "@uiw/react-codemirror": "4.25.10", + "@valtown/codemirror-ts": "^2.3.1", "@wirenboard/json-editor": "2.5.3-wb19", "@xterm/addon-fit": "0.11.0", "@xterm/addon-web-links": "0.12.0", diff --git a/frontend/scripts/generate-wb-rules-completions.mjs b/frontend/scripts/generate-wb-rules-completions.mjs new file mode 100644 index 000000000..13b303a5b --- /dev/null +++ b/frontend/scripts/generate-wb-rules-completions.mjs @@ -0,0 +1,76 @@ +// Generates autocomplete/globals-generated.ts from autocomplete/wb-rules.d.ts. +// The .d.ts (synced from wb-rules types/wb-rules.d.ts) is the single source +// of truth for the builtin API; run `npm run generate:completions` after +// updating it. +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; +import ts from 'typescript'; + +const here = path.dirname(url.fileURLToPath(import.meta.url)); +const dtsPath = path.join(here, '../src/stores/rules/autocomplete/wb-rules.d.ts'); +const outPath = path.join(here, '../src/stores/rules/autocomplete/globals-generated.ts'); + +const source = ts.createSourceFile('wb-rules.d.ts', fs.readFileSync(dtsPath, 'utf8'), ts.ScriptTarget.Latest); +const printer = ts.createPrinter({ removeComments: true }); + +const seen = new Set(); +const completions = []; + +const signatureOf = (node) => { + const text = printer.printNode(ts.EmitHint.Unspecified, node, source) + .replace(/^declare\s+/, '') + .replace(/\s+/g, ' ') + .trim(); + return text.length > 60 ? `${text.slice(0, 57)}...` : text; +}; + +const snippetFor = (name, params) => { + if (params.length === 0) return `${name}()`; + const args = params + .filter((p) => !p.questionToken && !p.dotDotDotToken) + .map((p, i) => `\${${i + 1}:${p.name.getText(source)}}`); + return `${name}(${args.join(', ')})`; +}; + +for (const stmt of source.statements) { + if (ts.isFunctionDeclaration(stmt) && stmt.name) { + const name = stmt.name.text; + if (seen.has(name)) continue; // keep the first overload only + seen.add(name); + completions.push({ + label: name, + type: 'function', + detail: signatureOf(stmt), + snippet: snippetFor(name, stmt.parameters), + }); + } else if (ts.isVariableStatement(stmt)) { + for (const decl of stmt.declarationList.declarations) { + const name = decl.name.getText(source); + if (seen.has(name)) continue; + seen.add(name); + completions.push({ + label: name, + type: 'variable', + detail: decl.type ? signatureOf(decl.type) : '', + }); + } + } +} + +const body = completions.map((c) => { + const detail = JSON.stringify(c.detail); + return c.snippet && c.snippet !== `${c.label}()` + ? ` snippetCompletion(${JSON.stringify(c.snippet)}, { label: ${JSON.stringify(c.label)}, type: '${c.type}', detail: ${detail} }),` + : ` { label: ${JSON.stringify(c.label)}, type: '${c.type}', detail: ${detail}${c.snippet ? `, apply: ${JSON.stringify(c.snippet)}` : ''} },`; +}).join('\n'); + +fs.writeFileSync(outPath, `// GENERATED from wb-rules.d.ts — do not edit by hand. +// Regenerate with: npm run generate:completions +import { snippetCompletion, type Completion } from '@codemirror/autocomplete'; + +export const wbRulesGlobals: Completion[] = [ +${body} +]; +`); +console.log(`generated ${completions.length} completions -> ${outPath}`); diff --git a/frontend/src/custom.d.ts b/frontend/src/custom.d.ts index 3e12bd241..50fadb144 100644 --- a/frontend/src/custom.d.ts +++ b/frontend/src/custom.d.ts @@ -23,3 +23,19 @@ declare const __LOGO__: string; declare const __LOGO_COMPACT__: string; declare const __APP_NAME__: string; declare const __APP_SHORT_NAME__: string; + +// raw-text imports (vite ?raw suffix) used by the TS language service +declare module '*?raw' { + const text: string; + export default text; +} + +// import.meta.glob is provided by vite; the project compiles with +// types: ["vitest/globals"] instead of vite/client, so declare the +// subset we use +interface ImportMeta { + glob( + pattern: string, + options: { query: string; import: string; eager: true } + ): Record; +} diff --git a/frontend/src/pages/rules/[rule]/edit-rule.tsx b/frontend/src/pages/rules/[rule]/edit-rule.tsx index 06fa10e89..324a64d64 100644 --- a/frontend/src/pages/rules/[rule]/edit-rule.tsx +++ b/frontend/src/pages/rules/[rule]/edit-rule.tsx @@ -11,6 +11,7 @@ import { authStore, UserRole } from '@/stores/auth'; import { devicesStore } from '@/stores/devices'; import { rulesStore } from '@/stores/rules'; import { getExtensions } from '@/stores/rules/autocomplete'; +import type { TsEditorSupport } from '@/stores/rules/autocomplete/types'; import { useAsyncAction } from '@/utils/async-action'; import { usePreventLeavePage } from '@/utils/prevent-page-leave'; import './styles.css'; @@ -24,6 +25,30 @@ const EditRulePage = observer(() => { const params = useParams(); const navigate = useNavigate(); const [isEditingTitle, setIsEditingTitle] = useState(!params['*']); + const ruleFileName = params['*'] || rule.name || ''; + const isTypeScript = ruleFileName.endsWith('.ts'); + const [tsSupport, setTsSupport] = useState(null); + + useEffect(() => { + if (!isTypeScript || isLoading) { + setTsSupport(null); + return undefined; + } + let alive = true; + // the language service (typescript + lib files, ~1 MB gzip) stays in a + // lazy chunk that .js-only users never download + import('@/stores/rules/autocomplete/ts-language-service') + .then((m) => m.loadTsEditorSupport(ruleFileName, rule.content)) + .then( + (support) => alive && setTsSupport(support), + () => {}, // editor still works without the language service + ); + return () => { + alive = false; + }; + // rule.content is deliberately not a dependency: it only seeds the + // language service; tsSync() tracks all further edits + }, [isTypeScript, ruleFileName, isLoading]); const errors = useMemo(() => { if (pageLoadError) { @@ -105,7 +130,13 @@ const EditRulePage = observer(() => { text={rule.content} errorLines={rule.error?.errorLine ? [rule.error.errorLine] : null} autoFocus={!!params['*']} - extensions={getExtensions(devicesStore)} + extensions={[ + ...getExtensions(devicesStore, { + typescript: isTypeScript, + typeAwareSource: tsSupport?.completionSource, + }), + ...(tsSupport?.extensions ?? []), + ]} onChange={(value) => { setIsDirty(true); rulesStore.setRule(value); diff --git a/frontend/src/stores/rules/autocomplete/globals-generated.ts b/frontend/src/stores/rules/autocomplete/globals-generated.ts new file mode 100644 index 000000000..34342fe49 --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/globals-generated.ts @@ -0,0 +1,37 @@ +// GENERATED from wb-rules.d.ts — do not edit by hand. +// Regenerate with: npm run generate:completions +import { snippetCompletion, type Completion } from '@codemirror/autocomplete'; + +export const wbRulesGlobals: Completion[] = [ + snippetCompletion('defineVirtualDevice(${1:name}, ${2:spec})', { label: 'defineVirtualDevice', type: 'function', detail: 'function defineVirtualDevice(name: string, spec: VirtualD...' }), + snippetCompletion('cron(${1:spec})', { label: 'cron', type: 'function', detail: 'function cron(spec: string): CronEntry;' }), + snippetCompletion('defineRule(${1:name}, ${2:spec})', { label: 'defineRule', type: 'function', detail: 'function defineRule(name: string, spec: RuleSpec): void;' }), + snippetCompletion('defineAlias(${1:aliasName}, ${2:cellRef})', { label: 'defineAlias', type: 'function', detail: 'function defineAlias(aliasName: string, cellRef: string):...' }), + snippetCompletion('enableRule(${1:name})', { label: 'enableRule', type: 'function', detail: 'function enableRule(name: string): void;' }), + snippetCompletion('disableRule(${1:name})', { label: 'disableRule', type: 'function', detail: 'function disableRule(name: string): void;' }), + snippetCompletion('runRule(${1:name})', { label: 'runRule', type: 'function', detail: 'function runRule(name: string): void;' }), + { label: 'runRules', type: 'function', detail: 'function runRules(): void;', apply: 'runRules()' }, + { label: 'dev', type: 'variable', detail: '{ [deviceOrRef: string]: { [control: string]: any; } & an...' }, + snippetCompletion('getDevice(${1:id})', { label: 'getDevice', type: 'function', detail: 'function getDevice(id: string): VirtualDevice;' }), + snippetCompletion('getControl(${1:ref})', { label: 'getControl', type: 'function', detail: 'function getControl(ref: string): VirtualDeviceControl;' }), + { label: 'log', type: 'variable', detail: 'LogFunction' }, + snippetCompletion('debug(${1:format})', { label: 'debug', type: 'function', detail: 'function debug(format: string, ...args: any[]): void;' }), + snippetCompletion('format(${1:format})', { label: 'format', type: 'function', detail: 'function format(format: string, ...args: any[]): string;' }), + snippetCompletion('publish(${1:topic}, ${2:payload})', { label: 'publish', type: 'function', detail: 'function publish(topic: string, payload: CellValue, qos?:...' }), + snippetCompletion('trackMqtt(${1:topic}, ${2:callback})', { label: 'trackMqtt', type: 'function', detail: 'function trackMqtt(topic: string, callback: (message: Mqt...' }), + { label: 'timers', type: 'variable', detail: 'Record' }, + snippetCompletion('startTimer(${1:name}, ${2:milliseconds})', { label: 'startTimer', type: 'function', detail: 'function startTimer(name: string, milliseconds: number): ...' }), + snippetCompletion('startTicker(${1:name}, ${2:milliseconds})', { label: 'startTicker', type: 'function', detail: 'function startTicker(name: string, milliseconds: number):...' }), + snippetCompletion('setTimeout(${1:callback}, ${2:milliseconds})', { label: 'setTimeout', type: 'function', detail: 'function setTimeout(callback: () => void, milliseconds: n...' }), + snippetCompletion('setInterval(${1:callback}, ${2:milliseconds})', { label: 'setInterval', type: 'function', detail: 'function setInterval(callback: () => void, milliseconds: ...' }), + snippetCompletion('clearTimeout(${1:id})', { label: 'clearTimeout', type: 'function', detail: 'function clearTimeout(id: number): void;' }), + snippetCompletion('clearInterval(${1:id})', { label: 'clearInterval', type: 'function', detail: 'function clearInterval(id: number): void;' }), + snippetCompletion('runShellCommand(${1:command})', { label: 'runShellCommand', type: 'function', detail: 'function runShellCommand(command: string, options?: Shell...' }), + snippetCompletion('spawn(${1:command}, ${2:args})', { label: 'spawn', type: 'function', detail: 'function spawn(command: string, args: string[], options?:...' }), + snippetCompletion('readConfig(${1:path})', { label: 'readConfig', type: 'function', detail: 'function readConfig(path: string): any;' }), + snippetCompletion('PersistentStorage(${1:name})', { label: 'PersistentStorage', type: 'function', detail: 'function PersistentStorage(name: string, options?: Persis...' }), + snippetCompletion('StorableObject(${1:obj})', { label: 'StorableObject', type: 'function', detail: 'function StorableObject(obj: T): T;' }), + { label: 'module', type: 'variable', detail: '{ readonly filename: string; readonly static: Record { +const globalsSource: CompletionSource = (context) => { + const word = context.matchBefore(/[A-Za-z_$][\w$]*/); + if (!word || (word.from === word.to && !context.explicit)) return null; + return { from: word.from, options: wbRulesGlobals, validFor: /^[\w$]*$/ }; +}; + +export const getExtensions = ( + devicesStore: DevicesStore, + options?: { typescript?: boolean; typeAwareSource?: CompletionSource }, +) => { const autocomplete = mergeSources([ + // the TS language service (when loaded) answers first: its completions + // are type-aware; static sources below cover plain .js files + ...(options?.typeAwareSource ? [options.typeAwareSource] : []), ...getEnums(devicesStore), ...methods, snippetSource, + globalsSource, ]); return [ autocompletion(), - javascript({ jsx: false }), + javascript({ jsx: false, typescript: !!options?.typescript }), javascriptLanguage.data.of({ autocomplete, }), diff --git a/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts b/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts new file mode 100644 index 000000000..7c97afb47 --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts @@ -0,0 +1,17 @@ +import { loadTsEditorSupport } from './ts-language-service'; + +describe('ts-language-service', () => { + it('builds editor support with extensions and a completion source seeded with wb-rules types', async () => { + const support = await loadTsEditorSupport('demo.ts', 'const n: number = 1;\n'); + expect(support.extensions.length).toBeGreaterThanOrEqual(4); + expect(typeof support.completionSource).toBe('function'); + }); + + it('reuses the environment for the same file and rebuilds for another file', async () => { + const first = loadTsEditorSupport('demo.ts', ''); + const again = loadTsEditorSupport('demo.ts', ''); + expect(again).toBe(first); + const other = loadTsEditorSupport('other.ts', ''); + expect(other).not.toBe(first); + }); +}); diff --git a/frontend/src/stores/rules/autocomplete/ts-language-service.ts b/frontend/src/stores/rules/autocomplete/ts-language-service.ts new file mode 100644 index 000000000..4ad971f2f --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/ts-language-service.ts @@ -0,0 +1,80 @@ +import type { TsEditorSupport } from './types'; +import wbRulesDts from './wb-rules.d.ts?raw'; + +// Browser-side TypeScript language service for .ts rule files: live type +// checking (squiggles while you type), type-aware completions and hover +// type info, seeded with the wb-rules builtin declarations. +// +// Everything heavy (the typescript package and its lib.*.d.ts files) is +// imported dynamically from here, and this module itself is imported +// dynamically by the edit page, so .js-only users never download it. + +// the same set the engine-side check uses: --lib esnext, no DOM globals +const libFiles = import.meta.glob('/node_modules/typescript/lib/lib.es*.d.ts', { + query: '?raw', + import: 'default', + eager: true, +}) as Record; +const decoratorLibs = import.meta.glob('/node_modules/typescript/lib/lib.decorators*.d.ts', { + query: '?raw', + import: 'default', + eager: true, +}) as Record; + +let cached: Promise | null = null; +let cachedPath = ''; + +async function build(path: string, initialContent: string): Promise { + const [ts, vfs, cmts] = await Promise.all([ + import('typescript').then((m) => m.default), + import('@typescript/vfs'), + import('@valtown/codemirror-ts'), + ]); + + const compilerOptions = { + target: ts.ScriptTarget.ESNext, + lib: ['lib.esnext.d.ts'], + allowJs: true, + strict: false, + noEmit: true, + }; + + const fsMap = new Map(); + for (const [modulePath, text] of Object.entries({ ...libFiles, ...decoratorLibs })) { + fsMap.set('/' + modulePath.split('/').pop(), text); + } + fsMap.set('/wb-rules.d.ts', wbRulesDts); + fsMap.set(path, initialContent || '\n'); + + const system = vfs.createSystem(fsMap); + const env = vfs.createVirtualTypeScriptEnvironment( + system, + [path, '/wb-rules.d.ts'], + ts, + compilerOptions, + ); + + return { + extensions: [ + cmts.tsFacet.of({ env, path }), + cmts.tsSync(), + cmts.tsLinter(), + cmts.tsHover(), + ], + completionSource: cmts.tsAutocomplete(), + }; +} + +// One shared environment: rule files are edited one at a time, and the +// language service survives page switches (path changes recreate it). +export function loadTsEditorSupport( + fileName: string, + initialContent: string, +): Promise { + const path = '/' + (fileName.replace(/^\/+/, '') || 'rule.ts'); + if (!cached || cachedPath !== path) { + cachedPath = path; + cached = build(path, initialContent); + } + return cached; +} diff --git a/frontend/src/stores/rules/autocomplete/types.ts b/frontend/src/stores/rules/autocomplete/types.ts new file mode 100644 index 000000000..c074d05b6 --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/types.ts @@ -0,0 +1,7 @@ +import type { CompletionSource } from '@codemirror/autocomplete'; +import type { Extension } from '@codemirror/state'; + +export interface TsEditorSupport { + extensions: Extension[]; + completionSource: CompletionSource; +} diff --git a/frontend/src/stores/rules/autocomplete/wb-rules.d.ts b/frontend/src/stores/rules/autocomplete/wb-rules.d.ts new file mode 100644 index 000000000..b99493e92 --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/wb-rules.d.ts @@ -0,0 +1,175 @@ +// Type declarations for the wb-rules scripting API. +// +// Consumed in two places: +// - the engine's background TypeScript check (tsgo --noEmit) includes this +// file so rule scripts see the builtins as typed globals; +// - the homeui rules editor loads it to provide typed completions. +// +// The API itself is defined by scripts/lib.js and the engine's DefineFunctions. + +declare type CellType = + | 'switch' | 'wo-switch' | 'alarm' | 'pushbutton' + | 'value' | 'temperature' | 'rel_humidity' | 'atmospheric_pressure' + | 'rainfall' | 'wind_speed' | 'power' | 'power_consumption' + | 'voltage' | 'water_flow' | 'water_consumption' | 'resistance' + | 'concentration' | 'heat_power' | 'heat_energy' | 'current' + | 'pressure' | 'range' | 'text' | 'rgb'; + +declare type CellValue = string | number | boolean; + +interface CellSpec { + type: CellType; + value?: CellValue; + title?: string | Record; + readonly?: boolean; + writeable?: boolean; + min?: number; + max?: number; + precision?: number; + units?: string; + order?: number; + enum?: Record>; + lazyInit?: boolean; + forceDefault?: boolean; +} + +interface VirtualDeviceSpec { + title?: string | Record; + cells: Record; +} + +interface VirtualDeviceControl { + getId(): string; + getValue(): CellValue; + setValue(value: CellValue | { value: CellValue; notify?: boolean }): void; + getError(): string; + setError(error: string): void; + getType(): string; + getDescription(): string; + setDescription(description: string): void; + getTitle(): string; + setTitle(title: string | Record): void; + getReadonly(): boolean; + setReadonly(readonly: boolean): void; + getMax(): number; + setMax(max: number): void; + getMin(): number; + setMin(min: number): void; + getUnits(): string; + setUnits(units: string): void; + getOrder(): number; + setOrder(order: number): void; + getEnumTitles(): Record; + setEnumTitles(titles: Record): void; +} + +interface VirtualDevice { + getId(): string; + getCellId(cellName: string): string; + addControl(name: string, spec: CellSpec): void; + removeControl(name: string): void; + getControl(name: string): VirtualDeviceControl; + isControlExists(name: string): boolean; + controlsList(): VirtualDeviceControl[]; + isVirtual(): boolean; +} + +declare function defineVirtualDevice(name: string, spec: VirtualDeviceSpec): VirtualDevice; + +interface CronEntry { + readonly spec: string; +} +declare function cron(spec: string): CronEntry; + +type RuleCondition = () => unknown; + +interface RuleSpec { + /** cell refs ("device/control"), alias names, or condition functions */ + whenChanged?: string | RuleCondition | Array; + when?: RuleCondition | CronEntry; + asSoonAs?: RuleCondition; + _cron?: string; + then: (newValue?: any, devName?: string, cellName?: string) => void; + readonly?: boolean; +} + +declare function defineRule(name: string, spec: RuleSpec): void; +declare function defineRule(spec: RuleSpec): void; + +declare function defineAlias(aliasName: string, cellRef: string): void; + +declare function enableRule(name: string): void; +declare function disableRule(name: string): void; +declare function runRule(name: string): void; +declare function runRules(): void; + +/** + * Device/cell access proxy: dev["device"]["control"], dev["device/control"], + * or dev.device.control. Append "#meta" (e.g. "device/control#type") to read + * control metadata. + */ +declare const dev: { + [deviceOrRef: string]: { [control: string]: any } & any; +}; + +declare function getDevice(id: string): VirtualDevice; +declare function getControl(ref: string): VirtualDeviceControl; + +interface LogFunction { + (format: string, ...args: any[]): void; + debug(format: string, ...args: any[]): void; + info(format: string, ...args: any[]): void; + warning(format: string, ...args: any[]): void; + error(format: string, ...args: any[]): void; +} +declare const log: LogFunction; +declare function debug(format: string, ...args: any[]): void; +declare function format(format: string, ...args: any[]): string; + +declare function publish(topic: string, payload: CellValue, qos?: 0 | 1 | 2, retain?: boolean): void; + +interface MqttMessage { + topic: string; value: string; +} +declare function trackMqtt(topic: string, callback: (message: MqttMessage) => void): void; + +interface Timer { + readonly firing: boolean; + stop(): void; +} +declare const timers: Record; +declare function startTimer(name: string, milliseconds: number): void; +declare function startTicker(name: string, milliseconds: number): void; + +declare function setTimeout(callback: () => void, milliseconds: number): number; +declare function setInterval(callback: () => void, milliseconds: number): number; +declare function clearTimeout(id: number): void; +declare function clearInterval(id: number): void; + +interface ShellCommandOptions { + captureOutput?: boolean; + captureErrorOutput?: boolean; + input?: string; + exitCallback?: (exitCode: number, capturedOutput?: string, capturedErrorOutput?: string) => void; +} +declare function runShellCommand(command: string, options?: ShellCommandOptions): void; +declare function spawn(command: string, args: string[], options?: ShellCommandOptions): void; + +declare function readConfig(path: string): any; + +interface PersistentStorageOptions { + global?: boolean; +} +declare function PersistentStorage(name: string, options?: PersistentStorageOptions): Record; +declare function StorableObject(obj: T): T; + +/** Per-file module object (rule files are CommonJS-like scenarios). */ +declare const module: { + readonly filename: string; + /** Storage shared between reloads of this file. */ + readonly static: Record; +}; + +declare function require(id: string): any; + +declare const global: typeof globalThis; diff --git a/frontend/src/stores/rules/rules-store-typescript.test.ts b/frontend/src/stores/rules/rules-store-typescript.test.ts new file mode 100644 index 000000000..49b25d621 --- /dev/null +++ b/frontend/src/stores/rules/rules-store-typescript.test.ts @@ -0,0 +1,23 @@ +// TypeScript rule-file support in the rules store (new engine feature). +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@/services', () => import('@/test/mocks/services')); +vi.mock('@/utils/id', () => import('@/test/mocks/utils-id')); + +const { default: RulesStore } = await import('./rules-store'); + +describe('rules store TypeScript support', () => { + const store = new RulesStore(); + + it('keeps .ts names as-is', () => { + expect(store.getValidRuleName('heating.ts')).toBe('heating.ts'); + }); + + it('keeps .js names as-is', () => { + expect(store.getValidRuleName('heating.js')).toBe('heating.js'); + }); + + it('defaults extensionless names to .js', () => { + expect(store.getValidRuleName('heating')).toBe('heating.js'); + }); +}); diff --git a/frontend/src/stores/rules/rules-store.ts b/frontend/src/stores/rules/rules-store.ts index 8f7f2bf37..8614ba711 100644 --- a/frontend/src/stores/rules/rules-store.ts +++ b/frontend/src/stores/rules/rules-store.ts @@ -110,7 +110,7 @@ export default class RulesStore { } getValidRuleName(path: string): string { - return path.endsWith('.js') ? path : `${path}.js`; + return path.endsWith('.js') || path.endsWith('.ts') ? path : `${path}.js`; } async changeState(path: string, state: boolean): Promise { @@ -132,11 +132,12 @@ export default class RulesStore { async copyRule(path: string) { const copiedRule = await this.load(path); + const extension = copiedRule.name.endsWith('.ts') ? '.ts' : '.js'; copiedRule.name = generateNextId( - this.rules.map((rule) => rule.virtualPath.replace(/\.js$/, '')), - copiedRule.name.replace(/\.js$/, ''), + this.rules.map((rule) => rule.virtualPath.replace(/\.(js|ts)$/, '')), + copiedRule.name.replace(/\.(js|ts)$/, ''), ); - const copiedRuleName = await this.save({ ...copiedRule, initName: this.getValidRuleName(copiedRule.name) }); + const copiedRuleName = await this.save({ ...copiedRule, initName: this.getValidRuleName(copiedRule.name + extension) }); await new Promise((resolve) => setTimeout(resolve, 2000)); await this.changeState(copiedRuleName, false); } From 91503dd4ae89bea964f3f12b912ba7a7c18e48aa Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Fri, 14 Aug 2026 13:46:06 +0000 Subject: [PATCH 02/18] rules editor: controller ts-check banner + review fixes - Show the controller-side type-check verdict: subscribe to the retained /wbrules/ts-check/ 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 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/ru.json | 1 + frontend/src/pages/rules/[rule]/edit-rule.tsx | 33 ++++++++++++++---- .../src/stores/rules/autocomplete/index.ts | 4 +-- .../rules/autocomplete/ts-language-service.ts | 11 ++++-- frontend/src/stores/rules/rules-store.ts | 34 +++++++++++++++++-- frontend/src/stores/rules/types.ts | 9 +++++ 7 files changed, 80 insertions(+), 13 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e78627ae3..eb2d6ecf5 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -710,6 +710,7 @@ "delete": "Delete" }, "labels": { + "ts-check": "Controller type check: line {{line}}:{{column}} — {{message}}", "delete-title": "Rule deletion", "open-rule": "Open rule \"{{path}}\"", "title-placeholder": "Rule name", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index e2a43b01e..432cf0835 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -739,6 +739,7 @@ "delete": "Удалить" }, "labels": { + "ts-check": "Проверка типов на контроллере: строка {{line}}:{{column}} — {{message}}", "delete-title": "Удаление правила", "open-rule": "Открыть правило \"{{path}}\"", "title-placeholder": "Название правила", diff --git a/frontend/src/pages/rules/[rule]/edit-rule.tsx b/frontend/src/pages/rules/[rule]/edit-rule.tsx index 324a64d64..0eaf1d201 100644 --- a/frontend/src/pages/rules/[rule]/edit-rule.tsx +++ b/frontend/src/pages/rules/[rule]/edit-rule.tsx @@ -36,9 +36,11 @@ const EditRulePage = observer(() => { } let alive = true; // the language service (typescript + lib files, ~1 MB gzip) stays in a - // lazy chunk that .js-only users never download + // lazy chunk that .js-only users never download. Unsaved rules use a + // stable placeholder path so title edits don't rebuild the service. + const servicePath = params['*'] || 'unsaved.ts'; import('@/stores/rules/autocomplete/ts-language-service') - .then((m) => m.loadTsEditorSupport(ruleFileName, rule.content)) + .then((m) => m.loadTsEditorSupport(servicePath, rule.content)) .then( (support) => alive && setTsSupport(support), () => {}, // editor still works without the language service @@ -50,15 +52,32 @@ const EditRulePage = observer(() => { // language service; tsSync() tracks all further edits }, [isTypeScript, ruleFileName, isLoading]); + useEffect(() => { + if (!isTypeScript || !ruleFileName) { + return undefined; + } + rulesStore.subscribeTsCheck(ruleFileName); + return () => rulesStore.unsubscribeTsCheck(); + }, [isTypeScript, ruleFileName]); + const errors = useMemo(() => { if (pageLoadError) { return [{ code: 404 }]; - } else if (rule.error) { - return [{ variant: 'danger', text: rule.error.message }]; - } else { - return []; } - }, [pageLoadError, rule.error]); + const result = []; + if (rule.error) { + result.push({ variant: 'danger', text: rule.error.message }); + } + // the controller re-checks saved .ts files with its own tsgo; its + // verdict arrives over MQTT and is authoritative + for (const d of rulesStore.tsCheckDiags) { + result.push({ + variant: d.severity === 'error' ? 'danger' : 'warn', + text: t('rules.labels.ts-check', { line: d.line, column: d.column, message: d.message }), + }); + } + return result; + }, [pageLoadError, rule.error, rulesStore.tsCheckDiags, t]); useEffect(() => { if (!params['*']) { diff --git a/frontend/src/stores/rules/autocomplete/index.ts b/frontend/src/stores/rules/autocomplete/index.ts index a8149d4b3..2057351dc 100644 --- a/frontend/src/stores/rules/autocomplete/index.ts +++ b/frontend/src/stores/rules/autocomplete/index.ts @@ -7,9 +7,9 @@ import { methods } from './methods'; import { snippetSource } from './snippets'; function mergeSources(sources: CompletionSource[]): CompletionSource { - return (context) => { + return async (context) => { for (const s of sources) { - const result = s(context); + const result = await s(context); if (result) return result; } return null; diff --git a/frontend/src/stores/rules/autocomplete/ts-language-service.ts b/frontend/src/stores/rules/autocomplete/ts-language-service.ts index 4ad971f2f..ee29a3b0f 100644 --- a/frontend/src/stores/rules/autocomplete/ts-language-service.ts +++ b/frontend/src/stores/rules/autocomplete/ts-language-service.ts @@ -23,6 +23,7 @@ const decoratorLibs = import.meta.glob('/node_modules/typescript/lib/lib.decorat let cached: Promise | null = null; let cachedPath = ''; +let cachedContent = ''; async function build(path: string, initialContent: string): Promise { const [ts, vfs, cmts] = await Promise.all([ @@ -72,9 +73,15 @@ export function loadTsEditorSupport( initialContent: string, ): Promise { const path = '/' + (fileName.replace(/^\/+/, '') || 'rule.ts'); - if (!cached || cachedPath !== path) { + // the content check matters on reopen: tsSync only tracks in-editor + // edits, so a file changed elsewhere must reseed the environment + if (!cached || cachedPath !== path || cachedContent !== initialContent) { cachedPath = path; - cached = build(path, initialContent); + cachedContent = initialContent; + cached = build(path, initialContent).catch((e) => { + cached = null; // a failed load must not poison TS support forever + throw e; + }); } return cached; } diff --git a/frontend/src/stores/rules/rules-store.ts b/frontend/src/stores/rules/rules-store.ts index 8614ba711..67d36e680 100644 --- a/frontend/src/stores/rules/rules-store.ts +++ b/frontend/src/stores/rules/rules-store.ts @@ -1,7 +1,7 @@ import { makeAutoObservable, runInAction } from 'mobx'; import { editorProxy, mqttClient } from '@/services'; import { generateNextId } from '@/utils/id'; -import type { Rule, RuleError, RuleLevel, RuleListItem, RuleLog } from './types'; +import type { Rule, RuleError, RuleLevel, RuleListItem, RuleLog, TsCheckDiag } from './types'; export default class RulesStore { public rule?: Rule = { @@ -13,6 +13,8 @@ export default class RulesStore { public isRuleDebugEnabled = false; public logs: RuleLog[] = []; public logLevelFilter = 'all'; + public tsCheckDiags: TsCheckDiag[] = []; + private tsCheckTopic: string | null = null; constructor() { makeAutoObservable(this); @@ -137,7 +139,10 @@ export default class RulesStore { this.rules.map((rule) => rule.virtualPath.replace(/\.(js|ts)$/, '')), copiedRule.name.replace(/\.(js|ts)$/, ''), ); - const copiedRuleName = await this.save({ ...copiedRule, initName: this.getValidRuleName(copiedRule.name + extension) }); + const copiedRuleName = await this.save({ + ...copiedRule, + initName: this.getValidRuleName(copiedRule.name + extension), + }); await new Promise((resolve) => setTimeout(resolve, 2000)); await this.changeState(copiedRuleName, false); } @@ -196,6 +201,31 @@ export default class RulesStore { mqttClient.unsubscribe('/wbrules/log/+'); } + // The controller re-checks .ts rules with the same tsgo it runs them + // with and publishes the verdict as retained JSON - the authoritative + // post-save result, shown next to the editor's own live check. + subscribeTsCheck(fileName: string) { + this.tsCheckDiags = []; + this.tsCheckTopic = `/wbrules/ts-check/${fileName}`; + mqttClient.addStickySubscription(this.tsCheckTopic, ({ payload }) => { + runInAction(() => { + try { + this.tsCheckDiags = payload ? JSON.parse(payload).diags ?? [] : []; + } catch { + this.tsCheckDiags = []; + } + }); + }); + } + + unsubscribeTsCheck() { + if (this.tsCheckTopic) { + mqttClient.unsubscribe(this.tsCheckTopic); + this.tsCheckTopic = null; + } + this.tsCheckDiags = []; + } + clearLogs() { this.logs = []; } diff --git a/frontend/src/stores/rules/types.ts b/frontend/src/stores/rules/types.ts index 22c89fbc6..054e7a6d8 100644 --- a/frontend/src/stores/rules/types.ts +++ b/frontend/src/stores/rules/types.ts @@ -45,3 +45,12 @@ export interface RuleLog { payload: string; time: number; } + +// one diagnostic from the controller-side tsgo check, published by +// wb-rules as retained JSON on /wbrules/ts-check/ +export interface TsCheckDiag { + line: number; + column: number; + severity: 'error' | 'warning'; + message: string; +} From c7d0b052f2a4b8adfc857948d0adbe49014d1fc0 Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Fri, 14 Aug 2026 13:51:20 +0000 Subject: [PATCH 03/18] rules editor: don't double-escape controller check messages React escapes on render; i18next's own interpolation escaping showed ' instead of quotes in the banner. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- frontend/src/pages/rules/[rule]/edit-rule.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/rules/[rule]/edit-rule.tsx b/frontend/src/pages/rules/[rule]/edit-rule.tsx index 0eaf1d201..ec70fceca 100644 --- a/frontend/src/pages/rules/[rule]/edit-rule.tsx +++ b/frontend/src/pages/rules/[rule]/edit-rule.tsx @@ -73,7 +73,13 @@ const EditRulePage = observer(() => { for (const d of rulesStore.tsCheckDiags) { result.push({ variant: d.severity === 'error' ? 'danger' : 'warn', - text: t('rules.labels.ts-check', { line: d.line, column: d.column, message: d.message }), + text: t('rules.labels.ts-check', { + line: d.line, + column: d.column, + message: d.message, + // React escapes on render; i18next's own escaping would show ' + interpolation: { escapeValue: false }, + }), }); } return result; From 71e0a3c5b4968fd669fcfcaccf516c60c75c10c0 Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Fri, 14 Aug 2026 14:14:29 +0000 Subject: [PATCH 04/18] rules editor: show controller type-check verdict inline, not as a banner 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 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- frontend/package-lock.json | 1 + frontend/package.json | 1 + frontend/src/i18n/locales/en.json | 1 - frontend/src/i18n/locales/ru.json | 1 - frontend/src/pages/rules/[rule]/edit-rule.tsx | 28 ++++-------- .../controller-diagnostics.test.ts | 29 +++++++++++++ .../autocomplete/controller-diagnostics.ts | 43 +++++++++++++++++++ 7 files changed, 82 insertions(+), 22 deletions(-) create mode 100644 frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts create mode 100644 frontend/src/stores/rules/autocomplete/controller-diagnostics.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7403e9a92..5d8bfbccb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@codemirror/lang-javascript": "6.2.5", "@codemirror/lang-json": "6.0.2", + "@codemirror/lint": "^6.9.6", "@codemirror/state": "6.6.0", "@codemirror/view": "6.43.0", "@daypicker/react": "10.0.1", diff --git a/frontend/package.json b/frontend/package.json index bd346a724..46a92042b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "dependencies": { "@codemirror/lang-javascript": "6.2.5", "@codemirror/lang-json": "6.0.2", + "@codemirror/lint": "^6.9.6", "@codemirror/state": "6.6.0", "@codemirror/view": "6.43.0", "@daypicker/react": "10.0.1", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index eb2d6ecf5..e78627ae3 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -710,7 +710,6 @@ "delete": "Delete" }, "labels": { - "ts-check": "Controller type check: line {{line}}:{{column}} — {{message}}", "delete-title": "Rule deletion", "open-rule": "Open rule \"{{path}}\"", "title-placeholder": "Rule name", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 432cf0835..e2a43b01e 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -739,7 +739,6 @@ "delete": "Удалить" }, "labels": { - "ts-check": "Проверка типов на контроллере: строка {{line}}:{{column}} — {{message}}", "delete-title": "Удаление правила", "open-rule": "Открыть правило \"{{path}}\"", "title-placeholder": "Название правила", diff --git a/frontend/src/pages/rules/[rule]/edit-rule.tsx b/frontend/src/pages/rules/[rule]/edit-rule.tsx index ec70fceca..2c85beddb 100644 --- a/frontend/src/pages/rules/[rule]/edit-rule.tsx +++ b/frontend/src/pages/rules/[rule]/edit-rule.tsx @@ -11,6 +11,7 @@ import { authStore, UserRole } from '@/stores/auth'; import { devicesStore } from '@/stores/devices'; import { rulesStore } from '@/stores/rules'; import { getExtensions } from '@/stores/rules/autocomplete'; +import { controllerDiagnostics } from '@/stores/rules/autocomplete/controller-diagnostics'; import type { TsEditorSupport } from '@/stores/rules/autocomplete/types'; import { useAsyncAction } from '@/utils/async-action'; import { usePreventLeavePage } from '@/utils/prevent-page-leave'; @@ -63,27 +64,12 @@ const EditRulePage = observer(() => { const errors = useMemo(() => { if (pageLoadError) { return [{ code: 404 }]; + } else if (rule.error) { + return [{ variant: 'danger', text: rule.error.message }]; + } else { + return []; } - const result = []; - if (rule.error) { - result.push({ variant: 'danger', text: rule.error.message }); - } - // the controller re-checks saved .ts files with its own tsgo; its - // verdict arrives over MQTT and is authoritative - for (const d of rulesStore.tsCheckDiags) { - result.push({ - variant: d.severity === 'error' ? 'danger' : 'warn', - text: t('rules.labels.ts-check', { - line: d.line, - column: d.column, - message: d.message, - // React escapes on render; i18next's own escaping would show ' - interpolation: { escapeValue: false }, - }), - }); - } - return result; - }, [pageLoadError, rule.error, rulesStore.tsCheckDiags, t]); + }, [pageLoadError, rule.error]); useEffect(() => { if (!params['*']) { @@ -161,6 +147,8 @@ const EditRulePage = observer(() => { typeAwareSource: tsSupport?.completionSource, }), ...(tsSupport?.extensions ?? []), + // controller-side tsgo verdict, inline at the reported lines + ...(isTypeScript ? [controllerDiagnostics(() => rulesStore.tsCheckDiags)] : []), ]} onChange={(value) => { setIsDirty(true); diff --git a/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts b/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts new file mode 100644 index 000000000..1f527aa8e --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts @@ -0,0 +1,29 @@ +import { Text } from '@codemirror/state'; +import type { TsCheckDiag } from '../types'; +import { controllerDiagsToCm } from './controller-diagnostics'; + +describe('controllerDiagsToCm', () => { + const doc = Text.of(['const a = 1;', 'let b: number = 0;', 'b = \'oops\';']); + + it('anchors a diagnostic at the reported line and column, spanning to end of line', () => { + const diags: TsCheckDiag[] = [ + { line: 3, column: 1, severity: 'error', message: 'Type \'string\' is not assignable to type \'number\'.' }, + ]; + const [d] = controllerDiagsToCm(doc, diags); + expect(d.from).toBe(doc.line(3).from); + expect(d.to).toBe(doc.line(3).to); + expect(d.severity).toBe('error'); + expect(d.source).toBe('controller (tsgo)'); + }); + + it('clamps out-of-range lines and columns instead of throwing', () => { + const diags: TsCheckDiag[] = [ + { line: 99, column: 1, severity: 'error', message: 'gone' }, + { line: 1, column: 500, severity: 'warning', message: 'far right' }, + ]; + const result = controllerDiagsToCm(doc, diags); + expect(result).toHaveLength(1); + expect(result[0].from).toBe(doc.line(1).to); + expect(result[0].severity).toBe('warning'); + }); +}); diff --git a/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts b/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts new file mode 100644 index 000000000..6ed697002 --- /dev/null +++ b/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts @@ -0,0 +1,43 @@ +import { forceLinting, linter, type Diagnostic } from '@codemirror/lint'; +import type { Extension, Text } from '@codemirror/state'; +import { ViewPlugin } from '@codemirror/view'; +import { autorun } from 'mobx'; +import type { TsCheckDiag } from '../types'; + +// Renders the controller-side tsgo verdict (retained MQTT JSON from +// /wbrules/ts-check/) inline in the editor: squiggles at the +// reported lines, merged with the local language service's own lint +// entries. The tooltip is labeled with the source so the two checks +// stay distinguishable. + +export function controllerDiagsToCm(doc: Text, diags: TsCheckDiag[]): Diagnostic[] { + const result: Diagnostic[] = []; + for (const d of diags) { + if (d.line < 1 || d.line > doc.lines) continue; + const line = doc.line(d.line); + const from = line.from + Math.min(Math.max(d.column - 1, 0), line.length); + result.push({ + from, + to: line.to, + severity: d.severity === 'error' ? 'error' : 'warning', + source: 'controller (tsgo)', + message: d.message, + }); + } + return result; +} + +// getDiags must be a mobx-observable read; new MQTT data re-triggers +// linting through the autorun below. +export function controllerDiagnostics(getDiags: () => TsCheckDiag[]): Extension { + return [ + linter((view) => controllerDiagsToCm(view.state.doc, getDiags())), + ViewPlugin.define((view) => { + const stop = autorun(() => { + getDiags(); + forceLinting(view); + }); + return { destroy: stop }; + }), + ]; +} From 098422e5d30d752d1c1741a92a820d22ba95998e Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Fri, 14 Aug 2026 14:23:20 +0000 Subject: [PATCH 05/18] rules editor: de-duplicate controller diagnostics against the local check 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 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- frontend/src/pages/rules/[rule]/edit-rule.tsx | 9 +++++++- .../controller-diagnostics.test.ts | 11 ++++++++++ .../autocomplete/controller-diagnostics.ts | 21 +++++++++++++++---- .../rules/autocomplete/ts-language-service.ts | 16 ++++++++++++++ .../src/stores/rules/autocomplete/types.ts | 2 ++ frontend/src/stores/rules/types.ts | 7 +++++++ 6 files changed, 61 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/rules/[rule]/edit-rule.tsx b/frontend/src/pages/rules/[rule]/edit-rule.tsx index 2c85beddb..31551348f 100644 --- a/frontend/src/pages/rules/[rule]/edit-rule.tsx +++ b/frontend/src/pages/rules/[rule]/edit-rule.tsx @@ -148,7 +148,14 @@ const EditRulePage = observer(() => { }), ...(tsSupport?.extensions ?? []), // controller-side tsgo verdict, inline at the reported lines - ...(isTypeScript ? [controllerDiagnostics(() => rulesStore.tsCheckDiags)] : []), + ...(isTypeScript + ? [ + controllerDiagnostics( + () => rulesStore.tsCheckDiags, + () => tsSupport?.getDiagnostics() ?? [], + ), + ] + : []), ]} onChange={(value) => { setIsDirty(true); diff --git a/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts b/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts index 1f527aa8e..f9ad280b9 100644 --- a/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts +++ b/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts @@ -16,6 +16,17 @@ describe('controllerDiagsToCm', () => { expect(d.source).toBe('controller (tsgo)'); }); + it('drops controller entries the local language service already shows, keeps skew-only ones', () => { + const diags: TsCheckDiag[] = [ + { line: 3, column: 1, severity: 'error', message: 'same finding' }, + { line: 3, column: 1, severity: 'error', message: 'controller-only finding' }, + ]; + const local = [{ line: 3, message: 'same finding' }]; + const result = controllerDiagsToCm(doc, diags, local); + expect(result).toHaveLength(1); + expect(result[0].message).toBe('controller-only finding'); + }); + it('clamps out-of-range lines and columns instead of throwing', () => { const diags: TsCheckDiag[] = [ { line: 99, column: 1, severity: 'error', message: 'gone' }, diff --git a/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts b/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts index 6ed697002..10a6c0a0f 100644 --- a/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts +++ b/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts @@ -2,7 +2,7 @@ import { forceLinting, linter, type Diagnostic } from '@codemirror/lint'; import type { Extension, Text } from '@codemirror/state'; import { ViewPlugin } from '@codemirror/view'; import { autorun } from 'mobx'; -import type { TsCheckDiag } from '../types'; +import type { LocalTsDiag, TsCheckDiag } from '../types'; // Renders the controller-side tsgo verdict (retained MQTT JSON from // /wbrules/ts-check/) inline in the editor: squiggles at the @@ -10,10 +10,20 @@ import type { TsCheckDiag } from '../types'; // entries. The tooltip is labeled with the source so the two checks // stay distinguishable. -export function controllerDiagsToCm(doc: Text, diags: TsCheckDiag[]): Diagnostic[] { +export function controllerDiagsToCm( + doc: Text, + diags: TsCheckDiag[], + localDiags: LocalTsDiag[] = [], +): Diagnostic[] { + // the local language service usually reports the same finding at the + // same line; showing both doubles every squiggle. Keep only the + // controller entries the editor does not already show (version/skew + // differences - the controller's unique value). + const local = new Set(localDiags.map((d) => `${d.line}\u0000${d.message}`)); const result: Diagnostic[] = []; for (const d of diags) { if (d.line < 1 || d.line > doc.lines) continue; + if (local.has(`${d.line}\u0000${d.message}`)) continue; const line = doc.line(d.line); const from = line.from + Math.min(Math.max(d.column - 1, 0), line.length); result.push({ @@ -29,9 +39,12 @@ export function controllerDiagsToCm(doc: Text, diags: TsCheckDiag[]): Diagnostic // getDiags must be a mobx-observable read; new MQTT data re-triggers // linting through the autorun below. -export function controllerDiagnostics(getDiags: () => TsCheckDiag[]): Extension { +export function controllerDiagnostics( + getDiags: () => TsCheckDiag[], + getLocalDiags?: () => LocalTsDiag[], +): Extension { return [ - linter((view) => controllerDiagsToCm(view.state.doc, getDiags())), + linter((view) => controllerDiagsToCm(view.state.doc, getDiags(), getLocalDiags?.() ?? [])), ViewPlugin.define((view) => { const stop = autorun(() => { getDiags(); diff --git a/frontend/src/stores/rules/autocomplete/ts-language-service.ts b/frontend/src/stores/rules/autocomplete/ts-language-service.ts index ee29a3b0f..e92fd908c 100644 --- a/frontend/src/stores/rules/autocomplete/ts-language-service.ts +++ b/frontend/src/stores/rules/autocomplete/ts-language-service.ts @@ -63,6 +63,22 @@ async function build(path: string, initialContent: string): Promise { + const sourceFile = env.getSourceFile(path); + if (!sourceFile) return []; + const all = [ + ...env.languageService.getSyntacticDiagnostics(path), + ...env.languageService.getSemanticDiagnostics(path), + ]; + return all + .filter((d) => d.start !== undefined) + .map((d) => ({ + line: sourceFile.getLineAndCharacterOfPosition(d.start).line + 1, + message: ts.flattenDiagnosticMessageText(d.messageText, ' '), + })); + }, }; } diff --git a/frontend/src/stores/rules/autocomplete/types.ts b/frontend/src/stores/rules/autocomplete/types.ts index c074d05b6..bd4a008cf 100644 --- a/frontend/src/stores/rules/autocomplete/types.ts +++ b/frontend/src/stores/rules/autocomplete/types.ts @@ -1,7 +1,9 @@ import type { CompletionSource } from '@codemirror/autocomplete'; import type { Extension } from '@codemirror/state'; +import type { LocalTsDiag } from '../types'; export interface TsEditorSupport { extensions: Extension[]; completionSource: CompletionSource; + getDiagnostics: () => LocalTsDiag[]; } diff --git a/frontend/src/stores/rules/types.ts b/frontend/src/stores/rules/types.ts index 054e7a6d8..35d3b104f 100644 --- a/frontend/src/stores/rules/types.ts +++ b/frontend/src/stores/rules/types.ts @@ -46,6 +46,13 @@ export interface RuleLog { time: number; } +// one diagnostic from the editor's local TypeScript language service, +// used to de-duplicate the controller's findings +export interface LocalTsDiag { + line: number; + message: string; +} + // one diagnostic from the controller-side tsgo check, published by // wb-rules as retained JSON on /wbrules/ts-check/ export interface TsCheckDiag { From 963ec1ab4b538f9574a8e3d686ba2218433f2bd9 Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Fri, 14 Aug 2026 14:41:42 +0000 Subject: [PATCH 06/18] rules editor: pull controller verdict via Editor.Check RPC; seed types 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 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- .../src/pages/rules/[rule]/edit-rule.test.tsx | 3 ++ frontend/src/pages/rules/[rule]/edit-rule.tsx | 23 +++++++++----- frontend/src/services/editor-proxy.ts | 6 ++-- .../rules/autocomplete/ts-language-service.ts | 16 +++++++--- frontend/src/stores/rules/rules-store.ts | 30 ++++++++----------- frontend/src/stores/rules/types.ts | 9 ++++-- 6 files changed, 54 insertions(+), 33 deletions(-) diff --git a/frontend/src/pages/rules/[rule]/edit-rule.test.tsx b/frontend/src/pages/rules/[rule]/edit-rule.test.tsx index 43b773c7f..80d08a3fd 100644 --- a/frontend/src/pages/rules/[rule]/edit-rule.test.tsx +++ b/frontend/src/pages/rules/[rule]/edit-rule.test.tsx @@ -18,6 +18,9 @@ const { rulesMock, navigateMock, paramsMock, setIsDirtyMock } = vi.hoisted(() => setRule: vi.fn(), setRuleName: vi.fn(), checkIsNameUnique: vi.fn(async () => true), + tsCheckDiags: [], + checkTsFile: vi.fn(async () => {}), + clearTsCheck: vi.fn(), }, navigateMock: vi.fn(), paramsMock: { id: 'test-rule.js' } as Record, diff --git a/frontend/src/pages/rules/[rule]/edit-rule.tsx b/frontend/src/pages/rules/[rule]/edit-rule.tsx index 31551348f..3466a3a47 100644 --- a/frontend/src/pages/rules/[rule]/edit-rule.tsx +++ b/frontend/src/pages/rules/[rule]/edit-rule.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/button'; import { CodeEditor } from '@/components/code-editor'; import { Tag } from '@/components/tag'; import { PageLayout } from '@/layouts/page'; +import { editorProxy } from '@/services'; import { authStore, UserRole } from '@/stores/auth'; import { devicesStore } from '@/stores/devices'; import { rulesStore } from '@/stores/rules'; @@ -39,9 +40,15 @@ const EditRulePage = observer(() => { // the language service (typescript + lib files, ~1 MB gzip) stays in a // lazy chunk that .js-only users never download. Unsaved rules use a // stable placeholder path so title edits don't rebuild the service. + // Types come from the controller (Editor.GetTypes) so the editor + // validates against the installed engine's API; the vendored copy is + // only the offline fallback. const servicePath = params['*'] || 'unsaved.ts'; - import('@/stores/rules/autocomplete/ts-language-service') - .then((m) => m.loadTsEditorSupport(servicePath, rule.content)) + Promise.all([ + import('@/stores/rules/autocomplete/ts-language-service'), + editorProxy.GetTypes().then((r) => r?.content, () => undefined), + ]) + .then(([m, typesDts]) => m.loadTsEditorSupport(servicePath, rule.content, typesDts)) .then( (support) => alive && setTsSupport(support), () => {}, // editor still works without the language service @@ -54,12 +61,11 @@ const EditRulePage = observer(() => { }, [isTypeScript, ruleFileName, isLoading]); useEffect(() => { - if (!isTypeScript || !ruleFileName) { - return undefined; + rulesStore.clearTsCheck(); + if (isTypeScript && params['*'] && !isLoading) { + rulesStore.checkTsFile(params['*']); } - rulesStore.subscribeTsCheck(ruleFileName); - return () => rulesStore.unsubscribeTsCheck(); - }, [isTypeScript, ruleFileName]); + }, [isTypeScript, params['*'], isLoading]); const errors = useMemo(() => { if (pageLoadError) { @@ -98,6 +104,9 @@ const EditRulePage = observer(() => { try { const savedRuleName = await rulesStore.save(rule); setIsDirty(false); + if (savedRuleName.endsWith('.ts')) { + rulesStore.checkTsFile(savedRuleName); + } if (!params['*']) { const encoded = savedRuleName.split('/').map(encodeURIComponent).join('/'); return navigate(`/rules/${encoded}`, { replace: true }); diff --git a/frontend/src/services/editor-proxy.ts b/frontend/src/services/editor-proxy.ts index c3526b30f..fe85a4783 100644 --- a/frontend/src/services/editor-proxy.ts +++ b/frontend/src/services/editor-proxy.ts @@ -1,4 +1,4 @@ -import type { RuleFetchData, RuleListItem, RuleSaveData } from '@/stores/rules/types'; +import type { RuleFetchData, RuleListItem, RuleSaveData, TsCheckResult } from '@/stores/rules/types'; import { createRpcProxy } from './rpc'; interface EditorProxyMethods { @@ -8,9 +8,11 @@ interface EditorProxyMethods { Save: (params: { path: string; content: string }) => Promise; Remove: (params: { path: string }) => Promise; Rename: (params: { path: string; new_path: string }) => Promise; + Check: (params: { path: string }) => Promise; + GetTypes: () => Promise<{ content: string }>; } export const editorProxy = createRpcProxy( 'wbrules/Editor', - ['ChangeState', 'List', 'Load', 'Save', 'Remove', 'Rename'], + ['ChangeState', 'List', 'Load', 'Save', 'Remove', 'Rename', 'Check', 'GetTypes'], ); diff --git a/frontend/src/stores/rules/autocomplete/ts-language-service.ts b/frontend/src/stores/rules/autocomplete/ts-language-service.ts index e92fd908c..818092c06 100644 --- a/frontend/src/stores/rules/autocomplete/ts-language-service.ts +++ b/frontend/src/stores/rules/autocomplete/ts-language-service.ts @@ -24,8 +24,13 @@ const decoratorLibs = import.meta.glob('/node_modules/typescript/lib/lib.decorat let cached: Promise | null = null; let cachedPath = ''; let cachedContent = ''; +let cachedTypes = ''; -async function build(path: string, initialContent: string): Promise { +async function build( + path: string, + initialContent: string, + typesDts: string, +): Promise { const [ts, vfs, cmts] = await Promise.all([ import('typescript').then((m) => m.default), import('@typescript/vfs'), @@ -44,7 +49,7 @@ async function build(path: string, initialContent: string): Promise { const path = '/' + (fileName.replace(/^\/+/, '') || 'rule.ts'); + const typesDts = controllerTypes || wbRulesDts; // the content check matters on reopen: tsSync only tracks in-editor // edits, so a file changed elsewhere must reseed the environment - if (!cached || cachedPath !== path || cachedContent !== initialContent) { + if (!cached || cachedPath !== path || cachedContent !== initialContent || cachedTypes !== typesDts) { cachedPath = path; cachedContent = initialContent; - cached = build(path, initialContent).catch((e) => { + cachedTypes = typesDts; + cached = build(path, initialContent, typesDts).catch((e) => { cached = null; // a failed load must not poison TS support forever throw e; }); diff --git a/frontend/src/stores/rules/rules-store.ts b/frontend/src/stores/rules/rules-store.ts index 67d36e680..0f220f0fa 100644 --- a/frontend/src/stores/rules/rules-store.ts +++ b/frontend/src/stores/rules/rules-store.ts @@ -14,7 +14,6 @@ export default class RulesStore { public logs: RuleLog[] = []; public logLevelFilter = 'all'; public tsCheckDiags: TsCheckDiag[] = []; - private tsCheckTopic: string | null = null; constructor() { makeAutoObservable(this); @@ -202,27 +201,22 @@ export default class RulesStore { } // The controller re-checks .ts rules with the same tsgo it runs them - // with and publishes the verdict as retained JSON - the authoritative - // post-save result, shown next to the editor's own live check. - subscribeTsCheck(fileName: string) { - this.tsCheckDiags = []; - this.tsCheckTopic = `/wbrules/ts-check/${fileName}`; - mqttClient.addStickySubscription(this.tsCheckTopic, ({ payload }) => { + // with (Editor.Check RPC) - the authoritative verdict, pulled on file + // open and after each save, shown next to the editor's own live check. + async checkTsFile(fileName: string) { + try { + const result = await editorProxy.Check({ path: fileName }); runInAction(() => { - try { - this.tsCheckDiags = payload ? JSON.parse(payload).diags ?? [] : []; - } catch { - this.tsCheckDiags = []; - } + this.tsCheckDiags = result?.tsSupported ? result.diags : []; }); - }); + } catch { + runInAction(() => { + this.tsCheckDiags = []; + }); + } } - unsubscribeTsCheck() { - if (this.tsCheckTopic) { - mqttClient.unsubscribe(this.tsCheckTopic); - this.tsCheckTopic = null; - } + clearTsCheck() { this.tsCheckDiags = []; } diff --git a/frontend/src/stores/rules/types.ts b/frontend/src/stores/rules/types.ts index 35d3b104f..3cfc5464f 100644 --- a/frontend/src/stores/rules/types.ts +++ b/frontend/src/stores/rules/types.ts @@ -53,8 +53,13 @@ export interface LocalTsDiag { message: string; } -// one diagnostic from the controller-side tsgo check, published by -// wb-rules as retained JSON on /wbrules/ts-check/ +// reply of the Editor.Check RPC: the controller-side tsgo verdict +export interface TsCheckResult { + tsSupported: boolean; + diags: TsCheckDiag[]; +} + +// one diagnostic from the controller-side tsgo check (Editor.Check RPC) export interface TsCheckDiag { line: number; column: number; From b3b5dcebc9e12ffcd13a6c30624a39f5ca16f18b Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Fri, 14 Aug 2026 14:49:23 +0000 Subject: [PATCH 07/18] rules editor: suppress stale controller diagnostics once the user edits 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 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- frontend/src/pages/rules/[rule]/edit-rule.tsx | 5 ++- .../controller-diagnostics.test.ts | 23 ++++++++++++- .../autocomplete/controller-diagnostics.ts | 32 ++++++++++++++++--- frontend/src/stores/rules/rules-store.ts | 7 ++++ 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/rules/[rule]/edit-rule.tsx b/frontend/src/pages/rules/[rule]/edit-rule.tsx index 3466a3a47..cc1625313 100644 --- a/frontend/src/pages/rules/[rule]/edit-rule.tsx +++ b/frontend/src/pages/rules/[rule]/edit-rule.tsx @@ -160,7 +160,10 @@ const EditRulePage = observer(() => { ...(isTypeScript ? [ controllerDiagnostics( - () => rulesStore.tsCheckDiags, + () => ({ + diags: rulesStore.tsCheckDiags, + checkedContent: rulesStore.tsCheckedContent, + }), () => tsSupport?.getDiagnostics() ?? [], ), ] diff --git a/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts b/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts index f9ad280b9..7821038dc 100644 --- a/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts +++ b/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts @@ -1,6 +1,6 @@ import { Text } from '@codemirror/state'; import type { TsCheckDiag } from '../types'; -import { controllerDiagsToCm } from './controller-diagnostics'; +import { controllerDiagsForDoc, controllerDiagsToCm } from './controller-diagnostics'; describe('controllerDiagsToCm', () => { const doc = Text.of(['const a = 1;', 'let b: number = 0;', 'b = \'oops\';']); @@ -38,3 +38,24 @@ describe('controllerDiagsToCm', () => { expect(result[0].severity).toBe('warning'); }); }); + +describe('controllerDiagsForDoc', () => { + const doc = Text.of(['let n: number = 0;', 'n = \'oops\';']); + const diags = [ + { line: 2, column: 1, severity: 'error' as const, message: 'stale finding' }, + ]; + + it('renders the verdict while the document matches the checked content', () => { + const verdict = { diags, checkedContent: doc.toString() }; + expect(controllerDiagsForDoc(doc, verdict)).toHaveLength(1); + }); + + it('suppresses the verdict once the user edits: a fixed line must not keep its old squiggle', () => { + const verdict = { diags, checkedContent: 'let n: number = 0;\nn = 5;' }; + expect(controllerDiagsForDoc(doc, verdict)).toHaveLength(0); + }); + + it('renders nothing when no check has completed yet', () => { + expect(controllerDiagsForDoc(doc, { diags, checkedContent: null })).toHaveLength(0); + }); +}); diff --git a/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts b/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts index 10a6c0a0f..8808e3869 100644 --- a/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts +++ b/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts @@ -37,17 +37,41 @@ export function controllerDiagsToCm( return result; } -// getDiags must be a mobx-observable read; new MQTT data re-triggers +export interface ControllerVerdict { + diags: TsCheckDiag[]; + // the editor content the verdict was computed for; null = unknown + checkedContent: string | null; +} + +// The verdict describes the last-saved file. Once the user edits, its +// line anchors and findings go stale - a fixed line must not keep its +// old squiggle - so diagnostics render only while the document still +// matches the checked content (the local language service covers the +// live state; saving triggers a fresh verdict). +export function controllerDiagsForDoc( + doc: Text, + verdict: ControllerVerdict, + localDiags: LocalTsDiag[] = [], +): Diagnostic[] { + if (verdict.checkedContent === null || verdict.checkedContent !== doc.toString()) { + return []; + } + return controllerDiagsToCm(doc, verdict.diags, localDiags); +} + +// getVerdict must be a mobx-observable read; new RPC data re-triggers // linting through the autorun below. export function controllerDiagnostics( - getDiags: () => TsCheckDiag[], + getVerdict: () => ControllerVerdict, getLocalDiags?: () => LocalTsDiag[], ): Extension { return [ - linter((view) => controllerDiagsToCm(view.state.doc, getDiags(), getLocalDiags?.() ?? [])), + linter((view) => controllerDiagsForDoc(view.state.doc, getVerdict(), getLocalDiags?.() ?? [])), ViewPlugin.define((view) => { const stop = autorun(() => { - getDiags(); + const v = getVerdict(); + void v.diags; + void v.checkedContent; forceLinting(view); }); return { destroy: stop }; diff --git a/frontend/src/stores/rules/rules-store.ts b/frontend/src/stores/rules/rules-store.ts index 0f220f0fa..09791ea28 100644 --- a/frontend/src/stores/rules/rules-store.ts +++ b/frontend/src/stores/rules/rules-store.ts @@ -14,6 +14,7 @@ export default class RulesStore { public logs: RuleLog[] = []; public logLevelFilter = 'all'; public tsCheckDiags: TsCheckDiag[] = []; + public tsCheckedContent: string | null = null; constructor() { makeAutoObservable(this); @@ -204,20 +205,26 @@ export default class RulesStore { // with (Editor.Check RPC) - the authoritative verdict, pulled on file // open and after each save, shown next to the editor's own live check. async checkTsFile(fileName: string) { + // the verdict describes the saved file; capture the matching editor + // content so stale diagnostics are suppressed once the user edits + const checkedContent = this.rule?.content ?? ''; try { const result = await editorProxy.Check({ path: fileName }); runInAction(() => { this.tsCheckDiags = result?.tsSupported ? result.diags : []; + this.tsCheckedContent = checkedContent; }); } catch { runInAction(() => { this.tsCheckDiags = []; + this.tsCheckedContent = null; }); } } clearTsCheck() { this.tsCheckDiags = []; + this.tsCheckedContent = null; } clearLogs() { From a20117434783fe9df5ed9c98f7c91cf9aa874226 Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Fri, 14 Aug 2026 15:09:38 +0000 Subject: [PATCH 08/18] rules editor: adapt to Editor.Check statuses; fix review findings - 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 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- .../controller-diagnostics.test.ts | 17 +++++++++++ .../autocomplete/controller-diagnostics.ts | 21 +++++++++----- frontend/src/stores/rules/rules-store.ts | 29 +++++++++++++------ frontend/src/stores/rules/types.ts | 10 +++++-- 4 files changed, 58 insertions(+), 19 deletions(-) diff --git a/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts b/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts index 7821038dc..7ed30842d 100644 --- a/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts +++ b/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts @@ -27,6 +27,23 @@ describe('controllerDiagsToCm', () => { expect(result[0].message).toBe('controller-only finding'); }); + it('de-duplicates by message prefix: the controller carries only the head line of chains', () => { + const diags: TsCheckDiag[] = [ + { line: 3, column: 1, severity: 'error', message: "Argument of type 'X' is not assignable." }, + ]; + const local = [ + { line: 3, message: "Argument of type 'X' is not assignable. Types of property 'x' are incompatible." }, + ]; + expect(controllerDiagsToCm(doc, diags, local)).toHaveLength(0); + }); + + it('skips diagnostics belonging to another file (import/reference)', () => { + const diags: TsCheckDiag[] = [ + { file: 'helper.ts', line: 1, column: 1, severity: 'error', message: 'foreign' }, + ]; + expect(controllerDiagsToCm(doc, diags)).toHaveLength(0); + }); + it('clamps out-of-range lines and columns instead of throwing', () => { const diags: TsCheckDiag[] = [ { line: 99, column: 1, severity: 'error', message: 'gone' }, diff --git a/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts b/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts index 8808e3869..415fafcb8 100644 --- a/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts +++ b/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts @@ -4,11 +4,10 @@ import { ViewPlugin } from '@codemirror/view'; import { autorun } from 'mobx'; import type { LocalTsDiag, TsCheckDiag } from '../types'; -// Renders the controller-side tsgo verdict (retained MQTT JSON from -// /wbrules/ts-check/) inline in the editor: squiggles at the -// reported lines, merged with the local language service's own lint -// entries. The tooltip is labeled with the source so the two checks -// stay distinguishable. +// Renders the controller-side tsgo verdict (Editor.Check RPC) inline in +// the editor: squiggles at the reported lines, merged with the local +// language service's own lint entries. The tooltip is labeled with the +// source so the two checks stay distinguishable. export function controllerDiagsToCm( doc: Text, @@ -18,12 +17,18 @@ export function controllerDiagsToCm( // the local language service usually reports the same finding at the // same line; showing both doubles every squiggle. Keep only the // controller entries the editor does not already show (version/skew - // differences - the controller's unique value). - const local = new Set(localDiags.map((d) => `${d.line}\u0000${d.message}`)); + // differences - the controller's unique value). The controller carries + // only the head line of chained messages while the local service + // flattens the whole chain, so match by prefix, not equality. + const localByLine = new Map(); + for (const l of localDiags) { + localByLine.set(l.line, [...(localByLine.get(l.line) ?? []), l.message]); + } const result: Diagnostic[] = []; for (const d of diags) { + if (d.file) continue; // belongs to another file; cannot anchor here if (d.line < 1 || d.line > doc.lines) continue; - if (local.has(`${d.line}\u0000${d.message}`)) continue; + if ((localByLine.get(d.line) ?? []).some((m) => m.startsWith(d.message))) continue; const line = doc.line(d.line); const from = line.from + Math.min(Math.max(d.column - 1, 0), line.length); result.push({ diff --git a/frontend/src/stores/rules/rules-store.ts b/frontend/src/stores/rules/rules-store.ts index 09791ea28..a6ff60b1b 100644 --- a/frontend/src/stores/rules/rules-store.ts +++ b/frontend/src/stores/rules/rules-store.ts @@ -15,6 +15,7 @@ export default class RulesStore { public logLevelFilter = 'all'; public tsCheckDiags: TsCheckDiag[] = []; public tsCheckedContent: string | null = null; + private _tsCheckToken = 0; constructor() { makeAutoObservable(this); @@ -208,17 +209,27 @@ export default class RulesStore { // the verdict describes the saved file; capture the matching editor // content so stale diagnostics are suppressed once the user edits const checkedContent = this.rule?.content ?? ''; + this._tsCheckToken += 1; + const token = this._tsCheckToken; try { - const result = await editorProxy.Check({ path: fileName }); - runInAction(() => { - this.tsCheckDiags = result?.tsSupported ? result.diags : []; - this.tsCheckedContent = checkedContent; - }); + // the controller answers 'pending' while its background check for + // a freshly loaded/saved file is still running - poll briefly + for (let attempt = 0; attempt < 15; attempt++) { + const result = await editorProxy.Check({ path: fileName }); + if (token !== this._tsCheckToken) return; // superseded by a newer check + if (result?.status !== 'pending') { + runInAction(() => { + this.tsCheckDiags = result?.status === 'ready' ? result.diags : []; + this.tsCheckedContent = checkedContent; + }); + return; + } + await new Promise((resolve) => setTimeout(resolve, 700)); + } + runInAction(() => this.clearTsCheck()); } catch { - runInAction(() => { - this.tsCheckDiags = []; - this.tsCheckedContent = null; - }); + if (token !== this._tsCheckToken) return; + runInAction(() => this.clearTsCheck()); } } diff --git a/frontend/src/stores/rules/types.ts b/frontend/src/stores/rules/types.ts index 3cfc5464f..49c04b616 100644 --- a/frontend/src/stores/rules/types.ts +++ b/frontend/src/stores/rules/types.ts @@ -53,14 +53,20 @@ export interface LocalTsDiag { message: string; } -// reply of the Editor.Check RPC: the controller-side tsgo verdict +export type TsCheckStatus = 'ready' | 'pending' | 'not-ts' | 'unsupported'; + +// reply of the Editor.Check RPC: the controller-side tsgo verdict; +// diags are valid only for 'ready', poll again on 'pending' export interface TsCheckResult { - tsSupported: boolean; + status: TsCheckStatus; diags: TsCheckDiag[]; } // one diagnostic from the controller-side tsgo check (Editor.Check RPC) export interface TsCheckDiag { + // set only for diagnostics from another file (import/reference); + // such entries must not be anchored in the checked file + file?: string; line: number; column: number; severity: 'error' | 'warning'; From 4e75e581341639e9e2aa1265730d707b78c91d72 Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Fri, 14 Aug 2026 16:19:43 +0000 Subject: [PATCH 09/18] rules editor: fix from-scratch review findings (H1-H3, M1-M4, L1-L7) - 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 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- frontend/eslint.config.mjs | 5 ++ frontend/package-lock.json | 8 +-- frontend/package.json | 8 +-- .../scripts/generate-wb-rules-completions.mjs | 10 +++- frontend/src/pages/rules/[rule]/edit-rule.tsx | 57 ++++++++++++------- .../controller-diagnostics.test.ts | 4 +- .../autocomplete/controller-diagnostics.ts | 7 +-- .../src/stores/rules/autocomplete/index.ts | 24 ++++++-- .../src/stores/rules/autocomplete/snippets.ts | 2 +- .../src/stores/rules/autocomplete/types.ts | 8 ++- frontend/src/stores/rules/rules-store.ts | 18 ++++-- frontend/tsconfig.json | 5 +- 12 files changed, 101 insertions(+), 55 deletions(-) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index e69ca7d0c..a4749ca8c 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -6,6 +6,11 @@ const getCustomConfig = (cfg) => { const customIgnores = [ 'src/custom.d.ts', 'src/components/json-editor/extensions/*', + // vendored wb-rules engine declarations (raw-imported for the TS + // language service, not app code) and the completion list generated + // from them + 'src/stores/rules/autocomplete/wb-rules.d.ts', + 'src/stores/rules/autocomplete/globals-generated.ts', ]; const { ignores, ...rest } = cfg.at(0); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5d8bfbccb..744b54943 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@codemirror/lang-javascript": "6.2.5", "@codemirror/lang-json": "6.0.2", - "@codemirror/lint": "^6.9.6", + "@codemirror/lint": "6.9.6", "@codemirror/state": "6.6.0", "@codemirror/view": "6.43.0", "@daypicker/react": "10.0.1", @@ -21,9 +21,9 @@ "@dnd-kit/utilities": "3.2.2", "@floating-ui/react": "0.27.19", "@rpldy/uploady": "1.13.0", - "@typescript/vfs": "^1.6.4", + "@typescript/vfs": "1.6.4", "@uiw/react-codemirror": "4.25.10", - "@valtown/codemirror-ts": "^2.3.1", + "@valtown/codemirror-ts": "2.3.1", "@wirenboard/json-editor": "2.5.3-wb19", "@xterm/addon-fit": "0.11.0", "@xterm/addon-web-links": "0.12.0", @@ -55,6 +55,7 @@ "react-select": "5.10.2", "react-sortablejs": "6.1.4", "sortablejs": "1.15.7", + "typescript": "6.0.3", "use-file-picker": "2.1.4", "xterm": "5.3.0" }, @@ -76,7 +77,6 @@ "globals": "17.6.0", "happy-dom": "20.9.0", "rimraf": "6.1.3", - "typescript": "6.0.3", "use-resize-observer": "9.1.0", "vite": "8.0.13", "vite-plugin-svgr": "5.2.0", diff --git a/frontend/package.json b/frontend/package.json index 46a92042b..04a49cf52 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,7 +26,7 @@ "dependencies": { "@codemirror/lang-javascript": "6.2.5", "@codemirror/lang-json": "6.0.2", - "@codemirror/lint": "^6.9.6", + "@codemirror/lint": "6.9.6", "@codemirror/state": "6.6.0", "@codemirror/view": "6.43.0", "@daypicker/react": "10.0.1", @@ -36,9 +36,9 @@ "@dnd-kit/utilities": "3.2.2", "@floating-ui/react": "0.27.19", "@rpldy/uploady": "1.13.0", - "@typescript/vfs": "^1.6.4", + "@typescript/vfs": "1.6.4", "@uiw/react-codemirror": "4.25.10", - "@valtown/codemirror-ts": "^2.3.1", + "@valtown/codemirror-ts": "2.3.1", "@wirenboard/json-editor": "2.5.3-wb19", "@xterm/addon-fit": "0.11.0", "@xterm/addon-web-links": "0.12.0", @@ -70,6 +70,7 @@ "react-select": "5.10.2", "react-sortablejs": "6.1.4", "sortablejs": "1.15.7", + "typescript": "6.0.3", "use-file-picker": "2.1.4", "xterm": "5.3.0" }, @@ -91,7 +92,6 @@ "globals": "17.6.0", "happy-dom": "20.9.0", "rimraf": "6.1.3", - "typescript": "6.0.3", "use-resize-observer": "9.1.0", "vite": "8.0.13", "vite-plugin-svgr": "5.2.0", diff --git a/frontend/scripts/generate-wb-rules-completions.mjs b/frontend/scripts/generate-wb-rules-completions.mjs index 13b303a5b..b186ea4dd 100644 --- a/frontend/scripts/generate-wb-rules-completions.mjs +++ b/frontend/scripts/generate-wb-rules-completions.mjs @@ -7,6 +7,10 @@ import path from 'node:path'; import url from 'node:url'; import ts from 'typescript'; +// single-quoted string literal matching the repo eslint style, so +// regenerating never dirties the tree +const q = (s) => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`; + const here = path.dirname(url.fileURLToPath(import.meta.url)); const dtsPath = path.join(here, '../src/stores/rules/autocomplete/wb-rules.d.ts'); const outPath = path.join(here, '../src/stores/rules/autocomplete/globals-generated.ts'); @@ -59,10 +63,10 @@ for (const stmt of source.statements) { } const body = completions.map((c) => { - const detail = JSON.stringify(c.detail); + const detail = q(c.detail); return c.snippet && c.snippet !== `${c.label}()` - ? ` snippetCompletion(${JSON.stringify(c.snippet)}, { label: ${JSON.stringify(c.label)}, type: '${c.type}', detail: ${detail} }),` - : ` { label: ${JSON.stringify(c.label)}, type: '${c.type}', detail: ${detail}${c.snippet ? `, apply: ${JSON.stringify(c.snippet)}` : ''} },`; + ? ` snippetCompletion(${q(c.snippet)}, { label: ${q(c.label)}, type: '${c.type}', detail: ${detail} }),` + : ` { label: ${q(c.label)}, type: '${c.type}', detail: ${detail}${c.snippet ? `, apply: ${q(c.snippet)}` : ''} },`; }).join('\n'); fs.writeFileSync(outPath, `// GENERATED from wb-rules.d.ts — do not edit by hand. diff --git a/frontend/src/pages/rules/[rule]/edit-rule.tsx b/frontend/src/pages/rules/[rule]/edit-rule.tsx index cc1625313..6cf55a8c0 100644 --- a/frontend/src/pages/rules/[rule]/edit-rule.tsx +++ b/frontend/src/pages/rules/[rule]/edit-rule.tsx @@ -44,9 +44,16 @@ const EditRulePage = observer(() => { // validates against the installed engine's API; the vendored copy is // only the offline fallback. const servicePath = params['*'] || 'unsaved.ts'; + // race the controller types against a short deadline: old firmware + // without Editor.GetTypes would otherwise stall TS support for the + // full 60s RPC timeout before the vendored fallback kicks in + const controllerTypes = Promise.race([ + editorProxy.GetTypes().then((r) => r?.content, () => undefined), + new Promise((resolve) => setTimeout(() => resolve(undefined), 3000)), + ]); Promise.all([ import('@/stores/rules/autocomplete/ts-language-service'), - editorProxy.GetTypes().then((r) => r?.content, () => undefined), + controllerTypes, ]) .then(([m, typesDts]) => m.loadTsEditorSupport(servicePath, rule.content, typesDts)) .then( @@ -65,8 +72,33 @@ const EditRulePage = observer(() => { if (isTypeScript && params['*'] && !isLoading) { rulesStore.checkTsFile(params['*']); } + // cancel the poll loop when leaving the page + return () => rulesStore.clearTsCheck(); }, [isTypeScript, params['*'], isLoading]); + // rebuilt only when the language service (re)loads: a fresh extensions + // array per keystroke would reconfigure CodeMirror and re-run every + // lint source synchronously on each character typed + const editorExtensions = useMemo(() => [ + ...getExtensions(devicesStore, { + typescript: isTypeScript, + typeAwareSource: tsSupport?.completionSource, + }), + ...(tsSupport?.extensions ?? []), + // controller-side tsgo verdict, inline at the reported lines + ...(isTypeScript + ? [ + controllerDiagnostics( + () => ({ + diags: rulesStore.tsCheckDiags, + checkedContent: rulesStore.tsCheckedContent, + }), + () => tsSupport?.getDiagnostics() ?? [], + ), + ] + : []), + ], [isTypeScript, tsSupport]); + const errors = useMemo(() => { if (pageLoadError) { return [{ code: 404 }]; @@ -102,10 +134,11 @@ const EditRulePage = observer(() => { await rulesStore.checkIsNameUnique(rule.name); } try { + const savedContent = rule.content; const savedRuleName = await rulesStore.save(rule); setIsDirty(false); if (savedRuleName.endsWith('.ts')) { - rulesStore.checkTsFile(savedRuleName); + rulesStore.checkTsFile(savedRuleName, savedContent); } if (!params['*']) { const encoded = savedRuleName.split('/').map(encodeURIComponent).join('/'); @@ -150,25 +183,7 @@ const EditRulePage = observer(() => { text={rule.content} errorLines={rule.error?.errorLine ? [rule.error.errorLine] : null} autoFocus={!!params['*']} - extensions={[ - ...getExtensions(devicesStore, { - typescript: isTypeScript, - typeAwareSource: tsSupport?.completionSource, - }), - ...(tsSupport?.extensions ?? []), - // controller-side tsgo verdict, inline at the reported lines - ...(isTypeScript - ? [ - controllerDiagnostics( - () => ({ - diags: rulesStore.tsCheckDiags, - checkedContent: rulesStore.tsCheckedContent, - }), - () => tsSupport?.getDiagnostics() ?? [], - ), - ] - : []), - ]} + extensions={editorExtensions} onChange={(value) => { setIsDirty(true); rulesStore.setRule(value); diff --git a/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts b/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts index 7ed30842d..0a70b80a3 100644 --- a/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts +++ b/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts @@ -29,10 +29,10 @@ describe('controllerDiagsToCm', () => { it('de-duplicates by message prefix: the controller carries only the head line of chains', () => { const diags: TsCheckDiag[] = [ - { line: 3, column: 1, severity: 'error', message: "Argument of type 'X' is not assignable." }, + { line: 3, column: 1, severity: 'error', message: 'Argument of type \'X\' is not assignable.' }, ]; const local = [ - { line: 3, message: "Argument of type 'X' is not assignable. Types of property 'x' are incompatible." }, + { line: 3, message: 'Argument of type \'X\' is not assignable. Types of property \'x\' are incompatible.' }, ]; expect(controllerDiagsToCm(doc, diags, local)).toHaveLength(0); }); diff --git a/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts b/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts index 415fafcb8..cf41ba1c4 100644 --- a/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts +++ b/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts @@ -3,6 +3,7 @@ import type { Extension, Text } from '@codemirror/state'; import { ViewPlugin } from '@codemirror/view'; import { autorun } from 'mobx'; import type { LocalTsDiag, TsCheckDiag } from '../types'; +import type { ControllerVerdict } from './types'; // Renders the controller-side tsgo verdict (Editor.Check RPC) inline in // the editor: squiggles at the reported lines, merged with the local @@ -42,12 +43,6 @@ export function controllerDiagsToCm( return result; } -export interface ControllerVerdict { - diags: TsCheckDiag[]; - // the editor content the verdict was computed for; null = unknown - checkedContent: string | null; -} - // The verdict describes the last-saved file. Once the user edits, its // line anchors and findings go stale - a fixed line must not keep its // old squiggle - so diagnostics render only while the document still diff --git a/frontend/src/stores/rules/autocomplete/index.ts b/frontend/src/stores/rules/autocomplete/index.ts index 2057351dc..61279dd4e 100644 --- a/frontend/src/stores/rules/autocomplete/index.ts +++ b/frontend/src/stores/rules/autocomplete/index.ts @@ -4,22 +4,35 @@ import { type DevicesStore } from '@/stores/devices'; import { getEnums } from './enums'; import { wbRulesGlobals } from './globals-generated'; import { methods } from './methods'; -import { snippetSource } from './snippets'; +import { snippets } from './snippets'; function mergeSources(sources: CompletionSource[]): CompletionSource { return async (context) => { for (const s of sources) { const result = await s(context); - if (result) return result; + // an empty result must not shadow later sources: the TS language + // service answers with zero entries inside dev["..."] (index + // signature, no literal keys), where the device-list source has + // the real completions + if (result && result.options.length > 0) return result; } return null; }; } -const globalsSource: CompletionSource = (context) => { +// snippets and generated globals are one namespace: offered together, +// snippet variants first (they insert richer templates), generated +// signatures for everything the snippet list does not cover +const snippetLabels = new Set(snippets.map((s) => s.label)); +const staticCompletions = [ + ...snippets, + ...wbRulesGlobals.filter((g) => !snippetLabels.has(g.label)), +]; + +const staticSource: CompletionSource = (context) => { const word = context.matchBefore(/[A-Za-z_$][\w$]*/); if (!word || (word.from === word.to && !context.explicit)) return null; - return { from: word.from, options: wbRulesGlobals, validFor: /^[\w$]*$/ }; + return { from: word.from, options: staticCompletions, validFor: /^[\w$]*$/ }; }; export const getExtensions = ( @@ -32,8 +45,7 @@ export const getExtensions = ( ...(options?.typeAwareSource ? [options.typeAwareSource] : []), ...getEnums(devicesStore), ...methods, - snippetSource, - globalsSource, + staticSource, ]); return [ diff --git a/frontend/src/stores/rules/autocomplete/snippets.ts b/frontend/src/stores/rules/autocomplete/snippets.ts index f75198578..66016fc7a 100644 --- a/frontend/src/stores/rules/autocomplete/snippets.ts +++ b/frontend/src/stores/rules/autocomplete/snippets.ts @@ -2,7 +2,7 @@ import { type CompletionSource, snippetCompletion } from '@codemirror/autocomplete'; -const snippets = [ +export const snippets = [ snippetCompletion( 'log("${1:string}");', { label: 'log', type: 'function', detail: '(fmt, ...args)' }, diff --git a/frontend/src/stores/rules/autocomplete/types.ts b/frontend/src/stores/rules/autocomplete/types.ts index bd4a008cf..2702e285e 100644 --- a/frontend/src/stores/rules/autocomplete/types.ts +++ b/frontend/src/stores/rules/autocomplete/types.ts @@ -1,9 +1,15 @@ import type { CompletionSource } from '@codemirror/autocomplete'; import type { Extension } from '@codemirror/state'; -import type { LocalTsDiag } from '../types'; +import type { LocalTsDiag, TsCheckDiag } from '../types'; export interface TsEditorSupport { extensions: Extension[]; completionSource: CompletionSource; getDiagnostics: () => LocalTsDiag[]; } + +export interface ControllerVerdict { + diags: TsCheckDiag[]; + // the editor content the verdict was computed for; null = unknown + checkedContent: string | null; +} diff --git a/frontend/src/stores/rules/rules-store.ts b/frontend/src/stores/rules/rules-store.ts index a6ff60b1b..ae79f58d3 100644 --- a/frontend/src/stores/rules/rules-store.ts +++ b/frontend/src/stores/rules/rules-store.ts @@ -95,10 +95,13 @@ export default class RulesStore { } async rename(oldName: string, newName: string): Promise { - return editorProxy.Rename({ path: oldName, new_path: this.getValidRuleName(newName) }) + // an extensionless new title keeps the file's language: renaming + // foo.ts to "bar" must not silently turn it into bar.js + const extension = oldName.endsWith('.ts') ? '.ts' : '.js'; + return editorProxy.Rename({ path: oldName, new_path: this.getValidRuleName(newName, extension) }) .then(async () => { await new Promise((resolve) => setTimeout(resolve, 1500)); - return this.getValidRuleName(newName); + return this.getValidRuleName(newName, extension); }); } @@ -112,8 +115,8 @@ export default class RulesStore { return true; } - getValidRuleName(path: string): string { - return path.endsWith('.js') || path.endsWith('.ts') ? path : `${path}.js`; + getValidRuleName(path: string, defaultExtension = '.js'): string { + return path.endsWith('.js') || path.endsWith('.ts') ? path : `${path}${defaultExtension}`; } async changeState(path: string, state: boolean): Promise { @@ -205,10 +208,11 @@ export default class RulesStore { // The controller re-checks .ts rules with the same tsgo it runs them // with (Editor.Check RPC) - the authoritative verdict, pulled on file // open and after each save, shown next to the editor's own live check. - async checkTsFile(fileName: string) { + async checkTsFile(fileName: string, contentOverride?: string) { // the verdict describes the saved file; capture the matching editor // content so stale diagnostics are suppressed once the user edits - const checkedContent = this.rule?.content ?? ''; + // (callers pass the exact content they saved when they have it) + const checkedContent = contentOverride ?? this.rule?.content ?? ''; this._tsCheckToken += 1; const token = this._tsCheckToken; try { @@ -226,6 +230,7 @@ export default class RulesStore { } await new Promise((resolve) => setTimeout(resolve, 700)); } + if (token !== this._tsCheckToken) return; // a newer check owns the state runInAction(() => this.clearTsCheck()); } catch { if (token !== this._tsCheckToken) return; @@ -234,6 +239,7 @@ export default class RulesStore { } clearTsCheck() { + this._tsCheckToken += 1; // cancels any in-flight poll loop this.tsCheckDiags = []; this.tsCheckedContent = null; } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c19a88931..55e06910e 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -25,6 +25,9 @@ "types": ["vitest/globals"] }, "include": ["app/scripts", "src"], - "exclude": ["vite.config.ts"], + "exclude": [ + "vite.config.ts", + "src/stores/rules/autocomplete/wb-rules.d.ts" + ], "references": [{ "path": "./tsconfig.node.json" }] } From dcfa5543f936283589d8fc7a13f404ac62f4c0b1 Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Fri, 14 Aug 2026 17:01:55 +0000 Subject: [PATCH 10/18] rules editor: run the language service for .js rules too 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 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- frontend/src/pages/rules/[rule]/edit-rule.tsx | 10 +++++++--- .../rules/autocomplete/ts-language-service.test.ts | 6 ++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/rules/[rule]/edit-rule.tsx b/frontend/src/pages/rules/[rule]/edit-rule.tsx index 6cf55a8c0..d07f4820f 100644 --- a/frontend/src/pages/rules/[rule]/edit-rule.tsx +++ b/frontend/src/pages/rules/[rule]/edit-rule.tsx @@ -32,7 +32,11 @@ const EditRulePage = observer(() => { const [tsSupport, setTsSupport] = useState(null); useEffect(() => { - if (!isTypeScript || isLoading) { + // the language service runs for .js rules too (allowJs): completions + // and hover reflect the controller's installed API via GetTypes + // instead of the build-time snapshot; checkJs stays off, so wild ES5 + // gets no type-error noise + if (isLoading) { setTsSupport(null); return undefined; } @@ -43,7 +47,7 @@ const EditRulePage = observer(() => { // Types come from the controller (Editor.GetTypes) so the editor // validates against the installed engine's API; the vendored copy is // only the offline fallback. - const servicePath = params['*'] || 'unsaved.ts'; + const servicePath = params['*'] || (isTypeScript ? 'unsaved.ts' : 'unsaved.js'); // race the controller types against a short deadline: old firmware // without Editor.GetTypes would otherwise stall TS support for the // full 60s RPC timeout before the vendored fallback kicks in @@ -65,7 +69,7 @@ const EditRulePage = observer(() => { }; // rule.content is deliberately not a dependency: it only seeds the // language service; tsSync() tracks all further edits - }, [isTypeScript, ruleFileName, isLoading]); + }, [ruleFileName, isLoading]); useEffect(() => { rulesStore.clearTsCheck(); diff --git a/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts b/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts index 7c97afb47..cc9ffe0cf 100644 --- a/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts +++ b/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts @@ -7,6 +7,12 @@ describe('ts-language-service', () => { expect(typeof support.completionSource).toBe('function'); }); + it('builds support for plain .js rule files too (allowJs completions/hover)', async () => { + const support = await loadTsEditorSupport('legacy.js', 'var n = 1;\n'); + expect(support.extensions.length).toBeGreaterThanOrEqual(4); + expect(typeof support.completionSource).toBe('function'); + }); + it('reuses the environment for the same file and rebuilds for another file', async () => { const first = loadTsEditorSupport('demo.ts', ''); const again = loadTsEditorSupport('demo.ts', ''); From 42b0a56af9572b1214a7bb1b2f983f2b8b7fe619 Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Fri, 14 Aug 2026 21:37:39 +0000 Subject: [PATCH 11/18] rules editor: CI-safe timeout for language-service cold-start tests 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 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- .../stores/rules/autocomplete/ts-language-service.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts b/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts index cc9ffe0cf..120524d47 100644 --- a/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts +++ b/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts @@ -5,13 +5,15 @@ describe('ts-language-service', () => { const support = await loadTsEditorSupport('demo.ts', 'const n: number = 1;\n'); expect(support.extensions.length).toBeGreaterThanOrEqual(4); expect(typeof support.completionSource).toBe('function'); - }); + // 30s: the first build pays the language-service cold start (parsing + // every lib.*.d.ts), which exceeds the 5s default on loaded CI hosts + }, 30000); it('builds support for plain .js rule files too (allowJs completions/hover)', async () => { const support = await loadTsEditorSupport('legacy.js', 'var n = 1;\n'); expect(support.extensions.length).toBeGreaterThanOrEqual(4); expect(typeof support.completionSource).toBe('function'); - }); + }, 30000); it('reuses the environment for the same file and rebuilds for another file', async () => { const first = loadTsEditorSupport('demo.ts', ''); From 33c7a3c9c0123271d82f578aed4727157c0b9d14 Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Fri, 14 Aug 2026 21:54:19 +0000 Subject: [PATCH 12/18] debian: 2.246.0 - TypeScript rules editor (The PR pipeline's version-bump check requires it; also gives the experimental debs a proper feature version.) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- debian/changelog | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/debian/changelog b/debian/changelog index d65691175..2aa53a366 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,20 @@ +wb-mqtt-homeui (2.246.0) stable; urgency=medium + + * Rules editor: TypeScript support. .ts rule files can be created and + edited with an in-browser TypeScript language service: live error + squiggles while typing, hover type info and type-aware completions, + seeded with the controller's installed wb-rules API declarations + (Editor.GetTypes RPC, vendored fallback for older firmware). + * Plain .js rules use the same language service (allowJs): completions + and hover reflect the running engine's API; no type-error noise. + * The controller's own background check verdict (Editor.Check RPC) is + rendered inline at the reported lines, de-duplicated against the + local check and suppressed while the buffer has unsaved edits. + * Requires wb-rules >= 2.47 for the TypeScript engine support; the + editor degrades gracefully on older firmware. + + -- Evgeny Boger Fri, 14 Aug 2026 22:00:00 +0000 + wb-mqtt-homeui (2.245.10) stable; urgency=medium * Fix create first user logic From 0f9a74c39ef13c82e9b2bb9d4e04b8cc3dedb46a Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Sat, 15 Aug 2026 00:39:44 +0000 Subject: [PATCH 13/18] rules editor: fix review findings from the from-scratch PR #1202 review - 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 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- frontend/src/pages/rules/[rule]/edit-rule.tsx | 12 ++++---- .../controller-diagnostics.test.ts | 11 +++++++ .../autocomplete/controller-diagnostics.ts | 6 +++- .../src/stores/rules/autocomplete/enums.ts | 17 ++++++----- .../src/stores/rules/autocomplete/index.ts | 30 +++++++++++++++++-- .../autocomplete/ts-language-service.test.ts | 17 +++++++++++ .../rules/autocomplete/ts-language-service.ts | 5 +++- .../rules/rules-store-typescript.test.ts | 19 ++++++++++++ frontend/src/stores/rules/rules-store.ts | 5 +++- 9 files changed, 104 insertions(+), 18 deletions(-) diff --git a/frontend/src/pages/rules/[rule]/edit-rule.tsx b/frontend/src/pages/rules/[rule]/edit-rule.tsx index d07f4820f..c95dae67d 100644 --- a/frontend/src/pages/rules/[rule]/edit-rule.tsx +++ b/frontend/src/pages/rules/[rule]/edit-rule.tsx @@ -30,6 +30,10 @@ const EditRulePage = observer(() => { const ruleFileName = params['*'] || rule.name || ''; const isTypeScript = ruleFileName.endsWith('.ts'); const [tsSupport, setTsSupport] = useState(null); + // for an unsaved rule the title changes on every keystroke; the service + // is keyed on a stable placeholder path instead, so typing a name does + // not re-run the effect below (a GetTypes RPC + environment rebuild each) + const servicePath = params['*'] || (isTypeScript ? 'unsaved.ts' : 'unsaved.js'); useEffect(() => { // the language service runs for .js rules too (allowJs): completions @@ -41,13 +45,11 @@ const EditRulePage = observer(() => { return undefined; } let alive = true; - // the language service (typescript + lib files, ~1 MB gzip) stays in a - // lazy chunk that .js-only users never download. Unsaved rules use a - // stable placeholder path so title edits don't rebuild the service. + // the language service (typescript + lib files, ~1 MB gzip) stays in + // a lazy chunk, loaded only once a rule editor opens. // Types come from the controller (Editor.GetTypes) so the editor // validates against the installed engine's API; the vendored copy is // only the offline fallback. - const servicePath = params['*'] || (isTypeScript ? 'unsaved.ts' : 'unsaved.js'); // race the controller types against a short deadline: old firmware // without Editor.GetTypes would otherwise stall TS support for the // full 60s RPC timeout before the vendored fallback kicks in @@ -69,7 +71,7 @@ const EditRulePage = observer(() => { }; // rule.content is deliberately not a dependency: it only seeds the // language service; tsSync() tracks all further edits - }, [ruleFileName, isLoading]); + }, [servicePath, isLoading]); useEffect(() => { rulesStore.clearTsCheck(); diff --git a/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts b/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts index 0a70b80a3..6cfb6ad28 100644 --- a/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts +++ b/frontend/src/stores/rules/autocomplete/controller-diagnostics.test.ts @@ -76,3 +76,14 @@ describe('controllerDiagsForDoc', () => { expect(controllerDiagsForDoc(doc, { diags, checkedContent: null })).toHaveLength(0); }); }); + +describe('controllerDiagsForDoc line endings', () => { + it('matches CRLF checked content against the LF-normalized editor document', () => { + // CodeMirror normalizes to \n on ingest; a rule saved with CRLF + // (scp from Windows) must still get its controller verdict rendered + const doc = Text.of(['let n: number = 0;', 'n = \'oops\';']); + const diags = [{ line: 2, column: 1, severity: 'error' as const, message: 'finding' }]; + const verdict = { diags, checkedContent: 'let n: number = 0;\r\nn = \'oops\';' }; + expect(controllerDiagsForDoc(doc, verdict)).toHaveLength(1); + }); +}); diff --git a/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts b/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts index cf41ba1c4..f71d57571 100644 --- a/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts +++ b/frontend/src/stores/rules/autocomplete/controller-diagnostics.ts @@ -53,7 +53,11 @@ export function controllerDiagsForDoc( verdict: ControllerVerdict, localDiags: LocalTsDiag[] = [], ): Diagnostic[] { - if (verdict.checkedContent === null || verdict.checkedContent !== doc.toString()) { + // CodeMirror normalizes line endings on ingest while checkedContent is + // the raw stored file - a CRLF file must not permanently fail the match + // and silently disable the verdict + if (verdict.checkedContent === null + || verdict.checkedContent.replace(/\r\n/g, '\n') !== doc.toString()) { return []; } return controllerDiagsToCm(doc, verdict.diags, localDiags); diff --git a/frontend/src/stores/rules/autocomplete/enums.ts b/frontend/src/stores/rules/autocomplete/enums.ts index 9612ec475..64ec886f6 100644 --- a/frontend/src/stores/rules/autocomplete/enums.ts +++ b/frontend/src/stores/rules/autocomplete/enums.ts @@ -128,15 +128,18 @@ const makeTopicSource = (fnName: string, topics: string[]): CompletionSource => }; export const getEnums = (devicesStore: DevicesStore) => { - const devices = Array.from(devicesStore.devices.keys()); - const topics = devicesStore.topicsWithoutSystem.flatMap((g) => g.options.map((o) => o.value)); + // read the store when a completion is requested, not when the editor + // extensions are built: the extension array is memoized upstream, and + // devices/topics keep arriving over MQTT long after that + const topics = () => devicesStore.topicsWithoutSystem.flatMap((g) => g.options.map((o) => o.value)); + const live = (build: () => CompletionSource): CompletionSource => (context) => build()(context); return [ typeCompletionSource, - makeGetDeviceSource(devices), - makeGetControlSource(devicesStore.devices, topics), - makeDevTopicsSource(topics), - makeTopicSource('publish', topics), - makeTopicSource('trackMqtt', topics), + live(() => makeGetDeviceSource(Array.from(devicesStore.devices.keys()))), + live(() => makeGetControlSource(devicesStore.devices, topics())), + live(() => makeDevTopicsSource(topics())), + live(() => makeTopicSource('publish', topics())), + live(() => makeTopicSource('trackMqtt', topics())), ]; }; diff --git a/frontend/src/stores/rules/autocomplete/index.ts b/frontend/src/stores/rules/autocomplete/index.ts index 61279dd4e..344ec51d6 100644 --- a/frontend/src/stores/rules/autocomplete/index.ts +++ b/frontend/src/stores/rules/autocomplete/index.ts @@ -35,15 +35,39 @@ const staticSource: CompletionSource = (context) => { return { from: word.from, options: staticCompletions, validFor: /^[\w$]*$/ }; }; +// the language service returns plain type-aware entries (label + kind +// only); the static list still contributes its richer snippet templates +// (which replace the plain entry of the same label) and any generated +// signature the service did not surface. Member accesses (obj.foo) stay +// the service's alone - global snippets don't belong in property lists. +const withStaticExtras = (typeAware: CompletionSource): CompletionSource => async (context) => { + const result = await typeAware(context); + if (!result || result.options.length === 0) return null; + if (context.state.sliceDoc(Math.max(0, result.from - 1), result.from) === '.') return result; + const tsLabels = new Set(result.options.map((o) => o.label)); + return { + ...result, + options: [ + ...result.options.filter((o) => !snippetLabels.has(o.label)), + ...snippets, + ...wbRulesGlobals.filter((g) => !snippetLabels.has(g.label) && !tsLabels.has(g.label)), + ], + }; +}; + export const getExtensions = ( devicesStore: DevicesStore, options?: { typescript?: boolean; typeAwareSource?: CompletionSource }, ) => { const autocomplete = mergeSources([ - // the TS language service (when loaded) answers first: its completions - // are type-aware; static sources below cover plain .js files - ...(options?.typeAwareSource ? [options.typeAwareSource] : []), + // device/topic string-argument contexts (dev["...], getDevice(...) + // answer from the live device list first - the language service + // returns non-empty identifier lists in the unquoted variants and + // would shadow them ...getEnums(devicesStore), + // the TS language service (when loaded) answers next with type-aware + // completions; static sources below are the no-service fallback + ...(options?.typeAwareSource ? [withStaticExtras(options.typeAwareSource)] : []), ...methods, staticSource, ]); diff --git a/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts b/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts index 120524d47..84de3f267 100644 --- a/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts +++ b/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts @@ -23,3 +23,20 @@ describe('ts-language-service', () => { expect(other).not.toBe(first); }); }); + +describe('type-aware completion surface', () => { + // regression: valtown's whitelist filter used to hide all ambient globals + it('offers the wb-rules API for an identifier prefix', async () => { + const { CompletionContext } = await import('@codemirror/autocomplete'); + const { EditorState } = await import('@codemirror/state'); + const content = 'var motion = 1;\ndefi'; + const support = await loadTsEditorSupport('probe.ts', content); + const state = EditorState.create({ doc: content, extensions: support.extensions }); + const result = await support.completionSource( + new CompletionContext(state, state.doc.length, false), + ); + const labels = (result?.options ?? []).map((o) => o.label); + expect(labels).toContain('defineRule'); + expect(labels).toContain('defineVirtualDevice'); + }, 30000); +}); diff --git a/frontend/src/stores/rules/autocomplete/ts-language-service.ts b/frontend/src/stores/rules/autocomplete/ts-language-service.ts index 818092c06..1b77ba1fa 100644 --- a/frontend/src/stores/rules/autocomplete/ts-language-service.ts +++ b/frontend/src/stores/rules/autocomplete/ts-language-service.ts @@ -62,7 +62,10 @@ async function build( return { extensions: [ - cmts.tsFacet.of({ env, path }), + // without the flag, valtown's completion filter drops every ambient + // global (sortText "15") not on its hardcoded standard-JS whitelist - + // i.e. the entire wb-rules API from wb-rules.d.ts + cmts.tsFacet.of({ env, path, keepLegacyLimitationForAutocompletionSymbols: false }), cmts.tsSync(), cmts.tsLinter(), cmts.tsHover(), diff --git a/frontend/src/stores/rules/rules-store-typescript.test.ts b/frontend/src/stores/rules/rules-store-typescript.test.ts index 49b25d621..a69b78f36 100644 --- a/frontend/src/stores/rules/rules-store-typescript.test.ts +++ b/frontend/src/stores/rules/rules-store-typescript.test.ts @@ -21,3 +21,22 @@ describe('rules store TypeScript support', () => { expect(store.getValidRuleName('heating')).toBe('heating.js'); }); }); + +const { editorProxyMock } = await import('@/test/mocks/services'); + +describe('rename/save pre-check extension', () => { + + it('checks the .ts path a rename of a .ts rule will target', async () => { + const store = new RulesStore(); + store.rule.initName = 'heating.ts'; + editorProxyMock.List.mockResolvedValue([{ virtualPath: 'bar.ts' }]); + await expect(store.checkIsNameUnique('bar')).rejects.toThrow('file-exists'); + }); + + it('does not flag a same-named .js file when renaming a .ts rule', async () => { + const store = new RulesStore(); + store.rule.initName = 'heating.ts'; + editorProxyMock.List.mockResolvedValue([{ virtualPath: 'bar.js' }]); + await expect(store.checkIsNameUnique('bar')).resolves.toBe(true); + }); +}); diff --git a/frontend/src/stores/rules/rules-store.ts b/frontend/src/stores/rules/rules-store.ts index ae79f58d3..cc430c805 100644 --- a/frontend/src/stores/rules/rules-store.ts +++ b/frontend/src/stores/rules/rules-store.ts @@ -106,7 +106,10 @@ export default class RulesStore { } async checkIsNameUnique(name: string): Promise { - const path = this.getValidRuleName(name); + // test the same path the upcoming save/rename will target: a rename + // keeps the old file's extension, a fresh save defaults to .js + const extension = this.rule?.initName?.endsWith('.ts') ? '.ts' : '.js'; + const path = this.getValidRuleName(name, extension); const list = await this.getList(); if (list.some((rule) => rule.virtualPath === path)) { throw new Error('file-exists'); From 8ba83fffb58731cbe2f0ad6eb67a24b9f2bfed48 Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Sat, 15 Aug 2026 09:56:56 +0000 Subject: [PATCH 14/18] rules editor: async API types - promise-returning spawn/runShellCommand, delay(), nextMqtt() Sync the vendored wb-rules declarations with the engine's new promise-native library: spawn()/runShellCommand() return Promise (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 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- .../rules/autocomplete/globals-generated.ts | 2 ++ .../stores/rules/autocomplete/wb-rules.d.ts | 21 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/frontend/src/stores/rules/autocomplete/globals-generated.ts b/frontend/src/stores/rules/autocomplete/globals-generated.ts index 34342fe49..0f57f2101 100644 --- a/frontend/src/stores/rules/autocomplete/globals-generated.ts +++ b/frontend/src/stores/rules/autocomplete/globals-generated.ts @@ -19,9 +19,11 @@ export const wbRulesGlobals: Completion[] = [ snippetCompletion('format(${1:format})', { label: 'format', type: 'function', detail: 'function format(format: string, ...args: any[]): string;' }), snippetCompletion('publish(${1:topic}, ${2:payload})', { label: 'publish', type: 'function', detail: 'function publish(topic: string, payload: CellValue, qos?:...' }), snippetCompletion('trackMqtt(${1:topic}, ${2:callback})', { label: 'trackMqtt', type: 'function', detail: 'function trackMqtt(topic: string, callback: (message: Mqt...' }), + snippetCompletion('nextMqtt(${1:topic})', { label: 'nextMqtt', type: 'function', detail: 'function nextMqtt(topic: string, timeoutMs?: number): Pro...' }), { label: 'timers', type: 'variable', detail: 'Record' }, snippetCompletion('startTimer(${1:name}, ${2:milliseconds})', { label: 'startTimer', type: 'function', detail: 'function startTimer(name: string, milliseconds: number): ...' }), snippetCompletion('startTicker(${1:name}, ${2:milliseconds})', { label: 'startTicker', type: 'function', detail: 'function startTicker(name: string, milliseconds: number):...' }), + snippetCompletion('delay(${1:milliseconds})', { label: 'delay', type: 'function', detail: 'function delay(milliseconds: number): Promise;' }), snippetCompletion('setTimeout(${1:callback}, ${2:milliseconds})', { label: 'setTimeout', type: 'function', detail: 'function setTimeout(callback: () => void, milliseconds: n...' }), snippetCompletion('setInterval(${1:callback}, ${2:milliseconds})', { label: 'setInterval', type: 'function', detail: 'function setInterval(callback: () => void, milliseconds: ...' }), snippetCompletion('clearTimeout(${1:id})', { label: 'clearTimeout', type: 'function', detail: 'function clearTimeout(id: number): void;' }), diff --git a/frontend/src/stores/rules/autocomplete/wb-rules.d.ts b/frontend/src/stores/rules/autocomplete/wb-rules.d.ts index b99493e92..e9b58f188 100644 --- a/frontend/src/stores/rules/autocomplete/wb-rules.d.ts +++ b/frontend/src/stores/rules/autocomplete/wb-rules.d.ts @@ -129,9 +129,14 @@ declare function format(format: string, ...args: any[]): string; declare function publish(topic: string, payload: CellValue, qos?: 0 | 1 | 2, retain?: boolean): void; interface MqttMessage { - topic: string; value: string; + topic: string; value: string; retained: boolean; qos: number; } declare function trackMqtt(topic: string, callback: (message: MqttMessage) => void): void; +/** + * Resolves with the next live (non-retained) MQTT message on the topic. + * With timeoutMs set, rejects if no message arrives in time. + */ +declare function nextMqtt(topic: string, timeoutMs?: number): Promise; interface Timer { readonly firing: boolean; @@ -141,6 +146,8 @@ declare const timers: Record; declare function startTimer(name: string, milliseconds: number): void; declare function startTicker(name: string, milliseconds: number): void; +/** Promise-returning pause: await delay(1000). The engine is not blocked. */ +declare function delay(milliseconds: number): Promise; declare function setTimeout(callback: () => void, milliseconds: number): number; declare function setInterval(callback: () => void, milliseconds: number): number; declare function clearTimeout(id: number): void; @@ -152,8 +159,16 @@ interface ShellCommandOptions { input?: string; exitCallback?: (exitCode: number, capturedOutput?: string, capturedErrorOutput?: string) => void; } -declare function runShellCommand(command: string, options?: ShellCommandOptions): void; -declare function spawn(command: string, args: string[], options?: ShellCommandOptions): void; +interface SpawnResult { + /** Process exit code; a nonzero exit resolves the promise, it does not reject. */ + exitCode: number; + capturedOutput: string | null; + capturedErrorOutput?: string; +} +/** Resolves on process exit; rejects only when the process cannot start. */ +declare function runShellCommand(command: string, options?: ShellCommandOptions): Promise; +/** Resolves on process exit; rejects only when the process cannot start. */ +declare function spawn(command: string, args: string[], options?: ShellCommandOptions): Promise; declare function readConfig(path: string): any; From b5ad707d51ce9c2ac66ad8e5d065036be1db0a37 Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Sat, 15 Aug 2026 10:33:30 +0000 Subject: [PATCH 15/18] rules editor: rename delay() to sleep() in wb-rules declarations Matches the engine-side rename; completions regenerated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- frontend/src/stores/rules/autocomplete/globals-generated.ts | 2 +- frontend/src/stores/rules/autocomplete/wb-rules.d.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/stores/rules/autocomplete/globals-generated.ts b/frontend/src/stores/rules/autocomplete/globals-generated.ts index 0f57f2101..3d72a328f 100644 --- a/frontend/src/stores/rules/autocomplete/globals-generated.ts +++ b/frontend/src/stores/rules/autocomplete/globals-generated.ts @@ -23,7 +23,7 @@ export const wbRulesGlobals: Completion[] = [ { label: 'timers', type: 'variable', detail: 'Record' }, snippetCompletion('startTimer(${1:name}, ${2:milliseconds})', { label: 'startTimer', type: 'function', detail: 'function startTimer(name: string, milliseconds: number): ...' }), snippetCompletion('startTicker(${1:name}, ${2:milliseconds})', { label: 'startTicker', type: 'function', detail: 'function startTicker(name: string, milliseconds: number):...' }), - snippetCompletion('delay(${1:milliseconds})', { label: 'delay', type: 'function', detail: 'function delay(milliseconds: number): Promise;' }), + snippetCompletion('sleep(${1:milliseconds})', { label: 'sleep', type: 'function', detail: 'function sleep(milliseconds: number): Promise;' }), snippetCompletion('setTimeout(${1:callback}, ${2:milliseconds})', { label: 'setTimeout', type: 'function', detail: 'function setTimeout(callback: () => void, milliseconds: n...' }), snippetCompletion('setInterval(${1:callback}, ${2:milliseconds})', { label: 'setInterval', type: 'function', detail: 'function setInterval(callback: () => void, milliseconds: ...' }), snippetCompletion('clearTimeout(${1:id})', { label: 'clearTimeout', type: 'function', detail: 'function clearTimeout(id: number): void;' }), diff --git a/frontend/src/stores/rules/autocomplete/wb-rules.d.ts b/frontend/src/stores/rules/autocomplete/wb-rules.d.ts index e9b58f188..de32308bc 100644 --- a/frontend/src/stores/rules/autocomplete/wb-rules.d.ts +++ b/frontend/src/stores/rules/autocomplete/wb-rules.d.ts @@ -146,8 +146,8 @@ declare const timers: Record; declare function startTimer(name: string, milliseconds: number): void; declare function startTicker(name: string, milliseconds: number): void; -/** Promise-returning pause: await delay(1000). The engine is not blocked. */ -declare function delay(milliseconds: number): Promise; +/** Promise-returning pause: await sleep(1000). The engine is not blocked. */ +declare function sleep(milliseconds: number): Promise; declare function setTimeout(callback: () => void, milliseconds: number): number; declare function setInterval(callback: () => void, milliseconds: number): number; declare function clearTimeout(id: number): void; From 5f6b292ccedb03c12176ce99f4cd26ac2fcf4190 Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Sat, 15 Aug 2026 10:54:53 +0000 Subject: [PATCH 16/18] rules editor: declare changed() - awaitable whenChanged Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- frontend/src/stores/rules/autocomplete/globals-generated.ts | 1 + frontend/src/stores/rules/autocomplete/wb-rules.d.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/frontend/src/stores/rules/autocomplete/globals-generated.ts b/frontend/src/stores/rules/autocomplete/globals-generated.ts index 3d72a328f..4e7a4a753 100644 --- a/frontend/src/stores/rules/autocomplete/globals-generated.ts +++ b/frontend/src/stores/rules/autocomplete/globals-generated.ts @@ -23,6 +23,7 @@ export const wbRulesGlobals: Completion[] = [ { label: 'timers', type: 'variable', detail: 'Record' }, snippetCompletion('startTimer(${1:name}, ${2:milliseconds})', { label: 'startTimer', type: 'function', detail: 'function startTimer(name: string, milliseconds: number): ...' }), snippetCompletion('startTicker(${1:name}, ${2:milliseconds})', { label: 'startTicker', type: 'function', detail: 'function startTicker(name: string, milliseconds: number):...' }), + snippetCompletion('changed(${1:ctrl})', { label: 'changed', type: 'function', detail: 'function changed(ctrl: string, timeoutMs?: number): Promi...' }), snippetCompletion('sleep(${1:milliseconds})', { label: 'sleep', type: 'function', detail: 'function sleep(milliseconds: number): Promise;' }), snippetCompletion('setTimeout(${1:callback}, ${2:milliseconds})', { label: 'setTimeout', type: 'function', detail: 'function setTimeout(callback: () => void, milliseconds: n...' }), snippetCompletion('setInterval(${1:callback}, ${2:milliseconds})', { label: 'setInterval', type: 'function', detail: 'function setInterval(callback: () => void, milliseconds: ...' }), diff --git a/frontend/src/stores/rules/autocomplete/wb-rules.d.ts b/frontend/src/stores/rules/autocomplete/wb-rules.d.ts index de32308bc..61f69b2cd 100644 --- a/frontend/src/stores/rules/autocomplete/wb-rules.d.ts +++ b/frontend/src/stores/rules/autocomplete/wb-rules.d.ts @@ -146,6 +146,12 @@ declare const timers: Record; declare function startTimer(name: string, milliseconds: number): void; declare function startTicker(name: string, milliseconds: number): void; +/** + * Resolves with the control's new value on its next change - the same + * semantics (triggers, value conversion) as a rule's whenChanged. + * With timeoutMs set, rejects if nothing changes in time. + */ +declare function changed(ctrl: string, timeoutMs?: number): Promise; /** Promise-returning pause: await sleep(1000). The engine is not blocked. */ declare function sleep(milliseconds: number): Promise; declare function setTimeout(callback: () => void, milliseconds: number): number; From 1a2e8e9c3dfa625444dde3f75f2b7470523312b7 Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Sat, 15 Aug 2026 13:57:11 +0000 Subject: [PATCH 17/18] debian: pair the TS rules editor with the TS-capable wb-rules 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 Claude-Session: https://claude.ai/code/session_01AoTGMFABCCENSPaHThxAru --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index 74aebaf58..180e7dbad 100644 --- a/debian/control +++ b/debian/control @@ -31,7 +31,7 @@ Depends: ${shlibs:Depends}, wb-homeui-backend (= ${binary:Version}) Recommends: wb-mqtt-logs (>= 1.2.0), wb-device-manager, - wb-rules (>= 2.37.0~~) + wb-rules (>= 2.47.0~quickjs3~~) Suggests: wb-mqtt-confed (>= 1.4.0), Breaks: wb-mqtt-confed (<< 1.0.3), wb-mqtt-db (<< 1.5), From 22342208faee4979b812f99a7119fab68a74f966 Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Sun, 16 Aug 2026 09:15:26 +0000 Subject: [PATCH 18/18] rules editor: sync the typed wb-rules API declarations 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 --- .../rules/autocomplete/globals-generated.ts | 24 +- .../autocomplete/ts-language-service.test.ts | 31 + .../stores/rules/autocomplete/wb-rules.d.ts | 651 ++++++++++++++++-- 3 files changed, 639 insertions(+), 67 deletions(-) diff --git a/frontend/src/stores/rules/autocomplete/globals-generated.ts b/frontend/src/stores/rules/autocomplete/globals-generated.ts index 4e7a4a753..c51f0278d 100644 --- a/frontend/src/stores/rules/autocomplete/globals-generated.ts +++ b/frontend/src/stores/rules/autocomplete/globals-generated.ts @@ -3,17 +3,17 @@ import { snippetCompletion, type Completion } from '@codemirror/autocomplete'; export const wbRulesGlobals: Completion[] = [ - snippetCompletion('defineVirtualDevice(${1:name}, ${2:spec})', { label: 'defineVirtualDevice', type: 'function', detail: 'function defineVirtualDevice(name: string, spec: VirtualD...' }), + snippetCompletion('defineVirtualDevice(${1:name}, ${2:spec})', { label: 'defineVirtualDevice', type: 'function', detail: 'function defineVirtualDevice...' }), + snippetCompletion('getDevice(${1:id})', { label: 'getDevice', type: 'function', detail: 'function getDevice(id: string): VirtualDevice | undefined;' }), + snippetCompletion('getControl(${1:ref})', { label: 'getControl', type: 'function', detail: 'function getControl(ref: string): VirtualDeviceControl | ...' }), snippetCompletion('cron(${1:spec})', { label: 'cron', type: 'function', detail: 'function cron(spec: string): CronEntry;' }), - snippetCompletion('defineRule(${1:name}, ${2:spec})', { label: 'defineRule', type: 'function', detail: 'function defineRule(name: string, spec: RuleSpec): void;' }), + snippetCompletion('defineRule(${1:name}, ${2:spec})', { label: 'defineRule', type: 'function', detail: 'function defineRule(name: string, spec: RuleSpec): RuleId;' }), snippetCompletion('defineAlias(${1:aliasName}, ${2:cellRef})', { label: 'defineAlias', type: 'function', detail: 'function defineAlias(aliasName: string, cellRef: string):...' }), - snippetCompletion('enableRule(${1:name})', { label: 'enableRule', type: 'function', detail: 'function enableRule(name: string): void;' }), - snippetCompletion('disableRule(${1:name})', { label: 'disableRule', type: 'function', detail: 'function disableRule(name: string): void;' }), - snippetCompletion('runRule(${1:name})', { label: 'runRule', type: 'function', detail: 'function runRule(name: string): void;' }), + snippetCompletion('enableRule(${1:ruleId})', { label: 'enableRule', type: 'function', detail: 'function enableRule(ruleId: RuleId): void;' }), + snippetCompletion('disableRule(${1:ruleId})', { label: 'disableRule', type: 'function', detail: 'function disableRule(ruleId: RuleId): void;' }), + snippetCompletion('runRule(${1:ruleId})', { label: 'runRule', type: 'function', detail: 'function runRule(ruleId: RuleId): void;' }), { label: 'runRules', type: 'function', detail: 'function runRules(): void;', apply: 'runRules()' }, { label: 'dev', type: 'variable', detail: '{ [deviceOrRef: string]: { [control: string]: any; } & an...' }, - snippetCompletion('getDevice(${1:id})', { label: 'getDevice', type: 'function', detail: 'function getDevice(id: string): VirtualDevice;' }), - snippetCompletion('getControl(${1:ref})', { label: 'getControl', type: 'function', detail: 'function getControl(ref: string): VirtualDeviceControl;' }), { label: 'log', type: 'variable', detail: 'LogFunction' }, snippetCompletion('debug(${1:format})', { label: 'debug', type: 'function', detail: 'function debug(format: string, ...args: any[]): void;' }), snippetCompletion('format(${1:format})', { label: 'format', type: 'function', detail: 'function format(format: string, ...args: any[]): string;' }), @@ -23,7 +23,7 @@ export const wbRulesGlobals: Completion[] = [ { label: 'timers', type: 'variable', detail: 'Record' }, snippetCompletion('startTimer(${1:name}, ${2:milliseconds})', { label: 'startTimer', type: 'function', detail: 'function startTimer(name: string, milliseconds: number): ...' }), snippetCompletion('startTicker(${1:name}, ${2:milliseconds})', { label: 'startTicker', type: 'function', detail: 'function startTicker(name: string, milliseconds: number):...' }), - snippetCompletion('changed(${1:ctrl})', { label: 'changed', type: 'function', detail: 'function changed(ctrl: string, timeoutMs?: number): Promi...' }), + snippetCompletion('changed(${1:ctrl})', { label: 'changed', type: 'function', detail: 'function changed(ctrl: string,...' }), snippetCompletion('sleep(${1:milliseconds})', { label: 'sleep', type: 'function', detail: 'function sleep(milliseconds: number): Promise;' }), snippetCompletion('setTimeout(${1:callback}, ${2:milliseconds})', { label: 'setTimeout', type: 'function', detail: 'function setTimeout(callback: () => void, milliseconds: n...' }), snippetCompletion('setInterval(${1:callback}, ${2:milliseconds})', { label: 'setInterval', type: 'function', detail: 'function setInterval(callback: () => void, milliseconds: ...' }), @@ -31,10 +31,14 @@ export const wbRulesGlobals: Completion[] = [ snippetCompletion('clearInterval(${1:id})', { label: 'clearInterval', type: 'function', detail: 'function clearInterval(id: number): void;' }), snippetCompletion('runShellCommand(${1:command})', { label: 'runShellCommand', type: 'function', detail: 'function runShellCommand(command: string, options?: Shell...' }), snippetCompletion('spawn(${1:command}, ${2:args})', { label: 'spawn', type: 'function', detail: 'function spawn(command: string, args: string[], options?:...' }), - snippetCompletion('readConfig(${1:path})', { label: 'readConfig', type: 'function', detail: 'function readConfig(path: string): any;' }), - snippetCompletion('PersistentStorage(${1:name})', { label: 'PersistentStorage', type: 'function', detail: 'function PersistentStorage(name: string, options?: Persis...' }), + snippetCompletion('readConfig(${1:path})', { label: 'readConfig', type: 'function', detail: 'function readConfig(path: string, options?: ReadConfigOpt...' }), + snippetCompletion('PersistentStorage(${1:name})', { label: 'PersistentStorage', type: 'function', detail: 'function PersistentStorage ...' }), snippetCompletion('StorableObject(${1:obj})', { label: 'StorableObject', type: 'function', detail: 'function StorableObject(obj: T): T;' }), + { label: 'Notify', type: 'variable', detail: 'NotifyApi' }, + { label: 'Alarms', type: 'variable', detail: 'AlarmsApi' }, + { label: '__filename', type: 'variable', detail: 'string' }, { label: 'module', type: 'variable', detail: '{ readonly filename: string; readonly static: Record' }, ]; diff --git a/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts b/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts index 84de3f267..c98ff0fcf 100644 --- a/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts +++ b/frontend/src/stores/rules/autocomplete/ts-language-service.test.ts @@ -24,6 +24,37 @@ describe('ts-language-service', () => { }); }); +describe('typed wb-rules API surface', () => { + // the promise-native idiom must check clean: changed() defaults to any + it('accepts awaited changed() values in arithmetic without complaints', async () => { + const content = [ + 'async function scenario() {', + ' let value = await changed("ts_demo/temperature");', + ' log(`got ${value}`);', + ' dev["ts_demo/new_temperature"] = value + 1;', + '}', + 'scenario();', + '', + ].join('\n'); + const support = await loadTsEditorSupport('changed-flow.ts', content); + expect(support.getDiagnostics()).toEqual([]); + }, 30000); + + it('rejects options illegal for the control type and rule names as rule ids', async () => { + const content = [ + 'defineVirtualDevice("d", {', + ' cells: { sw: { type: "switch", value: false, min: 0 } },', + '});', + 'disableRule("named-rule");', + '', + ].join('\n'); + const support = await loadTsEditorSupport('typed-errors.ts', content); + const diags = support.getDiagnostics(); + expect(diags.some((d) => d.line === 2 && d.message.includes('\'min\''))).toBe(true); + expect(diags.some((d) => d.line === 4)).toBe(true); + }, 30000); +}); + describe('type-aware completion surface', () => { // regression: valtown's whitelist filter used to hide all ambient globals it('offers the wb-rules API for an identifier prefix', async () => { diff --git a/frontend/src/stores/rules/autocomplete/wb-rules.d.ts b/frontend/src/stores/rules/autocomplete/wb-rules.d.ts index 61f69b2cd..015f4c224 100644 --- a/frontend/src/stores/rules/autocomplete/wb-rules.d.ts +++ b/frontend/src/stores/rules/autocomplete/wb-rules.d.ts @@ -5,132 +5,485 @@ // file so rule scripts see the builtins as typed globals; // - the homeui rules editor loads it to provide typed completions. // -// The API itself is defined by scripts/lib.js and the engine's DefineFunctions. - -declare type CellType = - | 'switch' | 'wo-switch' | 'alarm' | 'pushbutton' - | 'value' | 'temperature' | 'rel_humidity' | 'atmospheric_pressure' - | 'rainfall' | 'wind_speed' | 'power' | 'power_consumption' - | 'voltage' | 'water_flow' | 'water_consumption' | 'resistance' - | 'concentration' | 'heat_power' | 'heat_energy' | 'current' - | 'pressure' | 'range' | 'text' | 'rgb'; - -declare type CellValue = string | number | boolean; - -interface CellSpec { - type: CellType; - value?: CellValue; - title?: string | Record; +// The API itself is defined by scripts/lib.js, modules/wb-notify.js, +// modules/wb-alarms.js and the engine's DefineFunctions. Everything here is +// grounded in that code: option fields the engine rejects are typed away, +// per-control-type option sets match fillControlArgs exactly, and the +// rule-management functions take the numeric rule id defineRule returns. +// +// Design notes (techniques adapted from the public-domain wb-mirta/core +// `@mirta/globals` package): a single TypeMappings interface derives the +// control-type union and per-type value types; ControlOptions is a +// discriminated union so illegal option/type combinations fail to compile; +// rule ids are a branded number so arbitrary numbers (or rule names) are +// rejected by enableRule/disableRule/runRule. + +// --------------------------------------------------------------------------- +// Utility types +// --------------------------------------------------------------------------- + +/** Flattens an intersection so editor hovers show a single object literal. */ +declare type WbExpand = { [K in keyof T]: T[K] } & {}; + +/** Like Partial, but at least one of the properties must be present. */ +declare type WbAtLeastOne }> = Partial & U[keyof U]; + +/** + * Nominal (branded) alias of a primitive type. Purely a compile-time + * marker: values are plain primitives at runtime. + */ +declare type WbBranded = TValue & { readonly __wbBrand: TBrand }; + +/** Localized text: language code ("en", "ru", ...) to translation. */ +declare type LocalizedText = Record; + +/** A human-readable title: a plain string ("en") or per-language texts. */ +declare type Title = string | LocalizedText; + +// --------------------------------------------------------------------------- +// Controls: the type map everything else derives from +// --------------------------------------------------------------------------- + +/** + * Maps every control (cell) type to the JavaScript type its value has when + * read through `dev`, a rule's `newValue`, or a control object's getValue(). + * + * Derived from the Wiren Board MQTT conventions; extend here and every + * dependent type (CellType, ControlOptions, typed controls) follows. + */ +interface TypeMappings { + /** Boolean switch (writable by default). */ + switch: boolean; + /** Write-only switch: command out, no state readback. */ + "wo-switch": boolean; + /** Alarm indicator. */ + alarm: boolean; + /** Stateless push button; the only type that needs no initial `value`. */ + pushbutton: boolean; + + /** Arbitrary text. */ + text: string; + /** Color in "R;G;B" form, e.g. "255;127;0". */ + rgb: string; + + /** Generic numeric value. */ + value: number; + /** Integer slider between min and max (writable by default). */ + range: number; + /** Unix timestamp, seconds. */ + unixtime: number; + /** Temperature, °C. */ + temperature: number; + /** Relative humidity, %. */ + rel_humidity: number; + /** Atmospheric pressure, mbar. */ + atmospheric_pressure: number; + /** Rainfall rate, mm/h. */ + rainfall: number; + /** Wind speed, m/s. */ + wind_speed: number; + /** Power, W. */ + power: number; + /** Energy, kWh. */ + power_consumption: number; + /** Voltage, V. */ + voltage: number; + /** Water flow, m³/h. */ + water_flow: number; + /** Water volume, m³. */ + water_consumption: number; + /** Resistance, Ohm. */ + resistance: number; + /** Gas concentration, ppm. */ + concentration: number; + /** Pressure, bar. */ + pressure: number; + /** Illuminance, lux. */ + lux: number; + /** Sound level, dB. */ + sound_level: number; + /** Heat power, Gcal/h. */ + heat_power: number; + /** Heat energy, Gcal. */ + heat_energy: number; + /** Current, A. */ + current: number; +} + +/** Union of all control (cell) types. */ +declare type CellType = keyof TypeMappings; + +/** Any control value. */ +declare type CellValue = TypeMappings[CellType]; + +/** Value type of a specific control type: CellValueOf<"switch"> is boolean. */ +declare type CellValueOf = TypeMappings[T]; + +// --------------------------------------------------------------------------- +// Control options (defineVirtualDevice cells) +// --------------------------------------------------------------------------- + +/** Options every control type accepts. */ +interface WbControlOptionsBase { + /** Control type; decides the value type and which other options are legal. */ + type: TType; + /** Title shown in the UI (plain string = English, or per-language map). */ + title?: Title; + /** Longer description shown in the UI. */ + description?: string; + /** + * Forbid writes from the UI and rules. Defaults: switch, pushbutton, + * range and rgb are writable; every other type is read-only. + */ readonly?: boolean; - writeable?: boolean; - min?: number; - max?: number; - precision?: number; - units?: string; + /** Position among the device's controls (integer, >= 0). */ order?: number; - enum?: Record>; + /** + * Do not create the MQTT control until a value is first assigned + * (e.g. `dev["device/control"] = value`). + */ lazyInit?: boolean; + /** + * Reset to `value` on every engine start instead of restoring the last + * retained value. + */ forceDefault?: boolean; + /** @deprecated The engine rejects this flag - use `readonly` instead. */ + writeable?: never; } -interface VirtualDeviceSpec { - title?: string | Record; - cells: Record; -} +/** + * The initial value. Required for every type except pushbutton + * (a pushbutton is stateless). + */ +type __WbControlValue = TType extends "pushbutton" + ? { value?: TypeMappings[TType] } + : { value: TypeMappings[TType] }; -interface VirtualDeviceControl { +/** + * Per-type extra options, matching what the engine actually reads: + * units only on "value"; precision on "value" and "range"; enum titles on + * "value" and "text"; min/max on "value" and "range". + * + * Options a type does not support are declared as `?: never` instead of + * being omitted: providing one then fails real assignability, so the error + * fires even where object-literal freshness checks do not reach (e.g. + * through generic parameter inference in defineVirtualDevice). + */ +type __WbControlExtras = TType extends "value" + ? { + /** Unit shown next to the value (e.g. "W", "m³/h"). */ + units?: string; + /** Number of decimal places shown. */ + precision?: number; + /** + * Titles for the allowed values. Note: each title must be a + * per-language map - the engine silently drops plain strings. + */ + enum?: Record; + /** Smallest accepted value. */ + min?: number; + /** Largest accepted value. */ + max?: number; + } + : TType extends "range" + ? { + /** Number of decimal places shown. */ + precision?: number; + /** Smallest accepted value (default 0). */ + min?: number; + /** Largest accepted value (default 255). */ + max?: number; + units?: never; + enum?: never; + } + : TType extends "text" + ? { + /** + * Titles for the allowed values. Note: each title must be a + * per-language map - the engine silently drops plain strings. + */ + enum?: Record; + units?: never; + precision?: never; + min?: never; + max?: never; + } + : { + units?: never; + precision?: never; + enum?: never; + min?: never; + max?: never; + }; + +/** + * A control declaration for defineVirtualDevice()/addControl(). + * + * A discriminated union over `type`: options illegal for the chosen type do + * not compile, e.g. `{ type: "switch", min: 0 }` is an error because only + * "value" and "range" controls have `min`. + */ +declare type ControlOptions = { + [K in CellType]: WbExpand< + WbControlOptionsBase & __WbControlValue & __WbControlExtras + >; +}[CellType]; + +/** Options of one specific control type: ControlOptionsOfType<"range">. */ +declare type ControlOptionsOfType = Extract; + +declare type SwitchControlOptions = ControlOptionsOfType<"switch">; +declare type PushbuttonControlOptions = ControlOptionsOfType<"pushbutton">; +declare type AlarmControlOptions = ControlOptionsOfType<"alarm">; +declare type ValueControlOptions = ControlOptionsOfType<"value">; +declare type RangeControlOptions = ControlOptionsOfType<"range">; +declare type TextControlOptions = ControlOptionsOfType<"text">; +declare type RgbControlOptions = ControlOptionsOfType<"rgb">; + +/** @deprecated Old name; use ControlOptions. */ +declare type CellSpec = ControlOptions; + +// --------------------------------------------------------------------------- +// Virtual devices +// --------------------------------------------------------------------------- + +/** The controls of a device declaration, by control name. */ +declare type ControlsSpec = Record; + +/** Device declaration: a title plus controls under `cells` or `controls`. */ +declare type VirtualDeviceSpec = + | { title?: Title; cells: ControlsSpec } + | { title?: Title; controls: ControlsSpec }; + +/** + * A control of a virtual device, as returned by getControl(). + * + * The type parameter tracks the control's declared type, so getValue() on a + * control obtained from a typed device returns boolean/number/string as + * declared instead of the full union. + */ +interface VirtualDeviceControl { getId(): string; - getValue(): CellValue; - setValue(value: CellValue | { value: CellValue; notify?: boolean }): void; + getValue(): TypeMappings[TType]; + setValue(value: TypeMappings[TType] | { value: TypeMappings[TType]; notify?: boolean }): void; + /** Error state: a non-empty string marks the control as failed in the UI. */ getError(): string; setError(error: string): void; - getType(): string; + getType(): TType; + setType(type: CellType): void; getDescription(): string; setDescription(description: string): void; - getTitle(): string; - setTitle(title: string | Record): void; + /** Title in the given language ("en" when omitted). */ + getTitle(lang?: string): string; + setTitle(title: Title): void; getReadonly(): boolean; setReadonly(readonly: boolean): void; getMax(): number; setMax(max: number): void; getMin(): number; setMin(min: number): void; + getPrecision(): number; + setPrecision(precision: number): void; getUnits(): string; setUnits(units: string): void; getOrder(): number; setOrder(order: number): void; - getEnumTitles(): Record; - setEnumTitles(titles: Record): void; + /** Value titles; each title is a per-language map. */ + setEnumTitles(titles: Record): void; } -interface VirtualDevice { +/** Cells record of a device spec (whether declared as `cells` or `controls`). */ +type __WbCellsOf = S extends { cells: infer C extends ControlsSpec } + ? C + : S extends { controls: infer C extends ControlsSpec } + ? C + : ControlsSpec; + +/** + * A virtual device. When obtained from defineVirtualDevice(), getControl() + * with a literal control name returns a control typed by that control's + * declared type. + */ +interface VirtualDevice { getId(): string; + /** Full "device/control" reference of the named control. */ getCellId(cellName: string): string; - addControl(name: string, spec: CellSpec): void; + addControl(name: string, spec: ControlOptions): void; removeControl(name: string): void; - getControl(name: string): VirtualDeviceControl; + getControl( + name: K + ): K extends keyof TCells ? VirtualDeviceControl : VirtualDeviceControl; isControlExists(name: string): boolean; controlsList(): VirtualDeviceControl[]; isVirtual(): boolean; + /** Error state: a non-empty string marks the whole device as failed. */ + getError(): string; + setError(error: string): void; } -declare function defineVirtualDevice(name: string, spec: VirtualDeviceSpec): VirtualDevice; +/** + * Creates a virtual device backed by MQTT. + * + * The returned device is typed by the declaration: + * + * ```ts + * const dv = defineVirtualDevice("climate", { + * title: "Climate", + * cells: { + * temperature: { type: "temperature", value: 0 }, + * enabled: { type: "switch", value: false }, + * }, + * }); + * const t = dv.getControl("temperature").getValue(); // number + * ``` + */ +declare function defineVirtualDevice( + name: string, + spec: S +): VirtualDevice<__WbCellsOf>; + +/** The device with the given id, or undefined if there is no such device. */ +declare function getDevice(id: string): VirtualDevice | undefined; + +/** The control at "device/control", or undefined if it does not exist. */ +declare function getControl(ref: string): VirtualDeviceControl | undefined; + +// --------------------------------------------------------------------------- +// Rules +// --------------------------------------------------------------------------- + +/** + * Identifier of a rule, returned by defineRule(). + * + * A branded number: enableRule/disableRule/runRule accept only a value that + * came from defineRule, so passing a rule name or an arbitrary number is a + * compile-time error (and would fail at runtime). + */ +declare type RuleId = WbBranded; interface CronEntry { readonly spec: string; } + +/** + * A cron schedule for a rule's `when`, e.g. cron("@hourly") or + * cron("0 0 9 * * MON-FRI"). See the robfig/cron expression format. + */ declare function cron(spec: string): CronEntry; type RuleCondition = () => unknown; interface RuleSpec { - /** cell refs ("device/control"), alias names, or condition functions */ + /** + * Fire on control changes: a "device/control" reference, an alias name, + * a condition function whose return value is watched, or an array of + * those. + */ whenChanged?: string | RuleCondition | Array; + /** Fire whenever the condition is true, or on a cron() schedule. */ when?: RuleCondition | CronEntry; + /** Fire once each time the condition switches from false to true. */ asSoonAs?: RuleCondition; _cron?: string; - then: (newValue?: any, devName?: string, cellName?: string) => void; + /** + * The rule body. For whenChanged rules the arguments are the new value + * and the "device", "control" pair that caused the trigger; for other + * kinds they are undefined. May be async: rejections are reported to the + * rule engine log. + */ + then: (newValue?: any, devName?: string, cellName?: string) => void | Promise; readonly?: boolean; } -declare function defineRule(name: string, spec: RuleSpec): void; -declare function defineRule(spec: RuleSpec): void; +/** + * Defines a rule and returns its id. + * + * ```ts + * const nightLight = defineRule("night-light", { + * whenChanged: "motion/detected", + * then: (v) => { dev["light/on"] = !!v; }, + * }); + * disableRule(nightLight); + * ``` + */ +declare function defineRule(name: string, spec: RuleSpec): RuleId; +declare function defineRule(spec: RuleSpec): RuleId; +/** Makes `aliasName` usable in place of "device/control" references. */ declare function defineAlias(aliasName: string, cellRef: string): void; -declare function enableRule(name: string): void; -declare function disableRule(name: string): void; -declare function runRule(name: string): void; +/** Re-enables a rule disabled with disableRule(). */ +declare function enableRule(ruleId: RuleId): void; +/** Disables a rule: it stops reacting to events until enableRule(). */ +declare function disableRule(ruleId: RuleId): void; +/** + * Runs a rule's `then` immediately, with no trigger context (newValue and + * the device/control arguments are undefined). + */ +declare function runRule(ruleId: RuleId): void; declare function runRules(): void; +// --------------------------------------------------------------------------- +// Device access +// --------------------------------------------------------------------------- + /** - * Device/cell access proxy: dev["device"]["control"], dev["device/control"], - * or dev.device.control. Append "#meta" (e.g. "device/control#type") to read - * control metadata. + * Device/control access proxy. + * + * Values: `dev["device/control"]`, `dev["device"]["control"]` or + * `dev.device.control` reads the current value; assignment writes it. + * + * Metadata: append `#` to the control name to read or write control + * metadata, e.g. `dev["device/control#error"] = "sensor offline"` or + * `const t = dev["device/control#type"]`. */ declare const dev: { [deviceOrRef: string]: { [control: string]: any } & any; }; -declare function getDevice(id: string): VirtualDevice; -declare function getControl(ref: string): VirtualDeviceControl; +// --------------------------------------------------------------------------- +// Logging +// --------------------------------------------------------------------------- interface LogFunction { + /** Logs a message; `{}` placeholders are replaced by the arguments. */ (format: string, ...args: any[]): void; + /** Logs an arbitrary value. */ + (value: unknown): void; debug(format: string, ...args: any[]): void; + debug(value: unknown): void; info(format: string, ...args: any[]): void; + info(value: unknown): void; warning(format: string, ...args: any[]): void; + warning(value: unknown): void; error(format: string, ...args: any[]): void; + error(value: unknown): void; } +/** Engine log; log(...) is the same as log.info(...). */ declare const log: LogFunction; +/** Logs only when rule debugging is enabled. */ declare function debug(format: string, ...args: any[]): void; +/** Replaces `{}` placeholders in the format string with the arguments. */ declare function format(format: string, ...args: any[]): string; +// --------------------------------------------------------------------------- +// MQTT +// --------------------------------------------------------------------------- + +/** + * Publishes a raw MQTT message. Do not use this to change device controls - + * assign through `dev` instead. + */ declare function publish(topic: string, payload: CellValue, qos?: 0 | 1 | 2, retain?: boolean): void; interface MqttMessage { - topic: string; value: string; retained: boolean; qos: number; + topic: string; + value: string; + retained: boolean; + qos: number; } +/** Subscribes to an MQTT topic ("#" and "+" wildcards are allowed). */ declare function trackMqtt(topic: string, callback: (message: MqttMessage) => void): void; /** * Resolves with the next live (non-retained) MQTT message on the topic. @@ -138,20 +491,30 @@ declare function trackMqtt(topic: string, callback: (message: MqttMessage) => vo */ declare function nextMqtt(topic: string, timeoutMs?: number): Promise; +// --------------------------------------------------------------------------- +// Timers +// --------------------------------------------------------------------------- + interface Timer { readonly firing: boolean; stop(): void; } +/** Named timers started with startTimer()/startTicker(). */ declare const timers: Record; +/** One-shot named timer; watch it with `when: () => timers.name.firing`. */ declare function startTimer(name: string, milliseconds: number): void; +/** Periodic named timer. */ declare function startTicker(name: string, milliseconds: number): void; /** * Resolves with the control's new value on its next change - the same * semantics (triggers, value conversion) as a rule's whenChanged. * With timeoutMs set, rejects if nothing changes in time. + * + * The result type defaults to `any`; pass a type argument to pin it: + * `const t = await changed("climate/temperature")`. */ -declare function changed(ctrl: string, timeoutMs?: number): Promise; +declare function changed(ctrl: string, timeoutMs?: number): Promise; /** Promise-returning pause: await sleep(1000). The engine is not blocked. */ declare function sleep(milliseconds: number): Promise; declare function setTimeout(callback: () => void, milliseconds: number): number; @@ -159,11 +522,20 @@ declare function setInterval(callback: () => void, milliseconds: number): number declare function clearTimeout(id: number): void; declare function clearInterval(id: number): void; +// --------------------------------------------------------------------------- +// Processes +// --------------------------------------------------------------------------- + +type ExitCallback = (exitCode: number, capturedOutput?: string, capturedErrorOutput?: string) => void; + interface ShellCommandOptions { + /** Capture stdout and deliver it in the result / exit callback. */ captureOutput?: boolean; + /** Capture stderr instead of passing it through to the engine's stderr. */ captureErrorOutput?: boolean; + /** Text to feed to the process on stdin. */ input?: string; - exitCallback?: (exitCode: number, capturedOutput?: string, capturedErrorOutput?: string) => void; + exitCallback?: ExitCallback; } interface SpawnResult { /** Process exit code; a nonzero exit resolves the promise, it does not reject. */ @@ -172,18 +544,180 @@ interface SpawnResult { capturedErrorOutput?: string; } /** Resolves on process exit; rejects only when the process cannot start. */ -declare function runShellCommand(command: string, options?: ShellCommandOptions): Promise; +declare function runShellCommand( + command: string, + options?: ShellCommandOptions | ExitCallback +): Promise; /** Resolves on process exit; rejects only when the process cannot start. */ -declare function spawn(command: string, args: string[], options?: ShellCommandOptions): Promise; +declare function spawn( + command: string, + args: string[], + options?: ShellCommandOptions | ExitCallback +): Promise; + +// --------------------------------------------------------------------------- +// Configuration and storage +// --------------------------------------------------------------------------- -declare function readConfig(path: string): any; +interface ReadConfigOptions { + /** Log an error when the file is missing (default true). */ + logErrorOnNoFile?: boolean; +} +/** Parses a JSON configuration file (comments allowed). */ +declare function readConfig(path: string, options?: ReadConfigOptions): any; interface PersistentStorageOptions { + /** Share the storage between all rule files instead of per-file. */ global?: boolean; } -declare function PersistentStorage(name: string, options?: PersistentStorageOptions): Record; +/** + * A persistent key-value storage that survives engine restarts. + * Give it a shape for typed access: + * `const s = PersistentStorage<{ count: number }>("stats", { global: true })`. + */ +declare function PersistentStorage = Record>( + name: string, + options?: PersistentStorageOptions +): T; +/** + * Wraps an object so property changes propagate back to the + * PersistentStorage slot it is stored in. + */ declare function StorableObject(obj: T): T; +// --------------------------------------------------------------------------- +// Notifications (modules/wb-notify.js, available as the Notify global) +// --------------------------------------------------------------------------- + +/** Called when the notification has been handed off; error is null on success. */ +type NotifyCallback = (error: Error | null) => void; + +interface WebhookOptions { + url: string; + /** HTTP method; default POST when a body is present, GET otherwise. */ + method?: string; + /** Request body; objects are JSON-encoded. */ + body?: string | object; + /** Content-Type header; inferred from the body when omitted. */ + contentType?: string; + headers?: Record; +} + +interface TelegramMessageOptions { + /** Telegram parse_mode, e.g. "MarkdownV2" or "HTML". */ + parseMode?: string; + disableWebPagePreview?: boolean; + disableNotification?: boolean; +} + +interface NotifyApi { + /** Sends an email through the local sendmail. */ + sendEmail(to: string, subject: string, text: string, callback?: NotifyCallback): void; + /** Sends an SMS via ModemManager (or gammu); `command` overrides the tool. */ + sendSMS(to: string, text: string, command?: string, callback?: NotifyCallback): void; + sendSMS(to: string, text: string, callback: NotifyCallback): void; + /** Performs an HTTP request (curl) with the given options. */ + sendWebhook(options: WebhookOptions, callback?: NotifyCallback): void; + /** Sends a message via a Telegram bot. */ + sendTelegramMessage( + token: string, + chatId: string, + text: string, + options?: TelegramMessageOptions, + callback?: NotifyCallback + ): void; + sendTelegramMessage(token: string, chatId: string, text: string, callback: NotifyCallback): void; + /** Uppercases/validates an HTTP method name, defaulting appropriately. */ + normalizeWebhookMethod(method?: string): string; +} +/** Notification channels: email, SMS, webhooks, Telegram. */ +declare const Notify: NotifyApi; + +// --------------------------------------------------------------------------- +// Alarms (modules/wb-alarms.js, available as the Alarms global) +// --------------------------------------------------------------------------- + +declare type AlarmRecipient = + | { type: "email"; to: string; subject?: string } + | { type: "sms"; to: string; command?: string } + | { type: "telegram"; token: string; chatId: string } + | { type: "vk"; token: string; peerId: string; apiVersion?: string } + | { type: "max"; token: string; chatId: string } + | { type: "matrix"; homeserver: string; accessToken: string; roomId: string; msgType?: string } + | { type: "wechat"; key: string } + | { + type: "webhook"; + url: string; + method?: string; + contentType?: string; + headers?: Record; + /** Body template; `{}` is replaced with the alarm message. */ + bodyTemplate?: string; + }; + +interface AlarmBase { + /** Alarm cell name; derived from the watched cell when omitted. */ + name?: string; + /** The watched control, as "device/control". */ + cell: string; + /** Message sent when the alarm activates; `{}` is replaced by the value. */ + alarmMessage?: string; + /** Message sent when the alarm deactivates; `{}` is replaced by the value. */ + noAlarmMessage?: string; + /** Repeat interval for reminders about a still-active alarm, seconds. */ + interval?: number; + /** Maximum number of messages sent per activation. */ + maxCount?: number; + /** Require the out-of-range state to persist this long, ms. */ + alarmDelayMs?: number; + /** Require the back-to-normal state to persist this long, ms. */ + noAlarmDelayMs?: number; +} + +/** + * One alarm: watches a cell and alerts either when its value differs from + * `expectedValue`, or when it leaves the [minValue, maxValue] range (at + * least one bound required). + */ +declare type AlarmSpec = AlarmBase & + ({ expectedValue: CellValue } | WbAtLeastOne<{ minValue: number; maxValue: number }>); + +interface AlarmsConfig { + /** Virtual device created for the alarm cells and log. */ + deviceName: string; + deviceTitle?: Title; + recipients: AlarmRecipient[]; + alarms: AlarmSpec[]; +} + +interface AlarmsApi { + /** Loads alarms from a JSON config file path or an inline config object. */ + load(config: string | AlarmsConfig): void; +} +/** Threshold alarms with notification fan-out (see AlarmsConfig). */ +declare const Alarms: AlarmsApi; + +// --------------------------------------------------------------------------- +// String formatting (lib.js augments String.prototype) +// --------------------------------------------------------------------------- + +interface String { + /** Replaces `{}` placeholders with the arguments, like format(). */ + format(...args: any[]): string; + /** + * Like format(), but placeholders may contain expressions that are + * EVALUATED as code. Never use with untrusted input. + */ + xformat(...args: any[]): string; +} + +// --------------------------------------------------------------------------- +// Module system +// --------------------------------------------------------------------------- + +/** Absolute path of the current rule file. */ +declare const __filename: string; + /** Per-file module object (rule files are CommonJS-like scenarios). */ declare const module: { readonly filename: string; @@ -194,3 +728,6 @@ declare const module: { declare function require(id: string): any; declare const global: typeof globalThis; + +// CommonJS-style module surface available in every rule file +declare var exports: Record;