Skip to content
Merged
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
2 changes: 2 additions & 0 deletions dialect/agentscript/src/lint/passes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
positionIndexPass,
unreachableCodePass,
unsupportedConditionalsPass,
transitionTargetPass,
emptyBlockPass,
unusedVariablePass,
expressionValidationPass,
Expand Down Expand Up @@ -62,6 +63,7 @@ export function defaultRules(): LintPass[] {
positionIndexPass(),
unreachableCodePass(),
unsupportedConditionalsPass(),
transitionTargetPass(),
emptyBlockPass(),
unusedVariablePass(),
expressionValidationPass(),
Expand Down
1 change: 1 addition & 0 deletions packages/language/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/language/src/lint/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
108 changes: 108 additions & 0 deletions packages/language/src/lint/transition-target.test.ts
Original file line number Diff line number Diff line change
@@ -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 <target>`', () => {
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 <target> with k=v`', () => {
const diags = getDiagnostics(`
proc one:
label: "one"
body: ->
transition to @subagent.a with x="hi"
`);
expect(diags).toHaveLength(0);
});
});
93 changes: 93 additions & 0 deletions packages/language/src/lint/transition-target.ts
Original file line number Diff line number Diff line change
@@ -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 <target>'.";

const MULTIPLE_TARGETS_MESSAGE =
"'transition' accepts a single 'to' target. " +
"Multiple 'to' clauses are not allowed.";
Comment on lines +20 to +25

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.

These just get used here, so weird to have them defined as constants.

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.

java habbit :)


class TransitionTargetPass implements LintPass {
readonly id = storeKey('transition-target');
readonly description =
"Validates that 'transition' statements have exactly one 'to <target>' 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();
}
Loading