diff --git a/.chronus/changes/diagnostic-docs-openapi3-2026-6-9.md b/.chronus/changes/diagnostic-docs-openapi3-2026-6-9.md new file mode 100644 index 00000000000..d0f48cdd763 --- /dev/null +++ b/.chronus/changes/diagnostic-docs-openapi3-2026-6-9.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/openapi3" +--- + +Provide extended documentation for several diagnostics (`path-query`, `duplicate-header`, `inline-cycle`, `invalid-schema`, `invalid-server-variable`, `union-null`) via co-located markdown files. diff --git a/.chronus/changes/hover-diagnostic-docs-2026-6-9.md b/.chronus/changes/hover-diagnostic-docs-2026-6-9.md new file mode 100644 index 00000000000..fd4fb4b4237 --- /dev/null +++ b/.chronus/changes/hover-diagnostic-docs-2026-6-9.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +The language server now surfaces the extended documentation of diagnostics and linter rules when hovering over a reported error. If the diagnostic/rule provides `docs` (inline markdown or a `FileRef`), it is rendered in the hover, together with a link to the generated reference page when a documentation url is available. diff --git a/.chronus/changes/reference-docs-base-url-2026-6-9.md b/.chronus/changes/reference-docs-base-url-2026-6-9.md new file mode 100644 index 00000000000..c49d3352f29 --- /dev/null +++ b/.chronus/changes/reference-docs-base-url-2026-6-9.md @@ -0,0 +1,20 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add a `referenceDocs.baseUrl` field to the library definition passed to `createTypeSpecLibrary`. When set, the compiler auto-generates the `url` of each documented diagnostic and linter rule that does not specify one explicitly, so tooling (CLI and editor) can link to the generated reference pages without hardcoding a URL per rule/diagnostic: + +- diagnostics -> `${baseUrl}/diagnostics/` +- linter rules -> `${baseUrl}/rules/` + +```ts +export const $lib = createTypeSpecLibrary({ + name: "@typespec/my-lib", + referenceDocs: { baseUrl: "https://typespec.io/docs/libraries/my-lib/reference" }, + diagnostics: { + /* ... */ + }, +}); +``` diff --git a/.chronus/changes/rule-diagnostic-docs-compiler-2026-6-9.md b/.chronus/changes/rule-diagnostic-docs-compiler-2026-6-9.md new file mode 100644 index 00000000000..89055a06460 --- /dev/null +++ b/.chronus/changes/rule-diagnostic-docs-compiler-2026-6-9.md @@ -0,0 +1,19 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add a `docs` field to linter rule and diagnostic definitions to provide extended reference documentation. The value can be an inline markdown string or a `FileRef` created with `fileRef.fromPackageRoot("src/rules/my-rule.md")`, which is read lazily by tooling so it stays safe to bundle for the browser. + +```ts +export const myRule = createRule({ + name: "my-rule", + severity: "warning", + description: "Short description.", + docs: fileRef.fromPackageRoot("src/rules/my-rule.md"), + messages: { + /* ... */ + }, +}); +``` diff --git a/.chronus/changes/rule-diagnostic-docs-tspd-2026-6-9.md b/.chronus/changes/rule-diagnostic-docs-tspd-2026-6-9.md new file mode 100644 index 00000000000..648109d23d9 --- /dev/null +++ b/.chronus/changes/rule-diagnostic-docs-tspd-2026-6-9.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/tspd" +--- + +`tspd doc` now generates a documentation page per linter rule (`reference/rules/.md`) and per diagnostic (`reference/diagnostics/.md`), sourced from the `docs` field on the rule and diagnostic definitions. A `documentation-missing` warning is reported for any linter rule or diagnostic that does not provide documentation. diff --git a/.chronus/changes/rule-docs-http-2026-6-9.md b/.chronus/changes/rule-docs-http-2026-6-9.md new file mode 100644 index 00000000000..d36a6ffc4c8 --- /dev/null +++ b/.chronus/changes/rule-docs-http-2026-6-9.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/http" +--- + +Provide extended documentation for the `op-reference-container-route` linter rule via a co-located markdown file. diff --git a/packages/compiler/src/core/diagnostic-creator.ts b/packages/compiler/src/core/diagnostic-creator.ts index 96d4a737006..97a878aaa76 100644 --- a/packages/compiler/src/core/diagnostic-creator.ts +++ b/packages/compiler/src/core/diagnostic-creator.ts @@ -17,6 +17,7 @@ import type { export function createDiagnosticCreator( diagnostics: DiagnosticMap, libraryName?: string, + referenceDocsBaseUrl?: string, ): DiagnosticCreator { const errorMessage = libraryName ? `It must match one of the code defined in the library '${libraryName}'` @@ -59,6 +60,9 @@ export function createDiagnosticCreator(lib: Readonly>): TypeSpecLibrary { let emitterOptionValidator: JSONSchemaValidator; - const { reportDiagnostic, createDiagnostic } = createDiagnosticCreator(lib.diagnostics, lib.name); + const { reportDiagnostic, createDiagnostic } = createDiagnosticCreator( + lib.diagnostics, + lib.name, + lib.referenceDocs?.baseUrl, + ); function createStateSymbol(name: string): symbol { return Symbol.for(`${lib.name}.${name}`); diff --git a/packages/compiler/src/core/linter.ts b/packages/compiler/src/core/linter.ts index 8b6aa78651e..98223d83111 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -49,9 +49,14 @@ export interface LinterResult { export function resolveLinterDefinition( libName: string, linter: LinterDefinition, + referenceDocsBaseUrl?: string, ): LinterResolvedDefinition { const rules: LinterRule[] = linter.rules.map((rule) => { - return { ...rule, id: `${libName}/${rule.name}` }; + const resolved: LinterRule = { ...rule, id: `${libName}/${rule.name}` }; + if (resolved.url === undefined && resolved.docs && referenceDocsBaseUrl) { + resolved.url = `${referenceDocsBaseUrl.replace(/\/$/, "")}/rules/${rule.name}`; + } + return resolved; }); if (linter.rules.length === 0 || (linter.ruleSets && "all" in linter.ruleSets)) { return { diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index 513170c5a61..df7023fab0e 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -56,6 +56,7 @@ import { JsSourceFileNode, LibraryInstance, LibraryMetadata, + LinterDefinition, LiteralType, LocationContext, LogSink, @@ -134,12 +135,33 @@ export interface Program { /** Return location context of the given source file. */ getSourceFileLocationContext(sourceFile: SourceFile): LocationContext; + /** + * Get information about a library loaded during this compilation, by its package name. + * Used by tooling (e.g. the language server) to resolve reference documentation. + * @internal + */ + getLoadedLibraryInfo(name: string): Promise; + /** * 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; } +/** + * Information about a library loaded during compilation, used by tooling (e.g. the + * language server) to resolve reference documentation without re-resolving or reloading it. + * @internal + */ +export interface LoadedLibraryInfo { + /** Absolute path to the package root (the folder containing the library's package.json). */ + readonly packageRoot: string; + /** The library's `$lib` export, if any. */ + readonly $lib?: TypeSpecLibrary; + /** The library's `$linter` export, if any. */ + readonly $linter?: LinterDefinition; +} + interface EmitterRef { emitFunction: EmitterFunc; main: string; @@ -271,6 +293,7 @@ async function createProgram( /** @internal */ resolveTypeOrValueReference, getSourceFileLocationContext, + getLoadedLibraryInfo, projectRoot: getDirectoryPath(options.config ?? resolvedMain ?? ""), }; @@ -492,6 +515,25 @@ async function createProgram( return locationContext; } + async function getLoadedLibraryInfo(name: string): Promise { + const ref = sourceResolution.loadedLibraries.get(name); + if (ref === undefined) { + return undefined; + } + // Load the library's default entrypoint (which exports `$lib` and `$linter`) the same way the + // compiler does. `loadJsFile` is cached, so for a library already loaded during this + // compilation this does not re-read or re-evaluate anything. Note the entry imported by + // `.tsp` files (the `typespec` condition) may only expose `$lib`, so we can't rely on the + // already-parsed `jsSourceFiles` to also carry `$linter`. + const [resolution] = await resolveEmitterModuleAndEntrypoint(basedir, name); + const esmExports = resolution?.entrypoint?.esmExports; + return { + packageRoot: ref.path, + $lib: esmExports?.$lib, + $linter: esmExports?.$linter, + }; + } + async function loadEmitters( basedir: string, emitterNameOrPaths: string[], @@ -548,7 +590,13 @@ async function createProgram( ...resolution, metadata, definition: libDefinition, - linter: linterDef && resolveLinterDefinition(libraryNameOrPath, linterDef), + linter: + linterDef && + resolveLinterDefinition( + libraryNameOrPath, + linterDef, + libDefinition?.referenceDocs?.baseUrl, + ), }; } diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 9e6c2c43e38..504f6ab9ea6 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -1,6 +1,7 @@ import type { JSONSchemaType as AjvJSONSchemaType } from "ajv"; import type { ModuleResolutionResult } from "../module-resolver/index.js"; import type { YamlPathTarget, YamlScript } from "../yaml/types.js"; +import type { FileRef } from "./file-ref.js"; import type { Numeric } from "./numeric.js"; import type { Program } from "./program.js"; import type { TokenFlags } from "./scanner.js"; @@ -2417,6 +2418,12 @@ export interface DiagnosticDefinition { readonly description?: string; /** Specifies the URL at which the full documentation can be accessed. */ readonly url?: string; + /** + * Extended documentation for this diagnostic. Surfaced both in generated reference + * documentation and in editor tooling (e.g. completion and hover). Either raw markdown, + * or a {@link FileRef} pointing to a markdown file (recommended). + */ + readonly docs?: string | FileRef; } export interface DiagnosticMessages { @@ -2491,6 +2498,18 @@ export interface TypeSpecLibraryDef< /** Optional registration of capabilities the library/emitter provides */ readonly capabilities?: TypeSpecLibraryCapabilities; + /** + * Reference documentation configuration for this library. + * When set, the compiler auto-generates the `url` of each documented diagnostic and linter + * rule that does not specify one explicitly, pointing at the generated reference pages: + * - diagnostics -> `${baseUrl}/diagnostics/` + * - linter rules -> `${baseUrl}/rules/` + */ + readonly referenceDocs?: { + /** Base URL of the library's generated reference documentation (e.g. its `.../reference` page). */ + readonly baseUrl: string; + }; + /** * Map of potential diagnostics that can be emitted in this library where the key is the diagnostic code. */ @@ -2564,6 +2583,12 @@ interface LinterRuleDefinitionBase< description: string; /** Specifies the URL at which the full documentation can be accessed. */ url?: string; + /** + * Extended documentation for this rule. Surfaced both in generated reference + * documentation and in editor tooling (e.g. completion and hover). Either raw markdown, + * or a {@link FileRef} pointing to a markdown file (recommended). + */ + docs?: string | FileRef; /** Messages that can be reported with the diagnostic. */ messages: DM; /** diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 0c6a4e03663..7fdd18c70a4 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -46,6 +46,7 @@ export { type WriteLine, } from "./core/diagnostics.js"; export { emitFile, type EmitFileOptions, type NewLine } from "./core/emitter-utils.js"; +export { fileRef, isFileRef, type FileRef } from "./core/file-ref.js"; export { checkFormatTypeSpec, formatTypeSpec } from "./core/formatter.js"; export { DiscriminatedUnion, diff --git a/packages/compiler/src/server/serverlib.ts b/packages/compiler/src/server/serverlib.ts index 964cca1f927..4324d7bc151 100644 --- a/packages/compiler/src/server/serverlib.ts +++ b/packages/compiler/src/server/serverlib.ts @@ -24,7 +24,6 @@ import { InitializeParams, InitializeResult, Location, - MarkupContent, MarkupKind, ParameterInformation, PrepareRenameParams, @@ -53,6 +52,7 @@ import { getSymNode } from "../core/binder.js"; import { CharCode } from "../core/charcode.js"; import { resolveCodeFix } from "../core/code-fixes.js"; import { compilerAssert, getSourceLocation } from "../core/diagnostics.js"; +import { isFileRef } from "../core/file-ref.js"; import { formatTypeSpec } from "../core/formatter.js"; import { getEntityName, getTypeName } from "../core/helpers/type-name-utils.js"; import { builtInLinterRule_UnusedTemplateParameter } from "../core/linter-rules/unused-template-parameter.rule.js"; @@ -899,13 +899,14 @@ export function createServer( return { contents: [] }; } - const id = getNodeAtPosition(script, document.offsetAt(params.position)); + const offset = document.offsetAt(params.position); + const sections: string[] = []; + + const id = getNodeAtPosition(script, offset); const sym = id?.kind === SyntaxKind.Identifier ? program.checker.resolveRelatedSymbols(id) : undefined; - if (!sym || sym.length === 0) { - return { contents: { kind: MarkupKind.Markdown, value: "" } }; - } else { + if (sym && sym.length > 0) { // Only show full definition if the symbol is a model or interface that has extends or is clauses. // Avoid showing full definition in other cases which can be long and not useful let includeExpandedDefinition = false; @@ -924,21 +925,108 @@ export function createServer( } } - const markdown: MarkupContent = { + const details = await getSymbolDetails(program, sym[0], { + includeSignature: true, + includeParameterTags: true, + includeExpandedDefinition, + }); + if (details) { + sections.push(details); + } + } + + const diagnosticDoc = await getDiagnosticDocMarkdownAtPosition( + program, + script.file.path, + offset, + ); + if (diagnosticDoc) { + sections.push(diagnosticDoc); + } + + return { + contents: { kind: MarkupKind.Markdown, - value: - sym && sym.length > 0 - ? await getSymbolDetails(program, sym[0], { - includeSignature: true, - includeParameterTags: true, - includeExpandedDefinition, - }) - : "", - }; - return { - contents: markdown, - }; + value: sections.join("\n\n---\n\n"), + }, + }; + } + + /** + * Build a markdown section with the extended documentation (and reference link) of any + * reported diagnostic that covers the given offset in the given file. Reuses the diagnostics + * from the last full compile (matched the same way the squiggles are), since the extended + * documentation cannot be attached to the LSP diagnostic itself. + */ + async function getDiagnosticDocMarkdownAtPosition( + program: Program, + filePath: string, + offset: number, + ): Promise { + const sections: string[] = []; + const seenCodes = new Set(); + for (const diag of currentDiagnosticIndex.values()) { + if (typeof diag.code !== "string" || seenCodes.has(diag.code)) { + continue; + } + const location = getSourceLocation(diag.target, { locateId: true }); + if (!location?.file || location.file.path !== filePath) { + continue; + } + if (offset < location.pos || offset > location.end) { + continue; + } + seenCodes.add(diag.code); + const markdown = await resolveDiagnosticDocMarkdown(program, diag); + if (markdown) { + sections.push(markdown); + } + } + return sections.length > 0 ? sections.join("\n\n---\n\n") : undefined; + } + + /** Resolve the extended docs + reference link markdown for a single reported diagnostic. */ + async function resolveDiagnosticDocMarkdown( + program: Program, + diag: Diagnostic, + ): Promise { + const code = diag.code as string; + const segments = code.split("/"); + const localCode = segments.pop(); + const libName = segments.join("/"); + const sections: string[] = []; + + if (libName && localCode) { + // Reuse the library already loaded by the compiler instead of re-resolving/reloading it. + const library = await program.getLoadedLibraryInfo(libName); + const docs = + library?.$lib?.diagnostics?.[localCode]?.docs ?? + library?.$linter?.rules?.find((rule) => rule.name === localCode)?.docs; + let docText: string | undefined; + if (typeof docs === "string") { + docText = docs; + } else if (isFileRef(docs) && library) { + try { + const file = await compilerHost.readFile(joinPaths(library.packageRoot, docs.path)); + docText = file.text; + } catch (error) { + log({ + level: "debug", + message: `Unable to read extended documentation for diagnostic '${code}'.`, + detail: error, + }); + } + } + if (docText && docText.trim().length > 0) { + sections.push(docText.trim()); + } + } + + if (diag.url) { + sections.push(`[See documentation](${diag.url})`); } + + return sections.length > 0 ? sections.join("\n\n") : undefined; } async function getSignatureHelp(params: SignatureHelpParams): Promise { diff --git a/packages/compiler/test/core/reference-docs.test.ts b/packages/compiler/test/core/reference-docs.test.ts new file mode 100644 index 00000000000..0b6053fb6ba --- /dev/null +++ b/packages/compiler/test/core/reference-docs.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { createLinterRule, createTypeSpecLibrary } from "../../src/core/library.js"; +import { resolveLinterDefinition } from "../../src/core/linter.js"; +import { NoTarget } from "../../src/core/types.js"; + +const baseUrl = "https://example.com/reference/"; + +describe("reference documentation URLs", () => { + it("generates a diagnostic URL only when docs are provided", () => { + const lib = createTypeSpecLibrary({ + name: "test-lib", + referenceDocs: { baseUrl }, + diagnostics: { + documented: { + severity: "error", + docs: "Extended documentation.", + messages: { default: "Documented." }, + }, + undocumented: { + severity: "error", + messages: { default: "Undocumented." }, + }, + }, + }); + + expect(lib.createDiagnostic({ code: "documented", target: NoTarget }).url).toBe( + "https://example.com/reference/diagnostics/documented", + ); + expect(lib.createDiagnostic({ code: "undocumented", target: NoTarget }).url).toBeUndefined(); + }); + + it("preserves an explicit diagnostic URL", () => { + const lib = createTypeSpecLibrary({ + name: "test-lib", + referenceDocs: { baseUrl }, + diagnostics: { + documented: { + severity: "error", + docs: "Extended documentation.", + url: "https://example.com/custom", + messages: { default: "Documented." }, + }, + }, + }); + + expect(lib.createDiagnostic({ code: "documented", target: NoTarget }).url).toBe( + "https://example.com/custom", + ); + }); + + it("generates a linter rule URL only when docs are provided and preserves explicit URLs", () => { + const documented = createLinterRule({ + name: "documented", + severity: "warning", + description: "Documented.", + docs: "Extended documentation.", + messages: { default: "Documented." }, + create: () => ({}), + }); + const undocumented = createLinterRule({ + name: "undocumented", + severity: "warning", + description: "Undocumented.", + messages: { default: "Undocumented." }, + create: () => ({}), + }); + const explicit = createLinterRule({ + name: "explicit", + severity: "warning", + description: "Explicit.", + docs: "Extended documentation.", + url: "https://example.com/custom", + messages: { default: "Explicit." }, + create: () => ({}), + }); + + const resolved = resolveLinterDefinition( + "test-lib", + { rules: [documented, undocumented, explicit] }, + baseUrl, + ); + + expect(resolved.rules.find((x) => x.name === "documented")?.url).toBe( + "https://example.com/reference/rules/documented", + ); + expect(resolved.rules.find((x) => x.name === "undocumented")?.url).toBeUndefined(); + expect(resolved.rules.find((x) => x.name === "explicit")?.url).toBe( + "https://example.com/custom", + ); + }); +}); diff --git a/packages/compiler/test/server/get-hover.test.ts b/packages/compiler/test/server/get-hover.test.ts index 262b364a871..6ed2a38d5ba 100644 --- a/packages/compiler/test/server/get-hover.test.ts +++ b/packages/compiler/test/server/get-hover.test.ts @@ -1,6 +1,8 @@ import { deepStrictEqual, ok } from "assert"; import { describe, it } from "vitest"; import { Hover, MarkupKind } from "vscode-languageserver"; +import { fileRef } from "../../src/core/file-ref.js"; +import { createLinterRule, createTypeSpecLibrary } from "../../src/core/library.js"; import { extractCursor } from "../../src/testing/source-utils.js"; import { createTestServerHost } from "../../src/testing/test-server-host.js"; @@ -836,6 +838,238 @@ describe("template access", () => { }); }); +describe("compiler: server: on hover: diagnostic docs", () => { + it("shows the extended docs and reference link of a diagnostic reported at the position", async () => { + const value = await getHoverValueWithLibDiagnostic({ + docs: "This is the **extended** documentation for always-error.", + referenceDocsBaseUrl: "https://example.com/test-lib/reference", + }); + ok(value); + ok( + value.includes("This is the **extended** documentation for always-error."), + `Expected extended docs in hover, got:\n${value}`, + ); + ok( + value.includes( + "[See documentation](https://example.com/test-lib/reference/diagnostics/always-error)", + ), + `Expected reference link in hover, got:\n${value}`, + ); + }); + + it("reads docs from a co-located file referenced with fileRef", async () => { + const value = await getHoverValueWithLibDiagnostic({ + docs: fileRef.fromPackageRoot("docs/always-error.md"), + docContent: "Documentation loaded from a file.", + }); + ok(value); + ok( + value.includes("Documentation loaded from a file."), + `Expected file-based docs in hover, got:\n${value}`, + ); + }); + + it("resolves fileRef docs from a dependency hoisted to an ancestor node_modules", async () => { + const value = await getHoverValueWithLibDiagnostic({ + docs: fileRef.fromPackageRoot("docs/always-error.md"), + docContent: "Documentation loaded from a hoisted package.", + hoisted: true, + }); + ok(value); + ok( + value.includes("Documentation loaded from a hoisted package."), + `Expected hoisted file-based docs in hover, got:\n${value}`, + ); + }); + + it("does not show a dead reference link when the diagnostic has no docs", async () => { + const value = await getHoverValueWithLibDiagnostic({ + referenceDocsBaseUrl: "https://example.com/test-lib/reference", + }); + ok(value); + ok( + !value.includes("[See documentation]"), + `Expected no reference link in hover, got:\n${value}`, + ); + }); + + // Regression: a library's `$linter` commonly lives only in its default entry (e.g. http's + // `index.js`), while the entry imported by `.tsp` files (the `typespec` condition) exposes + // only `$lib`. The hover must still resolve linter-rule docs from the default entry. + it("shows linter rule docs whose $linter is only in the library's default entry", async () => { + const testHost = await createTestServerHost(); + const packageRoot = "test/node_modules/linter-lib"; + + const $lib = createTypeSpecLibrary({ + name: "linter-lib", + referenceDocs: { baseUrl: "https://example.com/linter-lib/reference" }, + diagnostics: {}, + } as any); + const noModelsRule = createLinterRule({ + name: "no-models", + severity: "warning", + description: "Models are not allowed.", + docs: "Extended docs for the **no-models** rule (from the default entry).", + messages: { default: "Models are not allowed." }, + create(context) { + return { model: (model) => context.reportDiagnostic({ target: model }) }; + }, + }); + + // `typespec` condition entry: only `$lib`, NO `$linter`. + testHost.addJsFile(`${packageRoot}/tsp-index.js`, { $lib }); + // default/`import` condition entry: both `$lib` and `$linter`. + testHost.addJsFile(`${packageRoot}/index.js`, { $lib, $linter: { rules: [noModelsRule] } }); + testHost.addTypeSpecFile( + `${packageRoot}/package.json`, + JSON.stringify({ + name: "linter-lib", + version: "0.1.0", + exports: { ".": { typespec: "./main.tsp", import: "./index.js" } }, + peerDependencies: { "@typespec/compiler": "*" }, + }), + ); + testHost.addTypeSpecFile( + `${packageRoot}/main.tsp`, + `import "./tsp-index.js";\nnamespace LinterLib;\n`, + ); + testHost.addTypeSpecFile( + "test/package.json", + JSON.stringify({ dependencies: { "linter-lib": "*" } }), + ); + testHost.addTypeSpecFile( + "test/tspconfig.yaml", + "linter:\n enable:\n 'linter-lib/no-models': true", + ); + + const { source, pos } = extractCursor(` + import "linter-lib"; + + model Fo┆o {} + `); + const document = testHost.addOrUpdateDocument("test/main.tsp", source); + await testHost.server.compile(document, undefined, { mode: "full" }); + + const value = getHoverValue( + await testHost.server.getHover({ + textDocument: document, + position: document.positionAt(pos), + }), + ); + ok(value); + ok( + value.includes("Extended docs for the **no-models** rule (from the default entry)."), + `Expected linter rule docs in hover, got:\n${value}`, + ); + }); + + async function getHoverValueWithLibDiagnostic(options: { + docs?: string | ReturnType; + referenceDocsBaseUrl?: string; + docContent?: string; + hoisted?: boolean; + }): Promise { + const testHost = await createTestServerHost(); + const projectRoot = options.hoisted ? "workspace/packages/test" : "test"; + const packageRoot = options.hoisted + ? "workspace/node_modules/test-lib" + : `${projectRoot}/node_modules/test-lib`; + + addTestLibrary(testHost, packageRoot, options); + const { document, pos } = addTestProject(testHost, projectRoot, "Foo"); + + // Full compile first so linter/library diagnostics are indexed for hover to pick up. + await testHost.server.compile(document, undefined, { mode: "full" }); + + return getHoverValue( + await testHost.server.getHover({ + textDocument: document, + position: document.positionAt(pos), + }), + ); + } + + function addTestLibrary( + testHost: Awaited>, + packageRoot: string, + options: { + docs?: string | ReturnType; + referenceDocsBaseUrl?: string; + docContent?: string; + }, + ) { + const $lib = createTypeSpecLibrary({ + name: "test-lib", + ...(options.referenceDocsBaseUrl + ? { referenceDocs: { baseUrl: options.referenceDocsBaseUrl } } + : {}), + diagnostics: { + "always-error": { + severity: "error", + ...(options.docs !== undefined ? { docs: options.docs } : {}), + messages: { default: "Always errors." }, + }, + }, + } as any); + + testHost.addJsFile(`${packageRoot}/index.js`, { + $lib, + $decorators: { + TestLib: { + alwaysError: (context: any, target: any) => { + ($lib as any).reportDiagnostic(context.program, { + code: "always-error", + target, + }); + }, + }, + }, + }); + testHost.addTypeSpecFile( + `${packageRoot}/package.json`, + JSON.stringify({ + name: "test-lib", + version: "0.1.0", + exports: { + ".": { + import: "./index.js", + typespec: "./main.tsp", + }, + }, + peerDependencies: { "@typespec/compiler": "*" }, + }), + ); + testHost.addTypeSpecFile( + `${packageRoot}/main.tsp`, + `import "./index.js";\nnamespace TestLib;\nextern dec alwaysError(target: unknown);\n`, + ); + if (options.docContent !== undefined) { + testHost.addTypeSpecFile(`${packageRoot}/docs/always-error.md`, options.docContent); + } + } + + function addTestProject( + testHost: Awaited>, + projectRoot: string, + modelName: string, + ) { + testHost.addTypeSpecFile( + `${projectRoot}/package.json`, + JSON.stringify({ dependencies: { "test-lib": "*" } }), + ); + + const { source, pos } = extractCursor(` + import "test-lib"; + using TestLib; + + @alwaysError + model ${modelName.slice(0, -1)}┆${modelName.slice(-1)} {} + `); + const document = testHost.addOrUpdateDocument(`${projectRoot}/main.tsp`, source); + return { document, pos }; + } +}); + async function getHoverAtCursor(sourceWithCursor: string): Promise { const { source, pos } = extractCursor(sourceWithCursor); const testHost = await createTestServerHost(); diff --git a/packages/http/README.md b/packages/http/README.md index 3600a1dfb10..5f7dc9e0d3c 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -26,9 +26,9 @@ Available ruleSets: ## Rules -| Name | Description | -| --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| [`@typespec/http/op-reference-container-route`](https://typespec.io/docs/libraries/http/rules/op-reference-container-route) | Check for referenced (`op is`) operations which have a @route on one of their containers. | +| Name | Description | +| ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| [`@typespec/http/op-reference-container-route`](https://typespec.io/docs/libraries/http/reference/rules/op-reference-container-route) | Check for referenced (`op is`) operations which have a @route on one of their containers. | ## Decorators diff --git a/packages/http/package.json b/packages/http/package.json index a0c0c7b77c5..c4478314361 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -70,6 +70,7 @@ }, "files": [ "lib/**/*.tsp", + "src/**/*.md", "dist/**", "!dist/test/**" ], diff --git a/packages/http/src/lib.ts b/packages/http/src/lib.ts index 344bdf7763b..46c42d5dbdf 100644 --- a/packages/http/src/lib.ts +++ b/packages/http/src/lib.ts @@ -2,6 +2,7 @@ import { createTypeSpecLibrary, paramMessage } from "@typespec/compiler"; export const $lib = createTypeSpecLibrary({ name: "@typespec/http", + referenceDocs: { baseUrl: "https://typespec.io/docs/libraries/http/reference" }, diagnostics: { "http-verb-duplicate": { severity: "error", diff --git a/packages/http/src/rules/op-reference-container-route.md b/packages/http/src/rules/op-reference-container-route.md new file mode 100644 index 00000000000..509246c8c54 --- /dev/null +++ b/packages/http/src/rules/op-reference-container-route.md @@ -0,0 +1,38 @@ +When referencing an operation with `op is`, only the data on the operation itself is carried over; anything on the parent container is lost. +This results in unexpected behavior where information is lost. +As a best practice the route should be provided on the operation itself. + +#### ❌ Incorrect + +```tsp +namespace Library { + @route("/pets") + interface Pets { + @route("/read") read(): string; + } +} + +@service +namespace Service { + interface PetStore { + readPet is Library.Pets.read; + } +} +``` + +#### ✅ Correct + +```tsp +namespace Library { + interface Pets { + @route("/pets/read") read(): string; + } +} + +@service +namespace Service { + interface PetStore { + readPet is Library.Pets.read; + } +} +``` diff --git a/packages/http/src/rules/op-reference-container-route.ts b/packages/http/src/rules/op-reference-container-route.ts index 2f59f779efd..0868f7166f6 100644 --- a/packages/http/src/rules/op-reference-container-route.ts +++ b/packages/http/src/rules/op-reference-container-route.ts @@ -1,4 +1,4 @@ -import { Operation, createRule, paramMessage } from "@typespec/compiler"; +import { Operation, createRule, fileRef, paramMessage } from "@typespec/compiler"; import { getRoutePath } from "../route.js"; import { OperationContainer } from "../types.js"; @@ -7,7 +7,7 @@ export const opReferenceContainerRouteRule = createRule({ severity: "warning", description: "Check for referenced (`op is`) operations which have a @route on one of their containers.", - url: "https://typespec.io/docs/libraries/http/rules/op-reference-container-route", + docs: fileRef.fromPackageRoot("src/rules/op-reference-container-route.md"), messages: { default: paramMessage`Operation ${"opName"} references an operation which has a @route prefix on its namespace or interface: "${"routePrefix"}". This operation will not carry forward the route prefix so the final route may be different than the referenced operation.`, }, diff --git a/packages/openapi3/package.json b/packages/openapi3/package.json index f503c082720..e0a97269b3a 100644 --- a/packages/openapi3/package.json +++ b/packages/openapi3/package.json @@ -59,6 +59,7 @@ }, "files": [ "lib/*.tsp", + "src/**/*.md", "dist/**", "!dist/test/**" ], diff --git a/packages/openapi3/src/diagnostics/duplicate-header.md b/packages/openapi3/src/diagnostics/duplicate-header.md new file mode 100644 index 00000000000..0ddd10ecde7 --- /dev/null +++ b/packages/openapi3/src/diagnostics/duplicate-header.md @@ -0,0 +1,22 @@ +This diagnostic is issued when a response header is defined more than once for a response of a specific status code. + +To fix this issue, ensure that each response header is defined only once for each status code. + +### Example + +```yaml +responses: + "200": + description: Successful response + headers: + X-Rate-Limit: + description: The number of allowed requests in the current period + schema: + type: integer + X-Rate-Limit: + description: The number of allowed requests in the current period + schema: + type: integer +``` + +In this example, the `X-Rate-Limit` header is defined twice for the `200` status code. To fix this issue, remove the duplicate header definition. diff --git a/packages/openapi3/src/diagnostics/inline-cycle.md b/packages/openapi3/src/diagnostics/inline-cycle.md new file mode 100644 index 00000000000..4e4c3b635b5 --- /dev/null +++ b/packages/openapi3/src/diagnostics/inline-cycle.md @@ -0,0 +1,19 @@ +This diagnostic is issued when a cyclic reference is detected within inline schemas. + +To fix this issue, refactor the schemas to remove the cyclic reference. + +### Example + +```yaml +components: + schemas: + Node: + type: object + properties: + value: + type: string + next: + $ref: "#/components/schemas/Node" +``` + +In this example, the `Node` schema references itself, creating a cyclic reference. To fix this issue, refactor the schema to remove the cyclic reference. diff --git a/packages/openapi3/src/diagnostics/invalid-schema.md b/packages/openapi3/src/diagnostics/invalid-schema.md new file mode 100644 index 00000000000..1e42f170432 --- /dev/null +++ b/packages/openapi3/src/diagnostics/invalid-schema.md @@ -0,0 +1,20 @@ +This diagnostic is issued when a schema is invalid according to the OpenAPI v3 specification. + +To fix this issue, review your TypeSpec definitions to ensure they map to valid OpenAPI schemas. + +### Example + +```yaml +components: + schemas: + User: + type: object + properties: + id: + type: string + age: + type: integer + format: "int" # Invalid format +``` + +In this example, the `format` value for the `age` property is invalid. To fix this issue, provide a valid format value such as `int32` or `int64`. diff --git a/packages/openapi3/src/diagnostics/invalid-server-variable.md b/packages/openapi3/src/diagnostics/invalid-server-variable.md new file mode 100644 index 00000000000..fe1c6d6d453 --- /dev/null +++ b/packages/openapi3/src/diagnostics/invalid-server-variable.md @@ -0,0 +1,14 @@ +This diagnostic is issued when a variable in the `@server` decorator is not defined as a string type. +Since server variables are substituted into the server URL which is a string, all variables must have string values. + +To fix this issue, make sure all server variables are of a type that is assignable to `string`. + +### Example + +```typespec +@server("{protocol}://{host}/api/{version}", "Custom endpoint", { + protocol: "http" | "https", + host: string, + version: 1, // Should be a string: "1" +}) +``` diff --git a/packages/openapi3/src/diagnostics/path-query.md b/packages/openapi3/src/diagnostics/path-query.md new file mode 100644 index 00000000000..38e8aee9194 --- /dev/null +++ b/packages/openapi3/src/diagnostics/path-query.md @@ -0,0 +1,26 @@ +This diagnostic is issued when the OpenAPI emitter finds an `@route` decorator that specifies a path that contains a query parameter. This is not permitted by the OpenAPI v3 specification, which requires query parameters to be defined separately. + +To fix this issue, redesign the API to only use paths without query parameters, and define query parameters using the `@query` decorator. + +### Example + +Instead of: + +```typespec +@route("/users?filter={filter}") +op getUsers(filter: string): User[]; +``` + +Use: + +```typespec +@route("/users") +op getUsers(@query filter?: string): User[]; +``` + +Alternatively, you can leverage TypeSpec's support for URI templates: + +```typespec +@route("/users{?filter}") +op getUsers(filter?: string): User[]; +``` diff --git a/packages/openapi3/src/diagnostics/union-null.md b/packages/openapi3/src/diagnostics/union-null.md new file mode 100644 index 00000000000..f75fc66f844 --- /dev/null +++ b/packages/openapi3/src/diagnostics/union-null.md @@ -0,0 +1,3 @@ +This diagnostic is issued when the result of model composition is effectively a `null` schema which cannot be represented in OpenAPI. + +To fix this issue, review your model compositions to ensure they produce valid schemas with actual properties or types. diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index 48e6ef75d12..2bf1077bf05 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, fileRef, JSONSchemaType, paramMessage } from "@typespec/compiler"; export type FileType = "yaml" | "json"; export type OpenAPIVersion = "3.0.0" | "3.1.0" | "3.2.0"; @@ -302,6 +302,7 @@ const EmitterOptionsSchema: JSONSchemaType = { export const $lib = createTypeSpecLibrary({ name: "@typespec/openapi3", + referenceDocs: { baseUrl: "https://typespec.io/docs/emitters/openapi3/reference" }, capabilities: { dryRun: true, }, @@ -321,6 +322,7 @@ export const $lib = createTypeSpecLibrary({ }, "invalid-server-variable": { severity: "error", + docs: fileRef.fromPackageRoot("src/diagnostics/invalid-server-variable.md"), messages: { default: paramMessage`Server variable '${"propName"}' must be assignable to 'string'. It must either be a string, enum of string or union of strings.`, }, @@ -352,12 +354,14 @@ export const $lib = createTypeSpecLibrary({ }, "path-query": { severity: "error", + docs: fileRef.fromPackageRoot("src/diagnostics/path-query.md"), messages: { default: `OpenAPI does not allow paths containing a query string.`, }, }, "duplicate-header": { severity: "error", + docs: fileRef.fromPackageRoot("src/diagnostics/duplicate-header.md"), messages: { default: paramMessage`The header ${"header"} is defined across multiple content types`, }, @@ -371,12 +375,14 @@ export const $lib = createTypeSpecLibrary({ "invalid-schema": { severity: "error", + docs: fileRef.fromPackageRoot("src/diagnostics/invalid-schema.md"), messages: { default: paramMessage`Couldn't get schema for type ${"type"}`, }, }, "union-null": { severity: "error", + docs: fileRef.fromPackageRoot("src/diagnostics/union-null.md"), messages: { default: "Cannot have a union containing only null types.", }, @@ -403,6 +409,7 @@ export const $lib = createTypeSpecLibrary({ }, "inline-cycle": { severity: "error", + docs: fileRef.fromPackageRoot("src/diagnostics/inline-cycle.md"), messages: { default: paramMessage`Cycle detected in '${"type"}'. Use @friendlyName decorator to assign an OpenAPI definition name and make it non-inline.`, }, diff --git a/packages/tspd/src/ref-doc/emitters/starlight.ts b/packages/tspd/src/ref-doc/emitters/starlight.ts index f43142108ea..96d88252daa 100644 --- a/packages/tspd/src/ref-doc/emitters/starlight.ts +++ b/packages/tspd/src/ref-doc/emitters/starlight.ts @@ -1,5 +1,6 @@ import { DeprecationNotice, + DiagnosticRefDoc, LinterRuleRefDoc, NamedTypeRefDoc, RefDocEntity, @@ -57,6 +58,17 @@ export function renderToAstroStarlightMarkdown( files["linter.md"] = linter; } + for (const rule of refDoc.linter?.rules ?? []) { + files[`rules/${rule.rule.name}.md`] = renderRule(rule); + } + + // Generate one page per documented diagnostic, under `diagnostics/`. No index page. + for (const diagnostic of refDoc.diagnostics ?? []) { + if (diagnostic.doc) { + files[`diagnostics/${diagnostic.name}.md`] = renderDiagnostic(diagnostic); + } + } + // Render sub-exports if (refDoc.subExports) { for (const [exportPath, subExport] of refDoc.subExports) { @@ -277,7 +289,38 @@ function renderLinter( "---", renderer.linterUsage(refDoc), ]; + return renderMarkdowDoc(content, 2); +} + +function renderRule(rule: LinterRuleRefDoc): string { + const content: MarkdownDoc = [ + "---", + `title: "${rule.rule.name}"`, + "---", + "", + codeblock(rule.name, 'text title="Id"'), + "", + rule.rule.description, + ]; + if (rule.doc) { + content.push("", rule.doc); + } + return renderMarkdowDoc(content, 2); +} +function renderDiagnostic(diagnostic: DiagnosticRefDoc): string { + const content: MarkdownDoc = [ + "---", + `title: "${diagnostic.name}"`, + "---", + "", + codeblock(diagnostic.id, 'text title="Id"'), + "", + `**Severity:** ${diagnostic.severity}`, + ]; + if (diagnostic.doc) { + content.push("", diagnostic.doc); + } return renderMarkdowDoc(content, 2); } @@ -430,7 +473,7 @@ export class StarlightRenderer extends MarkdownRenderer { } linterRuleLink(rule: LinterRuleRefDoc) { - return `../rules/${rule.rule.name}.md`; + return `./rules/${rule.rule.name}.md`; } deprecationNotice(notice: DeprecationNotice): MarkdownDoc { diff --git a/packages/tspd/src/ref-doc/extractor.ts b/packages/tspd/src/ref-doc/extractor.ts index 55614f5d95a..ff0e5473469 100644 --- a/packages/tspd/src/ref-doc/extractor.ts +++ b/packages/tspd/src/ref-doc/extractor.ts @@ -4,6 +4,7 @@ import { createDiagnosticCollector, Decorator, Diagnostic, + DiagnosticDefinition, DocContent, Enum, EnumMember, @@ -37,15 +38,18 @@ import { TypeSpecLibrary, Union, UnionVariant, + type FileRef, type PackageJson, } from "@typespec/compiler"; import { SyntaxKind, type DocUnknownTagNode } from "@typespec/compiler/ast"; +import { readFileSync } from "fs"; import { readFile } from "fs/promises"; import { pathToFileURL } from "url"; -import { reportDiagnostic } from "./lib.js"; +import { createDiagnostic, reportDiagnostic } from "./lib.js"; import { DecoratorRefDoc, DeprecationNotice, + DiagnosticRefDoc, EmitterOptionRefDoc, EmitterOptionVariantRefDoc, EnumMemberRefDoc, @@ -125,7 +129,39 @@ export async function extractLibraryRefDocs( } const linter = entrypoint.$linter; if (lib && linter) { - refDoc.linter = extractLinterRefDoc(lib.name, resolveLinterDefinition(lib.name, linter)); + const resolved = resolveLinterDefinition(lib.name, linter, lib.referenceDocs?.baseUrl); + refDoc.linter = extractLinterRefDoc(lib.name, resolved, libraryPath); + for (const r of refDoc.linter.rules) { + if (!r.doc) { + diagnostics.add( + createDiagnostic({ + code: "documentation-missing", + messageId: "rule", + format: { name: r.id }, + target: NoTarget, + }), + ); + } + } + } + if (lib?.diagnostics) { + refDoc.diagnostics = extractDiagnosticsRefDoc( + lib.name, + lib.diagnostics as Record>, + libraryPath, + ); + for (const diag of refDoc.diagnostics) { + if (!diag.doc) { + diagnostics.add( + createDiagnostic({ + code: "documentation-missing", + messageId: "diagnostic", + format: { name: diag.id }, + target: NoTarget, + }), + ); + } + } } } @@ -904,13 +940,40 @@ function resolveDescription(description: string | string[] | undefined): string return Array.isArray(description) ? description.join("\n") : description; } -function extractLinterRefDoc(libName: string, linter: LinterResolvedDefinition): LinterRefDoc { +function resolveDoc(doc: string | FileRef | undefined, libraryPath: string): string | undefined { + if (doc === undefined) return undefined; + if (typeof doc === "string") return doc; + try { + return readFileSync(joinPaths(libraryPath, doc.path), "utf-8"); + } catch { + return undefined; + } +} + +function extractLinterRefDoc( + libName: string, + linter: LinterResolvedDefinition, + libraryPath: string, +): LinterRefDoc { return { ruleSets: linter.ruleSets && extractLinterRuleSetsRefDoc(libName, linter.ruleSets), - rules: linter.rules.map((rule) => extractLinterRuleRefDoc(libName, rule)), + rules: linter.rules.map((rule) => extractLinterRuleRefDoc(libName, rule, libraryPath)), }; } +function extractDiagnosticsRefDoc( + libName: string, + diagnostics: Record>, + libraryPath: string, +): DiagnosticRefDoc[] { + return Object.entries(diagnostics).map(([name, def]) => ({ + id: `${libName}/${name}`, + name, + severity: def.severity, + doc: resolveDoc(def.docs, libraryPath), + })); +} + function extractLinterRuleSetsRefDoc( libName: string, ruleSets: Record, @@ -928,6 +991,7 @@ function extractLinterRuleSetsRefDoc( function extractLinterRuleRefDoc( libName: string, rule: LinterRuleDefinition, + libraryPath: string, ): LinterRuleRefDoc { const fullName = `${libName}/${rule.name}`; return { @@ -935,5 +999,6 @@ function extractLinterRuleRefDoc( id: fullName, name: fullName, rule, + doc: resolveDoc(rule.docs, libraryPath), }; } diff --git a/packages/tspd/src/ref-doc/lib.ts b/packages/tspd/src/ref-doc/lib.ts index 482e4ce199d..2bab7b16011 100644 --- a/packages/tspd/src/ref-doc/lib.ts +++ b/packages/tspd/src/ref-doc/lib.ts @@ -23,6 +23,8 @@ export const libDef = { interfaceOperation: paramMessage`Missing documentation for interface operation '${"name"}'.`, operation: paramMessage`Missing documentation for operation '${"name"}'.`, scalar: paramMessage`Missing documentation for scalar '${"name"}'.`, + rule: paramMessage`Missing documentation for linter rule '${"name"}'.`, + diagnostic: paramMessage`Missing documentation for diagnostic '${"name"}'.`, }, }, }, diff --git a/packages/tspd/src/ref-doc/types.ts b/packages/tspd/src/ref-doc/types.ts index 7929a9a2d4a..d9beb409e58 100644 --- a/packages/tspd/src/ref-doc/types.ts +++ b/packages/tspd/src/ref-doc/types.ts @@ -39,6 +39,9 @@ export type TypeSpecLibraryRefDoc = TypeSpecRefDocBase & { /** Documentation about the linter rules and ruleset provided in this library. */ readonly linter?: LinterRefDoc; + /** Documentation about the diagnostics that this library can report. */ + readonly diagnostics?: readonly DiagnosticRefDoc[]; + /** Documentation for sub-exports (e.g., "./streams", "./testing"). Keyed by export path. */ readonly subExports?: ReadonlyMap; }; @@ -72,6 +75,14 @@ export type LinterRuleSetRefDoc = ReferencableElement & { export type LinterRuleRefDoc = ReferencableElement & { readonly kind: "rule"; readonly rule: LinterRuleDefinition; + /** Extended documentation as raw markdown, read from a co-located `.md` file. */ + readonly doc?: string; +}; + +export type DiagnosticRefDoc = ReferencableElement & { + readonly severity: "warning" | "error"; + /** Extended documentation as raw markdown, read from a co-located `.md` file. */ + readonly doc?: string; }; export type EmitterOptionRefDoc = { diff --git a/website/src/content/current-sidebar.ts b/website/src/content/current-sidebar.ts index b6e8393692f..7e5b68baf66 100644 --- a/website/src/content/current-sidebar.ts +++ b/website/src/content/current-sidebar.ts @@ -124,7 +124,7 @@ const sidebar: SidebarItem[] = [ { label: "📚 Libraries", items: [ - createLibraryReferenceStructure("libraries/http", "Http", true, [ + createLibraryReferenceStructure("libraries/http", "Http", false, [ "libraries/http/cheat-sheet", "libraries/http/authentication", "libraries/http/operations", @@ -168,7 +168,6 @@ const sidebar: SidebarItem[] = [ createLibraryReferenceStructure("emitters/openapi3", "OpenAPI3", false, [ "emitters/openapi3/openapi", "emitters/openapi3/cli", - "emitters/openapi3/diagnostics", ]), createLibraryReferenceStructure( "emitters/protobuf", diff --git a/website/src/content/docs/docs/emitters/openapi3/diagnostics.md b/website/src/content/docs/docs/emitters/openapi3/diagnostics.md deleted file mode 100644 index e642ce30029..00000000000 --- a/website/src/content/docs/docs/emitters/openapi3/diagnostics.md +++ /dev/null @@ -1,203 +0,0 @@ ---- -title: Diagnostics ---- - -The OpenAPI emitter may produce any of the following diagnostic messages. - - - -## duplicate-header - -This diagnostic is issued when a response header is defined more than once for a response of a specific status code. - -To fix this issue, ensure that each response header is defined only once for each status code. - -### Example - -```yaml -responses: - "200": - description: Successful response - headers: - X-Rate-Limit: - description: The number of allowed requests in the current period - schema: - type: integer - X-Rate-Limit: - description: The number of allowed requests in the current period - schema: - type: integer -``` - -In this example, the `X-Rate-Limit` header is defined twice for the `200` status code. To fix this issue, remove the duplicate header definition. - -## duplicate-type-name - -This diagnostic is issued when a schema or parameter name is a duplicate of another schema or parameter. This generally happens when a model or parameter is renamed with the `@friendlyName` decorator, resulting in two different TypeSpec types getting the same name in the OpenAPI output. - -To fix this issue, change the name or friendly-name of one of the models or parameters. - -### Example - -```typespec -@friendlyName("User") -model Customer { - id: string; -} - -model User { - id: string; -} -``` - -In this example, both `Customer` and `User` would appear as `User` in the OpenAPI output, causing a conflict. - -## inline-cycle - -This diagnostic is issued when a cyclic reference is detected within inline schemas. - -To fix this issue, refactor the schemas to remove the cyclic reference. - -### Example - -```yaml -components: - schemas: - Node: - type: object - properties: - value: - type: string - next: - $ref: "#/components/schemas/Node" -``` - -In this example, the `Node` schema references itself, creating a cyclic reference. To fix this issue, refactor the schema to remove the cyclic reference. - -## invalid-default - -This diagnostic is issued when a default value is invalid for the specified schema type. - -To fix this issue, ensure that the default value is valid for the schema type. - -### Example - -```yaml -components: - schemas: - User: - type: object - properties: - age: - type: integer - default: "twenty" -``` - -In this example, the `default` value for the `age` property is invalid because it is a string instead of an integer. To fix this issue, provide a valid default value, such as `20`. - -## invalid-extension-key - -This diagnostic is issued by the `@extension` decorator when the extension key does not start with "x-" as -required by the OpenAPI v3 specification. - -To fix this issue, change the extension name to start with "x-". - -### Example - -```typespec -@extension("invalid-name", "value") -model User { - id: string; -} -``` - -Should be changed to: - -```typespec -@extension("x-valid-name", "value") -model User { - id: string; -} -``` - -## invalid-schema - -This diagnostic is issued when a schema is invalid according to the OpenAPI v3 specification. - -To fix this issue, review your TypeSpec definitions to ensure they map to valid OpenAPI schemas. - -### Example - -```yaml -components: - schemas: - User: - type: object - properties: - id: - type: string - age: - type: integer - format: "int" # Invalid format -``` - -In this example, the `format` value for the `age` property is invalid. To fix this issue, provide a valid format value such as `int32` or `int64`. - -## invalid-server-variable - -This diagnostic is issued when a variable in the `@server` decorator is not defined as a string type. -Since server variables are substituted into the server URL which is a string, all variables must have string values. - -To fix this issue, make sure all server variables are of a type that is assignable to `string`. - -### Example - -```typespec -@server("{protocol}://{host}/api/{version}", "Custom endpoint", { - protocol: "http" | "https", - host: string, - version: 1, // Should be a string: "1" -}) -``` - -## path-query - -This diagnostic is issued when the OpenAPI emitter finds an `@route` decorator that specifies a path that contains a query parameter. This is not permitted by the OpenAPI v3 specification, which requires query parameters to be defined separately. - -To fix this issue, redesign the API to only use paths without query parameters, and define query parameters using the `@query` decorator. - -### Example - -Instead of: - -```typespec -@route("/users?filter={filter}") -op getUsers(filter: string): User[]; -``` - -Use: - -```typespec -@route("/users") -op getUsers(@query filter?: string): User[]; -``` - -Alternatively, you can leverage TypeSpec's support for URI templates: - -```typespec -@route("/users{?filter}") -op getUsers(filter?: string): User[]; -``` - -## union-null - -This diagnostic is issued when the result of model composition is effectively a `null` schema which cannot be -represented in OpenAPI. - -To fix this issue, review your model compositions to ensure they produce valid schemas with actual properties or types. - -## union-unsupported - -This diagnostic is issued when the OpenAPI emitter finds a union of two incompatible types that cannot be represented in OpenAPI. OpenAPI has limited support for union types, and some combinations cannot be expressed. - -To fix this issue, consider restructuring your types to avoid incompatible unions, or split the operation into multiple operations with different return types. diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/duplicate-header.md b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/duplicate-header.md new file mode 100644 index 00000000000..935f3dda65c --- /dev/null +++ b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/duplicate-header.md @@ -0,0 +1,32 @@ +--- +title: "duplicate-header" +--- + +```text title="Id" +@typespec/openapi3/duplicate-header +``` + +**Severity:** error + +This diagnostic is issued when a response header is defined more than once for a response of a specific status code. + +To fix this issue, ensure that each response header is defined only once for each status code. + +### Example + +```yaml +responses: + "200": + description: Successful response + headers: + X-Rate-Limit: + description: The number of allowed requests in the current period + schema: + type: integer + X-Rate-Limit: + description: The number of allowed requests in the current period + schema: + type: integer +``` + +In this example, the `X-Rate-Limit` header is defined twice for the `200` status code. To fix this issue, remove the duplicate header definition. diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/inline-cycle.md b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/inline-cycle.md new file mode 100644 index 00000000000..62e392e1c08 --- /dev/null +++ b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/inline-cycle.md @@ -0,0 +1,29 @@ +--- +title: "inline-cycle" +--- + +```text title="Id" +@typespec/openapi3/inline-cycle +``` + +**Severity:** error + +This diagnostic is issued when a cyclic reference is detected within inline schemas. + +To fix this issue, refactor the schemas to remove the cyclic reference. + +### Example + +```yaml +components: + schemas: + Node: + type: object + properties: + value: + type: string + next: + $ref: "#/components/schemas/Node" +``` + +In this example, the `Node` schema references itself, creating a cyclic reference. To fix this issue, refactor the schema to remove the cyclic reference. diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/invalid-schema.md b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/invalid-schema.md new file mode 100644 index 00000000000..ad052ec03b8 --- /dev/null +++ b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/invalid-schema.md @@ -0,0 +1,30 @@ +--- +title: "invalid-schema" +--- + +```text title="Id" +@typespec/openapi3/invalid-schema +``` + +**Severity:** error + +This diagnostic is issued when a schema is invalid according to the OpenAPI v3 specification. + +To fix this issue, review your TypeSpec definitions to ensure they map to valid OpenAPI schemas. + +### Example + +```yaml +components: + schemas: + User: + type: object + properties: + id: + type: string + age: + type: integer + format: "int" # Invalid format +``` + +In this example, the `format` value for the `age` property is invalid. To fix this issue, provide a valid format value such as `int32` or `int64`. diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/invalid-server-variable.md b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/invalid-server-variable.md new file mode 100644 index 00000000000..26c49e3aee1 --- /dev/null +++ b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/invalid-server-variable.md @@ -0,0 +1,24 @@ +--- +title: "invalid-server-variable" +--- + +```text title="Id" +@typespec/openapi3/invalid-server-variable +``` + +**Severity:** error + +This diagnostic is issued when a variable in the `@server` decorator is not defined as a string type. +Since server variables are substituted into the server URL which is a string, all variables must have string values. + +To fix this issue, make sure all server variables are of a type that is assignable to `string`. + +### Example + +```typespec +@server("{protocol}://{host}/api/{version}", "Custom endpoint", { + protocol: "http" | "https", + host: string, + version: 1, // Should be a string: "1" +}) +``` diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/path-query.md b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/path-query.md new file mode 100644 index 00000000000..22a9107204f --- /dev/null +++ b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/path-query.md @@ -0,0 +1,36 @@ +--- +title: "path-query" +--- + +```text title="Id" +@typespec/openapi3/path-query +``` + +**Severity:** error + +This diagnostic is issued when the OpenAPI emitter finds an `@route` decorator that specifies a path that contains a query parameter. This is not permitted by the OpenAPI v3 specification, which requires query parameters to be defined separately. + +To fix this issue, redesign the API to only use paths without query parameters, and define query parameters using the `@query` decorator. + +### Example + +Instead of: + +```typespec +@route("/users?filter={filter}") +op getUsers(filter: string): User[]; +``` + +Use: + +```typespec +@route("/users") +op getUsers(@query filter?: string): User[]; +``` + +Alternatively, you can leverage TypeSpec's support for URI templates: + +```typespec +@route("/users{?filter}") +op getUsers(filter?: string): User[]; +``` diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/union-null.md b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/union-null.md new file mode 100644 index 00000000000..4d1113c7785 --- /dev/null +++ b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/union-null.md @@ -0,0 +1,13 @@ +--- +title: "union-null" +--- + +```text title="Id" +@typespec/openapi3/union-null +``` + +**Severity:** error + +This diagnostic is issued when the result of model composition is effectively a `null` schema which cannot be represented in OpenAPI. + +To fix this issue, review your model compositions to ensure they produce valid schemas with actual properties or types. diff --git a/website/src/content/docs/docs/libraries/http/reference/linter.md b/website/src/content/docs/docs/libraries/http/reference/linter.md index d51c365da51..f6e795cd91e 100644 --- a/website/src/content/docs/docs/libraries/http/reference/linter.md +++ b/website/src/content/docs/docs/libraries/http/reference/linter.md @@ -20,6 +20,6 @@ Available ruleSets: ## Rules -| Name | Description | -| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| [`@typespec/http/op-reference-container-route`](../rules/op-reference-container-route.md) | Check for referenced (`op is`) operations which have a @route on one of their containers. | +| Name | Description | +| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| [`@typespec/http/op-reference-container-route`](./rules/op-reference-container-route.md) | Check for referenced (`op is`) operations which have a @route on one of their containers. | diff --git a/website/src/content/docs/docs/libraries/http/rules/op-reference-container-route.md b/website/src/content/docs/docs/libraries/http/reference/rules/op-reference-container-route.md similarity index 67% rename from website/src/content/docs/docs/libraries/http/rules/op-reference-container-route.md rename to website/src/content/docs/docs/libraries/http/reference/rules/op-reference-container-route.md index 1574664af43..2a746e9f825 100644 --- a/website/src/content/docs/docs/libraries/http/rules/op-reference-container-route.md +++ b/website/src/content/docs/docs/libraries/http/reference/rules/op-reference-container-route.md @@ -6,10 +6,10 @@ title: "op-reference-container-route" @typespec/http/op-reference-container-route ``` -Check for referenced (`op is`) operations which have a `@route` on one of their containers. +Check for referenced (`op is`) operations which have a @route on one of their containers. -When referencing an operation with `op is` only the data on the operation itself is carried over anything on parent container is lost. -This result in unexpected behavior where information is lost. +When referencing an operation with `op is`, only the data on the operation itself is carried over; anything on the parent container is lost. +This results in unexpected behavior where information is lost. As a best practice the route should be provided on the operation itself. #### ❌ Incorrect