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..8530451 --- /dev/null +++ b/dialect/agentscript/src/lint/passes/set-variables-io.ts @@ -0,0 +1,144 @@ +/* + * 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 } from '@agentscript/language'; +import { + storeKey, + schemaContextKey, + resolveNamespaceKeys, + decomposeAtMemberExpression, + isNamedMap, + isAstNodeLike, + attachDiagnostic, + findSuggestion, + lintDiagnostic, +} from '@agentscript/language'; +import type { PassStore } from '@agentscript/language'; +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?: AstNodeLike[]; +} + +interface WithClauseNode extends AstNodeLike { + __kind: 'WithClause'; + param: string; + __paramCstNode?: SyntaxNode; +} + +// --------------------------------------------------------------------------- +// Type guards +// --------------------------------------------------------------------------- + +function isReasoningActionBlock(node: unknown): node is ReasoningActionBlock { + return isAstNodeLike(node) && node.__kind === 'ReasoningActionBlock'; +} + +function isWithClause(node: unknown): node is WithClauseNode { + return ( + isAstNodeLike(node) && + node.__kind === 'WithClause' && + typeof node.param === 'string' + ); +} + +/** Check if a reasoning action value is @utils.setVariables */ +function isSetVariablesAction(value: AstNodeLike | undefined): boolean { + if (!value) return false; + const decomposed = decomposeAtMemberExpression(value); + return ( + decomposed?.namespace === 'utils' && decomposed?.property === 'setVariables' + ); +} + +// --------------------------------------------------------------------------- +// Lint pass +// --------------------------------------------------------------------------- + +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 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 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) + .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) { + const range = stmt.__paramCstNode + ? toRange(stmt.__paramCstNode) + : cst.range; + + 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( + 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'"); + }); +});