diff --git a/.chronus/changes/http-operation-cache-2026-7-20-16-43-0.md b/.chronus/changes/http-operation-cache-2026-7-20-16-43-0.md new file mode 100644 index 00000000000..8d8eef94d4e --- /dev/null +++ b/.chronus/changes/http-operation-cache-2026-7-20-16-43-0.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add `currentStage` property and `useCache` method to `Program` for stage-aware caching. `currentStage` tracks the compilation pipeline stage (parsing → checking → validating → linting → emitting), and `useCache` provides a generic caching mechanism that libraries can use to avoid redundant computation during later stages. diff --git a/.chronus/changes/http-operation-cache-http-2026-7-22-19-25-0.md b/.chronus/changes/http-operation-cache-http-2026-7-22-19-25-0.md new file mode 100644 index 00000000000..ad1939b04ab --- /dev/null +++ b/.chronus/changes/http-operation-cache-http-2026-7-22-19-25-0.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http" +--- + +Cache `getHttpOperation` results during linting and emitting stages using `program.useCache()`. This eliminates redundant route resolution when multiple linter rules inspect the same operations, improving linter performance on large specs. diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index 513170c5a61..8defb1752e8 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -75,6 +75,12 @@ import { TypeSpecScriptNode, } from "./types.js"; +/** + * The current stage of the compilation pipeline. + * Stages progress in order: parsing → checking → validating → linting → emitting. + */ +export type CompilationStage = "parsing" | "checking" | "validating" | "linting" | "emitting"; + export interface Program { compilerOptions: CompilerOptions; /** @internal */ @@ -138,6 +144,11 @@ export interface Program { * Project root. If a tsconfig was found/specified this is the directory for the tsconfig.json. Otherwise directory where the entrypoint is located. */ readonly projectRoot: string; + + /** @internal */ + setCurrentStage(stage: CompilationStage): void; + /** @internal */ + readonly currentStage: CompilationStage; } interface EmitterRef { @@ -192,6 +203,7 @@ export async function compile( }; const timer = perf.startTimer(); // Emitter stage + program.setCurrentStage("emitting"); for (const emitter of program.emitters) { // If in dry mode run and an emitter doesn't support it we have to skip it. if (program.compilerOptions.dryRun && !emitter.library.definition?.capabilities?.dryRun) { @@ -229,6 +241,8 @@ async function createProgram( // eslint-disable-next-line prefer-const -- reassigned after source resolution let suppressionTracker: SuppressionTracker | undefined; + let currentStage: CompilationStage = "parsing"; + const logger = createLogger({ sink: host.logSink }); const tracer = createTracer(logger, { filter: options.trace }); const resolvedMain = await resolveTypeSpecEntrypoint(host, mainFile, reportDiagnostic); @@ -260,6 +274,12 @@ async function createProgram( get suppressionTracker() { return suppressionTracker; }, + get currentStage() { + return currentStage; + }, + setCurrentStage(stage: CompilationStage) { + currentStage = stage; + }, hasError() { return error; }, @@ -326,6 +346,7 @@ async function createProgram( } program.checker = createChecker(program, resolver); + currentStage = "checking"; runtimeStats.checker = perf.time(() => program.checker.checkProgram()); complexityStats.createdTypes = program.checker.stats.createdTypes; @@ -336,6 +357,7 @@ async function createProgram( } // onValidate stage + currentStage = "validating"; await runValidators(); validateRequiredImports(); @@ -347,6 +369,7 @@ async function createProgram( } // Linter stage + currentStage = "linting"; const lintResult = await linter.lint(); runtimeStats.linter = lintResult.stats.runtime; program.reportDiagnostics(lintResult.diagnostics); diff --git a/packages/compiler/src/experimental/cache.ts b/packages/compiler/src/experimental/cache.ts new file mode 100644 index 00000000000..d30c9f07a33 --- /dev/null +++ b/packages/compiler/src/experimental/cache.ts @@ -0,0 +1,43 @@ +import type { Program } from "../core/program.js"; +import type { Type } from "../core/types.js"; + +/** + * Get a cached value for the given key, computing it if not already cached. + * Caching is only active from the "validating" stage onward and only for + * finished types. During "parsing" and "checking", decorators are still being + * applied. Unfinished types (during decorator application or inside mutators) + * are never cached because the type graph may not yet be in a stable state. + * + * @param program The program instance. + * @param key A unique symbol identifying this cache namespace. + * @param type The type to use as the cache key within this namespace. + * @param compute A function that computes the value if not cached. + * @returns The cached or freshly computed value. + * + * @experimental + */ +export function useCache(program: Program, key: symbol, type: Type, compute: () => T): T { + const stage = program.currentStage; + // Only cache from "validating" onward. During "parsing" and "checking", + // decorators are still being applied and may not have finished setting up + // route options, filters, or other state that affects resolution. By + // "validating" all decorators have completed and types are fully resolved. + if (stage !== "validating" && stage !== "linting" && stage !== "emitting") { + return compute(); + } + // Don't cache results for unfinished types. Types are unfinished during + // decorator application (including late template instantiation in the + // emitting stage) and inside mutators. Caching at those points risks + // storing results computed against an incomplete type graph. + if (!type.isFinished) { + return compute(); + } + const map = program.stateMap(key); + const existing = map.get(type); + if (existing !== undefined) { + return existing as T; + } + const value = compute(); + map.set(type, value); + return value; +} diff --git a/packages/compiler/src/experimental/index.ts b/packages/compiler/src/experimental/index.ts index ba9304d39a3..9702b795a9e 100644 --- a/packages/compiler/src/experimental/index.ts +++ b/packages/compiler/src/experimental/index.ts @@ -1,4 +1,5 @@ export { createSourceLoader as unsafe_createSourceLoader } from "../core/source-loader.js"; +export { useCache as unsafe_useCache } from "./cache.js"; export { MutableType as unsafe_MutableType, Mutator as unsafe_Mutator, diff --git a/packages/http/src/operations.ts b/packages/http/src/operations.ts index 0ea98d7d874..b6d174fa0e9 100644 --- a/packages/http/src/operations.ts +++ b/packages/http/src/operations.ts @@ -12,6 +12,7 @@ import { Operation, Program, } from "@typespec/compiler"; +import { unsafe_useCache as useCache } from "@typespec/compiler/experimental"; import { getAuthenticationForOperation } from "./auth.js"; import { getAuthentication } from "./decorators.js"; import { isSharedRoute } from "./decorators/shared-route.js"; @@ -26,6 +27,8 @@ import { RouteResolutionOptions, } from "./types.js"; +const httpOperationCacheKey = Symbol.for("@typespec/http.httpOperationCache"); + /** * Return the Http Operation details for a given TypeSpec operation. * @param operation Operation @@ -36,6 +39,11 @@ export function getHttpOperation( operation: Operation, options?: RouteResolutionOptions, ): [HttpOperation, readonly Diagnostic[]] { + if (!options) { + return useCache(program, httpOperationCacheKey, operation, () => + getHttpOperationInternal(program, operation, options, new Map()), + ); + } return getHttpOperationInternal(program, operation, options, new Map()); } diff --git a/packages/http/test/cache.test.ts b/packages/http/test/cache.test.ts new file mode 100644 index 00000000000..2f7946ec2cb --- /dev/null +++ b/packages/http/test/cache.test.ts @@ -0,0 +1,489 @@ +import { t } from "@typespec/compiler/testing"; +import { deepStrictEqual, strictEqual } from "assert"; +import { describe, it } from "vitest"; +import { getAllHttpServices, getHttpOperation } from "../src/index.js"; +import { setRouteOptionsForNamespace, setRouteProducer } from "../src/route.js"; +import { Tester } from "./test-host.js"; + +describe("getHttpOperation caching", () => { + it("returns consistent results across multiple calls", async () => { + const { program, myOp } = await Tester.compile(t.code` + @service(#{title: "Test"}) namespace TestService; + @route("/items/{id}") op ${t.op("myOp")}(@path id: string): void; + `); + + const [result1] = getHttpOperation(program, myOp); + const [result2] = getHttpOperation(program, myOp); + + strictEqual(result1.path, result2.path); + strictEqual(result1.verb, result2.verb); + deepStrictEqual( + result1.parameters.parameters.map((p) => p.name), + result2.parameters.parameters.map((p) => p.name), + ); + }); + + it("caches during emitting stage (after compile)", async () => { + const { program, myOp } = await Tester.compile(t.code` + @service(#{title: "Test"}) namespace TestService; + @route("/things/{thingId}") op ${t.op("myOp")}(@path thingId: string): void; + `); + + // After compile(), cache should be active (stage is "emitting") + + const [result1] = getHttpOperation(program, myOp); + const [result2] = getHttpOperation(program, myOp); + + // Should be the exact same object reference (cached) + strictEqual(result1, result2); + }); + + it("returns correct results for multiple operations", async () => { + const { program, opA, opB } = await Tester.compile(t.code` + @service(#{title: "Test"}) namespace TestService; + @route("/a") op ${t.op("opA")}(): void; + @route("/b/{id}") op ${t.op("opB")}(@path id: string): void; + `); + + const [resultA] = getHttpOperation(program, opA); + const [resultB] = getHttpOperation(program, opB); + + strictEqual(resultA.path, "/a"); + strictEqual(resultB.path, "/b/{id}"); + deepStrictEqual( + resultA.parameters.parameters.map((p) => p.name), + [], + ); + deepStrictEqual( + resultB.parameters.parameters.map((p) => p.name), + ["id"], + ); + }); + + it("does not cache when options are provided", async () => { + const { program, myOp } = await Tester.compile(t.code` + @service(#{title: "Test"}) namespace TestService; + @route("/items/{id}") op ${t.op("myOp")}(@path id: string): void; + `); + + // Call with options — should not use cache + const [result1] = getHttpOperation(program, myOp, {}); + const [result2] = getHttpOperation(program, myOp, {}); + + // Results should be equal but NOT the same reference (not cached) + strictEqual(result1.path, result2.path); + }); + + describe("operations with overloads", () => { + it("caches overloaded operations correctly", async () => { + const { program, baseOp, overload1 } = await Tester.compile(t.code` + @service(#{title: "Test"}) namespace TestService; + @route("/items") op ${t.op("baseOp")}(@query filter: string): void; + @route("/items") @overload(baseOp) op ${t.op("overload1")}(@query filter: "active"): void; + `); + + const [baseResult1] = getHttpOperation(program, baseOp); + const [overloadResult1] = getHttpOperation(program, overload1); + const [baseResult2] = getHttpOperation(program, baseOp); + const [overloadResult2] = getHttpOperation(program, overload1); + + strictEqual(baseResult1, baseResult2); + strictEqual(overloadResult1, overloadResult2); + strictEqual(baseResult1.path, "/items"); + strictEqual(overloadResult1.path, "/items"); + }); + }); + + describe("route param filtering (ARM-like pattern)", () => { + it("filters path params via routeParamFilter set on namespace", async () => { + const { program, createOp, TestService } = await Tester.compile(t.code` + @service(#{title: "Test"}) namespace ${t.namespace("TestService")}; + @route("/providers/{provider}/resources/{resourceName}") + op ${t.op("createOp")}(@path provider: string, @path resourceName: string): void; + `); + + // Simulate ARM pattern: set routeParamFilter on the service namespace + setRouteOptionsForNamespace(program, TestService, { + autoRouteOptions: { + routeParamFilter: (_op: Operation, param) => { + if (param.name === "provider") { + return { routeParamString: "Microsoft.Test", excludeFromOperationParams: true }; + } + return undefined; + }, + }, + }); + + // routeParamFilter only applies via a custom route producer (like autoRouteProducer) + // DefaultRouteProducer does not invoke routeParamFilter + const [result1] = getHttpOperation(program, createOp); + const [result2] = getHttpOperation(program, createOp); + + // Results should be cached and identical + strictEqual(result1, result2); + strictEqual(result1.path, "/providers/{provider}/resources/{resourceName}"); + }); + + it("caches results consistently when using custom route producer", async () => { + const { program, createRes } = await Tester.compile(t.code` + @service(#{title: "Test"}) namespace TestService; + op ${t.op("createRes")}(@path provider: string, @path name: string): void; + `); + + // Set a custom route producer that filters params (simulates ARM's autoRouteProducer) + setRouteProducer( + program, + createRes, + (_prog, op, _parentSegments, _overloadBase, _options) => { + return [ + { + uriTemplate: "/providers/Microsoft.Test/resources/{name}", + parameters: { + verb: "put" as const, + parameters: [ + { + type: "path" as const, + name: "name", + param: op.parameters.properties.get("name")!, + }, + ], + body: undefined, + properties: [], + }, + }, + [], + ]; + }, + ); + + const [result1] = getHttpOperation(program, createRes); + const [result2] = getHttpOperation(program, createRes); + + strictEqual(result1, result2); + strictEqual(result1.path, "/providers/Microsoft.Test/resources/{name}"); + deepStrictEqual( + result1.parameters.parameters.map((p) => p.name), + ["name"], + ); + }); + }); + + describe("consistency across compilation stages", () => { + it("getHttpOperation during emitting matches getAllHttpServices from validation", async () => { + const { program, myOp } = await Tester.compile(t.code` + @service(#{title: "Test"}) namespace TestService; + @route("/items/{id}") @get op ${t.op("myOp")}(@path id: string): void; + `); + + // getAllHttpServices resolves operations via listHttpOperationsIn (bypasses our cache) + const [services] = getAllHttpServices(program); + const validationResult = services[0].operations.find((op) => op.operation === myOp); + + // getHttpOperation uses the cache + const [emittingResult] = getHttpOperation(program, myOp); + + // Both should resolve to the same path and parameters + strictEqual(validationResult!.path, emittingResult.path); + strictEqual(validationResult!.verb, emittingResult.verb); + deepStrictEqual( + validationResult!.parameters.parameters.map((p) => p.name), + emittingResult.parameters.parameters.map((p) => p.name), + ); + }); + + it("operations in nested namespaces are cached correctly", async () => { + const { program, innerOp } = await Tester.compile(t.code` + @service(#{title: "Test"}) namespace TestService; + @route("/outer") + namespace Outer { + @route("/inner/{id}") + op ${t.op("innerOp")}(@path id: string): void; + } + `); + + const [result1] = getHttpOperation(program, innerOp); + const [result2] = getHttpOperation(program, innerOp); + + strictEqual(result1, result2); + strictEqual(result1.path, "/outer/inner/{id}"); + }); + + it("interface operations are cached correctly", async () => { + const { program, list } = await Tester.compile(t.code` + @service(#{title: "Test"}) namespace TestService; + @route("/items") + interface Items { + @get op ${t.op("list")}(): void; + @post op create(): void; + } + `); + + const [result1] = getHttpOperation(program, list); + const [result2] = getHttpOperation(program, list); + + strictEqual(result1, result2); + strictEqual(result1.verb, "get"); + strictEqual(result1.path, "/items"); + }); + }); + + describe("template instantiation patterns", () => { + it("caches template-instantiated operations independently", async () => { + const { program, listItems, listUsers } = await Tester.compile(t.code` + @service(#{title: "Test"}) namespace TestService; + + op ResourceList(): T[]; + + @route("/items") op ${t.op("listItems")} is ResourceList; + @route("/users") op ${t.op("listUsers")} is ResourceList; + `); + + const [itemsResult1] = getHttpOperation(program, listItems); + const [usersResult1] = getHttpOperation(program, listUsers); + const [itemsResult2] = getHttpOperation(program, listItems); + const [usersResult2] = getHttpOperation(program, listUsers); + + strictEqual(itemsResult1, itemsResult2); + strictEqual(usersResult1, usersResult2); + strictEqual(itemsResult1.path, "/items"); + strictEqual(usersResult1.path, "/users"); + }); + + it("template operations with path params cache correctly", async () => { + const { program, getItem, getUser } = await Tester.compile(t.code` + @service(#{title: "Test"}) namespace TestService; + + @route("{name}") op ResourceGet(@path name: string): T; + + @route("/items/") op ${t.op("getItem")} is ResourceGet; + @route("/users/") op ${t.op("getUser")} is ResourceGet; + `); + + const [itemResult] = getHttpOperation(program, getItem); + const [userResult] = getHttpOperation(program, getUser); + + // Each template instantiation should have its own cached result + strictEqual(itemResult.path, "/items/{name}"); + strictEqual(userResult.path, "/users/{name}"); + + // Verify caching + const [itemResult2] = getHttpOperation(program, getItem); + strictEqual(itemResult, itemResult2); + }); + + it("template operations with nested namespace path params", async () => { + const { program, get } = await Tester.compile(t.code` + @service(#{title: "Test"}) namespace TestService; + + @route("{name}") op ResourceGet(@path name: string): T; + + @route("/resources") + namespace Resources { + @route("/items/") + op ${t.op("get")} is ResourceGet; + } + `); + + const [result1] = getHttpOperation(program, get); + const [result2] = getHttpOperation(program, get); + + strictEqual(result1, result2); + strictEqual(result1.path, "/resources/items/{name}"); + deepStrictEqual( + result1.parameters.parameters.filter((p) => p.type === "path").map((p) => p.name), + ["name"], + ); + }); + }); + + describe("ARM-like lifecycle (repro for Azure CI)", () => { + // These tests replicate the pattern that caused Azure CI failures: + // 1. A custom route producer that filters path params (like autoRouteProducer) + // 2. routeParamFilter set on the service namespace (like @armProviderNamespace) + // 3. String literal path params that should be excluded (like provider constants) + // 4. getAllHttpServices called first (validation), then getHttpOperation after (emitting) + + it("filtered params remain filtered after cache (singleton pattern)", async () => { + const { program, createOp, TestService } = await Tester.compile(t.code` + @service(#{title: "Test"}) namespace ${t.namespace("TestService")}; + op ${t.op("createOp")}( + @path provider: "Microsoft.Test", + @path singletonName: "default", + @path resourceGroupName: string, + @body resource: {}, + ): void; + `); + + // Simulate ARM's autoRouteProducer: filters string literals and singleton params + setRouteProducer(program, createOp, (_prog, op, parentSegments, _overloadBase, options) => { + const filteredParameters: any[] = []; + const props = op.parameters.properties; + + for (const [name, prop] of props) { + const isPath = prop.type.kind === "Scalar" || prop.type.kind === "String"; + if (!isPath) continue; + + // String literal — exclude from params (like ARM provider constants) + if (prop.type.kind === "String") { + continue; + } + + // Check routeParamFilter (like ARM singleton filter) + const filterResult = options.autoRouteOptions?.routeParamFilter?.(op, prop); + if (filterResult?.excludeFromOperationParams) { + continue; + } + + filteredParameters.push({ type: "path", name, param: prop }); + } + + return [ + { + uriTemplate: + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Test/singletons/default", + parameters: { + verb: "put" as const, + parameters: filteredParameters, + body: { + bodyKind: "single" as const, + type: props.get("resource")!, + property: props.get("resource")!, + }, + properties: [], + }, + }, + [], + ]; + }); + + // Set routeParamFilter on namespace (like @armProviderNamespace does) + setRouteOptionsForNamespace(program, TestService, { + autoRouteOptions: { + routeParamFilter: (_op: Operation, param) => { + if (param.name === "singletonName") { + return { routeParamString: "default", excludeFromOperationParams: true }; + } + return undefined; + }, + }, + }); + + // Simulate validation phase: getAllHttpServices processes all operations + getAllHttpServices(program); + + // Now simulate emitting phase: getHttpOperation called individually (cached) + const [emitResult1] = getHttpOperation(program, createOp); + const [emitResult2] = getHttpOperation(program, createOp); + + // Cache should return same reference + strictEqual(emitResult1, emitResult2); + + // The filtered params should NOT include provider or singletonName + const pathParams = emitResult1.parameters.parameters + .filter((p) => p.type === "path") + .map((p) => p.name); + deepStrictEqual(pathParams, ["resourceGroupName"]); + }); + + it("template ops with filtered params stay correct through cache", async () => { + const { program, create } = await Tester.compile(t.code` + @service(#{title: "Test"}) namespace ${t.namespace("TestService")}; + + op ResourceCreate(@path name: string, @body resource: T): void; + + op ${t.op("create")} is ResourceCreate<{}>; + `); + + // ARM-like: set route producer that filters based on namespace options + setRouteProducer(program, create, (_prog, op, _segments, _overload, options) => { + const params: any[] = []; + for (const [name, prop] of op.parameters.properties) { + if (prop.type.kind === "Model" && name === "resource") continue; // body + const filterResult = options.autoRouteOptions?.routeParamFilter?.(op, prop); + if (filterResult?.excludeFromOperationParams) continue; + params.push({ type: "path", name, param: prop }); + } + return [ + { + uriTemplate: "/resources/{name}", + parameters: { + verb: "put" as const, + parameters: params, + body: undefined, + properties: [], + }, + }, + [], + ]; + }); + + // getAllHttpServices first (like validation does) + getAllHttpServices(program); + + // Then getHttpOperation (like emitters/SDK do) + const [result1] = getHttpOperation(program, create); + const [result2] = getHttpOperation(program, create); + + strictEqual(result1, result2); + deepStrictEqual( + result1.parameters.parameters.map((p) => p.name), + ["name"], + ); + }); + + it("multiple operations with different filtering cached independently", async () => { + const { program, opA, opB } = await Tester.compile(t.code` + @service(#{title: "Test"}) namespace ${t.namespace("TestService")}; + op ${t.op("opA")}(@path provider: "Microsoft.Test", @path name: string): void; + op ${t.op("opB")}(@path provider: "Microsoft.Other", @path id: string): void; + `); + + // Each operation gets a route producer that filters string literals + for (const op of [opA, opB]) { + setRouteProducer(program, op, (_prog, operation, _segments, _overload, _options) => { + const params: any[] = []; + for (const [name, prop] of operation.parameters.properties) { + if (prop.type.kind === "String") continue; // filter string literals + params.push({ type: "path", name, param: prop }); + } + const nonLiteralParam = params[0]?.name ?? "id"; + return [ + { + uriTemplate: `/providers/{${nonLiteralParam}}`, + parameters: { + verb: "get" as const, + parameters: params, + body: undefined, + properties: [], + }, + }, + [], + ]; + }); + } + + // Validation path first + getAllHttpServices(program); + + // Emitting path — each op cached independently + const [resultA1] = getHttpOperation(program, opA); + const [resultB1] = getHttpOperation(program, opB); + const [resultA2] = getHttpOperation(program, opA); + const [resultB2] = getHttpOperation(program, opB); + + strictEqual(resultA1, resultA2); + strictEqual(resultB1, resultB2); + + // opA should only have "name" (provider filtered as string literal) + deepStrictEqual( + resultA1.parameters.parameters.map((p) => p.name), + ["name"], + ); + // opB should only have "id" (provider filtered as string literal) + deepStrictEqual( + resultB1.parameters.parameters.map((p) => p.name), + ["id"], + ); + }); + }); +});