From d4b5113f82173b7cc93878996f9567aa524477c5 Mon Sep 17 00:00:00 2001 From: Allen Li Date: Wed, 10 Jun 2026 15:39:44 -0400 Subject: [PATCH 1/6] fix: report error when @utils.setVariables assigns to undefined variable Add set-variables-io lint pass that validates `with` clause parameters in @utils.setVariables reasoning actions against declared variables. Produces a 'set-variables-unknown-variable' error with typo suggestions. Resolves W-22695026 --- dialect/agentscript/src/lint/passes/index.ts | 3 + .../src/lint/passes/set-variables-io.ts | 134 +++++++++++++++ dialect/agentscript/src/tests/lint.test.ts | 154 ++++++++++++++++++ 3 files changed, 291 insertions(+) create mode 100644 dialect/agentscript/src/lint/passes/set-variables-io.ts diff --git a/dialect/agentscript/src/lint/passes/index.ts b/dialect/agentscript/src/lint/passes/index.ts index c7bc7a2..4153fc6 100644 --- a/dialect/agentscript/src/lint/passes/index.ts +++ b/dialect/agentscript/src/lint/passes/index.ts @@ -27,6 +27,7 @@ import { reasoningActionsAnalyzer } from './reasoning-actions.js'; import { actionIoRule } from './action-io.js'; import { actionTypeCheckRule } from './action-type-check.js'; import { availableWhenTypeCheckRule } from './available-when-type-check.js'; +import { setVariablesIoRule } from './set-variables-io.js'; export { typeMapAnalyzer, typeMapKey } from './type-map.js'; export type { @@ -49,6 +50,7 @@ export type { ReasoningActionEntry } from './reasoning-actions.js'; export { actionIoRule } from './action-io.js'; export { actionTypeCheckRule } from './action-type-check.js'; export { availableWhenTypeCheckRule } from './available-when-type-check.js'; +export { setVariablesIoRule } from './set-variables-io.js'; /** All AgentScript lint passes in engine execution order. */ export function defaultRules(): LintPass[] { @@ -75,5 +77,6 @@ export function defaultRules(): LintPass[] { actionIoRule(), actionTypeCheckRule(), availableWhenTypeCheckRule(), + setVariablesIoRule(), ]; } diff --git a/dialect/agentscript/src/lint/passes/set-variables-io.ts b/dialect/agentscript/src/lint/passes/set-variables-io.ts new file mode 100644 index 0000000..425a892 --- /dev/null +++ b/dialect/agentscript/src/lint/passes/set-variables-io.ts @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * setVariables I/O validation — validates that `with` clause parameters in + * @utils.setVariables reasoning actions reference defined variables. + * + * When a `with param=value` or `with param=...` clause uses a param name + * that does not correspond to a declared variable, this produces an error. + * + * Diagnostic: set-variables-unknown-variable + */ + +import type { + AstNodeLike, + AstRoot, + LintPass, + NamedMap, +} from '@agentscript/language'; +import { + storeKey, + schemaContextKey, + resolveNamespaceKeys, + decomposeAtMemberExpression, + isNamedMap, + attachDiagnostic, + findSuggestion, + lintDiagnostic, +} from '@agentscript/language'; +import type { PassStore } from '@agentscript/language'; +import type { CstMeta, SyntaxNode } from '@agentscript/types'; +import { toRange, DiagnosticSeverity } from '@agentscript/types'; +import { typeMapKey } from './type-map.js'; + +/** Check if a reasoning action value is @utils.setVariables */ +function isSetVariablesAction(value: unknown): boolean { + if (!value) return false; + const decomposed = decomposeAtMemberExpression(value); + return ( + decomposed?.namespace === 'utils' && decomposed?.property === 'setVariables' + ); +} + +class SetVariablesIoValidator implements LintPass { + readonly id = storeKey('set-variables-io'); + readonly description = + 'Validates with clause params in @utils.setVariables reference defined variables'; + readonly requires = [typeMapKey] as const; + + run(store: PassStore, root: AstRoot): void { + const typeMap = store.get(typeMapKey); + if (!typeMap) return; + + const ctx = store.get(schemaContextKey); + if (!ctx) return; + + const rootObj = root as AstNodeLike; + const variableNames = [...typeMap.variables.keys()]; + + // Walk all subagent/topic blocks to find @utils.setVariables reasoning actions + const subagentKeys = new Set([ + ...resolveNamespaceKeys('subagent', ctx), + ...resolveNamespaceKeys('topic', ctx), + ]); + + for (const topicKey of subagentKeys) { + const topicMap = rootObj[topicKey]; + if (!topicMap || !isNamedMap(topicMap)) continue; + + for (const [, block] of topicMap as NamedMap) { + if (!block || typeof block !== 'object') continue; + const topic = block as AstNodeLike; + + const reasoning = topic.reasoning; + if (!reasoning || typeof reasoning !== 'object') continue; + + const reasoningObj = reasoning as Record; + const raActions = reasoningObj.actions; + if (!raActions || !isNamedMap(raActions)) continue; + + for (const [, raBlock] of raActions as NamedMap) { + if (!raBlock || typeof raBlock !== 'object') continue; + const ra = raBlock as Record; + if (ra.__kind !== 'ReasoningActionBlock') continue; + + // Check if this is a @utils.setVariables action + if (!isSetVariablesAction(ra.value)) continue; + + // Validate with clauses + const statements = ra.statements as + | Array> + | undefined; + if (!statements) continue; + + for (const stmt of statements) { + if (stmt.__kind !== 'WithClause') continue; + const param = stmt.param as string; + if (!param) continue; + + if (!typeMap.variables.has(param)) { + const cst = stmt.__cst as CstMeta | undefined; + if (cst) { + const paramCstNode = (stmt as { __paramCstNode?: SyntaxNode }) + .__paramCstNode; + const range = paramCstNode ? toRange(paramCstNode) : cst.range; + + const suggestion = findSuggestion(param, variableNames); + const msg = `'${param}' is not a defined variable. @utils.setVariables can only assign to declared variables.`; + attachDiagnostic( + stmt, + lintDiagnostic( + range, + msg, + DiagnosticSeverity.Error, + 'set-variables-unknown-variable', + { suggestion } + ) + ); + } + } + } + } + } + } + } +} + +export function setVariablesIoRule(): LintPass { + return new SetVariablesIoValidator(); +} diff --git a/dialect/agentscript/src/tests/lint.test.ts b/dialect/agentscript/src/tests/lint.test.ts index fb260f5..c33aa08 100644 --- a/dialect/agentscript/src/tests/lint.test.ts +++ b/dialect/agentscript/src/tests/lint.test.ts @@ -4759,3 +4759,157 @@ subagent main: expect(errors[0].message).toContain('a number'); }); }); + +// ============================================================================ +// set-variables-io rule tests +// ============================================================================ + +describe('setVariablesIoRule', () => { + function runLint(source: string): Diagnostic[] { + const ast = parseDocument(source); + const engine = createLintEngine(); + const { diagnostics } = engine.run(ast, testSchemaCtx); + return diagnostics; + } + + it('reports error when with clause param is not a defined variable', () => { + const diagnostics = runLint(` +variables: + name: mutable string +subagent main: + label: "Main" + reasoning: + instructions: -> + |Do it + actions: + update: @utils.setVariables + description: "Update" + with name=... + with ProductName=... +`); + + const errors = diagnostics.filter( + d => d.code === 'set-variables-unknown-variable' + ); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain("'ProductName'"); + expect(errors[0].message).toContain('not a defined variable'); + }); + + it('reports error with suggestion for typo in variable name', () => { + const diagnostics = runLint(` +variables: + account_name: mutable string + product_name: mutable string +subagent main: + label: "Main" + reasoning: + instructions: -> + |Do it + actions: + update: @utils.setVariables + description: "Update" + with account_name=... + with prodcut_name=... +`); + + const errors = diagnostics.filter( + d => d.code === 'set-variables-unknown-variable' + ); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain("'prodcut_name'"); + expect(errors[0].data?.suggestion).toBe('product_name'); + }); + + it('does not report error for multiple mutable variables', () => { + const diagnostics = runLint(` +variables: + name: mutable string + email: mutable string +subagent main: + label: "Main" + reasoning: + instructions: -> + |Do it + actions: + update: @utils.setVariables + description: "Update" + with name=... + with email=... +`); + + const errors = diagnostics.filter( + d => d.code === 'set-variables-unknown-variable' + ); + expect(errors).toHaveLength(0); + }); + + it('does not report error for linked variables', () => { + const diagnostics = runLint(` +variables: + context_val: linked string + name: mutable string +subagent main: + label: "Main" + reasoning: + instructions: -> + |Do it + actions: + update: @utils.setVariables + description: "Update" + with name=... + with context_val="test" +`); + + const errors = diagnostics.filter( + d => d.code === 'set-variables-unknown-variable' + ); + expect(errors).toHaveLength(0); + }); + + it('reports multiple undefined variables', () => { + const diagnostics = runLint(` +variables: + name: mutable string +subagent main: + label: "Main" + reasoning: + instructions: -> + |Do it + actions: + update: @utils.setVariables + description: "Update" + with FakeVar1=... + with FakeVar2=... + with name=... +`); + + const errors = diagnostics.filter( + d => d.code === 'set-variables-unknown-variable' + ); + expect(errors).toHaveLength(2); + }); + + it('reports error for direct assignment to undefined variable', () => { + const diagnostics = runLint(` +variables: + name: mutable string +subagent main: + label: "Main" + reasoning: + instructions: -> + |Do it + actions: + update: @utils.setVariables + description: "Update" + with name="John" + with status="active" +`); + + const errors = diagnostics.filter( + d => d.code === 'set-variables-unknown-variable' + ); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain("'status'"); + }); +}); From d3a4edadfc9c604e2f4b7cef3fbf21fbb211258c Mon Sep 17 00:00:00 2001 From: Allen Li Date: Wed, 10 Jun 2026 15:41:35 -0400 Subject: [PATCH 2/6] refactor: replace unknown casts with typed interfaces in set-variables-io Use AstNodeLike narrowing interfaces (ReasoningActionBlock, WithClauseNode) and isAstNodeLike() guards instead of Record casts. --- .../src/lint/passes/set-variables-io.ts | 72 ++++++++++++------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/dialect/agentscript/src/lint/passes/set-variables-io.ts b/dialect/agentscript/src/lint/passes/set-variables-io.ts index 425a892..6e70b62 100644 --- a/dialect/agentscript/src/lint/passes/set-variables-io.ts +++ b/dialect/agentscript/src/lint/passes/set-variables-io.ts @@ -27,17 +27,38 @@ import { resolveNamespaceKeys, decomposeAtMemberExpression, isNamedMap, + isAstNodeLike, attachDiagnostic, findSuggestion, lintDiagnostic, } from '@agentscript/language'; import type { PassStore } from '@agentscript/language'; -import type { CstMeta, SyntaxNode } from '@agentscript/types'; +import type { SyntaxNode } from '@agentscript/types'; import { toRange, DiagnosticSeverity } from '@agentscript/types'; import { typeMapKey } from './type-map.js'; +// --------------------------------------------------------------------------- +// AST shape interfaces — narrow the loosely-typed AstNodeLike for readability +// --------------------------------------------------------------------------- + +interface ReasoningActionBlock extends AstNodeLike { + __kind: 'ReasoningActionBlock'; + value?: AstNodeLike; + statements?: WithClauseNode[]; +} + +interface WithClauseNode extends AstNodeLike { + __kind: 'WithClause'; + param: string; + __paramCstNode?: SyntaxNode; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + /** Check if a reasoning action value is @utils.setVariables */ -function isSetVariablesAction(value: unknown): boolean { +function isSetVariablesAction(value: AstNodeLike | undefined): boolean { if (!value) return false; const decomposed = decomposeAtMemberExpression(value); return ( @@ -45,6 +66,10 @@ function isSetVariablesAction(value: unknown): boolean { ); } +// --------------------------------------------------------------------------- +// Lint pass +// --------------------------------------------------------------------------- + class SetVariablesIoValidator implements LintPass { readonly id = storeKey('set-variables-io'); readonly description = @@ -71,45 +96,38 @@ class SetVariablesIoValidator implements LintPass { const topicMap = rootObj[topicKey]; if (!topicMap || !isNamedMap(topicMap)) continue; - for (const [, block] of topicMap as NamedMap) { - if (!block || typeof block !== 'object') continue; - const topic = block as AstNodeLike; + for (const [, block] of topicMap as NamedMap) { + if (!isAstNodeLike(block)) continue; - const reasoning = topic.reasoning; - if (!reasoning || typeof reasoning !== 'object') continue; + const reasoning = block.reasoning; + if (!isAstNodeLike(reasoning)) continue; - const reasoningObj = reasoning as Record; - const raActions = reasoningObj.actions; + const raActions = reasoning.actions; if (!raActions || !isNamedMap(raActions)) continue; - for (const [, raBlock] of raActions as NamedMap) { - if (!raBlock || typeof raBlock !== 'object') continue; - const ra = raBlock as Record; - if (ra.__kind !== 'ReasoningActionBlock') continue; + for (const [, raBlock] of raActions as NamedMap) { + if (!isAstNodeLike(raBlock)) continue; + if (raBlock.__kind !== 'ReasoningActionBlock') continue; - // Check if this is a @utils.setVariables action + const ra = raBlock as ReasoningActionBlock; if (!isSetVariablesAction(ra.value)) continue; - // Validate with clauses - const statements = ra.statements as - | Array> - | undefined; + const statements = ra.statements; if (!statements) continue; for (const stmt of statements) { if (stmt.__kind !== 'WithClause') continue; - const param = stmt.param as string; - if (!param) continue; + if (!stmt.param) continue; - if (!typeMap.variables.has(param)) { - const cst = stmt.__cst as CstMeta | undefined; + if (!typeMap.variables.has(stmt.param)) { + const cst = stmt.__cst; if (cst) { - const paramCstNode = (stmt as { __paramCstNode?: SyntaxNode }) - .__paramCstNode; - const range = paramCstNode ? toRange(paramCstNode) : cst.range; + const range = stmt.__paramCstNode + ? toRange(stmt.__paramCstNode) + : cst.range; - const suggestion = findSuggestion(param, variableNames); - const msg = `'${param}' is not a defined variable. @utils.setVariables can only assign to declared variables.`; + const suggestion = findSuggestion(stmt.param, variableNames); + const msg = `'${stmt.param}' is not a defined variable. @utils.setVariables can only assign to declared variables.`; attachDiagnostic( stmt, lintDiagnostic( From 65a468914d67da70be20e7c1e6bb2f0eb0cc337f Mon Sep 17 00:00:00 2001 From: Allen Li Date: Wed, 10 Jun 2026 15:45:55 -0400 Subject: [PATCH 3/6] refactor: add type guards for ReasoningActionBlock and WithClause Replace __kind string checks + cast patterns with proper type guard functions (isReasoningActionBlock, isWithClause) that narrow the type directly. --- .../src/lint/passes/set-variables-io.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/dialect/agentscript/src/lint/passes/set-variables-io.ts b/dialect/agentscript/src/lint/passes/set-variables-io.ts index 6e70b62..ef0d592 100644 --- a/dialect/agentscript/src/lint/passes/set-variables-io.ts +++ b/dialect/agentscript/src/lint/passes/set-variables-io.ts @@ -54,9 +54,19 @@ interface WithClauseNode extends AstNodeLike { } // --------------------------------------------------------------------------- -// Helpers +// Type guards // --------------------------------------------------------------------------- +function isReasoningActionBlock( + node: AstNodeLike +): node is ReasoningActionBlock { + return node.__kind === 'ReasoningActionBlock'; +} + +function isWithClause(node: AstNodeLike): node is WithClauseNode { + return node.__kind === 'WithClause'; +} + /** Check if a reasoning action value is @utils.setVariables */ function isSetVariablesAction(value: AstNodeLike | undefined): boolean { if (!value) return false; @@ -107,16 +117,14 @@ class SetVariablesIoValidator implements LintPass { for (const [, raBlock] of raActions as NamedMap) { if (!isAstNodeLike(raBlock)) continue; - if (raBlock.__kind !== 'ReasoningActionBlock') continue; - - const ra = raBlock as ReasoningActionBlock; - if (!isSetVariablesAction(ra.value)) continue; + if (!isReasoningActionBlock(raBlock)) continue; + if (!isSetVariablesAction(raBlock.value)) continue; - const statements = ra.statements; + const statements = raBlock.statements; if (!statements) continue; for (const stmt of statements) { - if (stmt.__kind !== 'WithClause') continue; + if (!isWithClause(stmt)) continue; if (!stmt.param) continue; if (!typeMap.variables.has(stmt.param)) { From c1c1b1a955f8b8778e72f5e6cc6597f8f3bb7b06 Mon Sep 17 00:00:00 2001 From: Allen Li Date: Wed, 10 Jun 2026 15:49:58 -0400 Subject: [PATCH 4/6] refactor: remove redundant AstRoot-to-AstNodeLike cast AstRoot already extends AstNodeLike, so no cast is needed to access index-signature properties on root. --- .../src/lint/passes/set-variables-io.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/dialect/agentscript/src/lint/passes/set-variables-io.ts b/dialect/agentscript/src/lint/passes/set-variables-io.ts index ef0d592..f1eb5f0 100644 --- a/dialect/agentscript/src/lint/passes/set-variables-io.ts +++ b/dialect/agentscript/src/lint/passes/set-variables-io.ts @@ -57,14 +57,12 @@ interface WithClauseNode extends AstNodeLike { // Type guards // --------------------------------------------------------------------------- -function isReasoningActionBlock( - node: AstNodeLike -): node is ReasoningActionBlock { - return node.__kind === 'ReasoningActionBlock'; +function isReasoningActionBlock(node: unknown): node is ReasoningActionBlock { + return isAstNodeLike(node) && node.__kind === 'ReasoningActionBlock'; } -function isWithClause(node: AstNodeLike): node is WithClauseNode { - return node.__kind === 'WithClause'; +function isWithClause(node: unknown): node is WithClauseNode { + return isAstNodeLike(node) && node.__kind === 'WithClause'; } /** Check if a reasoning action value is @utils.setVariables */ @@ -93,7 +91,6 @@ class SetVariablesIoValidator implements LintPass { const ctx = store.get(schemaContextKey); if (!ctx) return; - const rootObj = root as AstNodeLike; const variableNames = [...typeMap.variables.keys()]; // Walk all subagent/topic blocks to find @utils.setVariables reasoning actions @@ -103,7 +100,7 @@ class SetVariablesIoValidator implements LintPass { ]); for (const topicKey of subagentKeys) { - const topicMap = rootObj[topicKey]; + const topicMap = root[topicKey]; if (!topicMap || !isNamedMap(topicMap)) continue; for (const [, block] of topicMap as NamedMap) { @@ -116,7 +113,6 @@ class SetVariablesIoValidator implements LintPass { if (!raActions || !isNamedMap(raActions)) continue; for (const [, raBlock] of raActions as NamedMap) { - if (!isAstNodeLike(raBlock)) continue; if (!isReasoningActionBlock(raBlock)) continue; if (!isSetVariablesAction(raBlock.value)) continue; From 50a3904d10f8383a636dc71d5f49359421738e78 Mon Sep 17 00:00:00 2001 From: Allen Li Date: Wed, 10 Jun 2026 16:21:05 -0400 Subject: [PATCH 5/6] make the impl a little tighter --- .../src/lint/passes/set-variables-io.ts | 45 +++++++------------ 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/dialect/agentscript/src/lint/passes/set-variables-io.ts b/dialect/agentscript/src/lint/passes/set-variables-io.ts index f1eb5f0..22335a0 100644 --- a/dialect/agentscript/src/lint/passes/set-variables-io.ts +++ b/dialect/agentscript/src/lint/passes/set-variables-io.ts @@ -15,12 +15,7 @@ * Diagnostic: set-variables-unknown-variable */ -import type { - AstNodeLike, - AstRoot, - LintPass, - NamedMap, -} from '@agentscript/language'; +import type { AstNodeLike, AstRoot, LintPass } from '@agentscript/language'; import { storeKey, schemaContextKey, @@ -44,7 +39,7 @@ import { typeMapKey } from './type-map.js'; interface ReasoningActionBlock extends AstNodeLike { __kind: 'ReasoningActionBlock'; value?: AstNodeLike; - statements?: WithClauseNode[]; + statements?: AstNodeLike[]; } interface WithClauseNode extends AstNodeLike { @@ -62,7 +57,11 @@ function isReasoningActionBlock(node: unknown): node is ReasoningActionBlock { } function isWithClause(node: unknown): node is WithClauseNode { - return isAstNodeLike(node) && node.__kind === 'WithClause'; + return ( + isAstNodeLike(node) && + node.__kind === 'WithClause' && + typeof node.param === 'string' + ); } /** Check if a reasoning action value is @utils.setVariables */ @@ -103,26 +102,16 @@ class SetVariablesIoValidator implements LintPass { const topicMap = root[topicKey]; if (!topicMap || !isNamedMap(topicMap)) continue; - for (const [, block] of topicMap as NamedMap) { - if (!isAstNodeLike(block)) continue; - - const reasoning = block.reasoning; - if (!isAstNodeLike(reasoning)) continue; - - const raActions = reasoning.actions; - if (!raActions || !isNamedMap(raActions)) continue; - - for (const [, raBlock] of raActions as NamedMap) { - if (!isReasoningActionBlock(raBlock)) continue; - if (!isSetVariablesAction(raBlock.value)) continue; - - const statements = raBlock.statements; - if (!statements) continue; - - for (const stmt of statements) { - if (!isWithClause(stmt)) continue; - if (!stmt.param) continue; - + for (const reasoningActions of [...topicMap.values()] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map(block => (block as any).reasoning?.actions) + .filter(isNamedMap)) { + for (const statements of [...reasoningActions.values()] + .filter(isReasoningActionBlock) + .filter(raBlock => isSetVariablesAction(raBlock.value)) + .map(raBlock => raBlock.statements) + .filter(statements => statements !== undefined)) { + for (const stmt of statements.filter(isWithClause)) { if (!typeMap.variables.has(stmt.param)) { const cst = stmt.__cst; if (cst) { From 6ce5fc70f90e4424c3e81902acc5ca7c81265eb6 Mon Sep 17 00:00:00 2001 From: Allen Li Date: Wed, 10 Jun 2026 16:25:30 -0400 Subject: [PATCH 6/6] make some more type changes --- dialect/agentscript/src/lint/passes/set-variables-io.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/dialect/agentscript/src/lint/passes/set-variables-io.ts b/dialect/agentscript/src/lint/passes/set-variables-io.ts index 22335a0..8530451 100644 --- a/dialect/agentscript/src/lint/passes/set-variables-io.ts +++ b/dialect/agentscript/src/lint/passes/set-variables-io.ts @@ -98,10 +98,9 @@ class SetVariablesIoValidator implements LintPass { ...resolveNamespaceKeys('topic', ctx), ]); - for (const topicKey of subagentKeys) { - const topicMap = root[topicKey]; - if (!topicMap || !isNamedMap(topicMap)) continue; - + for (const topicMap of [...subagentKeys] + .map(key => root[key]) + .filter(isNamedMap)) { for (const reasoningActions of [...topicMap.values()] // eslint-disable-next-line @typescript-eslint/no-explicit-any .map(block => (block as any).reasoning?.actions)