Skip to content

Commit afdf684

Browse files
committed
Replace legacy emitter with foundation skeleton and diagnostics
Removes the procedural GraphQL emitter and stands up the data-pipeline skeleton that the upcoming Alloy component-based emitter will build on. Deleted: - graphql-emitter.ts (old procedural emitter) - schema-emitter.ts (old schema-specific emitter) - registry.ts (old type registry) - type-maps.ts (old type mapping logic) - test/emitter.test.ts tests for the legacy emitter Rewrote src/emitter.ts as a four-phase data pipeline: - Phase 1: type usage tracking (reachability / input-output marking) - Phase 2: mutation (GraphQL naming via the mutation engine) - Phase 3: classification (interfaces, output models, input models, ops) - Phase 4: model variant lookups Phase 5 (SDL rendering) is intentionally left as a TODO. Component-based rendering, file emission, and the \`.tsx\` conversion land in follow-up PRs in the chain. Also introduces two new diagnostics that the pipeline reports today: - empty-schema: fires when a schema has no query root (GraphQL requires one) - void-operation-return: fires when an operation returns void (no GraphQL equivalent; the operation is filtered out of the schema) Test coverage added for both diagnostics in test/emitter.test.ts.
1 parent 06daaa6 commit afdf684

7 files changed

Lines changed: 419 additions & 403 deletions

File tree

packages/graphql/src/emitter.ts

Lines changed: 360 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,371 @@
1-
import type { EmitContext, NewLine } from "@typespec/compiler";
2-
import { resolvePath } from "@typespec/compiler";
3-
import { createGraphQLEmitter } from "./graphql-emitter.js";
4-
import type { GraphQLEmitterOptions } from "./lib.js";
5-
6-
const defaultOptions = {
7-
"new-line": "lf",
8-
"omit-unreachable-types": false,
9-
strict: false,
10-
} as const;
1+
import {
2+
getEncode,
3+
isArrayModelType,
4+
isUnknownType,
5+
isVoidType,
6+
navigateTypesInNamespace,
7+
type EmitContext,
8+
type Enum,
9+
type Model,
10+
type ModelProperty,
11+
type Namespace,
12+
type Operation,
13+
type Program,
14+
type Scalar,
15+
type Type,
16+
type Union,
17+
} from "@typespec/compiler";
18+
import { isInterface } from "./lib/interface.js";
19+
import { getOperationKind } from "./lib/operation-kind.js";
20+
import { type GraphQLEmitterOptions, reportDiagnostic } from "./lib.js";
21+
import { resolveTypeUsage, GraphQLTypeUsage, type TypeUsageResolver } from "./type-usage.js";
22+
import { listSchemas } from "./lib/schema.js";
23+
import { createGraphQLMutationEngine, GraphQLTypeContext } from "./mutation-engine/index.js";
24+
import type { ClassifiedTypes, ModelVariants, ScalarVariant } from "./context/index.js";
25+
import { unwrapNullableUnion } from "./lib/type-utils.js";
26+
import { getGraphQLBuiltinName, getScalarMapping } from "./lib/scalar-mappings.js";
27+
import { getSpecifiedBy } from "./lib/specified-by.js";
1128

29+
/**
30+
* Main emitter entry point for GraphQL SDL generation.
31+
*
32+
* Runs the full data pipeline (type usage → mutation → classification) but
33+
* does not yet render output. Component-based SDL rendering will be added
34+
* in follow-up PRs.
35+
*/
1236
export async function $onEmit(context: EmitContext<GraphQLEmitterOptions>) {
13-
const options = resolveOptions(context);
14-
const emitter = createGraphQLEmitter(context, options);
15-
await emitter.emitGraphQL();
37+
const schemas = listSchemas(context.program);
38+
if (schemas.length === 0) {
39+
schemas.push({ type: context.program.getGlobalNamespaceType() });
40+
}
41+
42+
for (const schema of schemas) {
43+
await emitSchema(context, schema);
44+
}
1645
}
1746

18-
export interface ResolvedGraphQLEmitterOptions {
19-
outputFile: string;
20-
newLine: NewLine;
21-
omitUnreachableTypes: boolean;
22-
strict: boolean;
47+
/**
48+
* Process a single GraphQL schema through the data pipeline.
49+
*/
50+
async function emitSchema(
51+
context: EmitContext<GraphQLEmitterOptions>,
52+
schema: { type: Namespace; name?: string },
53+
) {
54+
// Phase 1: Type usage tracking — determine which types are reachable from operations.
55+
// Must run before mutation so we can filter on original (pre-clone) type objects.
56+
const omitUnreachable = context.options["omit-unreachable-types"] ?? false;
57+
const typeUsage = resolveTypeUsage(schema.type, omitUnreachable);
58+
59+
// Phase 2: Mutation — transform TypeSpec types with GraphQL naming conventions.
60+
// Unreachable enums/unions are skipped during mutation (avoids identity mismatch with cloned objects).
61+
const { mutatedTypes, scalarSpecifications, originalToMutated } = mutateTypes(
62+
context,
63+
schema,
64+
typeUsage,
65+
);
66+
67+
// Phase 3: Classification — separate types by category.
68+
const classifiedTypes = classifyTypes(
69+
context.program,
70+
mutatedTypes,
71+
originalToMutated,
72+
typeUsage,
73+
);
74+
75+
// Phase 4: Build model variant lookups.
76+
const _modelVariants = buildModelVariants(classifiedTypes);
77+
78+
// GraphQL requires at least a Query root type. If there are no query operations,
79+
// the schema cannot be built. Emit a diagnostic and skip rendering.
80+
if (classifiedTypes.queries.length === 0) {
81+
reportDiagnostic(context.program, {
82+
code: "empty-schema",
83+
target: schema.type,
84+
});
85+
return;
86+
}
87+
88+
// Phase 5: Component-based SDL rendering.
89+
// TODO: Render using Alloy JSX components (added in follow-up PRs).
90+
void _modelVariants;
91+
void scalarSpecifications;
2392
}
2493

25-
export function resolveOptions(
94+
// ---------------------------------------------------------------------------
95+
// Phase 2: Mutation
96+
// ---------------------------------------------------------------------------
97+
98+
/**
99+
* Mutate all types in a schema namespace using the mutation engine.
100+
* Unreachable enums/unions are skipped based on the type usage resolver.
101+
*/
102+
function mutateTypes(
26103
context: EmitContext<GraphQLEmitterOptions>,
27-
): ResolvedGraphQLEmitterOptions {
28-
const resolvedOptions = { ...defaultOptions, ...context.options };
29-
const outputFile = resolvedOptions["output-file"] ?? "{schema-name}.graphql";
104+
schema: { type: Namespace },
105+
typeUsage: TypeUsageResolver,
106+
) {
107+
const engine = createGraphQLMutationEngine(context.program);
108+
const mutatedModels: Model[] = [];
109+
const mutatedEnums: Enum[] = [];
110+
const mutatedScalars: Scalar[] = [];
111+
const mutatedUnions: Union[] = [];
112+
const mutatedOperations: Operation[] = [];
113+
const wrapperModels: Model[] = [];
114+
const scalarSpecifications = new Map<string, string>();
115+
const originalToMutated = new Map<Model, Model>();
116+
const processedScalars = new Set<string>();
117+
const scalarVariantsMap = new Map<string, ScalarVariant>();
118+
119+
const processScalar = (node: Scalar): void => {
120+
const isDirectGraphQLBuiltin =
121+
context.program.checker.isStdType(node, "string") ||
122+
context.program.checker.isStdType(node, "int32") ||
123+
context.program.checker.isStdType(node, "float32") ||
124+
context.program.checker.isStdType(node, "float64") ||
125+
context.program.checker.isStdType(node, "boolean");
126+
127+
if (isDirectGraphQLBuiltin) return;
128+
129+
const mutation = engine.mutateScalar(node);
130+
const graphqlName = mutation.mutatedType.name;
131+
132+
if (!processedScalars.has(graphqlName)) {
133+
processedScalars.add(graphqlName);
134+
mutatedScalars.push(mutation.mutatedType);
135+
136+
const specUrl = getSpecifiedBy(context.program, mutation.mutatedType);
137+
if (specUrl) {
138+
scalarSpecifications.set(graphqlName, specUrl);
139+
}
140+
}
141+
};
142+
143+
const processScalarVariant = (target: ModelProperty): void => {
144+
if (isUnknownType(target.type)) {
145+
if (!scalarVariantsMap.has("Unknown")) {
146+
scalarVariantsMap.set("Unknown", {
147+
sourceScalar: target.type,
148+
encoding: "default",
149+
graphqlName: "Unknown",
150+
specificationUrl: undefined,
151+
});
152+
}
153+
return;
154+
}
155+
if (
156+
target.type.kind === "Scalar" &&
157+
context.program.checker.isStdType(target.type) &&
158+
!getGraphQLBuiltinName(context.program, target.type)
159+
) {
160+
const encodeData = getEncode(context.program, target);
161+
const encoding = encodeData?.encoding;
162+
const mapping = getScalarMapping(context.program, target.type, encoding);
163+
if (mapping && !scalarVariantsMap.has(mapping.graphqlName)) {
164+
scalarVariantsMap.set(mapping.graphqlName, {
165+
sourceScalar: target.type,
166+
encoding: encoding || "default",
167+
graphqlName: mapping.graphqlName,
168+
specificationUrl: mapping.specificationUrl,
169+
});
170+
}
171+
}
172+
};
173+
174+
navigateTypesInNamespace(schema.type, {
175+
model: (node: Model) => {
176+
if (isArrayModelType(context.program, node)) return;
177+
const mutation = engine.mutateModel(node, GraphQLTypeContext.Output);
178+
mutatedModels.push(mutation.mutatedType);
179+
originalToMutated.set(node, mutation.mutatedType);
180+
},
181+
enum: (node: Enum) => {
182+
if (typeUsage.isUnreachable(node)) return;
183+
const mutation = engine.mutateEnum(node);
184+
mutatedEnums.push(mutation.mutatedType);
185+
},
186+
scalar: (node: Scalar) => {
187+
processScalar(node);
188+
},
189+
union: (node: Union) => {
190+
// Skip nullable unions (e.g., string | null) — they're not union declarations.
191+
// Nullability for these is detected at render time in GraphQLTypeExpression.
192+
if (unwrapNullableUnion(node) !== undefined) {
193+
return;
194+
}
195+
if (typeUsage.isUnreachable(node)) return;
196+
const mutation = engine.mutateUnion(node, GraphQLTypeContext.Output);
197+
mutatedUnions.push(mutation.mutatedType as Union);
198+
wrapperModels.push(...mutation.wrapperModels);
199+
},
200+
operation: (node: Operation) => {
201+
// Operations are passed through unmutated. This is load-bearing:
202+
// typeUsage walked operation params/returns on original types to mark input/output.
203+
// classifyTypes reverse-maps mutated models via originalToMutated.
204+
mutatedOperations.push(node);
205+
},
206+
});
207+
208+
// Collect referenced scalars from model properties and operations.
209+
// Standard library scalars like int64, utcDateTime are not declared in the schema,
210+
// but are referenced in model properties — we need to collect and mutate them.
211+
const visitedTypes = new Set<Type>();
212+
213+
const collectReferencedScalars = (type: Type): void => {
214+
if (visitedTypes.has(type)) return;
215+
visitedTypes.add(type);
216+
217+
if (type.kind === "Scalar") {
218+
processScalar(type);
219+
} else if (type.kind === "Model" && isArrayModelType(context.program, type)) {
220+
if (type.indexer?.value) {
221+
collectReferencedScalars(type.indexer.value);
222+
}
223+
} else if (type.kind === "Model") {
224+
for (const prop of type.properties.values()) {
225+
collectReferencedScalars(prop.type);
226+
}
227+
} else if (type.kind === "Union") {
228+
for (const variant of type.variants.values()) {
229+
collectReferencedScalars(variant.type);
230+
}
231+
}
232+
};
233+
234+
// Uses original (pre-mutation) models because mutated type refs won't match processedScalars.
235+
const originalModels = Array.from(originalToMutated.keys());
236+
for (const model of originalModels) {
237+
for (const prop of model.properties.values()) {
238+
collectReferencedScalars(prop.type);
239+
processScalarVariant(prop);
240+
}
241+
}
242+
243+
for (const op of mutatedOperations) {
244+
for (const param of op.parameters.properties.values()) {
245+
collectReferencedScalars(param.type);
246+
processScalarVariant(param);
247+
}
248+
collectReferencedScalars(op.returnType);
249+
}
30250

31251
return {
32-
outputFile: resolvePath(context.emitterOutputDir, outputFile),
33-
newLine: resolvedOptions["new-line"],
34-
omitUnreachableTypes: resolvedOptions["omit-unreachable-types"],
35-
strict: resolvedOptions["strict"],
252+
mutatedTypes: {
253+
models: mutatedModels,
254+
enums: mutatedEnums,
255+
scalars: mutatedScalars,
256+
unions: mutatedUnions,
257+
operations: mutatedOperations,
258+
wrapperModels,
259+
scalarVariants: Array.from(scalarVariantsMap.values()),
260+
},
261+
scalarSpecifications,
262+
originalToMutated,
36263
};
37264
}
265+
266+
// ---------------------------------------------------------------------------
267+
// Phase 3: Classification
268+
// ---------------------------------------------------------------------------
269+
270+
/**
271+
* Classify types into categories (interfaces, output types, input types, operations).
272+
*/
273+
function classifyTypes(
274+
program: Program,
275+
mutatedTypes: ReturnType<typeof mutateTypes>["mutatedTypes"],
276+
originalToMutated: Map<Model, Model>,
277+
typeUsage: TypeUsageResolver,
278+
): ClassifiedTypes {
279+
const interfaces: Model[] = [];
280+
const outputModels: Model[] = [];
281+
const inputModels: Model[] = [];
282+
const queries: Operation[] = [];
283+
const mutations: Operation[] = [];
284+
const subscriptions: Operation[] = [];
285+
286+
// Create reverse mapping
287+
const mutatedToOriginal = new Map<Model, Model>();
288+
for (const [orig, mut] of originalToMutated) {
289+
mutatedToOriginal.set(mut, orig);
290+
}
291+
292+
for (const model of mutatedTypes.models) {
293+
const originalModel = mutatedToOriginal.get(model) || model;
294+
if (typeUsage.isUnreachable(originalModel)) {
295+
continue;
296+
}
297+
298+
// Check @Interface on the original (pre-clone) model, since decorator state
299+
// is stored against original type identity, not mutated clones.
300+
if (isInterface(program, originalModel)) {
301+
interfaces.push(model);
302+
} else {
303+
const usage = typeUsage.getUsage(originalModel);
304+
const usedAsInput = usage?.has(GraphQLTypeUsage.Input) ?? false;
305+
const usedAsOutput = usage?.has(GraphQLTypeUsage.Output) ?? false;
306+
307+
if (!usedAsInput && !usedAsOutput) {
308+
// Reachable but not referenced by any operation — default to Output
309+
outputModels.push(model);
310+
} else {
311+
if (usedAsOutput) outputModels.push(model);
312+
if (usedAsInput) inputModels.push(model);
313+
}
314+
}
315+
}
316+
317+
// Add wrapper models created by union mutations (always used as output)
318+
outputModels.push(...mutatedTypes.wrapperModels);
319+
320+
// Classify operations by kind, filtering out void-returning operations
321+
for (const op of mutatedTypes.operations) {
322+
if (isVoidType(op.returnType)) {
323+
reportDiagnostic(program, {
324+
code: "void-operation-return",
325+
format: { name: op.name },
326+
target: op,
327+
});
328+
continue;
329+
}
330+
const kind = getOperationKind(program, op);
331+
if (kind === "Query") queries.push(op);
332+
else if (kind === "Mutation") mutations.push(op);
333+
else if (kind === "Subscription") subscriptions.push(op);
334+
}
335+
336+
return {
337+
interfaces,
338+
outputModels,
339+
inputModels,
340+
enums: mutatedTypes.enums,
341+
scalars: mutatedTypes.scalars,
342+
scalarVariants: mutatedTypes.scalarVariants,
343+
unions: mutatedTypes.unions,
344+
queries,
345+
mutations,
346+
subscriptions,
347+
};
348+
}
349+
350+
// ---------------------------------------------------------------------------
351+
// Phase 4: Model variant lookups
352+
// ---------------------------------------------------------------------------
353+
354+
/**
355+
* Build model variant lookups for checking which variants exist.
356+
*/
357+
function buildModelVariants(classifiedTypes: ClassifiedTypes): ModelVariants {
358+
const modelVariants: ModelVariants = {
359+
outputModels: new Map(),
360+
inputModels: new Map(),
361+
};
362+
363+
classifiedTypes.outputModels.forEach((m) =>
364+
modelVariants.outputModels.set(m.name, m),
365+
);
366+
classifiedTypes.inputModels.forEach((m) =>
367+
modelVariants.inputModels.set(m.name, m),
368+
);
369+
370+
return modelVariants;
371+
}

0 commit comments

Comments
 (0)