-
Notifications
You must be signed in to change notification settings - Fork 47
feat: report error when @utils.setVariables assigns to undefined variable #59
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d4b5113
d3a4eda
65a4689
c1c1b1a
50a3904
6ce5fc7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should also look for these in procedurals, no? |
||
| 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)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mutability not enforced. The check only verifies the variable exists, not that it's Example that passes lint today but is likely a real bug: Suggest: |
||
| 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(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
| `); | ||
|
Comment on lines
+4847
to
+4862
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be an error, right? We shouldn't be able to assign?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess it'd be fine if this check doesn't flag here but a different one would disallow assignment to linked vars generally.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, that's a good point. I guess we're not allowed to change linked vars. But we should just cover both cases now. |
||
|
|
||
| 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'"); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reinvents the walker. The sibling rule
action-io.ts:28-36does the same kind of validation by consumingreasoningActionsKeyviadefineRule({ deps: { entry: each(reasoningActionsKey) } })— it gets the traversal, statement extraction, and typedWithClause/SetClauseshapes for free. This pass instead manually walkssubagent/topicnamespaces and reaches intoblock.reasoning?.actionswithas any+ an eslint-disable.Consequence: if reasoning AST shape changes,
action-iokeeps working but this pass silently breaks. Refactor to filterreasoningActionsKeyentries where the action ref is@utils.setVariables, then iterateentry.statements— drops ~60 lines and theas any.