From 343cb40e3a0f408f78f2e3e7cf04816fc1d6c614 Mon Sep 17 00:00:00 2001 From: Eric Dvorsak Date: Thu, 11 Jun 2026 16:59:21 +0200 Subject: [PATCH 1/3] Fix typed trigger parameter bindings --- crates/allium-parser/src/analysis.rs | 58 +++++++- .../allium/src/language-tools/analyzer.ts | 129 +++++++++++++++++- extensions/allium/test/analyzer.test.ts | 34 +++++ 3 files changed, 218 insertions(+), 3 deletions(-) diff --git a/crates/allium-parser/src/analysis.rs b/crates/allium-parser/src/analysis.rs index 29c5711..447178b 100644 --- a/crates/allium-parser/src/analysis.rs +++ b/crates/allium-parser/src/analysis.rs @@ -4485,8 +4485,14 @@ fn collect_bound_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) { } Expr::Call { args, .. } => { for arg in args { - if let CallArg::Positional(Expr::Ident(id)) = arg { - out.insert(&id.name); + match arg { + CallArg::Positional(Expr::Ident(id)) => { + out.insert(&id.name); + } + CallArg::Named(named) if is_type_annotation_expr(&named.value) => { + out.insert(&named.name.name); + } + _ => {} } } } @@ -4498,6 +4504,21 @@ fn collect_bound_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) { } } +fn is_type_annotation_expr(expr: &Expr) -> bool { + match expr { + Expr::Ident(id) => starts_uppercase(&id.name), + Expr::QualifiedName(q) => starts_uppercase(&q.name), + Expr::GenericType { name, args, .. } => { + is_type_annotation_expr(name) && args.iter().all(is_type_annotation_expr) + } + Expr::Pipe { left, right, .. } => { + is_type_annotation_expr(left) && is_type_annotation_expr(right) + } + Expr::TypeOptional { inner, .. } => is_type_annotation_expr(inner), + _ => false, + } +} + fn check_unbound_roots( expr: &Expr, bound: &HashSet<&str>, @@ -5092,6 +5113,39 @@ mod tests { assert!(!has_code(&ds, "allium.surface.unusedBinding")); } + // -- Rule bindings -- + + #[test] + fn typed_call_trigger_param_is_bound() { + let ds = analyze_src( + "entity Account {\n name: String\n}\n\n\ + entity Greeting {\n label: String\n}\n\n\ + rule TypedParam {\n when: AccountSeen(account: Account)\n \ + ensures: Greeting.created(label: account.name)\n}\n", + ); + assert!(!has_code(&ds, "allium.rule.undefinedBinding")); + } + + #[test] + fn generic_typed_call_trigger_param_is_bound() { + let ds = analyze_src( + "entity Account {\n name: String\n}\n\n\ + entity Greeting {\n label: String\n}\n\n\ + rule GenericTypedParam {\n when: AccountsSeen(accounts: List)\n \ + ensures: Greeting.created(label: accounts.count)\n}\n", + ); + assert!(!has_code(&ds, "allium.rule.undefinedBinding")); + } + + #[test] + fn named_literal_trigger_arg_is_not_binding() { + let ds = analyze_src( + "rule LiteralFilter {\n when: CommandInvoked(name: \"npm run test\")\n \ + requires: name.status = active\n ensures: Done()\n}\n", + ); + assert!(has_code(&ds, "allium.rule.undefinedBinding")); + } + // -- Status state machine -- #[test] diff --git a/extensions/allium/src/language-tools/analyzer.ts b/extensions/allium/src/language-tools/analyzer.ts index d8b10ad..4838040 100644 --- a/extensions/allium/src/language-tools/analyzer.ts +++ b/extensions/allium/src/language-tools/analyzer.ts @@ -2437,11 +2437,16 @@ function collectRuleBoundNames( /^\s*when\s*:\s*[A-Za-z_][A-Za-z0-9_]*(?:\/[A-Za-z_][A-Za-z0-9_]*)?\s*\(([^)]*)\)/m; const whenCallMatch = ruleBody.match(whenCallPattern); if (whenCallMatch) { - for (const raw of whenCallMatch[1].split(",")) { + for (const raw of splitTopLevelArgs(whenCallMatch[1])) { const name = raw.trim(); if (name.length === 0 || name === "_") { continue; } + const typed = name.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/); + if (typed && isTypeAnnotationText(typed[2])) { + bound.add(typed[1]); + continue; + } if (/^[A-Za-z_][A-Za-z0-9_]*\??$/.test(name)) { bound.add(name.replace(/\?$/, "")); } @@ -2487,6 +2492,128 @@ function collectRuleBoundNames( return bound; } +function splitTopLevelArgs(argsText: string): string[] { + const parts: string[] = []; + let start = 0; + let angleDepth = 0; + let parenDepth = 0; + let bracketDepth = 0; + let braceDepth = 0; + let stringQuote: string | undefined; + let escaped = false; + + for (let i = 0; i < argsText.length; i += 1) { + const ch = argsText[i]; + + if (stringQuote) { + if (escaped) { + escaped = false; + } else if (ch === "\\") { + escaped = true; + } else if (ch === stringQuote) { + stringQuote = undefined; + } + continue; + } + + if (ch === '"' || ch === "'") { + stringQuote = ch; + continue; + } + + if (ch === "<") { + angleDepth += 1; + } else if (ch === ">" && angleDepth > 0) { + angleDepth -= 1; + } else if (ch === "(") { + parenDepth += 1; + } else if (ch === ")" && parenDepth > 0) { + parenDepth -= 1; + } else if (ch === "[") { + bracketDepth += 1; + } else if (ch === "]" && bracketDepth > 0) { + bracketDepth -= 1; + } else if (ch === "{") { + braceDepth += 1; + } else if (ch === "}" && braceDepth > 0) { + braceDepth -= 1; + } else if ( + ch === "," && + angleDepth === 0 && + parenDepth === 0 && + bracketDepth === 0 && + braceDepth === 0 + ) { + parts.push(argsText.slice(start, i)); + start = i + 1; + } + } + + parts.push(argsText.slice(start)); + return parts; +} + +function isTypeAnnotationText(text: string): boolean { + const trimmed = text.trim(); + if (trimmed.length === 0) { + return false; + } + if (trimmed.endsWith("?")) { + return isTypeAnnotationText(trimmed.slice(0, -1)); + } + + const generic = parseGenericTypeText(trimmed); + if (generic) { + const args = splitTopLevelArgs(generic.args).map((arg) => arg.trim()); + return ( + isTypeAnnotationText(generic.name) && + args.length > 0 && + args.every((arg) => arg.length > 0 && isTypeAnnotationText(arg)) + ); + } + + return /^(?:[A-Za-z_][A-Za-z0-9_]*\/)?[A-Z][A-Za-z0-9_]*$/.test(trimmed); +} + +function parseGenericTypeText( + text: string, +): { name: string; args: string } | undefined { + let depth = 0; + let genericStart = -1; + let genericEnd = -1; + + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + if (ch === "<") { + if (depth === 0 && genericStart === -1) { + genericStart = i; + } + depth += 1; + } else if (ch === ">") { + depth -= 1; + if (depth < 0) { + return undefined; + } + if (depth === 0) { + genericEnd = i; + } + } + } + + if ( + depth !== 0 || + genericStart === -1 || + genericEnd !== text.length - 1 + ) { + return undefined; + } + + return { + name: text.slice(0, genericStart).trim(), + args: text.slice(genericStart + 1, genericEnd).trim(), + }; +} + function isInsideDoubleQuotedStringAtIndex( text: string, index: number, diff --git a/extensions/allium/test/analyzer.test.ts b/extensions/allium/test/analyzer.test.ts index 55e6ba2..e4518d5 100644 --- a/extensions/allium/test/analyzer.test.ts +++ b/extensions/allium/test/analyzer.test.ts @@ -779,6 +779,40 @@ test("does not report binding defined by trigger parameter", () => { ); }); +test("does not report binding defined by typed trigger parameter", () => { + const findings = analyzeAllium( + `entity User {\n status: String\n}\n\nrule Notify {\n when: UserUpdated(user: User)\n requires: user.status = active\n ensures: Done()\n}\n`, + ); + assert.equal( + findings.some((f) => f.code === "allium.rule.undefinedBinding"), + false, + ); +}); + +test("does not report binding defined by generic typed trigger parameter", () => { + const findings = analyzeAllium( + `entity User {\n status: String\n}\n\nrule Notify {\n when: UsersUpdated(users: List, metadata: Map)\n requires: users.count > 0\n requires: metadata.count > 0\n ensures: Done()\n}\n`, + ); + assert.equal( + findings.some((f) => f.code === "allium.rule.undefinedBinding"), + false, + ); +}); + +test("does not treat named literal trigger argument as binding", () => { + const findings = analyzeAllium( + `rule Notify {\n when: CommandInvoked(name: "npm run test")\n requires: name.status = active\n ensures: Done()\n}\n`, + ); + assert.ok(findings.some((f) => f.code === "allium.rule.undefinedBinding")); +}); + +test("does not treat lowercase named trigger argument as typed binding", () => { + const findings = analyzeAllium( + `rule Notify {\n when: FieldSelected(field: course_name)\n requires: field.status = active\n ensures: Done()\n}\n`, + ); + assert.ok(findings.some((f) => f.code === "allium.rule.undefinedBinding")); +}); + test("does not report binding defined in context block", () => { const findings = analyzeAllium( `entity User {\n status: String\n}\n\ngiven {\n user: User\n}\n\nrule Notify {\n when: Ping()\n requires: user.status = active\n ensures: Done()\n}\n`, From 91afb623d7f3f19a3f356cbca4b798fe8031012c Mon Sep 17 00:00:00 2001 From: Eric Dvorsak Date: Thu, 11 Jun 2026 17:45:24 +0200 Subject: [PATCH 2/3] Route undefined binding diagnostics through WASM --- crates/allium-parser/src/analysis.rs | 110 +++-- crates/allium-wasm/src/lib.rs | 14 +- .../allium/src/language-tools/analyzer.ts | 392 +++--------------- .../allium/src/language-tools/wasm-ast.ts | 19 +- extensions/allium/test/analyzer.test.ts | 14 + extensions/allium/test/wasm-analysis.test.ts | 26 ++ 6 files changed, 215 insertions(+), 360 deletions(-) create mode 100644 extensions/allium/test/wasm-analysis.test.ts diff --git a/crates/allium-parser/src/analysis.rs b/crates/allium-parser/src/analysis.rs index 447178b..c7fee69 100644 --- a/crates/allium-parser/src/analysis.rs +++ b/crates/allium-parser/src/analysis.rs @@ -4418,12 +4418,21 @@ impl Ctx<'_> { match &item.kind { BlockItemKind::ForBlock { binding, + collection, items, .. } => { + check_unbound_source_expr( + collection, + &bound, + rule_name, + &mut self.diagnostics, + ); let mut inner_bound = bound.clone(); match binding { - ForBinding::Single(id) => { inner_bound.insert(&id.name); } + ForBinding::Single(id) => { + inner_bound.insert(&id.name); + } ForBinding::Destructured(ids, _) => { for id in ids { inner_bound.insert(&id.name); @@ -4433,7 +4442,12 @@ impl Ctx<'_> { for sub_item in items { if let BlockItemKind::Clause { keyword, value } = &sub_item.kind { if keyword == "ensures" || keyword == "requires" { - check_unbound_roots(value, &inner_bound, rule_name, &mut self.diagnostics); + check_unbound_roots( + value, + &inner_bound, + rule_name, + &mut self.diagnostics, + ); } } } @@ -4528,21 +4542,7 @@ fn check_unbound_roots( match expr { Expr::MemberAccess { object, .. } => { if let Expr::Ident(id) = object.as_ref() { - if !starts_uppercase(&id.name) - && !bound.contains(id.name.as_str()) - && !is_builtin_name(&id.name) - { - diagnostics.push( - Diagnostic::error( - id.span, - format!( - "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.", - id.name - ), - ) - .with_code("allium.rule.undefinedBinding"), - ); - } + push_unbound_root_ident(id, bound, rule_name, diagnostics); } } Expr::Comparison { left, right, .. } => { @@ -4565,11 +4565,13 @@ fn check_unbound_roots( } } Expr::For { binding, collection, body, .. } => { - check_unbound_roots(collection, bound, rule_name, diagnostics); + check_unbound_source_expr(collection, bound, rule_name, diagnostics); // Skip filter (where clause) — fields are implicitly scoped to the binding let mut inner = bound.clone(); match binding { - ForBinding::Single(id) => { inner.insert(id.name.as_str()); } + ForBinding::Single(id) => { + inner.insert(id.name.as_str()); + } ForBinding::Destructured(ids, _) => { for id in ids { inner.insert(id.name.as_str()); @@ -4604,15 +4606,18 @@ fn check_unbound_roots( CallArg::Positional(e) => { check_unbound_roots(e, &call_bound, rule_name, diagnostics); } - CallArg::Named(n) => check_unbound_roots(&n.value, &call_bound, rule_name, diagnostics), + CallArg::Named(n) => { + check_unbound_roots(&n.value, &call_bound, rule_name, diagnostics) + } } } } - Expr::Not { operand, .. } - | Expr::Exists { operand, .. } - | Expr::NotExists { operand, .. } => { + Expr::Not { operand, .. } => { check_unbound_roots(operand, bound, rule_name, diagnostics); } + Expr::Exists { operand, .. } | Expr::NotExists { operand, .. } => { + check_unbound_source_expr(operand, bound, rule_name, diagnostics); + } Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => { check_unbound_roots(element, bound, rule_name, diagnostics); check_unbound_roots(collection, bound, rule_name, diagnostics); @@ -4630,8 +4635,49 @@ fn check_unbound_roots( } } +fn check_unbound_source_expr( + expr: &Expr, + bound: &HashSet<&str>, + rule_name: &str, + diagnostics: &mut Vec, +) { + if let Expr::Ident(id) = expr { + push_unbound_root_ident(id, bound, rule_name, diagnostics); + } else { + check_unbound_roots(expr, bound, rule_name, diagnostics); + } +} + +fn push_unbound_root_ident( + id: &Ident, + bound: &HashSet<&str>, + rule_name: &str, + diagnostics: &mut Vec, +) { + if starts_uppercase(&id.name) + || bound.contains(id.name.as_str()) + || is_builtin_name(&id.name) + { + return; + } + + diagnostics.push( + Diagnostic::error( + id.span, + format!( + "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.", + id.name + ), + ) + .with_code("allium.rule.undefinedBinding"), + ); +} + fn is_builtin_name(name: &str) -> bool { - matches!(name, "config" | "now" | "this" | "within" | "true" | "false" | "null") + matches!( + name, + "config" | "now" | "this" | "within" | "true" | "false" | "null" + ) } // --------------------------------------------------------------------------- @@ -5146,6 +5192,22 @@ mod tests { assert!(has_code(&ds, "allium.rule.undefinedBinding")); } + #[test] + fn exists_unbound_operand_is_reported() { + let ds = analyze_src( + "rule Check {\n when: Ping()\n requires: exists candidate\n ensures: Done()\n}\n", + ); + assert!(has_code(&ds, "allium.rule.undefinedBinding")); + } + + #[test] + fn for_block_unbound_collection_is_reported() { + let ds = analyze_src( + "rule Iterate {\n when: Ping()\n for item in items:\n ensures: Done()\n}\n", + ); + assert!(has_code(&ds, "allium.rule.undefinedBinding")); + } + // -- Status state machine -- #[test] diff --git a/crates/allium-wasm/src/lib.rs b/crates/allium-wasm/src/lib.rs index edd41eb..dbabb15 100644 --- a/crates/allium-wasm/src/lib.rs +++ b/crates/allium-wasm/src/lib.rs @@ -4,7 +4,15 @@ use wasm_bindgen::prelude::*; #[wasm_bindgen] pub fn parse(source: &str) -> String { let result = allium_parser::parse(source); - serde_json::to_string(&result).unwrap_or_else(|e| { - format!(r#"{{"error":"serialisation failed: {e}"}}"#) - }) + serde_json::to_string(&result) + .unwrap_or_else(|e| format!(r#"{{"error":"serialisation failed: {e}"}}"#)) +} + +/// Run semantic analysis over an Allium source string and return diagnostics as JSON. +#[wasm_bindgen] +pub fn analyze(source: &str) -> String { + let result = allium_parser::parse(source); + let diagnostics = allium_parser::analyze(&result.module, source); + serde_json::to_string(&diagnostics) + .unwrap_or_else(|e| format!(r#"{{"error":"serialisation failed: {e}"}}"#)) } diff --git a/extensions/allium/src/language-tools/analyzer.ts b/extensions/allium/src/language-tools/analyzer.ts index 4838040..7b1e664 100644 --- a/extensions/allium/src/language-tools/analyzer.ts +++ b/extensions/allium/src/language-tools/analyzer.ts @@ -3,7 +3,7 @@ import { parseAlliumBlocks, parseAlliumDocument, } from "./parser"; -import type { WasmDiagnostic } from "./wasm-ast"; +import { analyzeAlliumWithRust, type WasmDiagnostic } from "./wasm-ast"; export type FindingSeverity = "error" | "warning" | "info"; export type DiagnosticsMode = "strict" | "relaxed"; @@ -147,9 +147,7 @@ export function analyzeAllium( ...findRelationshipReferenceIssues(maskedText, lineStarts, blocks), ); findings.push(...findRuleTypeReferenceIssues(lineStarts, blocks, maskedText)); - findings.push( - ...findRuleUndefinedBindingIssues(lineStarts, blocks, maskedText), - ); + findings.push(...findRustRuleUndefinedBindingIssues(lineStarts, text)); findings.push(...findContextBindingIssues(maskedText, lineStarts, blocks)); findings.push(...findOpenQuestions(maskedText, lineStarts)); findings.push(...findSurfaceActorLinkIssues(maskedText, lineStarts, blocks)); @@ -2000,122 +1998,74 @@ function findRuleTypeReferenceIssues( return findings; } -function findRuleUndefinedBindingIssues( +function findRustRuleUndefinedBindingIssues( lineStarts: number[], - blocks: ReturnType, text: string, ): Finding[] { - const findings: Finding[] = []; - const contextBindings = collectContextBindingNames(blocks); - const defaultInstances = collectDefaultInstanceNames(text); - const ruleBlocks = blocks.filter((block) => block.kind === "rule"); - - for (const rule of ruleBlocks) { - const bound = collectRuleBoundNames( - rule.body, - contextBindings, - defaultInstances, + return analyzeAlliumWithRust(text) + .filter((diagnostic) => diagnostic.code === "allium.rule.undefinedBinding") + .map((diagnostic) => + wasmDiagnosticToFinding(diagnostic, text, lineStarts), ); - const seenUnknown = new Set(); - const referencePattern = /\b([a-z_][a-z0-9_]*)\s*\./g; - for ( - let match = referencePattern.exec(rule.body); - match; - match = referencePattern.exec(rule.body) - ) { - if (match.index > 0 && rule.body[match.index - 1] === ".") { - continue; - } - if (isCommentLineAtIndex(rule.body, match.index)) { - continue; - } - if (isInsideDoubleQuotedStringAtIndex(rule.body, match.index)) { - continue; - } - const root = match[1]; - if (root === "config" || root === "now" || bound.has(root)) { - continue; - } - if (seenUnknown.has(root)) { - continue; - } - seenUnknown.add(root); - const absoluteOffset = - rule.bodyStartOffset + match.index + match[0].indexOf(root); - findings.push( - rangeFinding( - lineStarts, - absoluteOffset, - absoluteOffset + root.length, - "allium.rule.undefinedBinding", - `Rule '${rule.name}' references '${root}' but no matching binding exists in context, trigger params, default instances, or local lets.`, - "error", - ), - ); +} + +function wasmDiagnosticToFinding( + diagnostic: WasmDiagnostic, + text: string, + lineStarts: number[], +): Finding { + const startOffset = byteOffsetToStringIndex(text, diagnostic.span.start); + const endOffset = byteOffsetToStringIndex(text, diagnostic.span.end); + return rangeFinding( + lineStarts, + startOffset, + endOffset, + diagnostic.code ?? "allium.diagnostic", + diagnostic.message, + wasmSeverityToFindingSeverity(diagnostic.severity), + ); +} + +function wasmSeverityToFindingSeverity( + severity: WasmDiagnostic["severity"], +): FindingSeverity { + if (severity === "Error") { + return "error"; + } + if (severity === "Warning") { + return "warning"; + } + return "info"; +} + +function byteOffsetToStringIndex(text: string, byteOffset: number): number { + let bytes = 0; + for (let index = 0; index < text.length;) { + if (bytes >= byteOffset) { + return index; } - const existsPattern = /\bexists\s+([a-z_][a-z0-9_]*)\b/g; - for ( - let match = existsPattern.exec(rule.body); - match; - match = existsPattern.exec(rule.body) - ) { - const root = match[1]; - if (isCommentLineAtIndex(rule.body, match.index)) { - continue; - } - if (root === "config" || root === "now" || bound.has(root)) { - continue; - } - if (seenUnknown.has(root)) { - continue; - } - seenUnknown.add(root); - const absoluteOffset = - rule.bodyStartOffset + match.index + match[0].indexOf(root); - findings.push( - rangeFinding( - lineStarts, - absoluteOffset, - absoluteOffset + root.length, - "allium.rule.undefinedBinding", - `Rule '${rule.name}' references '${root}' but no matching binding exists in context, trigger params, default instances, or local lets.`, - "error", - ), - ); + const codePoint = text.codePointAt(index); + if (codePoint === undefined) { + return index; } - const forInPattern = - /^\s*for\s+[A-Za-z_][A-Za-z0-9_]*\s+in\s+([a-z_][a-z0-9_]*)\b/gm; - for ( - let match = forInPattern.exec(rule.body); - match; - match = forInPattern.exec(rule.body) - ) { - const root = match[1]; - if (root === "config" || root === "now" || bound.has(root)) { - continue; - } - if (seenUnknown.has(root)) { - continue; - } - seenUnknown.add(root); - const absoluteOffset = - rule.bodyStartOffset + match.index + match[0].indexOf(root); - findings.push( - rangeFinding( - lineStarts, - absoluteOffset, - absoluteOffset + root.length, - "allium.rule.undefinedBinding", - `Rule '${rule.name}' references '${root}' but no matching binding exists in context, trigger params, default instances, or local lets.`, - "error", - ), - ); + const charBytes = + codePoint <= 0x7f + ? 1 + : codePoint <= 0x7ff + ? 2 + : codePoint <= 0xffff + ? 3 + : 4; + if (bytes + charBytes > byteOffset) { + return index; } - } - return findings; + bytes += charBytes; + index += codePoint > 0xffff ? 2 : 1; + } + return text.length; } function findContextBindingIssues( @@ -2217,24 +2167,6 @@ function findContextBindingIssues( return findings; } -function collectContextBindingNames( - blocks: ReturnType, -): Set { - const names = new Set(); - const contextBlocks = blocks.filter((block) => block.kind === "given"); - const bindingPattern = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/gm; - for (const block of contextBlocks) { - for ( - let match = bindingPattern.exec(block.body); - match; - match = bindingPattern.exec(block.body) - ) { - names.add(match[1]); - } - } - return names; -} - function collectContextBindingTypes( blocks: ReturnType, ): Map { @@ -2410,210 +2342,6 @@ function collectEntityTerminalStatuses(text: string): { return { terminalByEntity, hasTransitions }; } -function collectDefaultInstanceNames(text: string): Set { - const names = new Set(); - const pattern = - /^\s*default\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+([A-Za-z_][A-Za-z0-9_]*))?\s*=/gm; - for (let match = pattern.exec(text); match; match = pattern.exec(text)) { - names.add(match[2] ?? match[1]); - } - return names; -} - -function collectRuleBoundNames( - ruleBody: string, - contextBindings: Set, - defaultInstances: Set, -): Set { - const bound = new Set([...contextBindings, ...defaultInstances]); - const whenBindingPattern = - /^\s*when\s*:\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*[A-Za-z_][A-Za-z0-9_]*(?:\/[A-Za-z_][A-Za-z0-9_]*)?\./m; - const whenBindingMatch = ruleBody.match(whenBindingPattern); - if (whenBindingMatch) { - bound.add(whenBindingMatch[1]); - } - - const whenCallPattern = - /^\s*when\s*:\s*[A-Za-z_][A-Za-z0-9_]*(?:\/[A-Za-z_][A-Za-z0-9_]*)?\s*\(([^)]*)\)/m; - const whenCallMatch = ruleBody.match(whenCallPattern); - if (whenCallMatch) { - for (const raw of splitTopLevelArgs(whenCallMatch[1])) { - const name = raw.trim(); - if (name.length === 0 || name === "_") { - continue; - } - const typed = name.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/); - if (typed && isTypeAnnotationText(typed[2])) { - bound.add(typed[1]); - continue; - } - if (/^[A-Za-z_][A-Za-z0-9_]*\??$/.test(name)) { - bound.add(name.replace(/\?$/, "")); - } - } - } - - const forPattern = /^\s*for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+/gm; - for ( - let match = forPattern.exec(ruleBody); - match; - match = forPattern.exec(ruleBody) - ) { - bound.add(match[1]); - } - - const letPattern = /^\s*let\s+([A-Za-z_][A-Za-z0-9_]*)\s*=/gm; - for ( - let match = letPattern.exec(ruleBody); - match; - match = letPattern.exec(ruleBody) - ) { - bound.add(match[1]); - } - - const lambdaPattern = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=>/g; - for ( - let match = lambdaPattern.exec(ruleBody); - match; - match = lambdaPattern.exec(ruleBody) - ) { - bound.add(match[1]); - } - - const wherePattern = /\bwhere\s+([A-Za-z_][A-Za-z0-9_]*)\b/g; - for ( - let match = wherePattern.exec(ruleBody); - match; - match = wherePattern.exec(ruleBody) - ) { - bound.add(match[1]); - } - - return bound; -} - -function splitTopLevelArgs(argsText: string): string[] { - const parts: string[] = []; - let start = 0; - let angleDepth = 0; - let parenDepth = 0; - let bracketDepth = 0; - let braceDepth = 0; - let stringQuote: string | undefined; - let escaped = false; - - for (let i = 0; i < argsText.length; i += 1) { - const ch = argsText[i]; - - if (stringQuote) { - if (escaped) { - escaped = false; - } else if (ch === "\\") { - escaped = true; - } else if (ch === stringQuote) { - stringQuote = undefined; - } - continue; - } - - if (ch === '"' || ch === "'") { - stringQuote = ch; - continue; - } - - if (ch === "<") { - angleDepth += 1; - } else if (ch === ">" && angleDepth > 0) { - angleDepth -= 1; - } else if (ch === "(") { - parenDepth += 1; - } else if (ch === ")" && parenDepth > 0) { - parenDepth -= 1; - } else if (ch === "[") { - bracketDepth += 1; - } else if (ch === "]" && bracketDepth > 0) { - bracketDepth -= 1; - } else if (ch === "{") { - braceDepth += 1; - } else if (ch === "}" && braceDepth > 0) { - braceDepth -= 1; - } else if ( - ch === "," && - angleDepth === 0 && - parenDepth === 0 && - bracketDepth === 0 && - braceDepth === 0 - ) { - parts.push(argsText.slice(start, i)); - start = i + 1; - } - } - - parts.push(argsText.slice(start)); - return parts; -} - -function isTypeAnnotationText(text: string): boolean { - const trimmed = text.trim(); - if (trimmed.length === 0) { - return false; - } - if (trimmed.endsWith("?")) { - return isTypeAnnotationText(trimmed.slice(0, -1)); - } - - const generic = parseGenericTypeText(trimmed); - if (generic) { - const args = splitTopLevelArgs(generic.args).map((arg) => arg.trim()); - return ( - isTypeAnnotationText(generic.name) && - args.length > 0 && - args.every((arg) => arg.length > 0 && isTypeAnnotationText(arg)) - ); - } - - return /^(?:[A-Za-z_][A-Za-z0-9_]*\/)?[A-Z][A-Za-z0-9_]*$/.test(trimmed); -} - -function parseGenericTypeText( - text: string, -): { name: string; args: string } | undefined { - let depth = 0; - let genericStart = -1; - let genericEnd = -1; - - for (let i = 0; i < text.length; i += 1) { - const ch = text[i]; - if (ch === "<") { - if (depth === 0 && genericStart === -1) { - genericStart = i; - } - depth += 1; - } else if (ch === ">") { - depth -= 1; - if (depth < 0) { - return undefined; - } - if (depth === 0) { - genericEnd = i; - } - } - } - - if ( - depth !== 0 || - genericStart === -1 || - genericEnd !== text.length - 1 - ) { - return undefined; - } - - return { - name: text.slice(0, genericStart).trim(), - args: text.slice(genericStart + 1, genericEnd).trim(), - }; -} - function isInsideDoubleQuotedStringAtIndex( text: string, index: number, diff --git a/extensions/allium/src/language-tools/wasm-ast.ts b/extensions/allium/src/language-tools/wasm-ast.ts index 032855e..3800113 100644 --- a/extensions/allium/src/language-tools/wasm-ast.ts +++ b/extensions/allium/src/language-tools/wasm-ast.ts @@ -10,6 +10,7 @@ // --------------------------------------------------------------------------- let _parse: ((source: string) => string) | undefined; +let _analyze: ((source: string) => string) | undefined; function getWasmParse(): (source: string) => string { if (!_parse) { @@ -21,11 +22,26 @@ function getWasmParse(): (source: string) => string { return _parse!; } +function getWasmAnalyze(): (source: string) => string { + if (!_analyze) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + try { _analyze = require(__dirname + "/allium_wasm.js").analyze; } + // eslint-disable-next-line @typescript-eslint/no-require-imports + catch { _analyze = require("allium-parser-wasm").analyze; } + } + return _analyze!; +} + export function parseAllium(source: string): WasmParseResult { const json = getWasmParse()(source); return JSON.parse(json) as WasmParseResult; } +export function analyzeAlliumWithRust(source: string): WasmDiagnostic[] { + const json = getWasmAnalyze()(source); + return JSON.parse(json) as WasmDiagnostic[]; +} + // --------------------------------------------------------------------------- // Top level // --------------------------------------------------------------------------- @@ -49,7 +65,8 @@ export interface WasmSpan { export interface WasmDiagnostic { span: WasmSpan; message: string; - severity: "Error" | "Warning"; + severity: "Error" | "Warning" | "Info"; + code?: string | null; } // --------------------------------------------------------------------------- diff --git a/extensions/allium/test/analyzer.test.ts b/extensions/allium/test/analyzer.test.ts index e4518d5..4059603 100644 --- a/extensions/allium/test/analyzer.test.ts +++ b/extensions/allium/test/analyzer.test.ts @@ -769,6 +769,20 @@ test("reports undefined rule binding used in dotted reference", () => { assert.ok(findings.some((f) => f.code === "allium.rule.undefinedBinding")); }); +test("maps Rust undefined binding byte spans after non-ASCII text", () => { + const findings = analyzeAllium( + `-- café\nrule Notify {\n when: Ping()\n requires: user.status = active\n ensures: Done()\n}\n`, + ); + const finding = findings.find( + (f) => f.code === "allium.rule.undefinedBinding", + ); + assert.ok(finding); + assert.equal(finding.start.line, 3); + assert.equal(finding.start.character, 12); + assert.equal(finding.end.line, 3); + assert.equal(finding.end.character, 16); +}); + test("does not report binding defined by trigger parameter", () => { const findings = analyzeAllium( `rule Notify {\n when: UserUpdated(user)\n requires: user.status = active\n ensures: Done()\n}\n`, diff --git a/extensions/allium/test/wasm-analysis.test.ts b/extensions/allium/test/wasm-analysis.test.ts new file mode 100644 index 0000000..14c8ae5 --- /dev/null +++ b/extensions/allium/test/wasm-analysis.test.ts @@ -0,0 +1,26 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { analyzeAlliumWithRust } from "../src/language-tools/wasm-ast"; + +test("WASM analysis exposes Rust undefined binding diagnostics", () => { + const diagnostics = analyzeAlliumWithRust( + `rule Notify {\n when: Ping()\n requires: user.status = active\n ensures: Done()\n}\n`, + ); + assert.ok( + diagnostics.some( + (diagnostic) => diagnostic.code === "allium.rule.undefinedBinding", + ), + ); +}); + +test("WASM analysis accepts typed trigger parameter bindings", () => { + const diagnostics = analyzeAlliumWithRust( + `entity User {\n status: String\n}\n\nrule Notify {\n when: UserUpdated(user: User)\n requires: user.status = active\n ensures: Done()\n}\n`, + ); + assert.equal( + diagnostics.some( + (diagnostic) => diagnostic.code === "allium.rule.undefinedBinding", + ), + false, + ); +}); From 5e6ff85ac0c89117ab1d9b3449ba43853f5f6b34 Mon Sep 17 00:00:00 2001 From: Eric Dvorsak Date: Thu, 11 Jun 2026 18:46:59 +0200 Subject: [PATCH 3/3] Document WASM undefined-binding diagnostic ownership --- docs/project/rust-checker-parity.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/project/rust-checker-parity.md b/docs/project/rust-checker-parity.md index 27dae21..157a90d 100644 --- a/docs/project/rust-checker-parity.md +++ b/docs/project/rust-checker-parity.md @@ -1,17 +1,19 @@ # Rust checker parity: analysis and remaining work -The Rust CLI (`allium check`) now has a semantic analysis pass in `crates/allium-parser/src/analysis.rs`. It implements 16 diagnostic checks with `-- allium-ignore` suppression. This document describes the remaining gaps against the TypeScript reference implementation and the issues discovered during validation. +The Rust CLI (`allium check`) now has a semantic analysis pass in `crates/allium-parser/src/analysis.rs`. It implements 16 diagnostic checks with `-- allium-ignore` suppression. This document describes the remaining gaps against editor/LSP diagnostics and the issues discovered during validation. ## Architecture The TypeScript analyzer: -- `extensions/allium/src/language-tools/analyzer.ts` — drives the VS Code extension and LSP +- `extensions/allium/src/language-tools/analyzer.ts` — drives the VS Code extension and LSP. Most editor diagnostics still live here, but migrated checks can delegate to Rust via the WASM package. The Rust analyzer: -- `crates/allium-parser/src/analysis.rs` — drives the Rust CLI (`allium check`) +- `crates/allium-parser/src/analysis.rs` — drives the Rust CLI (`allium check`) and exposes migrated diagnostics to TypeScript through `crates/allium-wasm`. The TypeScript version uses regex over raw source text. The Rust version walks the typed AST produced by the parser. The Rust approach is structurally more reliable but needs to handle all AST node shapes the parser produces. +`allium.rule.undefinedBinding` is now a migrated check: the editor/LSP path calls Rust analysis through WASM and maps the Rust diagnostic spans back into TypeScript findings, instead of maintaining a parallel TypeScript rule-binding parser. + The language reference at `docs/allium-v3-language-reference.md` is the definitive source for language semantics. When the two implementations disagree, consult it. ## Validation test beds @@ -56,6 +58,8 @@ Per language reference rule 1: "All referenced entities and values exist." This `check_unbound_roots` now accumulates `LetExpr` bindings when walking `Block` expressions, so variables defined by `let` in ensures blocks are in scope for subsequent expressions. Also added `Expr::For` handling (with binding scope) and `Expr::BinaryOp` recursion. +The editor/LSP diagnostic for this code is sourced from the Rust analyzer via WASM, so the binding rules have one implementation across CLI and TypeScript surfaces. + ### 2. `rule.unreachableTrigger` — 20 now matching Replaced the deep expression walker (`collect_call_names`) with `collect_leading_ensures_call` for the emitted trigger set. The new function only collects the first `Call` expression in each ensures clause value, matching the TS regex which captures only the first identifier after `ensures:`. Also restricted collection to `ensures` clauses only (not requires/when). @@ -207,7 +211,7 @@ issue #19: | `allium.definition.unused` | warning | Yes | Yes | | `allium.deferred.missingLocationHint` | warning | Yes | Yes | | `allium.rule.invalidTrigger` | error | Yes | Yes | -| `allium.rule.undefinedBinding` | error | Yes | Yes | +| `allium.rule.undefinedBinding` | error | Yes | Yes (via Rust/WASM) | | `allium.let.duplicateBinding` | error | Yes | Yes | | `allium.config.undefinedReference` | warning | Yes | Yes | | `allium.surface.unusedPath` | info | Disabled | Yes |