From 7acd245f14ce8c070398a2bb787b69943d8b8ba9 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 30 Jul 2026 13:45:44 -0400 Subject: [PATCH 1/6] feat(compiler): add experimental $onInfo hook and program.getTypeInfo API Add a new experimental `$onInfo` library hook (gated behind the `type-info-hook` compiler feature flag) and `program.getTypeInfo(type)` API allowing libraries to contribute extra, domain-specific info about types for IDE hover and tooling. Wire it into LSP hover and add a `@typespec/http` provider surfacing verb + URI template + response status codes. --- .../changes/on-info-hook-2026-7-30-12-30-0.md | 18 ++++ .../on-info-http-route-2026-7-30-12-30-0.md | 7 ++ packages/compiler/src/core/binder.ts | 8 ++ packages/compiler/src/core/external-error.ts | 6 +- packages/compiler/src/core/features.ts | 4 + packages/compiler/src/core/library.ts | 18 ++++ packages/compiler/src/core/program.ts | 59 +++++++++++++ packages/compiler/src/core/types.ts | 31 +++++++ packages/compiler/src/index.ts | 4 + packages/compiler/src/server/type-details.ts | 23 +++++ packages/compiler/test/info-hook.test.ts | 88 +++++++++++++++++++ .../compiler/test/server/get-hover.test.ts | 37 ++++++++ packages/http/src/info.ts | 39 ++++++++ packages/http/src/tsp-index.ts | 1 + packages/http/test/info.test.ts | 25 ++++++ website/src/content/current-sidebar.ts | 1 + .../docs/extending-typespec/providing-info.md | 72 +++++++++++++++ 17 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 .chronus/changes/on-info-hook-2026-7-30-12-30-0.md create mode 100644 .chronus/changes/on-info-http-route-2026-7-30-12-30-0.md create mode 100644 packages/compiler/test/info-hook.test.ts create mode 100644 packages/http/src/info.ts create mode 100644 packages/http/test/info.test.ts create mode 100644 website/src/content/docs/docs/extending-typespec/providing-info.md diff --git a/.chronus/changes/on-info-hook-2026-7-30-12-30-0.md b/.chronus/changes/on-info-hook-2026-7-30-12-30-0.md new file mode 100644 index 00000000000..001dee0d922 --- /dev/null +++ b/.chronus/changes/on-info-hook-2026-7-30-12-30-0.md @@ -0,0 +1,18 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add a new experimental `$onInfo` library hook and `program.getTypeInfo(type)` API allowing libraries to contribute extra, domain-specific information about types. Unlike `$onValidate`, this hook never runs during compilation and must not mutate the type graph — it is invoked lazily and on demand (e.g. by the language server for hover documentation, or by tooling querying the type). The feature is gated behind the `type-info-hook` compiler feature flag. + +```ts +// A library exports a provider (use `defineInfoHook` for typing): +export const $onInfo = defineInfoHook(({ program, target }) => { + if (target.kind !== "Operation") return undefined; + return { content: "extra info about this operation" }; +}); + +// Tooling / language server queries it (merges every library's contribution): +const info = program.getTypeInfo(type); +``` diff --git a/.chronus/changes/on-info-http-route-2026-7-30-12-30-0.md b/.chronus/changes/on-info-http-route-2026-7-30-12-30-0.md new file mode 100644 index 00000000000..f06201eb752 --- /dev/null +++ b/.chronus/changes/on-info-http-route-2026-7-30-12-30-0.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http" +--- + +Add an `$onInfo` provider that surfaces the resolved HTTP route (verb and URI template) and response status codes of an operation. This is shown when hovering an operation in the IDE and can be queried programmatically via `program.getTypeInfo(operation)`. Requires the experimental `type-info-hook` compiler feature flag. diff --git a/packages/compiler/src/core/binder.ts b/packages/compiler/src/core/binder.ts index 09a5e5b5108..f3c63fc3b90 100644 --- a/packages/compiler/src/core/binder.ts +++ b/packages/compiler/src/core/binder.ts @@ -179,6 +179,14 @@ export function createBinder(program: Program): Binder { : ({ type: "file" } satisfies FileLibraryMetadata); program.onValidate(member as any, metadata); continue; + } else if (name === "onInfo") { + const context = getLocationContext(program, sourceFile); + const metadata = + context.type === "library" + ? context.metadata + : ({ type: "file" } satisfies FileLibraryMetadata); + program.registerInfoProvider(member as any, metadata); + continue; } else if (name === "onEmit") { // nothing to do here this is loaded as emitter. continue; diff --git a/packages/compiler/src/core/external-error.ts b/packages/compiler/src/core/external-error.ts index 5f52e8248a3..5c05f133527 100644 --- a/packages/compiler/src/core/external-error.ts +++ b/packages/compiler/src/core/external-error.ts @@ -30,7 +30,7 @@ export type Colors = | "bgWhite"; export interface ExternalErrorInfo { - kind: "emitter" | "validator"; + kind: "emitter" | "validator" | "info"; error: unknown; metadata: LibraryMetadata; } @@ -56,7 +56,9 @@ function renderExternalErrorInfo( color( kind === "emitter" ? `Emitter "${metadata.name}" crashed! This is a bug.` - : `Library "${metadata.name}" $onValidate crashed! This is a bug.`, + : kind === "info" + ? `Library "${metadata.name}" $onInfo crashed! This is a bug.` + : `Library "${metadata.name}" $onValidate crashed! This is a bug.`, "red", ), ]; diff --git a/packages/compiler/src/core/features.ts b/packages/compiler/src/core/features.ts index eaeeda6cb9b..52ccc994fc5 100644 --- a/packages/compiler/src/core/features.ts +++ b/packages/compiler/src/core/features.ts @@ -15,6 +15,10 @@ export const compilerFeatures = { description: "Allows use of auto decorator declarations without experimental warnings in project code.", }, + "type-info-hook": { + description: + "Enables the experimental `$onInfo` hook allowing libraries to contribute extra information about types to IDE hover and tooling (queried via `program.getTypeInfo`).", + }, } as const satisfies Record; export type CompilerFeatureName = keyof typeof compilerFeatures; diff --git a/packages/compiler/src/core/library.ts b/packages/compiler/src/core/library.ts index e8b596bc1b3..3316a77b41a 100644 --- a/packages/compiler/src/core/library.ts +++ b/packages/compiler/src/core/library.ts @@ -8,6 +8,7 @@ import { JSONSchemaValidator, LinterDefinition, LinterRuleDefinition, + OnInfoHook, PackageFlags, StateDef, TypeSpecLibrary, @@ -114,6 +115,23 @@ export function defineLinter(def: LinterDefinition): LinterDefinition { return def; } +/** + * Define a `$onInfo` hook that contributes extra information about types to IDE hover + * documentation and tooling. This helper only provides typing; export the result as `$onInfo` + * from your library's entry point. + * + * @example + * ```ts + * export const $onInfo = defineInfoHook(({ program, target }) => { + * if (target.kind !== "Operation") return undefined; + * return [{ content: `Operation ${target.name}` }]; + * }); + * ``` + */ +export function defineInfoHook(hook: OnInfoHook): OnInfoHook { + return hook; +} + /** Create a new linter rule. */ export function createLinterRule< const N extends string, diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index 188a1ded0c1..168978c7f42 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -21,6 +21,7 @@ import { compilerAssert } from "./diagnostics.js"; import { getEmittedFilesForProgram } from "./emitter-utils.js"; import { resolveTypeSpecEntrypoint } from "./entrypoint-resolution.js"; import { ExternalError } from "./external-error.js"; +import { isCompilerFeatureEnabled } from "./features.js"; import { getLibraryUrlsLoaded } from "./library.js"; import { builtInLinterLibraryName, @@ -60,6 +61,7 @@ import { EmitContext, EmitterFunc, Entity, + InfoContext, JsSourceFileNode, LibraryInstance, LibraryMetadata, @@ -70,6 +72,7 @@ import { Namespace, NoTarget, Node, + OnInfoHook, PerfReporter, SourceFile, Sym, @@ -78,6 +81,7 @@ import { TemplateInstanceTarget, Tracer, Type, + TypeInfo, TypeSpecLibrary, TypeSpecScriptNode, } from "./types.js"; @@ -118,6 +122,22 @@ export interface Program { cb: (program: Program) => void | Promise, LibraryMetadata: LibraryMetadata, ): void; + /** + * Register a provider that contributes extra information about types. Libraries wire this up + * by exporting a `$onInfo` hook; it is discovered automatically by the compiler. + * @internal + */ + registerInfoProvider(provider: OnInfoHook, metadata: LibraryMetadata): void; + /** + * Query the registered `$onInfo` providers for extra information about the given type. Returns + * the merged information contributed by every library, or `undefined` when none contributed. + * Providers are run lazily and never mutate the type graph. Used by the language server for + * hover docs and by tooling. + * + * Requires the experimental `type-info-hook` compiler feature to be enabled; returns + * `undefined` otherwise. + */ + getTypeInfo(target: Type): TypeInfo | undefined; /** @internal */ getOption(key: string): string | undefined; stateSet(key: symbol): Set; @@ -177,6 +197,11 @@ interface Validator { ) => void | readonly Diagnostic[] | Promise | Promise; } +interface InfoProvider { + metadata: LibraryMetadata; + callback: OnInfoHook; +} + interface TypeSpecLibraryReference { path: string; manifest: PackageJson; @@ -238,6 +263,7 @@ async function createProgram( ): Promise<{ program: Program; shouldAbort: boolean }> { const runtimeStats: Partial = {}; const validateCbs: Validator[] = []; + const infoProviders: InfoProvider[] = []; const stateMaps = new Map>(); const stateSets = new Map>(); const diagnostics: Diagnostic[] = []; @@ -301,6 +327,39 @@ async function createProgram( onValidate(cb, metadata) { validateCbs.push({ callback: cb, metadata }); }, + registerInfoProvider(provider, metadata) { + infoProviders.push({ callback: provider, metadata }); + }, + getTypeInfo(target) { + if (!isCompilerFeatureEnabled(program, "type-info-hook")) { + return undefined; + } + const contents: string[] = []; + const context: InfoContext = { program, target }; + for (const provider of infoProviders) { + let result: TypeInfo | undefined; + try { + result = provider.callback(context); + } catch (error: any) { + if (options.designTimeBuild) { + program.reportDiagnostic( + createDiagnostic({ + code: "on-validate-fail", + format: { error: error.stack }, + target: NoTarget, + }), + ); + continue; + } else { + throw new ExternalError({ kind: "info", metadata: provider.metadata, error }); + } + } + if (result) { + contents.push(result.content); + } + } + return contents.length > 0 ? { content: contents.join("\n\n") } : undefined; + }, getGlobalNamespaceType, resolveTypeReference, /** @internal */ diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 9f6a3ab9e48..37928de3894 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2351,6 +2351,37 @@ export type SemanticNodeListener = { } & TypeListeners & ValueListeners; +/** + * Extra information about a type contributed by a library through the `$onInfo` hook. Used to + * enrich IDE hover documentation and to answer queries (e.g. from AI agents/tooling). + */ +export interface TypeInfo { + /** Markdown content describing this information, e.g. ``"`HTTP Route`: `GET /pets/{id}`"``. */ + readonly content: string; +} + +/** + * Context passed to a library's `$onInfo` hook. Additional properties may be added over time. + */ +export interface InfoContext { + /** The current program. */ + readonly program: Program; + /** The type the information is being requested for. */ + readonly target: Type; +} + +/** + * Library hook returning extra information about a given type. + * + * A library provides this by exporting a `$onInfo` function (typically via {@link defineInfoHook}). + * Unlike `$onValidate`, this hook is never run during compilation and must not mutate the type + * graph. It is invoked lazily and on demand (e.g. by the language server when computing hover + * documentation, or by tooling querying {@link Program.getTypeInfo}). + * + * This hook is gated behind the experimental `type-info-hook` compiler feature. + */ +export type OnInfoHook = (context: InfoContext) => TypeInfo | undefined; + export type DiagnosticReportWithoutTarget< T extends { [code: string]: DiagnosticMessages }, C extends keyof T, diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 57bbe1acb87..583d53fd0bd 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -101,6 +101,7 @@ export { export { createLinterRule as createRule, createTypeSpecLibrary, + defineInfoHook, defineLinter, definePackageFlags, paramMessage, @@ -379,6 +380,7 @@ export type { IdentifierContext, IdentifierKind, IndeterminateEntity, + InfoContext, InsertTextCodeFixEdit, Interface, IntrinsicScalarName, @@ -425,6 +427,7 @@ export type { NumericValue, ObjectValue, ObjectValuePropertyDescriptor, + OnInfoHook, Operation, OperationSignature, PackageFlags, @@ -468,6 +471,7 @@ export type { TracerOptions, Tuple, Type, + TypeInfo, TypeInstantiationMap, TypeListeners, TypeMapper, diff --git a/packages/compiler/src/server/type-details.ts b/packages/compiler/src/server/type-details.ts index 218399989d2..2f2e41c5bf5 100644 --- a/packages/compiler/src/server/type-details.ts +++ b/packages/compiler/src/server/type-details.ts @@ -64,9 +64,32 @@ export async function getSymbolDetails( ); } + const infoType = resolveSymbolType(program, symbol); + if (infoType) { + const info = program.getTypeInfo(infoType); + if (info) { + lines.push(info.content); + } + } + return lines.join("\n\n"); } +/** Resolve the {@link Type} a symbol refers to, if any. */ +function resolveSymbolType(program: Program, symbol: Sym): Type | undefined { + if (symbol.type) { + return symbol.type; + } + const symNode = getSymNode(symbol); + if (symNode) { + const entity = program.checker.getTypeOrValueForNode(symNode); + if (entity && isType(entity)) { + return entity; + } + } + return undefined; +} + function getSymbolDocumentation(program: Program, symbol: Sym) { const docs: string[] = []; diff --git a/packages/compiler/test/info-hook.test.ts b/packages/compiler/test/info-hook.test.ts new file mode 100644 index 00000000000..0e8359c98d8 --- /dev/null +++ b/packages/compiler/test/info-hook.test.ts @@ -0,0 +1,88 @@ +import { deepStrictEqual, ok, strictEqual } from "assert"; +import { describe, it } from "vitest"; +import { ExternalError } from "../src/core/external-error.js"; +import type { CompilerOptions } from "../src/core/options.js"; +import type { InfoContext } from "../src/core/types.js"; +import { mockFile, t } from "../src/testing/index.js"; +import { Tester } from "./tester.js"; + +const withFeature: { compilerOptions: CompilerOptions } = { + compilerOptions: { + configFile: { + projectRoot: ".", + kind: "project", + features: ["type-info-hook"], + diagnostics: [], + outputDir: "tsp-output", + }, + }, +}; + +describe("compiler: $onInfo hook", () => { + it("merges the content contributed by multiple providers into a single result", async () => { + const runner = await Tester.files({ + "info1.js": mockFile.js({ + $onInfo: ({ target }: InfoContext) => + target.kind === "Operation" ? { content: "a" } : undefined, + }), + "info2.js": mockFile.js({ + $onInfo: ({ target }: InfoContext) => + target.kind === "Operation" ? { content: "b" } : undefined, + }), + }) + .import("./info1.js", "./info2.js") + .createInstance(); + + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, withFeature); + deepStrictEqual(runner.program.getTypeInfo(foo), { content: "a\n\nb" }); + }); + + it("returns undefined when no provider contributes for the type", async () => { + const runner = await Tester.files({ + "info.js": mockFile.js({ + $onInfo: ({ target }: InfoContext) => + target.kind === "Operation" ? { content: "a" } : undefined, + }), + }) + .import("./info.js") + .createInstance(); + + const { foo } = await runner.compile(t.code`model ${t.model("foo")} {}`, withFeature); + strictEqual(runner.program.getTypeInfo(foo), undefined); + }); + + it("returns undefined when the `type-info-hook` feature is not enabled", async () => { + const runner = await Tester.files({ + "info.js": mockFile.js({ + $onInfo: ({ target }: InfoContext) => + target.kind === "Operation" ? { content: "a" } : undefined, + }), + }) + .import("./info.js") + .createInstance(); + + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`); + strictEqual(runner.program.getTypeInfo(foo), undefined); + }); + + it("wraps provider crashes in an ExternalError", async () => { + const runner = await Tester.files({ + "info.js": mockFile.js({ + $onInfo: () => { + throw new Error("boom"); + }, + }), + }) + .import("./info.js") + .createInstance(); + + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, withFeature); + let error: unknown; + try { + runner.program.getTypeInfo(foo); + } catch (e) { + error = e; + } + ok(error instanceof ExternalError, "Expected getTypeInfo to throw an ExternalError"); + }); +}); diff --git a/packages/compiler/test/server/get-hover.test.ts b/packages/compiler/test/server/get-hover.test.ts index 262b364a871..57de570d4eb 100644 --- a/packages/compiler/test/server/get-hover.test.ts +++ b/packages/compiler/test/server/get-hover.test.ts @@ -836,6 +836,28 @@ describe("template access", () => { }); }); +describe("$onInfo hook", () => { + it("appends info entries contributed by a library to the hover", async () => { + const hover = await getHoverAtCursorWithInfoHook(` + import "./info.js"; + op wr┆ite(): void; + `); + const value = getHoverValue(hover); + ok(value); + ok(value.includes("op-info:write"), `Expected info entry in hover, got: ${value}`); + }); + + it("does not add anything when the provider returns undefined for the type", async () => { + const hover = await getHoverAtCursorWithInfoHook(` + import "./info.js"; + model Fo┆o {} + `); + const value = getHoverValue(hover); + ok(value); + ok(!value.includes("op-info:"), `Expected no info entry, got: ${value}`); + }); +}); + async function getHoverAtCursor(sourceWithCursor: string): Promise { const { source, pos } = extractCursor(sourceWithCursor); const testHost = await createTestServerHost(); @@ -849,6 +871,21 @@ async function getHoverAtCursor(sourceWithCursor: string): Promise { + const { source, pos } = extractCursor(sourceWithCursor); + const testHost = await createTestServerHost(); + testHost.addOrUpdateDocument("tspconfig.yaml", `features:\n - type-info-hook\n`); + testHost.addJsFile("info.js", { + $onInfo: ({ target }: { target: { kind: string; name?: string } }) => + target.kind === "Operation" ? { content: `op-info:${target.name}` } : undefined, + }); + const textDocument = testHost.addOrUpdateDocument("test.tsp", source); + return await testHost.server.getHover({ + textDocument, + position: textDocument.positionAt(pos), + }); +} + /** Normalize hover contents into a single comparable string for assertions. */ function getHoverValue(hover: Hover | undefined): string | undefined { if (!hover) return undefined; diff --git a/packages/http/src/info.ts b/packages/http/src/info.ts new file mode 100644 index 00000000000..e11fc60df7b --- /dev/null +++ b/packages/http/src/info.ts @@ -0,0 +1,39 @@ +import { defineInfoHook } from "@typespec/compiler"; +import { getHttpOperation } from "./operations.js"; +import type { HttpStatusCodeRange } from "./types.js"; + +/** + * Contribute HTTP specific information about types to IDEs (e.g. hover) and tooling. + * + * For an operation, this surfaces the resolved HTTP route (verb + URI template) plus basic + * response status codes so it can be shown on hover or queried programmatically. This hook never + * mutates the type graph. + */ +export const $onInfo = defineInfoHook(({ program, target }) => { + if (target.kind !== "Operation") { + return undefined; + } + const [operation] = getHttpOperation(program, target); + if (!operation) { + return undefined; + } + + const lines = [`\`HTTP Route\`: \`${operation.verb.toUpperCase()} ${operation.uriTemplate}\``]; + + const statusCodes = operation.responses.map((response) => formatStatusCode(response.statusCodes)); + if (statusCodes.length > 0) { + lines.push(`\`Responses\`: ${statusCodes.map((code) => `\`${code}\``).join(", ")}`); + } + + return { content: lines.join("\n\n") }; +}); + +function formatStatusCode(statusCode: HttpStatusCodeRange | number | "*"): string { + if (statusCode === "*") { + return "*"; + } + if (typeof statusCode === "number") { + return String(statusCode); + } + return `${statusCode.start}-${statusCode.end}`; +} diff --git a/packages/http/src/tsp-index.ts b/packages/http/src/tsp-index.ts index 227f96d878d..db98e0e98bc 100644 --- a/packages/http/src/tsp-index.ts +++ b/packages/http/src/tsp-index.ts @@ -37,6 +37,7 @@ import { $plainData, } from "./private.decorators.js"; +export { $onInfo } from "./info.js"; export { $lib } from "./lib.js"; export { $onValidate } from "./validate.js"; diff --git a/packages/http/test/info.test.ts b/packages/http/test/info.test.ts new file mode 100644 index 00000000000..7951fd6c406 --- /dev/null +++ b/packages/http/test/info.test.ts @@ -0,0 +1,25 @@ +import { t } from "@typespec/compiler/testing"; +import { deepStrictEqual } from "assert"; +import { describe, it } from "vitest"; +import { $onInfo } from "../src/info.js"; +import { Tester } from "./test-host.js"; + +describe("http: $onInfo", () => { + it("surfaces the verb, URI template and response status codes for an operation", async () => { + const { program, read } = await Tester.compile(t.code` + @route("/pets/{id}") @get op ${t.op("read")}(@path id: string): void; + `); + + deepStrictEqual($onInfo({ program, target: read }), { + content: "`HTTP Route`: `GET /pets/{id}`\n\n`Responses`: `204`", + }); + }); + + it("returns undefined for non-operation types", async () => { + const { program, Pet } = await Tester.compile(t.code` + model ${t.model("Pet")} {} + `); + + deepStrictEqual($onInfo({ program, target: Pet }), undefined); + }); +}); diff --git a/website/src/content/current-sidebar.ts b/website/src/content/current-sidebar.ts index c0119ce3ebd..fc12b68be16 100644 --- a/website/src/content/current-sidebar.ts +++ b/website/src/content/current-sidebar.ts @@ -244,6 +244,7 @@ const sidebar: SidebarItem[] = [ "extending-typespec/basics", "extending-typespec/diagnostics", "extending-typespec/create-decorators", + "extending-typespec/providing-info", "extending-typespec/linters", "extending-typespec/codefixes", "extending-typespec/testing", diff --git a/website/src/content/docs/docs/extending-typespec/providing-info.md b/website/src/content/docs/docs/extending-typespec/providing-info.md new file mode 100644 index 00000000000..59a59adf19e --- /dev/null +++ b/website/src/content/docs/docs/extending-typespec/providing-info.md @@ -0,0 +1,72 @@ +--- +id: providing-info +title: Providing IDE & tooling info +--- + +Libraries can contribute extra, domain-specific information about types that is not part of +the core language — for example, `@typespec/http` surfaces the effective HTTP route of an +operation. This information is shown in IDE hover tooltips and can also be queried +programmatically by tooling (such as AI agents). + +:::caution +This is an experimental feature. It must be enabled with the `type-info-hook` +[compiler feature flag](../handbook/configuration/configuration.md) in your `tspconfig.yaml`: + +```yaml +features: + - type-info-hook +``` + +::: + +## The `$onInfo` hook + +A library provides this information by exporting an `$onInfo` function from its main entry +point. Use the `defineInfoHook` helper for typing. The hook receives an `InfoContext` (with +the current `program` and the `target` type) and returns a single `TypeInfo` object, or +`undefined` when it has nothing to contribute. + +```ts +import { defineInfoHook } from "@typespec/compiler"; +import { getHttpOperation } from "./operations.js"; + +export const $onInfo = defineInfoHook(({ program, target }) => { + if (target.kind !== "Operation") { + return undefined; + } + const [operation] = getHttpOperation(program, target); + if (!operation) { + return undefined; + } + return { + content: `\`HTTP Route\`: \`${operation.verb.toUpperCase()} ${operation.uriTemplate}\``, + }; +}); +``` + +A `TypeInfo` currently has a single `content` field: the markdown content to show for this +piece of information. + +## Important constraints + +Unlike [`$onValidate`](./diagnostics.md), the `$onInfo` hook: + +- **is never run during compilation.** It is invoked lazily and on demand. +- **must not mutate the type graph.** It should only read the program and answer questions + about it. + +Because it does not change the type graph, there are no ordering or race concerns between +libraries — the `content` from every library is simply concatenated. + +## Querying info programmatically + +Tooling can query all registered providers for a given type with `program.getTypeInfo`, +which merges the contributions from every library into a single `TypeInfo` (or `undefined` +when nothing is contributed or the feature is disabled): + +```ts +const info = program.getTypeInfo(type); +// { content: "`HTTP Route`: `GET /pets/{id}`\n\n`Responses`: `204`" } +``` + +The language server uses this same API to enrich hover documentation. From ddd2cac5d5a79582e00ada6f80d5b057852e0f3a Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 30 Jul 2026 14:51:53 -0400 Subject: [PATCH 2/6] test: update compiler feature list assertions for type-info-hook --- packages/compiler/test/core/cli/actions/info.test.ts | 1 + .../compiler/test/server/completion.tspconfig.test.ts | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/compiler/test/core/cli/actions/info.test.ts b/packages/compiler/test/core/cli/actions/info.test.ts index 86fb69c71dd..e2ed72afbc6 100644 --- a/packages/compiler/test/core/cli/actions/info.test.ts +++ b/packages/compiler/test/core/cli/actions/info.test.ts @@ -22,5 +22,6 @@ it("lists available compiler features and marks enabled features", () => { "", " enabled function-declarations Allows use of function declarations without experimental warnings in project code.", " disabled auto-decorators Allows use of auto decorator declarations without experimental warnings in project code.", + " disabled type-info-hook Enables the experimental `$onInfo` hook allowing libraries to contribute extra information about types to IDE hover and tooling (queried via `program.getTypeInfo`).", ]); }); diff --git a/packages/compiler/test/server/completion.tspconfig.test.ts b/packages/compiler/test/server/completion.tspconfig.test.ts index 09680b8c713..e167564bb9d 100644 --- a/packages/compiler/test/server/completion.tspconfig.test.ts +++ b/packages/compiler/test/server/completion.tspconfig.test.ts @@ -134,19 +134,19 @@ describe("Test completion items for features", () => { it.each([ { config: `features:\n - ┆`, - expected: ['"auto-decorators"', '"function-declarations"'], + expected: ['"auto-decorators"', '"function-declarations"', '"type-info-hook"'], }, { config: `features:\n - "┆"`, - expected: ["auto-decorators", "function-declarations"], + expected: ["auto-decorators", "function-declarations", "type-info-hook"], }, { config: `features:\n - "function┆"`, - expected: ["auto-decorators", "function-declarations"], + expected: ["auto-decorators", "function-declarations", "type-info-hook"], }, { config: `features:\n - function-declarations\n - ┆`, - expected: ['"auto-decorators"'], + expected: ['"auto-decorators"', '"type-info-hook"'], }, ])("#%# Test features: $config", async ({ config, expected }) => { await checkCompletionItems(config, true, expected); @@ -159,6 +159,7 @@ describe("Test completion items for features", () => { [ "Allows use of auto decorator declarations without experimental warnings in project code.", "Allows use of function declarations without experimental warnings in project code.", + "Enables the experimental `$onInfo` hook allowing libraries to contribute extra information about types to IDE hover and tooling (queried via `program.getTypeInfo`).", ], true, ); From 4042b6986973bf9c970adf34a91bf711c7659427 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 31 Jul 2026 09:22:08 -0400 Subject: [PATCH 3/6] refactor: scope type-info-hook feature to the library declaring $onInfo The `type-info-hook` feature is now checked against the package that declares the $onInfo hook rather than the consuming project. Libraries opt in via their own tspconfig.yaml and consumers see the info without enabling anything. Enable it in @typespec/http and ship library tspconfig.yaml in bundles so it works in the playground. --- ...info-bundler-tspconfig-2026-7-31-9-30-0.md | 7 ++ .../changes/on-info-hook-2026-7-30-12-30-0.md | 4 +- .../on-info-http-route-2026-7-30-12-30-0.md | 2 +- packages/bundler/src/bundler.ts | 19 +++++ packages/compiler/src/core/binder.ts | 18 +++-- packages/compiler/src/core/program.ts | 8 +- packages/compiler/test/info-hook.test.ts | 74 ++++++++++++------- .../compiler/test/server/get-hover.test.ts | 2 +- packages/http/tspconfig.yaml | 3 + .../docs/extending-typespec/providing-info.md | 9 ++- 10 files changed, 103 insertions(+), 43 deletions(-) create mode 100644 .chronus/changes/on-info-bundler-tspconfig-2026-7-31-9-30-0.md create mode 100644 packages/http/tspconfig.yaml diff --git a/.chronus/changes/on-info-bundler-tspconfig-2026-7-31-9-30-0.md b/.chronus/changes/on-info-bundler-tspconfig-2026-7-31-9-30-0.md new file mode 100644 index 00000000000..ef4eddb17b3 --- /dev/null +++ b/.chronus/changes/on-info-bundler-tspconfig-2026-7-31-9-30-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/bundler" +--- + +Include a library's own `tspconfig.yaml` in the generated bundle so per-library opt-ins (such as compiler `features`) are preserved when the library is loaded in the browser (e.g. in the playground). diff --git a/.chronus/changes/on-info-hook-2026-7-30-12-30-0.md b/.chronus/changes/on-info-hook-2026-7-30-12-30-0.md index 001dee0d922..9e496b99289 100644 --- a/.chronus/changes/on-info-hook-2026-7-30-12-30-0.md +++ b/.chronus/changes/on-info-hook-2026-7-30-12-30-0.md @@ -4,7 +4,9 @@ packages: - "@typespec/compiler" --- -Add a new experimental `$onInfo` library hook and `program.getTypeInfo(type)` API allowing libraries to contribute extra, domain-specific information about types. Unlike `$onValidate`, this hook never runs during compilation and must not mutate the type graph — it is invoked lazily and on demand (e.g. by the language server for hover documentation, or by tooling querying the type). The feature is gated behind the `type-info-hook` compiler feature flag. +Add a new experimental `$onInfo` library hook and `program.getTypeInfo(type)` API allowing libraries to contribute extra, domain-specific information about types. Unlike `$onValidate`, this hook never runs during compilation and must not mutate the type graph — it is invoked lazily and on demand (e.g. by the language server for hover documentation, or by tooling querying the type). + +The hook is gated by the `type-info-hook` compiler feature, scoped to the package that declares it: a library opts in via its own `tspconfig.yaml` and consumers do not need to enable anything. ```ts // A library exports a provider (use `defineInfoHook` for typing): diff --git a/.chronus/changes/on-info-http-route-2026-7-30-12-30-0.md b/.chronus/changes/on-info-http-route-2026-7-30-12-30-0.md index f06201eb752..f636faa9ca6 100644 --- a/.chronus/changes/on-info-http-route-2026-7-30-12-30-0.md +++ b/.chronus/changes/on-info-http-route-2026-7-30-12-30-0.md @@ -4,4 +4,4 @@ packages: - "@typespec/http" --- -Add an `$onInfo` provider that surfaces the resolved HTTP route (verb and URI template) and response status codes of an operation. This is shown when hovering an operation in the IDE and can be queried programmatically via `program.getTypeInfo(operation)`. Requires the experimental `type-info-hook` compiler feature flag. +Add an `$onInfo` provider that surfaces the resolved HTTP route (verb and URI template) and response status codes of an operation. This is shown when hovering an operation in the IDE and can be queried programmatically via `program.getTypeInfo(operation)`. diff --git a/packages/bundler/src/bundler.ts b/packages/bundler/src/bundler.ts index 08768940467..5e010a641d4 100644 --- a/packages/bundler/src/bundler.ts +++ b/packages/bundler/src/bundler.ts @@ -186,6 +186,13 @@ async function createEsBuildContext( [normalizePath(join(libraryPath, "package.json"))]: JSON.stringify(definition.packageJson), }; + // Include the library's own `tspconfig.yaml` so per-library opt-ins (e.g. compiler + // `features`) are preserved in the bundle. + const tspconfig = await tryReadFile(join(libraryPath, "tspconfig.yaml")); + if (tspconfig !== undefined) { + typespecFiles[normalizePath(join(libraryPath, "tspconfig.yaml"))] = tspconfig; + } + for (const [filename, sourceFile] of program.sourceFiles) { typespecFiles[filename] = sourceFile.file.text; } @@ -339,6 +346,18 @@ async function readLibraryPackageJson(path: string): Promise { return JSON.parse(file.toString()); } +/** Read a file, returning `undefined` when it does not exist. */ +async function tryReadFile(path: string): Promise { + try { + return (await readFile(path)).toString(); + } catch (error: any) { + if (error.code === "ENOENT") { + return undefined; + } + throw error; + } +} + /** * Resolve which peer dependencies should be treated as external. * Only peer dependencies that are TypeSpec libraries (have `tspMain` in their package.json) diff --git a/packages/compiler/src/core/binder.ts b/packages/compiler/src/core/binder.ts index f3c63fc3b90..51aafe8307c 100644 --- a/packages/compiler/src/core/binder.ts +++ b/packages/compiler/src/core/binder.ts @@ -1,5 +1,6 @@ import { mutate } from "../utils/misc.js"; import { compilerAssert } from "./diagnostics.js"; +import { isCompilerFeatureEnabled } from "./features.js"; import { getLocationContext } from "./helpers/location-context.js"; import { visitChildren } from "./parser.js"; import type { Program } from "./program.js"; @@ -180,12 +181,17 @@ export function createBinder(program: Program): Binder { program.onValidate(member as any, metadata); continue; } else if (name === "onInfo") { - const context = getLocationContext(program, sourceFile); - const metadata = - context.type === "library" - ? context.metadata - : ({ type: "file" } satisfies FileLibraryMetadata); - program.registerInfoProvider(member as any, metadata); + // `$onInfo` is experimental: only respect it when the library (or project) that + // declares the hook opted into the `type-info-hook` feature in its own + // `tspconfig.yaml`. Consumers do not need to enable anything. + if (isCompilerFeatureEnabled(program, "type-info-hook", sourceFile)) { + const context = getLocationContext(program, sourceFile); + const metadata = + context.type === "library" + ? context.metadata + : ({ type: "file" } satisfies FileLibraryMetadata); + program.registerInfoProvider(member as any, metadata); + } continue; } else if (name === "onEmit") { // nothing to do here this is loaded as emitter. diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index 168978c7f42..ac9217332bf 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -21,7 +21,6 @@ import { compilerAssert } from "./diagnostics.js"; import { getEmittedFilesForProgram } from "./emitter-utils.js"; import { resolveTypeSpecEntrypoint } from "./entrypoint-resolution.js"; import { ExternalError } from "./external-error.js"; -import { isCompilerFeatureEnabled } from "./features.js"; import { getLibraryUrlsLoaded } from "./library.js"; import { builtInLinterLibraryName, @@ -134,8 +133,8 @@ export interface Program { * Providers are run lazily and never mutate the type graph. Used by the language server for * hover docs and by tooling. * - * Requires the experimental `type-info-hook` compiler feature to be enabled; returns - * `undefined` otherwise. + * Only libraries that opted into the experimental `type-info-hook` feature in their own + * `tspconfig.yaml` contribute; consumers do not need to enable anything. */ getTypeInfo(target: Type): TypeInfo | undefined; /** @internal */ @@ -331,9 +330,6 @@ async function createProgram( infoProviders.push({ callback: provider, metadata }); }, getTypeInfo(target) { - if (!isCompilerFeatureEnabled(program, "type-info-hook")) { - return undefined; - } const contents: string[] = []; const context: InfoContext = { program, target }; for (const provider of infoProviders) { diff --git a/packages/compiler/test/info-hook.test.ts b/packages/compiler/test/info-hook.test.ts index 0e8359c98d8..1743d099628 100644 --- a/packages/compiler/test/info-hook.test.ts +++ b/packages/compiler/test/info-hook.test.ts @@ -6,7 +6,8 @@ import type { InfoContext } from "../src/core/types.js"; import { mockFile, t } from "../src/testing/index.js"; import { Tester } from "./tester.js"; -const withFeature: { compilerOptions: CompilerOptions } = { +/** Root project config enabling the `type-info-hook` feature for the project's own files. */ +const projectFeature: { compilerOptions: CompilerOptions } = { compilerOptions: { configFile: { projectRoot: ".", @@ -18,46 +19,35 @@ const withFeature: { compilerOptions: CompilerOptions } = { }, }; +const opInfoHook = (content: string) => + mockFile.js({ + $onInfo: ({ target }: InfoContext) => (target.kind === "Operation" ? { content } : undefined), + }); + describe("compiler: $onInfo hook", () => { it("merges the content contributed by multiple providers into a single result", async () => { const runner = await Tester.files({ - "info1.js": mockFile.js({ - $onInfo: ({ target }: InfoContext) => - target.kind === "Operation" ? { content: "a" } : undefined, - }), - "info2.js": mockFile.js({ - $onInfo: ({ target }: InfoContext) => - target.kind === "Operation" ? { content: "b" } : undefined, - }), + "info1.js": opInfoHook("a"), + "info2.js": opInfoHook("b"), }) .import("./info1.js", "./info2.js") .createInstance(); - const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, withFeature); + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, projectFeature); deepStrictEqual(runner.program.getTypeInfo(foo), { content: "a\n\nb" }); }); it("returns undefined when no provider contributes for the type", async () => { - const runner = await Tester.files({ - "info.js": mockFile.js({ - $onInfo: ({ target }: InfoContext) => - target.kind === "Operation" ? { content: "a" } : undefined, - }), - }) + const runner = await Tester.files({ "info.js": opInfoHook("a") }) .import("./info.js") .createInstance(); - const { foo } = await runner.compile(t.code`model ${t.model("foo")} {}`, withFeature); + const { foo } = await runner.compile(t.code`model ${t.model("foo")} {}`, projectFeature); strictEqual(runner.program.getTypeInfo(foo), undefined); }); - it("returns undefined when the `type-info-hook` feature is not enabled", async () => { - const runner = await Tester.files({ - "info.js": mockFile.js({ - $onInfo: ({ target }: InfoContext) => - target.kind === "Operation" ? { content: "a" } : undefined, - }), - }) + it("ignores the hook when the declaring project did not enable the `type-info-hook` feature", async () => { + const runner = await Tester.files({ "info.js": opInfoHook("a") }) .import("./info.js") .createInstance(); @@ -76,7 +66,7 @@ describe("compiler: $onInfo hook", () => { .import("./info.js") .createInstance(); - const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, withFeature); + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, projectFeature); let error: unknown; try { runner.program.getTypeInfo(foo); @@ -85,4 +75,38 @@ describe("compiler: $onInfo hook", () => { } ok(error instanceof ExternalError, "Expected getTypeInfo to throw an ExternalError"); }); + + describe("feature is scoped to the library declaring the hook", () => { + function testerWithLib(tspconfig?: string) { + return Tester.files({ + "node_modules/my-lib/package.json": JSON.stringify({ name: "my-lib", main: "index.js" }), + "node_modules/my-lib/index.js": opInfoHook("from-lib"), + ...(tspconfig ? { "node_modules/my-lib/tspconfig.yaml": tspconfig } : {}), + }).import("my-lib"); + } + + it("respects the hook when the library enables the feature in its own tspconfig.yaml", async () => { + const runner = await testerWithLib( + `kind: project\nfeatures:\n - type-info-hook\n`, + ).createInstance(); + + // The consumer does NOT enable the feature; the library's opt-in is enough. + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`); + deepStrictEqual(runner.program.getTypeInfo(foo), { content: "from-lib" }); + }); + + it("ignores the hook when the library does not enable the feature", async () => { + const runner = await testerWithLib().createInstance(); + + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`); + strictEqual(runner.program.getTypeInfo(foo), undefined); + }); + + it("enabling the feature in the consumer project does not enable it for library code", async () => { + const runner = await testerWithLib().createInstance(); + + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, projectFeature); + strictEqual(runner.program.getTypeInfo(foo), undefined); + }); + }); }); diff --git a/packages/compiler/test/server/get-hover.test.ts b/packages/compiler/test/server/get-hover.test.ts index 57de570d4eb..09f46d48282 100644 --- a/packages/compiler/test/server/get-hover.test.ts +++ b/packages/compiler/test/server/get-hover.test.ts @@ -874,7 +874,7 @@ async function getHoverAtCursor(sourceWithCursor: string): Promise { const { source, pos } = extractCursor(sourceWithCursor); const testHost = await createTestServerHost(); - testHost.addOrUpdateDocument("tspconfig.yaml", `features:\n - type-info-hook\n`); + testHost.addOrUpdateDocument("tspconfig.yaml", `kind: project\nfeatures:\n - type-info-hook\n`); testHost.addJsFile("info.js", { $onInfo: ({ target }: { target: { kind: string; name?: string } }) => target.kind === "Operation" ? { content: `op-info:${target.name}` } : undefined, diff --git a/packages/http/tspconfig.yaml b/packages/http/tspconfig.yaml new file mode 100644 index 00000000000..0fa25f1df6e --- /dev/null +++ b/packages/http/tspconfig.yaml @@ -0,0 +1,3 @@ +kind: project +features: + - type-info-hook diff --git a/website/src/content/docs/docs/extending-typespec/providing-info.md b/website/src/content/docs/docs/extending-typespec/providing-info.md index 59a59adf19e..1e7f124d20f 100644 --- a/website/src/content/docs/docs/extending-typespec/providing-info.md +++ b/website/src/content/docs/docs/extending-typespec/providing-info.md @@ -9,14 +9,17 @@ operation. This information is shown in IDE hover tooltips and can also be queri programmatically by tooling (such as AI agents). :::caution -This is an experimental feature. It must be enabled with the `type-info-hook` -[compiler feature flag](../handbook/configuration/configuration.md) in your `tspconfig.yaml`: +This is an experimental feature. A library must opt in by enabling the `type-info-hook` +[compiler feature](../handbook/configuration/configuration.md) in **its own** `tspconfig.yaml`: ```yaml +kind: project features: - type-info-hook ``` +The opt-in is scoped to the package declaring the hook — consumers of the library do not need +to enable anything to see the information. ::: ## The `$onInfo` hook @@ -62,7 +65,7 @@ libraries — the `content` from every library is simply concatenated. Tooling can query all registered providers for a given type with `program.getTypeInfo`, which merges the contributions from every library into a single `TypeInfo` (or `undefined` -when nothing is contributed or the feature is disabled): +when nothing is contributed): ```ts const info = program.getTypeInfo(type); From 13b3648934ab1960ffa759f1903ec240b36b365d Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 31 Jul 2026 09:28:56 -0400 Subject: [PATCH 4/6] feat(compiler): separate $onInfo content from doc comments with a horizontal rule --- packages/compiler/src/server/type-details.ts | 5 +++++ .../compiler/test/server/get-hover.test.ts | 7 +++++-- .../docs/extending-typespec/providing-info.md | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/server/type-details.ts b/packages/compiler/src/server/type-details.ts index 2f2e41c5bf5..44bfbc7a229 100644 --- a/packages/compiler/src/server/type-details.ts +++ b/packages/compiler/src/server/type-details.ts @@ -68,6 +68,11 @@ export async function getSymbolDetails( if (infoType) { const info = program.getTypeInfo(infoType); if (info) { + // Separate library contributed info from the type's own signature/documentation with a + // horizontal rule so it is clearly not part of the doc comment. + if (lines.length > 0) { + lines.push("---"); + } lines.push(info.content); } } diff --git a/packages/compiler/test/server/get-hover.test.ts b/packages/compiler/test/server/get-hover.test.ts index 09f46d48282..bd42fa50bb5 100644 --- a/packages/compiler/test/server/get-hover.test.ts +++ b/packages/compiler/test/server/get-hover.test.ts @@ -837,14 +837,17 @@ describe("template access", () => { }); describe("$onInfo hook", () => { - it("appends info entries contributed by a library to the hover", async () => { + it("appends info contributed by a library to the hover, separated by a horizontal rule", async () => { const hover = await getHoverAtCursorWithInfoHook(` import "./info.js"; op wr┆ite(): void; `); const value = getHoverValue(hover); ok(value); - ok(value.includes("op-info:write"), `Expected info entry in hover, got: ${value}`); + ok( + value.endsWith("\n\n---\n\nop-info:write"), + `Expected info separated from the signature by a horizontal rule, got: ${value}`, + ); }); it("does not add anything when the provider returns undefined for the type", async () => { diff --git a/website/src/content/docs/docs/extending-typespec/providing-info.md b/website/src/content/docs/docs/extending-typespec/providing-info.md index 1e7f124d20f..5421a16d276 100644 --- a/website/src/content/docs/docs/extending-typespec/providing-info.md +++ b/website/src/content/docs/docs/extending-typespec/providing-info.md @@ -50,6 +50,24 @@ export const $onInfo = defineInfoHook(({ program, target }) => { A `TypeInfo` currently has a single `content` field: the markdown content to show for this piece of information. +## How it is displayed + +In the IDE, the contributed content is appended after the type's signature and documentation, +separated by a horizontal rule so it is clearly distinguishable from the type's own doc +comment: + +```md +op read(id: string): void + +Reads a pet. + +--- + +`HTTP Route`: `GET /pets/{id}` + +`Responses`: `204` +``` + ## Important constraints Unlike [`$onValidate`](./diagnostics.md), the `$onInfo` hook: From ac6256cc6c802195f898f1376e2fc7f6d951c230 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 31 Jul 2026 11:52:52 -0400 Subject: [PATCH 5/6] refactor: address review feedback on $onInfo hook - Report a dedicated `on-info-fail` diagnostic when an `$onInfo` provider crashes during a design-time build, instead of reusing the misleading `on-validate-fail` code. - Use vitest `expect` in the new info hook tests. - Badge the "Providing info" docs page as experimental in the sidebar. --- packages/compiler/src/core/messages.ts | 6 ++++++ packages/compiler/src/core/program.ts | 2 +- packages/compiler/test/info-hook.test.ts | 23 ++++++++--------------- packages/http/test/info.test.ts | 7 +++---- website/src/content/current-sidebar.ts | 5 ++++- 5 files changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 5e5f832eca3..ae5929caab1 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -805,6 +805,12 @@ const diagnostics = { default: paramMessage`onValidate failed with errors. ${"error"}`, }, }, + "on-info-fail": { + severity: "error", + messages: { + default: paramMessage`onInfo failed with errors. ${"error"}`, + }, + }, "invalid-emitter": { severity: "error", messages: { diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index ac9217332bf..25473b1ea98 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -340,7 +340,7 @@ async function createProgram( if (options.designTimeBuild) { program.reportDiagnostic( createDiagnostic({ - code: "on-validate-fail", + code: "on-info-fail", format: { error: error.stack }, target: NoTarget, }), diff --git a/packages/compiler/test/info-hook.test.ts b/packages/compiler/test/info-hook.test.ts index 1743d099628..2af5b5b811c 100644 --- a/packages/compiler/test/info-hook.test.ts +++ b/packages/compiler/test/info-hook.test.ts @@ -1,5 +1,4 @@ -import { deepStrictEqual, ok, strictEqual } from "assert"; -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { ExternalError } from "../src/core/external-error.js"; import type { CompilerOptions } from "../src/core/options.js"; import type { InfoContext } from "../src/core/types.js"; @@ -34,7 +33,7 @@ describe("compiler: $onInfo hook", () => { .createInstance(); const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, projectFeature); - deepStrictEqual(runner.program.getTypeInfo(foo), { content: "a\n\nb" }); + expect(runner.program.getTypeInfo(foo)).toEqual({ content: "a\n\nb" }); }); it("returns undefined when no provider contributes for the type", async () => { @@ -43,7 +42,7 @@ describe("compiler: $onInfo hook", () => { .createInstance(); const { foo } = await runner.compile(t.code`model ${t.model("foo")} {}`, projectFeature); - strictEqual(runner.program.getTypeInfo(foo), undefined); + expect(runner.program.getTypeInfo(foo)).toBeUndefined(); }); it("ignores the hook when the declaring project did not enable the `type-info-hook` feature", async () => { @@ -52,7 +51,7 @@ describe("compiler: $onInfo hook", () => { .createInstance(); const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`); - strictEqual(runner.program.getTypeInfo(foo), undefined); + expect(runner.program.getTypeInfo(foo)).toBeUndefined(); }); it("wraps provider crashes in an ExternalError", async () => { @@ -67,13 +66,7 @@ describe("compiler: $onInfo hook", () => { .createInstance(); const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, projectFeature); - let error: unknown; - try { - runner.program.getTypeInfo(foo); - } catch (e) { - error = e; - } - ok(error instanceof ExternalError, "Expected getTypeInfo to throw an ExternalError"); + expect(() => runner.program.getTypeInfo(foo)).toThrow(ExternalError); }); describe("feature is scoped to the library declaring the hook", () => { @@ -92,21 +85,21 @@ describe("compiler: $onInfo hook", () => { // The consumer does NOT enable the feature; the library's opt-in is enough. const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`); - deepStrictEqual(runner.program.getTypeInfo(foo), { content: "from-lib" }); + expect(runner.program.getTypeInfo(foo)).toEqual({ content: "from-lib" }); }); it("ignores the hook when the library does not enable the feature", async () => { const runner = await testerWithLib().createInstance(); const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`); - strictEqual(runner.program.getTypeInfo(foo), undefined); + expect(runner.program.getTypeInfo(foo)).toBeUndefined(); }); it("enabling the feature in the consumer project does not enable it for library code", async () => { const runner = await testerWithLib().createInstance(); const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, projectFeature); - strictEqual(runner.program.getTypeInfo(foo), undefined); + expect(runner.program.getTypeInfo(foo)).toBeUndefined(); }); }); }); diff --git a/packages/http/test/info.test.ts b/packages/http/test/info.test.ts index 7951fd6c406..f5a47f6c274 100644 --- a/packages/http/test/info.test.ts +++ b/packages/http/test/info.test.ts @@ -1,6 +1,5 @@ import { t } from "@typespec/compiler/testing"; -import { deepStrictEqual } from "assert"; -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { $onInfo } from "../src/info.js"; import { Tester } from "./test-host.js"; @@ -10,7 +9,7 @@ describe("http: $onInfo", () => { @route("/pets/{id}") @get op ${t.op("read")}(@path id: string): void; `); - deepStrictEqual($onInfo({ program, target: read }), { + expect($onInfo({ program, target: read })).toEqual({ content: "`HTTP Route`: `GET /pets/{id}`\n\n`Responses`: `204`", }); }); @@ -20,6 +19,6 @@ describe("http: $onInfo", () => { model ${t.model("Pet")} {} `); - deepStrictEqual($onInfo({ program, target: Pet }), undefined); + expect($onInfo({ program, target: Pet })).toBeUndefined(); }); }); diff --git a/website/src/content/current-sidebar.ts b/website/src/content/current-sidebar.ts index fc12b68be16..a777196504b 100644 --- a/website/src/content/current-sidebar.ts +++ b/website/src/content/current-sidebar.ts @@ -244,7 +244,10 @@ const sidebar: SidebarItem[] = [ "extending-typespec/basics", "extending-typespec/diagnostics", "extending-typespec/create-decorators", - "extending-typespec/providing-info", + { + slug: "extending-typespec/providing-info", + badge: { text: "experimental", variant: "caution" }, + }, "extending-typespec/linters", "extending-typespec/codefixes", "extending-typespec/testing", From bfd1778f9e4ad8e01b2f5756142526a6a9e12a40 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 31 Jul 2026 17:51:15 -0400 Subject: [PATCH 6/6] fix(http): ship tspconfig.yaml so $onInfo is registered when published The `type-info-hook` opt-in is resolved from the declaring package's own config, but `tspconfig.yaml` was missing from `package.json#files`, so the hook was silently dropped for anyone installing @typespec/http from a registry. It only ever worked inside the monorepo. Other review feedback: - `getTypeInfo` no longer reports diagnostics after compilation finished; it traces instead, so a crashing provider cannot accumulate duplicates on a cached Program or flip `continueToNextStage`. Drops the now unused `on-info-fail` diagnostic. - Fix the `defineInfoHook` JSDoc example, which returned an array and did not type check against `OnInfoHook`. - Add an end-to-end test exercising the real registration path, plus coverage for the status code range and `*` branches. - Fix a broken link to the configuration docs and document that a library must ship its `tspconfig.yaml`. --- .../on-info-http-route-2026-7-30-12-30-0.md | 5 +++ packages/compiler/src/core/library.ts | 4 +- packages/compiler/src/core/messages.ts | 6 --- packages/compiler/src/core/program.ts | 15 ++++--- packages/compiler/test/info-hook.test.ts | 44 ++++++++++++++++--- packages/http/package.json | 1 + packages/http/test/info.test.ts | 33 ++++++++++++++ packages/http/tspconfig.yaml | 4 ++ .../docs/extending-typespec/providing-info.md | 13 +++++- 9 files changed, 105 insertions(+), 20 deletions(-) diff --git a/.chronus/changes/on-info-http-route-2026-7-30-12-30-0.md b/.chronus/changes/on-info-http-route-2026-7-30-12-30-0.md index f636faa9ca6..5ac628b4cda 100644 --- a/.chronus/changes/on-info-http-route-2026-7-30-12-30-0.md +++ b/.chronus/changes/on-info-http-route-2026-7-30-12-30-0.md @@ -5,3 +5,8 @@ packages: --- Add an `$onInfo` provider that surfaces the resolved HTTP route (verb and URI template) and response status codes of an operation. This is shown when hovering an operation in the IDE and can be queried programmatically via `program.getTypeInfo(operation)`. + +```ts +const info = program.getTypeInfo(operation); +// { content: "`HTTP Route`: `GET /pets/{id}`\n\n`Responses`: `204`" } +``` diff --git a/packages/compiler/src/core/library.ts b/packages/compiler/src/core/library.ts index 3316a77b41a..9cdc091a6e5 100644 --- a/packages/compiler/src/core/library.ts +++ b/packages/compiler/src/core/library.ts @@ -122,9 +122,9 @@ export function defineLinter(def: LinterDefinition): LinterDefinition { * * @example * ```ts - * export const $onInfo = defineInfoHook(({ program, target }) => { + * export const $onInfo = defineInfoHook(({ target }) => { * if (target.kind !== "Operation") return undefined; - * return [{ content: `Operation ${target.name}` }]; + * return { content: `Operation ${target.name}` }; * }); * ``` */ diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index ae5929caab1..5e5f832eca3 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -805,12 +805,6 @@ const diagnostics = { default: paramMessage`onValidate failed with errors. ${"error"}`, }, }, - "on-info-fail": { - severity: "error", - messages: { - default: paramMessage`onInfo failed with errors. ${"error"}`, - }, - }, "invalid-emitter": { severity: "error", messages: { diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index 25473b1ea98..75663863d03 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -330,6 +330,9 @@ async function createProgram( infoProviders.push({ callback: provider, metadata }); }, getTypeInfo(target) { + if (infoProviders.length === 0) { + return undefined; + } const contents: string[] = []; const context: InfoContext = { program, target }; for (const provider of infoProviders) { @@ -337,13 +340,13 @@ async function createProgram( try { result = provider.callback(context); } catch (error: any) { + // This runs lazily, long after compilation finished, so a crashing provider must not + // mutate the program's diagnostics. In a design time build (language server) trace it + // and carry on so a broken library cannot take down hover or completion. if (options.designTimeBuild) { - program.reportDiagnostic( - createDiagnostic({ - code: "on-info-fail", - format: { error: error.stack }, - target: NoTarget, - }), + trace( + "info-provider.crash", + `Library "${provider.metadata.name ?? ""}" $onInfo crashed: ${error.stack}`, ); continue; } else { diff --git a/packages/compiler/test/info-hook.test.ts b/packages/compiler/test/info-hook.test.ts index 2af5b5b811c..06b24a93039 100644 --- a/packages/compiler/test/info-hook.test.ts +++ b/packages/compiler/test/info-hook.test.ts @@ -54,13 +54,15 @@ describe("compiler: $onInfo hook", () => { expect(runner.program.getTypeInfo(foo)).toBeUndefined(); }); + const crashingHook = mockFile.js({ + $onInfo: () => { + throw new Error("boom"); + }, + }); + it("wraps provider crashes in an ExternalError", async () => { const runner = await Tester.files({ - "info.js": mockFile.js({ - $onInfo: () => { - throw new Error("boom"); - }, - }), + "info.js": crashingHook, }) .import("./info.js") .createInstance(); @@ -69,6 +71,38 @@ describe("compiler: $onInfo hook", () => { expect(() => runner.program.getTypeInfo(foo)).toThrow(ExternalError); }); + it("swallows provider crashes in a design time build without mutating program diagnostics", async () => { + const runner = await Tester.files({ "info.js": crashingHook }) + .import("./info.js") + .createInstance(); + + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, { + compilerOptions: { ...projectFeature.compilerOptions, designTimeBuild: true }, + }); + + // `getTypeInfo` runs long after compilation, so a crash must not append to the (cached) + // program's diagnostics nor break the editor. + const before = runner.program.diagnostics.length; + expect(runner.program.getTypeInfo(foo)).toBeUndefined(); + expect(runner.program.getTypeInfo(foo)).toBeUndefined(); + expect(runner.program.diagnostics.length).toBe(before); + }); + + it("keeps collecting from healthy providers when one crashes in a design time build", async () => { + const runner = await Tester.files({ + "crash.js": crashingHook, + "ok.js": opInfoHook("still here"), + }) + .import("./crash.js", "./ok.js") + .createInstance(); + + const { foo } = await runner.compile(t.code`op ${t.op("foo")}(): void;`, { + compilerOptions: { ...projectFeature.compilerOptions, designTimeBuild: true }, + }); + + expect(runner.program.getTypeInfo(foo)).toEqual({ content: "still here" }); + }); + describe("feature is scoped to the library declaring the hook", () => { function testerWithLib(tspconfig?: string) { return Tester.files({ diff --git a/packages/http/package.json b/packages/http/package.json index 368611b2d53..2f2aa33c37b 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -70,6 +70,7 @@ }, "files": [ "lib/**/*.tsp", + "tspconfig.yaml", "dist/**", "!dist/test/**" ], diff --git a/packages/http/test/info.test.ts b/packages/http/test/info.test.ts index f5a47f6c274..3c7ef32e1cb 100644 --- a/packages/http/test/info.test.ts +++ b/packages/http/test/info.test.ts @@ -21,4 +21,37 @@ describe("http: $onInfo", () => { expect($onInfo({ program, target: Pet })).toBeUndefined(); }); + + it("formats a status code range", async () => { + const { program, read } = await Tester.compile(t.code` + @error model Error { + @statusCode @minValue(400) @maxValue(499) code: int32; + } + @route("/pets") @get op ${t.op("read")}(): void | Error; + `); + + expect($onInfo({ program, target: read })?.content).toContain("`Responses`: `204`, `400-499`"); + }); + + it("formats a default (`*`) status code", async () => { + const { program, read } = await Tester.compile(t.code` + @error model Error { message: string; } + @route("/pets") @get op ${t.op("read")}(): void | Error; + `); + + expect($onInfo({ program, target: read })?.content).toContain("`Responses`: `204`, `*`"); + }); + + // Guards against the hook being silently dropped: this exercises the real registration path + // (binder + `type-info-hook` opt-in from this package's own `tspconfig.yaml`) rather than + // calling `$onInfo` directly. + it("is registered by the compiler so `program.getTypeInfo` returns the route", async () => { + const { program, read } = await Tester.compile(t.code` + @route("/pets/{id}") @get op ${t.op("read")}(@path id: string): void; + `); + + expect(program.getTypeInfo(read)).toEqual({ + content: "`HTTP Route`: `GET /pets/{id}`\n\n`Responses`: `204`", + }); + }); }); diff --git a/packages/http/tspconfig.yaml b/packages/http/tspconfig.yaml index 0fa25f1df6e..7d302310cc0 100644 --- a/packages/http/tspconfig.yaml +++ b/packages/http/tspconfig.yaml @@ -1,3 +1,7 @@ +# Opt this library into the experimental `type-info-hook` feature so that the `$onInfo` hook +# exported from `src/info.ts` is registered by the compiler. Per-package feature enablement is +# resolved from the owning package's config, so consumers do not need to enable it. +# NOTE: this file must stay listed in `package.json#files` or the opt-in is lost when published. kind: project features: - type-info-hook diff --git a/website/src/content/docs/docs/extending-typespec/providing-info.md b/website/src/content/docs/docs/extending-typespec/providing-info.md index 5421a16d276..05921004be4 100644 --- a/website/src/content/docs/docs/extending-typespec/providing-info.md +++ b/website/src/content/docs/docs/extending-typespec/providing-info.md @@ -10,7 +10,7 @@ programmatically by tooling (such as AI agents). :::caution This is an experimental feature. A library must opt in by enabling the `type-info-hook` -[compiler feature](../handbook/configuration/configuration.md) in **its own** `tspconfig.yaml`: +[compiler feature](../handbook/configuration/configuration.mdx) in **its own** `tspconfig.yaml`: ```yaml kind: project @@ -20,6 +20,17 @@ features: The opt-in is scoped to the package declaring the hook — consumers of the library do not need to enable anything to see the information. + +Because the opt-in is read from the published package, make sure `tspconfig.yaml` is actually +shipped — add it to `files` in your `package.json`, otherwise the hook is silently ignored +once the library is installed from a registry: + +```json +{ + "files": ["lib/**/*.tsp", "tspconfig.yaml", "dist/**"] +} +``` + ::: ## The `$onInfo` hook