diff --git a/.chronus/changes/copilot-add-linter-rules-for-empty-clients-2026-0-13-20-44-50.md b/.chronus/changes/copilot-add-linter-rules-for-empty-clients-2026-0-13-20-44-50.md new file mode 100644 index 0000000000..c5fb84118f --- /dev/null +++ b/.chronus/changes/copilot-add-linter-rules-for-empty-clients-2026-0-13-20-44-50.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-client-generator-core" +--- + +Add linter warning for empty clients (clients with no operations) \ No newline at end of file diff --git a/packages/typespec-client-generator-core/src/clients.ts b/packages/typespec-client-generator-core/src/clients.ts index e14beea859..fe2d5983a3 100644 --- a/packages/typespec-client-generator-core/src/clients.ts +++ b/packages/typespec-client-generator-core/src/clients.ts @@ -33,7 +33,7 @@ import { isSubscriptionId, updateWithApiVersionInformation, } from "./internal-utils.js"; -import { createDiagnostic } from "./lib.js"; +import { createDiagnostic, reportDiagnostic } from "./lib.js"; import { createSdkMethods, getSdkMethodParameter } from "./methods.js"; import { getCrossLanguageDefinitionId } from "./public-utils.js"; import { getSdkBuiltInType, getSdkCredentialParameter, getTypeSpecBuiltInType } from "./types.js"; @@ -217,6 +217,17 @@ export function createSdkClientType(context, client, sdkClientType), ); + // Check if the client is empty (has no methods and no children) + // Only emit diagnostic if client.type is defined (client has a source TypeSpec type to attach the diagnostic to) + if (sdkClientType.methods.length === 0 && !sdkClientType.children?.length && client.type) { + reportDiagnostic(context.program, { + code: "empty-client", + target: client.type, + format: { + name: sdkClientType.name, + }, + }); + } addDefaultClientParameters(context, sdkClientType); // update initialization model properties diff --git a/packages/typespec-client-generator-core/src/lib.ts b/packages/typespec-client-generator-core/src/lib.ts index 6c5f9c3476..1de893dfe7 100644 --- a/packages/typespec-client-generator-core/src/lib.ts +++ b/packages/typespec-client-generator-core/src/lib.ts @@ -141,6 +141,12 @@ export const $lib = createTypeSpecLibrary({ default: paramMessage`Client "${"name"}" is not inside a service namespace. Use @client({service: MyServiceNS})`, }, }, + "empty-client": { + severity: "warning", + messages: { + default: paramMessage`Client "${"name"}" has no operations. Clients should contain at least one operation.`, + }, + }, "union-null": { severity: "warning", messages: { diff --git a/packages/typespec-client-generator-core/test/decorators/client.test.ts b/packages/typespec-client-generator-core/test/decorators/client.test.ts index a7626d516c..cfec379c82 100644 --- a/packages/typespec-client-generator-core/test/decorators/client.test.ts +++ b/packages/typespec-client-generator-core/test/decorators/client.test.ts @@ -78,7 +78,9 @@ describe("@client", () => { ` @client @service - @test namespace MyService; + @test namespace MyService { + op test(): void; + } `, ) .toEmitDiagnostics([ @@ -96,7 +98,9 @@ describe("@client", () => { ` @client({name: "MySDK"}) @service - @test namespace MyService; + @test namespace MyService { + op test(): void; + } `, ) .toEmitDiagnostics([ @@ -474,7 +478,9 @@ describe("@operationGroup", () => { @service(#{ title: "DeviceUpdateClient", }) - namespace Azure.IoT.DeviceUpdate; + namespace Azure.IoT.DeviceUpdate { + op test(): void; + } `, ` @client({name: "DeviceUpdateClient", service: Azure.IoT.DeviceUpdate}, "python") @@ -482,10 +488,12 @@ describe("@operationGroup", () => { @operationGroup("java") interface SubClientOnlyForJava { + op javaOp(): void; } @operationGroup("python") interface SubClientOnlyForPython { + op pythonOp(): void; } `, ]; @@ -514,7 +522,7 @@ describe("@operationGroup", () => { strictEqual(listOperationGroups(runner.context, client).length, 1); } - // csharp should have no client + // csharp should have one client (default from service namespace since no explicit @client for csharp) { const runner = await createSdkTestRunner({ emitterName: "@azure-tools/typespec-csharp" }); const [_, diagnostics] = await runner.compileAndDiagnoseWithCustomization( @@ -522,8 +530,9 @@ describe("@operationGroup", () => { testCode[1], ); expectDiagnosticEmpty(diagnostics); - const client = listClients(runner.context); - strictEqual(client.length, 0); + const clients = listClients(runner.context); + strictEqual(clients.length, 1); + strictEqual(listOperationGroups(runner.context, clients[0]).length, 0); } }); }); @@ -1461,3 +1470,153 @@ it("operations under namespace or interface without @client or @operationGroup", const operationGroup = operationGroups[0]; strictEqual(listOperationsInOperationGroup(runner.context, operationGroup).length, 1); }); + +describe("empty client diagnostic", () => { + it("should emit diagnostic for empty namespace client", async () => { + const diagnostics = await runner.diagnose(` + @client + @service + namespace MyService { + } + `); + + expectDiagnostics(diagnostics, { + code: "@azure-tools/typespec-client-generator-core/empty-client", + }); + }); + + it("should not emit diagnostic for client with operations", async () => { + const diagnostics = await runner.diagnose(` + @service + namespace MyService { + op test(): void; + } + `); + + expectDiagnosticEmpty(diagnostics); + }); + + it("should not emit diagnostic for client with operation groups", async () => { + const diagnostics = await runner.diagnose(` + @service + namespace MyService { + @operationGroup + interface SubClient { + op test(): void; + } + } + `); + + expectDiagnosticEmpty(diagnostics); + }); + + it("should emit diagnostic for empty interface client", async () => { + const diagnostics = await runner.diagnose(` + @service + namespace MyService { + op serviceOp(): void; + + @client({service: MyService}) + interface MyClient { + } + } + `); + + expectDiagnostics(diagnostics, { + code: "@azure-tools/typespec-client-generator-core/empty-client", + }); + }); + + it("should not emit diagnostic for interface client with operations", async () => { + const diagnostics = await runner.diagnose(` + @service + namespace MyService { + @route("/service") op serviceOp(): void; + + @client({service: MyService}) + interface MyClient { + @route("/test") op test(): void; + } + } + `); + + expectDiagnosticEmpty(diagnostics); + }); + + it("should not emit diagnostic for target when operation is moved into it via @clientLocation", async () => { + // Target was originally empty but receives an operation via @clientLocation + // Source still has an operation, so no empty-client diagnostic + const diagnostics = await runner.diagnose(` + @service + namespace MyService { + interface Source { + @route("/stay") + op stayOp(): void; + + @route("/moved") + @clientLocation(Target) + op movedOp(): void; + } + + interface Target { + } + } + `); + + expectDiagnosticEmpty(diagnostics); + }); + + it("should not emit diagnostic when @clientLocation moves all operations out of a non-explicit client", async () => { + // When all operations are moved out of a client that wasn't explicitly defined with @client/@operationGroup, + // the client is simply removed (not warned about) since it was auto-generated + const diagnostics = await runner.diagnose(` + @service + namespace MyService { + interface Source { + @route("/moved") + @clientLocation(Target) + op movedOp(): void; + } + + interface Target { + @route("/target") + op targetOp(): void; + } + } + `); + + expectDiagnosticEmpty(diagnostics); + }); + + it("should not emit diagnostic when @clientLocation moves all operations to root client", async () => { + // Source interface becomes empty and is removed since it wasn't explicitly defined + const diagnostics = await runner.diagnose(` + @service + namespace MyService { + interface Source { + @route("/moved") + @clientLocation(MyService) + op movedOp(): void; + } + } + `); + + expectDiagnosticEmpty(diagnostics); + }); + + it("should not emit diagnostic when @clientLocation moves all operations to new operation group", async () => { + // Source interface becomes empty and is removed since it wasn't explicitly defined + const diagnostics = await runner.diagnose(` + @service + namespace MyService { + interface Source { + @route("/moved") + @clientLocation("NewTarget") + op movedOp(): void; + } + } + `); + + expectDiagnosticEmpty(diagnostics); + }); +}); diff --git a/packages/typespec-client-generator-core/test/rules/require-client-suffix.test.ts b/packages/typespec-client-generator-core/test/rules/require-client-suffix.test.ts index ae44dc4528..f350b4cc1e 100644 --- a/packages/typespec-client-generator-core/test/rules/require-client-suffix.test.ts +++ b/packages/typespec-client-generator-core/test/rules/require-client-suffix.test.ts @@ -25,7 +25,9 @@ it("namespace doesn't end in client", async () => { ` @client @service - namespace MyService; + namespace MyService { + op test(): void; + } `, ) .toEmitDiagnostics([ @@ -43,7 +45,9 @@ it("explicit client name doesn't ends with Client", async () => { ` @client({name: "MySDK"}) @service - namespace MyService; + namespace MyService { + op test(): void; + } `, ) .toEmitDiagnostics([ @@ -60,11 +64,14 @@ it("interface", async () => { .expect( ` @service - namespace MyService; + namespace MyService { + op serviceOp(): void; + } namespace MyCustomizations { @client({service: MyService}) interface MyInterface { + op test(): void; }; } `, diff --git a/packages/typespec-client-generator-core/test/validations/types.test.ts b/packages/typespec-client-generator-core/test/validations/types.test.ts index ff1ad53f13..264e53dcc3 100644 --- a/packages/typespec-client-generator-core/test/validations/types.test.ts +++ b/packages/typespec-client-generator-core/test/validations/types.test.ts @@ -13,7 +13,7 @@ it("no duplicate operation with @clientLocation", async () => { ` @service namespace StorageService; - + interface StorageTasks { @clientLocation("StorageTasksReport") @route("/list") @@ -27,6 +27,8 @@ it("no duplicate operation with @clientLocation", async () => { `, ); + // StorageTasks becomes empty because all operations are moved out via @clientLocation + // Since it wasn't explicitly defined with @client/@operationGroup, it's simply removed (no diagnostic) expectDiagnosticEmpty(diagnostics); }); @@ -128,7 +130,7 @@ it("duplicate operation with @clientLocation to new clients", async () => { ` @service namespace Contoso.WidgetManager; - + interface A { @clientLocation("B") @route("/a") @@ -142,6 +144,7 @@ it("duplicate operation with @clientLocation to new clients", async () => { `, ); + // A becomes empty and is removed since it wasn't explicitly defined with @client/@operationGroup expectDiagnostics(diagnostics, [ { code: "@azure-tools/typespec-client-generator-core/duplicate-client-name",