diff --git a/.chronus/changes/http-server-csharp-only-emit-referenced-types-2026-7-31-18-15-0.md b/.chronus/changes/http-server-csharp-only-emit-referenced-types-2026-7-31-18-15-0.md new file mode 100644 index 00000000000..19edf1e70ec --- /dev/null +++ b/.chronus/changes/http-server-csharp-only-emit-referenced-types-2026-7-31-18-15-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-server-csharp" +--- + +Only emit types that belong to the service. Types declared outside the service namespace, for example in an imported library, are now emitted only when the service references them, directly or transitively. diff --git a/packages/http-server-csharp/src/diagnostics.ts b/packages/http-server-csharp/src/diagnostics.ts index 10a25fe152c..352bbce82ea 100644 --- a/packages/http-server-csharp/src/diagnostics.ts +++ b/packages/http-server-csharp/src/diagnostics.ts @@ -1,9 +1,5 @@ import type { Interface, Model, Program } from "@typespec/compiler"; -import { - isStdNamespace, - isTemplateDeclaration, - type Namespace as TspNamespace, -} from "@typespec/compiler"; +import { isTemplateDeclaration, type Namespace as TspNamespace } from "@typespec/compiler"; import { $ } from "@typespec/compiler/typekit"; import type { OperationHttpCanonicalization } from "@typespec/http-canonicalization"; import { assignAnonymousName } from "./components/models/anonymous-models.js"; @@ -13,18 +9,23 @@ import { isValidCSharpIdentifier } from "./utils/naming.js"; /** * Reports diagnostic warnings for models, scalars, and operations. * This pre-pass mirrors the old emitter behavior so that `tester.diagnose()` tests pass. + * + * Only types declared by the service itself are checked, since diagnostics on + * types declared elsewhere are not actionable for the spec author. */ export function reportEmitterDiagnostics( program: Program, interfaces: Interface[], canonicalOpsMap: Map, + serviceNamespaces: Set, ): void { const tk = $(program); const visited = new Set(); - // Walk all models in the service namespace(s) to check properties - for (const ns of program.getGlobalNamespaceType().namespaces.values()) { - if (isStdNamespace(ns)) continue; + // Walk the models declared by the service to check properties + const globalNs = program.getGlobalNamespaceType(); + for (const ns of serviceNamespaces) { + if (ns === globalNs) continue; walkNamespaceModels(program, ns, tk, visited); } @@ -130,12 +131,6 @@ function walkNamespaceModels( } } } - - // Recurse into sub-namespaces - for (const childNs of ns.namespaces.values()) { - if (isStdNamespace(childNs)) continue; - walkNamespaceModels(program, childNs, tk, visited); - } } function checkPropertyDiagnostics( diff --git a/packages/http-server-csharp/src/emitter.tsx b/packages/http-server-csharp/src/emitter.tsx index 76f6d035ba1..6c36510bebf 100644 --- a/packages/http-server-csharp/src/emitter.tsx +++ b/packages/http-server-csharp/src/emitter.tsx @@ -42,7 +42,12 @@ export async function $onEmit(context: EmitContext) const projectName = options["project-name"] ?? "ServiceProject"; // Report diagnostic warnings (pre-pass before rendering) - reportEmitterDiagnostics(context.program, resolution.interfaces, resolution.canonicalOpsMap); + reportEmitterDiagnostics( + context.program, + resolution.interfaces, + resolution.canonicalOpsMap, + resolution.declarationNamespaces, + ); // Resolve OpenAPI path for SwaggerUI let openApiPath: string | undefined = options["openapi-path"]; diff --git a/packages/http-server-csharp/src/service-discovery.ts b/packages/http-server-csharp/src/service-discovery.ts index e9e44987dd9..1006defa819 100644 --- a/packages/http-server-csharp/src/service-discovery.ts +++ b/packages/http-server-csharp/src/service-discovery.ts @@ -2,12 +2,51 @@ import type { Interface } from "@typespec/compiler"; import { isStdNamespace, isTemplateDeclaration, + listServices, type Operation, + type Program, type Namespace as TspNamespace, } from "@typespec/compiler"; import type { useTsp } from "@typespec/emitter-framework"; import { getCSharpIdentifier, NameCasingType } from "./utils/naming.js"; +/** + * Collects the namespaces whose declarations are emitted even when nothing references them. + * + * When the spec declares one or more services, only the service namespaces and their + * sub-namespaces qualify; types declared elsewhere are emitted only when the service + * references them. + * + * When no service is declared there is nothing to anchor the emit on, so every + * non-standard namespace is treated as part of the service. + */ +export function getDeclarationNamespaces(program: Program): Set { + const namespaces = new Set(); + const globalNs = program.getGlobalNamespaceType(); + + function addWithChildren(ns: TspNamespace): void { + if (namespaces.has(ns)) return; + namespaces.add(ns); + for (const child of ns.namespaces.values()) { + if (isStdNamespace(child)) continue; + addWithChildren(child); + } + } + + const services = listServices(program); + if (services.length === 0) { + addWithChildren(globalNs); + return namespaces; + } + + // Declarations at the global namespace level are always authored by the spec itself. + namespaces.add(globalNs); + for (const service of services) { + addWithChildren(service.type); + } + return namespaces; +} + /** * Collects all interfaces from the service namespace(s). * Also creates synthetic interfaces for namespace-level operations @@ -15,6 +54,7 @@ import { getCSharpIdentifier, NameCasingType } from "./utils/naming.js"; */ export function getServiceInterfaces( program: ReturnType["$"]["program"], + serviceNamespaces: Set = getDeclarationNamespaces(program), ): Interface[] { const interfaces: Interface[] = []; const globalNs = program.getGlobalNamespaceType(); @@ -54,15 +94,10 @@ export function getServiceInterfaces( interfaces.push(syntheticIface); } } - - for (const childNs of ns.namespaces?.values() ?? []) { - if (isStdNamespace(childNs)) continue; - collectFromNamespace(childNs); - } } - for (const ns of globalNs.namespaces.values()) { - if (isStdNamespace(ns)) continue; + for (const ns of serviceNamespaces) { + if (ns === globalNs) continue; collectFromNamespace(ns); } return interfaces; diff --git a/packages/http-server-csharp/src/service-resolution.test.ts b/packages/http-server-csharp/src/service-resolution.test.ts new file mode 100644 index 00000000000..4a80144616b --- /dev/null +++ b/packages/http-server-csharp/src/service-resolution.test.ts @@ -0,0 +1,108 @@ +import { Tester } from "#test/tester.js"; +import type { TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { HttpCanonicalizer } from "@typespec/http-canonicalization"; +import { beforeEach, expect, it } from "vitest"; +import { resolveServiceTypes, type ServiceTypeResolution } from "./service-resolution.js"; + +let runner: TesterInstance; + +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +async function resolve(code: string): Promise { + await runner.compile(code); + const tk = $(runner.program); + return resolveServiceTypes(runner.program, tk, new HttpCanonicalizer(tk)); +} + +it("excludes models declared outside the service namespace that are not referenced", async () => { + const resolution = await resolve(` + namespace Other { + model Used { name: string; } + model Unused { name: string; } + } + + @service + namespace Contoso { + model Widget { id: string; used: Other.Used; } + op read(): Widget; + } + `); + + expect(resolution.models.map((m) => m.name).sort()).toEqual(["Used", "Widget"]); +}); + +it("excludes enums and union enums declared outside the service namespace that are not referenced", async () => { + const resolution = await resolve(` + namespace Other { + enum UsedEnum { red, blue } + enum UnusedEnum { up, down } + union UsedUnion { "on", "off" } + union UnusedUnion { "left", "right" } + } + + @service + namespace Contoso { + model Widget { color: Other.UsedEnum; state: Other.UsedUnion; } + op read(): Widget; + } + `); + + expect(resolution.enums.map((e) => e.name)).toEqual(["UsedEnum"]); + expect(resolution.unionEnums.map((u) => u.name)).toEqual(["UsedUnion"]); +}); + +it("discovers types referenced transitively by an operation", async () => { + const resolution = await resolve(` + namespace Other { + model Envelope { detail: Detail; } + model Detail { kind: Kind; } + enum Kind { simple, complex } + model Untouched { name: string; } + } + + @service + namespace Contoso { + op read(): Other.Envelope; + } + `); + + expect(resolution.models.map((m) => m.name).sort()).toEqual(["Detail", "Envelope"]); + expect(resolution.enums.map((e) => e.name)).toEqual(["Kind"]); +}); + +it("does not create interfaces for operations declared outside the service namespace", async () => { + const resolution = await resolve(` + namespace Other { + interface OtherOps { + @route("/other") otherRead(): void; + } + } + + @service + namespace Contoso { + interface Widgets { + @route("/widgets") read(): void; + } + } + `); + + expect(resolution.interfaces.map((i) => i.name)).toEqual(["Widgets"]); +}); + +it("emits every namespace when no service is declared", async () => { + const resolution = await resolve(` + namespace Other { + model Standalone { name: string; } + } + + namespace Contoso { + model Widget { id: string; } + op read(): Widget; + } + `); + + expect(resolution.models.map((m) => m.name).sort()).toEqual(["Standalone", "Widget"]); +}); diff --git a/packages/http-server-csharp/src/service-resolution.ts b/packages/http-server-csharp/src/service-resolution.ts index 281c6147799..b0a6dc40afa 100644 --- a/packages/http-server-csharp/src/service-resolution.ts +++ b/packages/http-server-csharp/src/service-resolution.ts @@ -21,7 +21,11 @@ import { preAssignAnonymousResponseNames, resetAnonymousModels, } from "./components/models/anonymous-models.js"; -import { getServiceInterfaces, getServiceNamespaceName } from "./service-discovery.js"; +import { + getDeclarationNamespaces, + getServiceInterfaces, + getServiceNamespaceName, +} from "./service-discovery.js"; import { findServiceNamespace } from "./utils/namespace-utils.js"; /** All resolved service types, computed once before rendering. */ @@ -40,19 +44,24 @@ export interface ServiceTypeResolution { unionEnums: Union[]; /** Canonicalized HTTP operations per interface. */ canonicalOpsMap: Map; + /** Namespaces whose declarations are emitted without being referenced. */ + declarationNamespaces: Set; } /** * Resolves all service types in a single pass, eliminating redundant * namespace traversals that previously occurred in individual components. * + * Only types declared in the service namespace(s) are emitted unconditionally. + * Types declared elsewhere are emitted only when the service references them, + * directly or transitively. + * * Ordering: * 1. Service namespace discovery * 2. Interface collection (including synthetic interfaces for namespace-level ops) * 3. Anonymous response model naming (depends on interfaces) - * 4. Enum and union-enum collection - * 5. Model discovery (walks operations from interfaces + namespace models) - * 6. Canonicalization of HTTP operations + * 4. Type discovery (service declarations + everything they reference) + * 5. Canonicalization of HTTP operations */ export function resolveServiceTypes( program: Program, @@ -66,26 +75,27 @@ export function resolveServiceTypes( // Phase 1: Service namespace const serviceNamespace = findServiceNamespace(globalNs); const serviceNamespaceName = getServiceNamespaceName(program); + const declarationNamespaces = getDeclarationNamespaces(program); // Phase 2: Interfaces (includes synthetic ones for namespace-level operations) - const interfaces = getServiceInterfaces(program); + const interfaces = getServiceInterfaces(program, declarationNamespaces); // Phase 3: Pre-assign contextual names to anonymous response models preAssignAnonymousResponseNames(interfaces); - // Phase 4: Enums and union-enums from all non-std namespaces - const enums: Enum[] = []; - const unionEnums: Union[] = []; - collectEnumsFromNamespaces(globalNs, enums, unionEnums); - - // Phase 5: Model discovery (namespace models + operation-referenced models) + // Phase 4: Models, enums and union-enums. // Auth scheme models (e.g. those referenced by `@useAuth`) are protocol metadata, // not payload data, so they must not be emitted as C# model classes (aligns with // the OpenAPI3 emitter, which emits them under `components.securitySchemes`). const authModels = getAuthSchemeModels(program); - const models = getServiceModels($, globalNs, authModels); + const { models, enums, unionEnums } = collectServiceTypes( + $, + declarationNamespaces, + interfaces, + authModels, + ); - // Phase 6: Canonicalize all HTTP operations + // Phase 5: Canonicalize all HTTP operations const canonicalOpsMap = canonicalizeAllInterfaces(canonicalizer, interfaces); return { @@ -96,42 +106,10 @@ export function resolveServiceTypes( enums, unionEnums, canonicalOpsMap, + declarationNamespaces, }; } -/** - * Collects all enums and union-enums from non-std namespaces in a single walk. - */ -function collectEnumsFromNamespaces( - globalNs: TspNamespace, - enums: Enum[], - unionEnums: Union[], -): void { - const seenEnums = new Set(); - const seenUnions = new Set(); - - function walk(ns: TspNamespace): void { - for (const en of ns.enums?.values() ?? []) { - if (!seenEnums.has(en) && en.name) { - seenEnums.add(en); - enums.push(en); - } - } - for (const union of ns.unions?.values() ?? []) { - if (!seenUnions.has(union) && isUnionEnum(union)) { - seenUnions.add(union); - unionEnums.push(union); - } - } - for (const childNs of ns.namespaces?.values() ?? []) { - if (isStdNamespace(childNs)) continue; - walk(childNs); - } - } - - walk(globalNs); -} - /** * Canonicalize all operations for each interface, skipping any that fail. */ @@ -154,139 +132,174 @@ function canonicalizeAllInterfaces( return result; } -// ── Model discovery ───────────────────────────────────────────────────── +// ── Type discovery ────────────────────────────────────────────────────── + +/** The set of types that qualify for emission. */ +interface CollectedTypes { + models: Model[]; + enums: Enum[]; + unionEnums: Union[]; +} + +/** Sink used while walking the type graph. */ +interface TypeCollector { + addModel(model: Model): void; + addEnum(en: Enum): void; + addUnion(union: Union): void; +} /** - * Retrieves all models from the program that should be emitted. - * Includes namespace-level models AND models referenced by operations. + * Collects every type that should be emitted: the declarations of the service + * namespace(s) plus everything the service references, directly or transitively. + * + * Types declared outside those namespaces are only emitted when referenced, so + * that the emitter does not generate dead code for types the service never exposes. * * @param authModels Models that back authentication schemes; these are excluded * from emission because they represent protocol metadata rather than payloads. */ -function getServiceModels($: Typekit, globalNs: TspNamespace, authModels: Set): Model[] { +function collectServiceTypes( + $: Typekit, + declarationNamespaces: Set, + interfaces: Interface[], + authModels: Set, +): CollectedTypes { const models: Model[] = []; - const seen = new Set(); + const enums: Enum[] = []; + const unionEnums: Union[] = []; + const seenModels = new Set(); + const seenEnums = new Set(); + const seenUnions = new Set(); + const visited = new Set(); - function addModel(model: Model) { - if (seen.has(model)) return; - seen.add(model); - if (authModels.has(model)) return; - if (shouldEmitModel($, model)) { - models.push(model); - } - } + const collector: TypeCollector = { + addModel(model) { + if (seenModels.has(model)) return; + seenModels.add(model); + if (authModels.has(model)) return; + if (shouldEmitModel($, model)) { + models.push(model); + } + }, + addEnum(en) { + if (seenEnums.has(en) || !en.name) return; + seenEnums.add(en); + if (isBuiltInNamespace(en.namespace)) return; + enums.push(en); + }, + addUnion(union) { + if (seenUnions.has(union)) return; + seenUnions.add(union); + if (isBuiltInNamespace(union.namespace)) return; + if (isUnionEnum(union)) { + unionEnums.push(union); + } + }, + }; - // Collect from namespaces - for (const model of globalNs.models.values()) { - addModel(model); - } - for (const ns of globalNs.namespaces.values()) { - if (isStdNamespace(ns)) continue; - collectModelsFromNamespace($, ns, models, seen, authModels); + // Declarations of the service itself are always emitted. + const declaredUnions: Union[] = []; + for (const ns of declarationNamespaces) { + for (const model of ns.models?.values() ?? []) { + collector.addModel(model); + } + for (const en of ns.enums?.values() ?? []) { + collector.addEnum(en); + } + for (const union of ns.unions?.values() ?? []) { + collector.addUnion(union); + declaredUnions.push(union); + } } - // Walk operations to discover referenced models (template instantiations, etc.) - const visited = new Set(); - for (const ns of globalNs.namespaces.values()) { - if (isStdNamespace(ns)) continue; - discoverReferencedModels($, ns, addModel, visited); + // Walk operations to discover referenced types (template instantiations, etc.) + for (const iface of interfaces) { + for (const [, op] of iface.operations) { + discoverTypes($, op.returnType, collector, visited); + for (const param of op.parameters?.properties?.values() ?? []) { + discoverTypes($, param.type, collector, visited); + } + } } - // Walk all collected model properties to discover anonymous sub-models - const modelsSnapshot = [...models]; - for (const model of modelsSnapshot) { + // Walk the declared types to discover everything they reference. + for (const model of [...models]) { for (const prop of model.properties.values()) { - discoverModelsInType($, prop.type, addModel, visited); + discoverTypes($, prop.type, collector, visited); } if (model.baseModel) { - discoverModelsInType($, model.baseModel, addModel, visited); + discoverTypes($, model.baseModel, collector, visited); } } - - return models; -} - -/** Walks operations in a namespace to discover referenced model types. */ -function discoverReferencedModels( - $: Typekit, - ns: TspNamespace, - addModel: (m: Model) => void, - visited: Set, -): void { - for (const op of ns.operations?.values() ?? []) { - discoverModelsInType($, op.returnType, addModel, visited); - for (const param of op.parameters?.properties?.values() ?? []) { - discoverModelsInType($, param.type, addModel, visited); - } - } - for (const iface of ns.interfaces?.values() ?? []) { - for (const op of iface.operations?.values() ?? []) { - discoverModelsInType($, op.returnType, addModel, visited); - for (const param of op.parameters?.properties?.values() ?? []) { - discoverModelsInType($, param.type, addModel, visited); - } - } - } - for (const childNs of ns.namespaces?.values() ?? []) { - if (isStdNamespace(childNs)) continue; - discoverReferencedModels($, childNs, addModel, visited); + for (const union of declaredUnions) { + discoverTypes($, union, collector, visited); } + + return { models, enums, unionEnums }; } -/** Recursively discovers models referenced by a type. */ -function discoverModelsInType( - $: Typekit, - type: Type, - addModel: (m: Model) => void, - visited: Set, -): void { +/** Recursively discovers the emittable types referenced by a type. */ +function discoverTypes($: Typekit, type: Type, collector: TypeCollector, visited: Set): void { if (visited.has(type)) return; visited.add(type); - if (type.kind === "Model") { - if ($.array.is(type)) { - if (type.indexer?.value) { - discoverModelsInType($, type.indexer.value, addModel, visited); + switch (type.kind) { + case "Model": + if ($.array.is(type) || $.record.is(type)) { + if (type.indexer?.value) { + discoverTypes($, type.indexer.value, collector, visited); + } + return; } - } else if (!$.record.is(type)) { - addModel(type); + collector.addModel(type); for (const prop of type.properties.values()) { - discoverModelsInType($, prop.type, addModel, visited); + discoverTypes($, prop.type, collector, visited); } if (type.baseModel) { - discoverModelsInType($, type.baseModel, addModel, visited); + discoverTypes($, type.baseModel, collector, visited); } if (type.templateMapper) { for (const arg of type.templateMapper.args) { if (arg.entityKind === "Type") { - discoverModelsInType($, arg, addModel, visited); + discoverTypes($, arg, collector, visited); } } } - } - } else if (type.kind === "Union") { - for (const variant of type.variants.values()) { - discoverModelsInType($, variant.type, addModel, visited); - } + return; + case "Union": + collector.addUnion(type); + for (const variant of type.variants.values()) { + discoverTypes($, variant.type, collector, visited); + } + return; + case "UnionVariant": + discoverTypes($, type.type, collector, visited); + return; + case "Enum": + collector.addEnum(type); + return; + case "EnumMember": + collector.addEnum(type.enum); + return; + case "ModelProperty": + discoverTypes($, type.type, collector, visited); + return; + case "Tuple": + for (const value of type.values) { + discoverTypes($, value, collector, visited); + } + return; + default: + return; } } -function collectModelsFromNamespace( - $: Typekit, - ns: TspNamespace, - models: Model[], - seen: Set, - authModels: Set, -): void { - for (const model of ns.models?.values() ?? []) { - if (!seen.has(model) && !authModels.has(model) && shouldEmitModel($, model)) { - seen.add(model); - models.push(model); - } - } - for (const childNs of ns.namespaces?.values() ?? []) { - collectModelsFromNamespace($, childNs, models, seen, authModels); - } +/** Whether a namespace belongs to the TypeSpec standard library. */ +function isBuiltInNamespace(ns: TspNamespace | undefined): boolean { + if (!ns) return false; + if (isStdNamespace(ns)) return true; + const nsName = getNamespaceFullName(ns); + return nsName === "TypeSpec" || nsName.startsWith("TypeSpec."); } /**