From 958c192fca4c12628d9cf914735eac448c923674 Mon Sep 17 00:00:00 2001 From: "puneet.monga" Date: Mon, 15 Jun 2026 11:14:55 -0700 Subject: [PATCH 1/4] fix(lint): require single `to` target on transition statement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `transitionTargetPass` enforcing SPEC §5.10: a `transition` statement must have exactly one `to ` clause. - Bare `transition` (no target) was silently accepted. - Multiple `to` clauses (`transition to @a, to @b`) were silently accepted. Both now produce ERROR diagnostics with stable codes `transition-missing-target` and `transition-multiple-targets` and spec-referenced messages. Fixtures cover the bare form, `with`-only, missing-`to` typo, and multi-target cases. --- dialect/agentscript/src/lint/passes/index.ts | 2 + .../finding-1-transition-no-target.agent | 10 ++ .../finding-1b-transition-with-only.agent | 10 ++ ...ing-1c-transition-missing-to-keyword.agent | 16 +++ .../finding-2-multiple-to-clauses.agent | 22 ++++ packages/language/src/index.ts | 1 + packages/language/src/lint/index.ts | 1 + .../src/lint/transition-target.test.ts | 108 ++++++++++++++++++ .../language/src/lint/transition-target.ts | 92 +++++++++++++++ 9 files changed, 262 insertions(+) create mode 100644 packages/compiler/test/fixtures/scripts/finding-1-transition-no-target.agent create mode 100644 packages/compiler/test/fixtures/scripts/finding-1b-transition-with-only.agent create mode 100644 packages/compiler/test/fixtures/scripts/finding-1c-transition-missing-to-keyword.agent create mode 100644 packages/compiler/test/fixtures/scripts/finding-2-multiple-to-clauses.agent create mode 100644 packages/language/src/lint/transition-target.test.ts create mode 100644 packages/language/src/lint/transition-target.ts 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/compiler/test/fixtures/scripts/finding-1-transition-no-target.agent b/packages/compiler/test/fixtures/scripts/finding-1-transition-no-target.agent new file mode 100644 index 0000000..dd8fe02 --- /dev/null +++ b/packages/compiler/test/fixtures/scripts/finding-1-transition-no-target.agent @@ -0,0 +1,10 @@ +config: + agent_name: "Finding1" + +start_agent main: + description: "Bare `transition` (no `to `) — should be rejected per SPEC §5.10" + before_reasoning: + transition + reasoning: + instructions: -> + | hello diff --git a/packages/compiler/test/fixtures/scripts/finding-1b-transition-with-only.agent b/packages/compiler/test/fixtures/scripts/finding-1b-transition-with-only.agent new file mode 100644 index 0000000..e964150 --- /dev/null +++ b/packages/compiler/test/fixtures/scripts/finding-1b-transition-with-only.agent @@ -0,0 +1,10 @@ +config: + agent_name: "Finding1b" + +start_agent main: + description: "transition has `with` clauses but no `to` target — should still be rejected" + before_reasoning: + transition with foo="bar" + reasoning: + instructions: -> + | hello diff --git a/packages/compiler/test/fixtures/scripts/finding-1c-transition-missing-to-keyword.agent b/packages/compiler/test/fixtures/scripts/finding-1c-transition-missing-to-keyword.agent new file mode 100644 index 0000000..82fbee7 --- /dev/null +++ b/packages/compiler/test/fixtures/scripts/finding-1c-transition-missing-to-keyword.agent @@ -0,0 +1,16 @@ +config: + agent_name: "Finding1c" + +start_agent main: + description: "common typo — `transition @subagent.x` (forgot the `to` keyword)" + before_reasoning: + transition @subagent.a + reasoning: + instructions: -> + | hello + +subagent a: + description: "a" + reasoning: + instructions: -> + | I am A diff --git a/packages/compiler/test/fixtures/scripts/finding-2-multiple-to-clauses.agent b/packages/compiler/test/fixtures/scripts/finding-2-multiple-to-clauses.agent new file mode 100644 index 0000000..4867699 --- /dev/null +++ b/packages/compiler/test/fixtures/scripts/finding-2-multiple-to-clauses.agent @@ -0,0 +1,22 @@ +config: + agent_name: "Finding2" + +start_agent main: + description: "Multiple `to` clauses on one transition — only one target is meaningful" + before_reasoning: + transition to @subagent.a, to @subagent.b + reasoning: + instructions: -> + | hello + +subagent a: + description: "a" + reasoning: + instructions: -> + | I am A + +subagent b: + description: "b" + reasoning: + instructions: -> + | I am B 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..b2d6982 --- /dev/null +++ b/packages/language/src/lint/transition-target.ts @@ -0,0 +1,92 @@ +/* + * 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 ' " + + '(SPEC §5.10).'; + +const MULTIPLE_TARGETS_MESSAGE = + "'transition' accepts a single 'to' target. " + + "Multiple 'to' clauses are not allowed (SPEC §5.10)."; + +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; + + 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(); +} From 41e13e97575d29d6957e22439bbf722316fb0912 Mon Sep 17 00:00:00 2001 From: "puneet.monga" Date: Mon, 15 Jun 2026 11:32:28 -0700 Subject: [PATCH 2/4] test(lint): drop unused .agent fixtures, rely on inline tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four finding-*.agent fixtures were not referenced by any test — inline source strings in transition-target.test.ts already cover all cases, matching the pattern used by every other lint pass in the repo. --- .../finding-1-transition-no-target.agent | 10 --------- .../finding-1b-transition-with-only.agent | 10 --------- ...ing-1c-transition-missing-to-keyword.agent | 16 -------------- .../finding-2-multiple-to-clauses.agent | 22 ------------------- 4 files changed, 58 deletions(-) delete mode 100644 packages/compiler/test/fixtures/scripts/finding-1-transition-no-target.agent delete mode 100644 packages/compiler/test/fixtures/scripts/finding-1b-transition-with-only.agent delete mode 100644 packages/compiler/test/fixtures/scripts/finding-1c-transition-missing-to-keyword.agent delete mode 100644 packages/compiler/test/fixtures/scripts/finding-2-multiple-to-clauses.agent diff --git a/packages/compiler/test/fixtures/scripts/finding-1-transition-no-target.agent b/packages/compiler/test/fixtures/scripts/finding-1-transition-no-target.agent deleted file mode 100644 index dd8fe02..0000000 --- a/packages/compiler/test/fixtures/scripts/finding-1-transition-no-target.agent +++ /dev/null @@ -1,10 +0,0 @@ -config: - agent_name: "Finding1" - -start_agent main: - description: "Bare `transition` (no `to `) — should be rejected per SPEC §5.10" - before_reasoning: - transition - reasoning: - instructions: -> - | hello diff --git a/packages/compiler/test/fixtures/scripts/finding-1b-transition-with-only.agent b/packages/compiler/test/fixtures/scripts/finding-1b-transition-with-only.agent deleted file mode 100644 index e964150..0000000 --- a/packages/compiler/test/fixtures/scripts/finding-1b-transition-with-only.agent +++ /dev/null @@ -1,10 +0,0 @@ -config: - agent_name: "Finding1b" - -start_agent main: - description: "transition has `with` clauses but no `to` target — should still be rejected" - before_reasoning: - transition with foo="bar" - reasoning: - instructions: -> - | hello diff --git a/packages/compiler/test/fixtures/scripts/finding-1c-transition-missing-to-keyword.agent b/packages/compiler/test/fixtures/scripts/finding-1c-transition-missing-to-keyword.agent deleted file mode 100644 index 82fbee7..0000000 --- a/packages/compiler/test/fixtures/scripts/finding-1c-transition-missing-to-keyword.agent +++ /dev/null @@ -1,16 +0,0 @@ -config: - agent_name: "Finding1c" - -start_agent main: - description: "common typo — `transition @subagent.x` (forgot the `to` keyword)" - before_reasoning: - transition @subagent.a - reasoning: - instructions: -> - | hello - -subagent a: - description: "a" - reasoning: - instructions: -> - | I am A diff --git a/packages/compiler/test/fixtures/scripts/finding-2-multiple-to-clauses.agent b/packages/compiler/test/fixtures/scripts/finding-2-multiple-to-clauses.agent deleted file mode 100644 index 4867699..0000000 --- a/packages/compiler/test/fixtures/scripts/finding-2-multiple-to-clauses.agent +++ /dev/null @@ -1,22 +0,0 @@ -config: - agent_name: "Finding2" - -start_agent main: - description: "Multiple `to` clauses on one transition — only one target is meaningful" - before_reasoning: - transition to @subagent.a, to @subagent.b - reasoning: - instructions: -> - | hello - -subagent a: - description: "a" - reasoning: - instructions: -> - | I am A - -subagent b: - description: "b" - reasoning: - instructions: -> - | I am B From 6b25ec6c4be7d88fd6289647d217b5b18c2f11ca Mon Sep 17 00:00:00 2001 From: "puneet.monga" Date: Mon, 15 Jun 2026 11:38:43 -0700 Subject: [PATCH 3/4] fix(lint): drop spec section reference from transition diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the existing convention — no other lint pass cites SPEC § in its user-facing message. --- packages/language/src/lint/transition-target.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/language/src/lint/transition-target.ts b/packages/language/src/lint/transition-target.ts index b2d6982..4076fd4 100644 --- a/packages/language/src/lint/transition-target.ts +++ b/packages/language/src/lint/transition-target.ts @@ -18,12 +18,11 @@ import { TransitionStatement, ToClause } from '../core/statements.js'; import { toRange } from '@agentscript/types'; const MISSING_TARGET_MESSAGE = - "'transition' requires a target. Use 'transition to ' " + - '(SPEC §5.10).'; + "'transition' requires a target. Use 'transition to '."; const MULTIPLE_TARGETS_MESSAGE = "'transition' accepts a single 'to' target. " + - "Multiple 'to' clauses are not allowed (SPEC §5.10)."; + "Multiple 'to' clauses are not allowed."; class TransitionTargetPass implements LintPass { readonly id = storeKey('transition-target'); From c70e1e345698927e06131be634d203a116bca640 Mon Sep 17 00:00:00 2001 From: "puneet.monga" Date: Mon, 15 Jun 2026 11:42:51 -0700 Subject: [PATCH 4/4] fix(lint): restore isAstNodeLike guard for TS narrowing attachDiagnostic expects AstNodeLike; TransitionStatement does not widen automatically, so the guard is load-bearing for the type check even though the runtime invariant is already established by enterNode. --- packages/language/src/lint/transition-target.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/language/src/lint/transition-target.ts b/packages/language/src/lint/transition-target.ts index 4076fd4..f697f42 100644 --- a/packages/language/src/lint/transition-target.ts +++ b/packages/language/src/lint/transition-target.ts @@ -50,6 +50,8 @@ class TransitionTargetPass implements LintPass { 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