Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions dialect/agentscript/src/lint/passes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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[] {
Expand All @@ -75,5 +77,6 @@ export function defaultRules(): LintPass[] {
actionIoRule(),
actionTypeCheckRule(),
availableWhenTypeCheckRule(),
setVariablesIoRule(),
];
}
144 changes: 144 additions & 0 deletions dialect/agentscript/src/lint/passes/set-variables-io.ts
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]

Copy link
Copy Markdown
Contributor

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-36 does the same kind of validation by consuming reasoningActionsKey via defineRule({ deps: { entry: each(reasoningActionsKey) } }) — it gets the traversal, statement extraction, and typed WithClause/SetClause shapes for free. This pass instead manually walks subagent/topic namespaces and reaches into block.reasoning?.actions with as any + an eslint-disable.

Consequence: if reasoning AST shape changes, action-io keeps working but this pass silently breaks. Refactor to filter reasoningActionsKey entries where the action ref is @utils.setVariables, then iterate entry.statements — drops ~60 lines and the as any.

.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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 mutable. @utils.setVariables should not be able to assign to a linked (context-bound) variable, but the test at lint.test.ts:4845 explicitly asserts no error for that case.

Example that passes lint today but is likely a real bug:

variables:
  context_val: linked string
subagent main:
  reasoning:
    actions:
      update: @utils.setVariables
        with context_val="test"   # silently allowed

Suggest: typeMap.variables.get(stmt.param)?.mutability !== 'mutable' → emit a distinct diagnostic (e.g. set-variables-immutable-target).

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();
}
154 changes: 154 additions & 0 deletions dialect/agentscript/src/tests/lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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'");
});
});
Loading