diff --git a/.chronus/changes/add-examples-validate-tool-2026-07-15-12-30-00.md b/.chronus/changes/add-examples-validate-tool-2026-07-15-12-30-00.md new file mode 100644 index 0000000000..d84bd6cbe8 --- /dev/null +++ b/.chronus/changes/add-examples-validate-tool-2026-07-15-12-30-00.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-azure-examples" +--- + +Add `@azure-tools/typespec-azure-examples` with the `examples.yaml` JSON Schema and the `examples-validate` tool for the unified examples format. diff --git a/packages/typespec-azure-examples/.gitignore b/packages/typespec-azure-examples/.gitignore new file mode 100644 index 0000000000..a567718c02 --- /dev/null +++ b/packages/typespec-azure-examples/.gitignore @@ -0,0 +1,3 @@ +dist/ +temp/ +schema/dist/ diff --git a/packages/typespec-azure-examples/.scripts/schema-json-to-js.js b/packages/typespec-azure-examples/.scripts/schema-json-to-js.js new file mode 100644 index 0000000000..de0f47ebbb --- /dev/null +++ b/packages/typespec-azure-examples/.scripts/schema-json-to-js.js @@ -0,0 +1,21 @@ +// @ts-check + +import { readFile, writeFile } from "fs/promises"; +import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const jsonFilename = resolve(__dirname, "../schema/dist/ExamplesYaml.json"); +const jsFilename = resolve(__dirname, "../schema/dist/schema.js"); +console.log("Reading json schema at:", jsonFilename); +const content = await readFile(jsonFilename); + +const json = JSON.parse(content.toString()); + +const jsContent = ` +const schema = ${JSON.stringify(json)}; +export default schema; +`; +console.log("Writing json schema in js file:", jsFilename); +await writeFile(jsFilename, jsContent); diff --git a/packages/typespec-azure-examples/README.md b/packages/typespec-azure-examples/README.md new file mode 100644 index 0000000000..f2da998be5 --- /dev/null +++ b/packages/typespec-azure-examples/README.md @@ -0,0 +1,83 @@ +# @azure-tools/typespec-azure-examples + +Tooling for the Azure **unified examples format** (`examples.yaml`): the published JSON Schema +and the `examples-validate` CLI. + +The unified examples format replaces the ~282K per-version `x-ms-examples` JSON files with a +single version-aware `examples.yaml` per service (or `examples/.yaml` for large +services). See the RFC: _Unified Examples Format_. + +## Format + +```yaml +$schema: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/schemas/examples.schema.yaml +$namespace: Microsoft.EventGrid + +CaCertificates.get: + - request: + path: + subscriptionId: 8f6b6269-84f2-4d09-9e31-1127efcd1e40 + resourceGroupName: myResourceGroup + responses: + 200: + body: + name: exampleCaCertificate + properties: + provisioningState: Succeeded + - since: "2023-12-15-preview" + request: + path: + subscriptionId: 8f6b6269-84f2-4d09-9e31-1127efcd1e40 + resourceGroupName: myResourceGroup + responses: + 200: + body: + name: exampleCaCertificate + properties: + provisioningState: Succeeded + delegatedIdentityTokenExpirationTimeInUtc: "2023-10-12T23:06:43+00:00" +``` + +- File metadata uses `$`-prefixed keys (`$schema`, `$namespace`); every bare top-level key is an + operation, identified by its interface-relative name (`Interface.operation`). +- Each operation maps to a list of example variants. The base variant has no `since`; later + variants carry a quoted `since` and restate the full request/response. +- Response status codes are bare integer keys. `api-version` is implicit; use the + `{api-version}` placeholder where a version must be embedded in a value. + +## `examples-validate` + +Validate a service's example files against the JSON Schema and the format rules: + +```bash +examples-validate +``` + +It discovers `examples.yaml` and `examples/*.yaml` in the directory, reads the adjacent +`service.yaml` for version metadata, and reports diagnostics. It exits non-zero if any error is +found (use `--warn-as-error` to also fail on warnings). + +### Rules enforced + +- Only `$schema`/`$namespace` may be `$`-prefixed; other bare keys are operations that must be a + list of examples. +- Response keys are integer status codes; range keys (`2XX`) and `default` are rejected. +- `since` must be a quoted string and a version listed in `service.yaml`. +- Per lineage (entries grouped by `title`; untitled entries form the default lineage): at most one + entry without `since`, and `since` values are unique. +- An operation's full example set lives in a single file, and each interface appears in exactly + one file. +- `{api-version}` is the only supported placeholder, and `api-version` must not appear as a + request parameter. + +## API + +```ts +import { + validateExamplesDir, + validateExampleFiles, + loadExampleFile, +} from "@azure-tools/typespec-azure-examples"; + +const { diagnostics } = await validateExamplesDir("path/to/service"); +``` diff --git a/packages/typespec-azure-examples/package.json b/packages/typespec-azure-examples/package.json new file mode 100644 index 0000000000..693e3a6f70 --- /dev/null +++ b/packages/typespec-azure-examples/package.json @@ -0,0 +1,68 @@ +{ + "name": "@azure-tools/typespec-azure-examples", + "version": "0.1.0", + "author": "Microsoft Corporation", + "description": "Tooling for the Azure unified examples format (examples.yaml): JSON Schema and the examples-validate CLI", + "homepage": "https://azure.github.io/typespec-azure", + "readme": "https://github.com/Azure/typespec-azure/blob/main/README.md", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/Azure/typespec-azure.git" + }, + "bugs": { + "url": "https://github.com/Azure/typespec-azure/issues" + }, + "keywords": [ + "typespec", + "azure", + "examples" + ], + "type": "module", + "main": "dist/src/index.js", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + } + }, + "bin": { + "examples-validate": "./dist/src/cli.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "clean": "rimraf ./dist ./schema/dist", + "build": "npm run regen-examples-yaml-schema && tsc -p tsconfig.build.json", + "watch": "tsc -p tsconfig.build.json --watch", + "regen-examples-yaml-schema": "tsp compile ./schema/examples-yaml.tsp --warn-as-error && node ./.scripts/schema-json-to-js.js", + "test": "vitest run", + "test:watch": "vitest -w", + "test:ci": "vitest run --coverage --reporter=junit --reporter=default", + "lint": "oxlint . --deny-warnings", + "lint:fix": "oxlint . --fix" + }, + "files": [ + "schema/dist/schema.js", + "schema/dist/ExamplesYaml.json", + "dist/**", + "!dist/test/**" + ], + "dependencies": { + "ajv": "catalog:", + "picocolors": "catalog:", + "yaml": "catalog:", + "yargs": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "@types/yargs": "catalog:", + "@typespec/compiler": "workspace:^", + "@typespec/json-schema": "workspace:^", + "@vitest/coverage-v8": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/typespec-azure-examples/schema/.gitignore b/packages/typespec-azure-examples/schema/.gitignore new file mode 100644 index 0000000000..849ddff3b7 --- /dev/null +++ b/packages/typespec-azure-examples/schema/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/typespec-azure-examples/schema/examples-yaml.tsp b/packages/typespec-azure-examples/schema/examples-yaml.tsp new file mode 100644 index 0000000000..2021bed50c --- /dev/null +++ b/packages/typespec-azure-examples/schema/examples-yaml.tsp @@ -0,0 +1,87 @@ +/** + * Source of truth for the `examples.yaml` unified examples format emitted/consumed by + * Azure tooling. The JSON Schema generated from this file is published for editor and CI + * validation and is used by the `examples-validate` tool (via ajv). + * + * See the RFC: Unified Examples Format (§3–§4). + * + * Note: because YAML permits arbitrary string keys, status codes, headers, query params, and + * map-shaped bodies are modeled as `Record<...>`. Rules that JSON Schema cannot express + * (integer-only status codes, quoted `since`, `since` ∈ `service.yaml`, per-lineage + * uniqueness, file placement, `{api-version}`-only placeholder) are enforced by the + * `examples-validate` semantic rules. + */ +import "@typespec/json-schema"; + +using JsonSchema; + +/** + * Top-level `examples.yaml` file: `$schema`/`$namespace` metadata plus one entry per + * operation, keyed by the interface-relative operation name (e.g. `CaCertificates.get`). + * Each operation maps to a list of example variants. + * + * The indexer is widened to `Example[] | string` so the `$`-prefixed string metadata keys + * are structurally valid. The rules that a bare (non-`$`) key must be a list of examples and + * that the only allowed metadata keys are `$schema`/`$namespace` are enforced by the + * `examples-validate` semantic rules. + */ +@jsonSchema("examples.yaml") +model ExamplesYaml is Record { + /** Schema URL. Gives editors autocomplete and inline validation while authoring. */ + $schema?: string; + + /** + * Service namespace (e.g. `Microsoft.EventGrid`), prepended to each operation key to form + * the fully-qualified operation identity used for Swagger linkage (`x-id`). + */ + $namespace?: string; +} + +/** A single example variant representing one complete API interaction. */ +model Example { + /** + * Optional human-readable title. Omit for the single-example case; provide it only to + * disambiguate multiple distinct examples on the same operation. Entries sharing a `title` + * form one lineage; untitled entries all belong to the single default lineage. + */ + title?: string; + + /** Longer description of what this example demonstrates. */ + description?: string; + + /** + * Quoted version from which this variant applies (must be a version listed in the service's + * `service.yaml`). Omit to apply from the earliest version. + */ + since?: string; + + /** The request of the API interaction. */ + request: ExampleRequest; + + /** Responses keyed by integer status code (e.g. `200`, `404`). */ + responses: Record; +} + +/** The request portion of an example, split by parameter location. */ +model ExampleRequest { + /** Path parameters. `api-version` is implicit and MUST NOT be included. */ + path?: Record; + + /** Query parameters (e.g. `$top`, `$filter`). */ + query?: Record; + + /** Request headers (e.g. `If-Match`). */ + headers?: Record; + + /** Request body. Structure matches the operation's request schema. */ + body?: unknown; +} + +/** A single response of an example, keyed in the parent map by integer status code. */ +model ExampleResponse { + /** Response headers (e.g. `Location`, `Azure-AsyncOperation`). */ + headers?: Record; + + /** Response body. Structure matches the operation's response schema. */ + body?: unknown; +} diff --git a/packages/typespec-azure-examples/schema/tspconfig.yaml b/packages/typespec-azure-examples/schema/tspconfig.yaml new file mode 100644 index 0000000000..8e5f24614c --- /dev/null +++ b/packages/typespec-azure-examples/schema/tspconfig.yaml @@ -0,0 +1,7 @@ +emit: + - "@typespec/json-schema" + +options: + "@typespec/json-schema": + emitter-output-dir: "{project-root}/dist" + file-type: json diff --git a/packages/typespec-azure-examples/src/cli.ts b/packages/typespec-azure-examples/src/cli.ts new file mode 100644 index 0000000000..199b53fba6 --- /dev/null +++ b/packages/typespec-azure-examples/src/cli.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +import { resolve } from "path"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { validateExamplesDir } from "./discover.js"; +import { formatDiagnostics, formatSummary } from "./reporter.js"; + +async function main(): Promise { + const args = await yargs(hideBin(process.argv)) + .scriptName("examples-validate") + .usage("$0 [dir]", "Validate unified examples format files (examples.yaml)") + .positional("dir", { + type: "string", + describe: "Service directory containing examples.yaml / examples/*.yaml and service.yaml", + default: ".", + }) + .option("warn-as-error", { + type: "boolean", + default: false, + describe: "Treat warnings as errors (non-zero exit)", + }) + .strict() + .help() + .parse(); + + const dir = resolve(process.cwd(), args.dir as string); + const { diagnostics, files } = await validateExamplesDir(dir); + + if (files.length === 0) { + console.error( + `No examples files found in ${dir} (looked for examples.yaml and examples/*.yaml).`, + ); + process.exit(1); + } + + if (diagnostics.length > 0) { + console.log(formatDiagnostics(diagnostics)); + console.log(""); + } + console.log(formatSummary(diagnostics)); + + const hasError = diagnostics.some((d) => d.severity === "error"); + const hasWarning = diagnostics.some((d) => d.severity === "warning"); + process.exit(hasError || (args["warn-as-error"] && hasWarning) ? 1 : 0); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/typespec-azure-examples/src/discover.ts b/packages/typespec-azure-examples/src/discover.ts new file mode 100644 index 0000000000..c52b936d4b --- /dev/null +++ b/packages/typespec-azure-examples/src/discover.ts @@ -0,0 +1,77 @@ +import { readdir, readFile, stat } from "fs/promises"; +import { join, relative } from "path"; +import { loadExampleFile, parseServiceVersions, type LoadedExampleFile } from "./loader.js"; +import type { ExampleDiagnostic } from "./types.js"; +import { validateExampleFiles } from "./validate.js"; + +/** Result of validating an examples directory. */ +export interface ValidateDirResult { + /** All diagnostics produced (errors and warnings). */ + readonly diagnostics: ExampleDiagnostic[]; + /** The example files that were discovered and validated. */ + readonly files: string[]; +} + +async function exists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +/** + * Discover the example files under `dir`: a top-level `examples.yaml` and/or per-interface + * files under `examples/` (`examples/.yaml`). + */ +export async function discoverExampleFiles(dir: string): Promise { + const found: string[] = []; + + const topLevel = join(dir, "examples.yaml"); + if (await exists(topLevel)) found.push(topLevel); + + const examplesDir = join(dir, "examples"); + if ((await exists(examplesDir)) && (await stat(examplesDir)).isDirectory()) { + for (const entry of await readdir(examplesDir)) { + if (entry.endsWith(".yaml") || entry.endsWith(".yml")) { + found.push(join(examplesDir, entry)); + } + } + } + + return found.sort(); +} + +/** + * Validate all example files in a service directory. Discovers `examples.yaml` / + * `examples/*.yaml` and the adjacent `service.yaml` (for the `since ∈ service.yaml` check), + * then runs {@link validateExampleFiles}. Paths in diagnostics are relative to `dir`. + */ +export async function validateExamplesDir(dir: string): Promise { + const filePaths = await discoverExampleFiles(dir); + const files: LoadedExampleFile[] = []; + for (const path of filePaths) { + const content = await readFile(path, "utf-8"); + files.push(loadExampleFile(relative(dir, path), content)); + } + + const diagnostics: ExampleDiagnostic[] = []; + let serviceVersions: string[] | undefined; + const serviceYamlPath = join(dir, "service.yaml"); + if (await exists(serviceYamlPath)) { + serviceVersions = parseServiceVersions(await readFile(serviceYamlPath, "utf-8")).versions; + } else if (files.length > 0) { + diagnostics.push({ + code: "missing-service-yaml", + message: + "No service.yaml found next to the examples; skipping the 'since' version-membership check.", + severity: "warning", + file: relative(dir, serviceYamlPath), + }); + } + + diagnostics.push(...validateExampleFiles(files, { serviceVersions })); + + return { diagnostics, files: filePaths.map((path) => relative(dir, path)) }; +} diff --git a/packages/typespec-azure-examples/src/index.ts b/packages/typespec-azure-examples/src/index.ts new file mode 100644 index 0000000000..d82a0c9b82 --- /dev/null +++ b/packages/typespec-azure-examples/src/index.ts @@ -0,0 +1,27 @@ +/** + * `@azure-tools/typespec-azure-examples` — tooling for the Azure unified examples format + * (`examples.yaml`). This entrypoint exposes the JSON Schema and the programmatic validation API + * used by the `examples-validate` CLI. + */ +export { discoverExampleFiles, validateExamplesDir, type ValidateDirResult } from "./discover.js"; +export { + isQuotedScalar, + loadExampleFile, + locationAt, + parseServiceVersions, + positionAt, + type LoadedExampleFile, + type Position, +} from "./loader.js"; +export { formatDiagnostics, formatSummary } from "./reporter.js"; +export { checkFilePlacement, checkSemantics, type SemanticContext } from "./rules.js"; +export { ExamplesYamlSchema } from "./schema.js"; +export type { + DiagnosticSeverity, + ExampleDiagnostic, + ExampleRequest, + ExampleResponse, + ExampleVariant, + ServiceVersions, +} from "./types.js"; +export { checkStructure, validateExampleFiles } from "./validate.js"; diff --git a/packages/typespec-azure-examples/src/loader.ts b/packages/typespec-azure-examples/src/loader.ts new file mode 100644 index 0000000000..0cefa60bad --- /dev/null +++ b/packages/typespec-azure-examples/src/loader.ts @@ -0,0 +1,100 @@ +import { type Document, isScalar, LineCounter, parse, parseDocument, Scalar } from "yaml"; +import type { ServiceVersions } from "./types.js"; + +/** A parsed `examples.yaml` (or `examples/.yaml`) file with location metadata. */ +export interface LoadedExampleFile { + /** Absolute or repo-relative path, used verbatim in diagnostics. */ + readonly path: string; + /** Raw file content. */ + readonly content: string; + /** The parsed YAML document (retains node ranges for precise locations). */ + readonly document: Document.Parsed; + /** Plain-JS view of the document (`undefined` if the YAML could not be materialized). */ + readonly data: any; + /** Line counter for translating byte offsets to line/column. */ + readonly lineCounter: LineCounter; + /** Fatal YAML parse error, if any. */ + readonly parseError?: string; +} + +/** Parse a single examples file into a {@link LoadedExampleFile}. Never throws. */ +export function loadExampleFile(path: string, content: string): LoadedExampleFile { + const lineCounter = new LineCounter(); + const document = parseDocument(content, { lineCounter }); + const fatal = document.errors[0]; + let data: any; + if (!fatal) { + try { + data = document.toJS(); + } catch { + data = undefined; + } + } + return { + path, + content, + document, + data, + lineCounter, + parseError: fatal?.message, + }; +} + +/** 1-based line/column position. */ +export interface Position { + readonly line: number; + readonly col: number; +} + +/** Translate a byte offset into a 1-based line/column position. */ +export function positionAt( + file: LoadedExampleFile, + offset: number | undefined, +): Position | undefined { + if (offset === undefined) return undefined; + const pos = file.lineCounter.linePos(offset); + return { line: pos.line, col: pos.col }; +} + +/** Get the YAML node at a JSON path, or `undefined` if it doesn't exist. */ +export function nodeAt(file: LoadedExampleFile, path: (string | number)[]): unknown { + return file.document.getIn(path, true); +} + +/** Position of the node at a JSON path (start of its value). */ +export function locationAt( + file: LoadedExampleFile, + path: (string | number)[], +): Position | undefined { + const node = nodeAt(file, path); + const range = (node as { range?: [number, number, number] } | undefined)?.range; + return positionAt(file, range?.[0]); +} + +/** + * Whether the scalar at the given path was written as a quoted string in the source. + * Returns `false` for plain (unquoted) scalars, which YAML may coerce away from a string. + */ +export function isQuotedScalar(file: LoadedExampleFile, path: (string | number)[]): boolean { + const node = nodeAt(file, path); + if (!isScalar(node)) return false; + return node.type === Scalar.QUOTE_SINGLE || node.type === Scalar.QUOTE_DOUBLE; +} + +/** + * Parse the version list from a `service.yaml` document. Tolerates a missing/empty + * `versions` list and non-string entries. + */ +export function parseServiceVersions(content: string): ServiceVersions { + let doc: any; + try { + doc = parse(content); + } catch { + return { versions: [] }; + } + const raw = Array.isArray(doc?.versions) ? doc.versions : []; + const versions = raw + .map((entry: any) => entry?.version) + .filter((version: unknown): version is string => typeof version === "string"); + return { versions }; +} diff --git a/packages/typespec-azure-examples/src/reporter.ts b/packages/typespec-azure-examples/src/reporter.ts new file mode 100644 index 0000000000..4c73c7b860 --- /dev/null +++ b/packages/typespec-azure-examples/src/reporter.ts @@ -0,0 +1,29 @@ +import { relative } from "path"; +import pc from "picocolors"; +import type { ExampleDiagnostic } from "./types.js"; + +/** Format a set of diagnostics for terminal output, grouped by file. */ +export function formatDiagnostics( + diagnostics: readonly ExampleDiagnostic[], + baseDir?: string, +): string { + const lines: string[] = []; + for (const d of diagnostics) { + const file = baseDir ? relative(baseDir, d.file) : d.file; + const location = d.line !== undefined ? `${file}:${d.line}:${d.col ?? 1}` : file; + const label = d.severity === "error" ? pc.red("error") : pc.yellow("warning"); + lines.push(`${pc.cyan(location)} - ${label} ${pc.gray(d.code)}: ${d.message}`); + } + return lines.join("\n"); +} + +/** Format a one-line summary of the diagnostics. */ +export function formatSummary(diagnostics: readonly ExampleDiagnostic[]): string { + const errors = diagnostics.filter((d) => d.severity === "error").length; + const warnings = diagnostics.filter((d) => d.severity === "warning").length; + if (errors === 0 && warnings === 0) return pc.green("✔ Examples are valid"); + const parts: string[] = []; + if (errors > 0) parts.push(pc.red(`${errors} error${errors === 1 ? "" : "s"}`)); + if (warnings > 0) parts.push(pc.yellow(`${warnings} warning${warnings === 1 ? "" : "s"}`)); + return `Found ${parts.join(" and ")}`; +} diff --git a/packages/typespec-azure-examples/src/rules.ts b/packages/typespec-azure-examples/src/rules.ts new file mode 100644 index 0000000000..e7eee2414c --- /dev/null +++ b/packages/typespec-azure-examples/src/rules.ts @@ -0,0 +1,367 @@ +import { isQuotedScalar, locationAt, type LoadedExampleFile } from "./loader.js"; +import type { ExampleDiagnostic } from "./types.js"; + +/** File-level metadata keys allowed to be `$`-prefixed. */ +const METADATA_KEYS = new Set(["$schema", "$namespace"]); + +/** Context available to the semantic rules. */ +export interface SemanticContext { + /** + * API versions declared in the service's `service.yaml`. When `undefined`, the + * `since ∈ service.yaml` check is skipped (e.g. no `service.yaml` was found). + */ + readonly serviceVersions?: readonly string[]; +} + +/** Run all per-file semantic rules (RFC §3) against a single loaded file. */ +export function checkSemantics(file: LoadedExampleFile, ctx: SemanticContext): ExampleDiagnostic[] { + const diagnostics: ExampleDiagnostic[] = []; + const data = file.data; + if (data === null || typeof data !== "object" || Array.isArray(data)) { + return diagnostics; + } + + for (const [key, value] of Object.entries(data)) { + if (key.startsWith("$")) { + if (!METADATA_KEYS.has(key)) { + diagnostics.push( + diag( + file, + [key], + "error", + "unknown-metadata-key", + () => + `Unknown file metadata key '${key}'. Only '$schema' and '$namespace' are allowed; bare keys are operations.`, + ), + ); + } + continue; + } + + // Bare top-level key => operation. + if (!Array.isArray(value)) { + diagnostics.push( + diag( + file, + [key], + "error", + "operation-not-a-list", + () => `Operation '${key}' must be a list of examples.`, + key, + ), + ); + continue; + } + + checkOperation(file, ctx, key, value, diagnostics); + } + + return diagnostics; +} + +function checkOperation( + file: LoadedExampleFile, + ctx: SemanticContext, + operation: string, + variants: unknown[], + diagnostics: ExampleDiagnostic[], +): void { + const lineages = new Map(); + + variants.forEach((variant, i) => { + if (variant === null || typeof variant !== "object" || Array.isArray(variant)) { + return; + } + const v = variant as Record; + + checkResponses(file, operation, i, v.responses, diagnostics); + checkSince(file, ctx, operation, i, v, diagnostics); + checkExplicitApiVersion(file, operation, i, v.request, diagnostics); + checkPlaceholders(file, operation, i, v, diagnostics); + + const title = typeof v.title === "string" ? v.title : ""; + const hasSince = typeof v.since === "string"; + const entry = { index: i, since: hasSince ? (v.since as string) : undefined, hasSince }; + const group = lineages.get(title); + if (group) group.push(entry); + else lineages.set(title, [entry]); + }); + + checkLineages(file, operation, lineages, diagnostics); +} + +function checkResponses( + file: LoadedExampleFile, + operation: string, + index: number, + responses: unknown, + diagnostics: ExampleDiagnostic[], +): void { + if (responses === null || typeof responses !== "object" || Array.isArray(responses)) { + return; + } + for (const code of Object.keys(responses as Record)) { + if (!/^\d+$/.test(code) || Number(code) < 100 || Number(code) > 599) { + diagnostics.push( + diag( + file, + [operation, index, "responses"], + "error", + "invalid-status-code", + () => + `Response key '${code}' is not a valid integer status code. Range keys ('2XX') and 'default' are not permitted; use a concrete numeric status code.`, + operation, + ), + ); + } + } +} + +function checkSince( + file: LoadedExampleFile, + ctx: SemanticContext, + operation: string, + index: number, + variant: Record, + diagnostics: ExampleDiagnostic[], +): void { + if (!("since" in variant)) return; + const since = variant.since; + if (typeof since !== "string") { + diagnostics.push( + diag( + file, + [operation, index, "since"], + "error", + "unquoted-since", + () => `'since' must be a quoted version string (e.g. since: "2024-06-01").`, + operation, + ), + ); + return; + } + if (!isQuotedScalar(file, [operation, index, "since"])) { + diagnostics.push( + diag( + file, + [operation, index, "since"], + "error", + "unquoted-since", + () => `'since' must be quoted (since: "${since}") so YAML does not parse it as a date.`, + operation, + ), + ); + } + if (ctx.serviceVersions !== undefined && !ctx.serviceVersions.includes(since)) { + diagnostics.push( + diag( + file, + [operation, index, "since"], + "error", + "unknown-since-version", + () => `'since' value "${since}" is not a version listed in service.yaml.`, + operation, + ), + ); + } +} + +function checkExplicitApiVersion( + file: LoadedExampleFile, + operation: string, + index: number, + request: unknown, + diagnostics: ExampleDiagnostic[], +): void { + if (request === null || typeof request !== "object" || Array.isArray(request)) return; + const req = request as Record; + for (const location of ["path", "query"] as const) { + const params = req[location]; + if (params === null || typeof params !== "object" || Array.isArray(params)) continue; + for (const paramName of Object.keys(params as Record)) { + if (paramName.toLowerCase() === "api-version") { + diagnostics.push( + diag( + file, + [operation, index, "request", location], + "error", + "explicit-api-version", + () => + `'api-version' must not be written as a request ${location} parameter; it is implicit and resolved from the version context.`, + operation, + ), + ); + } + } + } +} + +const PLACEHOLDER_RE = /\{([A-Za-z0-9._-]+)\}/g; + +function checkPlaceholders( + file: LoadedExampleFile, + operation: string, + index: number, + variant: Record, + diagnostics: ExampleDiagnostic[], +): void { + const offenders = new Set(); + collectStrings(variant, (value) => { + let match: RegExpExecArray | null; + PLACEHOLDER_RE.lastIndex = 0; + while ((match = PLACEHOLDER_RE.exec(value)) !== null) { + if (match[1] !== "api-version") offenders.add(match[0]); + } + }); + if (offenders.size > 0) { + diagnostics.push( + diag( + file, + [operation, index], + "error", + "unsupported-placeholder", + () => + `Unsupported placeholder(s) ${[...offenders].map((o) => `'${o}'`).join(", ")}. '{api-version}' is the only supported placeholder.`, + operation, + ), + ); + } +} + +function checkLineages( + file: LoadedExampleFile, + operation: string, + lineages: Map, + diagnostics: ExampleDiagnostic[], +): void { + for (const [title, entries] of lineages) { + const label = title === "" ? "the default lineage" : `lineage '${title}'`; + + const baseEntries = entries.filter((e) => !e.hasSince); + if (baseEntries.length > 1) { + for (const extra of baseEntries.slice(1)) { + diagnostics.push( + diag( + file, + [operation, extra.index], + "error", + "multiple-base-variants", + () => + `${capitalize(label)} of operation '${operation}' has more than one entry without 'since'; only one base entry is allowed.`, + operation, + ), + ); + } + } + + const seen = new Map(); + for (const entry of entries) { + if (entry.since === undefined) continue; + if (seen.has(entry.since)) { + diagnostics.push( + diag( + file, + [operation, entry.index, "since"], + "error", + "duplicate-since", + () => + `Duplicate 'since' value "${entry.since}" in ${label} of operation '${operation}'.`, + operation, + ), + ); + } else { + seen.set(entry.since, entry.index); + } + } + } +} + +/** + * Cross-file placement rules (RFC §3.1): an operation's full example set lives in a single + * file, and an interface appears in exactly one file. + */ +export function checkFilePlacement(files: LoadedExampleFile[]): ExampleDiagnostic[] { + const diagnostics: ExampleDiagnostic[] = []; + const operationFiles = new Map(); + const interfaceFiles = new Map(); + + for (const file of files) { + const data = file.data; + if (data === null || typeof data !== "object" || Array.isArray(data)) continue; + + for (const key of Object.keys(data)) { + if (key.startsWith("$")) continue; + + const firstFile = operationFiles.get(key); + if (firstFile !== undefined && firstFile !== file.path) { + diagnostics.push( + diag( + file, + [key], + "error", + "operation-in-multiple-files", + () => + `Operation '${key}' also appears in '${firstFile}'. An operation's full example set must live in a single file.`, + key, + ), + ); + } else if (firstFile === undefined) { + operationFiles.set(key, file.path); + } + + const interfaceName = key.includes(".") ? key.slice(0, key.indexOf(".")) : key; + const firstInterfaceFile = interfaceFiles.get(interfaceName); + if (firstInterfaceFile !== undefined && firstInterfaceFile !== file.path) { + diagnostics.push( + diag( + file, + [key], + "error", + "interface-split-across-files", + () => + `Interface '${interfaceName}' is split across files ('${firstInterfaceFile}' and '${file.path}'). Each interface must appear in exactly one file.`, + key, + ), + ); + } else if (firstInterfaceFile === undefined) { + interfaceFiles.set(interfaceName, file.path); + } + } + } + + return diagnostics; +} + +function collectStrings(value: unknown, visit: (value: string) => void): void { + if (typeof value === "string") { + visit(value); + } else if (Array.isArray(value)) { + for (const item of value) collectStrings(item, visit); + } else if (value !== null && typeof value === "object") { + for (const item of Object.values(value)) collectStrings(item, visit); + } +} + +function diag( + file: LoadedExampleFile, + path: (string | number)[], + severity: "error" | "warning", + code: string, + message: () => string, + operation?: string, +): ExampleDiagnostic { + const loc = locationAt(file, path); + return { + code, + message: message(), + severity, + file: file.path, + line: loc?.line, + col: loc?.col, + operation, + }; +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} diff --git a/packages/typespec-azure-examples/src/schema.ts b/packages/typespec-azure-examples/src/schema.ts new file mode 100644 index 0000000000..33e4eb8038 --- /dev/null +++ b/packages/typespec-azure-examples/src/schema.ts @@ -0,0 +1,16 @@ +/** + * Loads the JSON Schema generated from `schema/examples-yaml.tsp`. The generated module lives + * outside `src`, so we resolve it relative to the compiled output first and fall back to the + * source-relative path when running from `src` (e.g. under vitest). + */ +let ExamplesYamlSchema: any; +try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - generated at build time by `regen-examples-yaml-schema` + ExamplesYamlSchema = (await import("../../schema/dist/schema.js")).default; +} catch { + const name = "../schema/dist/schema.js"; + ExamplesYamlSchema = (await import(/* @vite-ignore */ name)).default; +} + +export { ExamplesYamlSchema }; diff --git a/packages/typespec-azure-examples/src/types.ts b/packages/typespec-azure-examples/src/types.ts new file mode 100644 index 0000000000..6e076694be --- /dev/null +++ b/packages/typespec-azure-examples/src/types.ts @@ -0,0 +1,52 @@ +/** + * Public types for the unified examples format (`examples.yaml`) and the `examples-validate` + * diagnostics. These mirror the schema in `schema/examples-yaml.tsp`. + */ + +/** Severity of an {@link ExampleDiagnostic}. */ +export type DiagnosticSeverity = "error" | "warning"; + +/** A single validation diagnostic produced by {@link validateExampleFiles}. */ +export interface ExampleDiagnostic { + /** Machine-readable code (e.g. `invalid-status-code`). */ + readonly code: string; + /** Human-readable message. */ + readonly message: string; + /** Severity. Any `error` fails validation. */ + readonly severity: DiagnosticSeverity; + /** Absolute or repo-relative path of the file the diagnostic applies to. */ + readonly file: string; + /** 1-based line number, when a precise location is available. */ + readonly line?: number; + /** 1-based column number, when a precise location is available. */ + readonly col?: number; + /** The operation key the diagnostic relates to, when applicable. */ + readonly operation?: string; +} + +/** A single example variant (`Example` in the schema). */ +export interface ExampleVariant { + readonly title?: string; + readonly description?: string; + readonly since?: string; + readonly request?: ExampleRequest; + readonly responses?: Record; +} + +export interface ExampleRequest { + readonly path?: Record; + readonly query?: Record; + readonly headers?: Record; + readonly body?: unknown; +} + +export interface ExampleResponse { + readonly headers?: Record; + readonly body?: unknown; +} + +/** Parsed representation of a service's `service.yaml` version metadata. */ +export interface ServiceVersions { + /** The set of API version strings declared in `service.yaml`, in file order. */ + readonly versions: string[]; +} diff --git a/packages/typespec-azure-examples/src/validate.ts b/packages/typespec-azure-examples/src/validate.ts new file mode 100644 index 0000000000..49e3160336 --- /dev/null +++ b/packages/typespec-azure-examples/src/validate.ts @@ -0,0 +1,76 @@ +import { Ajv2020, type ErrorObject, type ValidateFunction } from "ajv/dist/2020.js"; +import { type LoadedExampleFile, positionAt } from "./loader.js"; +import { checkFilePlacement, checkSemantics, type SemanticContext } from "./rules.js"; +import { ExamplesYamlSchema } from "./schema.js"; +import type { ExampleDiagnostic } from "./types.js"; + +let cachedValidator: ValidateFunction | undefined; + +function getValidator(): ValidateFunction { + if (cachedValidator === undefined) { + const ajv = new Ajv2020({ allErrors: true, strict: false }); + cachedValidator = ajv.compile(ExamplesYamlSchema); + } + return cachedValidator; +} + +/** Run JSON Schema (structural) validation of a single file against the generated schema. */ +export function checkStructure(file: LoadedExampleFile): ExampleDiagnostic[] { + if (file.data === undefined) return []; + const validate = getValidator(); + const valid = validate(file.data); + if (valid || !validate.errors) return []; + return validate.errors.map((error) => structuralDiagnostic(file, error)); +} + +function structuralDiagnostic(file: LoadedExampleFile, error: ErrorObject): ExampleDiagnostic { + const instancePath = error.instancePath; + const path = instancePath + .split("/") + .slice(1) + .map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~")) + .map((segment) => (/^\d+$/.test(segment) ? Number(segment) : segment)); + const node = + path.length > 0 + ? (file.document.getIn(path, true) as { range?: [number, number, number] }) + : undefined; + const loc = positionAt(file, node?.range?.[0]); + const location = instancePath === "" ? "" : instancePath; + return { + code: "schema-validation", + message: `${location} ${error.message ?? "is invalid"}`, + severity: "error", + file: file.path, + line: loc?.line, + col: loc?.col, + }; +} + +/** + * Validate a set of loaded example files. Runs, per file: fatal parse errors, JSON Schema + * (structural) validation, and the RFC §3 semantic rules; then the cross-file placement rules. + */ +export function validateExampleFiles( + files: LoadedExampleFile[], + ctx: SemanticContext = {}, +): ExampleDiagnostic[] { + const diagnostics: ExampleDiagnostic[] = []; + + for (const file of files) { + if (file.parseError !== undefined) { + diagnostics.push({ + code: "yaml-parse-error", + message: `Failed to parse YAML: ${file.parseError}`, + severity: "error", + file: file.path, + }); + continue; + } + diagnostics.push(...checkStructure(file)); + diagnostics.push(...checkSemantics(file, ctx)); + } + + diagnostics.push(...checkFilePlacement(files)); + + return diagnostics; +} diff --git a/packages/typespec-azure-examples/test/placement.test.ts b/packages/typespec-azure-examples/test/placement.test.ts new file mode 100644 index 0000000000..c6e9e38d8c --- /dev/null +++ b/packages/typespec-azure-examples/test/placement.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { loadExampleFile, parseServiceVersions } from "../src/loader.js"; +import { checkFilePlacement } from "../src/rules.js"; + +describe("checkFilePlacement", () => { + it("rejects the same operation appearing in two files", () => { + const a = loadExampleFile("examples/A.yaml", `Foo.get:\n - request: {}\n responses: {}\n`); + const b = loadExampleFile("examples/B.yaml", `Foo.get:\n - request: {}\n responses: {}\n`); + const codes = checkFilePlacement([a, b]).map((d) => d.code); + expect(codes).toContain("operation-in-multiple-files"); + }); + + it("rejects an interface split across files", () => { + const a = loadExampleFile("examples/A.yaml", `Foo.get:\n - request: {}\n responses: {}\n`); + const b = loadExampleFile("examples/B.yaml", `Foo.list:\n - request: {}\n responses: {}\n`); + const codes = checkFilePlacement([a, b]).map((d) => d.code); + expect(codes).toContain("interface-split-across-files"); + }); + + it("accepts one interface per file", () => { + const a = loadExampleFile( + "examples/Foo.yaml", + `Foo.get:\n - request: {}\n responses: {}\n`, + ); + const b = loadExampleFile( + "examples/Bar.yaml", + `Bar.get:\n - request: {}\n responses: {}\n`, + ); + expect(checkFilePlacement([a, b])).toEqual([]); + }); +}); + +describe("parseServiceVersions", () => { + it("extracts version strings in order", () => { + const result = parseServiceVersions(` +versions: + - version: "2023-11-01" + source: typespec + - version: "2024-06-01" + source: typespec +`); + expect(result.versions).toEqual(["2023-11-01", "2024-06-01"]); + }); + + it("tolerates a missing versions list", () => { + expect(parseServiceVersions("other: value").versions).toEqual([]); + }); +}); diff --git a/packages/typespec-azure-examples/test/validate.test.ts b/packages/typespec-azure-examples/test/validate.test.ts new file mode 100644 index 0000000000..ee092cde76 --- /dev/null +++ b/packages/typespec-azure-examples/test/validate.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vitest"; +import { loadExampleFile } from "../src/loader.js"; +import type { ExampleDiagnostic } from "../src/types.js"; +import { validateExampleFiles } from "../src/validate.js"; + +function validate(content: string, serviceVersions?: string[]): ExampleDiagnostic[] { + return validateExampleFiles([loadExampleFile("examples.yaml", content)], { serviceVersions }); +} + +function codes(diagnostics: ExampleDiagnostic[]): string[] { + return diagnostics.map((d) => d.code); +} + +describe("valid files", () => { + it("accepts a single-example operation", () => { + const diagnostics = validate(` +$namespace: Microsoft.EventGrid +CaCertificates.get: + - request: + path: + subscriptionId: sub + responses: + 200: + body: { name: cert } +`); + expect(diagnostics).toEqual([]); + }); + + it("accepts a base + since lineage", () => { + const diagnostics = validate( + ` +CaCertificates.get: + - request: { path: { subscriptionId: sub } } + responses: { 200: { body: { name: cert } } } + - since: "2023-12-15-preview" + request: { path: { subscriptionId: sub } } + responses: { 200: { body: { name: cert } } } +`, + ["2023-12-15-preview"], + ); + expect(diagnostics).toEqual([]); + }); + + it("accepts the {api-version} placeholder", () => { + const diagnostics = validate(` +Things.list: + - request: { path: { subscriptionId: sub } } + responses: + 200: + body: + nextLink: "https://host/things?api-version={api-version}" +`); + expect(diagnostics).toEqual([]); + }); +}); + +describe("metadata keys", () => { + it("rejects unknown $-prefixed metadata keys", () => { + const diagnostics = validate(` +$unknown: value +Foo.get: + - request: { path: {} } + responses: { 200: {} } +`); + expect(codes(diagnostics)).toContain("unknown-metadata-key"); + }); + + it("rejects an operation that is not a list", () => { + const diagnostics = validate(` +Foo.get: + request: { path: {} } +`); + expect(codes(diagnostics)).toContain("operation-not-a-list"); + }); +}); + +describe("status codes", () => { + it("rejects range keys like 2XX", () => { + const diagnostics = validate(` +Foo.get: + - request: { path: {} } + responses: { 2XX: { body: {} } } +`); + expect(codes(diagnostics)).toContain("invalid-status-code"); + }); + + it("rejects default", () => { + const diagnostics = validate(` +Foo.get: + - request: { path: {} } + responses: { default: { body: {} } } +`); + expect(codes(diagnostics)).toContain("invalid-status-code"); + }); + + it("accepts concrete numeric codes", () => { + const diagnostics = validate(` +Foo.get: + - request: { path: {} } + responses: { 200: {}, 404: {} } +`); + expect(codes(diagnostics)).not.toContain("invalid-status-code"); + }); +}); + +describe("since", () => { + it("rejects an unquoted since", () => { + const diagnostics = validate( + ` +Foo.get: + - request: { path: {} } + responses: { 200: {} } + - since: 2024-06-01 + request: { path: {} } + responses: { 200: {} } +`, + ["2024-06-01"], + ); + expect(codes(diagnostics)).toContain("unquoted-since"); + }); + + it("accepts a quoted since", () => { + const diagnostics = validate( + ` +Foo.get: + - request: { path: {} } + responses: { 200: {} } + - since: "2024-06-01" + request: { path: {} } + responses: { 200: {} } +`, + ["2024-06-01"], + ); + expect(codes(diagnostics)).not.toContain("unquoted-since"); + }); + + it("rejects a since not listed in service.yaml", () => { + const diagnostics = validate( + ` +Foo.get: + - request: { path: {} } + responses: { 200: {} } + - since: "2030-01-01" + request: { path: {} } + responses: { 200: {} } +`, + ["2024-06-01"], + ); + expect(codes(diagnostics)).toContain("unknown-since-version"); + }); + + it("skips the membership check when no service versions are provided", () => { + const diagnostics = validate(` +Foo.get: + - request: { path: {} } + responses: { 200: {} } + - since: "2030-01-01" + request: { path: {} } + responses: { 200: {} } +`); + expect(codes(diagnostics)).not.toContain("unknown-since-version"); + }); +}); + +describe("lineage", () => { + it("rejects more than one base entry in a lineage", () => { + const diagnostics = validate(` +Foo.get: + - request: { path: {} } + responses: { 200: {} } + - request: { path: {} } + responses: { 200: {} } +`); + expect(codes(diagnostics)).toContain("multiple-base-variants"); + }); + + it("rejects duplicate since values in a lineage", () => { + const diagnostics = validate( + ` +Foo.get: + - request: { path: {} } + responses: { 200: {} } + - since: "2024-06-01" + request: { path: {} } + responses: { 200: {} } + - since: "2024-06-01" + request: { path: {} } + responses: { 200: {} } +`, + ["2024-06-01"], + ); + expect(codes(diagnostics)).toContain("duplicate-since"); + }); + + it("allows separate lineages disambiguated by title", () => { + const diagnostics = validate(` +Foo.create: + - title: With WebHook + request: { path: {} } + responses: { 200: {} } + - title: With Queue + request: { path: {} } + responses: { 200: {} } +`); + expect(codes(diagnostics)).not.toContain("multiple-base-variants"); + }); +}); + +describe("placeholders and api-version", () => { + it("rejects placeholders other than {api-version}", () => { + const diagnostics = validate(` +Foo.get: + - request: { path: {} } + responses: + 200: + body: { url: "https://host/{region}/foo" } +`); + expect(codes(diagnostics)).toContain("unsupported-placeholder"); + }); + + it("rejects api-version written as a request parameter", () => { + const diagnostics = validate(` +Foo.get: + - request: + query: { api-version: "2024-06-01" } + responses: { 200: {} } +`); + expect(codes(diagnostics)).toContain("explicit-api-version"); + }); +}); + +describe("structural schema", () => { + it("rejects a variant missing responses", () => { + const diagnostics = validate(` +Foo.get: + - request: { path: {} } +`); + expect(codes(diagnostics)).toContain("schema-validation"); + }); + + it("reports a fatal YAML parse error", () => { + const diagnostics = validate(`Foo.get: [1, 2`); + expect(codes(diagnostics)).toContain("yaml-parse-error"); + }); +}); diff --git a/packages/typespec-azure-examples/tsconfig.build.json b/packages/typespec-azure-examples/tsconfig.build.json new file mode 100644 index 0000000000..937d9b70ea --- /dev/null +++ b/packages/typespec-azure-examples/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts"], + "exclude": ["**/*.test.*"], + "compilerOptions": { + "outDir": "dist", + "rootDir": "." + } +} diff --git a/packages/typespec-azure-examples/tsconfig.json b/packages/typespec-azure-examples/tsconfig.json new file mode 100644 index 0000000000..69f85fd25d --- /dev/null +++ b/packages/typespec-azure-examples/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "outDir": "dist", + "rootDir": "." + } +} diff --git a/packages/typespec-azure-examples/vitest.config.ts b/packages/typespec-azure-examples/vitest.config.ts new file mode 100644 index 0000000000..dec912f113 --- /dev/null +++ b/packages/typespec-azure-examples/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import { defaultTypeSpecVitestConfig } from "../../core/vitest.config"; + +export default mergeConfig(defaultTypeSpecVitestConfig, defineConfig({})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08717ea737..8dd3079f95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3336,6 +3336,46 @@ importers: specifier: 'catalog:' version: 4.1.9(@types/node@26.0.0)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(happy-dom@20.10.6)(jsdom@25.0.1)(vite@8.1.0(@types/node@26.0.0)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0)) + packages/typespec-azure-examples: + dependencies: + ajv: + specifier: 'catalog:' + version: 8.20.0 + picocolors: + specifier: 'catalog:' + version: 1.1.1 + yaml: + specifier: 'catalog:' + version: 2.9.0 + yargs: + specifier: 'catalog:' + version: 18.0.0 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 26.0.0 + '@types/yargs': + specifier: 'catalog:' + version: 17.0.35 + '@typespec/compiler': + specifier: workspace:^ + version: link:../../core/packages/compiler + '@typespec/json-schema': + specifier: workspace:^ + version: link:../../core/packages/json-schema + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.9(vitest@4.1.9) + rimraf: + specifier: 'catalog:' + version: 6.1.3 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.9(@types/node@26.0.0)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(happy-dom@20.10.6)(jsdom@25.0.1)(vite@8.1.0(@types/node@26.0.0)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0)) + packages/typespec-azure-playground-website: dependencies: '@azure-tools/typespec-autorest': diff --git a/tsconfig.ws.json b/tsconfig.ws.json index c71b5b2218..17e82e0dc4 100644 --- a/tsconfig.ws.json +++ b/tsconfig.ws.json @@ -9,6 +9,7 @@ { "path": "packages/typespec-azure-rulesets/tsconfig.build.json" }, { "path": "packages/azure-http-specs/tsconfig.build.json" }, { "path": "packages/typespec-metadata/tsconfig.build.json" }, + { "path": "packages/typespec-azure-examples/tsconfig.build.json" }, { "path": "packages/samples/tsconfig.build.json" }, { "path": "packages/typespec-python/tsconfig.json" }, { "path": "packages/typespec-java/tsconfig.json" }