diff --git a/apps/docs/docs/architecture/diagnostic-conventions.md b/apps/docs/docs/architecture/diagnostic-conventions.md index 32f1850a..d78ff8ec 100644 --- a/apps/docs/docs/architecture/diagnostic-conventions.md +++ b/apps/docs/docs/architecture/diagnostic-conventions.md @@ -109,9 +109,9 @@ General-purpose factory for schema validation diagnostics. Accepts a `Range`, `S Parser-level errors only. Source is always `'parser'`. Code must be `'syntax-error'` or `'missing-token'`. -### `undefinedReferenceDiagnostic(range, message, referenceName, suggestion?, expected?)` +### `undefinedReferenceDiagnostic(range, message, referenceName, found, suggestion?, expected?)` -For undefined reference errors. Automatically appends "Did you mean?" hints via `formatSuggestionHint()`. +For undefined reference errors. Automatically appends "Did you mean?" hints via `formatSuggestionHint()`. `found` is the misspelled segment (used by the LSP quick fix). ### `typeMismatchDiagnostic(range, message, expectedType, actualType, source?)` diff --git a/dialect/agentfabric/src/lint/passes/rules/undefined-reference.ts b/dialect/agentfabric/src/lint/passes/rules/undefined-reference.ts index fd032fc6..642e2d24 100644 --- a/dialect/agentfabric/src/lint/passes/rules/undefined-reference.ts +++ b/dialect/agentfabric/src/lint/passes/rules/undefined-reference.ts @@ -571,6 +571,7 @@ function formatResolutionDiagnostic( range, `'${property}' is not defined in ${namespace}`, referenceName, + property, suggestion, result.members ); @@ -582,6 +583,7 @@ function formatResolutionDiagnostic( range, `'@${namespace}' is not a recognized namespace`, referenceName, + namespace, suggestion, result.knownNamespaces ); @@ -592,7 +594,8 @@ function formatResolutionDiagnostic( range, `'@${namespace}' cannot be used as a reference. ` + `This namespace is scoped to its parent block and is not directly referenceable`, - referenceName + referenceName, + namespace ); case 'colinear-miss': { @@ -601,6 +604,7 @@ function formatResolutionDiagnostic( range, `'${property}' is not defined in ${namespace}`, referenceName, + property, suggestion, result.members ); @@ -612,6 +616,7 @@ function formatResolutionDiagnostic( range, `'${property}' is not defined in ${namespace}`, referenceName, + property, suggestion, result.candidates ); diff --git a/packages/language/src/core/diagnostics.ts b/packages/language/src/core/diagnostics.ts index ea48a478..1838f15d 100644 --- a/packages/language/src/core/diagnostics.ts +++ b/packages/language/src/core/diagnostics.ts @@ -58,6 +58,7 @@ export function undefinedReferenceDiagnostic( range: Range, message: string, referenceName: string, + found: string, suggestion?: string, expected?: string[] ): Diagnostic { @@ -70,6 +71,7 @@ export function undefinedReferenceDiagnostic( source: 'agentscript-lint', data: { referenceName, + found, ...(suggestion ? { suggestion } : {}), ...(expected && expected.length > 0 ? { expected } : {}), }, diff --git a/packages/language/src/lint/undefined-reference.ts b/packages/language/src/lint/undefined-reference.ts index 3b99a303..03ab1a6f 100644 --- a/packages/language/src/lint/undefined-reference.ts +++ b/packages/language/src/lint/undefined-reference.ts @@ -607,6 +607,7 @@ function formatResolutionDiagnostic( range, `'${property}' is not defined in ${namespace}`, referenceName, + property, suggestion, result.members ); @@ -618,6 +619,7 @@ function formatResolutionDiagnostic( range, `'@${namespace}' is not a recognized namespace`, referenceName, + namespace, suggestion, result.knownNamespaces ); @@ -628,7 +630,8 @@ function formatResolutionDiagnostic( range, `'@${namespace}' cannot be used as a reference. ` + `This namespace is scoped to its parent block and is not directly referenceable`, - referenceName + referenceName, + namespace ); case 'colinear-miss': { @@ -637,6 +640,7 @@ function formatResolutionDiagnostic( range, `'${property}' is not defined in ${namespace}`, referenceName, + property, suggestion, result.members ); @@ -648,6 +652,7 @@ function formatResolutionDiagnostic( range, `'${property}' is not defined in ${namespace}`, referenceName, + property, suggestion, result.candidates ); @@ -802,6 +807,7 @@ function resolveNestedGlobalCheck( cst.range, `'${check.member}' is not defined in ${namespaceLabel}`, referenceName, + check.member, suggestion, members ); @@ -840,6 +846,7 @@ function resolveListElementAttrCheck( cst.range, `'${check.attr}' is not defined on ${namespaceLabel} elements`, referenceName, + check.attr, suggestion, members ); diff --git a/packages/lsp/src/providers/code-actions.test.ts b/packages/lsp/src/providers/code-actions.test.ts index 9e3d90f2..94941813 100644 --- a/packages/lsp/src/providers/code-actions.test.ts +++ b/packages/lsp/src/providers/code-actions.test.ts @@ -403,3 +403,57 @@ access: expect(config).not.toContain('default_agent_user'); }); }); + +/** Return the undefined-reference quick fixes offered for `source`. */ +function getUndefinedReferenceActions(source: string) { + const state = createState(source); + const diags = state.diagnostics.filter(d => d.code === 'undefined-reference'); + const actions = provideCodeActions( + state, + { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + diags + ); + return { diags, actions }; +} + +describe('undefined-reference quick-fix', () => { + const agent = (ref: string) => `config: + developer_name: "agent" + agent_type: "AgentforceServiceAgent" + +variables: + customer_name: string = "" + +start_agent topic_selector: + description: "route" + reasoning: + instructions: -> + | Hello {!${ref}} +`; + + test('replaces a misspelled member', () => { + const source = agent('@variables.custmer_name'); + const { actions } = getUndefinedReferenceActions(source); + expect(actions).toHaveLength(1); + expect(actions[0].title).toBe("Change to 'customer_name'"); + const edits = Object.values(actions[0].edit!.changes!)[0]; + expect(applyEdits(source, edits)).toBe(agent('@variables.customer_name')); + }); + + test('replaces a misspelled namespace', () => { + const source = agent('@varibles.customer_name'); + const { actions } = getUndefinedReferenceActions(source); + expect(actions).toHaveLength(1); + expect(actions[0].title).toBe("Change to 'variables'"); + const edits = Object.values(actions[0].edit!.changes!)[0]; + expect(applyEdits(source, edits)).toBe(agent('@variables.customer_name')); + }); + + test('offers nothing when there is no close match', () => { + const { diags, actions } = getUndefinedReferenceActions( + agent('@variables.zzzzzzzz') + ); + expect(diags).toHaveLength(1); + expect(actions).toHaveLength(0); + }); +}); diff --git a/packages/lsp/src/providers/code-actions.ts b/packages/lsp/src/providers/code-actions.ts index cca0d3f4..4c63cb1f 100644 --- a/packages/lsp/src/providers/code-actions.ts +++ b/packages/lsp/src/providers/code-actions.ts @@ -295,6 +295,58 @@ export function provideCodeActions( }); } + // Quick fix for a misspelled `@namespace.member` reference. The range + // covers the whole reference, so only the misspelled segment is replaced: + // the namespace (right after the `@`) or the last member. + if (diagnostic.code === 'undefined-reference') { + const found = diagnostic.data?.found as string | undefined; + const suggestion = diagnostic.data?.suggestion as string | undefined; + const referenceName = diagnostic.data?.referenceName as + | string + | undefined; + if (!found || !suggestion || !referenceName) continue; + + const { start, end } = diagnostic.range; + if (start.line !== end.line) continue; + + // `@foo.foo` can't tell the namespace from the member; offer nothing. + if (referenceName === `@${found}.${found}`) continue; + const isNamespace = referenceName.startsWith(`@${found}.`); + const foundStart = isNamespace + ? start.character + 1 + : end.character - found.length; + const line = source.split('\n')[start.line]; + if ( + line === undefined || + line.slice(foundStart, foundStart + found.length) !== found + ) { + continue; + } + + actions.push({ + title: `Change to '${suggestion}'`, + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + isPreferred: true, + edit: { + changes: { + [uri]: [ + { + range: { + start: { line: start.line, character: foundStart }, + end: { + line: start.line, + character: foundStart + found.length, + }, + }, + newText: suggestion, + }, + ], + }, + }, + }); + } + // Quick fix for unknown dialect — offer each available dialect if (diagnostic.code === 'unknown-dialect') { const availableNames = diagnostic.data?.availableNames as