From 3d5ceaa273bad73fe7cf4b23872e084c230954d0 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 2 Oct 2025 14:34:40 -0700 Subject: [PATCH 01/13] Initial options setup --- packages/compiler/lib/emitter/main.tsp | 5 ++ packages/compiler/package.json | 3 + packages/json-schema/options/main.tsp | 60 +++++++++++++ packages/openapi3/options/main.tsp | 111 +++++++++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 packages/compiler/lib/emitter/main.tsp create mode 100644 packages/json-schema/options/main.tsp create mode 100644 packages/openapi3/options/main.tsp diff --git a/packages/compiler/lib/emitter/main.tsp b/packages/compiler/lib/emitter/main.tsp new file mode 100644 index 00000000000..eb3d8aaff2c --- /dev/null +++ b/packages/compiler/lib/emitter/main.tsp @@ -0,0 +1,5 @@ +/** + * Define an config value that should resolve to an absolute path. + * `{project-dir}`, `{cwd}`, and other can be used to define a relative path. + */ +scalar absolutePath extends string; diff --git a/packages/compiler/package.json b/packages/compiler/package.json index afcf29cc4a9..7f2475c638d 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -62,6 +62,9 @@ }, "./casing": { "import": "./dist/src/casing/index.js" + }, + "./emitter": { + "typespec": "./lib/emitter/main.tsp" } }, "browser": { diff --git a/packages/json-schema/options/main.tsp b/packages/json-schema/options/main.tsp new file mode 100644 index 00000000000..d01960bb4b7 --- /dev/null +++ b/packages/json-schema/options/main.tsp @@ -0,0 +1,60 @@ +import "@typespec/compiler/emitter"; + +/** + * Json schema emitter options + */ +model EmitterOptions { + /** + * Serialize the schema as either yaml or json. + * @defaultValue yaml it not specified infer from the `output-file` extension + */ + `file-type`?: FileType; + + /** + * How to handle 64-bit integers on the wire. Options are: + * + * - string: Serialize as a string (widely interoperable) + * - number: Serialize as a number (not widely interoperable) + */ + `int64-strategy`?: Int64Strategy; + + /** + * When provided, bundle all the schemas into a single JSON Schema document + * with schemas under $defs. The provided id is the id of the root document + * and is also used for the file name. + */ + bundleId?: string; + + /** + * When true, emit all model declarations to JSON Schema without requiring + * the `@jsonSchema` decorator. + */ + emitAllModels?: boolean; + + /** + * When true, emit all references as JSON Schema files, even if the referenced + * type does not have the `@jsonSchema` decorator or is not within a namespace + * with the `@jsonSchema` decorator. + */ + emitAllRefs?: boolean; + + /** + * If true, then for models emitted as object schemas we default `unevaluatedProperties` to `{ not: {} }`, + * if not explicitly specified elsewhere. + * @defaultValue false + */ + `seal-object-schemas`?: boolean; +} + +/** + * File type + */ +alias FileType = "yaml" | "json"; + +/** + * Strategy for handling the int64 type in the resulting json schema. + * - string: As a string + * - number: As a number (In JavaScript, int64 cannot be accurately represented as number) + * + */ +alias Int64Strategy = "string" | "number"; diff --git a/packages/openapi3/options/main.tsp b/packages/openapi3/options/main.tsp new file mode 100644 index 00000000000..e0b70cf2061 --- /dev/null +++ b/packages/openapi3/options/main.tsp @@ -0,0 +1,111 @@ +import "@typespec/compiler/emitter"; + +model EmitterOptions { + /** + * If the content should be serialized as YAML or JSON. + * @default yaml, it not specified infer from the `output-file` extension + */ + `file-type`?: FileType; + + /** + * Name of the output file. + * Output file will interpolate the following values: + * - service-name: Name of the service + * - service-name-if-multiple: Name of the service if multiple + * - version: Version of the service if multiple + * + * @default `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if {@link OpenAPI3EmitterOptions["file-type"]} is `"json"` + * + * @example Single service no versioning + * - `openapi.yaml` + * + * @example Multiple services no versioning + * - `openapi.Org1.Service1.yaml` + * - `openapi.Org1.Service2.yaml` + * + * @example Single service with versioning + * - `openapi.v1.yaml` + * - `openapi.v2.yaml` + * + * @example Multiple service with versioning + * - `openapi.Org1.Service1.v1.yaml` + * - `openapi.Org1.Service1.v2.yaml` + * - `openapi.Org1.Service2.v1.0.yaml` + * - `openapi.Org1.Service2.v1.1.yaml` + */ + `output-file`?: string; + + /** + * The Open API specification versions to emit. + * If more than one version is specified, then the output file + * will be created inside a directory matching each specification version. + * + * @default ["v3.0"] + * @internal + */ + `openapi-versions`?: OpenAPIVersion[]; + + /** + * Set the newline character for emitting files. + * @default lf + */ + `new-line`?: "crlf" | "lf"; + + /** + * Omit unreachable types. + * By default all types declared under the service namespace will be included. With this flag on only types references in an operation will be emitted. + */ + `omit-unreachable-types`?: boolean; + + /** + * If the generated openapi types should have the `x-typespec-name` extension set with the name of the TypeSpec type that created it. + * This extension is meant for debugging and should not be depended on. + * @default "never" + */ + `include-x-typespec-name`?: "inline-only" | "never"; + + /** + * How to handle safeint type. Options are: + * - `double-int`: Will produce `type: integer, format: double-int` + * - `int64`: Will produce `type: integer, format: int64` + * @default "int64" + */ + `safeint-strategy`?: "double-int" | "int64"; + + /** + * If true, then for models emitted as object schemas we default `additionalProperties` to false for + * OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere. + * @default false + */ + `seal-object-schemas`?: boolean; + + /** + * Determines how to emit examples on parameters. + * + * Note: This is an experimental feature and may change in future versions. + * @see https://spec.openapis.org/oas/v3.0.4.html#style-examples for parameter example serialization rules. + * @see https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples. + */ + `experimental-parameter-examples`?: ExperimentalParameterExamplesStrategy; + + /** + * How should operation ID be generated when `@operationId` is not used. + * Available options are + * - `parent-container`: Uses the parent namespace/interface and operation name to generate the ID. + * - `fqn`: Uses the fully qualified name(from service root) of the operation to generate the ID. + * - `explicit-only`: Only use explicitly defined operation IDs. + * @default parent-container + */ + `operation-id-strategy`?: OperationIdStrategy | { + /** Strategy used to generate the operation ID. */ + kind: OperationIdStrategy; + + /** Separator used to join segment in the operation name. */ + separator?: string; + }; +} + +alias FileType = "yaml" | "json"; +alias OpenAPIVersion = "3.0.0" | "3.1.0"; +alias ExperimentalParameterExamplesStrategy = "data" | "serialized"; +alias OperationIdStrategy = "parent-container" | "fqn" | "explicit-only"; From fffae03cddc09d7bc9510574b7ef4c7edda910c9 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 3 Oct 2025 14:10:03 -0700 Subject: [PATCH 02/13] refactor type graph part 1 --- packages/compiler/src/core/checker.ts | 4 +- packages/compiler/src/core/program.ts | 362 +++++++++++++++----------- packages/openapi3/package.json | 3 + 3 files changed, 218 insertions(+), 151 deletions(-) diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 3802783dc4f..e73cc568c64 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -356,9 +356,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const pendingResolutions = new PendingResolutions(); const typespecNamespaceBinding = resolver.symbols.global.exports!.get("TypeSpec"); - if (typespecNamespaceBinding) { - initializeTypeSpecIntrinsics(); - } + initializeTypeSpecIntrinsics(); /** * Tracking the template parameters used or not. diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index 4eb5027cc10..1246ad5c8e2 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -12,7 +12,7 @@ import { } from "../module-resolver/module-resolver.js"; import { PackageJson } from "../types/package-json.js"; import { findProjectRoot } from "../utils/io.js"; -import { deepEquals, isDefined, mapEquals, mutate } from "../utils/misc.js"; +import { deepEquals, isDefined, mutate } from "../utils/misc.js"; import { createBinder } from "./binder.js"; import { Checker, createChecker } from "./checker.js"; import { createSuppressCodeFix } from "./compiler-code-fixes/suppress.codefix.js"; @@ -134,6 +134,9 @@ export interface Program { * Project root. If a tsconfig was found/specified this is the directory for the tsconfig.json. Otherwise directory where the entrypoint is located. */ readonly projectRoot: string; + + /** @internal Main type graph. */ + readonly typeGraph: TypeGraph; } interface EmitterRef { @@ -202,139 +205,78 @@ export async function compile( return program; } -async function createProgram( - host: CompilerHost, - mainFile: string, - options: CompilerOptions = {}, - oldProgram?: Program, -): Promise<{ program: Program; shouldAbort: boolean }> { - const runtimeStats: Partial = {}; - const validateCbs: Validator[] = []; - const stateMaps = new Map>(); - const stateSets = new Map>(); - const diagnostics: Diagnostic[] = []; - const duplicateSymbols = new Set(); - const emitters: EmitterRef[] = []; - const requireImports = new Map(); - const complexityStats: ComplexityStats = {} as any; - let sourceResolution: SourceResolution; - let error = false; - let continueToNextStage = true; +interface TypeGraph { + readonly globalNamespace: Namespace; + /** Complexity statistics */ + readonly complexityStats: ComplexityStats; + /** Runtime statistics */ + readonly runtimeStats: RuntimeStats; - const logger = createLogger({ sink: host.logSink }); - const tracer = createTracer(logger, { filter: options.trace }); - const resolvedMain = await resolveTypeSpecEntrypoint(host, mainFile, reportDiagnostic); - const program: Program = { - checker: undefined!, - resolver: undefined!, - compilerOptions: resolveOptions(options), - sourceFiles: new Map(), - jsSourceFiles: new Map(), - literalTypes: new Map(), - host, - diagnostics, - emitters, - loadTypeSpecScript, - getOption, - stateMaps, - stateSets, - stats: { - complexity: complexityStats, - runtime: runtimeStats as any, - }, + /** + * Checker used + * @internal + */ + readonly checker: Checker; - tracer, - trace, - ...createStateAccessors(stateMaps, stateSets), - reportDiagnostic, - reportDiagnostics, - reportDuplicateSymbols, - hasError() { - return error; - }, - onValidate(cb, metadata) { - validateCbs.push({ callback: cb, metadata }); - }, - getGlobalNamespaceType, - resolveTypeReference, - /** @internal */ - resolveTypeOrValueReference, - getSourceFileLocationContext, - projectRoot: getDirectoryPath(options.config ?? resolvedMain ?? ""), - }; + /** @internal */ + sourceResolution: SourceResolution; - trace("compiler.options", JSON.stringify(options, null, 2)); + /** @internal */ + resolveTypeReference(reference: string): [Type | undefined, readonly Diagnostic[]]; + /** @internal */ + resolveTypeOrValueReference(reference: string): [Entity | undefined, readonly Diagnostic[]]; +} - function trace(area: string, message: string) { - tracer.trace(area, message); - } +async function createTypeGraph( + program: Program, + resolvedMain: string, + options: CompilerOptions, + sourceFileCache: Map | undefined, +): Promise { + const host = program.host; const binder = createBinder(program); + const runtimeStats: Partial = {}; + const complexityStats: ComplexityStats = {} as any; - if (resolvedMain === undefined) { - return { program, shouldAbort: true }; - } - const basedir = getDirectoryPath(resolvedMain) || "/"; - await checkForCompilerVersionMismatch(basedir); + const sourceLoader = await createSourceLoader(host, { + parseOptions: options.parseOptions, + tracer: program.tracer, + getCachedScript: (file) => sourceFileCache?.get(file.path) ?? host.parseCache?.get(file), + }); - runtimeStats.loader = await timeAsync(() => loadSources(resolvedMain)); + const typeGraph: TypeGraph = { + globalNamespace: undefined!, + checker: undefined!, + complexityStats, + runtimeStats: runtimeStats as any, + sourceResolution: sourceLoader.resolution, + resolveTypeReference, + resolveTypeOrValueReference, + }; + mutate(program).typeGraph = typeGraph; - const emit = options.noEmit ? [] : (options.emit ?? []); - const emitterOptions = options.options; + runtimeStats.loader = await timeAsync(() => loadSources(sourceLoader, resolvedMain)); - await loadEmitters(basedir, emit, emitterOptions ?? {}); + const resolver = createResolver(program); - if ( - oldProgram && - mapEquals(oldProgram.sourceFiles, program.sourceFiles) && - deepEquals(oldProgram.compilerOptions, program.compilerOptions) - ) { - return { program: oldProgram, shouldAbort: true }; - } - - // let GC reclaim old program, we do not reuse it beyond this point. - oldProgram = undefined; - - const resolver = (program.resolver = createResolver(program)); + program.resolver = resolver; // Update the current resolver for back compat runtimeStats.resolver = time(() => resolver.resolveProgram()); + const checker = createChecker(program, resolver); + mutate(typeGraph).checker = checker; + mutate(typeGraph).globalNamespace = checker.getGlobalNamespaceType(); + program.checker = checker; // Update current checker for back compat - const linter = createLinter(program, (name) => loadLibrary(basedir, name)); - linter.registerLinterLibrary(builtInLinterLibraryName, createBuiltInLinterLibrary()); - if (options.linterRuleSet) { - program.reportDiagnostics(await linter.extendRuleSet(options.linterRuleSet)); - } + runtimeStats.checker = time(() => checker.checkProgram()); - program.checker = createChecker(program, resolver); - runtimeStats.checker = time(() => program.checker.checkProgram()); - - complexityStats.createdTypes = program.checker.stats.createdTypes; - complexityStats.finishedTypes = program.checker.stats.finishedTypes; - - if (!continueToNextStage) { - return { program, shouldAbort: true }; - } - - // onValidate stage - await runValidators(); - - validateRequiredImports(); - - await validateLoadedLibraries(); - - if (!continueToNextStage) { - return { program, shouldAbort: true }; - } - - // Linter stage - const lintResult = linter.lint(); - runtimeStats.linter = lintResult.stats.runtime; - program.reportDiagnostics(lintResult.diagnostics); - - return { program, shouldAbort: false }; + complexityStats.createdTypes = checker.stats.createdTypes; + complexityStats.finishedTypes = checker.stats.finishedTypes; + await validateLoadedLibraries(sourceLoader); + return typeGraph; /** * Validate the libraries loaded during the compilation process are compatible. */ - async function validateLoadedLibraries() { + async function validateLoadedLibraries(sourceLoader: SourceLoader) { const loadedRoots = new Set(); // Check all the files that were loaded for (const fileUrl of getLibraryUrlsLoaded()) { @@ -346,7 +288,7 @@ async function createProgram( } } - const libraries = new Map([...sourceResolution.loadedLibraries.entries()]); + const libraries = new Map([...sourceLoader.resolution.loadedLibraries.entries()]); const incompatibleLibraries = new Map(); for (const root of loadedRoots) { const packageJsonPath = joinPaths(root, "package.json"); @@ -368,7 +310,7 @@ async function createProgram( } for (const [name, incompatibleLibs] of incompatibleLibraries) { - reportDiagnostic( + program.reportDiagnostic( createDiagnostic({ code: "incompatible-library", format: { @@ -383,14 +325,7 @@ async function createProgram( } } - async function loadSources(entrypoint: string) { - const sourceLoader = await createSourceLoader(host, { - parseOptions: options.parseOptions, - tracer, - getCachedScript: (file) => - oldProgram?.sourceFiles.get(file.path) ?? host.parseCache?.get(file), - }); - + async function loadSources(sourceLoader: SourceLoader, entrypoint: string) { // intrinsic.tsp await loadIntrinsicTypes(sourceLoader); @@ -409,7 +344,7 @@ async function createProgram( }); } - sourceResolution = sourceLoader.resolution; + const sourceResolution = sourceLoader.resolution; program.sourceFiles = sourceResolution.sourceFiles; program.jsSourceFiles = sourceResolution.jsSourceFiles; @@ -440,6 +375,153 @@ async function createProgram( } } + function resolveTypeReference(reference: string): [Type | undefined, readonly Diagnostic[]] { + const [node, parseDiagnostics] = parseStandaloneTypeReference(reference); + if (parseDiagnostics.length > 0) { + return [undefined, parseDiagnostics]; + } + const binder = createBinder(program); + binder.bindNode(node); + mutate(node).parent = resolver.symbols.global.declarations[0]; + resolver.resolveTypeReference(node); + return program.checker.resolveTypeReference(node); + } + + function resolveTypeOrValueReference( + reference: string, + ): [Entity | undefined, readonly Diagnostic[]] { + const [node, parseDiagnostics] = parseStandaloneTypeReference(reference); + if (parseDiagnostics.length > 0) { + return [undefined, parseDiagnostics]; + } + const binder = createBinder(program); + binder.bindNode(node); + mutate(node).parent = resolver.symbols.global.declarations[0]; + resolver.resolveTypeReference(node); + return program.checker.resolveTypeOrValueReference(node); + } +} + +async function createProgram( + host: CompilerHost, + mainFile: string, + options: CompilerOptions = {}, + oldProgram?: Program, +): Promise<{ program: Program; shouldAbort: boolean }> { + const runtimeStats: Partial = {}; + const validateCbs: Validator[] = []; + const stateMaps = new Map>(); + const stateSets = new Map>(); + const diagnostics: Diagnostic[] = []; + const duplicateSymbols = new Set(); + const emitters: EmitterRef[] = []; + const requireImports = new Map(); + const complexityStats: ComplexityStats = {} as any; + let error = false; + let continueToNextStage = true; + + const logger = createLogger({ sink: host.logSink }); + const tracer = createTracer(logger, { filter: options.trace }); + const resolvedMain = await resolveTypeSpecEntrypoint(host, mainFile, reportDiagnostic); + const program: Program = { + checker: undefined!, + resolver: undefined!, + typeGraph: undefined!, + compilerOptions: resolveOptions(options), + sourceFiles: new Map(), + jsSourceFiles: new Map(), + literalTypes: new Map(), + host, + diagnostics, + emitters, + loadTypeSpecScript, + getOption, + stateMaps, + stateSets, + stats: { + complexity: complexityStats, + runtime: runtimeStats as any, + }, + + tracer, + trace, + ...createStateAccessors(stateMaps, stateSets), + reportDiagnostic, + reportDiagnostics, + reportDuplicateSymbols, + hasError() { + return error; + }, + onValidate(cb, metadata) { + validateCbs.push({ callback: cb, metadata }); + }, + getGlobalNamespaceType, + resolveTypeReference, + /** @internal */ + resolveTypeOrValueReference, + getSourceFileLocationContext, + projectRoot: getDirectoryPath(options.config ?? resolvedMain ?? ""), + }; + + trace("compiler.options", JSON.stringify(options, null, 2)); + + function trace(area: string, message: string) { + tracer.trace(area, message); + } + + if (resolvedMain === undefined) { + return { program, shouldAbort: true }; + } + const basedir = getDirectoryPath(resolvedMain) || "/"; + await checkForCompilerVersionMismatch(basedir); + + const binder = createBinder(program); + + const emit = options.noEmit ? [] : (options.emit ?? []); + const emitterOptions = options.options; + + await loadEmitters(basedir, emit, emitterOptions ?? {}); + + const linter = createLinter(program, (name) => loadLibrary(basedir, name)); + linter.registerLinterLibrary(builtInLinterLibraryName, createBuiltInLinterLibrary()); + if (options.linterRuleSet) { + program.reportDiagnostics(await linter.extendRuleSet(options.linterRuleSet)); + } + + // if ( + // oldProgram && + // mapEquals(oldProgram.sourceFiles, program.sourceFiles) && + // deepEquals(oldProgram.compilerOptions, program.compilerOptions) + // ) { + // return { program: oldProgram, shouldAbort: true }; + // } + + // let GC reclaim old program, we do not reuse it beyond this point. + const typeGraph = await createTypeGraph(program, resolvedMain, options, oldProgram?.sourceFiles); + oldProgram = undefined; + program.checker = typeGraph.checker; + Object.assign(complexityStats, typeGraph.complexityStats); + + if (!continueToNextStage) { + return { program, shouldAbort: true }; + } + + // onValidate stage + await runValidators(); + + validateRequiredImports(); + + if (!continueToNextStage) { + return { program, shouldAbort: true }; + } + + // Linter stage + const lintResult = linter.lint(); + runtimeStats.linter = lintResult.stats.runtime; + program.reportDiagnostics(lintResult.diagnostics); + + return { program, shouldAbort: false }; + async function loadTypeSpecScript(file: SourceFile): Promise { // This is not a diagnostic because the compiler should never reuse the same path. // It's the caller's responsibility to use unique paths. @@ -465,7 +547,7 @@ async function createProgram( } function getSourceFileLocationContext(sourcefile: SourceFile): LocationContext { - const locationContext = sourceResolution.locationContexts.get(sourcefile); + const locationContext = program.typeGraph.sourceResolution.locationContexts.get(sourcefile); compilerAssert(locationContext, "SourceFile should have a declaration locationContext."); return locationContext; } @@ -670,7 +752,7 @@ async function createProgram( function validateRequiredImports() { for (const [requiredImport, emitterName] of requireImports) { - if (!sourceResolution.loadedLibraries.has(requiredImport)) { + if (!typeGraph.sourceResolution.loadedLibraries.has(requiredImport)) { program.reportDiagnostic( createDiagnostic({ code: "missing-import", @@ -934,29 +1016,13 @@ async function createProgram( } function resolveTypeReference(reference: string): [Type | undefined, readonly Diagnostic[]] { - const [node, parseDiagnostics] = parseStandaloneTypeReference(reference); - if (parseDiagnostics.length > 0) { - return [undefined, parseDiagnostics]; - } - const binder = createBinder(program); - binder.bindNode(node); - mutate(node).parent = resolver.symbols.global.declarations[0]; - resolver.resolveTypeReference(node); - return program.checker.resolveTypeReference(node); + return program.typeGraph.resolveTypeReference(reference); } function resolveTypeOrValueReference( reference: string, ): [Entity | undefined, readonly Diagnostic[]] { - const [node, parseDiagnostics] = parseStandaloneTypeReference(reference); - if (parseDiagnostics.length > 0) { - return [undefined, parseDiagnostics]; - } - const binder = createBinder(program); - binder.bindNode(node); - mutate(node).parent = resolver.symbols.global.declarations[0]; - resolver.resolveTypeReference(node); - return program.checker.resolveTypeOrValueReference(node); + return program.typeGraph.resolveTypeOrValueReference(reference); } } diff --git a/packages/openapi3/package.json b/packages/openapi3/package.json index aff70a38dd3..f45966cdd31 100644 --- a/packages/openapi3/package.json +++ b/packages/openapi3/package.json @@ -31,6 +31,9 @@ "./testing": { "types": "./dist/src/testing/index.d.ts", "default": "./dist/src/testing/index.js" + }, + "./options": { + "typespec": "./options/main.tsp" } }, "imports": { From 61d22a1b90c0015b3d4977d1bbbdfc50e6248609 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 6 Oct 2025 09:51:54 -0700 Subject: [PATCH 03/13] progress --- packages/compiler/src/core/program.ts | 9 ++++++++ .../src/testing/test-compiler-host.ts | 23 +++++++++++++------ packages/compiler/src/testing/tester.ts | 20 ++++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index 1246ad5c8e2..26559951bb6 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -638,6 +638,15 @@ async function createProgram( } } if (emitFunction !== undefined) { + // TODO-TIM reuse the module resolution logic for m more robust resolution. + const optionsEntrypoint = + library.module.type === "module" && + (library.module.manifest.exports as any)?.["./options"]?.["typespec"]; + if (optionsEntrypoint) { + const fullPath = resolvePath(library.module.path, optionsEntrypoint); + const typeGraph = await createTypeGraph(program, fullPath, options, program.sourceFiles); + } + if (libDefinition?.emitter?.options) { const diagnostics = libDefinition?.emitterOptionValidator?.validate( emitterOptions, diff --git a/packages/compiler/src/testing/test-compiler-host.ts b/packages/compiler/src/testing/test-compiler-host.ts index 067224cff2a..62b7891af39 100644 --- a/packages/compiler/src/testing/test-compiler-host.ts +++ b/packages/compiler/src/testing/test-compiler-host.ts @@ -10,8 +10,17 @@ export const StandardTestLibrary: TypeSpecTestLibrary = { name: "@typespec/compiler", packageRoot: CompilerPackageRoot, files: [ - { virtualPath: "./.tsp/dist/src/lib", realDir: "./dist/src/lib", pattern: "**" }, - { virtualPath: "./.tsp/lib", realDir: "./lib", pattern: "**" }, + { + virtualPath: "./node_modules/@typespec/compiler/dist/src", + realDir: "./dist/src", + pattern: "index.js", + }, + { + virtualPath: "./node_modules/@typespec/compiler/dist/src/lib", + realDir: "./dist/src/lib", + pattern: "**", + }, + { virtualPath: "./node_modules/@typespec/compiler/lib", realDir: "./lib", pattern: "**" }, ], }; @@ -26,9 +35,9 @@ export function createTestCompilerHost( jsImports: Map>, options?: TestHostOptions, ): CompilerHost { - const libDirs = [resolveVirtualPath(".tsp/lib/std")]; + const libDirs = [resolveVirtualPath("./node_modules/@typespec/compiler/lib/std")]; if (!options?.excludeTestLib) { - libDirs.push(resolveVirtualPath(".tsp/test-lib")); + libDirs.push(resolveVirtualPath("./node_modules/@typespec/compiler/test-lib")); } return { @@ -84,7 +93,7 @@ export function createTestCompilerHost( }, getExecutionRoot() { - return resolveVirtualPath(".tsp"); + return resolveVirtualPath("./node_modules/@typespec/compiler"); }, async getJsImport(path) { @@ -146,8 +155,8 @@ export function createTestCompilerHost( export function addTestLib(fs: TestFileSystem): Record { const testTypes: Record = {}; // add test decorators - fs.add(".tsp/test-lib/main.tsp", 'import "./test.js";'); - fs.addJsFile(".tsp/test-lib/test.js", { + fs.add("./node_modules/@typespec/compiler/test-lib/main.tsp", 'import "./test.js";'); + fs.addJsFile("./node_modules/@typespec/compiler/test-lib/test.js", { namespace: "TypeSpec", $test(_: any, target: Type, nameLiteral?: StringLiteral) { let name = nameLiteral?.value; diff --git a/packages/compiler/src/testing/tester.ts b/packages/compiler/src/testing/tester.ts index f2801115515..4a27777203e 100644 --- a/packages/compiler/src/testing/tester.ts +++ b/packages/compiler/src/testing/tester.ts @@ -87,6 +87,26 @@ async function createTesterFs(base: string, options: TesterOptions) { resolvePath("node_modules", lib, "package.json"), (resolved.manifest as any).file.text, ); + + if (typeof resolved.manifest.exports === "object" && resolved.manifest.exports !== null) { + for (const [key, value] of Object.entries(resolved.manifest.exports)) { + if (key === ".") continue; // already handled + if (typeof value === "object" && value !== null && "typespec" in value) { + await sl.importPath( + resolvePath(resolved.path, value["typespec"] as string), + NoTarget, + lib, + { + type: "library", + metadata: { + type: "module", + name: resolved.manifest.name, + }, + }, + ); + } + } + } } } From 41246c6d10504903dcc53573d5a8419c23e4cf20 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 6 Oct 2025 16:26:51 -0700 Subject: [PATCH 04/13] revert --- packages/compiler/src/testing/tester.ts | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/packages/compiler/src/testing/tester.ts b/packages/compiler/src/testing/tester.ts index b80f3b7762a..1d2aed77ea0 100644 --- a/packages/compiler/src/testing/tester.ts +++ b/packages/compiler/src/testing/tester.ts @@ -111,26 +111,6 @@ async function createTesterFs(base: string, options: TesterOptions) { resolvePath("node_modules", lib, "package.json"), (resolved.manifest as any).file.text, ); - - if (typeof resolved.manifest.exports === "object" && resolved.manifest.exports !== null) { - for (const [key, value] of Object.entries(resolved.manifest.exports)) { - if (key === ".") continue; // already handled - if (typeof value === "object" && value !== null && "typespec" in value) { - await sl.importPath( - resolvePath(resolved.path, value["typespec"] as string), - NoTarget, - lib, - { - type: "library", - metadata: { - type: "module", - name: resolved.manifest.name, - }, - }, - ); - } - } - } } } From 090e148b358f94fffb487868e2e4f2155a2ec982 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 7 Oct 2025 09:01:45 -0700 Subject: [PATCH 05/13] resolve emitter option model --- packages/compiler/src/core/diagnostics.ts | 4 +++ packages/compiler/src/core/emitter-options.ts | 33 +++++++++++++++++++ packages/compiler/src/core/messages.ts | 12 +++++++ packages/compiler/src/core/program.ts | 11 ++++++- 4 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/src/core/emitter-options.ts diff --git a/packages/compiler/src/core/diagnostics.ts b/packages/compiler/src/core/diagnostics.ts index 1da09adebe9..7df11001ace 100644 --- a/packages/compiler/src/core/diagnostics.ts +++ b/packages/compiler/src/core/diagnostics.ts @@ -333,6 +333,10 @@ export function createDiagnosticCollector(): DiagnosticCollector { } } +export function err(diagnostic: Diagnostic): DiagnosticResult { + return [undefined, [diagnostic]]; +} + /** * Ignore the diagnostics emitted by the diagnostic accessor pattern and just return the actual result. * @param result Accessor pattern tuple result including the actual result and the list of diagnostics. diff --git a/packages/compiler/src/core/emitter-options.ts b/packages/compiler/src/core/emitter-options.ts new file mode 100644 index 00000000000..5a20faee66a --- /dev/null +++ b/packages/compiler/src/core/emitter-options.ts @@ -0,0 +1,33 @@ +import { createDiagnosticCollector, err } from "./diagnostics.js"; +import { createDiagnostic } from "./messages.js"; +import { TypeGraph } from "./program.js"; +import { createSourceFile } from "./source-file.js"; +import { Diagnostic, Model } from "./types.js"; + +export function resolveEmitterOptions( + typeGraph: TypeGraph, +): [Model | undefined, readonly Diagnostic[]] { + const [root] = typeGraph.resolveTypeReference("EmitterOptions"); + const diagnostics = createDiagnosticCollector(); + + if (root === undefined) { + return [ + undefined, + [ + createDiagnostic({ + code: "missing-emitter-options", + target: { file: createSourceFile("", typeGraph.entrypoint), pos: 0, end: 0 }, + }), + ], + ]; + } + if (root.kind !== "Model") { + return err( + createDiagnostic({ + code: "emitter-options-not-model", + target: root, + }), + ); + } + return diagnostics.wrap(root); +} diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 5c42d275d59..bf871cdf765 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -688,6 +688,18 @@ const diagnostics = { "Value interpolated in this string template cannot be converted to a string. Only literal types can be automatically interpolated.", }, }, + "missing-emitter-options": { + severity: "error", + messages: { + default: "Emitter options should export an `EmitterOptions` model at the top level.", + }, + }, + "emitter-options-not-model": { + severity: "error", + messages: { + default: "Emitter options should be a model type.", + }, + }, /** * Binder diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index 250f5ff4448..fc7ee2bd88a 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -16,6 +16,7 @@ import { createBinder } from "./binder.js"; import { Checker, createChecker } from "./checker.js"; import { createSuppressCodeFix } from "./compiler-code-fixes/suppress.codefix.js"; import { compilerAssert } from "./diagnostics.js"; +import { resolveEmitterOptions } from "./emitter-options.js"; import { flushEmittedFilesPaths } from "./emitter-utils.js"; import { resolveTypeSpecEntrypoint } from "./entrypoint-resolution.js"; import { ExternalError } from "./external-error.js"; @@ -204,7 +205,7 @@ export async function compile( return program; } -interface TypeGraph { +export interface TypeGraph { readonly globalNamespace: Namespace; /** Complexity statistics */ readonly complexityStats: ComplexityStats; @@ -217,6 +218,11 @@ interface TypeGraph { */ readonly checker: Checker; + /** + * Entry point of that type graph + */ + readonly entrypoint: string; + /** @internal */ sourceResolution: SourceResolution; @@ -244,6 +250,7 @@ async function createTypeGraph( }); const typeGraph: TypeGraph = { + entrypoint: resolvedMain, globalNamespace: undefined!, checker: undefined!, complexityStats, @@ -644,6 +651,8 @@ async function createProgram( if (optionsEntrypoint) { const fullPath = resolvePath(library.module.path, optionsEntrypoint); const typeGraph = await createTypeGraph(program, fullPath, options, program.sourceFiles); + const [model, diagnostics] = resolveEmitterOptions(typeGraph); + program.reportDiagnostics(diagnostics); } if (libDefinition?.emitter?.options) { From d2add2c8977a62fe6d4741ab0950fbaca191fb59 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 7 Oct 2025 11:51:41 -0700 Subject: [PATCH 06/13] progress --- .../src/core/js-data-validator/validator.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 packages/compiler/src/core/js-data-validator/validator.ts diff --git a/packages/compiler/src/core/js-data-validator/validator.ts b/packages/compiler/src/core/js-data-validator/validator.ts new file mode 100644 index 00000000000..4a28cffd94e --- /dev/null +++ b/packages/compiler/src/core/js-data-validator/validator.ts @@ -0,0 +1,50 @@ +import type { Model, Scalar, StdTypeName, Type } from "../types.js"; + +interface ValidationError { + code: string; + message: string; + target: string[]; +} +export function validateJs(value: unknown, type: Type): readonly ValidationError[] {} + +function validateModel(value: unknown, type: Model): readonly ValidationError[] {} + +function validateBuiltinScalar( + value: unknown, + type: Scalar & { name: StdTypeName }, + target: string[], +): readonly ValidationError[] { + switch (type.name) { + case "string": + return assertType(value, "string", target); + case "boolean": + return assertType(value, "boolean", target); + case "int8": + case "int16": + case "int32": + case "int64": + case "uint8": + case "uint16": + case "uint32": + case "uint64": + case "float32": + case "float64": + return assertType(value, "number", target); + case "bytes": + if (value instanceof Uint8Array) { + return []; + } + return [{ code: "type-mismatch", message: `Expected type bytes`, target }]; + } +} + +function assertType( + value: unknown, + expectedType: string, + target: string[], +): readonly ValidationError[] { + if (typeof value === expectedType) { + return []; + } + return [{ code: "type-mismatch", message: `Expected type ${expectedType}`, target }]; +} From 883a89e113e618536ebd5fe897be8472c4d4d93a Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 17 Nov 2025 11:55:38 -0500 Subject: [PATCH 07/13] validate progress --- .../core/emitter-options/validator.test.ts | 101 +++++++++++++ .../src/core/emitter-options/validator.ts | 137 ++++++++++++++++++ .../src/core/js-data-validator/validator.ts | 50 ------- 3 files changed, 238 insertions(+), 50 deletions(-) create mode 100644 packages/compiler/src/core/emitter-options/validator.test.ts create mode 100644 packages/compiler/src/core/emitter-options/validator.ts delete mode 100644 packages/compiler/src/core/js-data-validator/validator.ts diff --git a/packages/compiler/src/core/emitter-options/validator.test.ts b/packages/compiler/src/core/emitter-options/validator.test.ts new file mode 100644 index 00000000000..cb0c84ab905 --- /dev/null +++ b/packages/compiler/src/core/emitter-options/validator.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { Tester } from "../../../test/tester.js"; +import { $ } from "../../typekit/index.js"; +import { compilerAssert } from "../diagnostics.js"; +import { validateEmitterOptions, ValidationError } from "./validator.js"; + +async function validateOptions(code: string, value: unknown): Promise { + const { program } = await Tester.compile(code); + + const type = $(program).type.resolve("EmitterOptions"); + compilerAssert(type, "EmitterOptions type not found"); + return validateEmitterOptions(program, value, type); +} + +describe("scalars", () => { + it("pass", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop: string; + }`, + { prop: "hello" }, + ); + expect(errors).toEqual([]); + }); + + describe("non supported scalars", () => { + it.each([ + ["int64", 1], + ["uint64", 1], + ["integer", 1], + ["float", 1], + ["decimal", 1], + ])("%s", async (typeStr, value) => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop: ${typeStr}; + }`, + { prop: value }, + ); + expect(errors).toEqual([ + { + code: "unsupported", + message: `${typeStr} is not supported for emitter options.`, + target: ["prop"], + }, + ]); + }); + }); +}); + +describe("@pattern", () => { + it("validate @pattern defined on property", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + @pattern("^hello$") + prop: string; + }`, + { prop: "hellobar" }, + ); + expect(errors).toEqual([ + { + code: "invalid-pattern", + message: "hellobar does not match pattern /^hello$/", + target: ["prop"], + }, + ]); + }); +}); + +describe("arrays", () => { + it("pass", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop: string[]; + }`, + { prop: ["hello", "world"] }, + ); + expect(errors).toEqual([]); + }); + + it("error if passing non array", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop: string[]; + }`, + { prop: "hello" }, + ); + expect(errors).toEqual([ + { + code: "type-mismatch", + message: "Expected type array", + target: ["prop"], + }, + ]); + }); +}); diff --git a/packages/compiler/src/core/emitter-options/validator.ts b/packages/compiler/src/core/emitter-options/validator.ts new file mode 100644 index 00000000000..70f894b2a82 --- /dev/null +++ b/packages/compiler/src/core/emitter-options/validator.ts @@ -0,0 +1,137 @@ +import { getPattern } from "../../lib/decorators.js"; +import { Program } from "../program.js"; +import { isArrayModelType } from "../type-utils.js"; +import type { ArrayModelType, Model, Scalar, StdTypeName, Type } from "../types.js"; + +export interface ValidationError { + code: string; + message: string; + target: string[]; +} + +export function validateEmitterOptions( + program: Program, + value: unknown, + type: Type, +): readonly ValidationError[] { + switch (type.kind) { + case "Model": + if (isArrayModelType(program, type)) { + return validateArray(program, value, type); + } + return validateModel(program, value, type); + case "Scalar": + return validateBuiltinScalar(value, type as any, []); + } + return []; +} + +function validateModel(program: Program, value: unknown, type: Model): readonly ValidationError[] { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return [ + { + code: "type-mismatch", + message: `Expected type object`, + target: [], + }, + ]; + } + + const errors: ValidationError[] = []; + const valObj = value as Record; + for (const propType of type.properties.values()) { + const propValue = valObj[propType.name]; + const propErrors = validateEmitterOptions(program, propValue, propType.type); + for (const err of propErrors) { + errors.push({ + ...err, + target: [propType.name, ...err.target], + }); + } + const pattern = getPattern(program, propType); + if (pattern) { + if (typeof propValue !== "string" || !new RegExp(pattern).test(propValue)) { + errors.push({ + code: "invalid-pattern", + message: `${propValue} does not match pattern /${pattern}/`, + target: [propType.name], + }); + } + } + } + return errors; +} + +function validateArray( + program: Program, + value: unknown, + type: ArrayModelType, +): readonly ValidationError[] { + if (!Array.isArray(value)) { + return [ + { + code: "type-mismatch", + message: `Expected type array`, + target: [], + }, + ]; + } + const errors: ValidationError[] = []; + for (let i = 0; i < value.length; i++) { + const itemErrors = validateEmitterOptions(program, value[i], type.indexer.value); + for (const err of itemErrors) { + errors.push({ + ...err, + target: [i.toString(), ...err.target], + }); + } + } + return errors; +} + +function validateBuiltinScalar( + value: unknown, + type: Scalar & { name: StdTypeName }, + target: string[], +): readonly ValidationError[] { + console.log("Validate", type.name, value); + switch (type.name) { + case "string": + return assertType(value, "string", target); + case "boolean": + return assertType(value, "boolean", target); + case "int8": + case "int16": + case "int32": + case "uint8": + case "uint16": + case "uint32": + case "float32": + case "float64": + return assertType(value, "number", target); + case "bytes": + if (value instanceof Uint8Array) { + return []; + } + return [{ code: "type-mismatch", message: `Expected type bytes`, target }]; + default: + return [ + { + code: "unsupported", + message: `${type.name} is not supported for emitter options.`, + target, + }, + ]; + } +} + +function assertType( + value: unknown, + expectedType: string, + target: string[], +): readonly ValidationError[] { + if (typeof value === expectedType) { + return []; + } + return [{ code: "type-mismatch", message: `Expected type ${expectedType}`, target }]; +} diff --git a/packages/compiler/src/core/js-data-validator/validator.ts b/packages/compiler/src/core/js-data-validator/validator.ts deleted file mode 100644 index 4a28cffd94e..00000000000 --- a/packages/compiler/src/core/js-data-validator/validator.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Model, Scalar, StdTypeName, Type } from "../types.js"; - -interface ValidationError { - code: string; - message: string; - target: string[]; -} -export function validateJs(value: unknown, type: Type): readonly ValidationError[] {} - -function validateModel(value: unknown, type: Model): readonly ValidationError[] {} - -function validateBuiltinScalar( - value: unknown, - type: Scalar & { name: StdTypeName }, - target: string[], -): readonly ValidationError[] { - switch (type.name) { - case "string": - return assertType(value, "string", target); - case "boolean": - return assertType(value, "boolean", target); - case "int8": - case "int16": - case "int32": - case "int64": - case "uint8": - case "uint16": - case "uint32": - case "uint64": - case "float32": - case "float64": - return assertType(value, "number", target); - case "bytes": - if (value instanceof Uint8Array) { - return []; - } - return [{ code: "type-mismatch", message: `Expected type bytes`, target }]; - } -} - -function assertType( - value: unknown, - expectedType: string, - target: string[], -): readonly ValidationError[] { - if (typeof value === expectedType) { - return []; - } - return [{ code: "type-mismatch", message: `Expected type ${expectedType}`, target }]; -} From 809d6289e1cd1d7ec5309c82b026938109c05741 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 26 Jun 2026 08:58:59 -0400 Subject: [PATCH 08/13] Stabilize emitter-options TypeGraph prototype and wire validation end-to-end - Restore incremental program reuse fast-path inside createTypeGraph (was disabled), and give TypeGraph its own sourceFiles/jsSourceFiles. - Expand the emitter-options validator: unions, enums, literals, optional/required, unknown-property rejection, custom scalars, Record<>. - Wire validation end-to-end in loadEmitter via validateEmitterOptionsAgainstModel, reporting located `invalid-emitter-options` diagnostics in tspconfig.yaml. - Transitional coexistence: legacy JSON-schema validator stays authoritative while present; the TypeSpec-options path is enforced only for emitters with no legacy validator. - Add validator unit tests + an end-to-end test for a greenfield emitter. --- ...ter-options-typegraph-2026-6-25-12-51-0.md | 7 + packages/compiler/src/core/emitter-options.ts | 42 ++++- .../core/emitter-options/validator.test.ts | 161 +++++++++++++++++ .../src/core/emitter-options/validator.ts | 167 +++++++++++++++++- packages/compiler/src/core/program.ts | 94 +++++++--- .../test/core/emitter-options.test.ts | 77 ++++++++ 6 files changed, 520 insertions(+), 28 deletions(-) create mode 100644 .chronus/changes/emitter-options-typegraph-2026-6-25-12-51-0.md diff --git a/.chronus/changes/emitter-options-typegraph-2026-6-25-12-51-0.md b/.chronus/changes/emitter-options-typegraph-2026-6-25-12-51-0.md new file mode 100644 index 00000000000..7dbd4e23211 --- /dev/null +++ b/.chronus/changes/emitter-options-typegraph-2026-6-25-12-51-0.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/compiler" +--- + +Introduce internal `TypeGraph` concept (a self-contained compilation result) and experimental support for defining emitter options as a TypeSpec file (`exports["./options"].typespec`). diff --git a/packages/compiler/src/core/emitter-options.ts b/packages/compiler/src/core/emitter-options.ts index 5a20faee66a..730e6bc3f88 100644 --- a/packages/compiler/src/core/emitter-options.ts +++ b/packages/compiler/src/core/emitter-options.ts @@ -1,8 +1,11 @@ +import { getLocationInYamlScript } from "../yaml/diagnostics.js"; +import { YamlScript } from "../yaml/types.js"; import { createDiagnosticCollector, err } from "./diagnostics.js"; +import { validateEmitterOptions } from "./emitter-options/validator.js"; import { createDiagnostic } from "./messages.js"; -import { TypeGraph } from "./program.js"; +import { Program, TypeGraph } from "./program.js"; import { createSourceFile } from "./source-file.js"; -import { Diagnostic, Model } from "./types.js"; +import { Diagnostic, Model, NoTarget } from "./types.js"; export function resolveEmitterOptions( typeGraph: TypeGraph, @@ -31,3 +34,38 @@ export function resolveEmitterOptions( } return diagnostics.wrap(root); } + +/** + * Where to anchor diagnostics produced while validating emitter options. + * `script` is the parsed `tspconfig.yaml` and `basePath` the path to the + * emitter's options inside it (e.g. `["options", "@typespec/openapi3"]`). + */ +export interface EmitterOptionsConfigTarget { + readonly script: YamlScript; + readonly basePath: string[]; +} + +/** + * Validate user provided emitter options against the `EmitterOptions` model + * declared by an emitter and turn validation errors into diagnostics anchored in + * the `tspconfig.yaml` when available. + */ +export function validateEmitterOptionsAgainstModel( + program: Program, + options: Record, + model: Model, + target: EmitterOptionsConfigTarget | typeof NoTarget, +): readonly Diagnostic[] { + const errors = validateEmitterOptions(program, options, model); + return errors.map( + (error): Diagnostic => ({ + severity: "error", + code: "invalid-emitter-options", + message: error.message, + target: + target === NoTarget + ? NoTarget + : getLocationInYamlScript(target.script, [...target.basePath, ...error.target], "key"), + }), + ); +} diff --git a/packages/compiler/src/core/emitter-options/validator.test.ts b/packages/compiler/src/core/emitter-options/validator.test.ts index cb0c84ab905..de88d483561 100644 --- a/packages/compiler/src/core/emitter-options/validator.test.ts +++ b/packages/compiler/src/core/emitter-options/validator.test.ts @@ -99,3 +99,164 @@ describe("arrays", () => { ]); }); }); + +describe("optional/required properties", () => { + it("missing optional property passes", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop?: string; + }`, + {}, + ); + expect(errors).toEqual([]); + }); + + it("missing required property errors", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop: string; + }`, + {}, + ); + expect(errors).toEqual([ + { + code: "missing-property", + message: `Missing required property "prop"`, + target: ["prop"], + }, + ]); + }); +}); + +describe("unknown properties", () => { + it("errors on unknown property", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop?: string; + }`, + { other: "hello" }, + ); + expect(errors).toEqual([ + { + code: "unknown-property", + message: `Unknown property "other"`, + target: ["other"], + }, + ]); + }); +}); + +describe("unions", () => { + it("passes when value matches a string literal variant", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop?: "yaml" | "json"; + }`, + { prop: "json" }, + ); + expect(errors).toEqual([]); + }); + + it("errors with allowed values when no literal variant matches", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop?: "yaml" | "json"; + }`, + { prop: "xml" }, + ); + expect(errors).toEqual([ + { + code: "invalid-value", + message: `Value "xml" is not one of the allowed values: "yaml", "json"`, + target: ["prop"], + }, + ]); + }); + + it("passes when value matches a model variant", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop?: "a" | { kind: "a" | "b", separator?: string }; + }`, + { prop: { kind: "b", separator: "/" } }, + ); + expect(errors).toEqual([]); + }); +}); + +describe("enums", () => { + it("passes when value matches an enum member", async () => { + const errors = await validateOptions( + ` + enum Color { Red: "red", Blue: "blue" } + model EmitterOptions { + prop?: Color; + }`, + { prop: "red" }, + ); + expect(errors).toEqual([]); + }); + + it("errors when value is not an enum member", async () => { + const errors = await validateOptions( + ` + enum Color { Red: "red", Blue: "blue" } + model EmitterOptions { + prop?: Color; + }`, + { prop: "green" }, + ); + expect(errors).toEqual([ + { + code: "invalid-value", + message: `Value "green" is not one of the allowed values: "red", "blue"`, + target: ["prop"], + }, + ]); + }); +}); + +describe("custom scalars", () => { + it("validates a custom scalar against its built-in base", async () => { + const errors = await validateOptions( + ` + scalar myPath extends string; + model EmitterOptions { + prop?: myPath; + }`, + { prop: 123 }, + ); + expect(errors).toEqual([ + { + code: "type-mismatch", + message: "Expected type string", + target: ["prop"], + }, + ]); + }); +}); + +describe("Record", () => { + it("validates every entry against the value type", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop?: Record; + }`, + { prop: { a: "x", b: 1 } }, + ); + expect(errors).toEqual([ + { + code: "type-mismatch", + message: "Expected type string", + target: ["prop", "b"], + }, + ]); + }); +}); diff --git a/packages/compiler/src/core/emitter-options/validator.ts b/packages/compiler/src/core/emitter-options/validator.ts index 70f894b2a82..7ddf37a33a3 100644 --- a/packages/compiler/src/core/emitter-options/validator.ts +++ b/packages/compiler/src/core/emitter-options/validator.ts @@ -1,7 +1,7 @@ import { getPattern } from "../../lib/decorators.js"; import { Program } from "../program.js"; import { isArrayModelType } from "../type-utils.js"; -import type { ArrayModelType, Model, Scalar, StdTypeName, Type } from "../types.js"; +import type { ArrayModelType, Enum, Model, Scalar, StdTypeName, Type, Union } from "../types.js"; export interface ValidationError { code: string; @@ -9,6 +9,20 @@ export interface ValidationError { target: string[]; } +const knownScalarNames = new Set([ + "string", + "boolean", + "bytes", + "int8", + "int16", + "int32", + "uint8", + "uint16", + "uint32", + "float32", + "float64", +]); + export function validateEmitterOptions( program: Program, value: unknown, @@ -21,7 +35,15 @@ export function validateEmitterOptions( } return validateModel(program, value, type); case "Scalar": - return validateBuiltinScalar(value, type as any, []); + return validateScalar(value, type); + case "Union": + return validateUnion(program, value, type); + case "Enum": + return validateEnum(value, type); + case "String": + case "Number": + case "Boolean": + return validateLiteral(value, type.value); } return []; } @@ -39,8 +61,30 @@ function validateModel(program: Program, value: unknown, type: Model): readonly const errors: ValidationError[] = []; const valObj = value as Record; + + // `Record` style models: validate every entry against the indexer value type. + if (type.indexer && type.indexer.key.name === "string") { + for (const [key, entryValue] of Object.entries(valObj)) { + const entryErrors = validateEmitterOptions(program, entryValue, type.indexer.value); + for (const err of entryErrors) { + errors.push({ ...err, target: [key, ...err.target] }); + } + } + return errors; + } + for (const propType of type.properties.values()) { const propValue = valObj[propType.name]; + if (propValue === undefined) { + if (!propType.optional) { + errors.push({ + code: "missing-property", + message: `Missing required property "${propType.name}"`, + target: [propType.name], + }); + } + continue; + } const propErrors = validateEmitterOptions(program, propValue, propType.type); for (const err of propErrors) { errors.push({ @@ -59,6 +103,17 @@ function validateModel(program: Program, value: unknown, type: Model): readonly } } } + + // Reject unknown properties for plain (non-indexed) models. + for (const key of Object.keys(valObj)) { + if (!type.properties.has(key)) { + errors.push({ + code: "unknown-property", + message: `Unknown property "${key}"`, + target: [key], + }); + } + } return errors; } @@ -89,13 +144,113 @@ function validateArray( return errors; } +function validateUnion(program: Program, value: unknown, type: Union): readonly ValidationError[] { + const variants = [...type.variants.values()]; + for (const variant of variants) { + if (validateEmitterOptions(program, value, variant.type).length === 0) { + return []; + } + } + + const literals = collectLiteralValues(variants); + const message = + literals !== undefined + ? `Value ${JSON.stringify(value)} is not one of the allowed values: ${literals + .map((l) => JSON.stringify(l)) + .join(", ")}` + : `Value ${JSON.stringify(value)} does not match any of the expected types.`; + return [{ code: "invalid-value", message, target: [] }]; +} + +function validateEnum(value: unknown, type: Enum): readonly ValidationError[] { + const allowed: (string | number)[] = []; + for (const member of type.members.values()) { + const memberValue = member.value ?? member.name; + allowed.push(memberValue); + if (value === memberValue) { + return []; + } + } + return [ + { + code: "invalid-value", + message: `Value ${JSON.stringify(value)} is not one of the allowed values: ${allowed + .map((l) => JSON.stringify(l)) + .join(", ")}`, + target: [], + }, + ]; +} + +function validateLiteral( + value: unknown, + expected: string | number | boolean, +): readonly ValidationError[] { + if (value === expected) { + return []; + } + return [ + { + code: "invalid-value", + message: `Expected ${JSON.stringify(expected)}`, + target: [], + }, + ]; +} + +/** + * If every variant of a union resolves to a literal (or enum) value, return the + * flattened list of allowed values so we can produce a friendly error message. + */ +function collectLiteralValues( + variants: { type: Type }[], +): (string | number | boolean)[] | undefined { + const values: (string | number | boolean)[] = []; + for (const variant of variants) { + const type = variant.type; + switch (type.kind) { + case "String": + case "Number": + case "Boolean": + values.push(type.value); + break; + case "Enum": + for (const member of type.members.values()) { + values.push(member.value ?? member.name); + } + break; + default: + return undefined; + } + } + return values; +} + +function validateScalar(value: unknown, type: Scalar): readonly ValidationError[] { + // Resolve custom scalars (e.g. `scalar absolutePath extends string`) to their + // known built-in base so they validate against the underlying representation. + let current: Scalar | undefined = type; + while (current && !knownScalarNames.has(current.name as StdTypeName)) { + current = current.baseScalar; + } + if (current === undefined) { + return [ + { + code: "unsupported", + message: `${type.name} is not supported for emitter options.`, + target: [], + }, + ]; + } + return validateBuiltinScalar(value, current.name as StdTypeName, []); +} + function validateBuiltinScalar( value: unknown, - type: Scalar & { name: StdTypeName }, + name: StdTypeName, target: string[], ): readonly ValidationError[] { - console.log("Validate", type.name, value); - switch (type.name) { + switch (name) { case "string": return assertType(value, "string", target); case "boolean": @@ -118,7 +273,7 @@ function validateBuiltinScalar( return [ { code: "unsupported", - message: `${type.name} is not supported for emitter options.`, + message: `${name} is not supported for emitter options.`, target, }, ]; diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index 265293faee8..c55a0b284fb 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -11,12 +11,12 @@ import { } from "../module-resolver/types.js"; import { PackageJson } from "../types/package-json.js"; import { findProjectRoot } from "../utils/io.js"; -import { deepEquals, isDefined, mutate } from "../utils/misc.js"; +import { deepEquals, isDefined, mapEquals, mutate } from "../utils/misc.js"; import { createBinder } from "./binder.js"; import { Checker, createChecker } from "./checker.js"; import { createSuppressCodeFix } from "./compiler-code-fixes/suppress.codefix.js"; import { compilerAssert } from "./diagnostics.js"; -import { resolveEmitterOptions } from "./emitter-options.js"; +import { resolveEmitterOptions, validateEmitterOptionsAgainstModel } from "./emitter-options.js"; import { flushEmittedFilesPaths } from "./emitter-utils.js"; import { resolveTypeSpecEntrypoint } from "./entrypoint-resolution.js"; import { ExternalError } from "./external-error.js"; @@ -224,6 +224,18 @@ export interface TypeGraph { */ readonly entrypoint: string; + /** + * TypeSpec source files that make up this type graph. + * @internal + */ + readonly sourceFiles: Map; + + /** + * JS source files that make up this type graph. + * @internal + */ + readonly jsSourceFiles: Map; + /** @internal */ sourceResolution: SourceResolution; @@ -238,7 +250,8 @@ async function createTypeGraph( resolvedMain: string, options: CompilerOptions, sourceFileCache: Map | undefined, -): Promise { + oldProgram?: Program, +): Promise<{ typeGraph: TypeGraph; reusedProgram?: Program }> { const host = program.host; const binder = createBinder(program); const runtimeStats: Partial = {}; @@ -254,6 +267,8 @@ async function createTypeGraph( entrypoint: resolvedMain, globalNamespace: undefined!, checker: undefined!, + sourceFiles: undefined!, + jsSourceFiles: undefined!, complexityStats, runtimeStats: runtimeStats as any, sourceResolution: sourceLoader.resolution, @@ -264,6 +279,17 @@ async function createTypeGraph( runtimeStats.loader = await timeAsync(() => loadSources(sourceLoader, resolvedMain)); + // Incremental reuse: if the source files and compiler options are unchanged from + // a previous compilation, skip resolving/checking entirely and reuse the old + // program. This is the fast-path the language server / watch mode rely on. + if ( + oldProgram && + mapEquals(oldProgram.sourceFiles, program.sourceFiles) && + deepEquals(oldProgram.compilerOptions, program.compilerOptions) + ) { + return { typeGraph, reusedProgram: oldProgram }; + } + const resolver = createResolver(program); program.resolver = resolver; // Update the current resolver for back compat @@ -279,7 +305,7 @@ async function createTypeGraph( complexityStats.finishedTypes = checker.stats.finishedTypes; await validateLoadedLibraries(sourceLoader); - return typeGraph; + return { typeGraph }; /** * Validate the libraries loaded during the compilation process are compatible. */ @@ -353,6 +379,8 @@ async function createTypeGraph( const sourceResolution = sourceLoader.resolution; + mutate(typeGraph).sourceFiles = sourceResolution.sourceFiles; + mutate(typeGraph).jsSourceFiles = sourceResolution.jsSourceFiles; program.sourceFiles = sourceResolution.sourceFiles; program.jsSourceFiles = sourceResolution.jsSourceFiles; @@ -495,16 +523,17 @@ async function createProgram( program.reportDiagnostics(await linter.extendRuleSet(options.linterRuleSet)); } - // if ( - // oldProgram && - // mapEquals(oldProgram.sourceFiles, program.sourceFiles) && - // deepEquals(oldProgram.compilerOptions, program.compilerOptions) - // ) { - // return { program: oldProgram, shouldAbort: true }; - // } - // let GC reclaim old program, we do not reuse it beyond this point. - const typeGraph = await createTypeGraph(program, resolvedMain, options, oldProgram?.sourceFiles); + const { typeGraph, reusedProgram } = await createTypeGraph( + program, + resolvedMain, + options, + oldProgram?.sourceFiles, + oldProgram, + ); + if (reusedProgram) { + return { program: reusedProgram, shouldAbort: true }; + } oldProgram = undefined; program.checker = typeGraph.checker; Object.assign(complexityStats, typeGraph.complexityStats); @@ -649,14 +678,12 @@ async function createProgram( const optionsEntrypoint = library.module.type === "module" && (library.module.manifest.exports as any)?.["./options"]?.["typespec"]; - if (optionsEntrypoint) { - const fullPath = resolvePath(library.module.path, optionsEntrypoint); - const typeGraph = await createTypeGraph(program, fullPath, options, program.sourceFiles); - const [model, diagnostics] = resolveEmitterOptions(typeGraph); - program.reportDiagnostics(diagnostics); - } - if (libDefinition?.emitter?.options) { + // Legacy JSON-schema based validation. While an emitter still ships a + // legacy validator it remains authoritative; the TypeSpec-options graph is + // only enforced once an emitter has fully migrated (see `else if` below). + // This is transitional: the experimental option models are not yet a + // complete source of truth for already-shipped emitters. const diagnostics = libDefinition?.emitterOptionValidator?.validate( emitterOptions, options.configFile?.file @@ -671,6 +698,33 @@ async function createProgram( program.reportDiagnostics(diagnostics); return; } + } else if (optionsEntrypoint) { + // Emitter declares its options as a TypeSpec file (and has no legacy + // validator): compile it into its own type graph and validate the user + // options against the exported `EmitterOptions` model. + const fullPath = resolvePath(library.module.path, optionsEntrypoint); + const { typeGraph } = await createTypeGraph( + program, + fullPath, + options, + program.sourceFiles, + ); + const [model, diagnostics] = resolveEmitterOptions(typeGraph); + program.reportDiagnostics(diagnostics); + if (model) { + const validationDiagnostics = validateEmitterOptionsAgainstModel( + program, + emitterOptions, + model, + options.configFile?.file + ? { script: options.configFile.file, basePath: ["options", emitterNameOrPath] } + : NoTarget, + ); + if (validationDiagnostics.length > 0) { + program.reportDiagnostics(validationDiagnostics); + return; + } + } } return { main: entrypoint.file.path, diff --git a/packages/compiler/test/core/emitter-options.test.ts b/packages/compiler/test/core/emitter-options.test.ts index cf479578d3e..e87381e5fc0 100644 --- a/packages/compiler/test/core/emitter-options.test.ts +++ b/packages/compiler/test/core/emitter-options.test.ts @@ -130,3 +130,80 @@ describe("compiler: emitter options", () => { }); }); }); + +describe("compiler: emitter options defined in TypeSpec", () => { + const tspOptionsEmitter = createTypeSpecLibrary({ + name: "tsp-options-emitter", + diagnostics: {}, + }); + + async function diagnoseEmitterOptions( + options: Record, + ): Promise { + const host = await createTestHost(); + host.addTypeSpecFile("main.tsp", ""); + host.addTypeSpecFile( + "node_modules/tsp-options-emitter/package.json", + JSON.stringify({ + main: "index.js", + exports: { + ".": "./index.js", + "./options": { typespec: "./options.tsp" }, + }, + }), + ); + host.addTypeSpecFile( + "node_modules/tsp-options-emitter/options.tsp", + `model EmitterOptions { + name?: string; + count?: int32; + format?: "yaml" | "json"; + }`, + ); + host.addJsFile("node_modules/tsp-options-emitter/index.js", { + $lib: tspOptionsEmitter, + $onEmit: () => {}, + }); + + return host.diagnose("main.tsp", { + emit: ["tsp-options-emitter"], + options: { + "tsp-options-emitter": options, + }, + }); + } + + it("passes valid options", async () => { + const diagnostics = await diagnoseEmitterOptions({ + "emitter-output-dir": "/out", + name: "hello", + count: 3, + format: "json", + }); + expectDiagnosticEmpty(diagnostics); + }); + + it("emits diagnostic for an unknown property", async () => { + const diagnostics = await diagnoseEmitterOptions({ "not-an-option": true }); + expectDiagnostics(diagnostics, { + code: "invalid-emitter-options", + message: `Unknown property "not-an-option"`, + }); + }); + + it("emits diagnostic for an invalid value type", async () => { + const diagnostics = await diagnoseEmitterOptions({ count: "not a number" }); + expectDiagnostics(diagnostics, { + code: "invalid-emitter-options", + message: "Expected type number", + }); + }); + + it("emits diagnostic for a value outside an allowed union", async () => { + const diagnostics = await diagnoseEmitterOptions({ format: "xml" }); + expectDiagnostics(diagnostics, { + code: "invalid-emitter-options", + message: `Value "xml" is not one of the allowed values: "yaml", "json"`, + }); + }); +}); From 3e23927fb1a96fe0db7fc9331f24b7cc90229779 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 26 Jun 2026 10:33:19 -0400 Subject: [PATCH 09/13] Migrate json-schema and openapi3 emitters to TypeSpec options Define each emitter's options as a TypeSpec model (options/main.tsp, exported via exports["./options"].typespec) and remove the legacy hand-written JSON-schema validators so the compiler validates user options against the model. Adds end-to-end emitter-options tests. --- ...on-schema-tsp-options-2026-6-26-10-20-0.md | 7 + .../openapi3-tsp-options-2026-6-26-10-30-0.md | 7 + packages/json-schema/options/main.tsp | 17 ++ packages/json-schema/package.json | 4 + packages/json-schema/src/index.ts | 2 +- packages/json-schema/src/lib.ts | 80 +------- packages/json-schema/src/on-emit.ts | 2 +- .../json-schema/test/emitter-options.test.ts | 47 +++++ packages/openapi3/options/main.tsp | 29 ++- packages/openapi3/package.json | 1 + packages/openapi3/src/lib.ts | 178 +----------------- .../openapi3/test/emitter-options.test.ts | 69 +++++++ 12 files changed, 177 insertions(+), 266 deletions(-) create mode 100644 .chronus/changes/json-schema-tsp-options-2026-6-26-10-20-0.md create mode 100644 .chronus/changes/openapi3-tsp-options-2026-6-26-10-30-0.md create mode 100644 packages/json-schema/test/emitter-options.test.ts create mode 100644 packages/openapi3/test/emitter-options.test.ts diff --git a/.chronus/changes/json-schema-tsp-options-2026-6-26-10-20-0.md b/.chronus/changes/json-schema-tsp-options-2026-6-26-10-20-0.md new file mode 100644 index 00000000000..74e204dd685 --- /dev/null +++ b/.chronus/changes/json-schema-tsp-options-2026-6-26-10-20-0.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/json-schema" +--- + +Migrate the JSON Schema emitter to define its options as a TypeSpec model (`options/main.tsp`, exported via `exports["./options"].typespec`) instead of a hand-written JSON schema. The compiler now validates user options against that model. Removes the internal `EmitterOptionsSchema` export. diff --git a/.chronus/changes/openapi3-tsp-options-2026-6-26-10-30-0.md b/.chronus/changes/openapi3-tsp-options-2026-6-26-10-30-0.md new file mode 100644 index 00000000000..3df01625eb4 --- /dev/null +++ b/.chronus/changes/openapi3-tsp-options-2026-6-26-10-30-0.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/openapi3" +--- + +Migrate the OpenAPI3 emitter to define its options as a TypeSpec model (`options/main.tsp`, exported via `exports["./options"].typespec`) instead of a hand-written JSON schema. The compiler now validates user options against that model. Removes the internal `EmitterOptionsSchema` export. diff --git a/packages/json-schema/options/main.tsp b/packages/json-schema/options/main.tsp index d01960bb4b7..0a4b82e4498 100644 --- a/packages/json-schema/options/main.tsp +++ b/packages/json-schema/options/main.tsp @@ -44,6 +44,15 @@ model EmitterOptions { * @defaultValue false */ `seal-object-schemas`?: boolean; + + /** + * Strategy for emitting models with the discriminator decorator. + * - ignore: Emit as regular object schema (default) + * - oneOf: Emit a oneOf schema with references to all derived models (closed union) + * - anyOf: Emit an anyOf schema with references to all derived models (open union) + * @defaultValue "ignore" + */ + `polymorphic-models-strategy`?: PolymorphicModelsStrategy; } /** @@ -58,3 +67,11 @@ alias FileType = "yaml" | "json"; * */ alias Int64Strategy = "string" | "number"; + +/** + * Strategy for emitting models with the `@discriminator` decorator. + * - ignore: Emit as regular object schema (default) + * - oneOf: Emit a oneOf schema with references to all derived models (closed union) + * - anyOf: Emit an anyOf schema with references to all derived models (open union) + */ +alias PolymorphicModelsStrategy = "ignore" | "oneOf" | "anyOf"; diff --git a/packages/json-schema/package.json b/packages/json-schema/package.json index 47e877acb76..e97333caf63 100644 --- a/packages/json-schema/package.json +++ b/packages/json-schema/package.json @@ -28,6 +28,9 @@ "./testing": { "types": "./dist/src/testing/index.d.ts", "default": "./dist/src/testing/index.js" + }, + "./options": { + "typespec": "./options/main.tsp" } }, "tspMain": "lib/main.tsp", @@ -50,6 +53,7 @@ }, "files": [ "lib/*.tsp", + "options/*.tsp", "dist/**", "!dist/test/**" ], diff --git a/packages/json-schema/src/index.ts b/packages/json-schema/src/index.ts index bd181361ae8..d5df7e34555 100644 --- a/packages/json-schema/src/index.ts +++ b/packages/json-schema/src/index.ts @@ -19,7 +19,7 @@ export type { /** @internal */ export { JsonSchemaEmitter } from "./json-schema-emitter.js"; -export { $flags, $lib, EmitterOptionsSchema } from "./lib.js"; +export { $flags, $lib } from "./lib.js"; export type { JSONSchemaEmitterOptions } from "./lib.js"; /** @internal */ diff --git a/packages/json-schema/src/lib.ts b/packages/json-schema/src/lib.ts index ccca2095106..b960a594cbc 100644 --- a/packages/json-schema/src/lib.ts +++ b/packages/json-schema/src/lib.ts @@ -1,9 +1,4 @@ -import { - createTypeSpecLibrary, - definePackageFlags, - type JSONSchemaType, - paramMessage, -} from "@typespec/compiler"; +import { createTypeSpecLibrary, definePackageFlags, paramMessage } from "@typespec/compiler"; /** * File type @@ -81,76 +76,6 @@ export interface JSONSchemaEmitterOptions { "polymorphic-models-strategy"?: PolymorphicModelsStrategy; } -/** - * Internal: Json Schema emitter options schema - */ -export const EmitterOptionsSchema: JSONSchemaType = { - type: "object", - additionalProperties: false, - properties: { - "file-type": { - type: "string", - enum: ["yaml", "json"], - nullable: true, - description: "Serialize the schema as either yaml or json.", - }, - "int64-strategy": { - type: "string", - enum: ["string", "number"], - nullable: true, - description: `How to handle 64 bit integers on the wire. Options are: - -* string: serialize as a string (widely interoperable) -* number: serialize as a number (not widely interoperable)`, - }, - bundleId: { - type: "string", - nullable: true, - description: - "When provided, bundle all the schemas into a single json schema document with schemas under $defs. The provided id is the id of the root document and is also used for the file name.", - }, - emitAllModels: { - type: "boolean", - nullable: true, - description: - "When true, emit all model declarations to JSON Schema without requiring the @jsonSchema decorator.", - }, - emitAllRefs: { - type: "boolean", - nullable: true, - description: - "When true, emit all references as json schema files, even if the referenced type does not have the `@jsonSchema` decorator or is not within a namespace with the `@jsonSchema` decorator.", - }, - "seal-object-schemas": { - type: "boolean", - nullable: true, - default: false, - description: [ - "If true, then for models emitted as object schemas we default `unevaluatedProperties` to `{ not: {} }`,", - "if not explicitly specified elsewhere.", - "Default: `false`", - ].join("\n"), - }, - "polymorphic-models-strategy": { - type: "string", - enum: ["ignore", "oneOf", "anyOf"], - nullable: true, - default: "ignore", - description: [ - "Strategy for emitting models with the @discriminator decorator:", - "- ignore: Emit as regular object schema (default). Derived models use allOf to reference their base model.", - "- oneOf: Emit a oneOf schema with references to all derived models (closed union)", - "- anyOf: Emit an anyOf schema with references to all derived models (open union)", - "", - "When using oneOf or anyOf, derived models will inline all properties from their base model", - "instead of using allOf references. This avoids circular references in the generated schemas,", - "since the base model references derived models via oneOf/anyOf.", - ].join("\n"), - }, - }, - required: [], -}; - /** Internal: TypeSpec library definition */ export const $lib = createTypeSpecLibrary({ name: "@typespec/json-schema", @@ -174,9 +99,6 @@ export const $lib = createTypeSpecLibrary({ }, }, }, - emitter: { - options: EmitterOptionsSchema as JSONSchemaType, - }, state: { JsonSchema: { description: "State indexing types marked with @jsonSchema" }, "JsonSchema.baseURI": { description: "Contains data configured with @baseUri decorator" }, diff --git a/packages/json-schema/src/on-emit.ts b/packages/json-schema/src/on-emit.ts index deae7af1b86..a0da5b3f00d 100644 --- a/packages/json-schema/src/on-emit.ts +++ b/packages/json-schema/src/on-emit.ts @@ -9,7 +9,7 @@ import { import { getJsonSchemaTypes } from "./decorators.js"; import { JsonSchemaEmitter } from "./json-schema-emitter.js"; import type { JSONSchemaEmitterOptions } from "./lib.js"; -export { $flags, $lib, EmitterOptionsSchema, type JSONSchemaEmitterOptions } from "./lib.js"; +export { $flags, $lib, type JSONSchemaEmitterOptions } from "./lib.js"; export const namespace = "TypeSpec.JsonSchema"; export type JsonSchemaDeclaration = Model | Union | Enum | Scalar; diff --git a/packages/json-schema/test/emitter-options.test.ts b/packages/json-schema/test/emitter-options.test.ts new file mode 100644 index 00000000000..2d89942badb --- /dev/null +++ b/packages/json-schema/test/emitter-options.test.ts @@ -0,0 +1,47 @@ +import { expectDiagnostics } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { emitSchema, emitSchemaWithDiagnostics } from "./utils.js"; + +// The json-schema emitter declares its options as a TypeSpec model +// (`options/main.tsp`, exported via package.json `exports["./options"].typespec`). +// These tests make sure the compiler validates user options against that model. +describe("json-schema: emitter options validation", () => { + it("accepts all documented options", async () => { + await emitSchema(`model Foo {}`, { + "file-type": "json", + "int64-strategy": "string", + bundleId: "bundle", + emitAllModels: true, + emitAllRefs: true, + "seal-object-schemas": true, + "polymorphic-models-strategy": "oneOf", + }); + }); + + it("rejects a value outside a union option", async () => { + const [, diagnostics] = await emitSchemaWithDiagnostics(`model Foo {}`, { + "polymorphic-models-strategy": "not-valid", + } as any); + expectDiagnostics(diagnostics, { + code: "invalid-emitter-options", + message: `Value "not-valid" is not one of the allowed values: "ignore", "oneOf", "anyOf"`, + }); + }); + + it("rejects a value of the wrong type", async () => { + const [, diagnostics] = await emitSchemaWithDiagnostics(`model Foo {}`, { + emitAllModels: "yes", + } as any); + expectDiagnostics(diagnostics, { + code: "invalid-emitter-options", + message: "Expected type boolean", + }); + }); + + it("rejects an unknown option", async () => { + const [, diagnostics] = await emitSchemaWithDiagnostics(`model Foo {}`, { + "totally-unknown": true, + } as any); + expect(diagnostics.some((d) => d.code === "invalid-emitter-options")).toBe(true); + }); +}); diff --git a/packages/openapi3/options/main.tsp b/packages/openapi3/options/main.tsp index e0b70cf2061..6910525a500 100644 --- a/packages/openapi3/options/main.tsp +++ b/packages/openapi3/options/main.tsp @@ -3,9 +3,10 @@ import "@typespec/compiler/emitter"; model EmitterOptions { /** * If the content should be serialized as YAML or JSON. + * Can be a single value or an array to emit multiple formats. * @default yaml, it not specified infer from the `output-file` extension */ - `file-type`?: FileType; + `file-type`?: FileType | FileType[]; /** * Name of the output file. @@ -96,16 +97,28 @@ model EmitterOptions { * - `explicit-only`: Only use explicitly defined operation IDs. * @default parent-container */ - `operation-id-strategy`?: OperationIdStrategy | { - /** Strategy used to generate the operation ID. */ - kind: OperationIdStrategy; + `operation-id-strategy`?: + | OperationIdStrategy + | { + /** Strategy used to generate the operation ID. */ + kind: OperationIdStrategy; - /** Separator used to join segment in the operation name. */ - separator?: string; - }; + /** Separator used to join segment in the operation name. */ + separator?: string; + }; + + /** + * How to emit TypeSpec enums. Options are: + * - `default`: Emit as a single schema using the `enum` keyword. + * - `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description` + * from each member's `@summary` and `@doc`. Only supported by OpenAPI 3.1.0 and above. + * @default "default" + */ + `enum-strategy`?: EnumStrategy; } alias FileType = "yaml" | "json"; -alias OpenAPIVersion = "3.0.0" | "3.1.0"; +alias OpenAPIVersion = "3.0.0" | "3.1.0" | "3.2.0"; alias ExperimentalParameterExamplesStrategy = "data" | "serialized"; alias OperationIdStrategy = "parent-container" | "fqn" | "explicit-only"; +alias EnumStrategy = "default" | "annotated"; diff --git a/packages/openapi3/package.json b/packages/openapi3/package.json index 43fc84692dc..8f6e22c26e7 100644 --- a/packages/openapi3/package.json +++ b/packages/openapi3/package.json @@ -62,6 +62,7 @@ }, "files": [ "lib/*.tsp", + "options/*.tsp", "dist/**", "!dist/test/**" ], diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index 48e6ef75d12..8e512455179 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -1,4 +1,4 @@ -import { createTypeSpecLibrary, JSONSchemaType, paramMessage } from "@typespec/compiler"; +import { createTypeSpecLibrary, paramMessage } from "@typespec/compiler"; export type FileType = "yaml" | "json"; export type OpenAPIVersion = "3.0.0" | "3.1.0" | "3.2.0"; @@ -127,179 +127,6 @@ export interface OpenAPI3EmitterOptions { export type OperationIdStrategy = "parent-container" | "fqn" | "explicit-only"; -const operationIdStrategySchema = { - type: "string", - enum: ["parent-container", "fqn", "explicit-only"], - default: "parent-container", - description: [ - "Determines how to generate operation IDs when `@operationId` is not used.", - "Avaliable options are:", - " - `parent-container`: Uses the parent namespace and operation name to generate the ID.", - " - `fqn`: Uses the fully qualified name of the operation to generate the ID.", - " - `explicit-only`: Only use explicitly defined operation IDs.", - ].join("\n"), -} as const; - -const EmitterOptionsSchema: JSONSchemaType = { - type: "object", - additionalProperties: false, - properties: { - "file-type": { - type: ["string", "array"], - nullable: true, - oneOf: [ - { - type: "string", - enum: ["yaml", "json"], - }, - { - type: "array", - items: { - type: "string", - enum: ["yaml", "json"], - }, - uniqueItems: true, - minItems: 1, - }, - ], - description: - "If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple formats. Default 'yaml', if not specified infer from the `output-file` extension", - }, - "output-file": { - type: "string", - nullable: true, - description: [ - "Name of the output file.", - " Output file will interpolate the following values:", - " - service-name: Name of the service", - " - service-name-if-multiple: Name of the service if multiple", - " - version: Version of the service if multiple", - " - file-type: The file type being emitted (json or yaml). Useful when `file-type` is an array.", - "", - ' Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`', - " When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}`", - "", - " Example Single service no versioning", - " - `openapi.yaml`", - "", - " Example Multiple services no versioning", - " - `openapi.Org1.Service1.yaml`", - " - `openapi.Org1.Service2.yaml`", - "", - " Example Single service with versioning", - " - `openapi.v1.yaml`", - " - `openapi.v2.yaml`", - "", - " Example Multiple service with versioning", - " - `openapi.Org1.Service1.v1.yaml`", - " - `openapi.Org1.Service1.v2.yaml`", - " - `openapi.Org1.Service2.v1.0.yaml`", - " - `openapi.Org1.Service2.v1.1.yaml` ", - ].join("\n"), - }, - "openapi-versions": { - title: "OpenAPI Versions", - type: "array", - items: { - type: "string", - enum: ["3.0.0", "3.1.0", "3.2.0"], - nullable: true, - description: "The versions of OpenAPI to emit. Defaults to `[3.0.0]`", - }, - nullable: true, - uniqueItems: true, - minItems: 1, - default: ["3.0.0"], - }, - "new-line": { - type: "string", - enum: ["crlf", "lf"], - default: "lf", - nullable: true, - description: "Set the newline character for emitting files.", - }, - "omit-unreachable-types": { - type: "boolean", - nullable: true, - description: - "Omit unreachable types.\nBy default all types declared under the service namespace will be included. With this flag on only types references in an operation will be emitted.", - }, - "include-x-typespec-name": { - type: "string", - enum: ["inline-only", "never"], - nullable: true, - default: "never", - description: - "If the generated openapi types should have the `x-typespec-name` extension set with the name of the TypeSpec type that created it.\nThis extension is meant for debugging and should not be depended on.", - }, - "safeint-strategy": { - type: "string", - enum: ["double-int", "int64"], - nullable: true, - default: "int64", - description: [ - "How to handle safeint type. Options are:", - " - `double-int`: Will produce `type: integer, format: double-int`", - " - `int64`: Will produce `type: integer, format: int64`", - "", - "Default: `int64`", - ].join("\n"), - }, - "seal-object-schemas": { - type: "boolean", - nullable: true, - default: false, - description: [ - "If true, then for models emitted as object schemas we default `additionalProperties` to false for", - "OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere.", - "Default: `false`", - ].join("\n"), - }, - "experimental-parameter-examples": { - type: "string", - enum: ["data", "serialized"], - nullable: true, - description: [ - "Determines how to emit examples on parameters.", - "Note: This is an experimental feature and may change in future versions.", - "See https://spec.openapis.org/oas/v3.0.4.html#style-examples for parameter example serialization rules", - "See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples.", - ].join("\n"), - }, - "operation-id-strategy": { - oneOf: [ - operationIdStrategySchema, - { - type: "object", - properties: { - kind: operationIdStrategySchema, - separator: { - type: "string", - nullable: true, - description: "Separator used to join segment in the operation name.", - }, - }, - required: ["kind"], - }, - ], - } as any, - "enum-strategy": { - type: "string", - enum: ["default", "annotated"], - nullable: true, - default: "default", - description: [ - "How to emit TypeSpec enums. Options are:", - " - `default`: Emit as a single schema using the `enum` keyword.", - " - `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description`", - " from each member's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern.", - " Only supported by OpenAPI 3.1.0 and above; on 3.0.0 the `default` style is used and a warning is reported.", - ].join("\n"), - }, - }, - required: [], -}; - export const $lib = createTypeSpecLibrary({ name: "@typespec/openapi3", capabilities: { @@ -464,9 +291,6 @@ export const $lib = createTypeSpecLibrary({ }, }, }, - emitter: { - options: EmitterOptionsSchema as JSONSchemaType, - }, }); export const { createDiagnostic, reportDiagnostic, createStateSymbol } = $lib; diff --git a/packages/openapi3/test/emitter-options.test.ts b/packages/openapi3/test/emitter-options.test.ts new file mode 100644 index 00000000000..7418890efd1 --- /dev/null +++ b/packages/openapi3/test/emitter-options.test.ts @@ -0,0 +1,69 @@ +import { expectDiagnostics } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { diagnoseOpenApiFor } from "./test-host.js"; + +// The openapi3 emitter declares its options as a TypeSpec model +// (`options/main.tsp`, exported via package.json `exports["./options"].typespec`). +// These tests make sure the compiler validates user options against that model. +describe("openapi3: emitter options validation", () => { + it("accepts all documented options", async () => { + const diagnostics = await diagnoseOpenApiFor(`op test(): void;`, { + "file-type": "json", + "new-line": "lf", + "omit-unreachable-types": true, + "include-x-typespec-name": "inline-only", + "safeint-strategy": "int64", + "seal-object-schemas": true, + "operation-id-strategy": "fqn", + }); + expectDiagnostics(diagnostics, []); + }); + + it("accepts file-type as an array of values", async () => { + const diagnostics = await diagnoseOpenApiFor(`op test(): void;`, { + "file-type": ["json", "yaml"], + } as any); + expectDiagnostics(diagnostics, []); + }); + + it("accepts openapi-versions including 3.2.0", async () => { + const diagnostics = await diagnoseOpenApiFor(`op test(): void;`, { + "openapi-versions": ["3.0.0", "3.1.0", "3.2.0"], + } as any); + expectDiagnostics(diagnostics, []); + }); + + it("accepts operation-id-strategy as an object", async () => { + const diagnostics = await diagnoseOpenApiFor(`op test(): void;`, { + "operation-id-strategy": { kind: "parent-container", separator: "_" }, + } as any); + expectDiagnostics(diagnostics, []); + }); + + it("rejects a value outside a union option", async () => { + const diagnostics = await diagnoseOpenApiFor(`op test(): void;`, { + "enum-strategy": "not-valid", + } as any); + expectDiagnostics(diagnostics, { + code: "invalid-emitter-options", + message: `Value "not-valid" is not one of the allowed values: "default", "annotated"`, + }); + }); + + it("rejects a value of the wrong type", async () => { + const diagnostics = await diagnoseOpenApiFor(`op test(): void;`, { + "omit-unreachable-types": "yes", + } as any); + expectDiagnostics(diagnostics, { + code: "invalid-emitter-options", + message: "Expected type boolean", + }); + }); + + it("rejects an unknown option", async () => { + const diagnostics = await diagnoseOpenApiFor(`op test(): void;`, { + "totally-unknown": true, + } as any); + expect(diagnostics.some((d) => d.code === "invalid-emitter-options")).toBe(true); + }); +}); From 7e0c95c19c79b42076f836611cb426edea0c7f97 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 26 Jun 2026 10:42:58 -0400 Subject: [PATCH 10/13] fix(bundler): skip typespec-only exports without a JS entrypoint Exports that only expose a `typespec` condition (e.g. `./emitter`, `./options`) have no JS module to bundle; their source files are already included via the sub-export compilation loop. Previously the bundler threw "missing import or default entrypoint" for these, breaking the playground bundle. --- ...typespec-only-exports-2026-6-26-10-45-0.md | 7 +++++ packages/bundler/src/bundler.ts | 31 ++++++++++--------- 2 files changed, 23 insertions(+), 15 deletions(-) create mode 100644 .chronus/changes/bundler-skip-typespec-only-exports-2026-6-26-10-45-0.md diff --git a/.chronus/changes/bundler-skip-typespec-only-exports-2026-6-26-10-45-0.md b/.chronus/changes/bundler-skip-typespec-only-exports-2026-6-26-10-45-0.md new file mode 100644 index 00000000000..f63d3c479b5 --- /dev/null +++ b/.chronus/changes/bundler-skip-typespec-only-exports-2026-6-26-10-45-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/bundler" +--- + +Skip exports that only expose a `typespec` entrypoint (e.g. `./emitter`, `./options`) when building the JS bundle. These exports have no JS module to bundle and their TypeSpec source files are already included via the sub-export compilation, so the bundler no longer fails with a "missing import or default entrypoint" error. diff --git a/packages/bundler/src/bundler.ts b/packages/bundler/src/bundler.ts index 367c42ad482..c94311c668e 100644 --- a/packages/bundler/src/bundler.ts +++ b/packages/bundler/src/bundler.ts @@ -210,11 +210,15 @@ async function createEsBuildContext( }); const extraEntry = Object.fromEntries( - Object.entries(definition.exports).map(([key, value]) => { - return [ - key.replace("./", ""), - normalizePath(resolve(libraryPath, getExportEntryPoint(value))), - ]; + Object.entries(definition.exports).flatMap(([key, value]) => { + const entryPoint = getExportEntryPoint(value); + // Skip exports that only expose a TypeSpec entrypoint (e.g. `./emitter`, + // `./options`). Those have no JS module to bundle and their source files are + // already included via the sub-export compilation loop above. + if (entryPoint === undefined) { + return []; + } + return [[key.replace("./", ""), normalizePath(resolve(libraryPath, entryPoint))]]; }), ); @@ -316,16 +320,13 @@ async function resolveTypeSpecBundle( }; } -function getExportEntryPoint(value: string | ExportData) { - const resolved = typeof value === "string" ? value : (value.import ?? value.default); - - if (!resolved) { - throw new Error( - `Exports ${JSON.stringify(value, null, 2)} is missing import or default entrypoint`, - ); - } - - return resolved; +/** + * Resolve the JS entrypoint for an export entry, or `undefined` if the export only + * exposes a TypeSpec entrypoint (e.g. `{ "typespec": "./options/main.tsp" }`) and has + * no JS module to bundle. + */ +function getExportEntryPoint(value: string | ExportData): string | undefined { + return typeof value === "string" ? value : (value.import ?? value.default); } async function readLibraryPackageJson(path: string): Promise { const file = await readFile(join(path, "package.json")); From a96eca2b959bfd1960055433d0c4442bfa8d4846 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 26 Jun 2026 11:09:58 -0400 Subject: [PATCH 11/13] Generate emitter options docs from TypeSpec model in tspd Add model-based emitter-options extraction so tspd reads options/main.tsp when no legacy JSON-schema validator is present, restoring the options reference sections for migrated emitters (openapi3, json-schema). --- ...ter-options-model-docs-2026-6-26-11-0-0.md | 7 + .../src/core/emitter-options/validator.ts | 2 +- packages/json-schema/README.md | 27 +- packages/json-schema/options/main.tsp | 6 +- packages/openapi3/README.md | 66 ++--- packages/openapi3/options/main.tsp | 12 +- packages/tspd/src/ref-doc/extractor.ts | 250 ++++++++++++++++++ .../ref-doc/emitter-options-model.test.ts | 124 +++++++++ .../emitters/json-schema/reference/emitter.md | 27 +- .../emitters/openapi3/reference/emitter.md | 66 ++--- 10 files changed, 468 insertions(+), 119 deletions(-) create mode 100644 .chronus/changes/tspd-emitter-options-model-docs-2026-6-26-11-0-0.md create mode 100644 packages/tspd/test/ref-doc/emitter-options-model.test.ts diff --git a/.chronus/changes/tspd-emitter-options-model-docs-2026-6-26-11-0-0.md b/.chronus/changes/tspd-emitter-options-model-docs-2026-6-26-11-0-0.md new file mode 100644 index 00000000000..a88b904a1aa --- /dev/null +++ b/.chronus/changes/tspd-emitter-options-model-docs-2026-6-26-11-0-0.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/tspd" +--- + +Generate emitter options reference docs from an emitter's TypeSpec `options/main.tsp` model when no legacy JSON-schema validator is present. diff --git a/packages/compiler/src/core/emitter-options/validator.ts b/packages/compiler/src/core/emitter-options/validator.ts index 7ddf37a33a3..c8b4eae8d38 100644 --- a/packages/compiler/src/core/emitter-options/validator.ts +++ b/packages/compiler/src/core/emitter-options/validator.ts @@ -30,7 +30,7 @@ export function validateEmitterOptions( ): readonly ValidationError[] { switch (type.kind) { case "Model": - if (isArrayModelType(program, type)) { + if (isArrayModelType(type)) { return validateArray(program, value, type); } return validateModel(program, value, type); diff --git a/packages/json-schema/README.md b/packages/json-schema/README.md index 1e3af685da6..1f917705f4f 100644 --- a/packages/json-schema/README.md +++ b/packages/json-schema/README.md @@ -65,33 +65,39 @@ See [Configuring output directory for more info](https://typespec.io/docs/handbo **Type:** `"yaml" | "json"` Serialize the schema as either yaml or json. +Default `yaml`, if not specified infer from the `output-file` extension. ### `int64-strategy` **Type:** `"string" | "number"` -How to handle 64 bit integers on the wire. Options are: +How to handle 64-bit integers on the wire. Options are: -- string: serialize as a string (widely interoperable) -- number: serialize as a number (not widely interoperable) +- string: Serialize as a string (widely interoperable) +- number: Serialize as a number (not widely interoperable) ### `bundleId` **Type:** `string` -When provided, bundle all the schemas into a single json schema document with schemas under $defs. The provided id is the id of the root document and is also used for the file name. +When provided, bundle all the schemas into a single JSON Schema document +with schemas under $defs. The provided id is the id of the root document +and is also used for the file name. ### `emitAllModels` **Type:** `boolean` -When true, emit all model declarations to JSON Schema without requiring the @jsonSchema decorator. +When true, emit all model declarations to JSON Schema without requiring +the `@jsonSchema` decorator. ### `emitAllRefs` **Type:** `boolean` -When true, emit all references as json schema files, even if the referenced type does not have the `@jsonSchema` decorator or is not within a namespace with the `@jsonSchema` decorator. +When true, emit all references as JSON Schema files, even if the referenced +type does not have the `@jsonSchema` decorator or is not within a namespace +with the `@jsonSchema` decorator. ### `seal-object-schemas` @@ -101,7 +107,6 @@ When true, emit all references as json schema files, even if the referenced type If true, then for models emitted as object schemas we default `unevaluatedProperties` to `{ not: {} }`, if not explicitly specified elsewhere. -Default: `false` ### `polymorphic-models-strategy` @@ -109,16 +114,12 @@ Default: `false` **Default:** `"ignore"` -Strategy for emitting models with the @discriminator decorator: +Strategy for emitting models with the discriminator decorator. -- ignore: Emit as regular object schema (default). Derived models use allOf to reference their base model. +- ignore: Emit as regular object schema (default) - oneOf: Emit a oneOf schema with references to all derived models (closed union) - anyOf: Emit an anyOf schema with references to all derived models (open union) -When using oneOf or anyOf, derived models will inline all properties from their base model -instead of using allOf references. This avoids circular references in the generated schemas, -since the base model references derived models via oneOf/anyOf. - ## Decorators ### TypeSpec.JsonSchema diff --git a/packages/json-schema/options/main.tsp b/packages/json-schema/options/main.tsp index 0a4b82e4498..dad6fb22dcc 100644 --- a/packages/json-schema/options/main.tsp +++ b/packages/json-schema/options/main.tsp @@ -6,7 +6,7 @@ import "@typespec/compiler/emitter"; model EmitterOptions { /** * Serialize the schema as either yaml or json. - * @defaultValue yaml it not specified infer from the `output-file` extension + * Default `yaml`, if not specified infer from the `output-file` extension. */ `file-type`?: FileType; @@ -41,7 +41,7 @@ model EmitterOptions { /** * If true, then for models emitted as object schemas we default `unevaluatedProperties` to `{ not: {} }`, * if not explicitly specified elsewhere. - * @defaultValue false + * @default false */ `seal-object-schemas`?: boolean; @@ -50,7 +50,7 @@ model EmitterOptions { * - ignore: Emit as regular object schema (default) * - oneOf: Emit a oneOf schema with references to all derived models (closed union) * - anyOf: Emit an anyOf schema with references to all derived models (open union) - * @defaultValue "ignore" + * @default "ignore" */ `polymorphic-models-strategy`?: PolymorphicModelsStrategy; } diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index a9f822d64a7..687ad755ba0 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -46,7 +46,9 @@ See [Configuring output directory for more info](https://typespec.io/docs/handbo **Type:** `"yaml" | "json" | ("yaml" | "json")[]` -If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple formats. Default 'yaml', if not specified infer from the `output-file` extension +If the content should be serialized as YAML or JSON. +Can be a single value or an array to emit multiple formats. +Default `yaml`, if not specified infer from the `output-file` extension. **Options:** @@ -65,29 +67,8 @@ Output file will interpolate the following values: - version: Version of the service if multiple - file-type: The file type being emitted (json or yaml). Useful when `file-type` is an array. -Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"` -When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}` - -Example Single service no versioning - -- `openapi.yaml` - -Example Multiple services no versioning - -- `openapi.Org1.Service1.yaml` -- `openapi.Org1.Service2.yaml` - -Example Single service with versioning - -- `openapi.v1.yaml` -- `openapi.v2.yaml` - -Example Multiple service with versioning - -- `openapi.Org1.Service1.v1.yaml` -- `openapi.Org1.Service1.v2.yaml` -- `openapi.Org1.Service2.v1.0.yaml` -- `openapi.Org1.Service2.v1.1.yaml` +Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`. +When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}`. ### `openapi-versions` @@ -95,6 +76,10 @@ Example Multiple service with versioning **Default:** `["3.0.0"]` +The Open API specification versions to emit. +If more than one version is specified, then the output file +will be created inside a directory matching each specification version. + ### `new-line` **Type:** `"crlf" | "lf"` @@ -130,8 +115,6 @@ How to handle safeint type. Options are: - `double-int`: Will produce `type: integer, format: double-int` - `int64`: Will produce `type: integer, format: int64` -Default: `int64` - ### `seal-object-schemas` **Type:** `boolean` @@ -140,37 +123,37 @@ Default: `int64` If true, then for models emitted as object schemas we default `additionalProperties` to false for OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere. -Default: `false` ### `experimental-parameter-examples` **Type:** `"data" | "serialized"` Determines how to emit examples on parameters. + Note: This is an experimental feature and may change in future versions. -See https://spec.openapis.org/oas/v3.0.4.html#style-examples for parameter example serialization rules -See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples. ### `operation-id-strategy` **Type:** `"parent-container" | "fqn" | "explicit-only" | object { kind, separator }` -**Options:** - -- `"parent-container" | "fqn" | "explicit-only"` (default: `"parent-container"`) +**Default:** `"parent-container"` - Determines how to generate operation IDs when `@operationId` is not used. - Avaliable options are: +How should operation ID be generated when `@operationId` is not used. +Available options are -- `parent-container`: Uses the parent namespace and operation name to generate the ID. -- `fqn`: Uses the fully qualified name of the operation to generate the ID. +- `parent-container`: Uses the parent namespace/interface and operation name to generate the ID. +- `fqn`: Uses the fully qualified name(from service root) of the operation to generate the ID. - `explicit-only`: Only use explicitly defined operation IDs. + +**Options:** + +- `"parent-container" | "fqn" | "explicit-only"` - `object { kind, separator }` -| Name | Type | Default | Description | -| ----------- | ------------------------------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `kind` | `"parent-container" \| "fqn" \| "explicit-only"` | `"parent-container"` | Determines how to generate operation IDs when `@operationId` is not used.
Avaliable options are:
- `parent-container`: Uses the parent namespace and operation name to generate the ID.
- `fqn`: Uses the fully qualified name of the operation to generate the ID.
- `explicit-only`: Only use explicitly defined operation IDs. | -| `separator` | `string` | | Separator used to join segment in the operation name. | +| Name | Type | Default | Description | +| ----------- | ------------------------------------------------ | ------- | ----------------------------------------------------- | +| `kind` | `"parent-container" \| "fqn" \| "explicit-only"` | | Strategy used to generate the operation ID. | +| `separator` | `string` | | Separator used to join segment in the operation name. | ### `enum-strategy` @@ -182,8 +165,7 @@ How to emit TypeSpec enums. Options are: - `default`: Emit as a single schema using the `enum` keyword. - `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description` - from each member's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern. - Only supported by OpenAPI 3.1.0 and above; on 3.0.0 the `default` style is used and a warning is reported. + from each member's `@summary` and `@doc`. Only supported by OpenAPI 3.1.0 and above. ## Decorators diff --git a/packages/openapi3/options/main.tsp b/packages/openapi3/options/main.tsp index 6910525a500..2b3e94430cb 100644 --- a/packages/openapi3/options/main.tsp +++ b/packages/openapi3/options/main.tsp @@ -4,7 +4,7 @@ model EmitterOptions { /** * If the content should be serialized as YAML or JSON. * Can be a single value or an array to emit multiple formats. - * @default yaml, it not specified infer from the `output-file` extension + * Default `yaml`, if not specified infer from the `output-file` extension. */ `file-type`?: FileType | FileType[]; @@ -14,8 +14,10 @@ model EmitterOptions { * - service-name: Name of the service * - service-name-if-multiple: Name of the service if multiple * - version: Version of the service if multiple + * - file-type: The file type being emitted (json or yaml). Useful when `file-type` is an array. * - * @default `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if {@link OpenAPI3EmitterOptions["file-type"]} is `"json"` + * Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`. + * When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}`. * * @example Single service no versioning * - `openapi.yaml` @@ -41,14 +43,14 @@ model EmitterOptions { * If more than one version is specified, then the output file * will be created inside a directory matching each specification version. * - * @default ["v3.0"] + * @default ["3.0.0"] * @internal */ `openapi-versions`?: OpenAPIVersion[]; /** * Set the newline character for emitting files. - * @default lf + * @default "lf" */ `new-line`?: "crlf" | "lf"; @@ -95,7 +97,7 @@ model EmitterOptions { * - `parent-container`: Uses the parent namespace/interface and operation name to generate the ID. * - `fqn`: Uses the fully qualified name(from service root) of the operation to generate the ID. * - `explicit-only`: Only use explicitly defined operation IDs. - * @default parent-container + * @default "parent-container" */ `operation-id-strategy`?: | OperationIdStrategy diff --git a/packages/tspd/src/ref-doc/extractor.ts b/packages/tspd/src/ref-doc/extractor.ts index 55614f5d95a..3ec6b025178 100644 --- a/packages/tspd/src/ref-doc/extractor.ts +++ b/packages/tspd/src/ref-doc/extractor.ts @@ -13,6 +13,7 @@ import { getSourceLocation, getTypeName, Interface, + isArrayModelType, isDeclaredType, isTemplateDeclaration, joinPaths, @@ -122,6 +123,20 @@ export async function extractLibraryRefDocs( refDoc.emitter = { options: extractEmitterOptionsRefDoc(lib.emitter.options), }; + } else { + // No legacy JSON-schema options: look for a `./options` export pointing at a + // TypeSpec model (the `EmitterOptions` model) and extract the options from it. + const optionsEntry = getExport(pkgJson, "./options", "typespec"); + if (optionsEntry) { + const options = await extractEmitterOptionsRefDocFromTypeSpec( + libraryPath, + optionsEntry, + diagnostics, + ); + if (options) { + refDoc.emitter = { options }; + } + } } const linter = entrypoint.$linter; if (lib && linter) { @@ -781,6 +796,241 @@ function extractEmitterOptionsRefDoc( }); } +/** + * Extract emitter option ref docs from an emitter that declares its options as a + * TypeSpec model. The `./options` export is expected to point at a TypeSpec entrypoint + * defining an `EmitterOptions` model. + */ +async function extractEmitterOptionsRefDocFromTypeSpec( + libraryPath: string, + tspEntry: string, + diagnostics: { add: (d: Diagnostic) => void }, +): Promise { + const main = resolvePath(libraryPath, tspEntry); + let program: Program; + try { + program = await compile(NodeHost, main, { + parseOptions: { comments: true, docs: true }, + }); + } catch { + return undefined; + } + for (const diag of program.diagnostics ?? []) { + diagnostics.add(diag); + } + + const model = program.getGlobalNamespaceType().models.get("EmitterOptions"); + if (model === undefined) { + return undefined; + } + + return extractEmitterOptionsRefDocFromModel(program, model); +} + +/** + * Extract emitter option ref docs from an `EmitterOptions` TypeSpec model, mapping each + * model property to an {@link EmitterOptionRefDoc}. + */ +export function extractEmitterOptionsRefDocFromModel( + program: Program, + model: Model, +): EmitterOptionRefDoc[] { + return [...model.properties.values()].map((prop) => + extractEmitterOptionFromModelProperty(program, prop), + ); +} + +interface OptionTypeDescription { + type: string; + allowedValues?: string[]; + nestedOptions?: EmitterOptionRefDoc[]; + variants?: EmitterOptionVariantRefDoc[]; +} + +function extractEmitterOptionFromModelProperty( + program: Program, + prop: ModelProperty, +): EmitterOptionRefDoc { + const desc = describeEmitterOptionType(program, prop.type); + const option: Mutable = { + name: prop.name, + type: desc.type, + doc: getDoc(program, prop) ?? "", + }; + + if (desc.allowedValues) option.allowedValues = desc.allowedValues; + if (desc.nestedOptions) option.nestedOptions = desc.nestedOptions; + if (desc.variants) option.variants = desc.variants; + + const defaultValue = getOptionDefaultDoc(prop); + if (defaultValue !== undefined) option.default = defaultValue; + + const deprecated = getDeprecated(program, prop); + if (deprecated !== undefined) option.deprecated = deprecated; + + return option; +} + +/** Read the `@default` doc tag value (verbatim) from a type's doc comment, if present. */ +function getOptionDefaultDoc(type: Type): string | undefined { + for (const doc of type.node?.docs ?? []) { + for (const tag of doc.tags) { + if (tag.kind === SyntaxKind.DocUnknownTag && tag.tagName.sv === "default") { + const content = getDocContent(tag.content).trim(); + return content.length > 0 ? content : undefined; + } + } + } + return undefined; +} + +function describeEmitterOptionType(program: Program, type: Type): OptionTypeDescription { + switch (type.kind) { + case "Scalar": + return { type: scalarToOptionType(type) }; + case "String": + case "Number": + case "Boolean": + case "Enum": { + const values = literalOptionValues(type)!; + return { type: values.join(" | "), allowedValues: values }; + } + case "Model": { + if (isArrayModelType(type)) { + const element = type.indexer!.value; + const elementValues = literalOptionValues(element); + if (elementValues) { + return { type: `(${elementValues.join(" | ")})[]`, allowedValues: elementValues }; + } + return { type: `${optionTypeToString(element)}[]` }; + } + return { + type: `object { ${[...type.properties.keys()].join(", ")} }`, + nestedOptions: [...type.properties.values()].map((p) => + extractEmitterOptionFromModelProperty(program, p), + ), + }; + } + case "Union": { + const variantTypes = [...type.variants.values()].map((v) => v.type); + const literals = variantTypes.filter((v) => isLiteralOptionType(v)); + const complex = variantTypes.filter((v) => !isLiteralOptionType(v)); + + if (complex.length === 0) { + const values = literalOptionValues(type)!; + return { type: values.join(" | "), allowedValues: values }; + } + + const variants: EmitterOptionVariantRefDoc[] = []; + const typeParts: string[] = []; + const literalValues = literals.flatMap((l) => literalOptionValues(l) ?? []); + if (literalValues.length > 0) { + variants.push({ type: literalValues.join(" | "), allowedValues: literalValues }); + typeParts.push(literalValues.join(" | ")); + } + for (const variantType of complex) { + const desc = describeEmitterOptionType(program, variantType); + // Complex variants (arrays/objects/scalars) display their full type string; + // do not copy `allowedValues` (which would hide e.g. the array brackets). + const variant: Mutable = { type: desc.type }; + if (desc.nestedOptions) variant.nestedOptions = desc.nestedOptions; + variants.push(variant); + typeParts.push(optionTypeToString(variantType)); + } + return { type: typeParts.join(" | "), variants }; + } + default: + return { type: getTypeName(type) }; + } +} + +/** Map a TypeSpec scalar to a simple JSON-schema-like type name for documentation. */ +function scalarToOptionType(type: Scalar): string { + let scalar: Scalar | undefined = type; + while (scalar) { + switch (scalar.name) { + case "boolean": + return "boolean"; + case "string": + case "url": + return "string"; + case "numeric": + case "integer": + case "float": + case "decimal": + return "number"; + } + scalar = scalar.baseScalar; + } + return type.name; +} + +/** Whether a type is a literal, an enum, or a union of only literals/enums. */ +function isLiteralOptionType(type: Type): boolean { + switch (type.kind) { + case "String": + case "Number": + case "Boolean": + case "Enum": + case "EnumMember": + return true; + case "Union": + return [...type.variants.values()].every((v) => isLiteralOptionType(v.type)); + default: + return false; + } +} + +/** Return the list of quoted allowed values for a literal/enum/literal-union type. */ +function literalOptionValues(type: Type): string[] | undefined { + switch (type.kind) { + case "String": + return [`"${type.value}"`]; + case "Number": + return [String(type.value)]; + case "Boolean": + return [String(type.value)]; + case "EnumMember": + return [typeof type.value === "string" ? `"${type.value}"` : String(type.value ?? type.name)]; + case "Enum": + return [...type.members.values()].flatMap((m) => literalOptionValues(m) ?? []); + case "Union": { + const all = [...type.variants.values()].map((v) => literalOptionValues(v.type)); + if (all.every((x) => x !== undefined)) { + return all.flat() as string[]; + } + return undefined; + } + default: + return undefined; + } +} + +function optionTypeToString(type: Type): string { + switch (type.kind) { + case "String": + return `"${type.value}"`; + case "Number": + return String(type.value); + case "Boolean": + return String(type.value); + case "Scalar": + return scalarToOptionType(type); + case "Enum": + return literalOptionValues(type)!.join(" | "); + case "Union": + return [...type.variants.values()].map((v) => optionTypeToString(v.type)).join(" | "); + case "Model": + if (isArrayModelType(type)) { + const element = optionTypeToString(type.indexer!.value); + return element.includes("|") ? `(${element})[]` : `${element}[]`; + } + return `object { ${[...type.properties.keys()].join(", ")} }`; + default: + return getTypeName(type); + } +} + function extractEmitterOptionInfo(name: string, prop: any): EmitterOptionRefDoc { // Handle oneOf: extract variants if (prop.oneOf) { diff --git a/packages/tspd/test/ref-doc/emitter-options-model.test.ts b/packages/tspd/test/ref-doc/emitter-options-model.test.ts new file mode 100644 index 00000000000..6aaa3359086 --- /dev/null +++ b/packages/tspd/test/ref-doc/emitter-options-model.test.ts @@ -0,0 +1,124 @@ +import { Model, resolvePath } from "@typespec/compiler"; +import { createTester } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { extractEmitterOptionsRefDocFromModel } from "../../src/ref-doc/extractor.js"; + +const Tester = createTester(resolvePath(import.meta.dirname, "..", ".."), { + libraries: [], +}); + +async function extractOptions(code: string) { + const [{ program }] = await Tester.compileAndDiagnose(code); + const model = program.getGlobalNamespaceType().models.get("EmitterOptions"); + if (model === undefined) { + throw new Error("Expected an `EmitterOptions` model in the compiled program"); + } + return extractEmitterOptionsRefDocFromModel(program, model as Model); +} + +describe("ref-doc: emitter options from TypeSpec model", () => { + it("maps scalar and boolean options", async () => { + const options = await extractOptions(` + model EmitterOptions { + name?: string; + flag?: boolean; + } + `); + expect(options).toEqual([ + { name: "name", type: "string", doc: "" }, + { name: "flag", type: "boolean", doc: "" }, + ]); + }); + + it("maps a union of string literals to allowed values", async () => { + const options = await extractOptions(` + model EmitterOptions { + strategy?: "a" | "b" | "c"; + } + `); + expect(options[0]).toEqual({ + name: "strategy", + type: `"a" | "b" | "c"`, + doc: "", + allowedValues: [`"a"`, `"b"`, `"c"`], + }); + }); + + it("reads doc and @default tag", async () => { + const options = await extractOptions(` + model EmitterOptions { + /** + * The newline character. + * @default "lf" + */ + \`new-line\`?: "crlf" | "lf"; + } + `); + expect(options[0]).toMatchObject({ + name: "new-line", + type: `"crlf" | "lf"`, + doc: "The newline character.", + default: `"lf"`, + allowedValues: [`"crlf"`, `"lf"`], + }); + }); + + it("maps an array of literals using allowed values", async () => { + const options = await extractOptions(` + model EmitterOptions { + versions?: ("3.0.0" | "3.1.0")[]; + } + `); + expect(options[0]).toEqual({ + name: "versions", + type: `("3.0.0" | "3.1.0")[]`, + doc: "", + allowedValues: [`"3.0.0"`, `"3.1.0"`], + }); + }); + + it("maps a union of literals and an array to variants", async () => { + const options = await extractOptions(` + model EmitterOptions { + \`file-type\`?: ("yaml" | "json") | ("yaml" | "json")[]; + } + `); + expect(options[0]).toMatchObject({ + name: "file-type", + type: `"yaml" | "json" | ("yaml" | "json")[]`, + variants: [ + { type: `"yaml" | "json"`, allowedValues: [`"yaml"`, `"json"`] }, + { type: `("yaml" | "json")[]` }, + ], + }); + }); + + it("maps a union of literals and an object to variants with nested options", async () => { + const options = await extractOptions(` + model EmitterOptions { + strategy?: "fqn" | { + kind: "fqn" | "explicit-only"; + separator?: string; + }; + } + `); + const option = options[0]; + expect(option.name).toEqual("strategy"); + expect(option.type).toEqual(`"fqn" | object { kind, separator }`); + expect(option.variants?.[0]).toEqual({ + type: `"fqn"`, + allowedValues: [`"fqn"`], + }); + const objectVariant = option.variants?.[1]; + expect(objectVariant?.type).toEqual("object { kind, separator }"); + expect(objectVariant?.nestedOptions).toEqual([ + { + name: "kind", + type: `"fqn" | "explicit-only"`, + doc: "", + allowedValues: [`"fqn"`, `"explicit-only"`], + }, + { name: "separator", type: "string", doc: "" }, + ]); + }); +}); diff --git a/website/src/content/docs/docs/emitters/json-schema/reference/emitter.md b/website/src/content/docs/docs/emitters/json-schema/reference/emitter.md index 609cc318b8f..0e3f1f994f8 100644 --- a/website/src/content/docs/docs/emitters/json-schema/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/json-schema/reference/emitter.md @@ -41,33 +41,39 @@ See [Configuring output directory for more info](https://typespec.io/docs/handbo **Type:** `"yaml" | "json"` Serialize the schema as either yaml or json. +Default `yaml`, if not specified infer from the `output-file` extension. ### `int64-strategy` **Type:** `"string" | "number"` -How to handle 64 bit integers on the wire. Options are: +How to handle 64-bit integers on the wire. Options are: -- string: serialize as a string (widely interoperable) -- number: serialize as a number (not widely interoperable) +- string: Serialize as a string (widely interoperable) +- number: Serialize as a number (not widely interoperable) ### `bundleId` **Type:** `string` -When provided, bundle all the schemas into a single json schema document with schemas under $defs. The provided id is the id of the root document and is also used for the file name. +When provided, bundle all the schemas into a single JSON Schema document +with schemas under $defs. The provided id is the id of the root document +and is also used for the file name. ### `emitAllModels` **Type:** `boolean` -When true, emit all model declarations to JSON Schema without requiring the @jsonSchema decorator. +When true, emit all model declarations to JSON Schema without requiring +the `@jsonSchema` decorator. ### `emitAllRefs` **Type:** `boolean` -When true, emit all references as json schema files, even if the referenced type does not have the `@jsonSchema` decorator or is not within a namespace with the `@jsonSchema` decorator. +When true, emit all references as JSON Schema files, even if the referenced +type does not have the `@jsonSchema` decorator or is not within a namespace +with the `@jsonSchema` decorator. ### `seal-object-schemas` @@ -77,7 +83,6 @@ When true, emit all references as json schema files, even if the referenced type If true, then for models emitted as object schemas we default `unevaluatedProperties` to `{ not: {} }`, if not explicitly specified elsewhere. -Default: `false` ### `polymorphic-models-strategy` @@ -85,12 +90,8 @@ Default: `false` **Default:** `"ignore"` -Strategy for emitting models with the @discriminator decorator: +Strategy for emitting models with the discriminator decorator. -- ignore: Emit as regular object schema (default). Derived models use allOf to reference their base model. +- ignore: Emit as regular object schema (default) - oneOf: Emit a oneOf schema with references to all derived models (closed union) - anyOf: Emit an anyOf schema with references to all derived models (open union) - -When using oneOf or anyOf, derived models will inline all properties from their base model -instead of using allOf references. This avoids circular references in the generated schemas, -since the base model references derived models via oneOf/anyOf. diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md index 39f7fc1e342..1bf96420097 100644 --- a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md @@ -40,7 +40,9 @@ See [Configuring output directory for more info](https://typespec.io/docs/handbo **Type:** `"yaml" | "json" | ("yaml" | "json")[]` -If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple formats. Default 'yaml', if not specified infer from the `output-file` extension +If the content should be serialized as YAML or JSON. +Can be a single value or an array to emit multiple formats. +Default `yaml`, if not specified infer from the `output-file` extension. **Options:** @@ -59,29 +61,8 @@ Output file will interpolate the following values: - version: Version of the service if multiple - file-type: The file type being emitted (json or yaml). Useful when `file-type` is an array. -Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"` -When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}` - -Example Single service no versioning - -- `openapi.yaml` - -Example Multiple services no versioning - -- `openapi.Org1.Service1.yaml` -- `openapi.Org1.Service2.yaml` - -Example Single service with versioning - -- `openapi.v1.yaml` -- `openapi.v2.yaml` - -Example Multiple service with versioning - -- `openapi.Org1.Service1.v1.yaml` -- `openapi.Org1.Service1.v2.yaml` -- `openapi.Org1.Service2.v1.0.yaml` -- `openapi.Org1.Service2.v1.1.yaml` +Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`. +When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}`. ### `openapi-versions` @@ -89,6 +70,10 @@ Example Multiple service with versioning **Default:** `["3.0.0"]` +The Open API specification versions to emit. +If more than one version is specified, then the output file +will be created inside a directory matching each specification version. + ### `new-line` **Type:** `"crlf" | "lf"` @@ -124,8 +109,6 @@ How to handle safeint type. Options are: - `double-int`: Will produce `type: integer, format: double-int` - `int64`: Will produce `type: integer, format: int64` -Default: `int64` - ### `seal-object-schemas` **Type:** `boolean` @@ -134,37 +117,37 @@ Default: `int64` If true, then for models emitted as object schemas we default `additionalProperties` to false for OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere. -Default: `false` ### `experimental-parameter-examples` **Type:** `"data" | "serialized"` Determines how to emit examples on parameters. + Note: This is an experimental feature and may change in future versions. -See https://spec.openapis.org/oas/v3.0.4.html#style-examples for parameter example serialization rules -See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples. ### `operation-id-strategy` **Type:** `"parent-container" | "fqn" | "explicit-only" | object { kind, separator }` -**Options:** - -- `"parent-container" | "fqn" | "explicit-only"` (default: `"parent-container"`) +**Default:** `"parent-container"` - Determines how to generate operation IDs when `@operationId` is not used. - Avaliable options are: +How should operation ID be generated when `@operationId` is not used. +Available options are -- `parent-container`: Uses the parent namespace and operation name to generate the ID. -- `fqn`: Uses the fully qualified name of the operation to generate the ID. +- `parent-container`: Uses the parent namespace/interface and operation name to generate the ID. +- `fqn`: Uses the fully qualified name(from service root) of the operation to generate the ID. - `explicit-only`: Only use explicitly defined operation IDs. + +**Options:** + +- `"parent-container" | "fqn" | "explicit-only"` - `object { kind, separator }` -| Name | Type | Default | Description | -| ----------- | ------------------------------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `kind` | `"parent-container" \| "fqn" \| "explicit-only"` | `"parent-container"` | Determines how to generate operation IDs when `@operationId` is not used.
Avaliable options are:
- `parent-container`: Uses the parent namespace and operation name to generate the ID.
- `fqn`: Uses the fully qualified name of the operation to generate the ID.
- `explicit-only`: Only use explicitly defined operation IDs. | -| `separator` | `string` | | Separator used to join segment in the operation name. | +| Name | Type | Default | Description | +| ----------- | ------------------------------------------------ | ------- | ----------------------------------------------------- | +| `kind` | `"parent-container" \| "fqn" \| "explicit-only"` | | Strategy used to generate the operation ID. | +| `separator` | `string` | | Separator used to join segment in the operation name. | ### `enum-strategy` @@ -176,5 +159,4 @@ How to emit TypeSpec enums. Options are: - `default`: Emit as a single schema using the `enum` keyword. - `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description` - from each member's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern. - Only supported by OpenAPI 3.1.0 and above; on 3.0.0 the `default` style is used and a warning is reported. + from each member's `@summary` and `@doc`. Only supported by OpenAPI 3.1.0 and above. From e55446e893a57fcad7c0dacd6c5fe8d6534b70a2 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 26 Jun 2026 14:40:56 -0400 Subject: [PATCH 12/13] Harden emitter-options TypeSpec feature with per-emitter opt-in Gate TypeSpec-model option validation behind a per-emitter experimentalEmitterOptions package flag, broaden validator scalar support, restore absolutePath parity, register diagnostics, scope resolveTypeReference to the graph checker, and generate option TS types/docs from the model. --- ...ter-options-typegraph-2026-6-25-12-51-0.md | 2 +- packages/compiler/src/core/emitter-options.ts | 31 +- .../core/emitter-options/validator.test.ts | 90 +++++- .../src/core/emitter-options/validator.ts | 40 +++ packages/compiler/src/core/messages.ts | 12 + packages/compiler/src/core/program.ts | 49 ++- packages/compiler/src/core/types.ts | 10 +- .../test/core/emitter-options.test.ts | 38 +++ .../generated-defs/emitter-options.ts | 46 +++ packages/json-schema/package.json | 3 +- packages/json-schema/src/lib.ts | 56 +--- packages/openapi3/README.md | 31 +- .../generated-defs/emitter-options.ts | 85 ++++++ packages/openapi3/package.json | 3 +- packages/openapi3/src/index.ts | 2 +- packages/openapi3/src/lib.ts | 129 +------- packages/tspd/src/cli.ts | 36 +++ .../gen-emitter-options-types.ts | 235 +++++++++++++++ packages/tspd/src/ref-doc/extractor.ts | 92 +++++- .../gen-emitter-options-types.test.ts | 119 ++++++++ .../ref-doc/emitter-options-model.test.ts | 281 ++++++++++++------ .../emitters/openapi3/reference/emitter.md | 31 +- 22 files changed, 1086 insertions(+), 335 deletions(-) create mode 100644 packages/json-schema/generated-defs/emitter-options.ts create mode 100644 packages/openapi3/generated-defs/emitter-options.ts create mode 100644 packages/tspd/src/gen-emitter-options-types/gen-emitter-options-types.ts create mode 100644 packages/tspd/test/gen-emitter-options-types/gen-emitter-options-types.test.ts diff --git a/.chronus/changes/emitter-options-typegraph-2026-6-25-12-51-0.md b/.chronus/changes/emitter-options-typegraph-2026-6-25-12-51-0.md index 7dbd4e23211..79e405df093 100644 --- a/.chronus/changes/emitter-options-typegraph-2026-6-25-12-51-0.md +++ b/.chronus/changes/emitter-options-typegraph-2026-6-25-12-51-0.md @@ -4,4 +4,4 @@ packages: - "@typespec/compiler" --- -Introduce internal `TypeGraph` concept (a self-contained compilation result) and experimental support for defining emitter options as a TypeSpec file (`exports["./options"].typespec`). +Introduce internal `TypeGraph` concept (a self-contained compilation result) and experimental support for defining emitter options as a TypeSpec file (`exports["./options"].typespec`). Emitters opt into validating user options against their exported `EmitterOptions` model via the `experimentalEmitterOptions` package flag (`definePackageFlags`). diff --git a/packages/compiler/src/core/emitter-options.ts b/packages/compiler/src/core/emitter-options.ts index 730e6bc3f88..4fb72d45908 100644 --- a/packages/compiler/src/core/emitter-options.ts +++ b/packages/compiler/src/core/emitter-options.ts @@ -57,15 +57,26 @@ export function validateEmitterOptionsAgainstModel( target: EmitterOptionsConfigTarget | typeof NoTarget, ): readonly Diagnostic[] { const errors = validateEmitterOptions(program, options, model); - return errors.map( - (error): Diagnostic => ({ - severity: "error", + return errors.map((error): Diagnostic => { + const diagnosticTarget = + target === NoTarget + ? NoTarget + : getLocationInYamlScript(target.script, [...target.basePath, ...error.target], "key"); + + // Re-emit the dedicated `config-path-absolute` diagnostic so options typed with the + // `absolutePath` scalar keep parity with the legacy JSON-schema `format: absolute-path`. + if (error.code === "config-path-absolute") { + return createDiagnostic({ + code: "config-path-absolute", + format: { path: error.value ?? "" }, + target: diagnosticTarget, + }); + } + + return createDiagnostic({ code: "invalid-emitter-options", - message: error.message, - target: - target === NoTarget - ? NoTarget - : getLocationInYamlScript(target.script, [...target.basePath, ...error.target], "key"), - }), - ); + format: { message: error.message }, + target: diagnosticTarget, + }); + }); } diff --git a/packages/compiler/src/core/emitter-options/validator.test.ts b/packages/compiler/src/core/emitter-options/validator.test.ts index de88d483561..c51e7ae66b0 100644 --- a/packages/compiler/src/core/emitter-options/validator.test.ts +++ b/packages/compiler/src/core/emitter-options/validator.test.ts @@ -24,14 +24,16 @@ describe("scalars", () => { expect(errors).toEqual([]); }); - describe("non supported scalars", () => { + describe("supported numeric scalars", () => { it.each([ ["int64", 1], ["uint64", 1], ["integer", 1], - ["float", 1], - ["decimal", 1], - ])("%s", async (typeStr, value) => { + ["float", 1.5], + ["decimal", 1.5], + ["numeric", 1], + ["safeint", 1], + ])("%s accepts a number", async (typeStr, value) => { const errors = await validateOptions( ` model EmitterOptions { @@ -39,14 +41,80 @@ describe("scalars", () => { }`, { prop: value }, ); - expect(errors).toEqual([ - { - code: "unsupported", - message: `${typeStr} is not supported for emitter options.`, - target: ["prop"], - }, - ]); + expect(errors).toEqual([]); }); + + it.each([["int64"], ["integer"], ["float"], ["numeric"]])( + "%s rejects a non-number", + async (typeStr) => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop: ${typeStr}; + }`, + { prop: "not a number" }, + ); + expect(errors).toEqual([ + { + code: "type-mismatch", + message: "Expected type number", + target: ["prop"], + }, + ]); + }, + ); + }); +}); + +describe("absolutePath", () => { + it("passes for an absolute path", async () => { + const errors = await validateOptions( + ` + scalar absolutePath extends string; + model EmitterOptions { + prop: absolutePath; + }`, + { prop: "/out/dir" }, + ); + expect(errors).toEqual([]); + }); + + it("errors for a relative path starting with `./`", async () => { + const errors = await validateOptions( + ` + scalar absolutePath extends string; + model EmitterOptions { + prop: absolutePath; + }`, + { prop: "./out" }, + ); + expect(errors).toEqual([ + { + code: "config-path-absolute", + message: `Path "./out" cannot be relative. Use {cwd} or {project-root} to specify what the path should be relative to.`, + target: ["prop"], + value: "./out", + }, + ]); + }); + + it("errors for a bare relative path", async () => { + const errors = await validateOptions( + ` + scalar absolutePath extends string; + model EmitterOptions { + prop: absolutePath; + }`, + { prop: "out" }, + ); + expect(errors).toEqual([ + { + code: "config-path-absolute", + message: `Path "out" cannot be relative. Use {cwd} or {project-root} to specify what the path should be relative to.`, + target: ["prop"], + value: "out", + }, + ]); }); }); diff --git a/packages/compiler/src/core/emitter-options/validator.ts b/packages/compiler/src/core/emitter-options/validator.ts index c8b4eae8d38..8d94c563657 100644 --- a/packages/compiler/src/core/emitter-options/validator.ts +++ b/packages/compiler/src/core/emitter-options/validator.ts @@ -1,4 +1,5 @@ import { getPattern } from "../../lib/decorators.js"; +import { isPathAbsolute } from "../path-utils.js"; import { Program } from "../program.js"; import { isArrayModelType } from "../type-utils.js"; import type { ArrayModelType, Enum, Model, Scalar, StdTypeName, Type, Union } from "../types.js"; @@ -7,18 +8,29 @@ export interface ValidationError { code: string; message: string; target: string[]; + /** Raw offending value, carried for codes that re-emit a specific diagnostic (e.g. `config-path-absolute`). */ + value?: string; } const knownScalarNames = new Set([ "string", + "url", "boolean", "bytes", + "numeric", + "integer", + "float", + "decimal", + "decimal128", "int8", "int16", "int32", + "int64", "uint8", "uint16", "uint32", + "uint64", + "safeint", "float32", "float64", ]); @@ -227,6 +239,25 @@ function collectLiteralValues( } function validateScalar(value: unknown, type: Scalar): readonly ValidationError[] { + // Special-case the built-in `absolutePath` scalar (from `@typespec/compiler/emitter`): + // it extends `string` but additionally requires the value to be an absolute path. This + // mirrors the legacy JSON-schema `format: absolute-path` validation. + for (let scalar: Scalar | undefined = type; scalar; scalar = scalar.baseScalar) { + if (scalar.name === "absolutePath") { + if (typeof value === "string" && (value.startsWith(".") || !isPathAbsolute(value))) { + return [ + { + code: "config-path-absolute", + message: `Path "${value}" cannot be relative. Use {cwd} or {project-root} to specify what the path should be relative to.`, + target: [], + value, + }, + ]; + } + break; + } + } + // Resolve custom scalars (e.g. `scalar absolutePath extends string`) to their // known built-in base so they validate against the underlying representation. let current: Scalar | undefined = type; @@ -252,15 +283,24 @@ function validateBuiltinScalar( ): readonly ValidationError[] { switch (name) { case "string": + case "url": return assertType(value, "string", target); case "boolean": return assertType(value, "boolean", target); + case "numeric": + case "integer": + case "float": + case "decimal": + case "decimal128": case "int8": case "int16": case "int32": + case "int64": case "uint8": case "uint16": case "uint32": + case "uint64": + case "safeint": case "float32": case "float64": return assertType(value, "number", target); diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 256c524c7d3..a281862ca83 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -779,6 +779,18 @@ const diagnostics = { default: "Emitter options should be a model type.", }, }, + "invalid-emitter-options": { + severity: "error", + messages: { + default: paramMessage`${"message"}`, + }, + }, + "invalid-emitter-options-definition": { + severity: "error", + messages: { + default: paramMessage`Emitter "${"emitter"}" has an invalid options definition:\n${"message"}`, + }, + }, /** * Binder diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index 348a7c56bae..33529662d9c 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -63,6 +63,7 @@ import { Namespace, NoTarget, Node, + PackageFlags, PerfReporter, SourceFile, Sym, @@ -419,6 +420,12 @@ async function createTypeGraph( } } + // NOTE: These resolve* closures use this graph's own `checker` (via `typeGraph.checker`) + // and `resolver` so that resolution stays scoped to the graph that produced them, even + // if `program.checker` is later repointed at a different graph (e.g. an emitter-options + // graph compiled during `loadEmitter`). Other `program` state (sourceFiles, resolver, + // stateMaps, ...) is still shared and mutated across graphs; full per-graph isolation is + // intentionally deferred. function resolveTypeReference(reference: string): [Type | undefined, readonly Diagnostic[]] { const [node, parseDiagnostics] = parseStandaloneTypeReference(reference); if (parseDiagnostics.length > 0) { @@ -428,7 +435,7 @@ async function createTypeGraph( binder.bindNode(node); mutate(node).parent = resolver.symbols.global.declarations[0]; resolver.resolveTypeReference(node); - return program.checker.resolveTypeReference(node); + return typeGraph.checker.resolveTypeReference(node); } function resolveTypeOrValueReference( @@ -442,7 +449,7 @@ async function createTypeGraph( binder.bindNode(node); mutate(node).parent = resolver.symbols.global.declarations[0]; resolver.resolveTypeReference(node); - return program.checker.resolveTypeOrValueReference(node); + return typeGraph.checker.resolveTypeOrValueReference(node); } } @@ -708,19 +715,51 @@ async function createProgram( program.reportDiagnostics(diagnostics); return; } - } else if (optionsEntrypoint) { + } else if ( + optionsEntrypoint && + (entrypoint.esmExports.$flags as PackageFlags | undefined)?.experimentalEmitterOptions + ) { // Emitter declares its options as a TypeSpec file (and has no legacy // validator): compile it into its own type graph and validate the user // options against the exported `EmitterOptions` model. + // + // Gated behind the per-emitter, experimental `experimentalEmitterOptions` + // package flag (`definePackageFlags`). When an emitter does not opt in this + // path is skipped entirely and such emitters get no options validation (the + // pre-existing behavior). const fullPath = resolvePath(library.module.path, optionsEntrypoint); + const diagnosticStart = program.diagnostics.length; const { typeGraph } = await createTypeGraph( program, fullPath, options, program.sourceFiles, ); - const [model, diagnostics] = resolveEmitterOptions(typeGraph); - program.reportDiagnostics(diagnostics); + const [model, optionDiagnostics] = resolveEmitterOptions(typeGraph); + + // Diagnostics produced while compiling the emitter's OWN options file are + // emitter-author problems, not user problems. If compiling it produced errors, + // collapse them into a single clearly-attributed diagnostic instead of leaking + // confusing library-internal diagnostics onto the user's program. + const optionsFileErrors = program.diagnostics + .slice(diagnosticStart) + .filter((d) => d.severity === "error"); + if (optionsFileErrors.length > 0) { + (program.diagnostics as Diagnostic[]).length = diagnosticStart; + program.reportDiagnostics([ + createDiagnostic({ + code: "invalid-emitter-options-definition", + format: { + emitter: metadata.name ?? emitterNameOrPath, + message: optionsFileErrors.map((d) => d.message).join("\n"), + }, + target: NoTarget, + }), + ]); + return; + } + + program.reportDiagnostics(optionDiagnostics); if (model) { const validationDiagnostics = validateEmitterOptionsAgainstModel( program, diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 50c36efbf4b..e0aac824f01 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2530,7 +2530,15 @@ export interface FunctionImplementations { }; } -export interface PackageFlags {} +export interface PackageFlags { + /** + * Opt into validating user-provided emitter options against the `EmitterOptions` TypeSpec + * model exported by this emitter (via the `./options` package export). This is an + * experimental, per-emitter opt-in: when omitted (the default) the emitter's TypeSpec + * options model is not used for runtime validation. + */ + readonly experimentalEmitterOptions?: boolean; +} export interface LinterDefinition { rules: LinterRuleDefinition[]; diff --git a/packages/compiler/test/core/emitter-options.test.ts b/packages/compiler/test/core/emitter-options.test.ts index cbed74c8855..775ef1b7ee9 100644 --- a/packages/compiler/test/core/emitter-options.test.ts +++ b/packages/compiler/test/core/emitter-options.test.ts @@ -137,6 +137,7 @@ describe("compiler: emitter options defined in TypeSpec", () => { async function diagnoseEmitterOptions( options: Record, + { optedIn = true }: { optedIn?: boolean } = {}, ): Promise { return Tester.files({ "node_modules/tsp-options-emitter/package.json": JSON.stringify({ @@ -154,6 +155,7 @@ describe("compiler: emitter options defined in TypeSpec", () => { "node_modules/tsp-options-emitter/index.js": mockFile.js({ $lib: tspOptionsEmitter, $onEmit: () => {}, + ...(optedIn ? { $flags: { experimentalEmitterOptions: true } } : {}), }), }).diagnose("", { compilerOptions: { @@ -198,4 +200,40 @@ describe("compiler: emitter options defined in TypeSpec", () => { message: `Value "xml" is not one of the allowed values: "yaml", "json"`, }); }); + + it("does not validate options when the emitter has not opted in", async () => { + const diagnostics = await diagnoseEmitterOptions( + { "not-an-option": true, count: "not a number" }, + { optedIn: false }, + ); + expectDiagnosticEmpty(diagnostics); + }); + + it("attributes errors in the emitter's own options file to the emitter author", async () => { + const diagnostics = await Tester.files({ + "node_modules/tsp-options-emitter/package.json": JSON.stringify({ + main: "index.js", + exports: { + ".": "./index.js", + "./options": { typespec: "./options.tsp" }, + }, + }), + "node_modules/tsp-options-emitter/options.tsp": `model EmitterOptions { + name?: NotARealType; + }`, + "node_modules/tsp-options-emitter/index.js": mockFile.js({ + $lib: tspOptionsEmitter, + $onEmit: () => {}, + $flags: { experimentalEmitterOptions: true }, + }), + }).diagnose("", { + compilerOptions: { + emit: ["tsp-options-emitter"], + options: { "tsp-options-emitter": {} }, + }, + }); + expectDiagnostics(diagnostics, { + code: "invalid-emitter-options-definition", + }); + }); }); diff --git a/packages/json-schema/generated-defs/emitter-options.ts b/packages/json-schema/generated-defs/emitter-options.ts new file mode 100644 index 00000000000..48e48a5f683 --- /dev/null +++ b/packages/json-schema/generated-defs/emitter-options.ts @@ -0,0 +1,46 @@ +/** + * Json schema emitter options + */ +export interface EmitterOptions { + /** + * Serialize the schema as either yaml or json. + * Default `yaml`, if not specified infer from the `output-file` extension. + */ + "file-type"?: "yaml" | "json"; + /** + * How to handle 64-bit integers on the wire. Options are: + * + * - string: Serialize as a string (widely interoperable) + * - number: Serialize as a number (not widely interoperable) + */ + "int64-strategy"?: "string" | "number"; + /** + * When provided, bundle all the schemas into a single JSON Schema document + * with schemas under $defs. The provided id is the id of the root document + * and is also used for the file name. + */ + bundleId?: string; + /** + * When true, emit all model declarations to JSON Schema without requiring + * the `@jsonSchema` decorator. + */ + emitAllModels?: boolean; + /** + * When true, emit all references as JSON Schema files, even if the referenced + * type does not have the `@jsonSchema` decorator or is not within a namespace + * with the `@jsonSchema` decorator. + */ + emitAllRefs?: boolean; + /** + * If true, then for models emitted as object schemas we default `unevaluatedProperties` to `{ not: {} }`, + * if not explicitly specified elsewhere. + */ + "seal-object-schemas"?: boolean; + /** + * Strategy for emitting models with the discriminator decorator. + * - ignore: Emit as regular object schema (default) + * - oneOf: Emit a oneOf schema with references to all derived models (closed union) + * - anyOf: Emit an anyOf schema with references to all derived models (open union) + */ + "polymorphic-models-strategy"?: "ignore" | "oneOf" | "anyOf"; +} diff --git a/packages/json-schema/package.json b/packages/json-schema/package.json index e97333caf63..66ac77afe63 100644 --- a/packages/json-schema/package.json +++ b/packages/json-schema/package.json @@ -39,9 +39,10 @@ }, "scripts": { "clean": "rimraf ./dist ./temp", - "build": "pnpm gen-extern-signature && tsc -p tsconfig.build.json && pnpm lint-typespec-library && pnpm api-extractor", + "build": "pnpm gen-extern-signature && pnpm gen-emitter-options-types && tsc -p tsconfig.build.json && pnpm lint-typespec-library && pnpm api-extractor", "watch": "tsc -p tsconfig.build.json --watch", "gen-extern-signature": "tspd --enable-experimental gen-extern-signature .", + "gen-emitter-options-types": "tspd --enable-experimental gen-emitter-options-types options/main.tsp --interface-name EmitterOptions --output-dir generated-defs", "lint-typespec-library": "tsp compile . --warn-as-error --import @typespec/library-linter --no-emit", "test": "vitest run", "test:ui": "vitest --ui", diff --git a/packages/json-schema/src/lib.ts b/packages/json-schema/src/lib.ts index b960a594cbc..46b5d38f8e9 100644 --- a/packages/json-schema/src/lib.ts +++ b/packages/json-schema/src/lib.ts @@ -24,57 +24,7 @@ export type PolymorphicModelsStrategy = "ignore" | "oneOf" | "anyOf"; /** * Json schema emitter options */ -export interface JSONSchemaEmitterOptions { - /** - * Serialize the schema as either yaml or json. - * @defaultValue yaml it not specified infer from the `output-file` extension - */ - "file-type"?: FileType; - - /** - * How to handle 64-bit integers on the wire. Options are: - * - * - string: Serialize as a string (widely interoperable) - * - number: Serialize as a number (not widely interoperable) - */ - "int64-strategy"?: Int64Strategy; - - /** - * When provided, bundle all the schemas into a single JSON Schema document - * with schemas under $defs. The provided id is the id of the root document - * and is also used for the file name. - */ - bundleId?: string; - - /** - * When true, emit all model declarations to JSON Schema without requiring - * the `@jsonSchema` decorator. - */ - emitAllModels?: boolean; - - /** - * When true, emit all references as JSON Schema files, even if the referenced - * type does not have the `@jsonSchema` decorator or is not within a namespace - * with the `@jsonSchema` decorator. - */ - emitAllRefs?: boolean; - - /** - * If true, then for models emitted as object schemas we default `unevaluatedProperties` to `{ not: {} }`, - * if not explicitly specified elsewhere. - * @defaultValue false - */ - "seal-object-schemas"?: boolean; - - /** - * Strategy for emitting models with the discriminator decorator. - * - ignore: Emit as regular object schema (default) - * - oneOf: Emit a oneOf schema with references to all derived models (closed union) - * - anyOf: Emit an anyOf schema with references to all derived models (open union) - * @defaultValue "ignore" - */ - "polymorphic-models-strategy"?: PolymorphicModelsStrategy; -} +export type { EmitterOptions as JSONSchemaEmitterOptions } from "../generated-defs/emitter-options.js"; /** Internal: TypeSpec library definition */ export const $lib = createTypeSpecLibrary({ @@ -138,7 +88,9 @@ export const $lib = createTypeSpecLibrary({ } as const); /** Internal: TypeSpec flags */ -export const $flags = definePackageFlags({}); +export const $flags = definePackageFlags({ + experimentalEmitterOptions: true, +}); export const { reportDiagnostic, createStateSymbol, stateKeys: JsonSchemaStateKeys } = $lib; diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index 687ad755ba0..5d71868503e 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -70,15 +70,26 @@ Output file will interpolate the following values: Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`. When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}`. -### `openapi-versions` +Example Single service no versioning -**Type:** `"3.0.0" | "3.1.0" | "3.2.0"` +- `openapi.yaml` -**Default:** `["3.0.0"]` +Example Multiple services no versioning -The Open API specification versions to emit. -If more than one version is specified, then the output file -will be created inside a directory matching each specification version. +- `openapi.Org1.Service1.yaml` +- `openapi.Org1.Service2.yaml` + +Example Single service with versioning + +- `openapi.v1.yaml` +- `openapi.v2.yaml` + +Example Multiple service with versioning + +- `openapi.Org1.Service1.v1.yaml` +- `openapi.Org1.Service1.v2.yaml` +- `openapi.Org1.Service2.v1.0.yaml` +- `openapi.Org1.Service2.v1.1.yaml` ### `new-line` @@ -150,10 +161,10 @@ Available options are - `"parent-container" | "fqn" | "explicit-only"` - `object { kind, separator }` -| Name | Type | Default | Description | -| ----------- | ------------------------------------------------ | ------- | ----------------------------------------------------- | -| `kind` | `"parent-container" \| "fqn" \| "explicit-only"` | | Strategy used to generate the operation ID. | -| `separator` | `string` | | Separator used to join segment in the operation name. | +| Name | Type | Default | Description | +| ----------- | ------------------------------------------------ | -------------------- | ----------------------------------------------------- | +| `kind` | `"parent-container" \| "fqn" \| "explicit-only"` | `"parent-container"` | Strategy used to generate the operation ID. | +| `separator` | `string` | | Separator used to join segment in the operation name. | ### `enum-strategy` diff --git a/packages/openapi3/generated-defs/emitter-options.ts b/packages/openapi3/generated-defs/emitter-options.ts new file mode 100644 index 00000000000..c6c4bf387c7 --- /dev/null +++ b/packages/openapi3/generated-defs/emitter-options.ts @@ -0,0 +1,85 @@ +export interface EmitterOptions { + /** + * If the content should be serialized as YAML or JSON. + * Can be a single value or an array to emit multiple formats. + * Default `yaml`, if not specified infer from the `output-file` extension. + */ + "file-type"?: "yaml" | "json" | ("yaml" | "json")[]; + /** + * Name of the output file. + * Output file will interpolate the following values: + * - service-name: Name of the service + * - service-name-if-multiple: Name of the service if multiple + * - version: Version of the service if multiple + * - file-type: The file type being emitted (json or yaml). Useful when `file-type` is an array. + * + * Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`. + * When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}`. + */ + "output-file"?: string; + /** + * The Open API specification versions to emit. + * If more than one version is specified, then the output file + * will be created inside a directory matching each specification version. + */ + "openapi-versions"?: ("3.0.0" | "3.1.0" | "3.2.0")[]; + /** + * Set the newline character for emitting files. + */ + "new-line"?: "crlf" | "lf"; + /** + * Omit unreachable types. + * By default all types declared under the service namespace will be included. With this flag on only types references in an operation will be emitted. + */ + "omit-unreachable-types"?: boolean; + /** + * If the generated openapi types should have the `x-typespec-name` extension set with the name of the TypeSpec type that created it. + * This extension is meant for debugging and should not be depended on. + */ + "include-x-typespec-name"?: "inline-only" | "never"; + /** + * How to handle safeint type. Options are: + * - `double-int`: Will produce `type: integer, format: double-int` + * - `int64`: Will produce `type: integer, format: int64` + */ + "safeint-strategy"?: "double-int" | "int64"; + /** + * If true, then for models emitted as object schemas we default `additionalProperties` to false for + * OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere. + */ + "seal-object-schemas"?: boolean; + /** + * Determines how to emit examples on parameters. + * + * Note: This is an experimental feature and may change in future versions. + */ + "experimental-parameter-examples"?: "data" | "serialized"; + /** + * How should operation ID be generated when `@operationId` is not used. + * Available options are + * - `parent-container`: Uses the parent namespace/interface and operation name to generate the ID. + * - `fqn`: Uses the fully qualified name(from service root) of the operation to generate the ID. + * - `explicit-only`: Only use explicitly defined operation IDs. + */ + "operation-id-strategy"?: + | "parent-container" + | "fqn" + | "explicit-only" + | { + /** + * Strategy used to generate the operation ID. + */ + kind: "parent-container" | "fqn" | "explicit-only"; + /** + * Separator used to join segment in the operation name. + */ + separator?: string; + }; + /** + * How to emit TypeSpec enums. Options are: + * - `default`: Emit as a single schema using the `enum` keyword. + * - `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description` + * from each member's `@summary` and `@doc`. Only supported by OpenAPI 3.1.0 and above. + */ + "enum-strategy"?: "default" | "annotated"; +} diff --git a/packages/openapi3/package.json b/packages/openapi3/package.json index 8f6e22c26e7..bd2de93dc51 100644 --- a/packages/openapi3/package.json +++ b/packages/openapi3/package.json @@ -44,10 +44,11 @@ }, "scripts": { "clean": "rimraf ./dist ./temp", - "build": "pnpm gen-version && pnpm gen-extern-signature && pnpm quickbuild && pnpm lint-typespec-library", + "build": "pnpm gen-version && pnpm gen-extern-signature && pnpm gen-emitter-options-types && pnpm quickbuild && pnpm lint-typespec-library", "quickbuild": "tsc -p tsconfig.build.json", "watch": "tsc -p tsconfig.build.json --watch", "gen-extern-signature": "tspd --enable-experimental gen-extern-signature .", + "gen-emitter-options-types": "tspd --enable-experimental gen-emitter-options-types options/main.tsp --interface-name EmitterOptions --output-dir generated-defs", "lint-typespec-library": "tsp compile . --warn-as-error --import @typespec/library-linter --no-emit", "test": "vitest run", "test:watch": "vitest -w", diff --git a/packages/openapi3/src/index.ts b/packages/openapi3/src/index.ts index c65e0b5de96..dd46756298c 100644 --- a/packages/openapi3/src/index.ts +++ b/packages/openapi3/src/index.ts @@ -2,7 +2,7 @@ export const namespace = "TypeSpec.OpenAPI"; export { convertOpenAPI3Document } from "./cli/actions/convert/convert.js"; export { $oneOf, $useRef, getOneOf, getRef } from "./decorators.js"; -export { $lib } from "./lib.js"; +export { $flags, $lib } from "./lib.js"; export { $onEmit, getOpenAPI3, diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index 8e512455179..65c8dafebfa 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -1,131 +1,11 @@ -import { createTypeSpecLibrary, paramMessage } from "@typespec/compiler"; +import { createTypeSpecLibrary, definePackageFlags, paramMessage } from "@typespec/compiler"; export type FileType = "yaml" | "json"; export type OpenAPIVersion = "3.0.0" | "3.1.0" | "3.2.0"; export type ExperimentalParameterExamplesStrategy = "data" | "serialized"; export type EnumStrategy = "default" | "annotated"; -export interface OpenAPI3EmitterOptions { - /** - * If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple file types. - * When an array is provided, the `{file-type}` variable can be used in `output-file` to produce distinct filenames. - * @default yaml, it not specified infer from the `output-file` extension - */ - - "file-type"?: FileType | FileType[]; - - /** - * Name of the output file. - * Output file will interpolate the following values: - * - service-name: Name of the service - * - service-name-if-multiple: Name of the service if multiple - * - version: Version of the service if multiple - * - * @default `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if {@link OpenAPI3EmitterOptions["file-type"]} is `"json"`. When `file-type` is an array, uses `{file-type}` variable. - * - * @example Single service no versioning - * - `openapi.yaml` - * - * @example Multiple services no versioning - * - `openapi.Org1.Service1.yaml` - * - `openapi.Org1.Service2.yaml` - * - * @example Single service with versioning - * - `openapi.v1.yaml` - * - `openapi.v2.yaml` - * - * @example Multiple service with versioning - * - `openapi.Org1.Service1.v1.yaml` - * - `openapi.Org1.Service1.v2.yaml` - * - `openapi.Org1.Service2.v1.0.yaml` - * - `openapi.Org1.Service2.v1.1.yaml` - */ - "output-file"?: string; - - /** - * The Open API specification versions to emit. - * If more than one version is specified, then the output file - * will be created inside a directory matching each specification version. - * - * @default ["3.0.0"] - */ - "openapi-versions"?: OpenAPIVersion[]; - - /** - * Set the newline character for emitting files. - * @default lf - */ - "new-line"?: "crlf" | "lf"; - - /** - * Omit unreachable types. - * By default all types declared under the service namespace will be included. With this flag on only types references in an operation will be emitted. - */ - "omit-unreachable-types"?: boolean; - - /** - * If the generated openapi types should have the `x-typespec-name` extension set with the name of the TypeSpec type that created it. - * This extension is meant for debugging and should not be depended on. - * @default "never" - */ - "include-x-typespec-name"?: "inline-only" | "never"; - - /** - * How to handle safeint type. Options are: - * - `double-int`: Will produce `type: integer, format: double-int` - * - `int64`: Will produce `type: integer, format: int64` - * @default "int64" - */ - "safeint-strategy"?: "double-int" | "int64"; - - /** - * If true, then for models emitted as object schemas we default `additionalProperties` to false for - * OpenAPI 3.0, and `unevaluatedProperties` to false for OpenAPI 3.1, if not explicitly specified elsewhere. - * @default false - */ - "seal-object-schemas"?: boolean; - - /** - * Determines how to emit examples on parameters. - * - * Note: This is an experimental feature and may change in future versions. - * @see https://spec.openapis.org/oas/v3.0.4.html#style-examples for parameter example serialization rules. - * @see https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion on handling parameter examples. - */ - "experimental-parameter-examples"?: ExperimentalParameterExamplesStrategy; - - /** - * How should operation ID be generated when `@operationId` is not used. - * Available options are - * - `parent-container`: Uses the parent namespace/interface and operation name to generate the ID. - * - `fqn`: Uses the fully qualified name(from service root) of the operation to generate the ID. - * - `explicit-only`: Only use explicitly defined operation IDs. - * @default parent-container - */ - "operation-id-strategy"?: - | OperationIdStrategy - | { - /** Strategy used to generate the operation ID. */ - kind: OperationIdStrategy; - /** Separator used to join segment in the operation name. */ - separator?: string; - }; - - /** - * How to emit TypeSpec enums. - * - * - `default`: Emit as a single schema using the `enum` keyword. - * - `annotated`: Emit as a `oneOf` of `const` subschemas, each annotated with `title` and `description` - * when the corresponding enum member has `@summary` or `@doc`. This follows the OpenAPI 3.1.1 - * [annotated enumerations](https://spec.openapis.org/oas/v3.1.1.html#annotated-enumerations) pattern. - * Only supported by OpenAPI 3.1.0 and above. When emitting OpenAPI 3.0.0, a warning will be reported - * and the `default` style will be used instead. - * - * @default "default" - */ - "enum-strategy"?: EnumStrategy; -} - export type OperationIdStrategy = "parent-container" | "fqn" | "explicit-only"; +export type { EmitterOptions as OpenAPI3EmitterOptions } from "../generated-defs/emitter-options.js"; export const $lib = createTypeSpecLibrary({ name: "@typespec/openapi3", @@ -294,4 +174,9 @@ export const $lib = createTypeSpecLibrary({ }); export const { createDiagnostic, reportDiagnostic, createStateSymbol } = $lib; +/** Internal: TypeSpec flags */ +export const $flags = definePackageFlags({ + experimentalEmitterOptions: true, +}); + export type OpenAPILibrary = typeof $lib; diff --git a/packages/tspd/src/cli.ts b/packages/tspd/src/cli.ts index 4ecdd3fa5d9..d5cddb160a8 100644 --- a/packages/tspd/src/cli.ts +++ b/packages/tspd/src/cli.ts @@ -2,6 +2,7 @@ import { NodeHost, logDiagnostics, resolvePath } from "@typespec/compiler"; import pc from "picocolors"; import yargs from "yargs"; +import { generateEmitterOptionsTypes } from "./gen-emitter-options-types/gen-emitter-options-types.js"; import { generateExternSignatures } from "./gen-extern-signatures/gen-extern-signatures.js"; import { generateLibraryDocs } from "./ref-doc/experimental.js"; @@ -130,6 +131,41 @@ async function main() { } }, ) + .command( + "gen-emitter-options-types ", + "Generate TypeScript emitter option types from a TypeSpec EmitterOptions model.", + (cmd) => { + return cmd + .positional("entrypoint", { + description: "Path to the emitter options entrypoint.", + type: "string", + demandOption: true, + }) + .option("output-dir", { + alias: "output", + description: "Directory where generated files should be written.", + type: "string", + default: "generated-defs", + }) + .option("interface-name", { + description: "Name of the exported TypeScript interface.", + type: "string", + default: "EmitterOptions", + }); + }, + async (args) => { + const resolvedMain = resolvePath(process.cwd(), args.entrypoint); + const outputDir = resolvePath(process.cwd(), args["output-dir"]); + const host = NodeHost; + const diagnostics = await generateEmitterOptionsTypes(host, resolvedMain, { + outputDir, + interfaceName: args["interface-name"], + }); + if (diagnostics.length > 0) { + logDiagnostics(diagnostics, host.logSink); + } + }, + ) .demandCommand(1, "You must use one of the supported commands.").argv; } diff --git a/packages/tspd/src/gen-emitter-options-types/gen-emitter-options-types.ts b/packages/tspd/src/gen-emitter-options-types/gen-emitter-options-types.ts new file mode 100644 index 00000000000..614bebefeb7 --- /dev/null +++ b/packages/tspd/src/gen-emitter-options-types/gen-emitter-options-types.ts @@ -0,0 +1,235 @@ +import { + CompilerHost, + Diagnostic, + Model, + ModelProperty, + NoTarget, + Program, + Scalar, + Type, + compile, + createTypeSpecLibrary, + getDoc, + isArrayModelType, + isRecordModelType, + joinPaths, + walkPropertiesInherited, +} from "@typespec/compiler"; +import prettier from "prettier"; + +const { createDiagnostic } = createTypeSpecLibrary({ + name: "@typespec/tspd", + diagnostics: { + "emitter-options-model-missing": { + severity: "error", + messages: { + default: `Couldn't find an EmitterOptions model in the global namespace.`, + }, + }, + }, +} as const); + +const numericScalars = new Set([ + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + "integer", + "safeint", + "float", + "float32", + "float64", + "numeric", + "decimal", + "decimal128", +]); + +export interface GenerateEmitterOptionsTypesOptions { + readonly outputDir: string; + readonly interfaceName?: string; +} + +export interface GenerateEmitterOptionsTypeOptions { + readonly interfaceName?: string; + readonly prettierConfig?: prettier.Options; +} + +export async function generateEmitterOptionsTypes( + host: CompilerHost, + entrypoint: string, + options: GenerateEmitterOptionsTypesOptions, +): Promise { + const program = await compile(host, entrypoint, { + parseOptions: { comments: true, docs: true }, + }); + + if (program.hasError()) { + return program.diagnostics; + } + + const emitterOptions = program.getGlobalNamespaceType().models.get("EmitterOptions"); + if (emitterOptions === undefined) { + return [ + createDiagnostic({ + code: "emitter-options-model-missing", + target: NoTarget, + }), + ]; + } + + const prettierConfig = await prettier.resolveConfig(entrypoint); + const content = await generateEmitterOptionsType(program, emitterOptions, { + interfaceName: options.interfaceName, + prettierConfig: prettierConfig ?? undefined, + }); + + await host.mkdirp(options.outputDir); + await host.writeFile(joinPaths(options.outputDir, "emitter-options.ts"), content); + return program.diagnostics; +} + +export async function generateEmitterOptionsType( + program: Program, + emitterOptions: Model, + options: GenerateEmitterOptionsTypeOptions = {}, +): Promise { + const interfaceName = options.interfaceName ?? "EmitterOptions"; + const source = `${emitDocComment(getDoc(program, emitterOptions))}export interface ${interfaceName} ${emitObject( + program, + emitterOptions, + new Set(), + )}`; + + return prettier.format(source, { + ...options.prettierConfig, + parser: "typescript", + }); +} + +function emitType(program: Program, type: Type, seenModels: Set): string { + switch (type.kind) { + case "Scalar": + return emitScalar(type); + case "String": + return JSON.stringify(type.value); + case "Number": + return type.valueAsString; + case "Boolean": + return String(type.value); + case "Enum": + return joinUnion( + [...type.members.values()].map((member) => + typeof member.value === "string" || typeof member.value === "number" + ? JSON.stringify(member.value) + : JSON.stringify(member.name), + ), + ); + case "Union": + return joinUnion( + [...type.variants.values()].map((variant) => emitType(program, variant.type, seenModels)), + ); + case "Model": + if (isArrayModelType(type)) { + return `${parenthesizeArrayElement(emitType(program, type.indexer.value, seenModels))}[]`; + } + if (isRecordModelType(type)) { + return `Record`; + } + return emitObject(program, type, seenModels); + case "Tuple": + return `[${type.values.map((x) => emitType(program, x, seenModels)).join(", ")}]`; + case "Intrinsic": + if (type.name === "null") { + return "null"; + } + return "unknown"; + default: + return "unknown"; + } +} + +function emitScalar(type: Scalar): string { + if (extendsScalar(type, "string")) { + return "string"; + } + if (extendsScalar(type, "boolean")) { + return "boolean"; + } + if (extendsScalar(type, "bytes")) { + return "Uint8Array"; + } + if (extendsAnyScalar(type, numericScalars)) { + return "number"; + } + return "unknown"; +} + +function extendsScalar(type: Scalar, name: string): boolean { + for (let current: Scalar | undefined = type; current; current = current.baseScalar) { + if (current.name === name) { + return true; + } + } + return false; +} + +function extendsAnyScalar(type: Scalar, names: Set): boolean { + for (let current: Scalar | undefined = type; current; current = current.baseScalar) { + if (names.has(current.name)) { + return true; + } + } + return false; +} + +function emitObject(program: Program, model: Model, seenModels: Set): string { + if (seenModels.has(model)) { + return "Record"; + } + const nextSeenModels = new Set(seenModels); + nextSeenModels.add(model); + + const properties = [...walkPropertiesInherited(model)]; + if (properties.length === 0) { + return "{}"; + } + + return `{ +${properties.map((property) => emitProperty(program, property, nextSeenModels)).join("\n")} +}`; +} + +function emitProperty(program: Program, property: ModelProperty, seenModels: Set): string { + const doc = emitDocComment(getDoc(program, property), " "); + const key = isValidIdentifier(property.name) ? property.name : JSON.stringify(property.name); + return `${doc} ${key}${property.optional ? "?" : ""}: ${emitType(program, property.type, seenModels)};`; +} + +function emitDocComment(doc: string | undefined, indent = ""): string { + if (doc === undefined || doc.length === 0) { + return ""; + } + + const lines = doc.split(/\r?\n/); + return `${indent}/** +${lines.map((line) => `${indent} *${line.length === 0 ? "" : ` ${line.replaceAll("*/", "*\\/")}`}`).join("\n")} +${indent} */ +`; +} + +function joinUnion(types: string[]): string { + const dedupedTypes = [...new Set(types)]; + return dedupedTypes.length === 0 ? "never" : dedupedTypes.join(" | "); +} + +function parenthesizeArrayElement(type: string): string { + return type.includes(" | ") ? `(${type})` : type; +} + +function isValidIdentifier(name: string): boolean { + return /^[$A-Z_a-z][$\w]*$/.test(name); +} diff --git a/packages/tspd/src/ref-doc/extractor.ts b/packages/tspd/src/ref-doc/extractor.ts index 3ec6b025178..a8b3e688eae 100644 --- a/packages/tspd/src/ref-doc/extractor.ts +++ b/packages/tspd/src/ref-doc/extractor.ts @@ -835,7 +835,7 @@ export function extractEmitterOptionsRefDocFromModel( program: Program, model: Model, ): EmitterOptionRefDoc[] { - return [...model.properties.values()].map((prop) => + return getDocumentedEmitterOptionProperties(model).map((prop) => extractEmitterOptionFromModelProperty(program, prop), ); } @@ -850,19 +850,20 @@ interface OptionTypeDescription { function extractEmitterOptionFromModelProperty( program: Program, prop: ModelProperty, + inheritedDefault?: string, ): EmitterOptionRefDoc { - const desc = describeEmitterOptionType(program, prop.type); + const defaultValue = getOptionDefaultDoc(prop) ?? inheritedDefault; + const desc = describeEmitterOptionType(program, prop.type, { defaultValue }); const option: Mutable = { name: prop.name, type: desc.type, - doc: getDoc(program, prop) ?? "", + doc: getEmitterOptionDoc(program, prop), }; if (desc.allowedValues) option.allowedValues = desc.allowedValues; if (desc.nestedOptions) option.nestedOptions = desc.nestedOptions; if (desc.variants) option.variants = desc.variants; - const defaultValue = getOptionDefaultDoc(prop); if (defaultValue !== undefined) option.default = defaultValue; const deprecated = getDeprecated(program, prop); @@ -871,6 +872,50 @@ function extractEmitterOptionFromModelProperty( return option; } +function getEmitterOptionDoc(program: Program, prop: ModelProperty): string { + const doc = getDoc(program, prop) ?? ""; + const examples = extractExamples(prop); + if (examples.length === 0) { + return doc; + } + + return [doc, ...examples.map(renderEmitterOptionExample)] + .filter((section) => section.length > 0) + .join("\n\n"); +} + +function renderEmitterOptionExample(example: ExampleRefDoc): string { + const title = example.title ? `Example ${example.title}` : "Example"; + return [title, example.content.trimEnd()].filter((section) => section.length > 0).join("\n\n"); +} + +function getDocumentedEmitterOptionProperties(model: Model): ModelProperty[] { + return [...model.properties.values()].filter((prop) => !isEmitterOptionInternal(prop)); +} + +function isEmitterOptionInternal(prop: ModelProperty): boolean { + return hasDocTag(prop, "internal"); +} + +function hasDocTag(type: Type, tagName: string): boolean { + return ( + type.node?.docs?.some((doc) => + doc.tags.some((tag) => tag.kind === SyntaxKind.DocUnknownTag && tag.tagName.sv === tagName), + ) ?? false + ); +} + +function getInheritedNestedDefault( + prop: ModelProperty, + parentDefault?: string, +): string | undefined { + if (parentDefault === undefined || prop.name !== "kind") { + return undefined; + } + + return literalOptionValues(prop.type)?.includes(parentDefault) ? parentDefault : undefined; +} + /** Read the `@default` doc tag value (verbatim) from a type's doc comment, if present. */ function getOptionDefaultDoc(type: Type): string | undefined { for (const doc of type.node?.docs ?? []) { @@ -884,7 +929,15 @@ function getOptionDefaultDoc(type: Type): string | undefined { return undefined; } -function describeEmitterOptionType(program: Program, type: Type): OptionTypeDescription { +interface OptionTypeDescriptionContext { + defaultValue?: string; +} + +function describeEmitterOptionType( + program: Program, + type: Type, + context: OptionTypeDescriptionContext = {}, +): OptionTypeDescription { switch (type.kind) { case "Scalar": return { type: scalarToOptionType(type) }; @@ -902,12 +955,17 @@ function describeEmitterOptionType(program: Program, type: Type): OptionTypeDesc if (elementValues) { return { type: `(${elementValues.join(" | ")})[]`, allowedValues: elementValues }; } - return { type: `${optionTypeToString(element)}[]` }; + return { type: `${optionTypeToString(program, element)}[]` }; } + const properties = getDocumentedEmitterOptionProperties(type); return { - type: `object { ${[...type.properties.keys()].join(", ")} }`, - nestedOptions: [...type.properties.values()].map((p) => - extractEmitterOptionFromModelProperty(program, p), + type: `object { ${properties.map((p) => p.name).join(", ")} }`, + nestedOptions: properties.map((p) => + extractEmitterOptionFromModelProperty( + program, + p, + getInheritedNestedDefault(p, context.defaultValue), + ), ), }; } @@ -929,13 +987,13 @@ function describeEmitterOptionType(program: Program, type: Type): OptionTypeDesc typeParts.push(literalValues.join(" | ")); } for (const variantType of complex) { - const desc = describeEmitterOptionType(program, variantType); + const desc = describeEmitterOptionType(program, variantType, context); // Complex variants (arrays/objects/scalars) display their full type string; // do not copy `allowedValues` (which would hide e.g. the array brackets). const variant: Mutable = { type: desc.type }; if (desc.nestedOptions) variant.nestedOptions = desc.nestedOptions; variants.push(variant); - typeParts.push(optionTypeToString(variantType)); + typeParts.push(desc.type); } return { type: typeParts.join(" | "), variants }; } @@ -1006,7 +1064,7 @@ function literalOptionValues(type: Type): string[] | undefined { } } -function optionTypeToString(type: Type): string { +function optionTypeToString(program: Program, type: Type): string { switch (type.kind) { case "String": return `"${type.value}"`; @@ -1019,13 +1077,17 @@ function optionTypeToString(type: Type): string { case "Enum": return literalOptionValues(type)!.join(" | "); case "Union": - return [...type.variants.values()].map((v) => optionTypeToString(v.type)).join(" | "); + return [...type.variants.values()] + .map((v) => optionTypeToString(program, v.type)) + .join(" | "); case "Model": if (isArrayModelType(type)) { - const element = optionTypeToString(type.indexer!.value); + const element = optionTypeToString(program, type.indexer!.value); return element.includes("|") ? `(${element})[]` : `${element}[]`; } - return `object { ${[...type.properties.keys()].join(", ")} }`; + return `object { ${getDocumentedEmitterOptionProperties(type) + .map((p) => p.name) + .join(", ")} }`; default: return getTypeName(type); } diff --git a/packages/tspd/test/gen-emitter-options-types/gen-emitter-options-types.test.ts b/packages/tspd/test/gen-emitter-options-types/gen-emitter-options-types.test.ts new file mode 100644 index 00000000000..582f418a491 --- /dev/null +++ b/packages/tspd/test/gen-emitter-options-types/gen-emitter-options-types.test.ts @@ -0,0 +1,119 @@ +import { resolvePath } from "@typespec/compiler"; +import { createTester, expectDiagnosticEmpty } from "@typespec/compiler/testing"; +import { expect, it } from "vitest"; +import { generateEmitterOptionsType } from "../../src/gen-emitter-options-types/gen-emitter-options-types.js"; + +const Tester = createTester(resolvePath(import.meta.dirname, "../.."), { + libraries: [], +}); + +async function generateOptions(code: string) { + const [{ program }] = await Tester.compileAndDiagnose(code, { + compilerOptions: { + parseOptions: { comments: true, docs: true }, + }, + }); + expectDiagnosticEmpty(program.diagnostics); + + const emitterOptions = program.getGlobalNamespaceType().models.get("EmitterOptions"); + expect(emitterOptions).toBeDefined(); + return generateEmitterOptionsType(program, emitterOptions!, { + interfaceName: "TestEmitterOptions", + prettierConfig: { plugins: [] }, + }); +} + +it("maps emitter options model to a TypeScript interface", async () => { + const result = await generateOptions(` + scalar absolutePath extends string; + enum Format { yaml, json } + + /** + * Test emitter options. + */ + model EmitterOptions { + /** + * File type. + */ + \`file-type\`?: Format | Format[]; + + /** + * Numeric value. + */ + count: int32; + + /** + * Custom string scalar. + */ + path?: absolutePath; + + /** + * Byte payload. + */ + payload?: bytes; + + /** + * String-indexed metadata. + */ + metadata?: Record; + + /** + * Nested object. + */ + nested?: { + /** + * Strategy kind. + */ + kind: "parent" | "fqn"; + + /** + * Optional separator. + */ + separator?: string; + }; + } + `); + + expect(result.trim()).toEqual( + ` +/** + * Test emitter options. + */ +export interface TestEmitterOptions { + /** + * File type. + */ + "file-type"?: "yaml" | "json" | ("yaml" | "json")[]; + /** + * Numeric value. + */ + count: number; + /** + * Custom string scalar. + */ + path?: string; + /** + * Byte payload. + */ + payload?: Uint8Array; + /** + * String-indexed metadata. + */ + metadata?: Record; + /** + * Nested object. + */ + nested?: { + /** + * Strategy kind. + */ + kind: "parent" | "fqn"; + /** + * Optional separator. + */ + separator?: string; + }; +} +`.trim(), + ); +}); diff --git a/packages/tspd/test/ref-doc/emitter-options-model.test.ts b/packages/tspd/test/ref-doc/emitter-options-model.test.ts index 6aaa3359086..1c7603a7a88 100644 --- a/packages/tspd/test/ref-doc/emitter-options-model.test.ts +++ b/packages/tspd/test/ref-doc/emitter-options-model.test.ts @@ -1,6 +1,6 @@ import { Model, resolvePath } from "@typespec/compiler"; import { createTester } from "@typespec/compiler/testing"; -import { describe, expect, it } from "vitest"; +import { expect, it } from "vitest"; import { extractEmitterOptionsRefDocFromModel } from "../../src/ref-doc/extractor.js"; const Tester = createTester(resolvePath(import.meta.dirname, "..", ".."), { @@ -16,109 +16,200 @@ async function extractOptions(code: string) { return extractEmitterOptionsRefDocFromModel(program, model as Model); } -describe("ref-doc: emitter options from TypeSpec model", () => { - it("maps scalar and boolean options", async () => { - const options = await extractOptions(` - model EmitterOptions { - name?: string; - flag?: boolean; - } - `); - expect(options).toEqual([ - { name: "name", type: "string", doc: "" }, - { name: "flag", type: "boolean", doc: "" }, - ]); +it("maps scalar and boolean options", async () => { + const options = await extractOptions(` + model EmitterOptions { + name?: string; + flag?: boolean; + } + `); + expect(options).toEqual([ + { name: "name", type: "string", doc: "" }, + { name: "flag", type: "boolean", doc: "" }, + ]); +}); + +it("maps a union of string literals to allowed values", async () => { + const options = await extractOptions(` + model EmitterOptions { + strategy?: "a" | "b" | "c"; + } + `); + expect(options[0]).toEqual({ + name: "strategy", + type: `"a" | "b" | "c"`, + doc: "", + allowedValues: [`"a"`, `"b"`, `"c"`], }); +}); - it("maps a union of string literals to allowed values", async () => { - const options = await extractOptions(` - model EmitterOptions { - strategy?: "a" | "b" | "c"; - } - `); - expect(options[0]).toEqual({ - name: "strategy", - type: `"a" | "b" | "c"`, - doc: "", - allowedValues: [`"a"`, `"b"`, `"c"`], - }); +it("reads doc and @default tag", async () => { + const options = await extractOptions(` + model EmitterOptions { + /** + * The newline character. + * @default "lf" + */ + \`new-line\`?: "crlf" | "lf"; + } + `); + expect(options[0]).toMatchObject({ + name: "new-line", + type: `"crlf" | "lf"`, + doc: "The newline character.", + default: `"lf"`, + allowedValues: [`"crlf"`, `"lf"`], }); +}); - it("reads doc and @default tag", async () => { - const options = await extractOptions(` - model EmitterOptions { - /** - * The newline character. - * @default "lf" - */ - \`new-line\`?: "crlf" | "lf"; - } - `); - expect(options[0]).toMatchObject({ - name: "new-line", - type: `"crlf" | "lf"`, - doc: "The newline character.", - default: `"lf"`, - allowedValues: [`"crlf"`, `"lf"`], - }); +it("maps an array of literals using allowed values", async () => { + const options = await extractOptions(` + model EmitterOptions { + versions?: ("3.0.0" | "3.1.0")[]; + } + `); + expect(options[0]).toEqual({ + name: "versions", + type: `("3.0.0" | "3.1.0")[]`, + doc: "", + allowedValues: [`"3.0.0"`, `"3.1.0"`], }); +}); - it("maps an array of literals using allowed values", async () => { - const options = await extractOptions(` - model EmitterOptions { - versions?: ("3.0.0" | "3.1.0")[]; - } - `); - expect(options[0]).toEqual({ - name: "versions", - type: `("3.0.0" | "3.1.0")[]`, - doc: "", - allowedValues: [`"3.0.0"`, `"3.1.0"`], - }); +it("maps a union of literals and an array to variants", async () => { + const options = await extractOptions(` + model EmitterOptions { + \`file-type\`?: ("yaml" | "json") | ("yaml" | "json")[]; + } + `); + expect(options[0]).toMatchObject({ + name: "file-type", + type: `"yaml" | "json" | ("yaml" | "json")[]`, + variants: [ + { type: `"yaml" | "json"`, allowedValues: [`"yaml"`, `"json"`] }, + { type: `("yaml" | "json")[]` }, + ], + }); +}); + +it("maps a union of literals and an object to variants with nested options", async () => { + const options = await extractOptions(` + model EmitterOptions { + strategy?: "fqn" | { + kind: "fqn" | "explicit-only"; + separator?: string; + }; + } + `); + const option = options[0]; + expect(option.name).toEqual("strategy"); + expect(option.type).toEqual(`"fqn" | object { kind, separator }`); + expect(option.variants?.[0]).toEqual({ + type: `"fqn"`, + allowedValues: [`"fqn"`], }); + const objectVariant = option.variants?.[1]; + expect(objectVariant?.type).toEqual("object { kind, separator }"); + expect(objectVariant?.nestedOptions).toEqual([ + { + name: "kind", + type: `"fqn" | "explicit-only"`, + doc: "", + allowedValues: [`"fqn"`, `"explicit-only"`], + }, + { name: "separator", type: "string", doc: "" }, + ]); +}); + +it("appends @example tags to option docs", async () => { + const options = await extractOptions(` + model EmitterOptions { + /** + * Name of the output file. + * + * @example Single service no versioning + * - \`openapi.yaml\` + * + * @example Multiple services no versioning + * - \`openapi.Org1.Service1.yaml\` + * - \`openapi.Org1.Service2.yaml\` + */ + \`output-file\`?: string; + } + `); - it("maps a union of literals and an array to variants", async () => { - const options = await extractOptions(` - model EmitterOptions { - \`file-type\`?: ("yaml" | "json") | ("yaml" | "json")[]; - } - `); - expect(options[0]).toMatchObject({ - name: "file-type", - type: `"yaml" | "json" | ("yaml" | "json")[]`, - variants: [ - { type: `"yaml" | "json"`, allowedValues: [`"yaml"`, `"json"`] }, - { type: `("yaml" | "json")[]` }, - ], - }); + expect(options[0].doc).toContain("Name of the output file."); + expect(options[0].doc).toContain("Example Single service no versioning"); + expect(options[0].doc).toContain("- `openapi.yaml`"); + expect(options[0].doc).toContain("Example Multiple services no versioning"); + expect(options[0].doc).toContain("- `openapi.Org1.Service2.yaml`"); +}); + +it("reads @default tags from nested object properties", async () => { + const options = await extractOptions(` + model EmitterOptions { + strategy?: "fqn" | { + /** + * Strategy kind. + * @default "parent-container" + */ + kind: "parent-container" | "fqn" | "explicit-only"; + }; + } + `); + + expect(options[0].variants?.[1].nestedOptions?.[0]).toMatchObject({ + name: "kind", + default: `"parent-container"`, }); +}); - it("maps a union of literals and an object to variants with nested options", async () => { - const options = await extractOptions(` - model EmitterOptions { - strategy?: "fqn" | { - kind: "fqn" | "explicit-only"; - separator?: string; - }; - } - `); - const option = options[0]; - expect(option.name).toEqual("strategy"); - expect(option.type).toEqual(`"fqn" | object { kind, separator }`); - expect(option.variants?.[0]).toEqual({ - type: `"fqn"`, - allowedValues: [`"fqn"`], - }); - const objectVariant = option.variants?.[1]; - expect(objectVariant?.type).toEqual("object { kind, separator }"); - expect(objectVariant?.nestedOptions).toEqual([ - { - name: "kind", - type: `"fqn" | "explicit-only"`, - doc: "", - allowedValues: [`"fqn"`, `"explicit-only"`], - }, - { name: "separator", type: "string", doc: "" }, - ]); +it("propagates shorthand defaults to nested kind properties", async () => { + const options = await extractOptions(` + model EmitterOptions { + /** + * Strategy option. + * @default "parent-container" + */ + strategy?: "parent-container" | "fqn" | { + kind: "parent-container" | "fqn"; + separator?: string; + }; + } + `); + + expect(options[0].variants?.[1].nestedOptions?.[0]).toMatchObject({ + name: "kind", + default: `"parent-container"`, }); }); + +it("excludes @internal option properties", async () => { + const options = await extractOptions(` + model EmitterOptions { + /** Public option. */ + visible?: string; + + /** + * Internal option. + * @internal + */ + hidden?: string; + + nested?: { + /** Public nested option. */ + enabled?: boolean; + + /** + * Internal nested option. + * @internal + */ + secret?: string; + }; + } + `); + + expect(options.map((x) => x.name)).toEqual(["visible", "nested"]); + expect(options[1].type).toEqual("object { enabled }"); + expect(options[1].nestedOptions?.map((x) => x.name)).toEqual(["enabled"]); +}); diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md index 1bf96420097..530a76672e2 100644 --- a/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/openapi3/reference/emitter.md @@ -64,15 +64,26 @@ Output file will interpolate the following values: Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`. When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}`. -### `openapi-versions` +Example Single service no versioning -**Type:** `"3.0.0" | "3.1.0" | "3.2.0"` +- `openapi.yaml` -**Default:** `["3.0.0"]` +Example Multiple services no versioning -The Open API specification versions to emit. -If more than one version is specified, then the output file -will be created inside a directory matching each specification version. +- `openapi.Org1.Service1.yaml` +- `openapi.Org1.Service2.yaml` + +Example Single service with versioning + +- `openapi.v1.yaml` +- `openapi.v2.yaml` + +Example Multiple service with versioning + +- `openapi.Org1.Service1.v1.yaml` +- `openapi.Org1.Service1.v2.yaml` +- `openapi.Org1.Service2.v1.0.yaml` +- `openapi.Org1.Service2.v1.1.yaml` ### `new-line` @@ -144,10 +155,10 @@ Available options are - `"parent-container" | "fqn" | "explicit-only"` - `object { kind, separator }` -| Name | Type | Default | Description | -| ----------- | ------------------------------------------------ | ------- | ----------------------------------------------------- | -| `kind` | `"parent-container" \| "fqn" \| "explicit-only"` | | Strategy used to generate the operation ID. | -| `separator` | `string` | | Separator used to join segment in the operation name. | +| Name | Type | Default | Description | +| ----------- | ------------------------------------------------ | -------------------- | ----------------------------------------------------- | +| `kind` | `"parent-container" \| "fqn" \| "explicit-only"` | `"parent-container"` | Strategy used to generate the operation ID. | +| `separator` | `string` | | Separator used to join segment in the operation name. | ### `enum-strategy` From 7e3c1ef739f6216b0bfb27163c72bb38ae9c3c17 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 30 Jun 2026 13:03:30 -0400 Subject: [PATCH 13/13] fix(compiler): improve emitter-options validation fidelity Resolve scalars by identity instead of name, enforce numeric ranges/ integer-ness and @minValue/@maxValue/@minLength/@maxLength constraints, apply @pattern in nested positions (arrays, Record, union variants), and surface nested errors for the best-matching union variant. --- .../core/emitter-options/validator.test.ts | 200 +++++++++ .../src/core/emitter-options/validator.ts | 399 +++++++++++++++--- 2 files changed, 532 insertions(+), 67 deletions(-) diff --git a/packages/compiler/src/core/emitter-options/validator.test.ts b/packages/compiler/src/core/emitter-options/validator.test.ts index c51e7ae66b0..b914be36bc6 100644 --- a/packages/compiler/src/core/emitter-options/validator.test.ts +++ b/packages/compiler/src/core/emitter-options/validator.test.ts @@ -328,3 +328,203 @@ describe("Record", () => { ]); }); }); + +describe("numeric ranges and integer-ness", () => { + it("accepts an in-range integer", async () => { + const errors = await validateOptions(`model EmitterOptions { prop?: int8; }`, { prop: 127 }); + expect(errors).toEqual([]); + }); + + it("rejects an out-of-range value", async () => { + const errors = await validateOptions(`model EmitterOptions { prop?: int8; }`, { prop: 9999 }); + expect(errors).toEqual([ + { + code: "invalid-value", + message: "Value 9999 is not assignable to int8, out of range [-128, 127].", + target: ["prop"], + }, + ]); + }); + + it("rejects a non-integer for an integer scalar", async () => { + const errors = await validateOptions(`model EmitterOptions { prop?: int32; }`, { prop: 1.5 }); + expect(errors).toEqual([ + { + code: "invalid-value", + message: "Value 1.5 is not assignable to int32, expected an integer.", + target: ["prop"], + }, + ]); + }); + + it("rejects a negative value for an unsigned scalar", async () => { + const errors = await validateOptions(`model EmitterOptions { prop?: uint8; }`, { prop: -3 }); + expect(errors).toEqual([ + { + code: "invalid-value", + message: "Value -3 is not assignable to uint8, out of range [0, 255].", + target: ["prop"], + }, + ]); + }); + + it("accepts a fractional value for a float scalar", async () => { + const errors = await validateOptions(`model EmitterOptions { prop?: float64; }`, { prop: 1.5 }); + expect(errors).toEqual([]); + }); +}); + +describe("@minValue/@maxValue", () => { + it("rejects a value below @minValue", async () => { + const errors = await validateOptions( + `model EmitterOptions { @minValue(1) @maxValue(10) prop?: int32; }`, + { prop: 0 }, + ); + expect(errors).toEqual([ + { + code: "invalid-value", + message: "Value 0 is less than the minimum allowed value 1.", + target: ["prop"], + }, + ]); + }); + + it("rejects a value above @maxValue", async () => { + const errors = await validateOptions( + `model EmitterOptions { @minValue(1) @maxValue(10) prop?: int32; }`, + { prop: 20 }, + ); + expect(errors).toEqual([ + { + code: "invalid-value", + message: "Value 20 is greater than the maximum allowed value 10.", + target: ["prop"], + }, + ]); + }); + + it("accepts a value within range", async () => { + const errors = await validateOptions( + `model EmitterOptions { @minValue(1) @maxValue(10) prop?: int32; }`, + { prop: 5 }, + ); + expect(errors).toEqual([]); + }); +}); + +describe("@minLength/@maxLength", () => { + it("rejects a string that is too short", async () => { + const errors = await validateOptions( + `model EmitterOptions { @minLength(2) @maxLength(4) prop?: string; }`, + { prop: "a" }, + ); + expect(errors).toEqual([ + { + code: "invalid-value", + message: `String "a" is too short, expected at least 2 characters.`, + target: ["prop"], + }, + ]); + }); + + it("rejects a string that is too long", async () => { + const errors = await validateOptions( + `model EmitterOptions { @minLength(2) @maxLength(4) prop?: string; }`, + { prop: "abcde" }, + ); + expect(errors).toEqual([ + { + code: "invalid-value", + message: `String "abcde" is too long, expected at most 4 characters.`, + target: ["prop"], + }, + ]); + }); + + it("accepts a string within length bounds", async () => { + const errors = await validateOptions( + `model EmitterOptions { @minLength(2) @maxLength(4) prop?: string; }`, + { prop: "abc" }, + ); + expect(errors).toEqual([]); + }); +}); + +describe("@pattern on scalars applies in nested positions", () => { + it("validates array items against a scalar @pattern", async () => { + const errors = await validateOptions( + ` + @pattern("^a") scalar prefixed extends string; + model EmitterOptions { prop?: prefixed[]; }`, + { prop: ["abc", "xyz"] }, + ); + expect(errors).toEqual([ + { + code: "invalid-pattern", + message: "xyz does not match pattern /^a/", + target: ["prop", "1"], + }, + ]); + }); + + it("validates Record values against a scalar @pattern", async () => { + const errors = await validateOptions( + ` + @pattern("^a") scalar prefixed extends string; + model EmitterOptions { prop?: Record; }`, + { prop: { a: "abc", b: "xyz" } }, + ); + expect(errors).toEqual([ + { + code: "invalid-pattern", + message: "xyz does not match pattern /^a/", + target: ["prop", "b"], + }, + ]); + }); +}); + +describe("scalar identity (not name)", () => { + it("does not treat a non-std scalar sharing a built-in name as that built-in", async () => { + const asString = await validateOptions( + ` + namespace Foo { scalar int32 extends string; } + model EmitterOptions { prop?: Foo.int32; }`, + { prop: "hello" }, + ); + expect(asString).toEqual([]); + + const errors = await validateOptions( + ` + namespace Foo { scalar int32 extends string; } + model EmitterOptions { prop?: Foo.int32; }`, + { prop: 123 }, + ); + expect(errors).toEqual([ + { + code: "type-mismatch", + message: "Expected type string", + target: ["prop"], + }, + ]); + }); +}); + +describe("union nested error attribution", () => { + it("surfaces the nested error of the matching model variant", async () => { + const errors = await validateOptions( + ` + model EmitterOptions { + prop?: "explicit-only" | { kind: "a" | "b", separator?: string }; + }`, + { prop: { kind: "b", seperator: "/" } }, + ); + expect(errors).toEqual([ + { + code: "unknown-property", + message: `Unknown property "seperator"`, + target: ["prop", "seperator"], + }, + ]); + }); +}); diff --git a/packages/compiler/src/core/emitter-options/validator.ts b/packages/compiler/src/core/emitter-options/validator.ts index 8d94c563657..96c25fea2c0 100644 --- a/packages/compiler/src/core/emitter-options/validator.ts +++ b/packages/compiler/src/core/emitter-options/validator.ts @@ -1,8 +1,27 @@ import { getPattern } from "../../lib/decorators.js"; +import { + getMaxLength, + getMaxValueAsNumeric, + getMaxValueExclusiveAsNumeric, + getMinLength, + getMinValueAsNumeric, + getMinValueExclusiveAsNumeric, +} from "../intrinsic-type-state.js"; +import { numericRanges } from "../numeric-ranges.js"; +import { Numeric } from "../numeric.js"; import { isPathAbsolute } from "../path-utils.js"; import { Program } from "../program.js"; import { isArrayModelType } from "../type-utils.js"; -import type { ArrayModelType, Enum, Model, Scalar, StdTypeName, Type, Union } from "../types.js"; +import type { + ArrayModelType, + Enum, + IntrinsicScalarName, + Model, + ModelProperty, + Scalar, + Type, + Union, +} from "../types.js"; export interface ValidationError { code: string; @@ -12,7 +31,7 @@ export interface ValidationError { value?: string; } -const knownScalarNames = new Set([ +const knownScalarNames: readonly IntrinsicScalarName[] = [ "string", "url", "boolean", @@ -33,23 +52,64 @@ const knownScalarNames = new Set([ "safeint", "float32", "float64", -]); +]; + +/** + * Resolved, realm-specific references used to identify scalars by identity rather + * than by name, so user-defined scalars that merely share a name with a built-in + * (e.g. a user `scalar int32` or `scalar absolutePath`) are not mis-classified. + */ +interface ValidationContext { + readonly program: Program; + /** Identity map of the std scalar type to its intrinsic name. */ + readonly stdScalars: Map; + /** The `absolutePath` scalar from `@typespec/compiler/emitter`, if available in this realm. */ + readonly absolutePath: Scalar | undefined; +} + +function createValidationContext(program: Program): ValidationContext { + const stdScalars = new Map(); + for (const name of knownScalarNames) { + const scalar = program.checker.getStdType(name); + if (scalar) { + stdScalars.set(scalar, name); + } + } + + // Resolve the real `absolutePath` scalar by identity. It is declared in + // `@typespec/compiler/emitter` and only present when the emitter's options file + // imports it; when absent, no value gets absolute-path semantics. + const [absolutePathType] = program.resolveTypeReference("absolutePath"); + const absolutePath = absolutePathType?.kind === "Scalar" ? absolutePathType : undefined; + + return { program, stdScalars, absolutePath }; +} export function validateEmitterOptions( program: Program, value: unknown, type: Type, +): readonly ValidationError[] { + return validate(createValidationContext(program), value, type, undefined); +} + +function validate( + context: ValidationContext, + value: unknown, + type: Type, + /** The model property (if any) that owns this value, used to read property-level constraint decorators. */ + property: ModelProperty | undefined, ): readonly ValidationError[] { switch (type.kind) { case "Model": if (isArrayModelType(type)) { - return validateArray(program, value, type); + return validateArray(context, value, type); } - return validateModel(program, value, type); + return validateModel(context, value, type); case "Scalar": - return validateScalar(value, type); + return validateScalar(context, value, type, property); case "Union": - return validateUnion(program, value, type); + return validateUnion(context, value, type, property); case "Enum": return validateEnum(value, type); case "String": @@ -60,7 +120,11 @@ export function validateEmitterOptions( return []; } -function validateModel(program: Program, value: unknown, type: Model): readonly ValidationError[] { +function validateModel( + context: ValidationContext, + value: unknown, + type: Model, +): readonly ValidationError[] { if (typeof value !== "object" || value === null || Array.isArray(value)) { return [ { @@ -77,7 +141,7 @@ function validateModel(program: Program, value: unknown, type: Model): readonly // `Record` style models: validate every entry against the indexer value type. if (type.indexer && type.indexer.key.name === "string") { for (const [key, entryValue] of Object.entries(valObj)) { - const entryErrors = validateEmitterOptions(program, entryValue, type.indexer.value); + const entryErrors = validate(context, entryValue, type.indexer.value, undefined); for (const err of entryErrors) { errors.push({ ...err, target: [key, ...err.target] }); } @@ -97,23 +161,13 @@ function validateModel(program: Program, value: unknown, type: Model): readonly } continue; } - const propErrors = validateEmitterOptions(program, propValue, propType.type); + const propErrors = validate(context, propValue, propType.type, propType); for (const err of propErrors) { errors.push({ ...err, target: [propType.name, ...err.target], }); } - const pattern = getPattern(program, propType); - if (pattern) { - if (typeof propValue !== "string" || !new RegExp(pattern).test(propValue)) { - errors.push({ - code: "invalid-pattern", - message: `${propValue} does not match pattern /${pattern}/`, - target: [propType.name], - }); - } - } } // Reject unknown properties for plain (non-indexed) models. @@ -130,7 +184,7 @@ function validateModel(program: Program, value: unknown, type: Model): readonly } function validateArray( - program: Program, + context: ValidationContext, value: unknown, type: ArrayModelType, ): readonly ValidationError[] { @@ -145,7 +199,7 @@ function validateArray( } const errors: ValidationError[] = []; for (let i = 0; i < value.length; i++) { - const itemErrors = validateEmitterOptions(program, value[i], type.indexer.value); + const itemErrors = validate(context, value[i], type.indexer.value, undefined); for (const err of itemErrors) { errors.push({ ...err, @@ -156,22 +210,63 @@ function validateArray( return errors; } -function validateUnion(program: Program, value: unknown, type: Union): readonly ValidationError[] { +function validateUnion( + context: ValidationContext, + value: unknown, + type: Union, + property: ModelProperty | undefined, +): readonly ValidationError[] { const variants = [...type.variants.values()]; + + let best: { errors: readonly ValidationError[]; score: number } | undefined; for (const variant of variants) { - if (validateEmitterOptions(program, value, variant.type).length === 0) { + const errors = validate(context, value, variant.type, property); + if (errors.length === 0) { return []; } + // Prefer the variant whose JS shape matches the value (e.g. an object value + // against a model variant) so we can surface its nested errors instead of a + // flat "no variant matched" message. Break ties by fewest errors. + const score = (valueMatchesKind(value, variant.type) ? 100 : 0) - errors.length; + if (best === undefined || score > best.score) { + best = { errors, score }; + } } + // When every variant is a literal/enum we can produce a friendly enumeration of + // the allowed values rather than surfacing per-variant assignability errors. const literals = collectLiteralValues(variants); - const message = - literals !== undefined - ? `Value ${JSON.stringify(value)} is not one of the allowed values: ${literals + if (literals !== undefined) { + return [ + { + code: "invalid-value", + message: `Value ${JSON.stringify(value)} is not one of the allowed values: ${literals .map((l) => JSON.stringify(l)) - .join(", ")}` - : `Value ${JSON.stringify(value)} does not match any of the expected types.`; - return [{ code: "invalid-value", message, target: [] }]; + .join(", ")}`, + target: [], + }, + ]; + } + + return best?.errors ?? []; +} + +/** Whether the JS `value` structurally matches the family of the given type. */ +function valueMatchesKind(value: unknown, type: Type): boolean { + switch (type.kind) { + case "Model": + return isArrayModelType(type) + ? Array.isArray(value) + : typeof value === "object" && value !== null && !Array.isArray(value); + case "String": + return typeof value === "string"; + case "Number": + return typeof value === "number"; + case "Boolean": + return typeof value === "boolean"; + default: + return false; + } } function validateEnum(value: unknown, type: Enum): readonly ValidationError[] { @@ -238,33 +333,40 @@ function collectLiteralValues( return values; } -function validateScalar(value: unknown, type: Scalar): readonly ValidationError[] { - // Special-case the built-in `absolutePath` scalar (from `@typespec/compiler/emitter`): - // it extends `string` but additionally requires the value to be an absolute path. This - // mirrors the legacy JSON-schema `format: absolute-path` validation. - for (let scalar: Scalar | undefined = type; scalar; scalar = scalar.baseScalar) { - if (scalar.name === "absolutePath") { - if (typeof value === "string" && (value.startsWith(".") || !isPathAbsolute(value))) { - return [ - { - code: "config-path-absolute", - message: `Path "${value}" cannot be relative. Use {cwd} or {project-root} to specify what the path should be relative to.`, - target: [], - value, - }, - ]; - } - break; +function validateScalar( + context: ValidationContext, + value: unknown, + type: Scalar, + property: ModelProperty | undefined, +): readonly ValidationError[] { + // Special-case the built-in `absolutePath` scalar (from `@typespec/compiler/emitter`). + // Matched by identity so a user-defined `scalar absolutePath` is not affected. It extends + // `string` but additionally requires the value to be an absolute path, mirroring the + // legacy JSON-schema `format: absolute-path` validation. + if (context.absolutePath && scalarChainIncludes(type, context.absolutePath)) { + if (typeof value === "string" && (value.startsWith(".") || !isPathAbsolute(value))) { + return [ + { + code: "config-path-absolute", + message: `Path "${value}" cannot be relative. Use {cwd} or {project-root} to specify what the path should be relative to.`, + target: [], + value, + }, + ]; } } - // Resolve custom scalars (e.g. `scalar absolutePath extends string`) to their - // known built-in base so they validate against the underlying representation. - let current: Scalar | undefined = type; - while (current && !knownScalarNames.has(current.name as StdTypeName)) { - current = current.baseScalar; + // Resolve custom scalars (e.g. `scalar myInt extends int32`) to their known built-in + // base by identity so they validate against the underlying representation. + let builtin: IntrinsicScalarName | undefined; + for (let scalar: Scalar | undefined = type; scalar; scalar = scalar.baseScalar) { + const name = context.stdScalars.get(scalar); + if (name !== undefined) { + builtin = name; + break; + } } - if (current === undefined) { + if (builtin === undefined) { return [ { code: "unsupported", @@ -273,20 +375,187 @@ function validateScalar(value: unknown, type: Scalar): readonly ValidationError[ }, ]; } - return validateBuiltinScalar(value, current.name as StdTypeName, []); + + const typeErrors = validateBuiltinScalar(value, builtin); + if (typeErrors.length > 0) { + return typeErrors; + } + + if (typeof value === "string") { + return validateStringConstraints(context, value, type, property); + } + if (typeof value === "number") { + return validateNumericConstraints(context, value, builtin, type, property); + } + return []; +} + +/** Whether `scalar` or any of its base scalars is identical to `target`. */ +function scalarChainIncludes(scalar: Scalar, target: Scalar): boolean { + for (let current: Scalar | undefined = scalar; current; current = current.baseScalar) { + if (current === target) { + return true; + } + } + return false; +} + +/** + * Read the most specific value of a constraint decorator: the owning property takes + * precedence, then the scalar chain from the leaf scalar up to its base. + */ +function effectiveConstraint( + context: ValidationContext, + accessor: (program: Program, target: Type) => T | undefined, + scalar: Scalar, + property: ModelProperty | undefined, +): T | undefined { + if (property) { + const value = accessor(context.program, property); + if (value !== undefined) { + return value; + } + } + for (let current: Scalar | undefined = scalar; current; current = current.baseScalar) { + const value = accessor(context.program, current); + if (value !== undefined) { + return value; + } + } + return undefined; +} + +function validateStringConstraints( + context: ValidationContext, + value: string, + scalar: Scalar, + property: ModelProperty | undefined, +): readonly ValidationError[] { + const errors: ValidationError[] = []; + + const pattern = effectiveConstraint(context, getPattern, scalar, property); + if (pattern && !new RegExp(pattern).test(value)) { + errors.push({ + code: "invalid-pattern", + message: `${value} does not match pattern /${pattern}/`, + target: [], + }); + } + + const minLength = effectiveConstraint(context, getMinLength, scalar, property); + if (minLength !== undefined && value.length < minLength) { + errors.push({ + code: "invalid-value", + message: `String "${value}" is too short, expected at least ${minLength} characters.`, + target: [], + }); + } + + const maxLength = effectiveConstraint(context, getMaxLength, scalar, property); + if (maxLength !== undefined && value.length > maxLength) { + errors.push({ + code: "invalid-value", + message: `String "${value}" is too long, expected at most ${maxLength} characters.`, + target: [], + }); + } + + return errors; +} + +function validateNumericConstraints( + context: ValidationContext, + value: number, + builtin: IntrinsicScalarName, + scalar: Scalar, + property: ModelProperty | undefined, +): readonly ValidationError[] { + const errors: ValidationError[] = []; + const numericValue = Numeric(String(value)); + + // Built-in integer-ness and range for sized scalars (int8, uint32, float32, ...). + if (builtin === "integer") { + if (!numericValue.isInteger) { + errors.push({ + code: "invalid-value", + message: `Value ${value} is not assignable to ${builtin}, expected an integer.`, + target: [], + }); + } + } else if (builtin in numericRanges) { + const [low, high, options] = numericRanges[builtin as keyof typeof numericRanges]; + if (options.int && !numericValue.isInteger) { + errors.push({ + code: "invalid-value", + message: `Value ${value} is not assignable to ${builtin}, expected an integer.`, + target: [], + }); + } else if (numericValue.lt(low) || numericValue.gt(high)) { + errors.push({ + code: "invalid-value", + message: `Value ${value} is not assignable to ${builtin}, out of range [${low.asNumber()}, ${high.asNumber()}].`, + target: [], + }); + } + } + + // Explicit `@minValue`/`@maxValue` (and exclusive variants) on the property or scalar. + const min = effectiveConstraint(context, getMinValueAsNumeric, scalar, property); + if (min !== undefined && numericValue.lt(min)) { + errors.push({ + code: "invalid-value", + message: `Value ${value} is less than the minimum allowed value ${min.asNumber()}.`, + target: [], + }); + } + const max = effectiveConstraint(context, getMaxValueAsNumeric, scalar, property); + if (max !== undefined && numericValue.gt(max)) { + errors.push({ + code: "invalid-value", + message: `Value ${value} is greater than the maximum allowed value ${max.asNumber()}.`, + target: [], + }); + } + const minExclusive = effectiveConstraint( + context, + getMinValueExclusiveAsNumeric, + scalar, + property, + ); + if (minExclusive !== undefined && numericValue.lte(minExclusive)) { + errors.push({ + code: "invalid-value", + message: `Value ${value} must be greater than ${minExclusive.asNumber()}.`, + target: [], + }); + } + const maxExclusive = effectiveConstraint( + context, + getMaxValueExclusiveAsNumeric, + scalar, + property, + ); + if (maxExclusive !== undefined && numericValue.gte(maxExclusive)) { + errors.push({ + code: "invalid-value", + message: `Value ${value} must be less than ${maxExclusive.asNumber()}.`, + target: [], + }); + } + + return errors; } function validateBuiltinScalar( value: unknown, - name: StdTypeName, - target: string[], + name: IntrinsicScalarName, ): readonly ValidationError[] { switch (name) { case "string": case "url": - return assertType(value, "string", target); + return assertType(value, "string"); case "boolean": - return assertType(value, "boolean", target); + return assertType(value, "boolean"); case "numeric": case "integer": case "float": @@ -303,30 +572,26 @@ function validateBuiltinScalar( case "safeint": case "float32": case "float64": - return assertType(value, "number", target); + return assertType(value, "number"); case "bytes": if (value instanceof Uint8Array) { return []; } - return [{ code: "type-mismatch", message: `Expected type bytes`, target }]; + return [{ code: "type-mismatch", message: `Expected type bytes`, target: [] }]; default: return [ { code: "unsupported", message: `${name} is not supported for emitter options.`, - target, + target: [], }, ]; } } -function assertType( - value: unknown, - expectedType: string, - target: string[], -): readonly ValidationError[] { +function assertType(value: unknown, expectedType: string): readonly ValidationError[] { if (typeof value === expectedType) { return []; } - return [{ code: "type-mismatch", message: `Expected type ${expectedType}`, target }]; + return [{ code: "type-mismatch", message: `Expected type ${expectedType}`, target: [] }]; }