Skip to content

Commit 7878487

Browse files
Copilottadelesh
andauthored
[TCGC] Add operation-not-in-client diagnostic for missing operations in @client definitions (#4273)
When `@client` is used to customize client structure, new operations added to the service namespace can be silently omitted if the client customization isn't updated. This adds a warning diagnostic to catch that. ### Changes - **New diagnostic `operation-not-in-client`** in `lib.ts` — warns on service operations not covered by any `@client` definition - **Validation in `prepareClientAndOperationCache`** (`cache.ts`) — after building the client→operation mapping, walks all service namespaces to find uncovered operations. Follows `sourceOperation` chains so `op foo is Service.foo` patterns are recognized as coverage. - **6 test cases** covering: no `@client` (no-op), full coverage, missing ops at namespace/nested-namespace/interface level, and direct `@client` on service namespace ### Behavior Only fires when explicit `@client` decorators exist. Skips template declarations, out-of-scope operations, and `omitOperation` targets. Reports on the original service operation. ```typespec @service namespace MyService { op opA(): void; op opB(): void; // ⚠️ warning: not included in any @client definition } // client.tsp @client({service: MyService, name: "MyServiceClient"}) namespace MyCustomClient { op customOpA is MyService.opA; // forgot opB } ``` --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tadelesh <1726438+tadelesh@users.noreply.github.com> Co-authored-by: Chenjie Shi <tadelesh.shi@live.cn>
1 parent 95614c0 commit 7878487

4 files changed

Lines changed: 233 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: feature
3+
packages:
4+
- "@azure-tools/typespec-client-generator-core"
5+
---
6+
7+
Add `operation-not-in-client` diagnostic to warn when service operations are not included in any `@client` definition.

packages/typespec-client-generator-core/src/cache.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
Enum,
3+
getNamespaceFullName,
34
Interface,
45
isService,
56
isTemplateDeclaration,
@@ -124,6 +125,25 @@ export function prepareClientAndOperationCache(context: TCGCContext): void {
124125
queue.push(...client.subClients);
125126
}
126127

128+
// Check that all service operations are included in client hierarchy when explicit @client is used
129+
if (context.__explicitClients!.size > 0) {
130+
// Collect all service operations that are covered by client operations
131+
// (including through sourceOperation chain)
132+
const coveredServiceOps = new Set<Operation>();
133+
for (const clientOp of context.__operationToClientCache!.keys()) {
134+
let current: Operation | undefined = clientOp;
135+
while (current) {
136+
coveredServiceOps.add(current);
137+
current = current.sourceOperation;
138+
}
139+
}
140+
141+
// For each service namespace, check all operations are covered
142+
for (const service of servicesNs) {
143+
checkUncoveredOperations(context, service, coveredServiceOps);
144+
}
145+
}
146+
127147
// omit empty clients
128148
const needKeep = (client: SdkClient): boolean => {
129149
if (context.__explicitClients!.has(client) && !client.autoMergeService) return true;
@@ -604,6 +624,51 @@ function createSubClient(
604624
return subClient;
605625
}
606626

627+
/**
628+
* Check for service operations that are not included in any @client definition.
629+
* Recursively walks the namespace and its children to find uncovered operations.
630+
*/
631+
function checkUncoveredOperations(
632+
context: TCGCContext,
633+
ns: Namespace,
634+
coveredOps: Set<Operation>,
635+
): void {
636+
for (const op of ns.operations.values()) {
637+
if (isTemplateDeclarationOrInstance(op) || !isInScope(context, op)) continue;
638+
if (context.program.stateMap(omitOperation).get(op)) continue;
639+
if (!coveredOps.has(op)) {
640+
reportDiagnostic(context.program, {
641+
code: "operation-not-in-client",
642+
format: {
643+
operationName: op.name,
644+
namespaceName: getNamespaceFullName(ns),
645+
},
646+
target: op,
647+
});
648+
}
649+
}
650+
for (const childNs of ns.namespaces.values()) {
651+
checkUncoveredOperations(context, childNs, coveredOps);
652+
}
653+
for (const iface of ns.interfaces.values()) {
654+
if (isTemplateDeclaration(iface)) continue;
655+
for (const op of iface.operations.values()) {
656+
if (isTemplateDeclarationOrInstance(op) || !isInScope(context, op)) continue;
657+
if (context.program.stateMap(omitOperation).get(op)) continue;
658+
if (!coveredOps.has(op)) {
659+
reportDiagnostic(context.program, {
660+
code: "operation-not-in-client",
661+
format: {
662+
operationName: op.name,
663+
namespaceName: getNamespaceFullName(iface.namespace ?? ns),
664+
},
665+
target: op,
666+
});
667+
}
668+
}
669+
}
670+
}
671+
607672
function isArm(service: Namespace[] | Namespace): boolean {
608673
if (Array.isArray(service)) {
609674
return service.some((s) => isArm(s));

packages/typespec-client-generator-core/src/lib.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,12 @@ export const $lib = createTypeSpecLibrary({
544544
default: "Auto-merging service client must be empty.",
545545
},
546546
},
547+
"operation-not-in-client": {
548+
severity: "warning",
549+
messages: {
550+
default: paramMessage`Operation "${"operationName"}" under namespace "${"namespaceName"}" is not included in any @client definition.`,
551+
},
552+
},
547553
},
548554
emitter: {
549555
options: TCGCEmitterOptionsSchema,

packages/typespec-client-generator-core/test/clients/structure.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3102,3 +3102,158 @@ it("validation: @clientLocation string target with multiple separate root client
31023102
},
31033103
]);
31043104
});
3105+
3106+
it("validation: no diagnostic when no @client is used", async () => {
3107+
const { program } = await SimpleTester.compile(`
3108+
@service
3109+
namespace MyService {
3110+
@route("/a")
3111+
op opA(): void;
3112+
3113+
@route("/b")
3114+
interface SubOps {
3115+
op opB(): void;
3116+
}
3117+
}
3118+
`);
3119+
await createSdkContextForTester(program);
3120+
const operationNotInClient = program.diagnostics.filter(
3121+
(d) => d.code === "@azure-tools/typespec-client-generator-core/operation-not-in-client",
3122+
);
3123+
strictEqual(operationNotInClient.length, 0);
3124+
});
3125+
3126+
it("validation: no diagnostic when all operations are covered by @client", async () => {
3127+
const { program } = await SimpleBaseTester.compile(
3128+
createClientCustomizationInput(
3129+
`
3130+
@service
3131+
namespace MyService {
3132+
@route("/a")
3133+
op opA(): void;
3134+
}
3135+
`,
3136+
`
3137+
@client({service: MyService, name: "MyServiceClient"})
3138+
namespace MyCustomClient {
3139+
op customOpA is MyService.opA;
3140+
}
3141+
`,
3142+
),
3143+
);
3144+
await createSdkContextForTester(program);
3145+
const operationNotInClient = program.diagnostics.filter(
3146+
(d) => d.code === "@azure-tools/typespec-client-generator-core/operation-not-in-client",
3147+
);
3148+
strictEqual(operationNotInClient.length, 0);
3149+
});
3150+
3151+
it("validation: diagnostic when operation is missing from @client definition", async () => {
3152+
const { program } = await SimpleBaseTester.compile(
3153+
createClientCustomizationInput(
3154+
`
3155+
@service
3156+
namespace MyService {
3157+
@route("/a")
3158+
op opA(): void;
3159+
3160+
@route("/b")
3161+
op opB(): void;
3162+
}
3163+
`,
3164+
`
3165+
@client({service: MyService, name: "MyServiceClient"})
3166+
namespace MyCustomClient {
3167+
op customOpA is MyService.opA;
3168+
}
3169+
`,
3170+
),
3171+
);
3172+
await createSdkContextForTester(program);
3173+
expectDiagnostics(program.diagnostics, [
3174+
{
3175+
code: "@azure-tools/typespec-client-generator-core/operation-not-in-client",
3176+
message: `Operation "opB" under namespace "MyService" is not included in any @client definition.`,
3177+
},
3178+
]);
3179+
});
3180+
3181+
it("validation: diagnostic for operations in nested namespace not covered by @client", async () => {
3182+
const { program } = await SimpleBaseTester.compile(
3183+
createClientCustomizationInput(
3184+
`
3185+
@service
3186+
namespace MyService {
3187+
@route("/a")
3188+
op opA(): void;
3189+
3190+
namespace SubGroup {
3191+
@route("/b")
3192+
op opB(): void;
3193+
}
3194+
}
3195+
`,
3196+
`
3197+
@client({service: MyService, name: "MyServiceClient"})
3198+
namespace MyCustomClient {
3199+
op customOpA is MyService.opA;
3200+
}
3201+
`,
3202+
),
3203+
);
3204+
await createSdkContextForTester(program);
3205+
expectDiagnostics(program.diagnostics, [
3206+
{
3207+
code: "@azure-tools/typespec-client-generator-core/operation-not-in-client",
3208+
message: `Operation "opB" under namespace "MyService.SubGroup" is not included in any @client definition.`,
3209+
},
3210+
]);
3211+
});
3212+
3213+
it("validation: diagnostic for operations in interface not covered by @client", async () => {
3214+
const { program } = await SimpleBaseTester.compile(
3215+
createClientCustomizationInput(
3216+
`
3217+
@service
3218+
namespace MyService {
3219+
@route("/a")
3220+
op opA(): void;
3221+
3222+
interface SubOps {
3223+
@route("/b")
3224+
op opB(): void;
3225+
}
3226+
}
3227+
`,
3228+
`
3229+
@client({service: MyService, name: "MyServiceClient"})
3230+
namespace MyCustomClient {
3231+
op customOpA is MyService.opA;
3232+
}
3233+
`,
3234+
),
3235+
);
3236+
await createSdkContextForTester(program);
3237+
expectDiagnostics(program.diagnostics, [
3238+
{
3239+
code: "@azure-tools/typespec-client-generator-core/operation-not-in-client",
3240+
message: `Operation "opB" under namespace "MyService" is not included in any @client definition.`,
3241+
},
3242+
]);
3243+
});
3244+
3245+
it("validation: no diagnostic when @client is applied directly to service namespace", async () => {
3246+
const { program } = await SimpleTester.compile(`
3247+
@client({service: MyService})
3248+
@service
3249+
namespace MyService {
3250+
@route("/a")
3251+
op opA(): void;
3252+
}
3253+
`);
3254+
await createSdkContextForTester(program);
3255+
const operationNotInClient = program.diagnostics.filter(
3256+
(d) => d.code === "@azure-tools/typespec-client-generator-core/operation-not-in-client",
3257+
);
3258+
strictEqual(operationNotInClient.length, 0);
3259+
});

0 commit comments

Comments
 (0)