From 243f50d8223b83a7d6dd331d3164b96671c0639c Mon Sep 17 00:00:00 2001 From: Bicheng Wang Date: Thu, 8 Oct 2026 12:40:41 -0700 Subject: [PATCH] feat(lsp): add quick fix for misspelled @namespace references The undefined-reference lint already computes a "did you mean" suggestion but the editor only showed it in the message. Add a "Change to ''" quick fix that replaces just the misspelled segment (the namespace or the last member) of the reference. To know which segment was misspelled, undefinedReferenceDiagnostic now records it in data.found, matching what invalid-modifier and unknown-type diagnostics already do. --- .../architecture/diagnostic-conventions.md | 4 +- .../lint/passes/rules/undefined-reference.ts | 7 ++- packages/language/src/core/diagnostics.ts | 2 + .../language/src/lint/undefined-reference.ts | 9 +++- .../lsp/src/providers/code-actions.test.ts | 54 +++++++++++++++++++ packages/lsp/src/providers/code-actions.ts | 52 ++++++++++++++++++ 6 files changed, 124 insertions(+), 4 deletions(-) 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