From ae2095bb8256371b58501934b5f627dcdd631720 Mon Sep 17 00:00:00 2001 From: RodgeFu Date: Thu, 6 Nov 2025 11:19:40 +0800 Subject: [PATCH 01/14] support async callback in linter --- packages/compiler/src/core/linter.ts | 55 +++++++++++++++---- packages/compiler/src/core/messages.ts | 6 ++ packages/compiler/src/core/program.ts | 19 +++++-- packages/compiler/src/core/semantic-walker.ts | 15 ++++- packages/compiler/src/core/types.ts | 3 + packages/compiler/test/core/linter.test.ts | 14 ++--- 6 files changed, 88 insertions(+), 24 deletions(-) diff --git a/packages/compiler/src/core/linter.ts b/packages/compiler/src/core/linter.ts index 9c098aab924..4ccae6377be 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -1,3 +1,4 @@ +import { isPromise } from "util/types"; import { DiagnosticCollector, compilerAssert, createDiagnosticCollector } from "./diagnostics.js"; import { getLocationContext } from "./helpers/location-context.js"; import { defineLinter } from "./library.js"; @@ -6,7 +7,7 @@ import { createUnusedUsingLinterRule } from "./linter-rules/unused-using.rule.js import { createDiagnostic } from "./messages.js"; import type { Program } from "./program.js"; import { EventEmitter, mapEventEmitterToNodeListener, navigateProgram } from "./semantic-walker.js"; -import { startTimer, time } from "./stats.js"; +import { startTimer } from "./stats.js"; import { Diagnostic, DiagnosticMessages, @@ -26,7 +27,12 @@ type LinterLibraryInstance = { linter: LinterResolvedDefinition }; export interface Linter { extendRuleSet(ruleSet: LinterRuleSet): Promise; registerLinterLibrary(name: string, lib?: LinterLibraryInstance): void; - lint(): LinterResult; + lint(options: LinterOptions): Promise; +} + +export interface LinterOptions { + /** Whether to run async linter rules or sync linter rules */ + asyncRules: boolean; } export interface LinterStats { @@ -157,7 +163,7 @@ export function createLinter( return diagnostics.diagnostics; } - function lint(): LinterResult { + async function lint(options: LinterOptions): Promise { const diagnostics = createDiagnosticCollector(); const eventEmitter = new EventEmitter(); const stats: LinterStats = { @@ -173,19 +179,44 @@ export function createLinter( ); const timer = startTimer(); + const allPromises: Promise[] = []; for (const rule of enabledRules.values()) { - const createTiming = startTimer(); - const listener = rule.create(createLinterRuleContext(program, rule, diagnostics)); - stats.runtime.rules[rule.id] = createTiming.end(); - for (const [name, cb] of Object.entries(listener)) { - const timedCb = (...args: any[]) => { - const duration = time(() => (cb as any)(...args)); - stats.runtime.rules[rule.id] += duration; - }; - eventEmitter.on(name as any, timedCb); + if ((rule.async ?? false) === options.asyncRules) { + const createTiming = startTimer(); + const listener = rule.create(createLinterRuleContext(program, rule, diagnostics)); + stats.runtime.rules[rule.id] = createTiming.end(); + for (const [name, cb] of Object.entries(listener)) { + const timedCb = (...args: any[]) => { + const timer = startTimer(); + const result = (cb as any)(...args); + if (isPromise(result)) { + if (rule.async !== true) { + diagnostics.add( + createDiagnostic({ + code: "sync-rule-returns-promise", + format: { ruleName: rule.name }, + target: NoTarget, + }), + ); + } + const rr = result.then(() => { + const duration = timer.end(); + stats.runtime.rules[rule.id] += duration; + }); + allPromises.push(rr); + } else { + const duration = timer.end(); + stats.runtime.rules[rule.id] += duration; + } + }; + eventEmitter.on(name as any, timedCb); + } } } navigateProgram(program, mapEventEmitterToNodeListener(eventEmitter)); + if (allPromises.length > 0) { + await Promise.all(allPromises); + } stats.runtime.total = timer.end(); return { diagnostics: diagnostics.diagnostics, stats }; } diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 5c42d275d59..689f72da1ce 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -760,6 +760,12 @@ const diagnostics = { default: paramMessage`Rule "${"ruleName"}" has been enabled and disabled in the same ruleset.`, }, }, + "sync-rule-returns-promise": { + severity: "warning", + messages: { + default: paramMessage`Synchronous rule "${"ruleName"}" should not return a Promise. Consider marking the rule as asynchronous by setting the "async" property to true in the rule definition.`, + }, + }, /** * Formatter diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index 528ca28e773..68e0e1e0264 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -323,10 +323,21 @@ async function createProgram( return { program, shouldAbort: true }; } - // Linter stage - const lintResult = linter.lint(); - runtimeStats.linter = lintResult.stats.runtime; - program.reportDiagnostics(lintResult.diagnostics); + // Sync linter stage + const syncLintResult = await linter.lint({ asyncRules: false }); + program.reportDiagnostics(syncLintResult.diagnostics); + + // Async linter stage + const asyncLintResult = await linter.lint({ asyncRules: true }); + program.reportDiagnostics(asyncLintResult.diagnostics); + + runtimeStats.linter = { + total: syncLintResult.stats.runtime.total + asyncLintResult.stats.runtime.total, + rules: { + ...syncLintResult.stats.runtime.rules, + ...asyncLintResult.stats.runtime.rules, + }, + }; return { program, shouldAbort: false }; diff --git a/packages/compiler/src/core/semantic-walker.ts b/packages/compiler/src/core/semantic-walker.ts index 6342db3d26a..80891029bed 100644 --- a/packages/compiler/src/core/semantic-walker.ts +++ b/packages/compiler/src/core/semantic-walker.ts @@ -1,3 +1,4 @@ +import { isPromise } from "util/types"; import type { Program } from "./program.js"; import { isTemplateDeclaration } from "./type-utils.js"; import { @@ -60,6 +61,8 @@ export function navigateProgram( context.emit("root", program); navigateNamespaceType(program.getGlobalNamespaceType(), context); + + context.emit("exitRoot", program); } /** @@ -148,7 +151,16 @@ function createNavigationContext( ): NavigationContext { return { visited: new Set(), - emit: (key, ...args) => (listeners as any)[key]?.(...(args as [any])), + emit: (key, ...args) => { + const r = (listeners as any)[key]?.(...(args as [any])); + if (isPromise(r)) { + // We won't await here to keep the API sync which is good enough for some scenarios which don't require await + // TODO: consider support await in the future when we have a real scenario for it which worth the API change + return undefined; + } else { + return r; + } + }, options: computeOptions(options), }; } @@ -476,6 +488,7 @@ export class EventEmitter any }> { const eventNames: Array = [ "root", + "exitRoot", "templateParameter", "exitTemplateParameter", "scalar", diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 4cb2b995198..5c0fcf48ceb 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2139,6 +2139,7 @@ export type TypeListeners = UnionToIntersection>; export type SemanticNodeListener = { root?: (context: Program) => void | undefined; + exitRoot?: (context: Program) => void | undefined | Promise; } & TypeListeners; export type DiagnosticReportWithoutTarget< @@ -2334,6 +2335,8 @@ export interface LinterRuleDefinition): SemanticNodeListener; } diff --git a/packages/compiler/test/core/linter.test.ts b/packages/compiler/test/core/linter.test.ts index 08137ace738..f5bfc1384ce 100644 --- a/packages/compiler/test/core/linter.test.ts +++ b/packages/compiler/test/core/linter.test.ts @@ -82,7 +82,7 @@ describe("compiler: linter", () => { const linter = await createTestLinter(`model Foo {}`, { rules: [noModelFoo], }); - expectDiagnosticEmpty(linter.lint().diagnostics); + expectDiagnosticEmpty((await linter.lint({ asyncRules: false })).diagnostics); }); it("enabling a rule that doesn't exists emit a diagnostic", async () => { @@ -159,7 +159,7 @@ describe("compiler: linter", () => { const linter = await createTestLinterAndEnableRules(files, { rules: [noModelFoo], }); - expectDiagnosticEmpty(linter.lint().diagnostics); + expectDiagnosticEmpty((await linter.lint({ asyncRules: false })).diagnostics); }); it("emit diagnostic when in the user code", async () => { @@ -174,7 +174,7 @@ describe("compiler: linter", () => { const linter = await createTestLinterAndEnableRules(files, { rules: [noModelFoo], }); - expectDiagnostics(linter.lint().diagnostics, { + expectDiagnostics((await linter.lint({ asyncRules: false })).diagnostics, { severity: "warning", code: "@typespec/test-linter/no-model-foo", message: `Cannot call model 'Foo'`, @@ -192,7 +192,7 @@ describe("compiler: linter", () => { enable: { "@typespec/test-linter/no-model-foo": true }, }), ); - expectDiagnostics(linter.lint().diagnostics, { + expectDiagnostics((await linter.lint({ asyncRules: false })).diagnostics, { severity: "warning", code: "@typespec/test-linter/no-model-foo", message: `Cannot call model 'Foo'`, @@ -208,7 +208,7 @@ describe("compiler: linter", () => { enable: { "@typespec/test-linter/no-model-foo": true }, }), ); - expectDiagnosticEmpty(linter.lint().diagnostics); + expectDiagnosticEmpty((await linter.lint({ asyncRules: false })).diagnostics); }); }); @@ -222,7 +222,7 @@ describe("compiler: linter", () => { extends: ["@typespec/test-linter/all"], }), ); - expectDiagnostics(linter.lint().diagnostics, { + expectDiagnostics((await linter.lint({ asyncRules: false })).diagnostics, { severity: "warning", code: "@typespec/test-linter/no-model-foo", message: `Cannot call model 'Foo'`, @@ -242,7 +242,7 @@ describe("compiler: linter", () => { extends: ["@typespec/test-linter/custom"], }), ); - expectDiagnostics(linter.lint().diagnostics, { + expectDiagnostics((await linter.lint({ asyncRules: false })).diagnostics, { severity: "warning", code: "@typespec/test-linter/no-model-foo", message: `Cannot call model 'Foo'`, From 726406963f6bc9c44ed4613ea0715d95f44bec87 Mon Sep 17 00:00:00 2001 From: RodgeFu Date: Thu, 6 Nov 2025 12:31:11 +0800 Subject: [PATCH 02/14] add test --- packages/compiler/test/core/linter.test.ts | 120 ++++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/packages/compiler/test/core/linter.test.ts b/packages/compiler/test/core/linter.test.ts index f5bfc1384ce..6e54fc75eaf 100644 --- a/packages/compiler/test/core/linter.test.ts +++ b/packages/compiler/test/core/linter.test.ts @@ -2,7 +2,12 @@ import { describe, it } from "vitest"; import { createLinterRule, createTypeSpecLibrary } from "../../src/core/library.js"; import { Linter, createLinter, resolveLinterDefinition } from "../../src/core/linter.js"; -import type { LibraryInstance, LinterDefinition } from "../../src/index.js"; +import type { + Interface, + LibraryInstance, + LinterDefinition, + LinterRuleContext, +} from "../../src/index.js"; import { createTestHost, expectDiagnosticEmpty, @@ -29,6 +34,42 @@ const noModelFoo = createLinterRule({ }, }); +const noInterfaceFooAsync = createLinterRule({ + name: "no-interface-foo2-async", + description: "", + severity: "warning", + messages: { + default: "Cannot call interface 'Foo2' (async rule)", + }, + async: true, + create( + context: LinterRuleContext<{ + readonly default: "Cannot call interface 'Foo2' (async rule)"; + }> & { interfaceToCheck?: Interface[] }, + ) { + return { + interface: (target) => { + if (!context.interfaceToCheck) { + context.interfaceToCheck = []; + } + context.interfaceToCheck.push(target); + }, + exitRoot: async () => { + const r = await new Promise((resolve) => { + setTimeout(() => { + resolve(context.interfaceToCheck?.filter((t) => t.name === "Foo2") ?? []); + }, 50); + }); + r.forEach((target) => { + context.reportDiagnostic({ + target, + }); + }); + }, + }; + }, +}); + describe("compiler: linter", () => { async function createTestLinter( code: string | Record, @@ -85,6 +126,13 @@ describe("compiler: linter", () => { expectDiagnosticEmpty((await linter.lint({ asyncRules: false })).diagnostics); }); + it("registering a rule doesn't enable it: async", async () => { + const linter = await createTestLinter(`interface Foo2 {}`, { + rules: [noInterfaceFooAsync], + }); + expectDiagnosticEmpty((await linter.lint({ asyncRules: true })).diagnostics); + }); + it("enabling a rule that doesn't exists emit a diagnostic", async () => { const linter = await createTestLinter(`model Foo {}`, { rules: [noModelFoo], @@ -199,6 +247,22 @@ describe("compiler: linter", () => { }); }); + it("emit a diagnostic if rule report one: async", async () => { + const linter = await createTestLinter(`interface Foo2 {}`, { + rules: [noInterfaceFooAsync], + }); + expectDiagnosticEmpty( + await linter.extendRuleSet({ + enable: { "@typespec/test-linter/no-interface-foo2-async": true }, + }), + ); + expectDiagnostics((await linter.lint({ asyncRules: true })).diagnostics, { + severity: "warning", + code: "@typespec/test-linter/no-interface-foo2-async", + message: `Cannot call interface 'Foo2' (async rule)`, + }); + }); + it("emit no diagnostic if rules report none", async () => { const linter = await createTestLinter(`model Bar {}`, { rules: [noModelFoo], @@ -210,6 +274,18 @@ describe("compiler: linter", () => { ); expectDiagnosticEmpty((await linter.lint({ asyncRules: false })).diagnostics); }); + + it("emit no diagnostic if rule report none: async", async () => { + const linter = await createTestLinter(`interface Foo3 {}`, { + rules: [noInterfaceFooAsync], + }); + expectDiagnosticEmpty( + await linter.extendRuleSet({ + enable: { "@typespec/test-linter/no-interface-foo2-async": true }, + }), + ); + expectDiagnosticEmpty((await linter.lint({ asyncRules: true })).diagnostics); + }); }); describe("when enabling a ruleset", () => { @@ -288,4 +364,46 @@ describe("compiler: linter", () => { expectDiagnosticEmpty(diagnostics); }); }); + + describe("async and sync rule together", () => { + it("runs both async and sync rules", async () => { + const linter = await createTestLinterAndEnableRules( + { + "main.tsp": ` + model Foo {} + interface Foo2 {} + `, + }, + { + rules: [noModelFoo, noInterfaceFooAsync], + }, + ); + + const resultSync = await linter.lint({ asyncRules: false }); + expectDiagnostics( + resultSync.diagnostics, + { + severity: "warning", + code: "@typespec/test-linter/no-model-foo", + message: `Cannot call model 'Foo'`, + }, + { + strict: true, + }, + ); + + const resultAsync = await linter.lint({ asyncRules: true }); + expectDiagnostics( + resultAsync.diagnostics, + { + severity: "warning", + code: "@typespec/test-linter/no-interface-foo2-async", + message: `Cannot call interface 'Foo2' (async rule)`, + }, + { + strict: true, + }, + ); + }); + }); }); From d91101e2aa3992d568a18f4952b7273eae903e4e Mon Sep 17 00:00:00 2001 From: RodgeFu Date: Thu, 6 Nov 2025 12:36:53 +0800 Subject: [PATCH 03/14] add changelog --- .../changes/linter-async-callback-2025-10-6-12-33-19.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .chronus/changes/linter-async-callback-2025-10-6-12-33-19.md diff --git a/.chronus/changes/linter-async-callback-2025-10-6-12-33-19.md b/.chronus/changes/linter-async-callback-2025-10-6-12-33-19.md new file mode 100644 index 00000000000..083a7274e00 --- /dev/null +++ b/.chronus/changes/linter-async-callback-2025-10-6-12-33-19.md @@ -0,0 +1,8 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +- Add 'exitRoot' final event for semantic walker and linter rules +- Support 'async' in linter definition and async function as callback for 'exitRoot' event. From 6f4e3cccbdd682e322bc27d26c5c7c3104450da5 Mon Sep 17 00:00:00 2001 From: RodgeFu Date: Thu, 6 Nov 2025 13:31:42 +0800 Subject: [PATCH 04/14] add our own isPromise instead of util/isPromise which is not supported by browser --- packages/compiler/src/core/linter.ts | 2 +- packages/compiler/src/core/semantic-walker.ts | 2 +- packages/compiler/src/utils/misc.ts | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/core/linter.ts b/packages/compiler/src/core/linter.ts index 4ccae6377be..56e401de40d 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -1,4 +1,4 @@ -import { isPromise } from "util/types"; +import { isPromise } from "../utils/misc.js"; import { DiagnosticCollector, compilerAssert, createDiagnosticCollector } from "./diagnostics.js"; import { getLocationContext } from "./helpers/location-context.js"; import { defineLinter } from "./library.js"; diff --git a/packages/compiler/src/core/semantic-walker.ts b/packages/compiler/src/core/semantic-walker.ts index 80891029bed..d0dc477644d 100644 --- a/packages/compiler/src/core/semantic-walker.ts +++ b/packages/compiler/src/core/semantic-walker.ts @@ -1,4 +1,4 @@ -import { isPromise } from "util/types"; +import { isPromise } from "../utils/misc.js"; import type { Program } from "./program.js"; import { isTemplateDeclaration } from "./type-utils.js"; import { diff --git a/packages/compiler/src/utils/misc.ts b/packages/compiler/src/utils/misc.ts index 6adcb2b15b1..688f097f0e9 100644 --- a/packages/compiler/src/utils/misc.ts +++ b/packages/compiler/src/utils/misc.ts @@ -484,3 +484,7 @@ class RekeyableMapImpl implements RekeyableMap { return true; } } + +export function isPromise(value: any): value is Promise { + return value && typeof value.then === "function"; +} From f3281b1565a82536bec0d9942cd9d4b0498defc2 Mon Sep 17 00:00:00 2001 From: RodgeFu Date: Fri, 7 Nov 2025 13:12:12 +0800 Subject: [PATCH 05/14] update --- packages/compiler/src/core/linter.ts | 27 ++++++---- packages/compiler/src/core/semantic-walker.ts | 3 -- packages/compiler/src/core/types.ts | 35 +++++++++--- packages/compiler/src/testing/rule-tester.ts | 3 ++ packages/compiler/test/core/linter.test.ts | 54 ++++++++++++++----- 5 files changed, 90 insertions(+), 32 deletions(-) diff --git a/packages/compiler/src/core/linter.ts b/packages/compiler/src/core/linter.ts index 56e401de40d..c478a2a53e0 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -27,10 +27,10 @@ type LinterLibraryInstance = { linter: LinterResolvedDefinition }; export interface Linter { extendRuleSet(ruleSet: LinterRuleSet): Promise; registerLinterLibrary(name: string, lib?: LinterLibraryInstance): void; - lint(options: LinterOptions): Promise; + lint(options: LintOptions): Promise; } -export interface LinterOptions { +export interface LintOptions { /** Whether to run async linter rules or sync linter rules */ asyncRules: boolean; } @@ -163,7 +163,7 @@ export function createLinter( return diagnostics.diagnostics; } - async function lint(options: LinterOptions): Promise { + async function lint(options: LintOptions): Promise { const diagnostics = createDiagnosticCollector(); const eventEmitter = new EventEmitter(); const stats: LinterStats = { @@ -179,6 +179,7 @@ export function createLinter( ); const timer = startTimer(); + const exitCallbacks = []; const allPromises: Promise[] = []; for (const rule of enabledRules.values()) { if ((rule.async ?? false) === options.asyncRules) { @@ -191,12 +192,9 @@ export function createLinter( const result = (cb as any)(...args); if (isPromise(result)) { if (rule.async !== true) { - diagnostics.add( - createDiagnostic({ - code: "sync-rule-returns-promise", - format: { ruleName: rule.name }, - target: NoTarget, - }), + compilerAssert( + false /* throw if this is not true */, + `Linter rule "${rule.id}" is not marked as async but returned a promise from the "${name}" callback.`, ); } const rr = result.then(() => { @@ -209,14 +207,23 @@ export function createLinter( stats.runtime.rules[rule.id] += duration; } }; - eventEmitter.on(name as any, timedCb); + if (name === "exit") { + // we need to trigger 'exit' callbacks explicitly after semantic walker is done + exitCallbacks.push(timedCb); + } else { + eventEmitter.on(name as any, timedCb); + } } } } navigateProgram(program, mapEventEmitterToNodeListener(eventEmitter)); + for (const cb of exitCallbacks) { + cb(program); + } if (allPromises.length > 0) { await Promise.all(allPromises); } + stats.runtime.total = timer.end(); return { diagnostics: diagnostics.diagnostics, stats }; } diff --git a/packages/compiler/src/core/semantic-walker.ts b/packages/compiler/src/core/semantic-walker.ts index d0dc477644d..064c205b08e 100644 --- a/packages/compiler/src/core/semantic-walker.ts +++ b/packages/compiler/src/core/semantic-walker.ts @@ -61,8 +61,6 @@ export function navigateProgram( context.emit("root", program); navigateNamespaceType(program.getGlobalNamespaceType(), context); - - context.emit("exitRoot", program); } /** @@ -488,7 +486,6 @@ export class EventEmitter any }> { const eventNames: Array = [ "root", - "exitRoot", "templateParameter", "exitTemplateParameter", "scalar", diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 5c0fcf48ceb..4f54de6bedd 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2139,7 +2139,6 @@ export type TypeListeners = UnionToIntersection>; export type SemanticNodeListener = { root?: (context: Program) => void | undefined; - exitRoot?: (context: Program) => void | undefined | Promise; } & TypeListeners; export type DiagnosticReportWithoutTarget< @@ -2324,7 +2323,7 @@ export interface LinterResolvedDefinition { }; } -export interface LinterRuleDefinition { +interface LinterRuleDefinitionBase { /** Rule name (without the library name) */ name: N; /** Rule default severity. */ @@ -2335,18 +2334,40 @@ export interface LinterRuleDefinition + extends LinterRuleDefinitionBase { /** Whether this is an async rule. Default is false */ - async?: boolean; + async?: false; /** Creator */ - create(context: LinterRuleContext): SemanticNodeListener; + create( + context: LinterRuleContext, + ): SemanticNodeListener & { exit?: (context: Program) => void | undefined }; } +interface LinterRuleDefinitionAsync + extends LinterRuleDefinitionBase { + /** Whether this is an async rule. Default is false */ + async: true; + /** Creator */ + create( + context: LinterRuleContext, + ): SemanticNodeListener & { exit?: (context: Program) => Promise }; +} + +export type LinterRuleDefinition = + | LinterRuleDefinitionSync + | LinterRuleDefinitionAsync; + /** Resolved instance of a linter rule that will run. */ -export interface LinterRule - extends LinterRuleDefinition { +export type LinterRule = LinterRuleDefinition< + N, + DM +> & { /** Expanded rule id in format `:` */ id: string; -} +}; /** Reference to a rule. In this format `:` */ export type RuleRef = `${string}/${string}`; diff --git a/packages/compiler/src/testing/rule-tester.ts b/packages/compiler/src/testing/rule-tester.ts index e4ca92303a5..948e1f6e860 100644 --- a/packages/compiler/src/testing/rule-tester.ts +++ b/packages/compiler/src/testing/rule-tester.ts @@ -133,6 +133,9 @@ export function createLinterRuleTester( const context = createLinterRuleContext(runner.program, rule, diagnostics); const listener = ruleDef.create(context); navigateProgram(runner.program, listener); + if (listener.exit) { + await listener.exit(runner.program); + } // No diagnostics should have been reported to the program. If it happened the rule is calling reportDiagnostic directly and should NOT be doing that. expectDiagnosticEmpty(runner.program.diagnostics); return [res, diagnostics.diagnostics]; diff --git a/packages/compiler/test/core/linter.test.ts b/packages/compiler/test/core/linter.test.ts index 6e54fc75eaf..66ac6f31065 100644 --- a/packages/compiler/test/core/linter.test.ts +++ b/packages/compiler/test/core/linter.test.ts @@ -2,11 +2,11 @@ import { describe, it } from "vitest"; import { createLinterRule, createTypeSpecLibrary } from "../../src/core/library.js"; import { Linter, createLinter, resolveLinterDefinition } from "../../src/core/linter.js"; -import type { - Interface, - LibraryInstance, - LinterDefinition, - LinterRuleContext, +import { + type Interface, + type LibraryInstance, + type LinterDefinition, + type LinterRuleContext, } from "../../src/index.js"; import { createTestHost, @@ -34,6 +34,29 @@ const noModelFoo = createLinterRule({ }, }); +const exitLintRuleSync = createLinterRule({ + name: "exit-lint-rule-sync", + description: "", + severity: "warning", + messages: { + default: "Exit lint rule sync called", + }, + async: false, + create(context: any) { + return { + interface: (target) => { + context.lastInterface = target; + }, + exit: () => { + context.reportDiagnostic({ + target: context.lastInterface, + messageId: "default", + }); + }, + }; + }, +}); + const noInterfaceFooAsync = createLinterRule({ name: "no-interface-foo2-async", description: "", @@ -54,7 +77,7 @@ const noInterfaceFooAsync = createLinterRule({ } context.interfaceToCheck.push(target); }, - exitRoot: async () => { + exit: async () => { const r = await new Promise((resolve) => { setTimeout(() => { resolve(context.interfaceToCheck?.filter((t) => t.name === "Foo2") ?? []); @@ -375,18 +398,25 @@ describe("compiler: linter", () => { `, }, { - rules: [noModelFoo, noInterfaceFooAsync], + rules: [noModelFoo, noInterfaceFooAsync, exitLintRuleSync], }, ); const resultSync = await linter.lint({ asyncRules: false }); expectDiagnostics( resultSync.diagnostics, - { - severity: "warning", - code: "@typespec/test-linter/no-model-foo", - message: `Cannot call model 'Foo'`, - }, + [ + { + severity: "warning", + code: "@typespec/test-linter/no-model-foo", + message: `Cannot call model 'Foo'`, + }, + { + severity: "warning", + code: "@typespec/test-linter/exit-lint-rule-sync", + message: "Exit lint rule sync called", + }, + ], { strict: true, }, From b41d4393828207b5d62bcfb9909561adb4974f53 Mon Sep 17 00:00:00 2001 From: RodgeFu Date: Fri, 7 Nov 2025 13:23:47 +0800 Subject: [PATCH 06/14] update changelog --- .chronus/changes/linter-async-callback-2025-10-6-12-33-19.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.chronus/changes/linter-async-callback-2025-10-6-12-33-19.md b/.chronus/changes/linter-async-callback-2025-10-6-12-33-19.md index 083a7274e00..b4aa8b1f17a 100644 --- a/.chronus/changes/linter-async-callback-2025-10-6-12-33-19.md +++ b/.chronus/changes/linter-async-callback-2025-10-6-12-33-19.md @@ -4,5 +4,5 @@ packages: - "@typespec/compiler" --- -- Add 'exitRoot' final event for semantic walker and linter rules -- Support 'async' in linter definition and async function as callback for 'exitRoot' event. +- Add 'exit' final event for linter rules +- Support 'async' in linter definition and async function as callback for 'exit' event. From 2770a8693a7c47b7129d109cfc2dbaf054d91ba0 Mon Sep 17 00:00:00 2001 From: RodgeFu Date: Fri, 7 Nov 2025 13:26:57 +0800 Subject: [PATCH 07/14] remove msg --- packages/compiler/src/core/messages.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 689f72da1ce..5c42d275d59 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -760,12 +760,6 @@ const diagnostics = { default: paramMessage`Rule "${"ruleName"}" has been enabled and disabled in the same ruleset.`, }, }, - "sync-rule-returns-promise": { - severity: "warning", - messages: { - default: paramMessage`Synchronous rule "${"ruleName"}" should not return a Promise. Consider marking the rule as asynchronous by setting the "async" property to true in the rule definition.`, - }, - }, /** * Formatter From 87bdfcbf54fdb22dea76ddbe6f83578307b4edae Mon Sep 17 00:00:00 2001 From: RodgeFu Date: Fri, 7 Nov 2025 13:29:28 +0800 Subject: [PATCH 08/14] revert semantic walker's change --- packages/compiler/src/core/semantic-walker.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/compiler/src/core/semantic-walker.ts b/packages/compiler/src/core/semantic-walker.ts index 064c205b08e..6342db3d26a 100644 --- a/packages/compiler/src/core/semantic-walker.ts +++ b/packages/compiler/src/core/semantic-walker.ts @@ -1,4 +1,3 @@ -import { isPromise } from "../utils/misc.js"; import type { Program } from "./program.js"; import { isTemplateDeclaration } from "./type-utils.js"; import { @@ -149,16 +148,7 @@ function createNavigationContext( ): NavigationContext { return { visited: new Set(), - emit: (key, ...args) => { - const r = (listeners as any)[key]?.(...(args as [any])); - if (isPromise(r)) { - // We won't await here to keep the API sync which is good enough for some scenarios which don't require await - // TODO: consider support await in the future when we have a real scenario for it which worth the API change - return undefined; - } else { - return r; - } - }, + emit: (key, ...args) => (listeners as any)[key]?.(...(args as [any])), options: computeOptions(options), }; } From 62f96c831ae187b947d0338db98cefbb6c1c87b3 Mon Sep 17 00:00:00 2001 From: RodgeFu Date: Mon, 10 Nov 2025 13:18:34 +0800 Subject: [PATCH 09/14] update per comment --- packages/compiler/src/core/linter.ts | 91 +++++++++++++--------- packages/compiler/src/core/program.ts | 19 +---- packages/compiler/src/utils/misc.ts | 4 +- packages/compiler/test/core/linter.test.ts | 42 ++++------ 4 files changed, 77 insertions(+), 79 deletions(-) diff --git a/packages/compiler/src/core/linter.ts b/packages/compiler/src/core/linter.ts index c478a2a53e0..f6264583b69 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -27,12 +27,7 @@ type LinterLibraryInstance = { linter: LinterResolvedDefinition }; export interface Linter { extendRuleSet(ruleSet: LinterRuleSet): Promise; registerLinterLibrary(name: string, lib?: LinterLibraryInstance): void; - lint(options: LintOptions): Promise; -} - -export interface LintOptions { - /** Whether to run async linter rules or sync linter rules */ - asyncRules: boolean; + lint(): Promise; } export interface LinterStats { @@ -163,7 +158,25 @@ export function createLinter( return diagnostics.diagnostics; } - async function lint(options: LintOptions): Promise { + async function lint(): Promise { + const syncLintResult = await lintInternal(false /* asyncRules */); + const asyncLintResult = await lintInternal(true /* asyncRules */); + + return { + diagnostics: [...syncLintResult.diagnostics, ...asyncLintResult.diagnostics], + stats: { + runtime: { + total: syncLintResult.stats.runtime.total + asyncLintResult.stats.runtime.total, + rules: { + ...syncLintResult.stats.runtime.rules, + ...asyncLintResult.stats.runtime.rules, + }, + }, + }, + }; + } + + async function lintInternal(asyncRules: boolean): Promise { const diagnostics = createDiagnosticCollector(); const eventEmitter = new EventEmitter(); const stats: LinterStats = { @@ -172,47 +185,51 @@ export function createLinter( rules: {}, }, }; + const filteredRules = new Map>(); + for (const [ruleId, rule] of enabledRules) { + if ((rule.async ?? false) === asyncRules) { + filteredRules.set(ruleId, rule); + } + } tracer.trace( "lint", - `Running linter with following rules:\n` + - [...enabledRules.keys()].map((x) => ` - ${x}`).join("\n"), + `Running ${asyncRules ? "async" : "sync"} linter with following rules:\n` + + [...filteredRules.keys()].map((x) => ` - ${x}`).join("\n"), ); const timer = startTimer(); const exitCallbacks = []; const allPromises: Promise[] = []; - for (const rule of enabledRules.values()) { - if ((rule.async ?? false) === options.asyncRules) { - const createTiming = startTimer(); - const listener = rule.create(createLinterRuleContext(program, rule, diagnostics)); - stats.runtime.rules[rule.id] = createTiming.end(); - for (const [name, cb] of Object.entries(listener)) { - const timedCb = (...args: any[]) => { - const timer = startTimer(); - const result = (cb as any)(...args); - if (isPromise(result)) { - if (rule.async !== true) { - compilerAssert( - false /* throw if this is not true */, - `Linter rule "${rule.id}" is not marked as async but returned a promise from the "${name}" callback.`, - ); - } - const rr = result.then(() => { - const duration = timer.end(); - stats.runtime.rules[rule.id] += duration; - }); - allPromises.push(rr); - } else { + for (const rule of filteredRules.values()) { + const createTiming = startTimer(); + const listener = rule.create(createLinterRuleContext(program, rule, diagnostics)); + stats.runtime.rules[rule.id] = createTiming.end(); + for (const [name, cb] of Object.entries(listener)) { + const timedCb = (...args: any[]) => { + const timer = startTimer(); + const result = (cb as any)(...args); + if (isPromise(result)) { + if (rule.async !== true) { + compilerAssert( + false /* throw if this is not true */, + `Linter rule "${rule.id}" is not marked as async but returned a promise from the "${name}" callback.`, + ); + } + const rr = result.then(() => { const duration = timer.end(); stats.runtime.rules[rule.id] += duration; - } - }; - if (name === "exit") { - // we need to trigger 'exit' callbacks explicitly after semantic walker is done - exitCallbacks.push(timedCb); + }); + allPromises.push(rr); } else { - eventEmitter.on(name as any, timedCb); + const duration = timer.end(); + stats.runtime.rules[rule.id] += duration; } + }; + if (name === "exit") { + // we need to trigger 'exit' callbacks explicitly after semantic walker is done + exitCallbacks.push(timedCb); + } else { + eventEmitter.on(name as any, timedCb); } } } diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index 68e0e1e0264..e44e3fea1b9 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -323,21 +323,10 @@ async function createProgram( return { program, shouldAbort: true }; } - // Sync linter stage - const syncLintResult = await linter.lint({ asyncRules: false }); - program.reportDiagnostics(syncLintResult.diagnostics); - - // Async linter stage - const asyncLintResult = await linter.lint({ asyncRules: true }); - program.reportDiagnostics(asyncLintResult.diagnostics); - - runtimeStats.linter = { - total: syncLintResult.stats.runtime.total + asyncLintResult.stats.runtime.total, - rules: { - ...syncLintResult.stats.runtime.rules, - ...asyncLintResult.stats.runtime.rules, - }, - }; + // Linter stage + const lintResult = await linter.lint(); + runtimeStats.linter = lintResult.stats.runtime; + program.reportDiagnostics(lintResult.diagnostics); return { program, shouldAbort: false }; diff --git a/packages/compiler/src/utils/misc.ts b/packages/compiler/src/utils/misc.ts index 688f097f0e9..b7524e3eb7c 100644 --- a/packages/compiler/src/utils/misc.ts +++ b/packages/compiler/src/utils/misc.ts @@ -485,6 +485,6 @@ class RekeyableMapImpl implements RekeyableMap { } } -export function isPromise(value: any): value is Promise { - return value && typeof value.then === "function"; +export function isPromise(value: unknown): value is Promise { + return !!value && typeof (value as any).then === "function"; } diff --git a/packages/compiler/test/core/linter.test.ts b/packages/compiler/test/core/linter.test.ts index 66ac6f31065..36da211dedc 100644 --- a/packages/compiler/test/core/linter.test.ts +++ b/packages/compiler/test/core/linter.test.ts @@ -81,7 +81,7 @@ const noInterfaceFooAsync = createLinterRule({ const r = await new Promise((resolve) => { setTimeout(() => { resolve(context.interfaceToCheck?.filter((t) => t.name === "Foo2") ?? []); - }, 50); + }, 0); }); r.forEach((target) => { context.reportDiagnostic({ @@ -146,14 +146,14 @@ describe("compiler: linter", () => { const linter = await createTestLinter(`model Foo {}`, { rules: [noModelFoo], }); - expectDiagnosticEmpty((await linter.lint({ asyncRules: false })).diagnostics); + expectDiagnosticEmpty((await linter.lint()).diagnostics); }); it("registering a rule doesn't enable it: async", async () => { const linter = await createTestLinter(`interface Foo2 {}`, { rules: [noInterfaceFooAsync], }); - expectDiagnosticEmpty((await linter.lint({ asyncRules: true })).diagnostics); + expectDiagnosticEmpty((await linter.lint()).diagnostics); }); it("enabling a rule that doesn't exists emit a diagnostic", async () => { @@ -230,7 +230,7 @@ describe("compiler: linter", () => { const linter = await createTestLinterAndEnableRules(files, { rules: [noModelFoo], }); - expectDiagnosticEmpty((await linter.lint({ asyncRules: false })).diagnostics); + expectDiagnosticEmpty((await linter.lint()).diagnostics); }); it("emit diagnostic when in the user code", async () => { @@ -245,7 +245,7 @@ describe("compiler: linter", () => { const linter = await createTestLinterAndEnableRules(files, { rules: [noModelFoo], }); - expectDiagnostics((await linter.lint({ asyncRules: false })).diagnostics, { + expectDiagnostics((await linter.lint()).diagnostics, { severity: "warning", code: "@typespec/test-linter/no-model-foo", message: `Cannot call model 'Foo'`, @@ -263,7 +263,7 @@ describe("compiler: linter", () => { enable: { "@typespec/test-linter/no-model-foo": true }, }), ); - expectDiagnostics((await linter.lint({ asyncRules: false })).diagnostics, { + expectDiagnostics((await linter.lint()).diagnostics, { severity: "warning", code: "@typespec/test-linter/no-model-foo", message: `Cannot call model 'Foo'`, @@ -279,7 +279,7 @@ describe("compiler: linter", () => { enable: { "@typespec/test-linter/no-interface-foo2-async": true }, }), ); - expectDiagnostics((await linter.lint({ asyncRules: true })).diagnostics, { + expectDiagnostics((await linter.lint()).diagnostics, { severity: "warning", code: "@typespec/test-linter/no-interface-foo2-async", message: `Cannot call interface 'Foo2' (async rule)`, @@ -295,7 +295,7 @@ describe("compiler: linter", () => { enable: { "@typespec/test-linter/no-model-foo": true }, }), ); - expectDiagnosticEmpty((await linter.lint({ asyncRules: false })).diagnostics); + expectDiagnosticEmpty((await linter.lint()).diagnostics); }); it("emit no diagnostic if rule report none: async", async () => { @@ -307,7 +307,7 @@ describe("compiler: linter", () => { enable: { "@typespec/test-linter/no-interface-foo2-async": true }, }), ); - expectDiagnosticEmpty((await linter.lint({ asyncRules: true })).diagnostics); + expectDiagnosticEmpty((await linter.lint()).diagnostics); }); }); @@ -321,7 +321,7 @@ describe("compiler: linter", () => { extends: ["@typespec/test-linter/all"], }), ); - expectDiagnostics((await linter.lint({ asyncRules: false })).diagnostics, { + expectDiagnostics((await linter.lint()).diagnostics, { severity: "warning", code: "@typespec/test-linter/no-model-foo", message: `Cannot call model 'Foo'`, @@ -341,7 +341,7 @@ describe("compiler: linter", () => { extends: ["@typespec/test-linter/custom"], }), ); - expectDiagnostics((await linter.lint({ asyncRules: false })).diagnostics, { + expectDiagnostics((await linter.lint()).diagnostics, { severity: "warning", code: "@typespec/test-linter/no-model-foo", message: `Cannot call model 'Foo'`, @@ -402,7 +402,7 @@ describe("compiler: linter", () => { }, ); - const resultSync = await linter.lint({ asyncRules: false }); + const resultSync = await linter.lint(); expectDiagnostics( resultSync.diagnostics, [ @@ -416,24 +416,16 @@ describe("compiler: linter", () => { code: "@typespec/test-linter/exit-lint-rule-sync", message: "Exit lint rule sync called", }, + { + severity: "warning", + code: "@typespec/test-linter/no-interface-foo2-async", + message: `Cannot call interface 'Foo2' (async rule)`, + }, ], { strict: true, }, ); - - const resultAsync = await linter.lint({ asyncRules: true }); - expectDiagnostics( - resultAsync.diagnostics, - { - severity: "warning", - code: "@typespec/test-linter/no-interface-foo2-async", - message: `Cannot call interface 'Foo2' (async rule)`, - }, - { - strict: true, - }, - ); }); }); }); From e4aee0f413ac7c0702754162d897344ba01a31a1 Mon Sep 17 00:00:00 2001 From: RodgeFu Date: Tue, 11 Nov 2025 10:29:20 +0800 Subject: [PATCH 10/14] update to check exit/async for async callback --- packages/compiler/src/core/linter.ts | 14 ++++---------- packages/compiler/src/utils/misc.ts | 4 ---- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/compiler/src/core/linter.ts b/packages/compiler/src/core/linter.ts index f6264583b69..ec7716506dd 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -1,4 +1,3 @@ -import { isPromise } from "../utils/misc.js"; import { DiagnosticCollector, compilerAssert, createDiagnosticCollector } from "./diagnostics.js"; import { getLocationContext } from "./helpers/location-context.js"; import { defineLinter } from "./library.js"; @@ -200,6 +199,7 @@ export function createLinter( const timer = startTimer(); const exitCallbacks = []; const allPromises: Promise[] = []; + const EXIT_EVENT_NAME = "exit"; for (const rule of filteredRules.values()) { const createTiming = startTimer(); const listener = rule.create(createLinterRuleContext(program, rule, diagnostics)); @@ -208,14 +208,8 @@ export function createLinter( const timedCb = (...args: any[]) => { const timer = startTimer(); const result = (cb as any)(...args); - if (isPromise(result)) { - if (rule.async !== true) { - compilerAssert( - false /* throw if this is not true */, - `Linter rule "${rule.id}" is not marked as async but returned a promise from the "${name}" callback.`, - ); - } - const rr = result.then(() => { + if (name === EXIT_EVENT_NAME && rule.async === true) { + const rr = result.finally(() => { const duration = timer.end(); stats.runtime.rules[rule.id] += duration; }); @@ -225,7 +219,7 @@ export function createLinter( stats.runtime.rules[rule.id] += duration; } }; - if (name === "exit") { + if (name === EXIT_EVENT_NAME) { // we need to trigger 'exit' callbacks explicitly after semantic walker is done exitCallbacks.push(timedCb); } else { diff --git a/packages/compiler/src/utils/misc.ts b/packages/compiler/src/utils/misc.ts index b7524e3eb7c..6adcb2b15b1 100644 --- a/packages/compiler/src/utils/misc.ts +++ b/packages/compiler/src/utils/misc.ts @@ -484,7 +484,3 @@ class RekeyableMapImpl implements RekeyableMap { return true; } } - -export function isPromise(value: unknown): value is Promise { - return !!value && typeof (value as any).then === "function"; -} From 316d1fe531680b14d1313a1a1eca43ed49e9dd04 Mon Sep 17 00:00:00 2001 From: RodgeFu Date: Wed, 12 Nov 2025 10:22:13 +0800 Subject: [PATCH 11/14] Revert "update to check exit/async for async callback" This reverts commit e4aee0f413ac7c0702754162d897344ba01a31a1. --- packages/compiler/src/core/linter.ts | 14 ++++++++++---- packages/compiler/src/utils/misc.ts | 4 ++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/core/linter.ts b/packages/compiler/src/core/linter.ts index ec7716506dd..f6264583b69 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -1,3 +1,4 @@ +import { isPromise } from "../utils/misc.js"; import { DiagnosticCollector, compilerAssert, createDiagnosticCollector } from "./diagnostics.js"; import { getLocationContext } from "./helpers/location-context.js"; import { defineLinter } from "./library.js"; @@ -199,7 +200,6 @@ export function createLinter( const timer = startTimer(); const exitCallbacks = []; const allPromises: Promise[] = []; - const EXIT_EVENT_NAME = "exit"; for (const rule of filteredRules.values()) { const createTiming = startTimer(); const listener = rule.create(createLinterRuleContext(program, rule, diagnostics)); @@ -208,8 +208,14 @@ export function createLinter( const timedCb = (...args: any[]) => { const timer = startTimer(); const result = (cb as any)(...args); - if (name === EXIT_EVENT_NAME && rule.async === true) { - const rr = result.finally(() => { + if (isPromise(result)) { + if (rule.async !== true) { + compilerAssert( + false /* throw if this is not true */, + `Linter rule "${rule.id}" is not marked as async but returned a promise from the "${name}" callback.`, + ); + } + const rr = result.then(() => { const duration = timer.end(); stats.runtime.rules[rule.id] += duration; }); @@ -219,7 +225,7 @@ export function createLinter( stats.runtime.rules[rule.id] += duration; } }; - if (name === EXIT_EVENT_NAME) { + if (name === "exit") { // we need to trigger 'exit' callbacks explicitly after semantic walker is done exitCallbacks.push(timedCb); } else { diff --git a/packages/compiler/src/utils/misc.ts b/packages/compiler/src/utils/misc.ts index 6adcb2b15b1..b7524e3eb7c 100644 --- a/packages/compiler/src/utils/misc.ts +++ b/packages/compiler/src/utils/misc.ts @@ -484,3 +484,7 @@ class RekeyableMapImpl implements RekeyableMap { return true; } } + +export function isPromise(value: unknown): value is Promise { + return !!value && typeof (value as any).then === "function"; +} From 0128589c19a46f606687e59b879fc9ad15e90ffa Mon Sep 17 00:00:00 2001 From: RodgeFu Date: Wed, 12 Nov 2025 10:28:34 +0800 Subject: [PATCH 12/14] update --- packages/compiler/src/core/linter.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/core/linter.ts b/packages/compiler/src/core/linter.ts index f6264583b69..ff38ca9ceb8 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -199,6 +199,7 @@ export function createLinter( const timer = startTimer(); const exitCallbacks = []; + const EXIT_EVENT_NAME = "exit"; const allPromises: Promise[] = []; for (const rule of filteredRules.values()) { const createTiming = startTimer(); @@ -208,14 +209,15 @@ export function createLinter( const timedCb = (...args: any[]) => { const timer = startTimer(); const result = (cb as any)(...args); - if (isPromise(result)) { + if (name === EXIT_EVENT_NAME && isPromise(result)) { if (rule.async !== true) { + // not expected, just in case compilerAssert( false /* throw if this is not true */, `Linter rule "${rule.id}" is not marked as async but returned a promise from the "${name}" callback.`, ); } - const rr = result.then(() => { + const rr = result.finally(() => { const duration = timer.end(); stats.runtime.rules[rule.id] += duration; }); @@ -225,7 +227,7 @@ export function createLinter( stats.runtime.rules[rule.id] += duration; } }; - if (name === "exit") { + if (name === EXIT_EVENT_NAME) { // we need to trigger 'exit' callbacks explicitly after semantic walker is done exitCallbacks.push(timedCb); } else { From 838dd7d2edf9cb0ff49dd9b768234109d488e309 Mon Sep 17 00:00:00 2001 From: Rodge Fu Date: Thu, 13 Nov 2025 10:35:03 +0800 Subject: [PATCH 13/14] Update packages/compiler/src/core/linter.ts Co-authored-by: Timothee Guerin --- packages/compiler/src/core/linter.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/core/linter.ts b/packages/compiler/src/core/linter.ts index ff38ca9ceb8..103900f2be9 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -210,13 +210,11 @@ export function createLinter( const timer = startTimer(); const result = (cb as any)(...args); if (name === EXIT_EVENT_NAME && isPromise(result)) { - if (rule.async !== true) { - // not expected, just in case + compilerAssert( - false /* throw if this is not true */, + rule.async, `Linter rule "${rule.id}" is not marked as async but returned a promise from the "${name}" callback.`, ); - } const rr = result.finally(() => { const duration = timer.end(); stats.runtime.rules[rule.id] += duration; From 13d034a11bab2c33816ef142247e9b1202e89939 Mon Sep 17 00:00:00 2001 From: RodgeFu Date: Thu, 13 Nov 2025 10:37:52 +0800 Subject: [PATCH 14/14] format --- packages/compiler/src/core/linter.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/core/linter.ts b/packages/compiler/src/core/linter.ts index 103900f2be9..3d6c9741db4 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -210,11 +210,10 @@ export function createLinter( const timer = startTimer(); const result = (cb as any)(...args); if (name === EXIT_EVENT_NAME && isPromise(result)) { - - compilerAssert( - rule.async, - `Linter rule "${rule.id}" is not marked as async but returned a promise from the "${name}" callback.`, - ); + compilerAssert( + rule.async, + `Linter rule "${rule.id}" is not marked as async but returned a promise from the "${name}" callback.`, + ); const rr = result.finally(() => { const duration = timer.end(); stats.runtime.rules[rule.id] += duration;