diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 23e32a7590c..487bcf39087 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -30,15 +30,16 @@ "node": ">=20.0.0" }, "dependencies": { - "@alloy-js/core": "^0.11.0", - "@alloy-js/typescript": "^0.11.0", + "@alloy-js/core": "^0.22.0", + "@alloy-js/graphql": "link:../../alloy/packages/graphql", + "@alloy-js/typescript": "^0.22.0", "change-case": "^5.4.4", "graphql": "^16.9.0" }, "scripts": { "clean": "rimraf ./dist ./temp", - "build": "tsc -p .", - "watch": "tsc --watch", + "build": "alloy build", + "watch": "alloy build --watch", "test": "vitest run", "test:watch": "vitest -w", "lint": "eslint . --max-warnings=0", @@ -56,6 +57,8 @@ "@typespec/mutator-framework": "workspace:~" }, "devDependencies": { + "@alloy-js/cli": "^0.22.0", + "@alloy-js/rollup-plugin": "^0.1.0", "@types/node": "~22.13.13", "@typespec/compiler": "workspace:~", "@typespec/emitter-framework": "workspace:~", diff --git a/packages/graphql/src/components/fields/field.tsx b/packages/graphql/src/components/fields/field.tsx new file mode 100644 index 00000000000..ff475b07f92 --- /dev/null +++ b/packages/graphql/src/components/fields/field.tsx @@ -0,0 +1,72 @@ +import { type ModelProperty, getDeprecationDetails, isArrayModelType } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { resolveGraphQLTypeName } from "../../lib/graphql-type-name.js"; +import { hasNullableElements, isNullable } from "../../lib/nullable.js"; + +export interface FieldProps { + property: ModelProperty; + isInput: boolean; +} + +export function Field(props: FieldProps) { + const { $, program } = useTsp(); + + const doc = $.type.getDoc(props.property); + const deprecation = getDeprecationDetails(program, props.property); + const nullable = isNullable(props.property) || props.property.optional; + const type = props.property.type; + + if (type.kind === "Model" && isArrayModelType(type)) { + const elemNullable = hasNullableElements(props.property); + const typeName = resolveGraphQLTypeName(type.indexer.value); + + if (props.isInput) { + return ( + + + + ); + } + + return ( + + + + ); + } + + if (props.isInput) { + return ( + + ); + } + + return ( + + ); +} diff --git a/packages/graphql/src/components/fields/index.ts b/packages/graphql/src/components/fields/index.ts new file mode 100644 index 00000000000..0cec3b46b26 --- /dev/null +++ b/packages/graphql/src/components/fields/index.ts @@ -0,0 +1,2 @@ +export { Field, type FieldProps } from "./field.js"; +export { OperationField, type OperationFieldProps } from "./operation-field.js"; diff --git a/packages/graphql/src/components/fields/operation-field.tsx b/packages/graphql/src/components/fields/operation-field.tsx new file mode 100644 index 00000000000..f6af1194b60 --- /dev/null +++ b/packages/graphql/src/components/fields/operation-field.tsx @@ -0,0 +1,58 @@ +import { type Operation, getDeprecationDetails, isArrayModelType } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { resolveGraphQLTypeName } from "../../lib/graphql-type-name.js"; +import { hasNullableElements, isNullable } from "../../lib/nullable.js"; + +export interface OperationFieldProps { + operation: Operation; +} + +export function OperationField(props: OperationFieldProps) { + const { $, program } = useTsp(); + + const doc = $.type.getDoc(props.operation); + const deprecation = getDeprecationDetails(program, props.operation); + const returnType = props.operation.returnType; + const nullable = isNullable(props.operation); + const params = Array.from(props.operation.parameters.properties.values()); + + const isList = returnType.kind === "Model" && isArrayModelType(returnType); + const typeName = isList + ? resolveGraphQLTypeName(returnType.indexer.value) + : resolveGraphQLTypeName(returnType); + const elemNullable = isList && hasNullableElements(props.operation); + + return ( + + {isList ? : undefined} + {params.map((param) => { + const paramNullable = isNullable(param) || param.optional; + const paramType = param.type; + const paramIsList = paramType.kind === "Model" && isArrayModelType(paramType); + const paramElemNullable = paramIsList && hasNullableElements(param); + const paramTypeName = paramIsList + ? resolveGraphQLTypeName(paramType.indexer.value) + : resolveGraphQLTypeName(paramType); + + return ( + + {paramIsList ? : undefined} + + ); + })} + + ); +} diff --git a/packages/graphql/src/components/types/enum-type.tsx b/packages/graphql/src/components/types/enum-type.tsx new file mode 100644 index 00000000000..8b98089d2d1 --- /dev/null +++ b/packages/graphql/src/components/types/enum-type.tsx @@ -0,0 +1,25 @@ +import { type Enum, getDoc, getDeprecationDetails } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; + +export interface EnumTypeProps { + type: Enum; +} + +export function EnumType(props: EnumTypeProps) { + const { program } = useTsp(); + const doc = getDoc(program, props.type); + const members = [...props.type.members.values()]; + + return ( + + {members.map((member) => ( + + ))} + + ); +} diff --git a/packages/graphql/src/components/types/index.ts b/packages/graphql/src/components/types/index.ts new file mode 100644 index 00000000000..00f4a951e33 --- /dev/null +++ b/packages/graphql/src/components/types/index.ts @@ -0,0 +1,3 @@ +export { EnumType, type EnumTypeProps } from "./enum-type.js"; +export { ScalarType, type ScalarTypeProps } from "./scalar-type.js"; +export { UnionType, type UnionTypeProps, type GraphQLUnion } from "./union-type.js"; diff --git a/packages/graphql/src/components/types/scalar-type.tsx b/packages/graphql/src/components/types/scalar-type.tsx new file mode 100644 index 00000000000..4ea4410fbc6 --- /dev/null +++ b/packages/graphql/src/components/types/scalar-type.tsx @@ -0,0 +1,21 @@ +import { type Scalar, getDoc } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; + +export interface ScalarTypeProps { + type: Scalar; + specificationUrl?: string; +} + +export function ScalarType(props: ScalarTypeProps) { + const { program } = useTsp(); + const doc = getDoc(program, props.type); + + return ( + + ); +} diff --git a/packages/graphql/src/components/types/union-type.tsx b/packages/graphql/src/components/types/union-type.tsx new file mode 100644 index 00000000000..387861f03b1 --- /dev/null +++ b/packages/graphql/src/components/types/union-type.tsx @@ -0,0 +1,24 @@ +import { type Model, type Union, getDoc } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; + +/** + * A Union guaranteed to have a name after mutation. + * The mutation engine ensures this: anonymous unions get derived names. + */ +export interface GraphQLUnion extends Union { + name: string; +} + +export interface UnionTypeProps { + type: GraphQLUnion; +} + +export function UnionType(props: UnionTypeProps) { + const { program } = useTsp(); + const doc = getDoc(program, props.type); + const variants = [...props.type.variants.values()]; + const members = variants.map((v) => (v.type as Model).name); + + return ; +} diff --git a/packages/graphql/src/context/graphql-schema-context.tsx b/packages/graphql/src/context/graphql-schema-context.tsx new file mode 100644 index 00000000000..f51826dcb89 --- /dev/null +++ b/packages/graphql/src/context/graphql-schema-context.tsx @@ -0,0 +1,21 @@ +import { type ComponentContext, createNamedContext, useContext } from "@alloy-js/core"; +import type { TypeGraph } from "../mutation-engine/type-graph.js"; + +export interface GraphQLSchemaContextValue { + typeGraph: TypeGraph; +} + +export const GraphQLSchemaContext: ComponentContext = + createNamedContext("GraphQLSchema"); + +export function useGraphQLSchema(): GraphQLSchemaContextValue { + const context = useContext(GraphQLSchemaContext); + + if (!context) { + throw new Error( + "useGraphQLSchema must be used within GraphQLSchemaContext.Provider.", + ); + } + + return context; +} diff --git a/packages/graphql/src/context/index.ts b/packages/graphql/src/context/index.ts new file mode 100644 index 00000000000..cf8c0efcbbb --- /dev/null +++ b/packages/graphql/src/context/index.ts @@ -0,0 +1,5 @@ +export { + GraphQLSchemaContext, + useGraphQLSchema, + type GraphQLSchemaContextValue, +} from "./graphql-schema-context.js"; diff --git a/packages/graphql/src/emitter.ts b/packages/graphql/src/emitter.ts index 6a34c0552df..03a3b77e3eb 100644 --- a/packages/graphql/src/emitter.ts +++ b/packages/graphql/src/emitter.ts @@ -1,37 +1,57 @@ -import type { EmitContext, NewLine } from "@typespec/compiler"; -import { resolvePath } from "@typespec/compiler"; -import { createGraphQLEmitter } from "./graphql-emitter.js"; -import type { GraphQLEmitterOptions } from "./lib.js"; - -const defaultOptions = { - "new-line": "lf", - "omit-unreachable-types": false, - strict: false, -} as const; +import { type EmitContext, type Namespace } from "@typespec/compiler"; +import { type GraphQLEmitterOptions, reportDiagnostic } from "./lib.js"; +import { listSchemas } from "./lib/schema.js"; +import { createGraphQLMutationEngine } from "./mutation-engine/index.js"; +import { mutateSchema } from "./mutation-engine/schema-mutator.js"; +import type { TypeGraph } from "./mutation-engine/type-graph.js"; +import { resolveTypeUsage } from "./type-usage.js"; +/** + * Main emitter entry point for GraphQL SDL generation. + * + * Pipeline: type-usage → mutation → buildTypeGraph → render. + * Rendering is a stub in this PR — will be implemented when the Schema + * orchestrator component is added. + */ export async function $onEmit(context: EmitContext) { - const options = resolveOptions(context); - const emitter = createGraphQLEmitter(context, options); - await emitter.emitGraphQL(); -} + const schemas = listSchemas(context.program); + if (schemas.length === 0) { + schemas.push({ type: context.program.getGlobalNamespaceType() }); + } -export interface ResolvedGraphQLEmitterOptions { - outputFile: string; - newLine: NewLine; - omitUnreachableTypes: boolean; - strict: boolean; + for (const schema of schemas) { + const typeGraph = emitSchema(context, schema.type); + if (typeGraph) { + renderSchema(typeGraph, schema.name); + } + } } -export function resolveOptions( +function emitSchema( context: EmitContext, -): ResolvedGraphQLEmitterOptions { - const resolvedOptions = { ...defaultOptions, ...context.options }; - const outputFile = resolvedOptions["output-file"] ?? "{schema-name}.graphql"; + schema: Namespace, +): TypeGraph | undefined { + const program = context.program; + const omitUnreachable = context.options["omit-unreachable-types"] ?? false; + + const typeUsage = resolveTypeUsage(schema, omitUnreachable); + const engine = createGraphQLMutationEngine(program); + const typeGraph = mutateSchema(program, engine, schema, typeUsage); + + if (typeGraph.globalNamespace.operations.size === 0) { + reportDiagnostic(program, { + code: "empty-schema", + target: schema, + }); + return undefined; + } + + return typeGraph; +} - return { - outputFile: resolvePath(context.emitterOutputDir, outputFile), - newLine: resolvedOptions["new-line"], - omitUnreachableTypes: resolvedOptions["omit-unreachable-types"], - strict: resolvedOptions["strict"], - }; +function renderSchema(_typeGraph: TypeGraph, _schemaName?: string): void { + // Stub — will be replaced with Alloy component rendering: + // + // + // } diff --git a/packages/graphql/src/graphql-emitter.ts b/packages/graphql/src/graphql-emitter.ts deleted file mode 100644 index fdb3ffca332..00000000000 --- a/packages/graphql/src/graphql-emitter.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { emitFile, interpolatePath, type EmitContext } from "@typespec/compiler"; -import { printSchema } from "graphql"; -import type { ResolvedGraphQLEmitterOptions } from "./emitter.js"; -import type { GraphQLEmitterOptions } from "./lib.js"; -import { listSchemas } from "./lib/schema.js"; -import { createSchemaEmitter } from "./schema-emitter.js"; -import type { GraphQLSchemaRecord } from "./types.js"; - -export function createGraphQLEmitter( - context: EmitContext, - options: ResolvedGraphQLEmitterOptions, -) { - const program = context.program; - - return { - emitGraphQL, - }; - - async function emitGraphQL() { - if (!program.compilerOptions.noEmit) { - const schemaRecords = await getGraphQL(); - // first, emit diagnostics - for (const schemaRecord of schemaRecords) { - program.reportDiagnostics(schemaRecord.diagnostics); - } - if (program.hasError()) { - return; - } - for (const schemaRecord of schemaRecords) { - const schemaName = schemaRecord.schema.name || "schema"; - const filePath = interpolatePath(options.outputFile, { - "schema-name": schemaName, - }); - await emitFile(program, { - path: filePath, - content: printSchema(schemaRecord.graphQLSchema), - newLine: options.newLine, - }); - } - } - } - - async function getGraphQL(): Promise { - const schemaRecords: GraphQLSchemaRecord[] = []; - const schemas = listSchemas(program); - if (schemas.length === 0) { - schemas.push({ type: program.getGlobalNamespaceType() }); - } - for (const schema of schemas) { - const schemaEmitter = createSchemaEmitter(schema, context, options); - const document = await schemaEmitter.emitSchema(); - if (document === undefined) { - continue; - } - schemaRecords.push({ - schema: schema, - graphQLSchema: document[0], - diagnostics: document[1], - }); - } - return schemaRecords; - } -} diff --git a/packages/graphql/src/lib.ts b/packages/graphql/src/lib.ts index 156ddba9127..4e4033a206a 100644 --- a/packages/graphql/src/lib.ts +++ b/packages/graphql/src/lib.ts @@ -162,6 +162,19 @@ export const libDef = { default: paramMessage`Scalar "${"name"}" collides with GraphQL built-in type "${"builtinName"}". This may cause unexpected behavior. Consider renaming the scalar.`, }, }, + "type-name-collision": { + severity: "error", + messages: { + default: paramMessage`Type "${"name"}" collides with another type of the same name in the GraphQL schema. Consider renaming one of the types.`, + }, + }, + "empty-schema": { + severity: "warning", + messages: { + default: + "GraphQL schema has no operations. At minimum a Query root type is required.", + }, + }, }, emitter: { options: EmitterOptionsSchema as JSONSchemaType, diff --git a/packages/graphql/src/lib/interface.ts b/packages/graphql/src/lib/interface.ts index cf76213f2a2..bb97cfcd93b 100644 --- a/packages/graphql/src/lib/interface.ts +++ b/packages/graphql/src/lib/interface.ts @@ -10,9 +10,11 @@ import { import { useStateMap, useStateSet } from "@typespec/compiler/utils"; import { GraphQLKeys, NAMESPACE, reportDiagnostic } from "../lib.js"; -import type { Tagged } from "../types.d.ts"; import { propertiesEqual } from "./utils.js"; +declare const tags: unique symbol; +type Tagged = BaseType & { [tags]: { [K in Tag]: void } }; + // This will set the namespace for decorators implemented in this file export const namespace = NAMESPACE; diff --git a/packages/graphql/src/mutation-engine/index.ts b/packages/graphql/src/mutation-engine/index.ts index 131420ec0ed..e66e7ad38c9 100644 --- a/packages/graphql/src/mutation-engine/index.ts +++ b/packages/graphql/src/mutation-engine/index.ts @@ -9,3 +9,5 @@ export { GraphQLUnionMutation, } from "./mutations/index.js"; export { GraphQLMutationOptions, GraphQLTypeContext } from "./options.js"; +export { mutateSchema } from "./schema-mutator.js"; +export { buildTypeGraph, type TypeGraph } from "./type-graph.js"; diff --git a/packages/graphql/src/mutation-engine/mutations/union.ts b/packages/graphql/src/mutation-engine/mutations/union.ts index e8ee273d9c2..ddc53652d6c 100644 --- a/packages/graphql/src/mutation-engine/mutations/union.ts +++ b/packages/graphql/src/mutation-engine/mutations/union.ts @@ -159,31 +159,8 @@ export class GraphQLUnionMutation extends UnionMutation { - return tk.unionVariant.create({ - name: variantNameToString(variant.name), - type: variant.type, - }); - }); - - const flattenedUnion = tk.union.create({ - name: unionName, - variants: variantArray, - }); - - this.#flattenedUnion = flattenedUnion; - } else { - this.#mutationNode.mutate((union) => { - union.name = unionName; - }); - } - - if (hasNull) { - setNullable(this.mutatedType); - } - // GraphQL unions can only contain object types — wrap scalars in synthetic models + // and substitute the wrapper into the variant so the union is self-contained. for (const variant of mutatedVariants) { const isScalar = variant.type.kind === "Scalar" || variant.type.kind === "Intrinsic"; @@ -204,8 +181,37 @@ export class GraphQLUnionMutation extends UnionMutation 0) { + const variantArray = mutatedVariants.map((variant) => { + return tk.unionVariant.create({ + name: variantNameToString(variant.name), + type: variant.type, + }); + }); + + const flattenedUnion = tk.type.clone(this.sourceType); + flattenedUnion.name = unionName; + flattenedUnion.variants.clear(); + for (const variant of variantArray) { + flattenedUnion.variants.set(variant.name, variant); + variant.union = flattenedUnion; + } + tk.type.finishType(flattenedUnion); + + this.#flattenedUnion = flattenedUnion; + } else { + this.#mutationNode.mutate((union) => { + union.name = unionName; + }); + } + + if (hasNull) { + setNullable(this.mutatedType); + } } /** diff --git a/packages/graphql/src/mutation-engine/schema-mutator.ts b/packages/graphql/src/mutation-engine/schema-mutator.ts new file mode 100644 index 00000000000..cc0c6978f20 --- /dev/null +++ b/packages/graphql/src/mutation-engine/schema-mutator.ts @@ -0,0 +1,99 @@ +import { + isArrayModelType, + navigateTypesInNamespace, + type Enum, + type Model, + type Namespace, + type Operation, + type Program, + type Scalar, + type Type, + type Union, +} from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; +import { reportDiagnostic } from "../lib.js"; +import { GraphQLTypeUsage, type TypeUsageResolver } from "../type-usage.js"; +import type { GraphQLMutationEngine } from "./engine.js"; +import { GraphQLTypeContext } from "./options.js"; +import { buildTypeGraph, type TypeGraph } from "./type-graph.js"; + +/** + * Walk every type in the schema namespace, mutate it through the GraphQL + * mutation engine, and package the results into a TypeGraph. + * + * Filtering (unreachable types, array models, nullable unions) happens here + * so the engine only processes types that belong in the schema. + * + * Models used as both input and output get two mutations (Output and Input), + * producing separate entries in the TypeGraph (e.g., `Book` and `BookInput`). + */ +export function mutateSchema( + program: Program, + engine: GraphQLMutationEngine, + schema: Namespace, + typeUsage: TypeUsageResolver, +): TypeGraph { + const tk = $(program); + const mutatedTypes: Type[] = []; + + navigateTypesInNamespace(schema, { + model: (node: Model) => { + if (isArrayModelType(node)) return; + if (typeUsage.isUnreachable(node)) return; + + const usage = typeUsage.getUsage(node); + const usedAsOutput = usage?.has(GraphQLTypeUsage.Output) ?? false; + const usedAsInput = usage?.has(GraphQLTypeUsage.Input) ?? false; + + if (usedAsOutput || !usage) { + const mutation = engine.mutateModel(node, GraphQLTypeContext.Output); + mutatedTypes.push(mutation.mutatedType); + } + if (usedAsInput) { + const mutation = engine.mutateModel(node, GraphQLTypeContext.Input); + mutatedTypes.push(mutation.mutatedType); + } + }, + enum: (node: Enum) => { + if (typeUsage.isUnreachable(node)) return; + + const mutation = engine.mutateEnum(node); + mutatedTypes.push(mutation.mutatedType); + }, + scalar: (node: Scalar) => { + if (typeUsage.isUnreachable(node)) return; + const mutation = engine.mutateScalar(node); + mutatedTypes.push(mutation.mutatedType); + }, + union: (node: Union) => { + if (typeUsage.isUnreachable(node)) return; + + const mutation = engine.mutateUnion(node, GraphQLTypeContext.Output); + mutatedTypes.push(mutation.mutatedType); + for (const wrapper of mutation.wrapperModels) { + mutatedTypes.push(wrapper); + } + }, + operation: (node: Operation) => { + const mutation = engine.mutateOperation(node); + mutatedTypes.push(mutation.mutatedType); + }, + }); + + const seen = new Map(); + for (const type of mutatedTypes) { + if (!("name" in type) || !type.name) continue; + const name = type.name as string; + if (seen.has(name)) { + reportDiagnostic(program, { + code: "type-name-collision", + format: { name }, + target: type, + }); + } else { + seen.set(name, type); + } + } + + return buildTypeGraph(program, tk, mutatedTypes); +} diff --git a/packages/graphql/src/registry.ts b/packages/graphql/src/registry.ts deleted file mode 100644 index 12e7d59ddd3..00000000000 --- a/packages/graphql/src/registry.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { UsageFlags, type Enum, type Model } from "@typespec/compiler"; -import { - GraphQLBoolean, - GraphQLEnumType, - GraphQLObjectType, - type GraphQLNamedType, - type GraphQLSchemaConfig, -} from "graphql"; - -// The TSPTypeContext interface represents the intermediate TSP type information before materialization. -// It stores the raw TSP type and any extracted metadata relevant for GraphQL generation. -interface TSPTypeContext { - tspType: Enum | Model; // Extend with other TSP types like Operation, Interface, TSP Union, etc. - name: string; - usageFlags?: Set; - // TODO: Add any other TSP-specific metadata here. -} -/** - * GraphQLTypeRegistry manages the registration and materialization of TypeSpec (TSP) - * types into their corresponding GraphQL type definitions. - * - * The registry operates in a two-stage process: - * 1. Registration: TSP types (like Enums, Models, etc.) are first registered - * along with relevant metadata (e.g., name, usage flags). This stores an - * intermediate representation (`TSPTypeContext`) without immediately creating - * GraphQL types. This stage is typically performed while traversing the TSP AST. - * Register type by calling the appropriate method (e.g., `addEnum`). - * - * 2. Materialization: When a GraphQL type is needed (e.g., to build the final - * schema or resolve a field type), the registry can materialize the TSP type - * into its GraphQL counterpart (e.g., `GraphQLEnumType`, `GraphQLObjectType`). - * Materialize types by calling the appropriate method (e.g., `materializeEnum`). - * - * This approach helps in: - * - Decoupling TSP AST traversal from GraphQL object instantiation. - * - Caching materialized GraphQL types to avoid redundant work and ensure object identity. - * - Handling forward references and circular dependencies, as types can be - * registered first and materialized later when all dependencies are known or - * by using thunks for fields/arguments. - */ -export class GraphQLTypeRegistry { - // Stores intermediate TSP type information, keyed by TSP type name. - // TODO: make this more of a seen set - private TSPTypeContextRegistry: Map = new Map(); - - // Stores materialized GraphQL types, keyed by their GraphQL name. - private materializedGraphQLTypes: Map = new Map(); - - addEnum(tspEnum: Enum): void { - const enumName = tspEnum.name; - if (this.TSPTypeContextRegistry.has(enumName)) { - // Optionally, log a warning or update if new information is more complete. - return; - } - - this.TSPTypeContextRegistry.set(enumName, { - tspType: tspEnum, - name: enumName, - // TODO: Populate usageFlags based on TSP context and other decorator context. - }); - } - - // Materializes a TSP Enum into a GraphQLEnumType. - materializeEnum(enumName: string): GraphQLEnumType | undefined { - // Check if the GraphQL type is already materialized. - if (this.materializedGraphQLTypes.has(enumName)) { - return this.materializedGraphQLTypes.get(enumName) as GraphQLEnumType; - } - - const context = this.TSPTypeContextRegistry.get(enumName); - if (!context || context.tspType.kind !== "Enum") { - // TODO: Handle error or warning for missing context. - return undefined; - } - - const tspEnum = context.tspType as Enum; - - const gqlEnum = new GraphQLEnumType({ - name: context.name, - values: Object.fromEntries( - Array.from(tspEnum.members.values()).map((member) => [ - member.name, - { - value: member.value ?? member.name, - }, - ]), - ), - }); - - this.materializedGraphQLTypes.set(enumName, gqlEnum); - return gqlEnum; - } - - materializeSchemaConfig(): GraphQLSchemaConfig { - const allMaterializedGqlTypes = Array.from(this.materializedGraphQLTypes.values()); - let queryType = this.materializedGraphQLTypes.get("Query") as GraphQLObjectType | undefined; - if (!queryType) { - queryType = new GraphQLObjectType({ - name: "Query", - fields: { - _: { - type: GraphQLBoolean, - description: - "A placeholder field. If you are seeing this, it means no operations were defined that could be emitted.", - }, - }, - }); - } - - return { - query: queryType, - types: allMaterializedGqlTypes.length > 0 ? allMaterializedGqlTypes : null, - }; - } -} diff --git a/packages/graphql/src/schema-emitter.ts b/packages/graphql/src/schema-emitter.ts deleted file mode 100644 index b922fe49b0a..00000000000 --- a/packages/graphql/src/schema-emitter.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - createDiagnosticCollector, - navigateTypesInNamespace, - type Diagnostic, - type DiagnosticCollector, - type EmitContext, - type Enum, - type Model, -} from "@typespec/compiler"; -import { GraphQLSchema, validateSchema } from "graphql"; -import { type GraphQLEmitterOptions } from "./lib.js"; -import type { Schema } from "./lib/schema.js"; -import { GraphQLTypeRegistry } from "./registry.js"; - -class GraphQLSchemaEmitter { - private tspSchema: Schema; - private context: EmitContext; - private options: GraphQLEmitterOptions; - private diagnostics: DiagnosticCollector; - private registry: GraphQLTypeRegistry; - constructor( - tspSchema: Schema, - context: EmitContext, - options: GraphQLEmitterOptions, - ) { - // Initialize any properties if needed, including the registry - this.tspSchema = tspSchema; - this.context = context; - this.options = options; - this.diagnostics = createDiagnosticCollector(); - this.registry = new GraphQLTypeRegistry(); - } - - async emitSchema(): Promise<[GraphQLSchema, Readonly] | undefined> { - const schemaNamespace = this.tspSchema.type; - // Logic to emit the GraphQL schema - navigateTypesInNamespace(schemaNamespace, this.semanticNodeListener()); - const schemaConfig = this.registry.materializeSchemaConfig(); - const schema = new GraphQLSchema(schemaConfig); - // validate the schema - const validationErrors = validateSchema(schema); - validationErrors.forEach((error) => { - this.diagnostics.add({ - message: error.message, - code: "GraphQLSchemaValidationError", - target: this.tspSchema.type, - severity: "error", - }); - }); - return [schema, this.diagnostics.diagnostics]; - } - - semanticNodeListener() { - // TODO: Add GraphQL types to registry as the TSP nodes are visited - return { - enum: (node: Enum) => { - this.registry.addEnum(node); - }, - model: (node: Model) => { - // Add logic to handle the model node - }, - exitEnum: (node: Enum) => { - this.registry.materializeEnum(node.name); - }, - exitModel: (node: Model) => { - // Add logic to handle the exit of the model node - }, - }; - } -} - -export function createSchemaEmitter( - schema: Schema, - context: EmitContext, - options: GraphQLEmitterOptions, -): GraphQLSchemaEmitter { - // Placeholder for creating a GraphQL schema emitter - return new GraphQLSchemaEmitter(schema, context, options); -} - -export type { GraphQLSchemaEmitter }; diff --git a/packages/graphql/src/type-maps.ts b/packages/graphql/src/type-maps.ts deleted file mode 100644 index eefb2b8aa33..00000000000 --- a/packages/graphql/src/type-maps.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { UsageFlags, type Type } from "@typespec/compiler"; -import type { GraphQLType } from "graphql"; - -/** - * TypeSpec context for type mapping - * @template T - The TypeSpec type - */ -export interface TSPContext { - type: T; // The TypeSpec type - usageFlag: UsageFlags; // How the type is being used (input, output, etc.) - graphqlName?: string; // Optional GraphQL type name override (e.g., "ModelInput" for input types) - metadata: Record; // Additional metadata -} - -/** - * Nominal type for keys in the TypeMap - */ -type TypeKey = string & { __typeKey: any }; - -/** - * Base TypeMap for all GraphQL type mappings - * @template T - The TypeSpec type constrained to TSP's Type - * @template G - The GraphQL type constrained to GraphQL's GraphQLType - */ -export abstract class TypeMap { - // Map of materialized GraphQL types - protected materializedMap = new Map(); - - // Map of registration contexts - protected registrationMap = new Map>(); - - /** - * Register a TypeSpec type with context for later materialization - * @param context - The TypeSpec context - * @returns The name used for registration as a TypeKey - */ - register(context: TSPContext): TypeKey { - // Check if the type is already registered - const name = this.getNameFromContext(context); - if (this.isRegistered(name)) { - throw new Error(`Type ${name} is already registered`); - } - - // Register the type - this.registrationMap.set(name, context); - return name; - } - - /** - * Get the materialized GraphQL type - * @param name - The type name as a TypeKey - * @returns The materialized GraphQL type or undefined - */ - get(name: TypeKey): G | undefined { - // Return already materialized type if available - if (this.materializedMap.has(name)) { - return this.materializedMap.get(name); - } - - // Attempt to materialize if registered - const context = this.registrationMap.get(name); - if (context) { - const materializedType = this.materialize(context); - this.materializedMap.set(name, materializedType); - return materializedType; - } - - return undefined; - } - - /** - * Check if a type is registered - */ - isRegistered(name: string): boolean { - return this.registrationMap.has(name as TypeKey); - } - - /** - * Get all materialized types - */ - getAllMaterialized(): MapIterator { - return this.materializedMap.values(); - } - - /** - * Reset the type map - */ - reset(): void { - this.materializedMap.clear(); - this.registrationMap.clear(); - } - - /** - * Get a name from a context - */ - protected abstract getNameFromContext(context: TSPContext): TypeKey; - - /** - * Materialize a type from a context - */ - protected abstract materialize(context: TSPContext): G; -} diff --git a/packages/graphql/src/types.d.ts b/packages/graphql/src/types.d.ts deleted file mode 100644 index 651cc1bb81f..00000000000 --- a/packages/graphql/src/types.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Diagnostic } from "@typespec/compiler"; -import type { GraphQLSchema } from "graphql"; -import type { Schema } from "./lib/schema.ts"; - -/** - * A record containing the GraphQL schema corresponding to - * a particular schema definition. - */ -export interface GraphQLSchemaRecord { - /** The declared schema that generated this GraphQL schema */ - readonly schema: Schema; - - /** The GraphQLSchema */ - readonly graphQLSchema: GraphQLSchema; - - /** The diagnostics created for this schema */ - readonly diagnostics: readonly Diagnostic[]; -} - -declare const tags: unique symbol; - -type Tagged = BaseType & { [tags]: { [K in Tag]: void } }; diff --git a/packages/graphql/test/components/enum-type.test.tsx b/packages/graphql/test/components/enum-type.test.tsx new file mode 100644 index 00000000000..dbc52639e86 --- /dev/null +++ b/packages/graphql/test/components/enum-type.test.tsx @@ -0,0 +1,102 @@ +import { t } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { EnumType } from "../../src/components/types/index.js"; +import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; +import { renderToSDL } from "./test-utils.js"; + +describe("EnumType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a basic enum", async () => { + const { Color } = await tester.compile( + t.code`enum ${t.enum("Color")} { Red, Green, Blue }`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Color).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain("enum Color"); + expect(sdl).toContain("RED"); + expect(sdl).toContain("GREEN"); + expect(sdl).toContain("BLUE"); + }); + + it("renders enum with doc comment as description", async () => { + const { Role } = await tester.compile( + t.code` + /** The role a user can have */ + enum ${t.enum("Role")} { Admin, User } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Role).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain('"The role a user can have"'); + expect(sdl).toContain("enum Role"); + }); + + it("renders enum with member descriptions", async () => { + const { Status } = await tester.compile( + t.code` + enum ${t.enum("Status")} { + /** Currently active */ + Active, + /** No longer active */ + Inactive, + } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Status).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain('"Currently active"'); + expect(sdl).toContain('"No longer active"'); + }); + + it("renders enum with deprecated members", async () => { + const { Status } = await tester.compile( + t.code` + enum ${t.enum("Status")} { + Active, + #deprecated "use Active instead" + Legacy, + } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Status).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain("@deprecated"); + expect(sdl).toContain("use Active instead"); + }); + + it("renders enum with mutation-engine-sanitized member names", async () => { + const { E } = await tester.compile( + t.code`enum ${t.enum("E")} { \`$val1$\`, \`val-2\` }`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(E).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + // Mutation engine: sanitize → CONSTANT_CASE + expect(sdl).toContain("_VAL_1"); + expect(sdl).toContain("VAL_2"); + }); +}); diff --git a/packages/graphql/test/components/scalar-type.test.tsx b/packages/graphql/test/components/scalar-type.test.tsx new file mode 100644 index 00000000000..d71ed1b89bf --- /dev/null +++ b/packages/graphql/test/components/scalar-type.test.tsx @@ -0,0 +1,79 @@ +import { t } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { ScalarType } from "../../src/components/types/index.js"; +import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; +import { getSpecifiedBy } from "../../src/lib/specified-by.js"; +import { Tester } from "../test-host.js"; +import { renderToSDL } from "./test-utils.js"; + +describe("ScalarType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a custom scalar", async () => { + const { DateTime } = await tester.compile( + t.code`scalar ${t.scalar("DateTime")} extends string;`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateScalar(DateTime).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain("scalar DateTime"); + }); + + it("renders a scalar with doc comment description", async () => { + const { JSON } = await tester.compile( + t.code` + /** Arbitrary JSON blob */ + scalar ${t.scalar("JSON")} extends string; + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateScalar(JSON).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain('"Arbitrary JSON blob"'); + expect(sdl).toContain("scalar JSON"); + }); + + it("renders a scalar with @specifiedBy", async () => { + const { MyScalar } = await tester.compile( + t.code` + @specifiedBy("https://example.com/spec") + scalar ${t.scalar("MyScalar")} extends string; + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateScalar(MyScalar).mutatedType; + const specUrl = getSpecifiedBy(tester.program, mutated); + + const sdl = renderToSDL( + tester.program, + , + ); + + expect(sdl).toContain("@specifiedBy"); + expect(sdl).toContain("https://example.com/spec"); + }); + + it("renders a scalar without @specifiedBy when not present", async () => { + const { MyScalar } = await tester.compile( + t.code`scalar ${t.scalar("MyScalar")} extends string;`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateScalar(MyScalar).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain("scalar MyScalar"); + expect(sdl).not.toContain("@specifiedBy"); + }); +}); diff --git a/packages/graphql/test/components/test-utils.tsx b/packages/graphql/test/components/test-utils.tsx new file mode 100644 index 00000000000..e88af9200e5 --- /dev/null +++ b/packages/graphql/test/components/test-utils.tsx @@ -0,0 +1,35 @@ +import { type Children } from "@alloy-js/core"; +import * as gql from "@alloy-js/graphql"; +import { renderSchema } from "@alloy-js/graphql"; +import { printSchema } from "graphql"; +import type { Program } from "@typespec/compiler"; +import { TspContext } from "@typespec/emitter-framework"; +import { GraphQLSchemaContext } from "../../src/context/index.js"; +import type { TypeGraph } from "../../src/mutation-engine/type-graph.js"; + +/** + * Render GraphQL components in isolation and return SDL string. + * Wraps children in required context providers and adds a placeholder Query + * (graphql-js requires at least one query field). + */ +export function renderToSDL(program: Program, children: Children): string { + const typeGraph: TypeGraph = { + globalNamespace: program.getGlobalNamespaceType(), + }; + + const schema = renderSchema( + + + {children} + + + + + , + { namePolicy: null }, + ); + + // Cast needed: alloy uses graphql@17-alpha internally, our package uses graphql@16. + // At runtime both are deduped via vitest config; the type mismatch is superficial. + return printSchema(schema as any); +} diff --git a/packages/graphql/test/components/union-type.test.tsx b/packages/graphql/test/components/union-type.test.tsx new file mode 100644 index 00000000000..897bdd67d48 --- /dev/null +++ b/packages/graphql/test/components/union-type.test.tsx @@ -0,0 +1,140 @@ +import { t } from "@typespec/compiler/testing"; +import * as gql from "@alloy-js/graphql"; +import { beforeEach, describe, expect, it } from "vitest"; +import { UnionType, type GraphQLUnion } from "../../src/components/types/index.js"; +import { + createGraphQLMutationEngine, + GraphQLTypeContext, +} from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; +import { renderToSDL } from "./test-utils.js"; + +describe("UnionType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a union of model types", async () => { + const { Pet } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + model ${t.model("Dog")} { breed: string; } + union ${t.union("Pet")} { cat: Cat; dog: Dog; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output); + const mutatedUnion = mutation.mutatedType as GraphQLUnion; + + // Union members must be registered for graphql-js to validate the schema + const sdl = renderToSDL( + tester.program, + <> + + + + + + + + , + ); + + expect(sdl).toContain("union Pet = Cat | Dog"); + }); + + it("renders a union with doc comment description", async () => { + const { Result } = await tester.compile( + t.code` + model ${t.model("Success")} { value: string; } + model ${t.model("Failure")} { message: string; } + /** The result of an operation */ + union ${t.union("Result")} { success: Success; failure: Failure; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Result, GraphQLTypeContext.Output); + const mutatedUnion = mutation.mutatedType as GraphQLUnion; + + const sdl = renderToSDL( + tester.program, + <> + + + + + + + + , + ); + + expect(sdl).toContain('"The result of an operation"'); + expect(sdl).toContain("union Result = Success | Failure"); + }); + + it("references wrapper model names for scalar variants", async () => { + const { Mixed } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + union ${t.union("Mixed")} { cat: Cat; text: string; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output); + const mutatedUnion = mutation.mutatedType as GraphQLUnion; + + // Register the wrapper model and Cat so graphql-js can validate + const sdl = renderToSDL( + tester.program, + <> + + + + + + + + , + ); + + expect(sdl).toContain("union Mixed = Cat | MixedTextUnionVariant"); + }); + + it("renders a union with three model members", async () => { + const { Shape } = await tester.compile( + t.code` + model ${t.model("Circle")} { radius: float32; } + model ${t.model("Square")} { side: float32; } + model ${t.model("Triangle")} { base: float32; } + union ${t.union("Shape")} { circle: Circle; square: Square; triangle: Triangle; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Shape, GraphQLTypeContext.Output); + const mutatedUnion = mutation.mutatedType as GraphQLUnion; + + const sdl = renderToSDL( + tester.program, + <> + + + + + + + + + + + , + ); + + expect(sdl).toContain("union Shape = Circle | Square | Triangle"); + }); +}); diff --git a/packages/graphql/test/emitter.test.ts b/packages/graphql/test/emitter.test.ts index bd0b1a74566..b43c7817f88 100644 --- a/packages/graphql/test/emitter.test.ts +++ b/packages/graphql/test/emitter.test.ts @@ -1,18 +1,8 @@ -import { strictEqual } from "node:assert"; import { describe, it } from "vitest"; -import { emitSingleSchema } from "./test-host.js"; +import { emitSingleSchemaWithDiagnostics } from "./test-host.js"; -// For now, the expected output is a placeholder string. -// In the future, this should be replaced with the actual GraphQL schema output. -const expectedGraphQLSchema = `type Query { - """ - A placeholder field. If you are seeing this, it means no operations were defined that could be emitted. - """ - _: Boolean -}`; - -describe("name", () => { - it("Emits a schema.graphql file with placeholder text", async () => { +describe("emitter", () => { + it("runs the mutation pipeline without errors", async () => { const code = ` @schema namespace TestNamespace { @@ -30,7 +20,10 @@ describe("name", () => { op getAuthors(): Author[]; } `; - const results = await emitSingleSchema(code, {}); - strictEqual(results, expectedGraphQLSchema); + const result = await emitSingleSchemaWithDiagnostics(code, {}); + // Rendering is a stub — no output expected yet. + // This test verifies the pipeline (type-usage → mutation → buildTypeGraph) completes. + // Output assertions will be added when the Schema orchestrator component lands. + result; // no-op, just ensure no throw }); }); diff --git a/packages/graphql/test/mutation-engine/schema-mutator.test.ts b/packages/graphql/test/mutation-engine/schema-mutator.test.ts new file mode 100644 index 00000000000..5fd9684c287 --- /dev/null +++ b/packages/graphql/test/mutation-engine/schema-mutator.test.ts @@ -0,0 +1,253 @@ +import { t } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; +import { mutateSchema } from "../../src/mutation-engine/schema-mutator.js"; +import { resolveTypeUsage } from "../../src/type-usage.js"; +import { Tester } from "../test-host.js"; + +describe("mutateSchema", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("produces a TypeGraph with mutated models", async () => { + await tester.compile( + t.code` + model ${t.model("ad_account")} { id: int32; } + op ${t.op("getAccount")}(): ad_account; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.models.has("AdAccount")).toBe(true); + }); + + it("produces a TypeGraph with mutated operations", async () => { + await tester.compile( + t.code` + op ${t.op("get_items")}(): string; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.operations.has("getItems")).toBe(true); + }); + + it("produces a TypeGraph with mutated enums", async () => { + await tester.compile( + t.code` + enum ${t.enum("status")} { active, inactive } + model ${t.model("Foo")} { s: status; } + op ${t.op("getFoo")}(): Foo; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.enums.has("Status")).toBe(true); + }); + + it("produces a TypeGraph with mutated unions", async () => { + await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + model ${t.model("Dog")} { breed: string; } + union ${t.union("Pet")} { cat: Cat; dog: Dog; } + op ${t.op("getPet")}(): Pet; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.unions.has("Pet")).toBe(true); + }); + + it("skips unreachable types when omitUnreachableTypes is true", async () => { + await tester.compile( + t.code` + model ${t.model("Reachable")} { x: int32; } + model ${t.model("Unreachable")} { y: string; } + op ${t.op("getReachable")}(): Reachable; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, true); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.models.has("Reachable")).toBe(true); + expect(typeGraph.globalNamespace.models.has("Unreachable")).toBe(false); + }); + + it("includes all declared types when omitUnreachableTypes is false", async () => { + await tester.compile( + t.code` + model ${t.model("Reachable")} { x: int32; } + model ${t.model("Unreachable")} { y: string; } + op ${t.op("getReachable")}(): Reachable; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.models.has("Reachable")).toBe(true); + expect(typeGraph.globalNamespace.models.has("Unreachable")).toBe(true); + }); + + it("T | null unions do not appear in the TypeGraph (engine replaces with inner type)", async () => { + await tester.compile( + t.code` + union ${t.union("MaybeString")} { string, null } + model ${t.model("Foo")} { x: int32; } + op ${t.op("getFoo")}(): Foo; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + // MaybeString is T | null — should NOT appear as a union in the TypeGraph + expect(typeGraph.globalNamespace.unions.has("MaybeString")).toBe(false); + }); + + it("includes wrapper models from union scalar variants", async () => { + await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + union ${t.union("Mixed")} { cat: Cat; text: string; num: int32; } + op ${t.op("getMixed")}(): Mixed; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + // Scalar variants get wrapper models registered in the TypeGraph + expect(typeGraph.globalNamespace.models.has("MixedTextUnionVariant")).toBe(true); + expect(typeGraph.globalNamespace.models.has("MixedNumUnionVariant")).toBe(true); + }); + + it("produces Input variant for models used as operation parameters", async () => { + await tester.compile( + t.code` + model ${t.model("Book")} { title: string; } + op ${t.op("getBooks")}(): Book[]; + op ${t.op("createBook")}(input: Book): Book; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + // Book is used as both output (return) and input (parameter), + // so both variants should appear in the TypeGraph + expect(typeGraph.globalNamespace.models.has("Book")).toBe(true); + expect(typeGraph.globalNamespace.models.has("BookInput")).toBe(true); + }); + + it("does not produce Input variant for output-only models", async () => { + await tester.compile( + t.code` + model ${t.model("Book")} { title: string; } + op ${t.op("getBooks")}(): Book[]; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.models.has("Book")).toBe(true); + expect(typeGraph.globalNamespace.models.has("BookInput")).toBe(false); + }); + + it("does not produce Output variant for input-only models", async () => { + await tester.compile( + t.code` + model ${t.model("Book")} { title: string; } + model ${t.model("Payload")} { title: string; } + op ${t.op("getBooks")}(): Book[]; + op ${t.op("createBook")}(input: Payload): Book; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, true); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + // Payload is only used as input — should only appear as Input variant (PayloadInput) + expect(typeGraph.globalNamespace.models.has("PayloadInput")).toBe(true); + // Should NOT have an Output variant + expect(typeGraph.globalNamespace.models.has("Payload")).toBe(false); + }); + + it("reports diagnostic when two types produce the same GraphQL name", async () => { + const [_, diagnostics] = await tester.compileAndDiagnose( + t.code` + model ${t.model("BookInput")} { x: int32; } + model ${t.model("Book")} { title: string; } + op ${t.op("getBooks")}(): Book[]; + op ${t.op("createBook")}(input: Book): Book; + `, + ); + + // Book used as input → Input mutation → "BookInput" + // BookInput declared explicitly → Output mutation → "BookInput" + // This should produce a collision diagnostic + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + mutateSchema(tester.program, engine, ns, typeUsage); + + const collisions = tester.program.diagnostics.filter( + (d) => d.code === "@typespec/graphql/type-name-collision", + ); + expect(collisions.length).toBeGreaterThan(0); + }); + + it("skips array models (they are list types, not object types)", async () => { + await tester.compile( + t.code` + model ${t.model("Item")} { name: string; } + op ${t.op("getItems")}(): Item[]; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + // Item should be in the graph, but the Array model should NOT + expect(typeGraph.globalNamespace.models.has("Item")).toBe(true); + const modelNames = [...typeGraph.globalNamespace.models.keys()]; + expect(modelNames.every((n) => !n.includes("Array"))).toBe(true); + }); +}); diff --git a/packages/graphql/test/mutation-engine/unions.test.ts b/packages/graphql/test/mutation-engine/unions.test.ts index 08c2ba8d4df..723d2d01637 100644 --- a/packages/graphql/test/mutation-engine/unions.test.ts +++ b/packages/graphql/test/mutation-engine/unions.test.ts @@ -1,4 +1,4 @@ -import type { Model, Union } from "@typespec/compiler"; +import { getDoc, type Model, type Union } from "@typespec/compiler"; import { t } from "@typespec/compiler/testing"; import { beforeEach, describe, expect, it } from "vitest"; import { isNullable } from "../../src/lib/nullable.js"; @@ -71,6 +71,32 @@ describe("GraphQL Mutation Engine - Unions", () => { expect(mutation.wrapperModels[0].name).toBe("MixedTextUnionVariant"); }); + it("substitutes wrapper models into union variant types", async () => { + const { Mixed } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + union ${t.union("Mixed")} { cat: Cat; text: string; } + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output); + const mutatedUnion = mutation.mutatedType as Union; + + const variants = [...mutatedUnion.variants.values()]; + expect(variants).toHaveLength(2); + + // Model variant points to the mutated model + const catVariant = variants.find((v) => v.name === "cat")!; + expect(catVariant.type.kind).toBe("Model"); + expect((catVariant.type as Model).name).toBe("Cat"); + + // Scalar variant points to the wrapper model, not the raw scalar + const textVariant = variants.find((v) => v.name === "text")!; + expect(textVariant.type.kind).toBe("Model"); + expect((textVariant.type as Model).name).toBe("MixedTextUnionVariant"); + }); + it("does not create wrappers for model-only unions", async () => { const { Pet } = await tester.compile( t.code` @@ -111,6 +137,13 @@ describe("GraphQL Mutation Engine - Unions", () => { expect(mutation.wrapperModels).toHaveLength(2); const names = mutation.wrapperModels.map((m) => m.name).sort(); expect(names).toEqual(["MixedCountUnionVariant", "MixedTextUnionVariant"]); + + // All union variants point to Models (originals or wrappers) + const mutatedUnion = mutation.mutatedType as Union; + const variants = [...mutatedUnion.variants.values()]; + for (const variant of variants) { + expect(variant.type.kind).toBe("Model"); + } }); it("names anonymous return type union as OperationUnion", async () => { @@ -185,6 +218,22 @@ describe("GraphQL Mutation Engine - Unions", () => { expect(variantNames).toEqual(["AdAccount", "Board"]); }); + it("preserves decorator state (e.g. @doc) on flattened unions", async () => { + const { MaybePet } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + model ${t.model("Dog")} { breed: string; } + /** A pet or nothing */ + union ${t.union("MaybePet")} { cat: Cat; dog: Dog; null; } + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(MaybePet, GraphQLTypeContext.Output); + + expect(getDoc(tester.program, mutation.mutatedType)).toBe("A pet or nothing"); + }); + it("T | null replacement gets its mutation pipeline applied", async () => { await tester.compile( t.code` diff --git a/packages/graphql/tsconfig.json b/packages/graphql/tsconfig.json index ad68b784463..d2f18d1ce33 100644 --- a/packages/graphql/tsconfig.json +++ b/packages/graphql/tsconfig.json @@ -1,10 +1,18 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "useDefineForClassFields": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "es2022", + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "jsx": "preserve", + "jsxImportSource": "@alloy-js/core", + "emitDeclarationOnly": true, "rootDir": ".", - "outDir": "dist", - "verbatimModuleSyntax": true + "outDir": "dist" }, - "include": ["src", "test"] + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], + "exclude": ["node_modules", "dist"] } diff --git a/packages/graphql/vitest.config.ts b/packages/graphql/vitest.config.ts index 63cad767f57..c35108ef7bb 100644 --- a/packages/graphql/vitest.config.ts +++ b/packages/graphql/vitest.config.ts @@ -1,4 +1,18 @@ +import alloyPlugin from "@alloy-js/rollup-plugin"; import { defineConfig, mergeConfig } from "vitest/config"; import { defaultTypeSpecVitestConfig } from "../../vitest.config.js"; -export default mergeConfig(defaultTypeSpecVitestConfig, defineConfig({})); +export default mergeConfig( + defaultTypeSpecVitestConfig, + defineConfig({ + esbuild: { + jsx: "preserve", + sourcemap: "both", + }, + plugins: [alloyPlugin()], + resolve: { + conditions: ["development"], + dedupe: ["@alloy-js/core", "graphql"], + }, + }), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99e5c06ef65..2ae0ad4c390 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -540,6 +540,58 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@25.3.0)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2) + packages/graphql: + dependencies: + '@alloy-js/core': + specifier: ^0.22.0 + version: 0.22.0 + '@alloy-js/graphql': + specifier: link:/home/swatikumar/code/alloy/packages/graphql + version: link:../../../alloy/packages/graphql + '@alloy-js/typescript': + specifier: ^0.22.0 + version: 0.22.0 + change-case: + specifier: ^5.4.4 + version: 5.4.4 + graphql: + specifier: ^16.9.0 + version: 16.14.1 + devDependencies: + '@alloy-js/cli': + specifier: ^0.22.0 + version: 0.22.0 + '@alloy-js/rollup-plugin': + specifier: ^0.1.0 + version: 0.1.0(@babel/core@7.29.0)(@types/babel__core@7.20.5)(rollup@4.49.0) + '@types/node': + specifier: ~22.13.13 + version: 22.13.17 + '@typespec/compiler': + specifier: workspace:~ + version: link:../compiler + '@typespec/emitter-framework': + specifier: workspace:~ + version: link:../emitter-framework + '@typespec/http': + specifier: workspace:~ + version: link:../http + '@typespec/mutator-framework': + specifier: workspace:~ + version: link:../mutator-framework + rimraf: + specifier: ~6.0.1 + version: 6.0.1 + source-map-support: + specifier: ~0.5.21 + version: 0.5.21 + typescript: + specifier: ~5.8.2 + version: 5.8.2 + vitest: + specifier: ^3.0.9 + version: 3.2.6(@types/debug@4.1.12)(@types/node@22.13.17)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2) + packages/html-program-viewer: dependencies: '@fluentui/react-components': @@ -1544,6 +1596,9 @@ importers: '@typespec/events': specifier: workspace:^ version: link:../events + '@typespec/graphql': + specifier: workspace:^ + version: link:../graphql '@typespec/html-program-viewer': specifier: workspace:^ version: link:../html-program-viewer @@ -6315,6 +6370,9 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@22.13.17': + resolution: {integrity: sha512-nAJuQXoyPj04uLgu+obZcSmsfOenUg6DxPKogeUy6yNCFwWaj5sBF8/G/pNo8EtBJjAfSVgfIlugR/BCOleO+g==} + '@types/node@25.3.0': resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} @@ -6522,9 +6580,23 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@3.2.6': + resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==} + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/mocker@3.2.6': + resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.0.18': resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} peerDependencies: @@ -6539,18 +6611,30 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@3.2.6': + resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==} + '@vitest/pretty-format@4.0.18': resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/runner@3.2.6': + resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==} + '@vitest/runner@4.0.18': resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/snapshot@3.2.6': + resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==} + '@vitest/snapshot@4.0.18': resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@3.2.6': + resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==} + '@vitest/spy@4.0.18': resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} @@ -6562,6 +6646,9 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@3.2.6': + resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} + '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} @@ -7426,6 +7513,10 @@ packages: monocart-coverage-reports: optional: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + cacache@19.0.1: resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -9120,6 +9211,10 @@ packages: grapheme-splitter@1.0.4: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} + graphql@16.14.1: + resolution: {integrity: sha512-cQOsSMS/IrDz82PVyRDvf/Q1F/bRbBVjJlh+xYOkI1qw2bWRvWGiWc+m2O0d6l4Bt1fyY+8kzJ8JFWGJqNeDBg==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gray-matter@4.0.3: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} @@ -9794,6 +9889,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -11706,6 +11804,11 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@6.0.1: + resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} + engines: {node: 20 || >=22} + hasBin: true + rimraf@6.1.3: resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} engines: {node: 20 || >=22} @@ -12232,6 +12335,9 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.1.2: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} @@ -12395,6 +12501,9 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -12406,6 +12515,10 @@ packages: tinylogic@2.0.0: resolution: {integrity: sha512-dljTkiLLITtsjqBvTA1MRZQK/sGP4kI3UJKc3yA9fMzYbMF2RhcN04SeROVqJBIYYOoJMM8u0WDnhFwMSFQotw==} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} @@ -12724,6 +12837,9 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -12976,6 +13092,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-plugin-checker@0.12.0: resolution: {integrity: sha512-CmdZdDOGss7kdQwv73UyVgLPv0FVYe5czAgnmRX2oKljgEvSrODGuClaV3PDR2+3ou7N/OKGauDDBjy2MB07Rg==} engines: {node: '>=16.11'} @@ -13115,6 +13236,34 @@ packages: vite: optional: true + vitest@3.2.6: + resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.6 + '@vitest/ui': 3.2.6 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.0.18: resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -18624,6 +18773,10 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/node@22.13.17': + dependencies: + undici-types: 6.20.0 + '@types/node@25.3.0': dependencies: undici-types: 7.18.2 @@ -18892,6 +19045,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/expect@3.2.6': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + tinyrainbow: 2.0.0 + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -18901,6 +19062,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 + '@vitest/mocker@3.2.6(vite@7.3.1(@types/node@22.13.17)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.2.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.13.17)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 @@ -18913,15 +19082,31 @@ snapshots: dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@3.2.6': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.0.18': dependencies: tinyrainbow: 3.0.3 + '@vitest/runner@3.2.6': + dependencies: + '@vitest/utils': 3.2.6 + pathe: 2.0.3 + strip-literal: 3.1.0 + '@vitest/runner@4.0.18': dependencies: '@vitest/utils': 4.0.18 pathe: 2.0.3 + '@vitest/snapshot@3.2.6': + dependencies: + '@vitest/pretty-format': 3.2.6 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/snapshot@4.0.18': dependencies: '@vitest/pretty-format': 4.0.18 @@ -18932,6 +19117,10 @@ snapshots: dependencies: tinyspy: 4.0.4 + '@vitest/spy@3.2.6': + dependencies: + tinyspy: 4.0.4 + '@vitest/spy@4.0.18': {} '@vitest/ui@4.0.18(vitest@4.0.18)': @@ -18951,6 +19140,12 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vitest/utils@3.2.6': + dependencies: + '@vitest/pretty-format': 3.2.6 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vitest/utils@4.0.18': dependencies: '@vitest/pretty-format': 4.0.18 @@ -20252,6 +20447,8 @@ snapshots: yargs: 17.7.2 yargs-parser: 21.1.1 + cac@6.7.14: {} + cacache@19.0.1: dependencies: '@npmcli/fs': 4.0.0 @@ -22299,6 +22496,8 @@ snapshots: grapheme-splitter@1.0.4: {} + graphql@16.14.1: {} + gray-matter@4.0.3: dependencies: js-yaml: 3.14.2 @@ -23123,6 +23322,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -25637,6 +25838,11 @@ snapshots: dependencies: glob: 7.2.3 + rimraf@6.0.1: + dependencies: + glob: 11.1.0 + package-json-from-dist: 1.0.1 + rimraf@6.1.3: dependencies: glob: 13.0.6 @@ -26300,6 +26506,10 @@ snapshots: strip-json-comments@5.0.3: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + strnum@2.1.2: {} structured-source@4.0.0: @@ -26541,6 +26751,8 @@ snapshots: tinybench@2.9.0: {} + tinyexec@0.3.2: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -26550,6 +26762,8 @@ snapshots: tinylogic@2.0.0: {} + tinypool@1.1.1: {} + tinyrainbow@2.0.0: {} tinyrainbow@3.0.3: {} @@ -26829,6 +27043,8 @@ snapshots: undici-types@5.26.5: {} + undici-types@6.20.0: {} + undici-types@7.18.2: {} undici@7.22.0: {} @@ -27047,6 +27263,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@22.13.17)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@8.1.1) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@22.13.17)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-plugin-checker@0.12.0(eslint@10.0.2)(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@babel/code-frame': 7.29.0 @@ -27104,6 +27341,20 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vite@7.3.1(@types/node@22.13.17)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.49.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.13.17 + fsevents: 2.3.3 + tsx: 4.21.0 + yaml: 2.8.2 + vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 @@ -27122,6 +27373,51 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2) + vitest@3.2.6(@types/debug@4.1.12)(@types/node@22.13.17)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.6 + '@vitest/mocker': 3.2.6(vite@7.3.1(@types/node@22.13.17)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.6 + '@vitest/runner': 3.2.6 + '@vitest/snapshot': 3.2.6 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + debug: 4.4.3(supports-color@8.1.1) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@22.13.17)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@22.13.17)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 22.13.17 + '@vitest/ui': 4.0.18(vitest@4.0.18) + happy-dom: 20.7.0 + jsdom: 25.0.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@4.0.18(@types/node@25.3.0)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18