From b9aec78b47c949c470e407a09006da84442619a8 Mon Sep 17 00:00:00 2001 From: amazingjoe Date: Fri, 6 Nov 2026 11:15:08 -0800 Subject: [PATCH] fix: prevent lint diagnostics from accumulating across runs (#99) --- dialect/agentscript/src/tests/lint.test.ts | 97 +++++++++++++++++++ .../language/src/core/analysis/lint-engine.ts | 3 +- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/dialect/agentscript/src/tests/lint.test.ts b/dialect/agentscript/src/tests/lint.test.ts index a8498015..c0770d3e 100644 --- a/dialect/agentscript/src/tests/lint.test.ts +++ b/dialect/agentscript/src/tests/lint.test.ts @@ -20,6 +20,7 @@ import { createSchemaContext, LintEngine, PassStore, + attachDiagnostic, collectDiagnostics, DependencyResolutionError, storeKey, @@ -36,6 +37,7 @@ import { NumberValue, BooleanValue, Sequence, + ActionsBlock, requiredFieldPass, constraintValidationPass, expressionValidationPass, @@ -504,6 +506,101 @@ describe('LintEngine', () => { ); }); + it('replaces required-field diagnostics on repeated runs and clears them when the rule is disabled', () => { + const schema = { actions: ActionsBlock }; + const schemaCtx = createSchemaContext({ schema, aliases: {} }); + const ast = toAstRoot( + parseWithSchema( + ` +actions: + Notify_Manager: + description: "Notify the store manager" +`, + schema + ) + ); + const rule = requiredFieldPass(); + const engine = new LintEngine({ passes: [rule] }); + const count = () => + engine + .run(ast, schemaCtx) + .diagnostics.filter(d => d.code === 'missing-required-field').length; + + const counts = [count(), count(), count()]; + engine.disable(rule.id); + counts.push(count()); + + expect(counts).toEqual([1, 1, 1, 0]); + }); + + it.each([undefined, 'custom-lint'])( + 'refreshes matching diagnostics and preserves other sources with engine source %s', + source => { + const range = { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, + }; + const preserved = ['parser', 'agentscript-schema', 'other-lint'].map( + source => ({ + range, + source, + severity: DiagnosticSeverity.Error, + message: `${source} diagnostic`, + }) + ); + const ast = toAstRoot( + parseWithSchema('test: "value"', { test: StringValue }) + ); + ast.__diagnostics = [...preserved]; + const diagnostic = { + range, + source: source ?? 'agentscript-lint', + severity: DiagnosticSeverity.Warning, + message: 'Diagnostic from the enabled rule', + }; + const rule: LintPass = { + id: storeKey('test/source-cleanup'), + description: 'Emits a diagnostic matching the engine source', + run(_store, root) { + attachDiagnostic(root, { ...diagnostic }); + }, + }; + const engine = new LintEngine({ passes: [rule], source }); + + for (let run = 0; run < 3; run++) { + expect(engine.run(ast, EMPTY_SCHEMA_CTX).diagnostics).toEqual([ + ...preserved, + diagnostic, + ]); + } + + engine.disable(rule.id); + expect(engine.run(ast, EMPTY_SCHEMA_CTX).diagnostics).toEqual(preserved); + } + ); + + it.each([ + [undefined, 'agentscript-lint'], + ['custom-lint', 'custom-lint'], + ])('tags engine errors with %s source as %s', (source, expectedSource) => { + const rule: LintPass = { + id: storeKey('test/source-error'), + description: 'Fails to trigger an engine-generated diagnostic', + run() { + throw new Error('validation failed'); + }, + }; + const engine = new LintEngine({ passes: [rule], source }); + + const { diagnostics } = engine.run(EMPTY_AST, EMPTY_SCHEMA_CTX); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: 'lint-pass-error', + source: expectedSource, + }); + }); + it('is idempotent — running twice produces the same result', () => { const ast = parseDocument(` variables: diff --git a/packages/language/src/core/analysis/lint-engine.ts b/packages/language/src/core/analysis/lint-engine.ts index 4f00b927..75c1d628 100644 --- a/packages/language/src/core/analysis/lint-engine.ts +++ b/packages/language/src/core/analysis/lint-engine.ts @@ -22,6 +22,7 @@ import type { NamedMap } from '../block.js'; import { isNamedMap, isAstNodeLike } from '../types.js'; import type { ScopeContext, SchemaContext } from './scope.js'; import { collectDiagnostics, dispatchAstChildren } from './ast-walkers.js'; +import { LINT_SOURCE } from '../../lint/lint-utils.js'; /** A branded string that carries value type `T` at the type level. */ export type StoreKey = string & { readonly __type: T }; @@ -252,7 +253,7 @@ export class LintEngine { private readonly source: string; constructor(options?: { passes?: readonly LintPass[]; source?: string }) { - this.source = options?.source ?? 'lint'; + this.source = options?.source ?? LINT_SOURCE; for (const p of options?.passes ?? []) this.addPass(p); }