diff --git a/.chronus/changes/compiler-fix-template-param-decorator-2026-7-30.md b/.chronus/changes/compiler-fix-template-param-decorator-2026-7-30.md new file mode 100644 index 00000000000..ac0cffe1279 --- /dev/null +++ b/.chronus/changes/compiler-fix-template-param-decorator-2026-7-30.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Fix decorators running with unresolved template parameters when a decorated template is used as a template parameter default of an operation (e.g. `op foo>(...)`, the ARM `TagsUpdateModel` pattern). Operations now enter the template declaration scope before resolving template parameter defaults, so decorators on those defaults are no longer executed with the still-unresolved template parameter. This matches the existing behavior for models and interfaces. diff --git a/.chronus/changes/http-server-csharp-fix-friendly-name-file-2026-7-29.md b/.chronus/changes/http-server-csharp-fix-friendly-name-file-2026-7-29.md new file mode 100644 index 00000000000..e1416f424f9 --- /dev/null +++ b/.chronus/changes/http-server-csharp-fix-friendly-name-file-2026-7-29.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-server-csharp" +--- + +Fix model file name for `@friendlyName` ARM-style template patterns (e.g. `@friendlyName("{name}TagsUpdate", Resource)` on `TagsUpdate`). The instantiation now correctly receives the substituted name (e.g. `FooResourceTagsUpdate.cs`) from the compiler. diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 30f44f4c4c5..19668f0005e 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -2629,6 +2629,16 @@ export function createChecker(program: Program, resolver: NameResolver): Checker node.parent, ); } + // Enter the template declaration scope before checking the template declaration itself. + // `checkTemplateDeclaration` resolves template parameter defaults (e.g. + // `Properties extends {} = TagsUpdateModel`), which can instantiate types and run + // their decorators. Those instantiations still reference the enclosing template parameters, so + // they must be treated as being in a template declaration (skipDecorators) to avoid running + // decorators with unresolved template parameters. This mirrors what `checkModelStatement` does. + if (ctx.mapper === undefined && node.templateParameters.length > 0) { + ctx = ctx.withFlags(CheckFlags.InTemplateDeclaration); + } + checkTemplateDeclaration(ctx, node); // If we are instantating operation inside of interface @@ -2636,7 +2646,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ctx = ctx.withMapper({ ...ctx.mapper, partial: true }); } - if ((ctx.mapper === undefined || ctx.mapper.partial) && node.templateParameters.length > 0) { + if (ctx.mapper?.partial && node.templateParameters.length > 0) { ctx = ctx.withFlags(CheckFlags.InTemplateDeclaration); } diff --git a/packages/compiler/test/decorators/decorators.test.ts b/packages/compiler/test/decorators/decorators.test.ts index 07ef224ea2d..b7a72b91a68 100644 --- a/packages/compiler/test/decorators/decorators.test.ts +++ b/packages/compiler/test/decorators/decorators.test.ts @@ -19,6 +19,7 @@ import { setMediaTypeHint, } from "../../src/lib/decorators.js"; import { expectDiagnosticEmpty, expectDiagnostics, t } from "../../src/testing/index.js"; +import { createTestHost } from "../../src/testing/test-host.js"; import { Tester } from "../tester.js"; describe("dev comment /** */", () => { @@ -369,6 +370,60 @@ describe("@friendlyName", () => { strictEqual(getFriendlyName(program, B), "BModel"); }); + it("does not run decorators with unresolved template parameter arguments when a decorated template is used as a template parameter default", async () => { + // Regression test for https://github.com/microsoft/typespec/issues/11454 + // When an operation uses a decorated template as a template parameter default + // (e.g. `Properties extends {} = TagsUpdateModel`, the ARM tags-update pattern), + // checking that default must happen within the template declaration scope so the + // decorators on the default type are NOT executed with the still-unresolved template parameter. + const host = await createTestHost(); + const trackArgKinds: string[] = []; + host.addJsFile("track.js", { + namespace: "Test", + $track(_ctx: any, _target: any, arg: any) { + trackArgKinds.push(arg?.kind); + }, + }); + host.addTypeSpecFile( + "main.tsp", + ` + import "./track.js"; + + namespace Test { + extern dec track(target: unknown, arg: unknown); + } + + using Test; + + @track(Resource) + model TagsUpdateModel { + tags?: string; + } + + op armTagsPatch< + Resource extends {}, + Properties extends {} = TagsUpdateModel + >(body: Properties): void; + + model FooResource { id: string; } + + op patch is armTagsPatch; + `, + ); + await host.compile("main.tsp"); + + // The decorator must only run on the concrete instantiation (FooResource), never with the + // unresolved `Resource` template parameter of the operation. + ok( + !trackArgKinds.includes("TemplateParameter"), + `@track should never run with a TemplateParameter argument, but ran with: [${trackArgKinds.join(", ")}]`, + ); + ok( + trackArgKinds.includes("Model"), + "expected @track to run on the concrete TagsUpdateModel instantiation", + ); + }); + it(" @friendlyName doesn't carry over to derived models", async () => { const { A, B, program } = await Tester.compile(t.code` @friendlyName("MyNameIsA") diff --git a/packages/http-server-csharp/test/generation.test.ts b/packages/http-server-csharp/test/generation.test.ts index 9f17b2c02e5..6badfa95a47 100644 --- a/packages/http-server-csharp/test/generation.test.ts +++ b/packages/http-server-csharp/test/generation.test.ts @@ -3448,3 +3448,36 @@ it("emits class for model extending another model with no additional properties" ], ); }); + +it("emits correct file name for @friendlyName template used as a template parameter default (ARM pattern)", async () => { + // Regression test for https://github.com/microsoft/typespec/issues/11454 + // A model decorated with @friendlyName("{name}...", Resource) is used as an operation template + // parameter default (the ARM `TagsUpdateModel` pattern). The concrete instantiation must + // get the substituted friendly name as its file/class name, and no file with an unresolved + // `{name}` placeholder should be emitted. + const [result] = await compileAndDiagnose( + tester, + ` + @service(#{title: "Test"}) + namespace Test { + @friendlyName("{name}TagsUpdate", Resource) + model TagsUpdateModel { + tags?: string; + } + + op armTagsPatch< + Resource extends {}, + Properties extends {} = TagsUpdateModel + >(...Properties): void; + + model FooResource { id: string; } + + @route("/tags") @patch op patch is armTagsPatch; + } + `, + ); + const files = [...result.fs.fs.keys()]; + // No emitted file should contain an unresolved template placeholder. + const unresolved = files.filter((f) => f.includes("{") || f.includes("}")); + deepStrictEqual(unresolved, [], `Unexpected files with unresolved placeholders: ${unresolved}`); +});