From 14d52c913b9694ce2d453cfd8b832c976cb31b51 Mon Sep 17 00:00:00 2001 From: tadelesh Date: Thu, 9 Jul 2026 16:35:46 +0800 Subject: [PATCH 1/7] feat(tspd): auto-generate linter rule and diagnostic reference pages Add an optional docs field (raw markdown, loaded from a co-located .md file) to linter rule (createRule) and diagnostic (createTypeSpecLibrary) definitions. tspd renders a page per rule (reference/rules/.md) and per diagnostic (reference/diagnostics/.md) plus a diagnostics index, and reports documentation-missing for undocumented rules/diagnostics. Migrates @typespec/http op-reference-container-route and @typespec/openapi3 path-query as pilots. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../diagnostic-docs-openapi3-2026-6-9.md | 7 + .../rule-diagnostic-docs-tspd-2026-6-9.md | 7 + .chronus/changes/rule-docs-http-2026-6-9.md | 7 + packages/http/README.md | 6 +- .../src/rules/op-reference-container-route.md | 38 ++++ .../src/rules/op-reference-container-route.ts | 2 +- .../src/diagnostics/duplicate-header.md | 22 ++ .../openapi3/src/diagnostics/inline-cycle.md | 19 ++ .../src/diagnostics/invalid-schema.md | 20 ++ .../diagnostics/invalid-server-variable.md | 14 ++ .../openapi3/src/diagnostics/path-query.md | 26 +++ .../openapi3/src/diagnostics/union-null.md | 3 + packages/openapi3/src/lib.ts | 8 + .../tspd/src/ref-doc/emitters/starlight.ts | 75 ++++++- packages/tspd/src/ref-doc/extractor.ts | 79 ++++++- packages/tspd/src/ref-doc/lib.ts | 2 + packages/tspd/src/ref-doc/types.ts | 13 ++ website/src/content/current-sidebar.ts | 3 +- .../docs/emitters/openapi3/diagnostics.md | 203 ------------------ .../openapi3/reference/diagnostics.md | 33 +++ .../reference/diagnostics/duplicate-header.md | 30 +++ .../reference/diagnostics/inline-cycle.md | 27 +++ .../reference/diagnostics/invalid-schema.md | 28 +++ .../diagnostics/invalid-server-variable.md | 22 ++ .../reference/diagnostics/path-query.md | 34 +++ .../reference/diagnostics/union-null.md | 11 + .../docs/libraries/http/reference/linter.md | 6 +- .../rules/op-reference-container-route.md | 6 +- 28 files changed, 531 insertions(+), 220 deletions(-) create mode 100644 .chronus/changes/diagnostic-docs-openapi3-2026-6-9.md create mode 100644 .chronus/changes/rule-diagnostic-docs-tspd-2026-6-9.md create mode 100644 .chronus/changes/rule-docs-http-2026-6-9.md create mode 100644 packages/http/src/rules/op-reference-container-route.md create mode 100644 packages/openapi3/src/diagnostics/duplicate-header.md create mode 100644 packages/openapi3/src/diagnostics/inline-cycle.md create mode 100644 packages/openapi3/src/diagnostics/invalid-schema.md create mode 100644 packages/openapi3/src/diagnostics/invalid-server-variable.md create mode 100644 packages/openapi3/src/diagnostics/path-query.md create mode 100644 packages/openapi3/src/diagnostics/union-null.md delete mode 100644 website/src/content/docs/docs/emitters/openapi3/diagnostics.md create mode 100644 website/src/content/docs/docs/emitters/openapi3/reference/diagnostics.md create mode 100644 website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/duplicate-header.md create mode 100644 website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/inline-cycle.md create mode 100644 website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/invalid-schema.md create mode 100644 website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/invalid-server-variable.md create mode 100644 website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/path-query.md create mode 100644 website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/union-null.md rename website/src/content/docs/docs/libraries/http/{ => reference}/rules/op-reference-container-route.md (67%) 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/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..394dff1e736 --- /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`) plus a diagnostics index, sourced from the `docs` field on the definitions. A `documentation-missing` warning is now also reported for linter rules and diagnostics without 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/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/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..fa948897352 100644 --- a/packages/http/src/rules/op-reference-container-route.ts +++ b/packages/http/src/rules/op-reference-container-route.ts @@ -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", + url: "https://typespec.io/docs/libraries/http/reference/rules/op-reference-container-route", 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/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..4feb5199817 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -321,6 +321,7 @@ export const $lib = createTypeSpecLibrary({ }, "invalid-server-variable": { severity: "error", + description: "A variable in the `@server` decorator is not assignable to `string`.", 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 +353,15 @@ export const $lib = createTypeSpecLibrary({ }, "path-query": { severity: "error", + description: "An `@route` path contains a query parameter, which OpenAPI v3 does not allow.", messages: { default: `OpenAPI does not allow paths containing a query string.`, }, }, "duplicate-header": { severity: "error", + description: + "A response header is defined more than once for a response of a specific status code.", messages: { default: paramMessage`The header ${"header"} is defined across multiple content types`, }, @@ -371,12 +375,15 @@ export const $lib = createTypeSpecLibrary({ "invalid-schema": { severity: "error", + description: "A schema is invalid according to the OpenAPI v3 specification.", messages: { default: paramMessage`Couldn't get schema for type ${"type"}`, }, }, "union-null": { severity: "error", + description: + "The result of model composition is effectively a `null` schema, which cannot be represented in OpenAPI.", messages: { default: "Cannot have a union containing only null types.", }, @@ -403,6 +410,7 @@ export const $lib = createTypeSpecLibrary({ }, "inline-cycle": { severity: "error", + description: "A cyclic reference was detected within inline schemas.", 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..93cf1307752 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, @@ -12,8 +13,10 @@ import { MarkdownSection, codeblock, inlinecode, + link, renderMarkdowDoc, section, + table, } from "../utils/markdown.js"; import { MarkdownRenderer, groupByNamespace } from "./markdown.js"; @@ -57,6 +60,20 @@ export function renderToAstroStarlightMarkdown( files["linter.md"] = linter; } + for (const rule of refDoc.linter?.rules ?? []) { + files[`rules/${rule.rule.name}.md`] = renderRule(rule); + } + + const diagnosticsIndex = renderDiagnostics(refDoc); + if (diagnosticsIndex) { + files["diagnostics.md"] = diagnosticsIndex; + 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 +294,63 @@ 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 renderDiagnostics(refDoc: TypeSpecLibraryRefDoc): string | undefined { + const diagnostics = refDoc.diagnostics; + // Only generate the diagnostics reference once a library has started documenting its + // diagnostics (at least one has `docs`). This keeps libraries that haven't opted in yet + // from getting an empty/auto index page. + if (diagnostics === undefined || !diagnostics.some((d) => d.doc)) { + return undefined; + } + const sorted = [...diagnostics].sort((a, b) => a.name.localeCompare(b.name, "en")); + const rows: string[][] = [["Code", "Severity", "Description"]]; + for (const diagnostic of sorted) { + const label = inlinecode(diagnostic.id); + const codeCell = diagnostic.doc ? link(label, `./diagnostics/${diagnostic.name}.md`) : label; + rows.push([codeCell, diagnostic.severity, diagnostic.description ?? ""]); + } + const content: MarkdownDoc = [ + "---", + `title: "Diagnostics"`, + "---", + "", + "The following diagnostics can be reported by this library.", + "", + table(rows), + ]; + return renderMarkdowDoc(content, 2); +} +function renderDiagnostic(diagnostic: DiagnosticRefDoc): string { + const content: MarkdownDoc = [ + "---", + `title: "${diagnostic.name}"`, + "---", + "", + codeblock(diagnostic.id, 'text title="Id"'), + ]; + if (diagnostic.doc) { + content.push("", diagnostic.doc); + } return renderMarkdowDoc(content, 2); } @@ -430,7 +503,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..f357d21ad14 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, @@ -40,12 +41,14 @@ import { 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 +128,47 @@ 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); + refDoc.linter = extractLinterRefDoc(lib.name, resolved, libraryPath); + // Only nag about missing docs for libraries that have started documenting their + // rules, to avoid noise for libraries that haven't opted in yet. + if (refDoc.linter.rules.some((r) => r.doc)) { + 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, + ); + // Only nag about missing docs for libraries that have started documenting their + // diagnostics, to avoid noise for libraries that haven't opted in yet. + if (refDoc.diagnostics.some((diag) => diag.doc)) { + 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 +947,39 @@ function resolveDescription(description: string | string[] | undefined): string return Array.isArray(description) ? description.join("\n") : description; } -function extractLinterRefDoc(libName: string, linter: LinterResolvedDefinition): LinterRefDoc { +function tryReadDoc(libraryPath: string, relativePath: string): string | undefined { + try { + return readFileSync(joinPaths(libraryPath, relativePath), "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, + description: def.description, + doc: tryReadDoc(libraryPath, `src/diagnostics/${name}.md`), + })); +} + function extractLinterRuleSetsRefDoc( libName: string, ruleSets: Record, @@ -928,6 +997,7 @@ function extractLinterRuleSetsRefDoc( function extractLinterRuleRefDoc( libName: string, rule: LinterRuleDefinition, + libraryPath: string, ): LinterRuleRefDoc { const fullName = `${libName}/${rule.name}`; return { @@ -935,5 +1005,6 @@ function extractLinterRuleRefDoc( id: fullName, name: fullName, rule, + doc: tryReadDoc(libraryPath, `src/rules/${rule.name}.md`), }; } 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..452e98bb98b 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,16 @@ 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"; + /** One-line summary shown in the diagnostics index. */ + readonly description?: string; + /** Extended documentation as raw markdown. */ + 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.md b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics.md new file mode 100644 index 00000000000..0911339c660 --- /dev/null +++ b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics.md @@ -0,0 +1,33 @@ +--- +title: "Diagnostics" +--- + +The following diagnostics can be reported by this library. + +| Code | Severity | Description | +| ---------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------- | +| `@typespec/openapi3/default-not-supported` | warning | | +| [`@typespec/openapi3/duplicate-header`](./diagnostics/duplicate-header.md) | error | A response header is defined more than once for a response of a specific status code. | +| `@typespec/openapi3/empty-enum` | error | | +| `@typespec/openapi3/empty-union` | error | | +| `@typespec/openapi3/enum-strategy-not-supported` | warning | | +| `@typespec/openapi3/enum-unique-type` | error | | +| `@typespec/openapi3/inconsistent-shared-route-request-visibility` | error | | +| [`@typespec/openapi3/inline-cycle`](./diagnostics/inline-cycle.md) | error | A cyclic reference was detected within inline schemas. | +| `@typespec/openapi3/invalid-component-fixed-field-key` | warning | | +| `@typespec/openapi3/invalid-format` | warning | | +| `@typespec/openapi3/invalid-model-property` | error | | +| [`@typespec/openapi3/invalid-schema`](./diagnostics/invalid-schema.md) | error | A schema is invalid according to the OpenAPI v3 specification. | +| [`@typespec/openapi3/invalid-server-variable`](./diagnostics/invalid-server-variable.md) | error | A variable in the `@server` decorator is not assignable to `string`. | +| `@typespec/openapi3/invalid-style` | warning | | +| `@typespec/openapi3/oneof-union` | error | | +| [`@typespec/openapi3/path-query`](./diagnostics/path-query.md) | error | An `@route` path contains a query parameter, which OpenAPI v3 does not allow. | +| `@typespec/openapi3/path-reserved-expansion` | warning | | +| `@typespec/openapi3/resource-namespace` | error | | +| `@typespec/openapi3/status-code-in-default-response` | error | | +| `@typespec/openapi3/streams-not-supported` | warning | | +| [`@typespec/openapi3/union-null`](./diagnostics/union-null.md) | error | The result of model composition is effectively a `null` schema, which cannot be represented in OpenAPI. | +| `@typespec/openapi3/unsupported-auth` | warning | | +| `@typespec/openapi3/unsupported-status-code-range` | error | | +| `@typespec/openapi3/xml-attribute-invalid-property-type` | warning | | +| `@typespec/openapi3/xml-unwrapped-invalid-property-type` | warning | | 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..8c6f6db07c8 --- /dev/null +++ b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/duplicate-header.md @@ -0,0 +1,30 @@ +--- +title: "duplicate-header" +--- + +```text title="Id" +@typespec/openapi3/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. 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..084d2bde19b --- /dev/null +++ b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/inline-cycle.md @@ -0,0 +1,27 @@ +--- +title: "inline-cycle" +--- + +```text title="Id" +@typespec/openapi3/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. 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..b0cec56effc --- /dev/null +++ b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/invalid-schema.md @@ -0,0 +1,28 @@ +--- +title: "invalid-schema" +--- + +```text title="Id" +@typespec/openapi3/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`. 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..52c805171ab --- /dev/null +++ b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/invalid-server-variable.md @@ -0,0 +1,22 @@ +--- +title: "invalid-server-variable" +--- + +```text title="Id" +@typespec/openapi3/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" +}) +``` 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..97703b638d9 --- /dev/null +++ b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/path-query.md @@ -0,0 +1,34 @@ +--- +title: "path-query" +--- + +```text title="Id" +@typespec/openapi3/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[]; +``` 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..287a8ddc595 --- /dev/null +++ b/website/src/content/docs/docs/emitters/openapi3/reference/diagnostics/union-null.md @@ -0,0 +1,11 @@ +--- +title: "union-null" +--- + +```text title="Id" +@typespec/openapi3/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. 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 From 3db903d7fdc2b61f15840c2f1912dd9534c29594 Mon Sep 17 00:00:00 2001 From: tadelesh Date: Tue, 14 Jul 2026 11:24:21 +0800 Subject: [PATCH 2/7] refactor(tspd): use docs field with FileRef for reference docs Address review feedback: - Add a docs field (string | FileRef) to linter rule and diagnostic definitions in the compiler; docs are read lazily by tspd so definitions stay browser-bundle safe. - Always report documentation-missing for any linter rule or diagnostic without docs, instead of gating the check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../rule-diagnostic-docs-compiler-2026-6-9.md | 19 ++++++ .../rule-diagnostic-docs-tspd-2026-6-9.md | 2 +- packages/compiler/src/core/file-ref.ts | 31 ++++++++++ packages/compiler/src/core/types.ts | 13 ++++ packages/compiler/src/index.ts | 1 + .../src/rules/op-reference-container-route.ts | 3 +- packages/openapi3/src/lib.ts | 8 ++- packages/tspd/src/ref-doc/extractor.ts | 59 +++++++++---------- 8 files changed, 101 insertions(+), 35 deletions(-) create mode 100644 .chronus/changes/rule-diagnostic-docs-compiler-2026-6-9.md create mode 100644 packages/compiler/src/core/file-ref.ts 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 index 394dff1e736..648109d23d9 100644 --- a/.chronus/changes/rule-diagnostic-docs-tspd-2026-6-9.md +++ b/.chronus/changes/rule-diagnostic-docs-tspd-2026-6-9.md @@ -4,4 +4,4 @@ packages: - "@typespec/tspd" --- -`tspd doc` now generates a documentation page per linter rule (`reference/rules/.md`) and per diagnostic (`reference/diagnostics/.md`) plus a diagnostics index, sourced from the `docs` field on the definitions. A `documentation-missing` warning is now also reported for linter rules and diagnostics without documentation. +`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/packages/compiler/src/core/file-ref.ts b/packages/compiler/src/core/file-ref.ts new file mode 100644 index 00000000000..5d787c93a5a --- /dev/null +++ b/packages/compiler/src/core/file-ref.ts @@ -0,0 +1,31 @@ +/** + * A lazy reference to a file on disk. Unlike embedding the file content directly, a + * `FileRef` only describes *where* the content lives; it is resolved (read) by tooling + * such as `tspd` when needed. Because it does not read the file at definition time, it is + * safe to include in code that is bundled for the browser (e.g. the playground). + */ +export interface FileRef { + readonly kind: "file-ref"; + /** Path to the file, relative to the package root. */ + readonly path: string; +} + +export const fileRef = { + /** + * Create a {@link FileRef} pointing to a file relative to the package root (the directory + * containing the library's `package.json`). + * + * @example + * ```ts + * docs: fileRef.fromPackageRoot("src/rules/my-rule.md"), + * ``` + */ + fromPackageRoot(path: string): FileRef { + return { kind: "file-ref", path }; + }, +}; + +/** Type guard for {@link FileRef}. */ +export function isFileRef(value: unknown): value is FileRef { + return typeof value === "object" && value !== null && (value as FileRef).kind === "file-ref"; +} diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 50c36efbf4b..2cf237a3c61 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"; @@ -2410,6 +2411,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 { @@ -2557,6 +2564,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/http/src/rules/op-reference-container-route.ts b/packages/http/src/rules/op-reference-container-route.ts index fa948897352..cd033fb45a5 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"; @@ -8,6 +8,7 @@ export const opReferenceContainerRouteRule = createRule({ description: "Check for referenced (`op is`) operations which have a @route on one of their containers.", url: "https://typespec.io/docs/libraries/http/reference/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/src/lib.ts b/packages/openapi3/src/lib.ts index 48e6ef75d12..2cf30e5a936 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"; @@ -321,6 +321,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 +353,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 +374,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 +408,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/extractor.ts b/packages/tspd/src/ref-doc/extractor.ts index 5e1527cc2c6..77cb6e2409d 100644 --- a/packages/tspd/src/ref-doc/extractor.ts +++ b/packages/tspd/src/ref-doc/extractor.ts @@ -38,6 +38,7 @@ import { TypeSpecLibrary, Union, UnionVariant, + type FileRef, type PackageJson, } from "@typespec/compiler"; import { SyntaxKind, type DocUnknownTagNode } from "@typespec/compiler/ast"; @@ -130,20 +131,16 @@ export async function extractLibraryRefDocs( if (lib && linter) { const resolved = resolveLinterDefinition(lib.name, linter); refDoc.linter = extractLinterRefDoc(lib.name, resolved, libraryPath); - // Only nag about missing docs for libraries that have started documenting their - // rules, to avoid noise for libraries that haven't opted in yet. - if (refDoc.linter.rules.some((r) => r.doc)) { - for (const r of refDoc.linter.rules) { - if (!r.doc) { - diagnostics.add( - createDiagnostic({ - code: "documentation-missing", - messageId: "rule", - format: { name: r.id }, - target: NoTarget, - }), - ); - } + for (const r of refDoc.linter.rules) { + if (!r.doc) { + diagnostics.add( + createDiagnostic({ + code: "documentation-missing", + messageId: "rule", + format: { name: r.id }, + target: NoTarget, + }), + ); } } } @@ -153,20 +150,16 @@ export async function extractLibraryRefDocs( lib.diagnostics as Record>, libraryPath, ); - // Only nag about missing docs for libraries that have started documenting their - // diagnostics, to avoid noise for libraries that haven't opted in yet. - if (refDoc.diagnostics.some((diag) => diag.doc)) { - for (const diag of refDoc.diagnostics) { - if (!diag.doc) { - diagnostics.add( - createDiagnostic({ - code: "documentation-missing", - messageId: "diagnostic", - format: { name: diag.id }, - target: NoTarget, - }), - ); - } + for (const diag of refDoc.diagnostics) { + if (!diag.doc) { + diagnostics.add( + createDiagnostic({ + code: "documentation-missing", + messageId: "diagnostic", + format: { name: diag.id }, + target: NoTarget, + }), + ); } } } @@ -947,9 +940,11 @@ function resolveDescription(description: string | string[] | undefined): string return Array.isArray(description) ? description.join("\n") : description; } -function tryReadDoc(libraryPath: string, relativePath: string): string | undefined { +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, relativePath), "utf-8"); + return readFileSync(joinPaths(libraryPath, doc.path), "utf-8"); } catch { return undefined; } @@ -975,7 +970,7 @@ function extractDiagnosticsRefDoc( id: `${libName}/${name}`, name, severity: def.severity, - doc: tryReadDoc(libraryPath, `src/diagnostics/${name}.md`), + doc: resolveDoc(def.docs, libraryPath), })); } @@ -1004,6 +999,6 @@ function extractLinterRuleRefDoc( id: fullName, name: fullName, rule, - doc: tryReadDoc(libraryPath, `src/rules/${rule.name}.md`), + doc: resolveDoc(rule.docs, libraryPath), }; } From 6fcb0e37158983cf9103196277b98f36f197f858 Mon Sep 17 00:00:00 2001 From: tadelesh Date: Tue, 14 Jul 2026 14:45:53 +0800 Subject: [PATCH 3/7] feat(compiler): auto-generate reference doc url for diagnostics and rules Add a referenceDocs.baseUrl field to the library definition. When set, the compiler auto-fills the url of each diagnostic and linter rule that does not specify one explicitly (diagnostics -> /diagnostics/, rules -> /rules/), so the editor and CLI get a documentation link without hardcoding a url per item. Migrate @typespec/http and @typespec/openapi3 to declare referenceDocs and drop the hardcoded rule url. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../reference-docs-base-url-2026-6-9.md | 20 +++++++++++++++++++ .../compiler/src/core/diagnostic-creator.ts | 4 ++++ packages/compiler/src/core/library.ts | 6 +++++- packages/compiler/src/core/linter.ts | 7 ++++++- packages/compiler/src/core/program.ts | 8 +++++++- packages/compiler/src/core/types.ts | 12 +++++++++++ packages/http/src/lib.ts | 1 + .../src/rules/op-reference-container-route.ts | 1 - packages/openapi3/src/lib.ts | 1 + packages/tspd/src/ref-doc/extractor.ts | 2 +- 10 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 .chronus/changes/reference-docs-base-url-2026-6-9.md 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..aceafc3e596 --- /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 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/packages/compiler/src/core/diagnostic-creator.ts b/packages/compiler/src/core/diagnostic-creator.ts index 96d4a737006..16404c19ed1 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..9f20183ce27 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 && 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..9428640b615 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -548,7 +548,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 2cf237a3c61..f55bf8c30c6 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2491,6 +2491,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 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. */ 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.ts b/packages/http/src/rules/op-reference-container-route.ts index cd033fb45a5..0868f7166f6 100644 --- a/packages/http/src/rules/op-reference-container-route.ts +++ b/packages/http/src/rules/op-reference-container-route.ts @@ -7,7 +7,6 @@ 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/reference/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/src/lib.ts b/packages/openapi3/src/lib.ts index 2cf30e5a936..2bf1077bf05 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -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, }, diff --git a/packages/tspd/src/ref-doc/extractor.ts b/packages/tspd/src/ref-doc/extractor.ts index 77cb6e2409d..ff0e5473469 100644 --- a/packages/tspd/src/ref-doc/extractor.ts +++ b/packages/tspd/src/ref-doc/extractor.ts @@ -129,7 +129,7 @@ export async function extractLibraryRefDocs( } const linter = entrypoint.$linter; if (lib && linter) { - const resolved = 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) { From d6180efda0a0d1760b5348a94fb3323ec12b4af7 Mon Sep 17 00:00:00 2001 From: tadelesh Date: Tue, 14 Jul 2026 15:30:33 +0800 Subject: [PATCH 4/7] feat(compiler): show diagnostic and rule docs on hover in the language server When hovering over a reported error, the language server now renders the extended documentation of the corresponding diagnostic or linter rule (its docs field, inline markdown or a FileRef read lazily) together with a link to the generated reference page when a documentation url is available. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../changes/hover-diagnostic-docs-2026-6-9.md | 7 + .../src/server/npm-package-provider.ts | 5 + packages/compiler/src/server/serverlib.ts | 117 +++++++++++++--- .../compiler/test/server/get-hover.test.ts | 128 ++++++++++++++++++ 4 files changed, 239 insertions(+), 18 deletions(-) create mode 100644 .chronus/changes/hover-diagnostic-docs-2026-6-9.md 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/packages/compiler/src/server/npm-package-provider.ts b/packages/compiler/src/server/npm-package-provider.ts index 2fa9c88b918..057ae0ebfe8 100644 --- a/packages/compiler/src/server/npm-package-provider.ts +++ b/packages/compiler/src/server/npm-package-provider.ts @@ -112,6 +112,11 @@ export class NpmPackage { return this.packageJsonData; } + /** Absolute path to the folder containing this package's `package.json`. */ + get rootFolder(): string { + return this.packageJsonFolder; + } + private packageModule: Record | undefined; async getModuleExports(): Promise | undefined> { if (!this.packageModule) { diff --git a/packages/compiler/src/server/serverlib.ts b/packages/compiler/src/server/serverlib.ts index 964cca1f927..b6310c89f75 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"; @@ -159,6 +159,9 @@ export function createServer( npmPackageProvider, (exports) => exports.$linter !== undefined, ); + // Provider used to resolve any TypeSpec library (that depends on the compiler) by name, + // e.g. to look up the extended documentation of a diagnostic/rule when hovering an error. + const libraryProvider = new LibraryProvider(npmPackageProvider, () => true); const updateManager = new UpdateManager("doc-update", log); const signatureHelpUpdateManager = new UpdateManager( @@ -899,13 +902,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 +928,98 @@ 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(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 + * diagnostic currently reported that covers the given offset in the given file. + */ + async function getDiagnosticDocMarkdownAtPosition( + 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(diag, filePath); + 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( + diag: Diagnostic, + filePath: string, + ): 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) { + try { + const pkg = await libraryProvider.getLibrary(getDirectoryPath(filePath), libName); + const exports = await pkg?.getModuleExports(); + const definition = + exports?.$lib?.diagnostics?.[localCode] ?? + exports?.$linter?.rules?.find((rule: any) => rule?.name === localCode); + const docs = definition?.docs; + let docText: string | undefined; + if (typeof docs === "string") { + docText = docs; + } else if (isFileRef(docs) && pkg) { + const file = await compilerHost.readFile(joinPaths(pkg.rootFolder, docs.path)); + docText = file.text; + } + if (docText && docText.trim().length > 0) { + sections.push(docText.trim()); + } + } catch { + // Ignore failures to resolve documentation; just fall back to the link (if any). + } + } + + 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/server/get-hover.test.ts b/packages/compiler/test/server/get-hover.test.ts index 71314e96311..7acd615b8c1 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 { createTypeSpecLibrary } from "../../src/core/library.js"; import { extractCursor } from "../../src/testing/source-utils.js"; import { createTestServerHost } from "../../src/testing/test-server-host.js"; @@ -864,3 +866,129 @@ interface TestNs.Bird { return contents.value; } }); + +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"), + docFile: { + path: "test/node_modules/test-lib/docs/always-error.md", + content: "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("shows only the 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](https://example.com/test-lib/reference/diagnostics/always-error)", + ), + `Expected reference link in hover, got:\n${value}`, + ); + }); + + async function getHoverValueWithLibDiagnostic(options: { + docs?: string | ReturnType; + referenceDocsBaseUrl?: string; + docFile?: { path: string; content: string }; + }): Promise { + const testHost = await createTestServerHost(); + + 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("test/node_modules/test-lib/index.js", { + $lib, + $decorators: { + TestLib: { + alwaysError: (context: any, target: any) => { + ($lib as any).reportDiagnostic(context.program, { code: "always-error", target }); + }, + }, + }, + }); + testHost.addTypeSpecFile( + "test/node_modules/test-lib/package.json", + JSON.stringify({ + name: "test-lib", + version: "0.1.0", + main: "index.js", + tspMain: "main.tsp", + peerDependencies: { "@typespec/compiler": "*" }, + }), + ); + testHost.addTypeSpecFile( + "test/node_modules/test-lib/main.tsp", + `import "./index.js";\nnamespace TestLib;\nextern dec alwaysError(target: unknown);\n`, + ); + testHost.addTypeSpecFile( + "test/package.json", + JSON.stringify({ dependencies: { "test-lib": "*" } }), + ); + if (options.docFile) { + testHost.addTypeSpecFile(options.docFile.path, options.docFile.content); + } + + const { source, pos } = extractCursor(` + import "test-lib"; + using TestLib; + + @alwaysError + model Fo┆o {} + `); + const textDocument = testHost.addOrUpdateDocument("test/main.tsp", source); + + // Full compile first so linter/library diagnostics are indexed for hover to pick up. + await testHost.server.compile(textDocument, undefined, { mode: "full" }); + + const hover = await testHost.server.getHover({ + textDocument, + position: textDocument.positionAt(pos), + }); + if (!hover) return undefined; + const contents = hover.contents; + if (typeof contents === "string") return contents; + if (Array.isArray(contents)) { + return contents.map((x) => (typeof x === "string" ? x : x.value)).join("\n"); + } + return contents.value; + } +}); From 69c49cdd14ea85325d0cb9b622059013b8d8879c Mon Sep 17 00:00:00 2001 From: tadelesh Date: Tue, 14 Jul 2026 16:56:43 +0800 Subject: [PATCH 5/7] fix(compiler): harden diagnostic documentation links and hover Publish FileRef markdown assets and avoid generating reference links for undocumented diagnostics/rules. On hover, reuse the diagnostics from the last full compile (matched by source location the same way squiggles are) to show the extended documentation of the diagnostic/rule under the cursor, resolved by reusing the library already loaded by the compiler instead of re-resolving and reloading it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: de8537de-5e61-4a0d-b2de-59ec12c15dc4 --- .../reference-docs-base-url-2026-6-9.md | 2 +- .../compiler/src/core/diagnostic-creator.ts | 2 +- packages/compiler/src/core/linter.ts | 2 +- packages/compiler/src/core/program.ts | 42 ++++ packages/compiler/src/core/types.ts | 4 +- .../src/server/npm-package-provider.ts | 5 - packages/compiler/src/server/serverlib.ts | 55 +++--- .../compiler/test/core/reference-docs.test.ts | 91 +++++++++ .../compiler/test/server/get-hover.test.ts | 182 ++++++++++++++---- packages/http/package.json | 1 + packages/openapi3/package.json | 1 + 11 files changed, 315 insertions(+), 72 deletions(-) create mode 100644 packages/compiler/test/core/reference-docs.test.ts diff --git a/.chronus/changes/reference-docs-base-url-2026-6-9.md b/.chronus/changes/reference-docs-base-url-2026-6-9.md index aceafc3e596..c49d3352f29 100644 --- a/.chronus/changes/reference-docs-base-url-2026-6-9.md +++ b/.chronus/changes/reference-docs-base-url-2026-6-9.md @@ -4,7 +4,7 @@ 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 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: +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/` diff --git a/packages/compiler/src/core/diagnostic-creator.ts b/packages/compiler/src/core/diagnostic-creator.ts index 16404c19ed1..97a878aaa76 100644 --- a/packages/compiler/src/core/diagnostic-creator.ts +++ b/packages/compiler/src/core/diagnostic-creator.ts @@ -60,7 +60,7 @@ export function createDiagnosticCreator[] = linter.rules.map((rule) => { const resolved: LinterRule = { ...rule, id: `${libName}/${rule.name}` }; - if (resolved.url === undefined && referenceDocsBaseUrl) { + if (resolved.url === undefined && resolved.docs && referenceDocsBaseUrl) { resolved.url = `${referenceDocsBaseUrl.replace(/\/$/, "")}/rules/${rule.name}`; } return resolved; diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index 9428640b615..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[], diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 15c73d7a9e3..504f6ab9ea6 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2500,8 +2500,8 @@ export interface TypeSpecLibraryDef< /** * Reference documentation configuration for this library. - * When set, the compiler auto-generates the `url` of each diagnostic and linter rule that - * does not specify one explicitly, pointing at the generated reference pages: + * 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/` */ diff --git a/packages/compiler/src/server/npm-package-provider.ts b/packages/compiler/src/server/npm-package-provider.ts index 057ae0ebfe8..2fa9c88b918 100644 --- a/packages/compiler/src/server/npm-package-provider.ts +++ b/packages/compiler/src/server/npm-package-provider.ts @@ -112,11 +112,6 @@ export class NpmPackage { return this.packageJsonData; } - /** Absolute path to the folder containing this package's `package.json`. */ - get rootFolder(): string { - return this.packageJsonFolder; - } - private packageModule: Record | undefined; async getModuleExports(): Promise | undefined> { if (!this.packageModule) { diff --git a/packages/compiler/src/server/serverlib.ts b/packages/compiler/src/server/serverlib.ts index b6310c89f75..4324d7bc151 100644 --- a/packages/compiler/src/server/serverlib.ts +++ b/packages/compiler/src/server/serverlib.ts @@ -159,9 +159,6 @@ export function createServer( npmPackageProvider, (exports) => exports.$linter !== undefined, ); - // Provider used to resolve any TypeSpec library (that depends on the compiler) by name, - // e.g. to look up the extended documentation of a diagnostic/rule when hovering an error. - const libraryProvider = new LibraryProvider(npmPackageProvider, () => true); const updateManager = new UpdateManager("doc-update", log); const signatureHelpUpdateManager = new UpdateManager( @@ -938,7 +935,11 @@ export function createServer( } } - const diagnosticDoc = await getDiagnosticDocMarkdownAtPosition(script.file.path, offset); + const diagnosticDoc = await getDiagnosticDocMarkdownAtPosition( + program, + script.file.path, + offset, + ); if (diagnosticDoc) { sections.push(diagnosticDoc); } @@ -953,9 +954,12 @@ export function createServer( /** * Build a markdown section with the extended documentation (and reference link) of any - * diagnostic currently reported that covers the given offset in the given file. + * 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 { @@ -973,7 +977,7 @@ export function createServer( continue; } seenCodes.add(diag.code); - const markdown = await resolveDiagnosticDocMarkdown(diag, filePath); + const markdown = await resolveDiagnosticDocMarkdown(program, diag); if (markdown) { sections.push(markdown); } @@ -983,8 +987,8 @@ export function createServer( /** Resolve the extended docs + reference link markdown for a single reported diagnostic. */ async function resolveDiagnosticDocMarkdown( + program: Program, diag: Diagnostic, - filePath: string, ): Promise { const code = diag.code as string; const segments = code.split("/"); @@ -993,25 +997,28 @@ export function createServer( const sections: string[] = []; if (libName && localCode) { - try { - const pkg = await libraryProvider.getLibrary(getDirectoryPath(filePath), libName); - const exports = await pkg?.getModuleExports(); - const definition = - exports?.$lib?.diagnostics?.[localCode] ?? - exports?.$linter?.rules?.find((rule: any) => rule?.name === localCode); - const docs = definition?.docs; - let docText: string | undefined; - if (typeof docs === "string") { - docText = docs; - } else if (isFileRef(docs) && pkg) { - const file = await compilerHost.readFile(joinPaths(pkg.rootFolder, docs.path)); + // 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()); - } - } catch { - // Ignore failures to resolve documentation; just fall back to the link (if any). + } + if (docText && docText.trim().length > 0) { + sections.push(docText.trim()); } } 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 543ed0f95aa..6ed2a38d5ba 100644 --- a/packages/compiler/test/server/get-hover.test.ts +++ b/packages/compiler/test/server/get-hover.test.ts @@ -2,7 +2,7 @@ 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 { createTypeSpecLibrary } from "../../src/core/library.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"; @@ -860,10 +860,7 @@ describe("compiler: server: on hover: diagnostic docs", () => { it("reads docs from a co-located file referenced with fileRef", async () => { const value = await getHoverValueWithLibDiagnostic({ docs: fileRef.fromPackageRoot("docs/always-error.md"), - docFile: { - path: "test/node_modules/test-lib/docs/always-error.md", - content: "Documentation loaded from a file.", - }, + docContent: "Documentation loaded from a file.", }); ok(value); ok( @@ -872,26 +869,135 @@ describe("compiler: server: on hover: diagnostic docs", () => { ); }); - it("shows only the link when the diagnostic has no docs", async () => { + 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](https://example.com/test-lib/reference/diagnostics/always-error)", - ), - `Expected reference link in hover, got:\n${value}`, + !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; - docFile?: { path: string; content: 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 @@ -906,61 +1012,61 @@ describe("compiler: server: on hover: diagnostic docs", () => { }, } as any); - testHost.addJsFile("test/node_modules/test-lib/index.js", { + testHost.addJsFile(`${packageRoot}/index.js`, { $lib, $decorators: { TestLib: { alwaysError: (context: any, target: any) => { - ($lib as any).reportDiagnostic(context.program, { code: "always-error", target }); + ($lib as any).reportDiagnostic(context.program, { + code: "always-error", + target, + }); }, }, }, }); testHost.addTypeSpecFile( - "test/node_modules/test-lib/package.json", + `${packageRoot}/package.json`, JSON.stringify({ name: "test-lib", version: "0.1.0", - main: "index.js", - tspMain: "main.tsp", + exports: { + ".": { + import: "./index.js", + typespec: "./main.tsp", + }, + }, peerDependencies: { "@typespec/compiler": "*" }, }), ); testHost.addTypeSpecFile( - "test/node_modules/test-lib/main.tsp", + `${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( - "test/package.json", + `${projectRoot}/package.json`, JSON.stringify({ dependencies: { "test-lib": "*" } }), ); - if (options.docFile) { - testHost.addTypeSpecFile(options.docFile.path, options.docFile.content); - } const { source, pos } = extractCursor(` import "test-lib"; using TestLib; @alwaysError - model Fo┆o {} + model ${modelName.slice(0, -1)}┆${modelName.slice(-1)} {} `); - const textDocument = testHost.addOrUpdateDocument("test/main.tsp", source); - - // Full compile first so linter/library diagnostics are indexed for hover to pick up. - await testHost.server.compile(textDocument, undefined, { mode: "full" }); - - const hover = await testHost.server.getHover({ - textDocument, - position: textDocument.positionAt(pos), - }); - if (!hover) return undefined; - const contents = hover.contents; - if (typeof contents === "string") return contents; - if (Array.isArray(contents)) { - return contents.map((x) => (typeof x === "string" ? x : x.value)).join("\n"); - } - return contents.value; + const document = testHost.addOrUpdateDocument(`${projectRoot}/main.tsp`, source); + return { document, pos }; } }); 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/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/**" ], From 92d0f17d705b0405f2dca5bf4fbc760ca8dff37b Mon Sep 17 00:00:00 2001 From: tadelesh Date: Fri, 17 Jul 2026 11:30:48 +0800 Subject: [PATCH 6/7] chore: split reference-doc url auto-generation and language server hover into a follow-up PR This PR now only contains the tspd documentation generation (the \docs\ field + \FileRef\, and tspd rendering a page per rule/diagnostic). The \ eferenceDocs.baseUrl\ url auto-generation and the language server hover that surfaces the docs are moved to a separate PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: de8537de-5e61-4a0d-b2de-59ec12c15dc4 --- .../changes/hover-diagnostic-docs-2026-6-9.md | 7 - .../reference-docs-base-url-2026-6-9.md | 20 -- .../compiler/src/core/diagnostic-creator.ts | 4 - packages/compiler/src/core/library.ts | 6 +- packages/compiler/src/core/linter.ts | 7 +- packages/compiler/src/core/program.ts | 50 +--- packages/compiler/src/core/types.ts | 12 - packages/compiler/src/server/serverlib.ts | 124 ++-------- .../compiler/test/core/reference-docs.test.ts | 91 ------- .../compiler/test/server/get-hover.test.ts | 234 ------------------ packages/http/README.md | 6 +- packages/http/package.json | 1 - packages/http/src/lib.ts | 1 - .../src/rules/op-reference-container-route.ts | 1 + packages/openapi3/package.json | 1 - packages/openapi3/src/lib.ts | 1 - packages/tspd/src/ref-doc/extractor.ts | 2 +- 17 files changed, 26 insertions(+), 542 deletions(-) delete mode 100644 .chronus/changes/hover-diagnostic-docs-2026-6-9.md delete mode 100644 .chronus/changes/reference-docs-base-url-2026-6-9.md delete mode 100644 packages/compiler/test/core/reference-docs.test.ts diff --git a/.chronus/changes/hover-diagnostic-docs-2026-6-9.md b/.chronus/changes/hover-diagnostic-docs-2026-6-9.md deleted file mode 100644 index fd4fb4b4237..00000000000 --- a/.chronus/changes/hover-diagnostic-docs-2026-6-9.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -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 deleted file mode 100644 index c49d3352f29..00000000000 --- a/.chronus/changes/reference-docs-base-url-2026-6-9.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -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/packages/compiler/src/core/diagnostic-creator.ts b/packages/compiler/src/core/diagnostic-creator.ts index 97a878aaa76..96d4a737006 100644 --- a/packages/compiler/src/core/diagnostic-creator.ts +++ b/packages/compiler/src/core/diagnostic-creator.ts @@ -17,7 +17,6 @@ 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}'` @@ -60,9 +59,6 @@ export function createDiagnosticCreator(lib: Readonly>): TypeSpecLibrary { let emitterOptionValidator: JSONSchemaValidator; - const { reportDiagnostic, createDiagnostic } = createDiagnosticCreator( - lib.diagnostics, - lib.name, - lib.referenceDocs?.baseUrl, - ); + const { reportDiagnostic, createDiagnostic } = createDiagnosticCreator(lib.diagnostics, lib.name); 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 98223d83111..8b6aa78651e 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -49,14 +49,9 @@ export interface LinterResult { export function resolveLinterDefinition( libName: string, linter: LinterDefinition, - referenceDocsBaseUrl?: string, ): LinterResolvedDefinition { const rules: LinterRule[] = linter.rules.map((rule) => { - const resolved: LinterRule = { ...rule, id: `${libName}/${rule.name}` }; - if (resolved.url === undefined && resolved.docs && referenceDocsBaseUrl) { - resolved.url = `${referenceDocsBaseUrl.replace(/\/$/, "")}/rules/${rule.name}`; - } - return resolved; + return { ...rule, id: `${libName}/${rule.name}` }; }); 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 df7023fab0e..513170c5a61 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -56,7 +56,6 @@ import { JsSourceFileNode, LibraryInstance, LibraryMetadata, - LinterDefinition, LiteralType, LocationContext, LogSink, @@ -135,33 +134,12 @@ 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; @@ -293,7 +271,6 @@ async function createProgram( /** @internal */ resolveTypeOrValueReference, getSourceFileLocationContext, - getLoadedLibraryInfo, projectRoot: getDirectoryPath(options.config ?? resolvedMain ?? ""), }; @@ -515,25 +492,6 @@ 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[], @@ -590,13 +548,7 @@ async function createProgram( ...resolution, metadata, definition: libDefinition, - linter: - linterDef && - resolveLinterDefinition( - libraryNameOrPath, - linterDef, - libDefinition?.referenceDocs?.baseUrl, - ), + linter: linterDef && resolveLinterDefinition(libraryNameOrPath, linterDef), }; } diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 504f6ab9ea6..96606f13daf 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2498,18 +2498,6 @@ 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. */ diff --git a/packages/compiler/src/server/serverlib.ts b/packages/compiler/src/server/serverlib.ts index 4324d7bc151..964cca1f927 100644 --- a/packages/compiler/src/server/serverlib.ts +++ b/packages/compiler/src/server/serverlib.ts @@ -24,6 +24,7 @@ import { InitializeParams, InitializeResult, Location, + MarkupContent, MarkupKind, ParameterInformation, PrepareRenameParams, @@ -52,7 +53,6 @@ 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,14 +899,13 @@ export function createServer( return { contents: [] }; } - const offset = document.offsetAt(params.position); - const sections: string[] = []; - - const id = getNodeAtPosition(script, offset); + const id = getNodeAtPosition(script, document.offsetAt(params.position)); const sym = id?.kind === SyntaxKind.Identifier ? program.checker.resolveRelatedSymbols(id) : undefined; - if (sym && sym.length > 0) { + if (!sym || sym.length === 0) { + return { contents: { kind: MarkupKind.Markdown, value: "" } }; + } else { // 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; @@ -925,108 +924,21 @@ export function createServer( } } - 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: { + const markdown: MarkupContent = { kind: MarkupKind.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})`); + value: + sym && sym.length > 0 + ? await getSymbolDetails(program, sym[0], { + includeSignature: true, + includeParameterTags: true, + includeExpandedDefinition, + }) + : "", + }; + return { + contents: markdown, + }; } - - 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 deleted file mode 100644 index 0b6053fb6ba..00000000000 --- a/packages/compiler/test/core/reference-docs.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -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 6ed2a38d5ba..262b364a871 100644 --- a/packages/compiler/test/server/get-hover.test.ts +++ b/packages/compiler/test/server/get-hover.test.ts @@ -1,8 +1,6 @@ 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"; @@ -838,238 +836,6 @@ 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 5f7dc9e0d3c..3600a1dfb10 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/reference/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/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 c4478314361..a0c0c7b77c5 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -70,7 +70,6 @@ }, "files": [ "lib/**/*.tsp", - "src/**/*.md", "dist/**", "!dist/test/**" ], diff --git a/packages/http/src/lib.ts b/packages/http/src/lib.ts index 46c42d5dbdf..344bdf7763b 100644 --- a/packages/http/src/lib.ts +++ b/packages/http/src/lib.ts @@ -2,7 +2,6 @@ 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.ts b/packages/http/src/rules/op-reference-container-route.ts index 0868f7166f6..29becc083cd 100644 --- a/packages/http/src/rules/op-reference-container-route.ts +++ b/packages/http/src/rules/op-reference-container-route.ts @@ -7,6 +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 e0a97269b3a..f503c082720 100644 --- a/packages/openapi3/package.json +++ b/packages/openapi3/package.json @@ -59,7 +59,6 @@ }, "files": [ "lib/*.tsp", - "src/**/*.md", "dist/**", "!dist/test/**" ], diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index 2bf1077bf05..2cf30e5a936 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -302,7 +302,6 @@ const EmitterOptionsSchema: JSONSchemaType = { export const $lib = createTypeSpecLibrary({ name: "@typespec/openapi3", - referenceDocs: { baseUrl: "https://typespec.io/docs/emitters/openapi3/reference" }, capabilities: { dryRun: true, }, diff --git a/packages/tspd/src/ref-doc/extractor.ts b/packages/tspd/src/ref-doc/extractor.ts index ff0e5473469..77cb6e2409d 100644 --- a/packages/tspd/src/ref-doc/extractor.ts +++ b/packages/tspd/src/ref-doc/extractor.ts @@ -129,7 +129,7 @@ export async function extractLibraryRefDocs( } const linter = entrypoint.$linter; if (lib && linter) { - const resolved = resolveLinterDefinition(lib.name, linter, lib.referenceDocs?.baseUrl); + const resolved = resolveLinterDefinition(lib.name, linter); refDoc.linter = extractLinterRefDoc(lib.name, resolved, libraryPath); for (const r of refDoc.linter.rules) { if (!r.doc) { From c0b02f69f7ce39bb798c76d8ce694119f8e9bac0 Mon Sep 17 00:00:00 2001 From: tadelesh Date: Fri, 17 Jul 2026 11:48:13 +0800 Subject: [PATCH 7/7] feat(compiler): auto-generate reference url and surface docs on hover Builds on the tspd documentation generation. Add `referenceDocs.baseUrl` to the library definition so the compiler auto-fills the `url` of documented diagnostics/rules that don't set one (an explicit url still wins), and make the language server render a diagnostic/rule's `docs` (plus a documentation link) when hovering over a reported error, resolving the library via a new `@internal` Program.getLoadedLibraryInfo. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: de8537de-5e61-4a0d-b2de-59ec12c15dc4 --- .../changes/hover-diagnostic-docs-2026-6-9.md | 7 + .../reference-docs-base-url-2026-6-9.md | 20 ++ .../compiler/src/core/diagnostic-creator.ts | 4 + packages/compiler/src/core/library.ts | 6 +- packages/compiler/src/core/linter.ts | 7 +- packages/compiler/src/core/program.ts | 50 +++- packages/compiler/src/core/types.ts | 12 + packages/compiler/src/server/serverlib.ts | 124 ++++++++-- .../compiler/test/core/reference-docs.test.ts | 91 +++++++ .../compiler/test/server/get-hover.test.ts | 234 ++++++++++++++++++ packages/http/README.md | 6 +- packages/http/package.json | 1 + packages/http/src/lib.ts | 1 + .../src/rules/op-reference-container-route.ts | 1 - packages/openapi3/package.json | 1 + packages/openapi3/src/lib.ts | 1 + packages/tspd/src/ref-doc/extractor.ts | 2 +- 17 files changed, 542 insertions(+), 26 deletions(-) create mode 100644 .chronus/changes/hover-diagnostic-docs-2026-6-9.md create mode 100644 .chronus/changes/reference-docs-base-url-2026-6-9.md create mode 100644 packages/compiler/test/core/reference-docs.test.ts 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/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 96606f13daf..504f6ab9ea6 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2498,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. */ 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.ts b/packages/http/src/rules/op-reference-container-route.ts index 29becc083cd..0868f7166f6 100644 --- a/packages/http/src/rules/op-reference-container-route.ts +++ b/packages/http/src/rules/op-reference-container-route.ts @@ -7,7 +7,6 @@ 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/lib.ts b/packages/openapi3/src/lib.ts index 2cf30e5a936..2bf1077bf05 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -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, }, diff --git a/packages/tspd/src/ref-doc/extractor.ts b/packages/tspd/src/ref-doc/extractor.ts index 77cb6e2409d..ff0e5473469 100644 --- a/packages/tspd/src/ref-doc/extractor.ts +++ b/packages/tspd/src/ref-doc/extractor.ts @@ -129,7 +129,7 @@ export async function extractLibraryRefDocs( } const linter = entrypoint.$linter; if (lib && linter) { - const resolved = 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) {