diff --git a/dialect/agentscript/src/lint/passes/index.ts b/dialect/agentscript/src/lint/passes/index.ts index c7bc7a2..4a3ffd0 100644 --- a/dialect/agentscript/src/lint/passes/index.ts +++ b/dialect/agentscript/src/lint/passes/index.ts @@ -16,6 +16,7 @@ import { positionIndexPass, unreachableCodePass, unsupportedConditionalsPass, + transitionTargetPass, emptyBlockPass, unusedVariablePass, expressionValidationPass, @@ -62,6 +63,7 @@ export function defaultRules(): LintPass[] { positionIndexPass(), unreachableCodePass(), unsupportedConditionalsPass(), + transitionTargetPass(), emptyBlockPass(), unusedVariablePass(), expressionValidationPass(), diff --git a/packages/language/src/index.ts b/packages/language/src/index.ts index 064cbc2..9d88aa6 100644 --- a/packages/language/src/index.ts +++ b/packages/language/src/index.ts @@ -347,6 +347,7 @@ export { export { positionIndexPass } from './core/analysis/position-index-pass.js'; export { unreachableCodePass } from './lint/unreachable-code.js'; export { unsupportedConditionalsPass } from './lint/unsupported-conditionals.js'; +export { transitionTargetPass } from './lint/transition-target.js'; export { emptyBlockPass } from './lint/empty-block.js'; export { spreadContextPass } from './lint/spread-context.js'; export { nullLiteralValidationPass } from './lint/null-literal-validation.js'; diff --git a/packages/language/src/lint/index.ts b/packages/language/src/lint/index.ts index e31c3ab..4fcd6c7 100644 --- a/packages/language/src/lint/index.ts +++ b/packages/language/src/lint/index.ts @@ -45,6 +45,7 @@ export { constraintValidationPass } from './constraint-validation.js'; export { positionIndexPass } from '../core/analysis/position-index-pass.js'; export { unreachableCodePass } from './unreachable-code.js'; export { unsupportedConditionalsPass } from './unsupported-conditionals.js'; +export { transitionTargetPass } from './transition-target.js'; export { emptyBlockPass } from './empty-block.js'; export { spreadContextPass } from './spread-context.js'; export { nullLiteralValidationPass } from './null-literal-validation.js'; diff --git a/packages/language/src/lint/transition-target.test.ts b/packages/language/src/lint/transition-target.test.ts new file mode 100644 index 0000000..e0e6465 --- /dev/null +++ b/packages/language/src/lint/transition-target.test.ts @@ -0,0 +1,108 @@ +/* + * 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 + */ + +import { describe, it, expect } from 'vitest'; +import { parse } from '@agentscript/parser'; +import { Dialect } from '../core/dialect.js'; +import { NamedBlock, NamedCollectionBlock } from '../core/block.js'; +import { StringValue, ProcedureValue } from '../core/primitives.js'; +import { LintEngine } from '../core/analysis/lint-engine.js'; +import { createSchemaContext } from '../core/analysis/scope.js'; +import { transitionTargetPass } from './transition-target.js'; + +const ProcBlock = NamedBlock('ProcBlock', { + label: StringValue.describe('Label'), + body: ProcedureValue.describe('Procedure body'), +}); + +const TestSchema = { + proc: NamedCollectionBlock(ProcBlock), +}; + +const schemaCtx = createSchemaContext({ schema: TestSchema, aliases: {} }); + +function getDiagnostics(source: string, code?: string) { + const { rootNode: root } = parse(source); + const mappingNode = + root.namedChildren.find(n => n.type === 'mapping') ?? root; + + const dialect = new Dialect(); + const result = dialect.parse(mappingNode, TestSchema); + + const engine = new LintEngine({ + passes: [transitionTargetPass()], + source: 'test', + }); + const { diagnostics } = engine.run(result.value, schemaCtx); + if (!code) return diagnostics; + return diagnostics.filter(d => d.code === code); +} + +describe('transition-target lint pass', () => { + it('does not flag a valid `transition to `', () => { + const diags = getDiagnostics(` +proc one: + label: "one" + body: -> + transition to @subagent.a +`); + expect(diags).toHaveLength(0); + }); + + it('flags a bare `transition` with no target (F1)', () => { + const diags = getDiagnostics( + ` +proc one: + label: "one" + body: -> + transition +`, + 'transition-missing-target' + ); + expect(diags).toHaveLength(1); + expect(diags[0].message).toContain('requires a target'); + expect(diags[0].severity).toBe(1); // Error + }); + + it('flags multiple `to` clauses on a single transition (F2)', () => { + const diags = getDiagnostics( + ` +proc one: + label: "one" + body: -> + transition to @subagent.a, to @subagent.b +`, + 'transition-multiple-targets' + ); + expect(diags).toHaveLength(1); + expect(diags[0].message).toContain('single'); + expect(diags[0].severity).toBe(1); + }); + + it('flags every extra `to` in a 3-target transition', () => { + const diags = getDiagnostics( + ` +proc one: + label: "one" + body: -> + transition to @subagent.a, to @subagent.b, to @subagent.c +`, + 'transition-multiple-targets' + ); + expect(diags).toHaveLength(2); + }); + + it('does not flag `transition to with k=v`', () => { + const diags = getDiagnostics(` +proc one: + label: "one" + body: -> + transition to @subagent.a with x="hi" +`); + expect(diags).toHaveLength(0); + }); +}); diff --git a/packages/language/src/lint/transition-target.ts b/packages/language/src/lint/transition-target.ts new file mode 100644 index 0000000..f697f42 --- /dev/null +++ b/packages/language/src/lint/transition-target.ts @@ -0,0 +1,93 @@ +/* + * 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 + */ + +import type { AstRoot } from '../core/types.js'; +import { isAstNodeLike } from '../core/types.js'; +import { DiagnosticSeverity, attachDiagnostic } from '../core/diagnostics.js'; +import { + storeKey, + type LintPass, + type PassStore, +} from '../core/analysis/lint-engine.js'; +import { lintDiagnostic } from './lint-utils.js'; +import { TransitionStatement, ToClause } from '../core/statements.js'; +import { toRange } from '@agentscript/types'; + +const MISSING_TARGET_MESSAGE = + "'transition' requires a target. Use 'transition to '."; + +const MULTIPLE_TARGETS_MESSAGE = + "'transition' accepts a single 'to' target. " + + "Multiple 'to' clauses are not allowed."; + +class TransitionTargetPass implements LintPass { + readonly id = storeKey('transition-target'); + readonly description = + "Validates that 'transition' statements have exactly one 'to ' clause."; + + private transitions: TransitionStatement[] = []; + + init(): void { + this.transitions = []; + } + + enterNode(_key: string, value: unknown, _parent: unknown): void { + if ( + isAstNodeLike(value) && + value.__kind === 'TransitionStatement' && + value instanceof TransitionStatement + ) { + this.transitions.push(value); + } + } + + run(_store: PassStore, _root: AstRoot): void { + for (const stmt of this.transitions) { + const toClauses = stmt.clauses.filter(c => c instanceof ToClause); + const range = stmt.__cst?.range; + if (!range) continue; + // Type narrowing for attachDiagnostic; runtime guaranteed by enterNode. + if (!isAstNodeLike(stmt)) continue; + + if (toClauses.length === 0) { + // Highlight just the `transition` keyword token (first child of the + // CST node), not the whole statement — keeps the squiggle pointed + // at the actual problem. + const keywordNode = stmt.__cst?.node?.children?.[0]; + const keywordRange = keywordNode ? toRange(keywordNode) : range; + attachDiagnostic( + stmt, + lintDiagnostic( + keywordRange, + MISSING_TARGET_MESSAGE, + DiagnosticSeverity.Error, + 'transition-missing-target' + ) + ); + } else if (toClauses.length > 1) { + // Flag every extra `to` clause (keep the first as the intended one). + for (let i = 1; i < toClauses.length; i++) { + const extra = toClauses[i]; + const extraRange = extra.__cst?.range ?? range; + attachDiagnostic( + stmt, + lintDiagnostic( + extraRange, + MULTIPLE_TARGETS_MESSAGE, + DiagnosticSeverity.Error, + 'transition-multiple-targets' + ) + ); + } + } + } + } +} + +export function transitionTargetPass(): LintPass { + return new TransitionTargetPass(); +}