diff --git a/.gitattributes b/.gitattributes index 05867262d..63cf0fcce 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,7 @@ *.sh text eol=lf *.ps1 text eol=lf pnpm-lock.yaml text eol=lf +tests/AtsJsonGenerator.Tests/Golden/*.golden text eol=lf whitespace=-blank-at-eol # Force LF for agent-skills artifacts so sha256 digests are byte-stable across # Windows and Linux checkouts (referenced by /.well-known/agent-skills/index.json). diff --git a/.github/workflows/update-integration-data.yml b/.github/workflows/update-integration-data.yml index f5a34ea6a..74d0f91f0 100644 --- a/.github/workflows/update-integration-data.yml +++ b/.github/workflows/update-integration-data.yml @@ -129,7 +129,8 @@ jobs: src/frontend/src/data/samples.json \ src/frontend/src/assets/samples \ src/frontend/src/data/pkgs \ - src/frontend/src/data/ts-modules \ + src/frontend/src/data/apphost-modules \ + src/frontend/src/data/apphost-language-support.json \ src/frontend/src/data/twoslash/aspire.d.ts if git diff --cached --quiet; then diff --git a/.github/workflows/update-release-branch.yml b/.github/workflows/update-release-branch.yml index 7ec4b09be..d9eb61744 100644 --- a/.github/workflows/update-release-branch.yml +++ b/.github/workflows/update-release-branch.yml @@ -102,10 +102,12 @@ jobs: src/frontend/src/data/aspire-integrations.json src/frontend/src/data/github-stats.json src/frontend/src/data/samples.json + src/frontend/src/data/apphost-language-support.json src/frontend/src/data/twoslash/aspire.d.ts " GEN_DIRS=" src/frontend/src/data/pkgs + src/frontend/src/data/apphost-modules src/frontend/src/data/ts-modules src/frontend/src/assets/samples " @@ -115,8 +117,10 @@ jobs: src/frontend/src/data/aspire-integrations.json|\ src/frontend/src/data/github-stats.json|\ src/frontend/src/data/samples.json|\ + src/frontend/src/data/apphost-language-support.json|\ src/frontend/src/data/twoslash/aspire.d.ts|\ src/frontend/src/data/pkgs/*|\ + src/frontend/src/data/apphost-modules/*|\ src/frontend/src/data/ts-modules/*|\ src/frontend/src/assets/samples/*) return 0 ;; *) return 1 ;; diff --git a/src/frontend/config/sidebar/reference.topics.ts b/src/frontend/config/sidebar/reference.topics.ts index d1bed860e..904ad0fba 100644 --- a/src/frontend/config/sidebar/reference.topics.ts +++ b/src/frontend/config/sidebar/reference.topics.ts @@ -150,25 +150,25 @@ export const referenceTopics: StarlightSidebarTopicsUserConfig[number] = { link: '/reference/api/csharp/', }, { - label: 'Search TypeScript APIs', + label: 'Search AppHost APIs', translations: { - da: "Søg i TypeScript API'er", - de: 'TypeScript APIs durchsuchen', - en: 'Search TypeScript APIs', - es: 'Buscar API de TypeScript', - fr: 'Rechercher les API TypeScript', - hi: 'TypeScript एपीआई खोजें', - id: 'Cari API TypeScript', - it: 'Cerca API TypeScript', - ja: 'TypeScript APIを検索する', - ko: 'TypeScript API 검색', - 'pt-BR': 'Pesquisar APIs TypeScript', - ru: 'Поиск TypeScript API', - tr: "TypeScript API'leri Arayın", - uk: 'Пошук TypeScript API', - 'zh-CN': '搜索 TypeScript API', + da: "Søg i AppHost API'er", + de: 'AppHost APIs durchsuchen', + en: 'Search AppHost APIs', + es: 'Buscar API de AppHost', + fr: 'Rechercher les API AppHost', + hi: 'AppHost एपीआई खोजें', + id: 'Cari API AppHost', + it: 'Cerca API AppHost', + ja: 'AppHost APIを検索する', + ko: 'AppHost API 검색', + 'pt-BR': 'Pesquisar APIs AppHost', + ru: 'Поиск AppHost API', + tr: "AppHost API'lerini Arayın", + uk: 'Пошук AppHost API', + 'zh-CN': '搜索 AppHost API', }, - link: '/reference/api/typescript/', + link: '/reference/api/apphost/', }, ], }, diff --git a/src/frontend/package.json b/src/frontend/package.json index 71c8f0d57..47976ff33 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -52,7 +52,8 @@ "update:all": "pnpm update:integrations && pnpm update:github-stats && pnpm update:samples", "update:schemas": "tsx ./scripts/update-schemas.ts", "update:integrations": "tsx ./scripts/update-integrations.ts", - "update:ts-api": "tsx ./scripts/update-ts-api.ts", + "update:apphost-api": "tsx ./scripts/update-ts-api.ts", + "update:ts-api": "pnpm update:apphost-api", "update:github-stats": "tsx ./scripts/update-github-stats.ts", "update:release-contributors": "tsx ./scripts/update-release-contributors.ts", "update:samples": "tsx ./scripts/update-samples.ts", diff --git a/src/frontend/scripts/generate-twoslash-types.ts b/src/frontend/scripts/generate-twoslash-types.ts index f254a9dfd..7e74dc081 100644 --- a/src/frontend/scripts/generate-twoslash-types.ts +++ b/src/frontend/scripts/generate-twoslash-types.ts @@ -3,7 +3,7 @@ * TypeScript SDK surface, consumed by the twoslash plugin so TS code blocks * in the docs get accurate hover tooltips. * - * Reads: src/data/ts-modules/*.json (produced by update-ts-api.ts) + * Reads: src/data/apphost-modules/*.json (produced by update-ts-api.ts) * Writes: src/data/twoslash/aspire.d.ts (source-controlled — commit updates * after regenerating). */ @@ -13,9 +13,11 @@ import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const MODULES_DIR = process.env.ASPIRE_API_TS_MODULES_DIR - ? resolve(process.env.ASPIRE_API_TS_MODULES_DIR) - : resolve(__dirname, '..', 'src', 'data', 'ts-modules'); +const MODULES_DIR = process.env.ASPIRE_API_APPHOST_MODULES_DIR + ? resolve(process.env.ASPIRE_API_APPHOST_MODULES_DIR) + : process.env.ASPIRE_API_TS_MODULES_DIR + ? resolve(process.env.ASPIRE_API_TS_MODULES_DIR) + : resolve(__dirname, '..', 'src', 'data', 'apphost-modules'); const PKGS_DIR = process.env.ASPIRE_API_PKGS_DIR ? resolve(process.env.ASPIRE_API_PKGS_DIR) : resolve(__dirname, '..', 'src', 'data', 'pkgs'); @@ -35,6 +37,7 @@ interface Parameter { } interface FunctionEntry { + id: string; name: string; description?: string; kind: 'Method' | 'InstanceMethod' | 'PropertyGetter' | 'PropertySetter'; @@ -89,6 +92,43 @@ interface ModuleJson { handleTypes?: HandleType[]; } +interface SemanticProjection { + status: 'supported' | 'unsupported'; + identifier?: string; + signature?: string; + declaration?: string; + parameters?: Parameter[]; + return?: { type: string }; + fields?: DtoField[]; + members?: Array<{ name: string }>; +} + +interface SemanticItem { + id: string; + kind: 'capability' | 'handle' | 'dto' | 'enum' | 'exportedValue'; + name: string; + fullName?: string; + capabilityKind?: FunctionEntry['kind']; + qualifiedName?: string; + capabilityId?: string; + targetTypeId?: string; + expandedTargetTypes?: string[]; + returnsBuilder?: boolean; + description?: string; + isInterface?: boolean; + exposeProperties?: boolean; + implementedInterfaces?: string[]; + baseTypeHierarchy?: string[]; + projections: { + typescript?: SemanticProjection; + }; +} + +interface SemanticModuleJson { + package: PackageMetadata; + items: SemanticItem[]; +} + interface PkgTypeEntry { name: string; fullName: string; @@ -121,6 +161,135 @@ function packageIdentity(metadata: PackageMetadata): string { return `${metadata.name}@${metadata.version}`; } +function normalizeTypeIdentity(typeId: string): string { + return typeId.includes('/') ? typeId.slice(typeId.indexOf('/') + 1) : typeId; +} + +function expandGeneratedOptions( + parameters: Parameter[], + declaration: string | undefined +): Parameter[] { + if (!declaration) return parameters; + + return parameters.flatMap((parameter) => { + const escapedType = parameter.type.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = declaration.match( + new RegExp(`export interface\\s+${escapedType}\\s*\\{([\\s\\S]*?)\\}`) + ); + if (!match) return [parameter]; + + const fields = match[1] + .split(/\r?\n/) + .map((line) => line.trim()) + .map((line): Parameter | undefined => { + const field = line.match(/^([A-Za-z_$][\w$]*)(\?)?:\s*(.+);$/); + if (!field) return undefined; + return { + name: field[1], + type: field[3], + isOptional: field[2] === '?', + }; + }) + .filter((field): field is Parameter => field !== undefined); + if (fields.length === 0) return [parameter]; + + return fields; + }); +} + +function unwrapProjectedReturnType(returnType: string | undefined): string { + const type = returnType?.trim() ?? 'void'; + const promise = type.match(/^Promise<(.+)>$/); + return promise?.[1] ?? type; +} + +function projectTypeScriptModule(module: SemanticModuleJson): ModuleJson { + const functions: FunctionEntry[] = module.items.flatMap((item) => { + const projection = item.projections.typescript; + if ( + item.kind !== 'capability' || + projection?.status !== 'supported' || + !projection.identifier + ) { + return []; + } + return [{ + id: item.id, + name: projection.identifier, + description: item.description, + kind: item.capabilityKind ?? 'Method', + signature: projection.signature ?? projection.identifier, + parameters: expandGeneratedOptions( + projection.parameters ?? [], + projection.declaration + ), + // The generated SDK exposes thenable wrapper objects for handle results. + // Twoslash models those as their resolved handle so fluent docs chains + // retain the concrete surface, matching the legacy TypeScript API data. + returnType: unwrapProjectedReturnType(projection.return?.type), + returnsBuilder: item.returnsBuilder, + targetTypeId: item.targetTypeId ?? '', + expandedTargetTypes: item.expandedTargetTypes ?? [], + }]; + }); + + const capabilitiesByTarget = new Map(); + for (const fn of functions) { + for (const target of [fn.targetTypeId, ...fn.expandedTargetTypes].filter(Boolean)) { + const key = normalizeTypeIdentity(target); + const entries = capabilitiesByTarget.get(key) ?? []; + if (!entries.some((entry) => entry.id === fn.id)) { + entries.push(fn); + } + capabilitiesByTarget.set(key, entries); + } + } + + return { + package: module.package, + functions, + handleTypes: module.items.flatMap((item): HandleType[] => { + const projection = item.projections.typescript; + if (item.kind !== 'handle' || projection?.status !== 'supported' || !projection.identifier) { + return []; + } + return [{ + name: projection.identifier, + fullName: item.fullName ?? item.id, + kind: 'handle', + exposeProperties: item.exposeProperties, + implementedInterfaces: item.implementedInterfaces, + baseTypeHierarchy: item.baseTypeHierarchy, + capabilities: capabilitiesByTarget.get(normalizeTypeIdentity(item.fullName ?? item.id)) ?? [], + }]; + }), + dtoTypes: module.items.flatMap((item): DtoType[] => { + const projection = item.projections.typescript; + if (item.kind !== 'dto' || projection?.status !== 'supported' || !projection.identifier) { + return []; + } + return [{ + name: projection.identifier, + fullName: item.fullName ?? item.id, + kind: 'dto', + fields: projection.fields ?? [], + }]; + }), + enumTypes: module.items.flatMap((item): EnumType[] => { + const projection = item.projections.typescript; + if (item.kind !== 'enum' || projection?.status !== 'supported' || !projection.identifier) { + return []; + } + return [{ + name: projection.identifier, + fullName: item.fullName ?? item.id, + kind: 'enum', + members: (projection.members ?? []).map((member) => member.name), + }]; + }), + }; +} + function cleanType(raw: string | undefined): string { if (!raw) return 'unknown'; let s = raw.trim(); @@ -250,7 +419,7 @@ const PARAM_TYPE_OVERRIDES: Record> = { resourceGroup: 'string | ParameterResource', }, // Current Aspire TS SDKs accept connection-string resources as wait - // dependencies; the 13.3 ts-modules snapshot still reports IResource only. + // dependencies; the 13.3 TypeScript projection snapshot still reports IResource only. waitFor: { dependency: 'IResource | IResourceWithConnectionString', }, @@ -288,10 +457,8 @@ function applyParamOverrides(fnName: string, params: Parameter[]): Parameter[] { // optional-primitive params into an options object. Returns the split index // (number of leading required params to keep positional) or -1 to skip. // Rules: -// - `add*` / `with*` / `publish*` methods: any trailing optional tail (≥1) gets -// collapsed — docs consistently surface these as `addX/withX/publishX(..., options?)`. -// - other methods: require ≥2 trailing optional params to avoid noisy -// single-field options overloads. +// - Any nonempty trailing optional-primitive tail gets an options overload, +// matching the SDK even for single-field methods such as DockerfileBuilder.from. // - `with*` / `add*` tails must all be primitive-typed (callback-heavy tails // stay positional for readability). `publish*` tails may include callbacks // — the docs consistently pass `{ configure: ..., configureSlot: ... }`. @@ -306,8 +473,6 @@ function optionsOverloadSplit(fnName: string, params: Parameter[]): number { if (firstOpt < 0) return -1; const tail = params.slice(firstOpt); if (tail.length === 1 && tail[0].name === 'options') return -1; - const minTail = /^(add|with|publish)[A-Z0-9]/.test(fnName) ? 1 : 2; - if (tail.length < minTail) return -1; // `with*` methods that take a callback (e.g. `withPgAdmin(configureContainer?)`) // are consistently invoked in docs as `withX({ configureContainer: cb })`. const allowCallbacks = /^(publish|with)[A-Z0-9]/.test(fnName); @@ -342,14 +507,17 @@ const files = readdirSync(MODULES_DIR) .filter((f) => f.endsWith('.json')) .sort(); -const modules: ModuleJson[] = files.map( - (f) => JSON.parse(readFileSync(resolve(MODULES_DIR, f), 'utf8')) as ModuleJson -); +const modules: ModuleJson[] = files.map((fileName) => { + const raw = JSON.parse(readFileSync(resolve(MODULES_DIR, fileName), 'utf8')) as + | ModuleJson + | SemanticModuleJson; + return 'items' in raw ? projectTypeScriptModule(raw) : raw; +}); console.log(`📚 Loaded ${modules.length} module JSON files`); // Load class-inheritance metadata from the richer pkgs/*.json dumps. Older -// ts-modules snapshots omitted BaseTypeHierarchy, so this remains the fallback. +// older TypeScript projections omitted BaseTypeHierarchy, so this remains the fallback. // Scope full type names to their package: separate integrations can export the // same namespace/type identity with different inheritance. const classBasesByPackage = new Map>(); @@ -408,9 +576,9 @@ const genericArity = new Map(); const FREE_FUNCTION_NAMES = new Set(['createBuilder', 'createBuilderWithOptions']); // Confirmed Aspire 13.4 API surface that may be absent from the checked-in -// 13.3 ts-modules snapshot until package data is refreshed. Keep these shims +// 13.3 TypeScript projection snapshot until package data is refreshed. Keep these shims // narrow and remove them when update-ts-api brings the APIs into -// src/data/ts-modules. +// src/data/apphost-modules. const POST_SNAPSHOT_FREE_FUNCTIONS = [ `/** * Creates a reference expression from a tagged template literal diff --git a/src/frontend/scripts/normalize-generated-api-data.ts b/src/frontend/scripts/normalize-generated-api-data.ts index 10332bdbf..b39504d34 100644 --- a/src/frontend/scripts/normalize-generated-api-data.ts +++ b/src/frontend/scripts/normalize-generated-api-data.ts @@ -1,9 +1,9 @@ /** * normalize-generated-api-data.ts — Enforces Aspire terminology in the generated - * C#/TypeScript API reference JSON (`src/data/pkgs/*.json`, `src/data/ts-modules/*.json`). + * C#/AppHost API reference JSON (`src/data/pkgs/*.json`, `src/data/apphost-modules/*.json`). * - * The C# API JSON is produced by the .NET `PackageJsonGenerator`; the TS API JSON - * by `AtsJsonGenerator`. Both copy XML/JSDoc documentation text verbatim from the + * The C# API JSON is produced by the .NET `PackageJsonGenerator`; the semantic + * AppHost API JSON by `AtsJsonGenerator`. Both copy documentation text verbatim from the * upstream packages, so deprecated Aspire terminology leaks into the committed * data and trips the Forbidden Words CI check (see `.github/forbidden-words.json`). * This pass rewrites only prose fields, reusing the single source of truth in @@ -20,7 +20,7 @@ * Usage: * tsx ./scripts/normalize-generated-api-data.ts # both areas * tsx ./scripts/normalize-generated-api-data.ts --pkgs # C# API only - * tsx ./scripts/normalize-generated-api-data.ts --ts-modules # TS API only + * tsx ./scripts/normalize-generated-api-data.ts --apphost-modules # AppHost API only */ import fs from 'fs'; @@ -32,7 +32,12 @@ import { normalizeAspireTerminology } from './aspire-terminology'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DATA_DIR = path.resolve(__dirname, '..', 'src', 'data'); export const PKGS_DIR = path.join(DATA_DIR, 'pkgs'); -export const TS_MODULES_DIR = path.join(DATA_DIR, 'ts-modules'); +export const APPHOST_MODULES_DIR = path.join(DATA_DIR, 'apphost-modules'); +export const APPHOST_LANGUAGE_SUPPORT_FILE = path.join( + DATA_DIR, + 'apphost-language-support.json' +); +export const TS_MODULES_DIR = APPHOST_MODULES_DIR; // Per-line matcher (multiline) for EITHER a documentation node's `kind` marker // OR a prose string field. Groups: @@ -46,12 +51,13 @@ export const TS_MODULES_DIR = path.join(DATA_DIR, 'ts-modules'); // The `text` field is prose only inside `kind:"text"` nodes; code-bearing nodes // (code, codeblock, cref, langword, paramref, ...) also carry `text` and must be // left intact, hence the kind gating below. `description`/`returns`/`remarks` -// are always prose (and appear as string values only in the TS API + member +// are always prose (and appear as string values only in the AppHost API + member // summaries; the C# doc arrays open with `[` and are skipped, their inner text // nodes handled by the `text` rule). `Reason` is the prose payload of -// `AspireExportIgnoreAttribute`. +// `AspireExportIgnoreAttribute`; lowercase `reason` is used by generated +// language-projection limitations and the support matrix. const nodeLine = - /^[ \t]*"kind"[ \t]*:[ \t]*"([^"]*)"|^([ \t]*")(text|description|returns|remarks|Reason)("[ \t]*:[ \t]*")((?:[^"\\]|\\.)*)(")/gm; + /^[ \t]*"kind"[ \t]*:[ \t]*"([^"]*)"|^([ \t]*")(text|description|returns|remarks|Reason|reason)("[ \t]*:[ \t]*")((?:[^"\\]|\\.)*)(")/gm; /** * Rewrite deprecated Aspire terminology in the prose fields of a generated API @@ -133,7 +139,11 @@ export function normalizeApiDir(dir: string): { function main(): void { const args = process.argv.slice(2); - const explicit = args.includes('--pkgs') || args.includes('--ts-modules'); + const explicit = + args.includes('--pkgs') || + args.includes('--apphost-modules') || + args.includes('--ts-modules') || + args.includes('--support-matrix'); const targets: Array<{ label: string; dir: string }> = []; if (!explicit || args.includes('--pkgs')) { targets.push({ @@ -143,12 +153,14 @@ function main(): void { : PKGS_DIR, }); } - if (!explicit || args.includes('--ts-modules')) { + if (!explicit || args.includes('--apphost-modules') || args.includes('--ts-modules')) { targets.push({ - label: 'ts-modules', - dir: process.env.ASPIRE_API_TS_MODULES_DIR - ? path.resolve(process.env.ASPIRE_API_TS_MODULES_DIR) - : TS_MODULES_DIR, + label: 'apphost-modules', + dir: process.env.ASPIRE_API_APPHOST_MODULES_DIR + ? path.resolve(process.env.ASPIRE_API_APPHOST_MODULES_DIR) + : process.env.ASPIRE_API_TS_MODULES_DIR + ? path.resolve(process.env.ASPIRE_API_TS_MODULES_DIR) + : APPHOST_MODULES_DIR, }); } @@ -163,6 +175,16 @@ function main(): void { console.log(` • ${file}`); } } + if (!explicit || args.includes('--support-matrix')) { + const supportFile = process.env.ASPIRE_API_LANGUAGE_SUPPORT_FILE + ? path.resolve(process.env.ASPIRE_API_LANGUAGE_SUPPORT_FILE) + : APPHOST_LANGUAGE_SUPPORT_FILE; + if (fs.existsSync(supportFile)) { + const changes = normalizeApiFile(supportFile); + total += changes; + console.log(` apphost-language-support: normalized ${changes} occurrence(s)`); + } + } console.log( total > 0 diff --git a/src/frontend/scripts/update-integration-data.ps1 b/src/frontend/scripts/update-integration-data.ps1 index bfa1e6870..b44f12c99 100644 --- a/src/frontend/scripts/update-integration-data.ps1 +++ b/src/frontend/scripts/update-integration-data.ps1 @@ -5,7 +5,7 @@ .DESCRIPTION Refreshes the aspire.dev integration data and, when integration package - versions change, regenerates the C#/TypeScript API reference JSON and the + versions change, regenerates the C#/AppHost API reference JSON and the twoslash bundle. Replaces the former `gh aw` agentic workflow with plain, reliable scripting that runs identically in CI and locally. @@ -18,8 +18,8 @@ counts) do NOT trigger regeneration. 3. Conditional API-reference regeneration (only when a version changed): a. generate-package-json.ps1 -> src/data/pkgs/*.json (C# API) - b. pnpm update:ts-api -> src/data/ts-modules/*.json (TS API) - + chains twoslash aspire.d.ts bundle + b. pnpm update:apphost-api -> src/data/apphost-modules/*.json + + support matrix + twoslash bundle 4. Semantic validation — cross-checks generated identities, provenance, DTO optionality, inheritance, options shapes, and attribute payloads. 5. Scope check — the working tree must only contain allowed data files. @@ -28,7 +28,7 @@ Exit codes: 0 success (whether or not there were changes) - 1 a required phase failed (data update/validation, TS API regen, out-of-scope diff). + 1 a required phase failed (data update/validation, AppHost API regen, out-of-scope diff). The caller must NOT open a PR on a non-zero exit. Packages without a public API surface are reported as explicit skips. Any @@ -77,7 +77,8 @@ $AllowedPaths = @( 'src/frontend/src/data/samples.json', 'src/frontend/src/assets/samples/', 'src/frontend/src/data/pkgs/', - 'src/frontend/src/data/ts-modules/', + 'src/frontend/src/data/apphost-modules/', + 'src/frontend/src/data/apphost-language-support.json', 'src/frontend/src/data/twoslash/aspire.d.ts' ) @@ -133,7 +134,9 @@ function Test-PathAllowed { function Restore-ApiEnvironment { foreach ($name in @( 'ASPIRE_API_PKGS_DIR', + 'ASPIRE_API_APPHOST_MODULES_DIR', 'ASPIRE_API_TS_MODULES_DIR', + 'ASPIRE_API_LANGUAGE_SUPPORT_FILE', 'ASPIRE_API_TWOSLASH_FILE' )) { $previousValue = $script:PreviousApiEnvironment[$name] @@ -172,6 +175,7 @@ function Publish-GeneratedApiData { param( [Parameter(Mandatory)][string]$PackageSource, [Parameter(Mandatory)][string]$ModuleSource, + [Parameter(Mandatory)][string]$SupportSource, [Parameter(Mandatory)][string]$TwoslashSource ) @@ -183,8 +187,13 @@ function Publish-GeneratedApiData { }, [PSCustomObject]@{ Source = $ModuleSource - Destination = Join-Path $DataDir 'ts-modules' - Backup = Join-Path $script:ApiStageRoot 'backup-ts-modules' + Destination = Join-Path $DataDir 'apphost-modules' + Backup = Join-Path $script:ApiStageRoot 'backup-apphost-modules' + }, + [PSCustomObject]@{ + Source = $SupportSource + Destination = Join-Path $DataDir 'apphost-language-support.json' + Backup = Join-Path $script:ApiStageRoot 'backup-apphost-language-support.json' }, [PSCustomObject]@{ Source = $TwoslashSource @@ -199,16 +208,17 @@ function Publish-GeneratedApiData { if (-not (Test-Path $move.Source)) { throw "Staged API artifact is missing: $($move.Source)" } - if (-not (Test-Path $move.Destination)) { - throw "Published API artifact is missing: $($move.Destination)" - } if (Test-Path $move.Backup) { throw "API recovery path already exists: $($move.Backup)" } + $move | Add-Member -NotePropertyName HadDestination ` + -NotePropertyValue (Test-Path $move.Destination) } foreach ($move in $moves) { - Move-Item -LiteralPath $move.Destination -Destination $move.Backup + if ($move.HadDestination) { + Move-Item -LiteralPath $move.Destination -Destination $move.Backup + } $completed.Add($move) Move-Item -LiteralPath $move.Source -Destination $move.Destination } @@ -365,8 +375,8 @@ else { $regenRan = $false $pkgSummary = '' $pkgSkippedPackages = '' -$tsApiSummary = '' -$tsSkippedPackages = '' +$appHostApiSummary = '' +$appHostSkippedPackages = '' $twoslashSummary = '' $semanticSummary = '' @@ -376,17 +386,22 @@ if ($versionsChanged -and -not $SkipRegen) { $script:ApiStageRoot = Join-Path $DataDir ".api-generation-$([Guid]::NewGuid().ToString('N'))" New-Item -ItemType Directory -Path $script:ApiStageRoot -Force | Out-Null $pkgStageDir = Join-Path $script:ApiStageRoot 'pkgs' - $tsStageDir = Join-Path $script:ApiStageRoot 'ts-modules' + $appHostStageDir = Join-Path $script:ApiStageRoot 'apphost-modules' + $supportStageFile = Join-Path $script:ApiStageRoot 'apphost-language-support.json' $twoslashStageFile = Join-Path $script:ApiStageRoot 'twoslash' 'aspire.d.ts' foreach ($name in @( 'ASPIRE_API_PKGS_DIR', + 'ASPIRE_API_APPHOST_MODULES_DIR', 'ASPIRE_API_TS_MODULES_DIR', + 'ASPIRE_API_LANGUAGE_SUPPORT_FILE', 'ASPIRE_API_TWOSLASH_FILE' )) { $script:PreviousApiEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') } $env:ASPIRE_API_PKGS_DIR = $pkgStageDir - $env:ASPIRE_API_TS_MODULES_DIR = $tsStageDir + $env:ASPIRE_API_APPHOST_MODULES_DIR = $appHostStageDir + Remove-Item Env:ASPIRE_API_TS_MODULES_DIR -ErrorAction SilentlyContinue + $env:ASPIRE_API_LANGUAGE_SUPPORT_FILE = $supportStageFile $env:ASPIRE_API_TWOSLASH_FILE = $twoslashStageFile # 3a. C# API JSON. @@ -421,7 +436,7 @@ if ($versionsChanged -and -not $SkipRegen) { # 3a-normalize. Enforce Aspire terminology in the freshly generated C# API # JSON so regenerated pkgs/ prose never trips the Forbidden Words check. The - # ts-modules JSON is normalized inside update:ts-api below (before its + # apphost-modules JSON is normalized inside update:apphost-api below (before its # twoslash bundle), so only pkgs/ is handled here. Write-Host "→ pnpm normalize:api-data --pkgs (Aspire terminology → pkgs/)" -ForegroundColor Cyan Push-Location $FrontendDir @@ -436,44 +451,44 @@ if ($versionsChanged -and -not $SkipRegen) { Stop-ApiRegeneration "normalize:api-data (pkgs) failed (exit $pkgNormExit).`n$pkgNormLog`nAborting; no PR will be opened." } - # 3b. TS API JSON (+ chained twoslash bundle). Requires the Aspire CLI; the + # 3b. AppHost API JSON (+ support matrix and chained twoslash bundle). Requires the Aspire CLI; the # script honours ASPIRE_CLI_PATH. A non-zero exit here IS fatal — we must not # ship a PR with C#-only pkgs updates. - Write-Host "→ pnpm update:ts-api (TS API → ts-modules/ + twoslash aspire.d.ts)" -ForegroundColor Cyan + Write-Host "→ pnpm update:apphost-api (semantic API → apphost-modules/ + support matrix + twoslash)" -ForegroundColor Cyan Push-Location $FrontendDir try { - $tsLog = & pnpm run update:ts-api 2>&1 | Tee-Object -Variable tsTeed | Out-String - $tsExit = $LASTEXITCODE + $appHostLog = & pnpm run update:apphost-api 2>&1 | Tee-Object -Variable appHostTeed | Out-String + $appHostExit = $LASTEXITCODE } finally { Pop-Location } - if ($tsExit -ne 0) { + if ($appHostExit -ne 0) { # Distinguish phase-2 vs phase-3 failure using the script's log markers. - if ($tsLog -match 'Twoslash type generation failed') { - Stop-ApiRegeneration "Twoslash bundle generation failed.`n$tsLog`nAborting; no PR will be opened." + if ($appHostLog -match 'Twoslash type generation failed') { + Stop-ApiRegeneration "Twoslash bundle generation failed.`n$appHostLog`nAborting; no PR will be opened." } else { - Stop-ApiRegeneration "TypeScript API generation failed.`n$tsLog`nAborting; no PR will be opened." + Stop-ApiRegeneration "AppHost API generation failed.`n$appHostLog`nAborting; no PR will be opened." } } - $tsApiDone = ($tsLog -split "`n" | + $appHostApiDone = ($appHostLog -split "`n" | Where-Object { $_ -match 'Complete:\s+\d+\s+succeeded,\s+\d+\s+failed,\s+\d+\s+skipped' } | Select-Object -First 1) - if (-not $tsApiDone -or - $tsApiDone -notmatch 'Complete:\s+(?\d+)\s+succeeded,\s+(?\d+)\s+failed,\s+(?\d+)\s+skipped') { - Stop-ApiRegeneration "TypeScript API generation did not emit a valid completion summary. Aborting; no PR will be opened." + if (-not $appHostApiDone -or + $appHostApiDone -notmatch 'Complete:\s+(?\d+)\s+succeeded,\s+(?\d+)\s+failed,\s+(?\d+)\s+skipped') { + Stop-ApiRegeneration "AppHost API generation did not emit a valid completion summary. Aborting; no PR will be opened." } - $tsApiSummary = "$($Matches.Succeeded) succeeded, $($Matches.Failed) failed, $($Matches.Skipped) skipped" + $appHostApiSummary = "$($Matches.Succeeded) succeeded, $($Matches.Failed) failed, $($Matches.Skipped) skipped" if ([int]$Matches.Failed -gt 0) { - Stop-ApiRegeneration "TypeScript API generation reported $($Matches.Failed) package failure(s). Aborting; no PR will be opened." + Stop-ApiRegeneration "AppHost API generation reported $($Matches.Failed) package failure(s). Aborting; no PR will be opened." } - $tsSkippedLine = ($tsLog -split "`n" | + $appHostSkippedLine = ($appHostLog -split "`n" | Where-Object { $_ -match '^\s*Skipped packages:' } | Select-Object -First 1) - $tsSkippedPackages = if ($tsSkippedLine) { - ($tsSkippedLine -replace '^\s*Skipped packages:\s*', '').Trim() + $appHostSkippedPackages = if ($appHostSkippedLine) { + ($appHostSkippedLine -replace '^\s*Skipped packages:\s*', '').Trim() } else { '' } @@ -498,7 +513,8 @@ if ($versionsChanged -and -not $SkipRegen) { try { Publish-GeneratedApiData ` -PackageSource $pkgStageDir ` - -ModuleSource $tsStageDir ` + -ModuleSource $appHostStageDir ` + -SupportSource $supportStageFile ` -TwoslashSource $twoslashStageFile } catch { @@ -552,7 +568,9 @@ function Get-AreaCounts { } $pkgsCounts = Get-AreaCounts -Prefix 'src/frontend/src/data/pkgs/' -$tsModulesCounts = Get-AreaCounts -Prefix 'src/frontend/src/data/ts-modules/' +$appHostModulesCounts = Get-AreaCounts -Prefix 'src/frontend/src/data/apphost-modules/' +$supportMatrixChanged = @(Invoke-Git @('diff', '--name-only', 'HEAD', '--', 'src/frontend/src/data/apphost-language-support.json') | + Where-Object { $_ -and $_.Trim().Length -gt 0 }).Count -gt 0 $twoslashChanged = @(Invoke-Git @('diff', '--name-only', 'HEAD', '--', 'src/frontend/src/data/twoslash/aspire.d.ts') | Where-Object { $_ -and $_.Trim().Length -gt 0 }).Count -gt 0 @@ -575,7 +593,7 @@ $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine("### API reference regeneration") [void]$sb.AppendLine("") if ($regenRan) { - [void]$sb.AppendLine("Versions changed for the following packages, so the C# and TypeScript API reference data and the twoslash bundle were regenerated:") + [void]$sb.AppendLine("Versions changed for the following packages, so the C# and generated AppHost API reference data, support matrix, and twoslash bundle were regenerated:") [void]$sb.AppendLine("") $shown = 0 foreach ($change in $versionChanges) { @@ -590,20 +608,21 @@ if ($regenRan) { [void]$sb.AppendLine("| Area | Added | Modified | Removed |") [void]$sb.AppendLine("|---|---|---|---|") [void]$sb.AppendLine("| ``src/frontend/src/data/pkgs/**`` | $($pkgsCounts.Added) | $($pkgsCounts.Modified) | $($pkgsCounts.Removed) |") - [void]$sb.AppendLine("| ``src/frontend/src/data/ts-modules/**`` | $($tsModulesCounts.Added) | $($tsModulesCounts.Modified) | $($tsModulesCounts.Removed) |") + [void]$sb.AppendLine("| ``src/frontend/src/data/apphost-modules/**`` | $($appHostModulesCounts.Added) | $($appHostModulesCounts.Modified) | $($appHostModulesCounts.Removed) |") + [void]$sb.AppendLine("| ``src/frontend/src/data/apphost-language-support.json`` | — | $(if ($supportMatrixChanged) { 'yes' } else { 'no' }) | — |") [void]$sb.AppendLine("| ``src/frontend/src/data/twoslash/aspire.d.ts`` | — | $(if ($twoslashChanged) { 'yes' } else { 'no' }) | — |") [void]$sb.AppendLine("") [void]$sb.AppendLine("Generator summary:") [void]$sb.AppendLine("") [void]$sb.AppendLine("- C# API JSON (``generate-package-json.ps1`` → ``pkgs/``): $pkgSummary") - [void]$sb.AppendLine("- TS API JSON (``update:ts-api`` → ``ts-modules/``): $tsApiSummary") + [void]$sb.AppendLine("- AppHost API JSON (``update:apphost-api`` → ``apphost-modules/``): $appHostApiSummary") [void]$sb.AppendLine("- Twoslash bundle (``twoslash/aspire.d.ts``): $twoslashSummary") [void]$sb.AppendLine("- Semantic generated-data validation: $semanticSummary") if ($pkgSkippedPackages) { [void]$sb.AppendLine("- C# packages skipped because they have no public API: ``$pkgSkippedPackages``") } - if ($tsSkippedPackages) { - [void]$sb.AppendLine("- TypeScript packages skipped because they export no ATS functions: ``$tsSkippedPackages``") + if ($appHostSkippedPackages) { + [void]$sb.AppendLine("- AppHost packages skipped because they export no ATS items: ``$appHostSkippedPackages``") } } else { @@ -624,7 +643,8 @@ if ($iconWarnings) { [void]$sb.AppendLine("- [ ] Package counts and versions updated appropriately") [void]$sb.AppendLine("- [ ] Official Aspire package icons still use package-specific NuGet icon URLs") if ($regenRan) { - [void]$sb.AppendLine("- [ ] New/removed ``pkgs/`` and ``ts-modules/`` files match the version changes") + [void]$sb.AppendLine("- [ ] New/removed ``pkgs/`` and ``apphost-modules/`` files match the version changes") + [void]$sb.AppendLine("- [ ] ``apphost-language-support.json`` accounts for every ATS item") [void]$sb.AppendLine("- [ ] ``src/frontend/src/data/twoslash/aspire.d.ts`` is included in the diff") } $prBody = $sb.ToString() diff --git a/src/frontend/scripts/update-ts-api.ts b/src/frontend/scripts/update-ts-api.ts index 0bbb9917f..ae5a07948 100644 --- a/src/frontend/scripts/update-ts-api.ts +++ b/src/frontend/scripts/update-ts-api.ts @@ -1,13 +1,13 @@ /** - * update-ts-api.ts — Regenerates TypeScript API reference data. + * update-ts-api.ts — Regenerates config-driven AppHost API reference data. * * Runs the AtsJsonGenerator tool against the aspire sdk dump output. * Requires: dotnet SDK and aspire CLI. * Set ASPIRE_CLI_PATH to use an installed Aspire CLI that is not on PATH. * * By default, reads the generated C# package JSON files and generates - * data for the matching Aspire.Hosting* and CommunityToolkit.Aspire.Hosting* - * package/version sets. + * one semantic document for each matching Aspire.Hosting* and + * CommunityToolkit.Aspire.Hosting* package/version set. * * Optionally pass an Aspire repo clone path to discover packages from source: * tsx ./scripts/update-ts-api.ts /path/to/aspire @@ -23,7 +23,12 @@ import { existsSync } from 'fs'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; -import { normalizeApiDir, TS_MODULES_DIR } from './normalize-generated-api-data'; +import { + APPHOST_LANGUAGE_SUPPORT_FILE, + APPHOST_MODULES_DIR, + normalizeApiDir, + normalizeApiFile, +} from './normalize-generated-api-data'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SCRIPT_PATH = resolve( @@ -32,7 +37,7 @@ const SCRIPT_PATH = resolve( '..', 'tools', 'AtsJsonGenerator', - 'generate-ts-api-json.ps1' + 'generate-apphost-api-json.ps1' ); function getErrorMessage(error: unknown): string { @@ -49,6 +54,10 @@ function checkPrerequisite(cmd: string, args: string[], name: string): boolean { } } +function readCommandOutput(cmd: string, args: string[]): string { + return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + function main(): void { const aspireRepoPath = process.argv[2] ?? process.env.ASPIRE_REPO_PATH; const aspireCliPath = process.env.ASPIRE_CLI_PATH?.trim() || 'aspire'; @@ -59,46 +68,64 @@ function main(): void { if (!checkPrerequisite(aspireCliPath, ['--version'], 'Aspire CLI')) { process.exit(1); } + const aspireCliVersion = readCommandOutput(aspireCliPath, ['--version']); - const psArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', SCRIPT_PATH]; + const psArgs = [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + SCRIPT_PATH, + '-DumpCliVersion', + aspireCliVersion, + ]; if (aspireRepoPath) { const resolvedPath = resolve(aspireRepoPath); if (!existsSync(resolvedPath)) { console.error(`❌ Aspire repo not found at: ${resolvedPath}`); process.exit(1); } - console.log(`🔄 Generating TypeScript API reference data from ${resolvedPath}...`); + console.log(`🔄 Generating AppHost API reference data from ${resolvedPath}...`); psArgs.push('-AspireRepoPath', resolvedPath); } else { - console.log('🔄 Generating TypeScript API reference data from installed Aspire CLI...'); + console.log('🔄 Generating AppHost API reference data from installed Aspire CLI...'); } - const outputDir = process.env.ASPIRE_API_TS_MODULES_DIR - ? resolve(process.env.ASPIRE_API_TS_MODULES_DIR) - : TS_MODULES_DIR; - if (process.env.ASPIRE_API_TS_MODULES_DIR) { + const outputDir = process.env.ASPIRE_API_APPHOST_MODULES_DIR + ? resolve(process.env.ASPIRE_API_APPHOST_MODULES_DIR) + : process.env.ASPIRE_API_TS_MODULES_DIR + ? resolve(process.env.ASPIRE_API_TS_MODULES_DIR) + : APPHOST_MODULES_DIR; + if (process.env.ASPIRE_API_APPHOST_MODULES_DIR || process.env.ASPIRE_API_TS_MODULES_DIR) { psArgs.push('-OutputDir', outputDir); } try { execFileSync('pwsh', psArgs, { stdio: 'inherit', cwd: resolve(__dirname, '..') }); - console.log('✅ TypeScript API reference data updated.'); + console.log('✅ AppHost API reference data updated.'); } catch (error: unknown) { console.error('❌ Generation failed:', getErrorMessage(error)); process.exit(1); } - // Enforce Aspire terminology in the freshly generated ts-modules JSON before + // Enforce Aspire terminology in the freshly generated apphost-modules JSON before // the twoslash bundle is derived from it, so both the JSON and the .d.ts hover // tooltips stay free of the deprecated Aspire terminology that upstream // JSDoc/XML docs may carry. Reuses the single source of truth in // aspire-terminology.ts. - console.log('🔄 Normalizing Aspire terminology in ts-modules JSON...'); + console.log('🔄 Normalizing Aspire terminology in apphost-modules JSON...'); const { changes: tsModuleChanges } = normalizeApiDir(outputDir); - console.log(`✅ Normalized ${tsModuleChanges} occurrence(s) in ts-modules JSON.`); + console.log(`✅ Normalized ${tsModuleChanges} occurrence(s) in apphost-modules JSON.`); + const supportFile = process.env.ASPIRE_API_LANGUAGE_SUPPORT_FILE + ? resolve(process.env.ASPIRE_API_LANGUAGE_SUPPORT_FILE) + : APPHOST_LANGUAGE_SUPPORT_FILE; + if (existsSync(supportFile)) { + const supportChanges = normalizeApiFile(supportFile); + console.log(`✅ Normalized ${supportChanges} occurrence(s) in the AppHost support matrix.`); + } // Refresh the twoslash .d.ts bundle so docs hover tooltips stay in sync - // with the regenerated ts-modules JSON. The bundle is source-controlled + // with the TypeScript projection. The bundle is source-controlled // at src/data/twoslash/aspire.d.ts — commit the diff alongside the JSON. const generatorScript = resolve(__dirname, 'generate-twoslash-types.ts'); const tsxBin = resolve(__dirname, '..', 'node_modules', 'tsx', 'dist', 'cli.mjs'); diff --git a/src/frontend/scripts/validate-generated-api-data.ts b/src/frontend/scripts/validate-generated-api-data.ts index 5ed77b7b6..b5b04a163 100644 --- a/src/frontend/scripts/validate-generated-api-data.ts +++ b/src/frontend/scripts/validate-generated-api-data.ts @@ -77,6 +77,90 @@ interface TsModuleJson { handleTypes?: HandleType[]; } +type GeneratedLanguage = 'typescript' | 'python' | 'go' | 'java' | 'rust'; +type ProjectionValidation = + | 'source-derived' + | 'upstream-test-validated' + | 'sdk-output-validated'; + +interface SemanticProjection { + status: 'supported' | 'unsupported'; + validation: ProjectionValidation; + reason?: string; + identifier?: string; + parameters?: Array<{ + name: string; + type: string; + isOptional?: boolean; + isNullable?: boolean; + defaultValue?: string; + isCallback?: boolean; + callbackSignature?: string; + }>; + return?: { type: string }; + fields?: DtoField[]; +} + +interface SemanticItem { + id: string; + kind: 'capability' | 'handle' | 'dto' | 'enum' | 'exportedValue'; + name: string; + fullName?: string; + capabilityKind?: string; + targetTypeId?: string; + expandedTargetTypes?: string[]; + implementedInterfaces?: string[]; + baseTypeHierarchy?: string[]; + projections: Record; +} + +interface SemanticModuleJson { + schemaVersion: string; + generatorProvenance: { + repository: string; + commit: string; + lockFile: string; + }; + dumpProvenance?: { + cliVersion?: string; + productCommit?: string; + generatedAt?: string; + }; + package: PackageMetadata; + items: SemanticItem[]; +} + +interface AppHostLanguageSupportMatrix { + schemaVersion: string; + generatedFrom: { + repository: string; + commit: string; + lockFile: string; + dumpProvenance?: SemanticModuleJson['dumpProvenance']; + }; + packages: Record< + string, + { + package: { name: string; version?: string }; + items: Record< + string, + { + kind: SemanticItem['kind']; + name: string; + languages: Record< + GeneratedLanguage, + { + supported: boolean; + validation: ProjectionValidation; + reason?: string; + } + >; + } + >; + } + >; +} + export interface GeneratedFile { fileName: string; data: T; @@ -88,6 +172,8 @@ export interface ValidationInput { packages: GeneratedFile[]; modules: GeneratedFile[]; declarations: string; + semanticModules?: GeneratedFile[]; + supportMatrix?: AppHostLanguageSupportMatrix; } export interface ValidationResult { @@ -360,6 +446,12 @@ function resolveBaseHierarchy( export function validateGeneratedApiData(input: ValidationInput): ValidationResult { const errors: string[] = []; + const projectionChecks = validateSemanticModules(input.semanticModules ?? [], errors); + const supportChecks = validateSupportMatrix( + input.semanticModules ?? [], + input.supportMatrix, + errors + ); const catalogByName = new Map(); for (const entry of input.catalog) { if (catalogByName.has(entry.title)) { @@ -370,7 +462,12 @@ export function validateGeneratedApiData(input: ValidationInput): ValidationResu } const packageByIdentity = addUnique(input.packages, (pkg) => pkg.package, 'pkgs', errors); - const moduleByIdentity = addUnique(input.modules, (module) => module.package, 'ts-modules', errors); + const moduleByIdentity = addUnique( + input.modules, + (module) => module.package, + 'AppHost TypeScript projections', + errors + ); for (const entry of input.catalog) { if (!isPackageOutputExpected(entry.title)) continue; @@ -420,19 +517,19 @@ export function validateGeneratedApiData(input: ValidationInput): ValidationResu const metadata = file.data.package; const catalogEntry = catalogByName.get(metadata.name); if (!catalogEntry) { - errors.push(`TypeScript API output ${identity(metadata)} is not present in the integration catalog.`); + errors.push(`AppHost TypeScript projection ${identity(metadata)} is not present in the integration catalog.`); } else if (catalogEntry.version !== metadata.version) { errors.push( - `Stale TypeScript API output ${identity(metadata)}; catalog version is ${catalogEntry.version}.` + `Stale AppHost TypeScript projection ${identity(metadata)}; catalog version is ${catalogEntry.version}.` ); } if ((file.data.functions?.length ?? 0) === 0) { - errors.push(`TypeScript API output ${identity(metadata)} contains no exported functions.`); + errors.push(`AppHost TypeScript projection ${identity(metadata)} contains no exported functions.`); } const matchingPackage = packageByIdentity.get(identity(metadata))?.data; if (!matchingPackage) { - errors.push(`TypeScript API output ${identity(metadata)} has no exact C# API output.`); + errors.push(`AppHost TypeScript projection ${identity(metadata)} has no exact C# API output.`); continue; } if (metadata.sourceRepository !== matchingPackage.package.sourceRepository) { @@ -449,7 +546,7 @@ export function validateGeneratedApiData(input: ValidationInput): ValidationResu for (const file of input.packages) { if (hasExportedApi(file.data) && !moduleByIdentity.has(identity(file.data.package))) { - errors.push(`Missing TypeScript API output for exported package ${identity(file.data.package)}.`); + errors.push(`Missing AppHost TypeScript projection for exported package ${identity(file.data.package)}.`); } } @@ -486,7 +583,7 @@ export function validateGeneratedApiData(input: ValidationInput): ValidationResu errors.push(`Twoslash DTO ${dto.name} is missing property ${propertyName}.`); } else if (!property.optional) { errors.push( - `Twoslash DTO ${dto.name}.${propertyName} optionality does not match ts-modules metadata.` + `Twoslash DTO ${dto.name}.${propertyName} optionality does not match AppHost TypeScript projection metadata.` ); } if ( @@ -494,7 +591,7 @@ export function validateGeneratedApiData(input: ValidationInput): ValidationResu normalizeTypeScriptType(property.type) !== normalizeTypeScriptType(field.type) ) { errors.push( - `Twoslash DTO ${dto.name}.${propertyName} type ${property.type} does not match ts-modules metadata ${field.type}.` + `Twoslash DTO ${dto.name}.${propertyName} type ${property.type} does not match AppHost TypeScript projection metadata ${field.type}.` ); } } @@ -556,14 +653,188 @@ export function validateGeneratedApiData(input: ValidationInput): ValidationResu errors, checks: [ `${catalogByName.size} catalog package identities reconciled`, - `${moduleByIdentity.size} TypeScript modules matched to C# provenance`, + `${moduleByIdentity.size} AppHost TypeScript projections matched to C# provenance`, `${selectedDtos.size} DTO shapes checked`, `${selectedHandles.size} handle inheritance chains checked`, + `${projectionChecks} semantic AppHost items fully accounted for`, + `${supportChecks} support-matrix items reconciled with semantic modules`, 'attribute payload regressions checked against HEAD', ], }; } +export function validateSemanticModules( + modules: GeneratedFile[], + errors: string[] = [] +): number { + const languages: GeneratedLanguage[] = ['typescript', 'python', 'go', 'java', 'rust']; + const validations = new Set([ + 'source-derived', + 'upstream-test-validated', + 'sdk-output-validated', + ]); + let itemCount = 0; + for (const file of modules) { + if (file.data.schemaVersion !== '1.0') { + errors.push(`${file.fileName} has unsupported AppHost module schema ${file.data.schemaVersion}.`); + } + if ( + file.data.generatorProvenance.repository !== 'microsoft/aspire' || + file.data.generatorProvenance.commit !== + '62028348b5d02dfc8f8baf03a4472946537b0d16' || + file.data.generatorProvenance.lockFile !== + 'src/tools/AtsJsonGenerator/upstream-sources.lock.json' + ) { + errors.push(`${file.fileName} has unexpected AppHost generator provenance.`); + } + const ids = new Set(); + for (const item of file.data.items) { + itemCount++; + if (ids.has(item.id)) { + errors.push(`${file.fileName} contains duplicate semantic item ${item.id}.`); + } + ids.add(item.id); + for (const language of languages) { + const projection = item.projections[language]; + if (!projection) { + errors.push(`${file.fileName} item ${item.id} has no ${language} projection.`); + continue; + } + if (projection.status === 'unsupported' && !projection.reason?.trim()) { + errors.push(`${file.fileName} item ${item.id} has no ${language} limitation reason.`); + } + if (projection.status === 'supported' && !projection.identifier?.trim()) { + errors.push(`${file.fileName} item ${item.id} has a supported ${language} projection without an identifier.`); + } + if (!validations.has(projection.validation)) { + errors.push( + `${file.fileName} item ${item.id} has invalid ${language} validation evidence ${String(projection.validation)}.` + ); + } + } + } + } + return itemCount; +} + +export function validateSupportMatrix( + modules: GeneratedFile[], + matrix: AppHostLanguageSupportMatrix | undefined, + errors: string[] = [] +): number { + if (modules.length === 0) return 0; + if (!matrix) { + errors.push('Missing AppHost language support matrix.'); + return 0; + } + if (matrix.schemaVersion !== '1.0') { + errors.push(`Unsupported AppHost language support schema ${matrix.schemaVersion}.`); + } + + const firstModule = modules[0].data; + if ( + matrix.generatedFrom.repository !== firstModule.generatorProvenance.repository || + matrix.generatedFrom.commit !== firstModule.generatorProvenance.commit || + matrix.generatedFrom.lockFile !== firstModule.generatorProvenance.lockFile + ) { + errors.push('AppHost language support generator provenance does not match semantic modules.'); + } + + const expectedPackageIds = new Set(modules.map((file) => identity(file.data.package))); + for (const packageId of Object.keys(matrix.packages)) { + if (!expectedPackageIds.has(packageId)) { + errors.push(`AppHost language support contains stale package ${packageId}.`); + } + } + + const languages: GeneratedLanguage[] = ['typescript', 'python', 'go', 'java', 'rust']; + let itemCount = 0; + for (const file of modules) { + const packageId = identity(file.data.package); + const supportPackage = matrix.packages[packageId]; + if (!supportPackage) { + errors.push(`Missing AppHost language support for package ${packageId}.`); + continue; + } + + const expectedItemIds = new Set(file.data.items.map((item) => item.id)); + for (const itemId of Object.keys(supportPackage.items)) { + if (!expectedItemIds.has(itemId)) { + errors.push(`AppHost language support contains stale item ${packageId}/${itemId}.`); + } + } + + for (const item of file.data.items) { + itemCount++; + const supportItem = supportPackage.items[item.id]; + if (!supportItem) { + errors.push(`Missing AppHost language support for item ${packageId}/${item.id}.`); + continue; + } + if (supportItem.kind !== item.kind || supportItem.name !== item.name) { + errors.push(`AppHost language support identity mismatch for ${packageId}/${item.id}.`); + } + for (const language of languages) { + const projection = item.projections[language]; + if (!projection) continue; + const support = supportItem.languages[language]; + if (!support) { + errors.push(`Missing ${language} support status for ${packageId}/${item.id}.`); + continue; + } + if ( + support.supported !== (projection.status === 'supported') || + support.reason !== projection.reason || + support.validation !== projection.validation + ) { + errors.push(`AppHost language support mismatch for ${packageId}/${item.id}/${language}.`); + } + } + } + } + + return itemCount; +} + +function projectTypeScriptModule(module: SemanticModuleJson): TsModuleJson { + const functions = module.items.flatMap((item) => { + const projection = item.projections.typescript; + if (item.kind !== 'capability' || projection.status !== 'supported' || !projection.identifier) { + return []; + } + return [{ + name: projection.identifier, + kind: item.capabilityKind, + targetTypeId: item.targetTypeId, + expandedTargetTypes: item.expandedTargetTypes, + parameters: projection.parameters, + returnType: projection.return?.type, + }]; + }); + + return { + package: module.package, + functions, + dtoTypes: module.items.flatMap((item): DtoType[] => { + const projection = item.projections.typescript; + return item.kind === 'dto' && projection.status === 'supported' && projection.identifier + ? [{ name: projection.identifier, fields: projection.fields ?? [] }] + : []; + }), + handleTypes: module.items.flatMap((item): HandleType[] => { + const projection = item.projections.typescript; + return item.kind === 'handle' && projection.status === 'supported' && projection.identifier + ? [{ + name: projection.identifier, + fullName: item.fullName ?? item.id, + implementedInterfaces: item.implementedInterfaces, + baseTypeHierarchy: item.baseTypeHierarchy, + }] + : []; + }), + }; +} + const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const frontendDir = path.resolve(scriptDir, '..'); const repoRoot = path.resolve(frontendDir, '..', '..'); @@ -644,18 +915,31 @@ function main(): void { const packageDir = process.env.ASPIRE_API_PKGS_DIR ? path.resolve(process.env.ASPIRE_API_PKGS_DIR) : canonicalPackageDir; - const moduleDir = process.env.ASPIRE_API_TS_MODULES_DIR - ? path.resolve(process.env.ASPIRE_API_TS_MODULES_DIR) - : path.join(dataDir, 'ts-modules'); + const moduleDir = process.env.ASPIRE_API_APPHOST_MODULES_DIR + ? path.resolve(process.env.ASPIRE_API_APPHOST_MODULES_DIR) + : process.env.ASPIRE_API_TS_MODULES_DIR + ? path.resolve(process.env.ASPIRE_API_TS_MODULES_DIR) + : path.join(dataDir, 'apphost-modules'); const declarationsFile = process.env.ASPIRE_API_TWOSLASH_FILE ? path.resolve(process.env.ASPIRE_API_TWOSLASH_FILE) : path.join(dataDir, 'twoslash', 'aspire.d.ts'); + const supportFile = process.env.ASPIRE_API_LANGUAGE_SUPPORT_FILE + ? path.resolve(process.env.ASPIRE_API_LANGUAGE_SUPPORT_FILE) + : path.join(dataDir, 'apphost-language-support.json'); + const semanticModules = loadJsonFiles(moduleDir); const result = validateGeneratedApiData({ catalog: JSON.parse( fs.readFileSync(path.join(dataDir, 'aspire-integrations.json'), 'utf8') ) as CatalogEntry[], packages: loadJsonFiles(packageDir, canonicalPackageDir), - modules: loadJsonFiles(moduleDir), + modules: semanticModules.map((file) => ({ + fileName: file.fileName, + data: projectTypeScriptModule(file.data), + })), + semanticModules, + supportMatrix: JSON.parse( + fs.readFileSync(supportFile, 'utf8') + ) as AppHostLanguageSupportMatrix, declarations: fs.readFileSync(declarationsFile, 'utf8'), }); diff --git a/src/frontend/src/components/ContainerImages.astro b/src/frontend/src/components/ContainerImages.astro index cd0b08de6..fd47dac3f 100644 --- a/src/frontend/src/components/ContainerImages.astro +++ b/src/frontend/src/components/ContainerImages.astro @@ -2,6 +2,14 @@ import { Code, Icon } from '@astrojs/starlight/components'; import { Image } from 'astro:assets'; import { getContainerImages, getRegistryUrl, getRegistryLabel } from '@utils/container-images'; +import { + getEnabledAppHostLanguages, + type AppHostLanguageId, +} from '@utils/apphost-languages'; +import { + getAppHostModules, + resolveAppHostCapabilityLanguageSupport, +} from '@utils/apphost-modules'; import dockerLogo from '@assets/icons/docker.svg'; import microsoftMark from '@assets/icons/microsoft-icon.svg?raw'; @@ -18,8 +26,8 @@ interface Props { /** * Optional override for the introducing AppHost API (e.g. `WithPgAdmin`). * Defaults to each image's `api` from the data file; set this only to override - * it. `()` is appended automatically and the casing pivots with the page's - * C#/TypeScript language selection. + * it. `()` is appended automatically and the projected name follows the + * page's AppHost language selection. */ api?: string; } @@ -41,16 +49,40 @@ if (images.length === 0) { const count = images.length; const displayLabel = title ?? (count === 1 ? 'Container image' : 'Container images'); const groupLabel = `${displayLabel} for ${aspirePackage}`; +const enabledLanguages = getEnabledAppHostLanguages(); +const appHostModule = (await getAppHostModules()).find( + (entry) => entry.data.package.name === aspirePackage +)?.data; + +interface ApiLabel { + language: AppHostLanguageId; + value: string; +} + +function asCall(identifier: string): string { + return identifier.endsWith(')') ? identifier : `${identifier}()`; +} + +// Resolve exact generated member names from the semantic AppHost API catalog. +// Unsupported or missing projections are omitted instead of guessing casing. +function apiLabels(raw: string | null | undefined): ApiLabel[] { + if (!raw) return []; + + const csharpMemberName = raw.replace(/\(\)$/, ''); + const resolution = appHostModule + ? resolveAppHostCapabilityLanguageSupport(appHostModule, csharpMemberName) + : undefined; + + return enabledLanguages.flatMap((language) => { + if (language.id === 'csharp') { + return [{ language: language.id, value: asCall(csharpMemberName) }]; + } -// Resolve the introducing API label pair for an image. The explicit `api` prop -// overrides the per-image `api` from the data file. Mirrors the page's C#/TS -// pivot (Starlight `syncKey="aspire-lang"`): C# is PascalCase (`AddPostgres`), -// the TypeScript AppHost binding lowercases the first letter (`addPostgres`). -function apiLabels(raw: string | null | undefined) { - if (!raw) return null; - const cs = raw.endsWith(')') ? raw : `${raw}()`; - const ts = cs.charAt(0).toLowerCase() + cs.slice(1); - return { cs, ts }; + const projection = resolution?.languages[language.id]; + return projection?.status === 'supported' && projection.identifier + ? [{ language: language.id, value: asCall(projection.identifier) }] + : []; + }); } // Registry-specific brand glyph, only for registries with a recognizable mark: @@ -125,15 +157,11 @@ function registryGlyph(registry: string) {
- {labels && ( -

- Added by{' '} - - {labels.cs} - {labels.ts} - . + {labels.map((label) => ( +

+ Added by {label.value}.

- )} + ))} so the API label above can swap casing with CSS. - Inlined + guarded so it runs once per page and before paint. */} - - diff --git a/src/frontend/src/components/api-reference/MemberCardBase.astro b/src/frontend/src/components/api-reference/MemberCardBase.astro index 4f6f72522..22b88528b 100644 --- a/src/frontend/src/components/api-reference/MemberCardBase.astro +++ b/src/frontend/src/components/api-reference/MemberCardBase.astro @@ -1,7 +1,7 @@ --- /** * MemberCardBase — shared layout shell for rendering a single API member - * (method, property, field) across both C# and TypeScript. + * (method, property, field) across generated AppHost languages and C#. * * Provides the visual structure: header, description, signature code block, * parameter list, and return type. Language-specific details are handled @@ -36,7 +36,7 @@ interface Props { /** Syntax-highlighted code signature */ signature?: string | null; /** Language for syntax highlighting */ - lang?: 'typescript' | 'csharp'; + lang?: 'typescript' | 'csharp' | 'python' | 'go' | 'java' | 'rust'; /** Return type display string */ returnType?: string | null; /** Parameter list */ diff --git a/src/frontend/src/components/api-reference/apphost-api-search-controller.ts b/src/frontend/src/components/api-reference/apphost-api-search-controller.ts new file mode 100644 index 000000000..c692d63b9 --- /dev/null +++ b/src/frontend/src/components/api-reference/apphost-api-search-controller.ts @@ -0,0 +1,365 @@ +import { + formatAppHostApiSearchStats, + getAppHostApiSearchStats, +} from '@utils/apphost-api-search-stats'; +import type { AppHostApiSearchEntry } from '@utils/apphost-api-search'; +import type { AppHostLanguageId } from '@utils/apphost-languages'; + +import { InpageSearchSync } from './inpage-search-sync'; + +declare global { + interface Window { + __appHostApiSearchIndex?: AppHostApiSearchEntry[]; + __appHostApiLanguageIds?: AppHostLanguageId[]; + } +} + +const PAGE_SIZE = 10; +const DEBOUNCE_MS = 250; +const KIND_COLORS: Record = { + function: '#3b82f6', + method: '#3b82f6', + property: '#10b981', + handle: '#8b5cf6', + interface: '#06b6d4', + dto: '#f59e0b', + enum: '#ef4444', + value: '#ec4899', +}; + +class AppHostApiSearchController { + private readonly input = requiredElement('apphost-api-search-input'); + private readonly results = requiredElement('apphost-api-search-results'); + private readonly packages = requiredElement('apphost-api-package-list'); + private readonly status = requiredElement('apphost-api-search-status'); + private readonly count = requiredElement('apphost-api-search-count'); + private readonly filters = requiredElement('apphost-api-kind-filters'); + private readonly clearFilters = requiredElement('apphost-api-clear-filters'); + private readonly index = window.__appHostApiSearchIndex ?? []; + private readonly languageIds = window.__appHostApiLanguageIds ?? ['typescript']; + private readonly sync = new InpageSearchSync('apphost-api', () => this.clear()); + private activeKinds = new Set(); + private activeVersions: Set | null = null; + private scored: Array<{ entry: AppHostApiSearchEntry; score: number }> = []; + private tokens: string[] = []; + private visibleCount = 0; + private debounceTimer: number | undefined; + + constructor() { + this.bindEvents(); + this.restoreFromUrl(); + this.syncVersionStateFromDom(); + this.filterPackages(); + this.applySearch(); + } + + private activeLanguage(): AppHostLanguageId { + const selected = document.documentElement.dataset.apphostLang as AppHostLanguageId | undefined; + return selected && this.languageIds.includes(selected) ? selected : this.languageIds[0]; + } + + private bindEvents(): void { + this.input.addEventListener('input', () => { + if (this.debounceTimer !== undefined) window.clearTimeout(this.debounceTimer); + this.debounceTimer = window.setTimeout(() => { + this.syncUrl(); + this.applySearch(); + }, DEBOUNCE_MS); + }); + this.input.addEventListener('keydown', (event) => { + if (event.key === 'Escape') this.clear(); + }); + + this.filters.querySelectorAll('.api-filter-chip').forEach((chip) => { + chip.addEventListener('click', () => { + const kind = chip.dataset.kind; + if (!kind) return; + if (this.activeKinds.delete(kind)) { + chip.classList.remove('active'); + chip.setAttribute('aria-pressed', 'false'); + } else { + this.activeKinds.add(kind); + chip.classList.add('active'); + chip.setAttribute('aria-pressed', 'true'); + } + this.updateClearFiltersVisibility(); + this.syncUrl(); + this.applySearch(); + }); + }); + + this.clearFilters.addEventListener('click', () => this.resetFilters()); + document.addEventListener('version-filter-change', (event) => { + const detail = (event as CustomEvent<{ + all?: boolean; + none?: boolean; + selected?: string[]; + }>).detail; + this.activeVersions = detail.all + ? null + : new Set(detail.none ? [] : (detail.selected ?? [])); + this.filterPackages(); + this.updateClearFiltersVisibility(); + this.applySearch(); + }); + + const refreshLanguage = (): void => { + window.requestAnimationFrame(() => { + this.filterPackages(); + this.applySearch(); + }); + }; + window.addEventListener('apphost-language-change', refreshLanguage); + window.addEventListener('apphost-language-select', refreshLanguage); + } + + private restoreFromUrl(): void { + const query = this.sync.readQuery(); + const validKinds = new Set( + [...this.filters.querySelectorAll('.api-filter-chip')] + .map((chip) => chip.dataset.kind) + .filter((kind): kind is string => Boolean(kind)) + ); + const kinds = this.sync.readKinds(validKinds); + + if (query) { + this.input.value = query; + this.sync.updateClearButton(); + } + if (kinds.size > 0) { + this.activeKinds = kinds; + this.filters.querySelectorAll('.api-filter-chip').forEach((chip) => { + if (chip.dataset.kind && kinds.has(chip.dataset.kind)) { + chip.classList.add('active'); + chip.setAttribute('aria-pressed', 'true'); + } + }); + } + } + + private syncVersionStateFromDom(): void { + const checkboxes = [ + ...document.querySelectorAll( + '.version-filter .version-filter-cb:not([data-version="__all__"])' + ), + ]; + const selected = checkboxes + .filter((checkbox) => checkbox.checked) + .map((checkbox) => checkbox.dataset.version) + .filter((version): version is string => Boolean(version)); + this.activeVersions = checkboxes.length === selected.length ? null : new Set(selected); + this.updateClearFiltersVisibility(); + } + + private resetFilters(): void { + this.activeKinds.clear(); + this.filters.querySelectorAll('.api-filter-chip').forEach((chip) => { + chip.classList.remove('active'); + chip.setAttribute('aria-pressed', 'false'); + }); + this.activeVersions = null; + + const versionFilter = document.querySelector('.version-filter'); + versionFilter?.querySelectorAll('.version-filter-cb').forEach((checkbox) => { + checkbox.checked = true; + checkbox.indeterminate = false; + }); + const versionCount = versionFilter?.querySelector('[id$="-count"]'); + const total = versionFilter?.querySelectorAll( + '.version-filter-cb:not([data-version="__all__"])' + ).length ?? 0; + if (versionCount) versionCount.textContent = `${total}/${total}`; + versionFilter?.querySelector('.version-filter-btn')?.classList.remove('filtered', 'none-selected'); + + this.updateClearFiltersVisibility(); + this.filterPackages(); + this.syncUrl(); + this.applySearch(); + } + + private clear(): void { + this.input.value = ''; + this.sync.updateClearButton(); + this.syncUrl(); + this.applySearch(); + } + + private syncUrl(): void { + this.sync.writeUrl(this.input.value.trim(), this.activeKinds); + } + + private updateClearFiltersVisibility(): void { + this.clearFilters.style.display = + this.activeKinds.size > 0 || this.activeVersions !== null ? '' : 'none'; + } + + private filterPackages(): void { + const language = this.activeLanguage(); + this.packages.querySelectorAll('.api-list-item').forEach((item) => { + const supportsLanguage = (item.dataset.languages ?? '').split(',').includes(language); + const supportsVersion = + this.activeVersions === null || + (item.dataset.version !== undefined && this.activeVersions.has(item.dataset.version)); + item.style.display = supportsLanguage && supportsVersion ? '' : 'none'; + }); + } + + private applySearch(): void { + const query = this.input.value.trim(); + if (!query && this.activeKinds.size === 0) { + this.showPackages(); + return; + } + this.search(query); + } + + private showPackages(): void { + this.results.style.display = 'none'; + this.results.innerHTML = ''; + this.packages.style.display = ''; + this.status.textContent = ''; + this.count.textContent = formatAppHostApiSearchStats( + getAppHostApiSearchStats(this.index, this.activeLanguage(), this.activeVersions) + ); + } + + private search(query: string): void { + const tokens = query.toLowerCase().split(/\s+/).filter(Boolean); + const scored: Array<{ entry: AppHostApiSearchEntry; score: number }> = []; + for (const entry of this.index) { + if (entry.l !== this.activeLanguage()) continue; + if (this.activeKinds.size > 0 && !this.activeKinds.has(entry.k)) continue; + if (this.activeVersions !== null && (!entry.v || !this.activeVersions.has(entry.v))) continue; + + let score = tokens.length === 0 ? 50 : 0; + for (const token of tokens) { + const tokenScore = scoreToken(entry, token); + if (tokenScore === 0) { + score = 0; + break; + } + score += tokenScore; + } + if (score > 0) scored.push({ entry, score }); + } + + scored.sort((left, right) => + right.score - left.score || left.entry.n.localeCompare(right.entry.n) + ); + this.scored = scored; + this.tokens = tokens; + this.visibleCount = 0; + this.packages.style.display = 'none'; + this.results.style.display = ''; + this.results.innerHTML = ''; + + if (scored.length === 0) { + const heading = this.activeVersions?.size === 0 + ? 'No versions selected' + : query + ? `No results for "${escapeHtml(query)}"` + : 'No results match the selected filters'; + this.results.innerHTML = `

${heading}

Try adjusting the language, version, kind, or search text.

`; + this.count.textContent = '0 results'; + this.status.textContent = 'No API results found.'; + return; + } + + this.loadMore(); + } + + private loadMore(): void { + const next = this.scored.slice(this.visibleCount, this.visibleCount + PAGE_SIZE); + this.visibleCount += next.length; + this.results.querySelector('.api-load-more')?.remove(); + + const fragment = document.createDocumentFragment(); + for (const { entry } of next) { + const wrapper = document.createElement('div'); + wrapper.innerHTML = renderResult(entry, this.tokens); + if (wrapper.firstElementChild) fragment.append(wrapper.firstElementChild); + } + this.results.append(fragment); + + const remaining = this.scored.length - this.visibleCount; + this.count.textContent = remaining > 0 + ? `Showing ${this.visibleCount.toLocaleString()} of ${this.scored.length.toLocaleString()} results` + : `${this.scored.length.toLocaleString()} result${this.scored.length === 1 ? '' : 's'}`; + this.status.textContent = this.count.textContent; + + if (remaining > 0) { + const button = document.createElement('button'); + button.className = 'api-load-more'; + button.type = 'button'; + button.textContent = `Show ${Math.min(remaining, PAGE_SIZE).toLocaleString()} more of ${remaining.toLocaleString()} remaining`; + button.addEventListener('click', () => this.loadMore()); + this.results.append(button); + } + } +} + +function requiredElement(id: string): T { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing AppHost API search element: ${id}`); + return element as T; +} + +function scoreToken(entry: AppHostApiSearchEntry, token: string): number { + const name = entry.n.toLowerCase(); + if (name === token) return 100; + if (name.startsWith(token)) return 60; + if (camelMatch(entry.n, token)) return 55; + if (name.includes(token)) return 40; + if (entry.f.toLowerCase().includes(token)) return 30; + if (entry.t?.toLowerCase().includes(token)) return 25; + if (entry.p.toLowerCase().includes(token)) return 15; + if (entry.s.toLowerCase().includes(token)) return 10; + return 0; +} + +function camelMatch(name: string, token: string): boolean { + const capitals = name.replace(/[^A-Z]/g, '').toLowerCase(); + return capitals.length >= 2 && capitals.includes(token); +} + +function renderResult(entry: AppHostApiSearchEntry, tokens: string[]): string { + const color = KIND_COLORS[entry.k] ?? KIND_COLORS.method; + const kindClass = entry.k.toLowerCase().replace(/[^a-z0-9_-]+/g, '-'); + const parent = entry.t + ? ` · ${escapeHtml(entry.t)}` + : ''; + const description = entry.s + ? `
${escapeHtml(entry.s)}
` + : ''; + + return `
+
+ + ${highlight(entry.n, tokens)}${parent} + + ${escapeHtml(entry.k)} +
${escapeHtml(entry.p)}
+
+ ${description} +
`; +} + +function highlight(text: string, tokens: string[]): string { + if (tokens.length === 0) return escapeHtml(text); + const pattern = tokens.map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + return escapeHtml(text).replace(new RegExp(`(${pattern})`, 'gi'), '$1'); +} + +function escapeHtml(text: string): string { + return text.replace( + /[&<>"']/g, + (character) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] ?? + character + ); +} + +document.addEventListener('DOMContentLoaded', () => { + new AppHostApiSearchController(); + document.querySelector('#apphost-api-search-input')?.focus(); +}); diff --git a/src/frontend/src/components/api-reference/inpage-search-sync.ts b/src/frontend/src/components/api-reference/inpage-search-sync.ts index 9a7dfe442..c6025263c 100644 --- a/src/frontend/src/components/api-reference/inpage-search-sync.ts +++ b/src/frontend/src/components/api-reference/inpage-search-sync.ts @@ -13,7 +13,7 @@ declare global { } } -class InpageSearchSync { +export class InpageSearchSync { private input: HTMLInputElement; private clearBtn: HTMLElement; private onClear: () => void; diff --git a/src/frontend/src/components/starlight/Search.astro b/src/frontend/src/components/starlight/Search.astro index 4e0f6a60e..a42f65ebb 100644 --- a/src/frontend/src/components/starlight/Search.astro +++ b/src/frontend/src/components/starlight/Search.astro @@ -2,7 +2,12 @@ import DefaultSearch from "@astrojs/starlight/components/Search.astro"; import { Image } from 'astro:assets'; import csharpIcon from '@assets/icons/csharp.svg'; +import goIcon from '@assets/icons/go.svg'; +import javaIcon from '@assets/icons/java.svg'; +import pythonIcon from '@assets/icons/python.svg'; +import rustIcon from '@assets/icons/rust.svg'; import typescriptIcon from '@assets/icons/typescript.svg'; +import { getGeneratedApiLanguages } from '@utils/apphost-languages'; const routeData = Astro.locals.starlightRoute; const pageId = routeData?.id ?? ''; @@ -10,7 +15,19 @@ const isApiRefPage = pageId.startsWith('reference/api') || Astro.url.pathname.st const hideSearch = isApiRefPage; const base = import.meta.env.BASE_URL.replace(/\/$/, ''); const csharpHref = `${base}/reference/api/csharp/`; -const typescriptHref = `${base}/reference/api/typescript/`; +const apphostHref = `${base}/reference/api/apphost/`; +const generatedLanguageIcons = { + typescript: typescriptIcon, + python: pythonIcon, + go: goIcon, + java: javaIcon, + rust: rustIcon, +}; +const generatedApiLinks = getGeneratedApiLanguages().map((language) => ({ + ...language, + href: `${apphostHref}?aspire-lang=${language.id}`, + icon: generatedLanguageIcons[language.id as keyof typeof generatedLanguageIcons], +})); --- { hideSearch ? <> : } @@ -29,16 +46,18 @@ const typescriptHref = `${base}/reference/api/typescript/`; C# API Reference - - - TypeScript API Reference - + {generatedApiLinks.map((language) => ( + + + {language.label} AppHost API + + ))}
@@ -64,7 +83,7 @@ const typescriptHref = `${base}/reference/api/typescript/`; + + diff --git a/src/frontend/src/pages/reference/api/typescript.md.ts b/src/frontend/src/pages/reference/api/typescript.md.ts index 9ecf3ecaf..040d64ed3 100644 --- a/src/frontend/src/pages/reference/api/typescript.md.ts +++ b/src/frontend/src/pages/reference/api/typescript.md.ts @@ -1,13 +1,9 @@ import type { APIRoute } from 'astro'; -import { markdownResponse } from '@utils/api-markdown-shared'; -import { renderTypeScriptIndexMarkdown } from '@utils/typescript-api-markdown'; -import { getTsModules } from '@utils/ts-modules'; - export const prerender = true; -export const GET: APIRoute = async () => { - const base = import.meta.env.BASE_URL.replace(/\/$/, ''); - const modules = (await getTsModules()).map((entry) => entry.data); - return markdownResponse(renderTypeScriptIndexMarkdown(modules, base)); -}; \ No newline at end of file +export const GET: APIRoute = () => + new Response(null, { + status: 308, + headers: { Location: '/reference/api/apphost.md' }, + }); diff --git a/src/frontend/src/pages/reference/api/typescript/[module].md.ts b/src/frontend/src/pages/reference/api/typescript/[module].md.ts index 197417fc5..429ee6f67 100644 --- a/src/frontend/src/pages/reference/api/typescript/[module].md.ts +++ b/src/frontend/src/pages/reference/api/typescript/[module].md.ts @@ -1,34 +1,39 @@ import type { APIRoute } from 'astro'; -import { markdownResponse } from '@utils/api-markdown-shared'; -import { renderTypeScriptModuleMarkdown } from '@utils/typescript-api-markdown'; -import type { TsApiDocument } from '@utils/ts-modules'; -import { getTsModules, tsModuleSlug } from '@utils/ts-modules'; +import { appHostModuleSlug, getAppHostModules } from '@utils/apphost-modules'; +import { + getAppHostTypeScriptMarkdownTarget, + getAppHostTypeScriptRouteAliases, +} from '@utils/apphost-typescript-route-aliases'; export const prerender = true; -type RouteProps = { - pkg: TsApiDocument; -}; - -type StaticPath = { - params: { module: string }; - props: RouteProps; -}; - -export async function getStaticPaths(): Promise { - const packages = await getTsModules(); - - return packages.map((entry) => ({ - params: { module: tsModuleSlug(entry.data.package.name) }, +export async function getStaticPaths() { + const paths = (await getAppHostModules()).map((entry) => ({ + params: { module: appHostModuleSlug(entry.data.package.name) }, props: { - pkg: entry.data, + target: `/reference/api/apphost/${appHostModuleSlug(entry.data.package.name)}/`, }, })); + const routeKeys = new Set(paths.map((path) => path.params.module)); + for (const alias of getAppHostTypeScriptRouteAliases(1)) { + if (routeKeys.has(alias.source)) continue; + paths.push({ + params: { module: alias.source }, + props: { target: alias.target }, + }); + routeKeys.add(alias.source); + } + return paths; } export const GET: APIRoute = ({ props }) => { - const base = import.meta.env.BASE_URL.replace(/\/$/, ''); - const routeProps = props as RouteProps; - return markdownResponse(renderTypeScriptModuleMarkdown(routeProps.pkg, base)); + if (typeof props.target !== 'string') { + throw new TypeError('Missing TypeScript API Markdown redirect target.'); + } + + return new Response(null, { + status: 308, + headers: { Location: getAppHostTypeScriptMarkdownTarget(props.target) }, + }); }; diff --git a/src/frontend/src/pages/reference/api/typescript/[module]/[item].md.ts b/src/frontend/src/pages/reference/api/typescript/[module]/[item].md.ts index 94f9870f9..87e9f1a86 100644 --- a/src/frontend/src/pages/reference/api/typescript/[module]/[item].md.ts +++ b/src/frontend/src/pages/reference/api/typescript/[module]/[item].md.ts @@ -1,119 +1,59 @@ import type { APIRoute } from 'astro'; -import { markdownResponse } from '@utils/api-markdown-shared'; -import { renderTypeScriptItemMarkdown } from '@utils/typescript-api-markdown'; -import type { TsApiDocument, TsDtoType, TsEnumType, TsFunction, TsHandleType } from '@utils/ts-modules'; -import { getTsModules, tsModuleSlug, tsSlugify } from '@utils/ts-modules'; +import { getAppHostItemSlug, getAppHostTopLevelItems } from '@utils/apphost-api-routes'; +import { appHostModuleSlug, getAppHostModules, projectAppHostModule } from '@utils/apphost-modules'; +import { + getAppHostTypeScriptMarkdownTarget, + getAppHostTypeScriptRouteAliases, +} from '@utils/apphost-typescript-route-aliases'; +import { getTsItemSlug, getTsTopLevelRouteItems } from '@utils/ts-api-routes'; export const prerender = true; -type TypeScriptItemKind = 'handle' | 'dto' | 'enum' | 'function'; -type TypeScriptItem = TsHandleType | TsDtoType | TsEnumType | TsFunction; - -type RouteProps = { - item: TypeScriptItem; - itemKind: TypeScriptItemKind; - pkg: TsApiDocument; -}; - -type StaticPath = { - params: { item: string; module: string }; - props: RouteProps; -}; - -export async function getStaticPaths(): Promise { - const packages = await getTsModules(); - const paths: StaticPath[] = []; - - for (const entry of packages) { - const pkg = entry.data; - const pkgSlug = tsModuleSlug(pkg.package.name); - - for (const handle of pkg.handleTypes ?? []) { - const itemSlug = tsSlugify(handle.name); - if (!itemSlug) { - continue; - } - - paths.push({ - params: { - item: itemSlug, - module: pkgSlug, - }, - props: { - item: handle, - itemKind: 'handle', - pkg, - }, - }); - } - - for (const dto of pkg.dtoTypes ?? []) { - const itemSlug = tsSlugify(dto.name); - if (!itemSlug) { - continue; - } - - paths.push({ - params: { - item: itemSlug, - module: pkgSlug, - }, - props: { - item: dto, - itemKind: 'dto', - pkg, - }, - }); - } - - for (const enumType of pkg.enumTypes ?? []) { - const itemSlug = tsSlugify(enumType.name); - if (!itemSlug) { - continue; - } - - paths.push({ - params: { - item: itemSlug, - module: pkgSlug, - }, - props: { - item: enumType, - itemKind: 'enum', - pkg, - }, - }); - } - - for (const fn of (pkg.functions ?? []).filter((candidate) => !candidate.qualifiedName || !candidate.qualifiedName.includes('.'))) { - const itemSlug = tsSlugify(fn.name); - if (!itemSlug) { - continue; - } - +export async function getStaticPaths() { + const paths = []; + for (const entry of await getAppHostModules()) { + const sharedItems = getAppHostTopLevelItems(entry.data); + const tsDocument = projectAppHostModule(entry.data, 'typescript'); + const tsItems = getTsTopLevelRouteItems(tsDocument); + for (const tsItem of tsItems) { + const sharedItem = sharedItems.find((item) => item.id === tsItem.id); + if (!sharedItem) continue; paths.push({ params: { - item: itemSlug, - module: pkgSlug, + module: appHostModuleSlug(entry.data.package.name), + item: getTsItemSlug(tsItem, tsItems), }, props: { - item: fn, - itemKind: 'function', - pkg, + target: `/reference/api/apphost/${appHostModuleSlug(entry.data.package.name)}/${getAppHostItemSlug(sharedItem, sharedItems)}/`, }, }); } } - + const routeKeys = new Set( + paths.map((path) => `${path.params.module}/${path.params.item}`) + ); + for (const alias of getAppHostTypeScriptRouteAliases(2)) { + if (routeKeys.has(alias.source)) continue; + const [module, item] = alias.source.split('/'); + paths.push({ + params: { module, item }, + props: { target: alias.target }, + }); + routeKeys.add(alias.source); + } return paths; } export const GET: APIRoute = ({ props }) => { - const base = import.meta.env.BASE_URL.replace(/\/$/, ''); - const routeProps = props as RouteProps; + if (typeof props.target !== 'string') { + throw new TypeError('Missing TypeScript API Markdown redirect target.'); + } - return markdownResponse( - renderTypeScriptItemMarkdown(routeProps.pkg, routeProps.item, routeProps.itemKind, base) - ); + return new Response(null, { + status: 308, + headers: { + Location: getAppHostTypeScriptMarkdownTarget(props.target), + }, + }); }; diff --git a/src/frontend/src/pages/reference/api/typescript/[module]/[item]/[member].md.ts b/src/frontend/src/pages/reference/api/typescript/[module]/[item]/[member].md.ts index 579d239dd..22552de82 100644 --- a/src/frontend/src/pages/reference/api/typescript/[module]/[item]/[member].md.ts +++ b/src/frontend/src/pages/reference/api/typescript/[module]/[item]/[member].md.ts @@ -1,69 +1,71 @@ import type { APIRoute } from 'astro'; -import { markdownResponse } from '@utils/api-markdown-shared'; -import { renderTypeScriptMemberMarkdownPage } from '@utils/typescript-api-markdown'; -import type { TsApiDocument, TsFunction, TsHandleType } from '@utils/ts-modules'; -import { getTsModules, tsModuleSlug, tsSlugify } from '@utils/ts-modules'; +import { getAppHostItemSlug, getAppHostMemberSlug, getAppHostTopLevelItems } from '@utils/apphost-api-routes'; +import { + appHostModuleSlug, + getAppHostModules, + getCapabilitiesForHandle, + projectAppHostModule, +} from '@utils/apphost-modules'; +import { + getAppHostTypeScriptMarkdownTarget, + getAppHostTypeScriptRouteAliases, +} from '@utils/apphost-typescript-route-aliases'; +import { getTsItemSlug, getTsMethodSlug, getTsTopLevelRouteItems } from '@utils/ts-api-routes'; export const prerender = true; -type RouteProps = { - method: TsFunction; - parentType: TsHandleType; - pkg: TsApiDocument; -}; - -type StaticPath = { - params: { item: string; member: string; module: string }; - props: RouteProps; -}; - -export async function getStaticPaths(): Promise { - const packages = await getTsModules(); - const paths: StaticPath[] = []; - - for (const entry of packages) { - const pkg = entry.data; - const pkgSlug = tsModuleSlug(pkg.package.name); - - for (const handle of pkg.handleTypes ?? []) { - const itemSlug = tsSlugify(handle.name); - if (!itemSlug) { - continue; - } - - for (const method of (handle.capabilities ?? []).filter( - (capability) => capability.kind === 'Method' || capability.kind === 'InstanceMethod' - )) { - const memberSlug = tsSlugify(method.name); - if (!memberSlug) { - continue; - } - +export async function getStaticPaths() { + const paths = []; + for (const entry of await getAppHostModules()) { + const sharedItems = getAppHostTopLevelItems(entry.data); + const tsDocument = projectAppHostModule(entry.data, 'typescript'); + const tsItems = getTsTopLevelRouteItems(tsDocument); + for (const tsHandle of tsDocument.handleTypes) { + const sharedHandle = sharedItems.find((item) => item.id === tsHandle.id); + if (!sharedHandle) continue; + const sharedMembers = getCapabilitiesForHandle(entry.data, sharedHandle); + const tsMethods = tsHandle.capabilities ?? []; + for (const tsMethod of tsMethods) { + const sharedMember = sharedMembers.find((item) => item.id === tsMethod.id); + if (!sharedMember) continue; paths.push({ params: { - item: itemSlug, - member: memberSlug, - module: pkgSlug, + module: appHostModuleSlug(entry.data.package.name), + item: getTsItemSlug(tsHandle, tsItems), + member: getTsMethodSlug(tsMethod, tsMethods, tsHandle.name), }, props: { - method, - parentType: handle, - pkg, + target: `/reference/api/apphost/${appHostModuleSlug(entry.data.package.name)}/${getAppHostItemSlug(sharedHandle, sharedItems)}/${getAppHostMemberSlug(sharedMember, sharedMembers, sharedHandle.name)}/`, }, }); } } } - + const routeKeys = new Set( + paths.map((path) => `${path.params.module}/${path.params.item}/${path.params.member}`) + ); + for (const alias of getAppHostTypeScriptRouteAliases(3)) { + if (routeKeys.has(alias.source)) continue; + const [module, item, member] = alias.source.split('/'); + paths.push({ + params: { module, item, member }, + props: { target: alias.target }, + }); + routeKeys.add(alias.source); + } return paths; } export const GET: APIRoute = ({ props }) => { - const base = import.meta.env.BASE_URL.replace(/\/$/, ''); - const routeProps = props as RouteProps; + if (typeof props.target !== 'string') { + throw new TypeError('Missing TypeScript API Markdown redirect target.'); + } - return markdownResponse( - renderTypeScriptMemberMarkdownPage(routeProps.pkg, routeProps.parentType, routeProps.method, base) - ); + return new Response(null, { + status: 308, + headers: { + Location: getAppHostTypeScriptMarkdownTarget(props.target), + }, + }); }; diff --git a/src/frontend/src/pages/reference/api/typescript/[module]/[item]/[member]/index.astro b/src/frontend/src/pages/reference/api/typescript/[module]/[item]/[member]/index.astro index 74cac3c95..71002ab57 100644 --- a/src/frontend/src/pages/reference/api/typescript/[module]/[item]/[member]/index.astro +++ b/src/frontend/src/pages/reference/api/typescript/[module]/[item]/[member]/index.astro @@ -1,186 +1,63 @@ --- -/** - * /reference/api/typescript/[module]/[item]/[member]/ — Method detail page. - * - * Renders a single method (capability) of a handle type, showing its signature - * in the context of the parent interface, parameters, return type, and a link - * back to the parent type. - */ -import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; -import { Code } from '@astrojs/starlight/components'; -import { tsModuleSlug, getTsModules, simplifyType, formatTsSignature } from '@utils/ts-modules'; -import { getTsApiReferenceSidebar } from '@utils/ts-api-sidebar'; +import { getAppHostItemSlug, getAppHostMemberSlug, getAppHostTopLevelItems } from '@utils/apphost-api-routes'; +import { + appHostModuleSlug, + getAppHostModules, + getCapabilitiesForHandle, + projectAppHostModule, +} from '@utils/apphost-modules'; +import { + getAppHostTypeScriptHtmlTarget, + getAppHostTypeScriptRouteAliases, +} from '@utils/apphost-typescript-route-aliases'; import { getTsItemSlug, getTsMethodSlug, getTsTopLevelRouteItems } from '@utils/ts-api-routes'; -import Breadcrumb from '@components/Breadcrumb.astro'; -import TsMemberCard from '@components/api-reference/TsMemberCard.astro'; -export async function getStaticPaths() { - const packages = await getTsModules(); - const paths: any[] = []; - - for (const pkg of packages) { - const pkgSlug = tsModuleSlug(pkg.data.package.name); - const topLevelItems = getTsTopLevelRouteItems(pkg.data); - const handles = (pkg.data.handleTypes ?? []) as any[]; - - for (const handle of handles) { - const itemSlug = getTsItemSlug(handle, topLevelItems); - if (!itemSlug) continue; - - const methods = (handle.capabilities ?? []).filter( - (c: any) => c.kind === 'Method' || c.kind === 'InstanceMethod' - ); +export const prerender = true; - for (const method of methods) { - const memberSlug = getTsMethodSlug(method, methods, handle.name); - if (!memberSlug) continue; +export async function getStaticPaths() { + const paths = []; + for (const entry of await getAppHostModules()) { + const sharedItems = getAppHostTopLevelItems(entry.data); + const tsDocument = projectAppHostModule(entry.data, 'typescript'); + const tsItems = getTsTopLevelRouteItems(tsDocument); + for (const tsHandle of tsDocument.handleTypes) { + const sharedHandle = sharedItems.find((item) => item.id === tsHandle.id); + if (!sharedHandle) continue; + const sharedMembers = getCapabilitiesForHandle(entry.data, sharedHandle); + const tsMethods = tsHandle.capabilities ?? []; + for (const tsMethod of tsMethods) { + const sharedMember = sharedMembers.find((item) => item.id === tsMethod.id); + if (!sharedMember) continue; paths.push({ - params: { module: pkgSlug, item: itemSlug, member: memberSlug }, + params: { + module: appHostModuleSlug(entry.data.package.name), + item: getTsItemSlug(tsHandle, tsItems), + member: getTsMethodSlug(tsMethod, tsMethods, tsHandle.name), + }, props: { - pkg: pkg.data, - parentType: handle, - method, + target: `/reference/api/apphost/${appHostModuleSlug(entry.data.package.name)}/${getAppHostItemSlug(sharedHandle, sharedItems)}/${getAppHostMemberSlug(sharedMember, sharedMembers, sharedHandle.name)}/`, }, }); } } } - + const routeKeys = new Set( + paths.map((path) => `${path.params.module}/${path.params.item}/${path.params.member}`) + ); + for (const alias of getAppHostTypeScriptRouteAliases(3)) { + if (routeKeys.has(alias.source)) continue; + const [module, item, member] = alias.source.split('/'); + paths.push({ + params: { module, item, member }, + props: { target: alias.target }, + }); + routeKeys.add(alias.source); + } return paths; } -const { pkg, parentType, method } = Astro.props; -const base = import.meta.env.BASE_URL.replace(/\/$/, ''); -const tsSidebar = await getTsApiReferenceSidebar({ packageName: pkg.package.name }); -const pkgSlug = tsModuleSlug(pkg.package.name); -const topLevelItems = getTsTopLevelRouteItems(pkg); -const parentSlug = Astro.params.item ?? getTsItemSlug(parentType, topLevelItems); - -/** Simple string hash → 0–3 for parameter type color assignment. */ -function typeColorIndex(s: string): number { - let h = 0; - for (const c of s) h = ((h * 31) + c.charCodeAt(0)) | 0; - return Math.abs(h) % 4; -} - -/* ── Build hero declaration showing the method in its parent interface ── */ -const params = (method.parameters ?? []).map((p: any) => { - const opt = p.isOptional ? '?' : ''; - const type = p.isCallback && p.callbackSignature ? p.callbackSignature : p.type; - return `${p.name}${opt}: ${type}`; -}); -const memberSig = `${method.name}(${params.join(', ')}): ${method.returnType ?? 'void'};`; -const formattedMember = formatTsSignature(memberSig); -const indentedMember = formattedMember.split('\n').map((line: string) => ' ' + line).join('\n'); -const keyword = parentType.isInterface ? 'interface' : 'interface'; -const heroDeclaration = `${keyword} ${parentType.name} {\n // ... omitted for brevity\n${indentedMember}\n}`; - -/* ── Headings for right-side TOC ─────────────────────────────── */ -const headings: { depth: 2; slug: string; text: string }[] = [ - { depth: 2, slug: 'signature', text: 'Signature' }, -]; -if ((method.parameters ?? []).length > 0 || method.returnType) { - // Signature section already includes params & returns via TsMemberCard -} -headings.push({ depth: 2, slug: 'defined-on', text: 'Defined on' }); +return Astro.redirect( + getAppHostTypeScriptHtmlTarget(Astro.props.target), + 308 +); --- - - - - -
- {/* ── Hero ────────────────────────────────────────────────── */} -
-
-
- Method -
-
- - 📦 {pkg.package.name} {pkg.package.version && v{pkg.package.version}} - - {/* Declaration showing method in context of parent interface */} -
-
- -
-
-
- - {/* ── Signature section with full TsMemberCard ────────────── */} -
-
-

Signature

-
- -
-
- - {/* ── Defined on ─────────────────────────────────────────── */} -
-

Defined on

-

This method is defined on the following type:

- -
-
-
-
- - diff --git a/src/frontend/src/pages/reference/api/typescript/[module]/[item]/index.astro b/src/frontend/src/pages/reference/api/typescript/[module]/[item]/index.astro index b871eba66..b99cde765 100644 --- a/src/frontend/src/pages/reference/api/typescript/[module]/[item]/index.astro +++ b/src/frontend/src/pages/reference/api/typescript/[module]/[item]/index.astro @@ -1,805 +1,51 @@ --- -/** - * /reference/api/typescript/[module]/[item]/ — Individual type/item detail page. - * - * Renders detail for a handle type, DTO type, or enum type, organized by module - * (namespace), matching the C# type detail page pattern. - */ -import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; -import { Code } from '@astrojs/starlight/components'; -import { tsModuleSlug, getTsModules, simplifyType, formatTsSignature } from '@utils/ts-modules'; -import { getTsApiReferenceSidebar } from '@utils/ts-api-sidebar'; -import { getTsFunctionDisplayLabel, isTsExtensionStyleFunction } from '@utils/ts-api-function-kind'; -import { getTsItemSlug, getTsMemberAnchor, getTsMethodSlug, getTsStandaloneFunctions, getTsTopLevelRouteItems } from '@utils/ts-api-routes'; -import Breadcrumb from '@components/Breadcrumb.astro'; -import InpageSearch from '@components/api-reference/InpageSearch.astro'; -import TsMemberCard from '@components/api-reference/TsMemberCard.astro'; +import { getAppHostItemSlug, getAppHostTopLevelItems } from '@utils/apphost-api-routes'; +import { appHostModuleSlug, getAppHostModules, projectAppHostModule } from '@utils/apphost-modules'; +import { + getAppHostTypeScriptHtmlTarget, + getAppHostTypeScriptRouteAliases, +} from '@utils/apphost-typescript-route-aliases'; +import { getTsItemSlug, getTsTopLevelRouteItems } from '@utils/ts-api-routes'; -export async function getStaticPaths() { - const packages = await getTsModules(); - const paths: any[] = []; - - for (const pkg of packages) { - const pkgSlug = tsModuleSlug(pkg.data.package.name); - const topLevelItems = getTsTopLevelRouteItems(pkg.data); - - // Handle types - for (const handle of pkg.data.handleTypes ?? []) { - const slug = getTsItemSlug(handle, topLevelItems); - if (!slug) continue; - paths.push({ - params: { module: pkgSlug, item: slug }, - props: { pkg: pkg.data, item: handle, itemKind: 'handle' }, - }); - } +export const prerender = true; - // DTO types - for (const dto of pkg.data.dtoTypes ?? []) { - const slug = getTsItemSlug(dto, topLevelItems); - if (!slug) continue; - paths.push({ - params: { module: pkgSlug, item: slug }, - props: { pkg: pkg.data, item: dto, itemKind: 'dto' }, - }); - } - - // Enum types - for (const enumType of pkg.data.enumTypes ?? []) { - const slug = getTsItemSlug(enumType, topLevelItems); - if (!slug) continue; - paths.push({ - params: { module: pkgSlug, item: slug }, - props: { pkg: pkg.data, item: enumType, itemKind: 'enum' }, - }); - } - - // Standalone functions (no dot in qualifiedName) - for (const fn of getTsStandaloneFunctions(pkg.data)) { - const slug = getTsItemSlug(fn, topLevelItems); - if (!slug) continue; +export async function getStaticPaths() { + const paths = []; + for (const entry of await getAppHostModules()) { + const sharedItems = getAppHostTopLevelItems(entry.data); + const tsDocument = projectAppHostModule(entry.data, 'typescript'); + const tsItems = getTsTopLevelRouteItems(tsDocument); + for (const tsItem of tsItems) { + const sharedItem = sharedItems.find((item) => item.id === tsItem.id); + if (!sharedItem) continue; paths.push({ - params: { module: pkgSlug, item: slug }, - props: { pkg: pkg.data, item: fn, itemKind: 'function' }, + params: { + module: appHostModuleSlug(entry.data.package.name), + item: getTsItemSlug(tsItem, tsItems), + }, + props: { + target: `/reference/api/apphost/${appHostModuleSlug(entry.data.package.name)}/${getAppHostItemSlug(sharedItem, sharedItems)}/`, + }, }); } } - - return paths; -} - -const { pkg, item, itemKind } = Astro.props; -const base = import.meta.env.BASE_URL.replace(/\/$/, ''); -const tsSidebar = await getTsApiReferenceSidebar({ packageName: pkg.package.name }); -const pkgSlug = tsModuleSlug(pkg.package.name); -const topLevelItems = getTsTopLevelRouteItems(pkg); -const typeItems = [ - ...(pkg.handleTypes ?? []), - ...(pkg.dtoTypes ?? []), - ...(pkg.enumTypes ?? []), -]; -const currentItemSlug = Astro.params.item ?? getTsItemSlug(item, topLevelItems); - -/* ── Handle type: group capabilities into properties & methods ── */ -const capabilities = item.capabilities ?? []; -const getters = capabilities.filter((c: any) => c.kind === 'PropertyGetter'); -const setters = capabilities.filter((c: any) => c.kind === 'PropertySetter'); -const methods = capabilities.filter((c: any) => c.kind === 'Method' || c.kind === 'InstanceMethod'); - -/* ── Kind labels and CSS ─────────────────────────────────────── */ - -/** Simple string hash → 0–3 for parameter type color assignment. */ -function typeColorIndex(s: string): number { - let h = 0; - for (const c of s) h = ((h * 31) + c.charCodeAt(0)) | 0; - return Math.abs(h) % 4; -} - -const isExtensionStyleFunction = itemKind === 'function' && isTsExtensionStyleFunction(item); - -const kindLabel = itemKind === 'handle' ? (item.isInterface ? 'Interface' : 'Handle') - : itemKind === 'dto' ? 'Type' - : itemKind === 'function' ? getTsFunctionDisplayLabel(item) - : 'Enum'; - -const kindPillClass = itemKind === 'handle' - ? (item.isInterface ? 'kind-interface' : 'kind-handle') - : itemKind === 'dto' ? 'kind-struct' - : itemKind === 'function' ? 'kind-method' - : 'kind-enum'; - -/* ── Build TypeScript declaration for the Code block ──────────── */ -let typeDeclaration = ''; -if (itemKind === 'handle') { - const keyword = item.isInterface ? 'interface' : 'interface'; - const declMembers: string[] = []; - - // Properties - for (const g of getters) { - const hasSetter = setters.some((s: any) => s.name.replace(/^set/, '').toLowerCase() === g.name.toLowerCase()); - declMembers.push(` ${hasSetter ? '' : 'readonly '}${g.name}: ${g.returnType};`); - } - for (const s of setters) { - if (!getters.some((g: any) => g.name.toLowerCase() === s.name.replace(/^set/, '').toLowerCase())) { - declMembers.push(` ${s.name}(value: ${s.parameters?.[0]?.type ?? 'unknown'}): void;`); - } - } - - // Methods — fully expanded with parameter signatures - for (const m of methods.sort((a: any, b: any) => a.name.localeCompare(b.name))) { - const rawSig = `${m.name}(${(m.parameters ?? []).map((p: any) => { - const opt = p.isOptional ? '?' : ''; - const type = p.isCallback && p.callbackSignature ? p.callbackSignature : p.type; - return `${p.name}${opt}: ${type}`; - }).join(', ')}): ${m.returnType};`; - // Format with the shared formatter, then indent for interface body - const formatted = formatTsSignature(rawSig); - declMembers.push(formatted.split('\n').map(line => ' ' + line).join('\n')); - } - - const ifaces = (item.implementedInterfaces ?? []).map((i: string) => simplifyType(i)); - let extendsClause = ''; - if (ifaces.length === 1) { - extendsClause = ` extends ${ifaces[0]}`; - } else if (ifaces.length > 1) { - extendsClause = `\n extends ${ifaces.join(',\n ')}`; - } - const body = declMembers.length > 0 - ? ` {\n${declMembers.join('\n')}\n}` - : ' { }'; - typeDeclaration = `${keyword} ${item.name}${extendsClause}${body}`; -} else if (itemKind === 'dto') { - const fields = (item.fields ?? []).map((f: any) => - ` ${f.name}${f.isOptional ? '?' : ''}: ${f.type};` + const routeKeys = new Set( + paths.map((path) => `${path.params.module}/${path.params.item}`) ); - typeDeclaration = fields.length > 0 - ? `type ${item.name} = {\n${fields.join('\n')}\n}` - : `type ${item.name} = { }`; -} else if (itemKind === 'enum') { - const enumMembers = (item.members ?? []).map((m: string, i: number) => ` ${m} = ${i},`); - typeDeclaration = enumMembers.length > 0 - ? `enum ${item.name} {\n${enumMembers.join('\n')}\n}` - : `enum ${item.name} { }`; -} else if (itemKind === 'function') { - // Build the function signature showing it in the context of its target type - const params = (item.parameters ?? []).map((p: any) => { - const opt = p.isOptional ? '?' : ''; - return `${p.name}${opt}: ${p.type}`; - }); - const paramStr = params.join(', '); - const memberSig = `${item.name}(${paramStr}): ${item.returnType ?? 'void'};`; - const formattedMember = formatTsSignature(memberSig); - const indentedMember = formattedMember.split('\n').map((line: string) => ' ' + line).join('\n'); - - if ((item.expandedTargetTypes ?? []).length > 0) { - const targetName = simplifyType(item.expandedTargetTypes[0]); - typeDeclaration = `interface ${targetName} {\n // ... omitted for brevity\n${indentedMember}\n}`; - } else { - typeDeclaration = `function ${formatTsSignature(memberSig.replace(/;$/, ''))}`; + for (const alias of getAppHostTypeScriptRouteAliases(2)) { + if (routeKeys.has(alias.source)) continue; + const [module, item] = alias.source.split('/'); + paths.push({ + params: { module, item }, + props: { target: alias.target }, + }); + routeKeys.add(alias.source); } + return paths; } -/* ── Build type-scoped search index for complex types ─────────── */ -const memberCount = getters.length + setters.length + methods.length + - (itemKind === 'dto' ? (item.fields ?? []).length : 0) + - (itemKind === 'enum' ? (item.members ?? []).length : 0); -const hasSearchableMembers = memberCount >= 5; - -interface TypeIndexEntry { n: string; k: string; s: string; r?: string; a: string; } -const typeIndex: TypeIndexEntry[] = []; -if (itemKind === 'handle') { - for (const g of getters) typeIndex.push({ n: g.name, k: 'property', s: g.description ?? '', r: g.returnType, a: getTsMemberAnchor(g.name) }); - for (const m of methods) typeIndex.push({ n: m.name, k: 'method', s: m.description ?? '', r: m.returnType, a: getTsMethodSlug(m, methods, item.name) }); -} else if (itemKind === 'dto') { - for (const f of (item.fields ?? [])) typeIndex.push({ n: f.name, k: 'field', s: '', r: f.type, a: getTsMemberAnchor(f.name) }); -} else if (itemKind === 'enum') { - for (const m of (item.members ?? [])) typeIndex.push({ n: m, k: 'enum member', s: '', a: getTsMemberAnchor(m) }); -} -const typeSearchKinds = [...new Set(typeIndex.map((e: TypeIndexEntry) => e.k))].sort(); -const typeIndexJson = JSON.stringify(typeIndex); - -/* ── Headings for right-side TOC ─────────────────────────────── */ -const headings: { depth: 2; slug: string; text: string }[] = []; -if (itemKind === 'handle') { - if (getters.length > 0 || setters.length > 0) headings.push({ depth: 2, slug: 'properties', text: 'Properties' }); - if (methods.length > 0) headings.push({ depth: 2, slug: 'methods', text: 'Methods' }); -} else if (itemKind === 'dto') { - if ((item.fields ?? []).length > 0) headings.push({ depth: 2, slug: 'fields', text: 'Fields' }); -} else if (itemKind === 'enum') { - if ((item.members ?? []).length > 0) headings.push({ depth: 2, slug: 'values', text: 'Values' }); -} else if (itemKind === 'function') { - headings.push({ depth: 2, slug: 'signature', text: 'Signature' }); - if ((item.expandedTargetTypes ?? []).length > 0) headings.push({ depth: 2, slug: 'applies-to', text: 'Applies to' }); -} +return Astro.redirect( + getAppHostTypeScriptHtmlTarget(Astro.props.target), + 308 +); --- - - - - -
- {/* ── Type Hero (matches C# TypeHero pattern) ────────────────── */} -
-
-
- {kindLabel} -
-
- - 📦 {pkg.package.name} {pkg.package.version && v{pkg.package.version}} - - {/* Definition (signature) */} - {typeDeclaration && ( -
-
- -
-
- )} - - {/* Implements footer */} - {(item.implementedInterfaces ?? []).length > 0 && ( - - )} -
- - {/* ── Type-scoped search (for complex types) ──────────────── */} - {hasSearchableMembers && ( -
- -
- )} - - {/* ── Type content sections ───────────────────────────────── */} -
- - {/* ── Handle Type Detail ─────────────────────────────────── */} - {itemKind === 'handle' && ( - <> - {/* Properties (getters paired with matching setters) */} - {(getters.length > 0 || setters.length > 0) && ( -
-

Properties

-
- {getters.map((g: any) => { - const hasSetter = setters.some((s: any) => s.name.replace(/^set/, '').toLowerCase() === g.name.toLowerCase()); - return ( -
- -
- ); - })} - {setters.filter((s: any) => - !getters.some((g: any) => g.name.toLowerCase() === s.name.replace(/^set/, '').toLowerCase()) - ).map((s: any) => ( -
- -
- ))} -
-
- )} - - {/* Methods */} - {methods.length > 0 && ( -
-

Methods

-
- {methods.sort((a: any, b: any) => a.name.localeCompare(b.name)).map((m: any) => ( -
- -
- ))} -
-
- )} - - - )} - - {/* ── DTO Type Detail ────────────────────────────────────── */} - {itemKind === 'dto' && (item.fields ?? []).length > 0 && ( -
-

Fields

-
- {item.fields.map((f: any) => ( -
- -
- ))} -
-
- )} - - {/* ── Enum Type Detail ───────────────────────────────────── */} - {itemKind === 'enum' && (item.members ?? []).length > 0 && ( -
-

Values

-
- {item.members.map((m: string, i: number) => ( -
-
-
- {m} - {i} -
-
-
- ))} -
-
- )} - - {/* ── Function Detail ────────────────────────────────────── */} - {itemKind === 'function' && ( - <> - {/* Full function rendered as a member card with signature, params, return */} -
-

Signature

-
- -
-
- - {(item.expandedTargetTypes ?? []).length > 0 && ( -
-

Applies to

-

This {isExtensionStyleFunction ? 'method' : 'function'} applies to the following types:

-
- {item.expandedTargetTypes.map((t: string) => { - const simpleName = simplifyType(t); - const targetType = typeItems.find((candidate: any) => candidate.name === simpleName); - const typeSlug = targetType ? getTsItemSlug(targetType, topLevelItems) : simpleName.toLowerCase(); - return ( - -
- type - {simpleName} -
-
- ); - })} -
-
- )} - - )} -
{/* close type-content-sections */} -
{/* close not-content kind-tint */} -
- -{hasSearchableMembers && ( - -)} - -{hasSearchableMembers && ( - -)} - - diff --git a/src/frontend/src/pages/reference/api/typescript/[module]/index.astro b/src/frontend/src/pages/reference/api/typescript/[module]/index.astro index e9a39ba04..7bd56ac65 100644 --- a/src/frontend/src/pages/reference/api/typescript/[module]/index.astro +++ b/src/frontend/src/pages/reference/api/typescript/[module]/index.astro @@ -1,815 +1,33 @@ --- -/** - * /reference/api/typescript/[package]/ — TypeScript package landing page. - * - * Lists all types, standalone functions, and enums grouped by module (namespace). - * Includes in-page search scoped to this package's types and members, - * and collapsible module sections. - */ -import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; -import { tsModuleSlug, getTsModules, simplifyType } from '@utils/ts-modules'; -import { getTsApiReferenceSidebar } from '@utils/ts-api-sidebar'; +import { appHostModuleSlug, getAppHostModules } from '@utils/apphost-modules'; import { - getTsCallableIdentityKey, - getTsFunctionDisplayKind, - getTsFunctionDisplayLabel, -} from '@utils/ts-api-function-kind'; -import { getTsItemSlug, getTsMethodSlug, getTsStandaloneFunctions, getTsTopLevelRouteItems } from '@utils/ts-api-routes'; -import InpageSearch from '@components/api-reference/InpageSearch.astro'; -import Breadcrumb from '@components/Breadcrumb.astro'; + getAppHostTypeScriptHtmlTarget, + getAppHostTypeScriptRouteAliases, +} from '@utils/apphost-typescript-route-aliases'; + +export const prerender = true; export async function getStaticPaths() { - const packages = await getTsModules(); - return packages.map(pkg => ({ - params: { module: tsModuleSlug(pkg.data.package.name) }, - props: { pkg: pkg.data }, + const paths = (await getAppHostModules()).map((entry) => ({ + params: { module: appHostModuleSlug(entry.data.package.name) }, + props: { + target: `/reference/api/apphost/${appHostModuleSlug(entry.data.package.name)}/`, + }, })); -} - -const { pkg } = Astro.props; -const base = import.meta.env.BASE_URL.replace(/\/$/, ''); -const tsSidebar = await getTsApiReferenceSidebar({ packageName: pkg.package.name }); -const pkgSlug = tsModuleSlug(pkg.package.name); -const isOfficial = pkg.package.name.startsWith('Aspire.'); -const topLevelItems = getTsTopLevelRouteItems(pkg); - -/* ── Flatten all items into a single sorted list ─────────────── */ - -const allTypes = [ - ...(pkg.handleTypes ?? []).map((item: any) => ({ item, _typeKind: 'handle' as const })), - ...(pkg.dtoTypes ?? []).map((item: any) => ({ item, _typeKind: 'dto' as const })), -].sort((a: any, b: any) => a.item.name.localeCompare(b.item.name)); - -const allEnums = [...(pkg.enumTypes ?? [])].sort((a: any, b: any) => a.name.localeCompare(b.name)); - -const standaloneFunctions = getTsStandaloneFunctions(pkg) - .sort((a: any, b: any) => a.name.localeCompare(b.name)); - -/* ── Stats ───────────────────────────────────────────────────── */ -const totalFunctionCount = standaloneFunctions.length; -const totalTypeCount = allTypes.length + allEnums.length; - -/* ── Headings for right-side TOC ─────────────────────────────── */ -const headings: { depth: 2; slug: string; text: string }[] = []; -if (allTypes.length > 0) headings.push({ depth: 2, slug: 'types', text: 'Types' }); -if (standaloneFunctions.length > 0) headings.push({ depth: 2, slug: 'functions', text: 'Functions' }); -if (allEnums.length > 0) headings.push({ depth: 2, slug: 'enums', text: 'Enums' }); - -/* ── Build package-scoped search index ───────────────────────── */ - -/** Extract namespace from a fully-qualified name. */ -function nsOf(fullName: string): string { - const lastDot = fullName.lastIndexOf('.'); - return lastDot > 0 ? fullName.slice(0, lastDot) : fullName; -} - -/** Extract module from a capabilityId. */ -function modOf(capId: string): string { - const slashIdx = capId.indexOf('/'); - return slashIdx > 0 ? capId.slice(0, slashIdx) : capId; -} - -interface PkgIndexEntry { - /** Short name */ - n: string; - /** Fully-qualified name */ - f: string; - /** Namespace / module */ - ns: string; - /** Kind (handle, interface, dto, enum, function, method, property) */ - k: string; - /** Summary text (first ~160 chars) */ - s: string; - /** Parent type name (for members only) */ - t?: string; - /** Resolved destination for the search result */ - h?: string; -} - -const pkgIndex: PkgIndexEntry[] = []; -let memberCount = 0; -const standaloneFunctionKeys = new Set(standaloneFunctions.map((fn: any) => getTsCallableIdentityKey(fn))); - -function capabilityKindToSearchKind(capKind: string): string { - switch (capKind) { - case 'PropertyGetter': - case 'PropertySetter': - return 'property'; - case 'Method': - case 'InstanceMethod': - return 'method'; - default: - return capKind.toLowerCase(); - } -} - -// Handle types -for (const h of ((pkg.handleTypes ?? []) as any[])) { - const kind = h.isInterface ? 'interface' : 'handle'; - const typeHref = `${base}/reference/api/typescript/${pkgSlug}/${getTsItemSlug(h, topLevelItems)}/`; - const methods = (h.capabilities ?? []).filter( - (cap: any) => cap.kind === 'Method' || cap.kind === 'InstanceMethod' - ); - - pkgIndex.push({ - n: h.name, - f: h.fullName ?? h.name, - ns: nsOf(h.fullName ?? h.name), - k: kind, - s: (h.description ?? '').slice(0, 160), - h: typeHref, - }); - // Index members from capabilities - for (const cap of (h.capabilities ?? [])) { - if ((cap.kind === 'Method' || cap.kind === 'InstanceMethod') && standaloneFunctionKeys.has(getTsCallableIdentityKey(cap))) { - continue; - } - - memberCount++; - const memberName = cap.name ?? cap.capabilityId?.split('/').pop() ?? ''; - const memberHref = cap.kind === 'Method' || cap.kind === 'InstanceMethod' - ? `${typeHref}${getTsMethodSlug(cap, methods, h.name)}/` - : typeHref; - - pkgIndex.push({ - n: memberName, - f: `${h.fullName ?? h.name}.${memberName}`, - ns: nsOf(h.fullName ?? h.name), - k: capabilityKindToSearchKind(cap.kind ?? 'method'), - s: (cap.description ?? '').slice(0, 160), - t: h.name, - h: memberHref, + const routeKeys = new Set(paths.map((path) => path.params.module)); + for (const alias of getAppHostTypeScriptRouteAliases(1)) { + if (routeKeys.has(alias.source)) continue; + paths.push({ + params: { module: alias.source }, + props: { target: alias.target }, }); + routeKeys.add(alias.source); } + return paths; } -// DTO types -for (const d of ((pkg.dtoTypes ?? []) as any[])) { - pkgIndex.push({ - n: d.name, - f: d.fullName ?? d.name, - ns: nsOf(d.fullName ?? d.name), - k: 'type', - s: (d.description ?? '').slice(0, 160), - h: `${base}/reference/api/typescript/${pkgSlug}/${getTsItemSlug(d, topLevelItems)}/`, - }); -} - -// Enum types -for (const e of ((pkg.enumTypes ?? []) as any[])) { - pkgIndex.push({ - n: e.name, - f: e.fullName ?? e.name, - ns: nsOf(e.fullName ?? e.name), - k: 'enum', - s: (e.description ?? '').slice(0, 160), - h: `${base}/reference/api/typescript/${pkgSlug}/${getTsItemSlug(e, topLevelItems)}/`, - }); -} - -// Standalone functions -for (const f of (getTsStandaloneFunctions(pkg) as any[])) { - pkgIndex.push({ - n: f.name, - f: f.qualifiedName ?? f.name, - ns: modOf(f.capabilityId ?? f.name), - k: getTsFunctionDisplayKind(f), - s: (f.description ?? '').slice(0, 160), - h: `${base}/reference/api/typescript/${pkgSlug}/${getTsItemSlug(f, topLevelItems)}/`, - }); -} - -const allKinds = [...new Set(pkgIndex.map(e => e.k))].sort(); -const pkgIndexJson = JSON.stringify(pkgIndex); -const pkgName = pkg.package.name; +return Astro.redirect( + getAppHostTypeScriptHtmlTarget(Astro.props.target), + 308 +); --- - - - - -
- {isOfficial && ( -
- - Official -
- )} -
-
- 📦 {pkg.package.name} - {pkg.package.version && v{pkg.package.version}} -
-
-
-
-
- {totalFunctionCount} - Functions -
-
- {totalTypeCount} - Types -
-
-
-
- - -
- -
- - -
- {/* Types (handles + DTOs) */} - {allTypes.length > 0 && ( -
-

Types

- -
- )} - - {/* Standalone functions */} - {standaloneFunctions.length > 0 && ( -
-

Functions

- -
- )} - - {/* Enums */} - {allEnums.length > 0 && ( -
-

Enums

- -
- )} -
-
- - - - - - - diff --git a/src/frontend/src/pages/reference/api/typescript/index.astro b/src/frontend/src/pages/reference/api/typescript/index.astro index a4f85f9ab..e5415f922 100644 --- a/src/frontend/src/pages/reference/api/typescript/index.astro +++ b/src/frontend/src/pages/reference/api/typescript/index.astro @@ -1,510 +1,4 @@ --- -/** - * /reference/api/typescript/ — TypeScript API Reference landing page with search. - * - * Lists all TypeScript API modules with a client-side search bar that lets - * users filter across functions, handle members, handle types, types, and enums. - */ -import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; -import { tsModuleSlug, getTsModules, simplifyType } from '@utils/ts-modules'; -import { getTsApiReferenceSidebar } from '@utils/ts-api-sidebar'; -import { buildTsApiSearchIndex } from '@utils/ts-api-search'; -import { formatTsApiSearchStats, getTsApiSearchStats } from '@utils/ts-api-search-stats'; -import ApiSearchBar from '@components/api-reference/ApiSearchBar.astro'; -import Breadcrumb from '@components/Breadcrumb.astro'; - -const tsSidebar = await getTsApiReferenceSidebar(); -const packages = await getTsModules(); -const sorted = packages - .map(p => p.data) - .sort((a, b) => a.package.name.localeCompare(b.package.name)); - -const base = import.meta.env.BASE_URL.replace(/\/$/, ''); - -/* ── Collect unique versions for the filter ─────────────────────── */ -const allTsVersions = [...new Set(sorted.map(p => p.package.version).filter(Boolean))] as string[]; - -/* ── Build the search index ─────────────────────────────────────── */ -const index = buildTsApiSearchIndex(sorted, base); -const defaultStats = getTsApiSearchStats(index); - -const allKinds = [...new Set(index.map(e => e.k))].sort(); -const indexJson = JSON.stringify(index); +export const prerender = true; +return Astro.redirect('/reference/api/apphost/?aspire-lang=typescript', 308); --- - - - - -
-

- Browse the TypeScript APIs available when writing an - Aspire TypeScript AppHost. - These are the capabilities exposed by the Aspire hosting modules for use in apphost.ts. -

- - - - -
- {sorted.map(pkg => { - const funcCount = (pkg.functions ?? []).length; - const handleCount = (pkg.handleTypes ?? []).length; - const dtoCount = (pkg.dtoTypes ?? []).length; - const enumCount = (pkg.enumTypes ?? []).length; - const totalItems = funcCount + handleCount + dtoCount + enumCount; - return ( - -
- {pkg.package.name} -
- {pkg.package.version && ( - {pkg.package.version} - )} - {funcCount} functions - {handleCount + dtoCount + enumCount} types -
-
-
- ); - })} -
-
-
- - - - - - - diff --git a/src/frontend/src/utils/apphost-api-markdown.ts b/src/frontend/src/utils/apphost-api-markdown.ts new file mode 100644 index 000000000..1aa44d447 --- /dev/null +++ b/src/frontend/src/utils/apphost-api-markdown.ts @@ -0,0 +1,201 @@ +import { + bulletList, + codeBlock, + finalizeMarkdown, + inlineCode, + link, + normalizeBase, + section, +} from './api-markdown-shared'; +import { + type AppHostLanguage, + getGeneratedApiLanguages, +} from './apphost-languages'; +import type { + AppHostApiItem, + AppHostModuleDocument, +} from './apphost-modules'; +import { + appHostModuleSlug, + getCapabilitiesForHandle, +} from './apphost-modules'; +import { + getAppHostItemSlug, + getAppHostMemberSlug, + getAppHostTopLevelItems, +} from './apphost-api-routes'; + +export function appHostIndexMdHref(base: string): string { + return `${normalizeBase(base)}/reference/api/apphost.md`; +} + +export function appHostModuleMdHref(base: string, packageName: string): string { + return `${normalizeBase(base)}/reference/api/apphost/${appHostModuleSlug(packageName)}.md`; +} + +export function appHostItemMdHref( + base: string, + packageName: string, + itemSlug: string +): string { + return `${normalizeBase(base)}/reference/api/apphost/${appHostModuleSlug(packageName)}/${itemSlug}.md`; +} + +export function appHostMemberMdHref( + base: string, + packageName: string, + itemSlug: string, + memberSlug: string +): string { + return `${normalizeBase(base)}/reference/api/apphost/${appHostModuleSlug(packageName)}/${itemSlug}/${memberSlug}.md`; +} + +export function renderAppHostIndexMarkdown( + documents: AppHostModuleDocument[], + base: string +): string { + return finalizeMarkdown([ + '# AppHost API Reference', + 'Browse generated Aspire AppHost APIs by package. Each page includes every enabled language projection.', + section( + 'Packages', + bulletList( + [...documents] + .sort((left, right) => left.package.name.localeCompare(right.package.name)) + .map((document) => + `- ${link(document.package.name, appHostModuleMdHref(base, document.package.name))}${ + document.package.version ? ` — ${inlineCode(document.package.version)}` : '' + }` + ) + ) + ), + ]); +} + +export function renderAppHostModuleMarkdown( + document: AppHostModuleDocument, + base: string +): string { + const items = getAppHostTopLevelItems(document); + return finalizeMarkdown([ + `# ${document.package.name}`, + document.package.version ? `Version: ${inlineCode(document.package.version)}` : '', + section( + 'API items', + bulletList( + items.map((item) => + `- ${link( + item.name, + appHostItemMdHref(base, document.package.name, getAppHostItemSlug(item, items)) + )} — ${inlineCode(item.kind)}${item.description ? ` — ${item.description}` : ''}` + ) + ) + ), + ]); +} + +export function renderAppHostItemMarkdown( + document: AppHostModuleDocument, + item: AppHostApiItem, + base: string, + languages: readonly AppHostLanguage[] = getGeneratedApiLanguages() +): string { + const items = getAppHostTopLevelItems(document); + const itemSlug = getAppHostItemSlug(item, items); + const members = item.kind === 'handle' ? getCapabilitiesForHandle(document, item) : []; + + return finalizeMarkdown([ + `# ${item.name}`, + item.description ?? '', + ...renderLanguageProjections(item, languages), + members.length > 0 + ? section( + 'Members', + bulletList( + members.map((member) => + `- ${link( + member.name, + appHostMemberMdHref( + base, + document.package.name, + itemSlug, + getAppHostMemberSlug(member, members, item.name) + ) + )}` + ) + ) + ) + : '', + ]); +} + +export function renderAppHostMemberMarkdown( + document: AppHostModuleDocument, + handle: AppHostApiItem, + member: AppHostApiItem, + base: string, + languages: readonly AppHostLanguage[] = getGeneratedApiLanguages() +): string { + const items = getAppHostTopLevelItems(document); + return finalizeMarkdown([ + `# ${handle.name}.${member.name}`, + `Defined on ${link( + handle.name, + appHostItemMdHref(base, document.package.name, getAppHostItemSlug(handle, items)) + )}.`, + member.description ?? '', + ...renderLanguageProjections(member, languages), + ]); +} + +function renderLanguageProjections( + item: AppHostApiItem, + languages: readonly AppHostLanguage[] +): string[] { + return languages.map((language) => { + const projection = item.projections[language.id]; + if (!projection) { + return section(language.label, 'Unsupported: no projection was generated.'); + } + if (projection.status === 'unsupported') { + return section(language.label, `Unsupported: ${projection.reason ?? 'No reason provided.'}`); + } + + const declaration = projection.declaration ?? projection.signature ?? projection.identifier ?? ''; + const parameters = projection.parameters?.length + ? section( + 'Parameters', + bulletList( + projection.parameters.map((parameter) => + `- ${inlineCode(parameter.name)}: ${inlineCode( + parameter.isCallback ? parameter.callbackSignature ?? parameter.type : parameter.type + )}${parameter.isOptional ? ` ${inlineCode('optional')}` : ''}${ + parameter.defaultValue ? ` ${inlineCode(`default: ${parameter.defaultValue}`)}` : '' + }` + ) + ) + ) + : ''; + const returns = projection.return + ? section( + 'Returns', + `${inlineCode(projection.return.type)}${ + projection.return.errorModel && projection.return.errorModel !== 'none' + ? ` — errors: ${inlineCode(projection.return.errorModel)}` + : '' + }` + ) + : ''; + + return section( + language.label, + [ + projection.sourceFile ? `Source: ${inlineCode(projection.sourceFile)}` : '', + projection.reason ? `Limitation: ${projection.reason}` : '', + declaration ? codeBlock(declaration, language.codeFence) : '', + parameters, + returns, + ].filter(Boolean).join('\n\n') + ); + }); +} diff --git a/src/frontend/src/utils/apphost-api-routes.ts b/src/frontend/src/utils/apphost-api-routes.ts new file mode 100644 index 000000000..db6039364 --- /dev/null +++ b/src/frontend/src/utils/apphost-api-routes.ts @@ -0,0 +1,71 @@ +import type { + AppHostApiItem, + AppHostModuleDocument, +} from './apphost-modules'; +import { + appHostSlugify, + getSupportedProjection, +} from './apphost-modules'; + +export function getAppHostTopLevelItems(document: AppHostModuleDocument): AppHostApiItem[] { + return document.items.filter((item) => + item.kind !== 'capability' || !item.qualifiedName?.includes('.') + ); +} + +export function getAppHostItemSlug( + item: AppHostApiItem, + siblings: AppHostApiItem[] +): string { + return uniqueSlug(item, siblings); +} + +export function getAppHostMemberSlug( + item: AppHostApiItem, + siblings: AppHostApiItem[], + parentName?: string +): string { + return uniqueSlug(item, siblings, parentName); +} + +function uniqueSlug( + item: AppHostApiItem, + siblings: AppHostApiItem[], + parentName?: string +): string { + const identifier = stableIdentifier(item); + const base = appHostSlugify(identifier); + const conflicts = siblings.filter((candidate) => appHostSlugify(stableIdentifier(candidate)) === base); + if (conflicts.length <= 1) return base; + + const disambiguator = [ + parentName, + item.targetTypeId, + ...(item.parameters ?? []).map((parameter) => parameter.type), + ] + .filter(Boolean) + .map((value) => appHostSlugify(String(value))) + .filter(Boolean) + .join('-') || appHostSlugify(item.id); + + const candidate = `${base}-${disambiguator}`; + const same = conflicts.filter((entry) => { + const entryDisambiguator = [ + parentName, + entry.targetTypeId, + ...(entry.parameters ?? []).map((parameter) => parameter.type), + ] + .filter(Boolean) + .map((value) => appHostSlugify(String(value))) + .filter(Boolean) + .join('-') || appHostSlugify(entry.id); + return `${base}-${entryDisambiguator}` === candidate; + }); + + const index = same.findIndex((entry) => entry === item); + return index > 0 ? `${candidate}-${index + 1}` : candidate; +} + +function stableIdentifier(item: AppHostApiItem): string { + return getSupportedProjection(item, 'typescript')?.identifier ?? item.name; +} diff --git a/src/frontend/src/utils/apphost-api-search-stats.ts b/src/frontend/src/utils/apphost-api-search-stats.ts new file mode 100644 index 000000000..aea8f4915 --- /dev/null +++ b/src/frontend/src/utils/apphost-api-search-stats.ts @@ -0,0 +1,32 @@ +import type { AppHostApiSearchEntry } from './apphost-api-search'; +import type { AppHostLanguageId } from './apphost-languages'; + +export interface AppHostApiSearchStats { + packageCount: number; + capabilityCount: number; + typeCount: number; +} + +const capabilityKinds = new Set(['function', 'method', 'property']); + +export function getAppHostApiSearchStats( + index: ReadonlyArray, + language: AppHostLanguageId, + versions: ReadonlySet | null = null +): AppHostApiSearchStats { + const visibleEntries = index.filter( + (entry) => + entry.l === language && + (versions === null || (entry.v !== undefined && versions.has(entry.v))) + ); + + return { + packageCount: new Set(visibleEntries.map((entry) => entry.p)).size, + capabilityCount: visibleEntries.filter((entry) => capabilityKinds.has(entry.k)).length, + typeCount: visibleEntries.filter((entry) => !capabilityKinds.has(entry.k)).length, + }; +} + +export function formatAppHostApiSearchStats(stats: AppHostApiSearchStats): string { + return `${stats.capabilityCount.toLocaleString()} capabilities and ${stats.typeCount.toLocaleString()} types across ${stats.packageCount.toLocaleString()} modules`; +} diff --git a/src/frontend/src/utils/apphost-api-search.ts b/src/frontend/src/utils/apphost-api-search.ts new file mode 100644 index 000000000..5f0b656d0 --- /dev/null +++ b/src/frontend/src/utils/apphost-api-search.ts @@ -0,0 +1,102 @@ +import { + type AppHostLanguageId, + getGeneratedApiLanguages, +} from './apphost-languages'; +import { + type AppHostModuleDocument, + appHostModuleSlug, + getCapabilitiesForHandle, + getGeneratedProjectionLanguages, + getSupportedProjection, +} from './apphost-modules'; +import { + getAppHostItemSlug, + getAppHostMemberSlug, + getAppHostTopLevelItems, +} from './apphost-api-routes'; + +export interface AppHostApiSearchEntry { + n: string; + f: string; + k: string; + p: string; + s: string; + h: string; + l: string; + t?: string; + v?: string; + m?: boolean; +} + +export function buildAppHostApiSearchIndex( + documents: AppHostModuleDocument[], + base = '', + languages: readonly AppHostLanguageId[] = getGeneratedApiLanguages().map((language) => language.id) +): AppHostApiSearchEntry[] { + const normalizedBase = base.replace(/\/$/, ''); + const entries: AppHostApiSearchEntry[] = []; + + for (const document of documents) { + const moduleSlug = appHostModuleSlug(document.package.name); + const topLevelItems = getAppHostTopLevelItems(document); + const seen = new Set(); + + for (const item of topLevelItems) { + const itemSlug = getAppHostItemSlug(item, topLevelItems); + if (!itemSlug) continue; + + for (const language of getGeneratedProjectionLanguages(item, languages)) { + const projection = getSupportedProjection(item, language); + if (!projection?.identifier) continue; + seen.add(`${language}:${item.id}`); + entries.push({ + n: projection.identifier, + f: projection.signature ?? projection.declaration ?? projection.identifier, + k: item.kind === 'capability' + ? normalizeCapabilityKind(item.capabilityKind) + : item.kind === 'exportedValue' + ? 'value' + : item.kind, + p: document.package.name, + s: item.description ?? '', + h: `${normalizedBase}/reference/api/apphost/${moduleSlug}/${itemSlug}/?aspire-lang=${language}`, + l: language, + ...(document.package.version ? { v: document.package.version } : {}), + }); + } + } + + for (const handle of document.items.filter((item) => item.kind === 'handle')) { + const handleSlug = getAppHostItemSlug(handle, topLevelItems); + const members = getCapabilitiesForHandle(document, handle); + for (const member of members) { + const memberSlug = getAppHostMemberSlug(member, members, handle.name); + for (const language of getGeneratedProjectionLanguages(member, languages)) { + if (seen.has(`${language}:${member.id}`)) continue; + const projection = getSupportedProjection(member, language); + if (!projection?.identifier) continue; + entries.push({ + n: projection.identifier, + f: projection.signature ?? projection.declaration ?? projection.identifier, + k: normalizeCapabilityKind(member.capabilityKind), + p: document.package.name, + s: member.description ?? '', + h: `${normalizedBase}/reference/api/apphost/${moduleSlug}/${handleSlug}/${memberSlug}/?aspire-lang=${language}`, + l: language, + t: getSupportedProjection(handle, language)?.identifier ?? handle.name, + m: true, + ...(document.package.version ? { v: document.package.version } : {}), + }); + } + } + } + } + + return entries; +} + +function normalizeCapabilityKind(kind?: string): string { + if (kind === 'PropertyGetter' || kind === 'PropertySetter') return 'property'; + if (kind === 'Method' || kind === 'InstanceMethod') return 'method'; + return kind?.toLowerCase() || 'function'; +} diff --git a/src/frontend/src/utils/apphost-api-sidebar.ts b/src/frontend/src/utils/apphost-api-sidebar.ts new file mode 100644 index 000000000..bf8f16dfb --- /dev/null +++ b/src/frontend/src/utils/apphost-api-sidebar.ts @@ -0,0 +1,62 @@ +import { getGeneratedApiLanguages } from './apphost-languages'; +import { + type AppHostModuleDocument, + appHostModuleSlug, + getAppHostModules, + getSupportedProjection, +} from './apphost-modules'; +import { getAppHostItemSlug, getAppHostTopLevelItems } from './apphost-api-routes'; + +interface SidebarLinkItem { + label: string; + link: string; +} + +interface SidebarGroupItem { + label: string; + collapsed: boolean; + items: Array; +} + +type SidebarItem = SidebarLinkItem | SidebarGroupItem; + +function buildModuleEntry(document: AppHostModuleDocument, collapsed = true): SidebarGroupItem { + const moduleSlug = appHostModuleSlug(document.package.name); + const topLevelItems = getAppHostTopLevelItems(document); + const preferredLanguage = getGeneratedApiLanguages()[0]?.id ?? 'typescript'; + const items = topLevelItems + .sort((left, right) => left.name.localeCompare(right.name)) + .map((item) => ({ + label: getSupportedProjection(item, preferredLanguage)?.identifier ?? item.name, + link: `/reference/api/apphost/${moduleSlug}/${getAppHostItemSlug(item, topLevelItems)}/`, + })); + + return { + label: document.package.name, + collapsed, + items: [ + { label: 'Overview', link: `/reference/api/apphost/${moduleSlug}/` }, + ...items, + ], + }; +} + +export async function getAppHostApiSidebar(packageName?: string): Promise { + const documents = (await getAppHostModules()).map((entry) => entry.data); + const root: SidebarLinkItem = { + label: 'Search AppHost APIs', + link: '/reference/api/apphost/', + }; + + if (packageName) { + const current = documents.find((document) => document.package.name === packageName); + return current ? [root, buildModuleEntry(current, false)] : [root]; + } + + return [ + root, + ...documents + .sort((left, right) => left.package.name.localeCompare(right.package.name)) + .map((document) => buildModuleEntry(document)), + ]; +} diff --git a/src/frontend/src/utils/apphost-modules.ts b/src/frontend/src/utils/apphost-modules.ts new file mode 100644 index 000000000..af39b0918 --- /dev/null +++ b/src/frontend/src/utils/apphost-modules.ts @@ -0,0 +1,518 @@ +import type { CollectionEntry } from 'astro:content'; +import { getCollection } from 'astro:content'; + +import { + type AppHostLanguageId, + getAppHostLanguages, + getGeneratedApiLanguages, +} from './apphost-languages'; + +export type AppHostApiItemKind = + | 'capability' + | 'handle' + | 'dto' + | 'enum' + | 'exportedValue'; + +export type AppHostProjectionValidation = + | 'source-derived' + | 'upstream-test-validated' + | 'sdk-output-validated'; + +export interface AppHostApiParameter { + name: string; + type: string; + isOptional?: boolean; + isNullable?: boolean; + defaultValue?: string; + isCallback?: boolean; + callbackSignature?: string; + description?: string; +} + +export interface AppHostApiField { + name: string; + type: string; + isOptional?: boolean; + isNullable?: boolean; + description?: string; +} + +export interface AppHostApiEnumMember { + name: string; + value?: string | number; + description?: string; +} + +export interface AppHostApiProjection { + status: 'supported' | 'unsupported'; + validation: AppHostProjectionValidation; + reason?: string; + identifier?: string; + signature?: string; + declaration?: string; + sourceFile?: string; + kind?: 'interface' | 'class' | 'handle'; + parameters?: AppHostApiParameter[]; + return?: { + type: string; + errorModel?: 'exception' | 'result' | 'deferred' | 'none'; + }; + fields?: AppHostApiField[]; + members?: AppHostApiEnumMember[]; + implementedInterfaces?: string[]; + valueExpression?: string; +} + +export interface AppHostApiItem { + id: string; + kind: AppHostApiItemKind; + name: string; + fullName?: string; + capabilityId?: string; + qualifiedName?: string; + capabilityKind?: string; + description?: string; + remarks?: string; + returns?: string; + targetTypeId?: string; + expandedTargetTypes?: string[]; + returnsBuilder?: boolean; + parameters?: AppHostApiParameter[]; + returnType?: string; + fields?: AppHostApiField[]; + members?: AppHostApiEnumMember[]; + pathSegments?: string[]; + value?: unknown; + isInterface?: boolean; + exposeProperties?: boolean; + exposeMethods?: boolean; + implementedInterfaces?: string[]; + baseTypeHierarchy?: string[]; + projections: Partial>; +} + +export interface AppHostModulePackage { + name: string; + version?: string; + sourceRepository?: string; + sourceCommit?: string; +} + +export interface AppHostModuleDocument { + schemaVersion: string; + generatorProvenance: { + repository: string; + commit: string; + lockFile: string; + }; + dumpProvenance?: { + cliVersion?: string; + productCommit?: string; + generatedAt?: string; + }; + package: AppHostModulePackage; + items: AppHostApiItem[]; +} + +export interface AppHostCapabilityLanguageResolution { + csharpMemberName: string; + itemIds: string[]; + languages: Partial>; +} + +export type AppHostPackageLanguageStatus = + | 'supported' + | 'limited' + | 'unsupported' + | 'missing'; + +export interface AppHostPackageLanguageResolution { + packageName: string; + packageVersion?: string; + languages: Partial>; +} + +export interface ProjectedFunction { + id: string; + name: string; + kind?: string; + qualifiedName?: string; + capabilityId?: string; + targetTypeId?: string; + signature?: string; + description?: string; + remarks?: string; + returns?: string; + parameters?: AppHostApiParameter[]; + returnType?: string; + returnsBuilder?: boolean; + expandedTargetTypes?: string[]; + sourceFile?: string; + errorModel?: 'exception' | 'result' | 'deferred' | 'none'; +} + +export interface ProjectedNamedItem { + id: string; + name: string; + fullName?: string; + kind?: string; + description?: string; + remarks?: string; + declaration?: string; + sourceFile?: string; +} + +export interface ProjectedHandleType extends ProjectedNamedItem { + kind?: 'handle'; + isInterface?: boolean; + exposeProperties?: boolean; + exposeMethods?: boolean; + implementedInterfaces?: string[]; + baseTypeHierarchy?: string[]; + capabilities?: ProjectedFunction[]; +} + +export interface ProjectedDtoType extends ProjectedNamedItem { + kind?: 'dto'; + fields?: AppHostApiField[]; +} + +export interface ProjectedEnumType extends ProjectedNamedItem { + kind?: 'enum'; + members?: string[]; +} + +export interface ProjectedExportedValue extends ProjectedNamedItem { + kind?: 'exportedValue'; + pathSegments?: string[]; + type?: string; + valueExpression?: string; +} + +export interface ProjectedAppHostModule { + package: AppHostModulePackage & { + language: AppHostLanguageId; + }; + functions: ProjectedFunction[]; + handleTypes: ProjectedHandleType[]; + dtoTypes: ProjectedDtoType[]; + enumTypes: ProjectedEnumType[]; + exportedValues: ProjectedExportedValue[]; +} + +export type AppHostModuleCollectionEntry = Omit, 'data'> & { + data: AppHostModuleDocument; +}; + +let modulesPromise: Promise | undefined; +const shouldCacheModules = import.meta.env.PROD; + +export function getAppHostModules(): Promise { + if (!shouldCacheModules) { + return getCollection('apphostModules'); + } + + modulesPromise ??= getCollection('apphostModules') as Promise; + return modulesPromise; +} + +export function getSupportedProjection( + item: AppHostApiItem, + language: AppHostLanguageId +): AppHostApiProjection | undefined { + const projection = item.projections[language]; + return projection?.status === 'supported' ? projection : undefined; +} + +export function getGeneratedProjectionLanguages( + item: AppHostApiItem, + languages: readonly AppHostLanguageId[] = getGeneratedApiLanguages().map((language) => language.id) +): AppHostLanguageId[] { + return languages + .filter((language) => getSupportedProjection(item, language)); +} + +export function projectAppHostModule( + document: AppHostModuleDocument, + language: AppHostLanguageId +): ProjectedAppHostModule { + const capabilityItems = document.items.filter((item) => item.kind === 'capability'); + const functions = capabilityItems.flatMap((item) => { + const projection = getSupportedProjection(item, language); + if (!projection?.identifier) { + return []; + } + + return [{ + id: item.id, + name: projection.identifier, + kind: item.capabilityKind, + qualifiedName: item.qualifiedName, + capabilityId: item.capabilityId, + targetTypeId: item.targetTypeId, + signature: projection.signature ?? projection.declaration, + description: item.description, + remarks: item.remarks, + returns: item.returns, + parameters: projection.parameters ?? [], + returnType: projection.return?.type ?? 'void', + returnsBuilder: item.returnsBuilder, + expandedTargetTypes: item.expandedTargetTypes ?? [], + sourceFile: projection.sourceFile, + errorModel: projection.return?.errorModel, + }]; + }); + + const capabilitiesByTarget = new Map(); + for (const fn of functions) { + for (const target of [fn.targetTypeId, ...(fn.expandedTargetTypes ?? [])]) { + if (!target) continue; + const key = normalizeTypeIdentity(target); + const entries = capabilitiesByTarget.get(key) ?? []; + if (!entries.some((candidate) => candidate.id === fn.id)) { + entries.push(fn); + } + capabilitiesByTarget.set(key, entries); + } + } + + const handleTypes = document.items.flatMap((item): ProjectedHandleType[] => { + if (item.kind !== 'handle') return []; + const projection = getSupportedProjection(item, language); + if (!projection?.identifier) return []; + const typeIdentity = normalizeTypeIdentity(item.fullName ?? item.id); + return [{ + id: item.id, + name: projection.identifier, + fullName: item.fullName, + kind: 'handle', + isInterface: projection.kind === 'interface' || item.isInterface, + exposeProperties: item.exposeProperties, + exposeMethods: item.exposeMethods, + description: item.description, + remarks: item.remarks, + declaration: projection.declaration, + sourceFile: projection.sourceFile, + implementedInterfaces: projection.implementedInterfaces ?? item.implementedInterfaces ?? [], + baseTypeHierarchy: item.baseTypeHierarchy ?? [], + capabilities: capabilitiesByTarget.get(typeIdentity) ?? [], + }]; + }); + + const dtoTypes = document.items.flatMap((item): ProjectedDtoType[] => { + if (item.kind !== 'dto') return []; + const projection = getSupportedProjection(item, language); + if (!projection?.identifier) return []; + return [{ + id: item.id, + name: projection.identifier, + fullName: item.fullName, + kind: 'dto', + description: item.description, + remarks: item.remarks, + declaration: projection.declaration, + sourceFile: projection.sourceFile, + fields: projection.fields ?? item.fields ?? [], + }]; + }); + + const enumTypes = document.items.flatMap((item): ProjectedEnumType[] => { + if (item.kind !== 'enum') return []; + const projection = getSupportedProjection(item, language); + if (!projection?.identifier) return []; + return [{ + id: item.id, + name: projection.identifier, + fullName: item.fullName, + kind: 'enum', + description: item.description, + remarks: item.remarks, + declaration: projection.declaration, + sourceFile: projection.sourceFile, + members: (projection.members ?? item.members ?? []).map((member) => member.name), + }]; + }); + + const exportedValues = document.items.flatMap((item): ProjectedExportedValue[] => { + if (item.kind !== 'exportedValue') return []; + const projection = getSupportedProjection(item, language); + if (!projection?.identifier) return []; + return [{ + id: item.id, + name: projection.identifier, + fullName: item.fullName, + kind: 'exportedValue', + description: item.description, + remarks: item.remarks, + declaration: projection.declaration, + sourceFile: projection.sourceFile, + pathSegments: item.pathSegments, + type: projection.return?.type, + valueExpression: projection.valueExpression, + }]; + }); + + return { + package: { ...document.package, language }, + functions, + handleTypes, + dtoTypes, + enumTypes, + exportedValues, + }; +} + +export function normalizeTypeIdentity(typeId: string): string { + const slashIndex = typeId.indexOf('/'); + return slashIndex >= 0 ? typeId.slice(slashIndex + 1) : typeId; +} + +export function getCapabilitiesForHandle( + document: AppHostModuleDocument, + handle: AppHostApiItem +): AppHostApiItem[] { + const handleIdentity = normalizeTypeIdentity(handle.fullName ?? handle.id); + return document.items.filter((item) => { + if (item.kind !== 'capability') return false; + return [item.targetTypeId, ...(item.expandedTargetTypes ?? [])] + .filter((value): value is string => Boolean(value)) + .some((value) => normalizeTypeIdentity(value) === handleIdentity); + }); +} + +export function resolveAppHostCapabilityLanguageSupport( + document: AppHostModuleDocument, + csharpMemberName: string +): AppHostCapabilityLanguageResolution | undefined { + const names = csharpCapabilityNameCandidates(csharpMemberName); + const capabilities = document.items.filter((item) => item.kind === 'capability'); + let matches = capabilities.filter((item) => { + const capabilityName = item.capabilityId?.split('/').at(-1); + return [item.name, capabilityName] + .filter((value): value is string => Boolean(value)) + .some((value) => value.toLowerCase() === names.exact); + }); + if (matches.length === 0 && names.withoutAsync !== names.exact) { + matches = capabilities.filter((item) => { + const capabilityName = item.capabilityId?.split('/').at(-1); + return [item.name, capabilityName] + .filter((value): value is string => Boolean(value)) + .some((value) => value.toLowerCase() === names.withoutAsync); + }); + } + if (matches.length === 0) return undefined; + + const languages: AppHostCapabilityLanguageResolution['languages'] = {}; + for (const language of getAppHostLanguages().filter((candidate) => candidate.generatedApi)) { + const projections = matches + .map((item) => item.projections[language.id]) + .filter((projection): projection is AppHostApiProjection => Boolean(projection)); + const projection = + projections.find((candidate) => candidate.status === 'supported') ?? + projections[0]; + if (!projection) continue; + languages[language.id] = { + status: projection.status, + identifier: projection.identifier, + validation: projection.validation, + reason: projection.reason, + }; + } + + return { + csharpMemberName, + itemIds: matches.map((item) => item.id), + languages, + }; +} + +export function resolveAppHostPackageLanguageSupport( + document: AppHostModuleDocument +): AppHostPackageLanguageResolution { + const languages: AppHostPackageLanguageResolution['languages'] = {}; + for (const language of getAppHostLanguages().filter((candidate) => candidate.generatedApi)) { + const projections = document.items + .map((item) => item.projections[language.id]) + .filter((projection): projection is AppHostApiProjection => Boolean(projection)); + const supportedItems = projections.filter((projection) => projection.status === 'supported').length; + const totalItems = document.items.length; + const reasons = [...new Set( + projections + .map((projection) => projection.reason) + .filter((reason): reason is string => Boolean(reason)) + )]; + const status: AppHostPackageLanguageStatus = + projections.length < totalItems + ? 'missing' + : supportedItems === 0 + ? 'unsupported' + : supportedItems < totalItems + ? 'limited' + : 'supported'; + languages[language.id] = { status, supportedItems, totalItems, reasons }; + } + + return { + packageName: document.package.name, + packageVersion: document.package.version, + languages, + }; +} + +export async function getAppHostPackageLanguageSupport( + packageName: string +): Promise { + const module = (await getAppHostModules()) + .find((entry) => entry.data.package.name === packageName); + return module ? resolveAppHostPackageLanguageSupport(module.data) : undefined; +} + +export async function getAppHostCapabilityLanguageSupport( + packageName: string, + csharpMemberName: string +): Promise { + const module = (await getAppHostModules()) + .find((entry) => entry.data.package.name === packageName); + return module + ? resolveAppHostCapabilityLanguageSupport(module.data, csharpMemberName) + : undefined; +} + +function csharpCapabilityNameCandidates(name: string): { + exact: string; + withoutAsync: string; +} { + const normalized = name.trim(); + const lowerCamel = normalized.length > 0 + ? normalized[0].toLowerCase() + normalized.slice(1) + : normalized; + const withoutAsync = lowerCamel.endsWith('Async') + ? lowerCamel.slice(0, -'Async'.length) + : lowerCamel; + return { + exact: lowerCamel.toLowerCase(), + withoutAsync: withoutAsync.toLowerCase(), + }; +} + +export function appHostModuleSlug(name: string): string { + return name.toLowerCase(); +} + +export function appHostSlugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} diff --git a/src/frontend/src/utils/apphost-typescript-route-aliases.ts b/src/frontend/src/utils/apphost-typescript-route-aliases.ts new file mode 100644 index 000000000..4cb60ef69 --- /dev/null +++ b/src/frontend/src/utils/apphost-typescript-route-aliases.ts @@ -0,0 +1,24 @@ +import aliasManifest from '../data/apphost-typescript-route-aliases.json'; + +export interface AppHostTypeScriptRouteAlias { + source: string; + target: string; + semanticItemId: string; +} + +export function getAppHostTypeScriptRouteAliases( + segmentCount: number +): AppHostTypeScriptRouteAlias[] { + return aliasManifest.aliases.filter( + (alias) => alias.source.split('/').length === segmentCount + ); +} + +export function getAppHostTypeScriptHtmlTarget(target: string): string { + const separator = target.includes('?') ? '&' : '?'; + return `${target}${separator}aspire-lang=typescript`; +} + +export function getAppHostTypeScriptMarkdownTarget(target: string): string { + return `${target.replace(/\/$/, '')}.md`; +} diff --git a/src/frontend/src/utils/ts-modules.ts b/src/frontend/src/utils/ts-modules.ts index de0704a76..dead60dcc 100644 --- a/src/frontend/src/utils/ts-modules.ts +++ b/src/frontend/src/utils/ts-modules.ts @@ -1,123 +1,44 @@ -/* ------------------------------------------------------------------ */ -/* Shared helpers for the auto-generated TypeScript API reference. */ -/* ------------------------------------------------------------------ */ - import type { CollectionEntry } from 'astro:content'; -import { getCollection } from 'astro:content'; - -export interface TsFunctionParameter { - name: string; - type?: string; - callbackSignature?: string; - isCallback?: boolean; - isOptional?: boolean; - defaultValue?: string; -} - -export interface TsFunction { - name: string; - kind?: string; - qualifiedName?: string; - capabilityId?: string; - targetTypeId?: string; - callbackSignature?: string; - type?: string; - signature?: string; - description?: string; - parameters?: TsFunctionParameter[]; - returnType?: string; - returnsBuilder?: boolean; - expandedTargetTypes?: string[]; -} - -export interface TsNamedItem { - name: string; - fullName?: string; - kind?: string; - isInterface?: boolean; - description?: string; -} - -export interface TsField { - name: string; - type?: string; - isOptional?: boolean; - description?: string; -} -export interface TsHandleType extends TsNamedItem { - kind?: 'handle'; - exposeProperties?: boolean; - implementedInterfaces?: string[]; - baseTypeHierarchy?: string[]; - capabilities?: TsFunction[]; -} - -export interface TsDtoType extends TsNamedItem { - kind?: 'dto'; - fields?: TsField[]; -} - -export interface TsEnumType extends TsNamedItem { - kind?: 'enum'; - members?: string[]; -} - -export interface TsModulePackage { - name: string; - version?: string; - language?: string; - sourceRepository?: string; - sourceCommit?: string; -} - -export interface TsApiDocument { - package: TsModulePackage; - functions?: TsFunction[]; - handleTypes?: TsHandleType[]; - dtoTypes?: TsDtoType[]; - enumTypes?: TsEnumType[]; -} +import { + type AppHostApiField as TsField, + type AppHostApiParameter as TsFunctionParameter, + type ProjectedAppHostModule as TsApiDocument, + type ProjectedDtoType as TsDtoType, + type ProjectedEnumType as TsEnumType, + type ProjectedFunction as TsFunction, + type ProjectedHandleType as TsHandleType, + type ProjectedNamedItem as TsNamedItem, + appHostModuleSlug as tsModuleSlug, + appHostSlugify as tsSlugify, + getAppHostModules, + projectAppHostModule, +} from './apphost-modules'; + +export type { + TsApiDocument, + TsDtoType, + TsEnumType, + TsField, + TsFunction, + TsFunctionParameter, + TsHandleType, + TsNamedItem, +}; +export { tsModuleSlug, tsSlugify }; -export type TsModuleCollectionEntry = Omit, 'data'> & { +export type TsModuleCollectionEntry = Omit, 'data'> & { data: TsApiDocument; }; -let tsModulesPromise: Promise | undefined; -const shouldCacheTsModules = import.meta.env.PROD; - -/** - * Fetch all TypeScript module entries from the content collection. - * Memoized so Astro's many API routes reuse a single collection load. - */ -export function getTsModules(): Promise { - if (!shouldCacheTsModules) { - return getCollection('tsModules'); - } - - tsModulesPromise ??= getCollection('tsModules'); - return tsModulesPromise; +export async function getTsModules(): Promise { + const modules = await getAppHostModules(); + return modules.map((entry) => ({ + ...entry, + data: projectAppHostModule(entry.data, 'typescript'), + })); } -/** Normalize a module name for use in a URL path segment. */ -export function tsModuleSlug(name: string): string { - return name.toLowerCase(); -} - -/** - * Slugify a type or function name for URL use. - * Converts PascalCase/camelCase to lowercase. - */ -export function tsSlugify(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, ''); -} - -/* ---- Capability kind helpers ---------------------------------------- */ - -/** Ordered list of capability kinds for consistent display. */ export const capabilityKindOrder = [ 'Method', 'InstanceMethod', @@ -132,8 +53,6 @@ export const capabilityKindLabels: Record = { PropertySetter: 'Property Setters', }; -/* ---- Type kind helpers ---------------------------------------------- */ - export const typeKindOrder = ['handle', 'dto', 'enum'] as const; export const typeKindLabels: Record = { @@ -142,165 +61,83 @@ export const typeKindLabels: Record = { enum: 'Enums', }; -/* ---- Grouping helpers ----------------------------------------------- */ - -/** - * Group functions by their capability kind, maintaining a meaningful order. - */ export function groupFunctionsByKind(functions: TsFunction[]): Map { const groups = new Map(); for (const kind of capabilityKindOrder) { - const matching = functions.filter((f) => f.kind === kind); + const matching = functions.filter((fn) => fn.kind === kind); if (matching.length > 0) { - groups.set( - kind, - matching.sort((a, b) => a.name.localeCompare(b.name)) - ); + groups.set(kind, matching.sort((left, right) => left.name.localeCompare(right.name))); } } return groups; } -/** - * Group top-level functions by their target handle type for display. - * Returns a map from handle type display name to the functions that target it. - */ export function groupFunctionsByTarget(functions: TsFunction[]): Map { const groups = new Map(); - for (const func of functions) { - const targetId = func.targetTypeId; - if (!targetId) continue; - - // Extract simple name from "Assembly/Full.Type.Name" - const slashIdx = targetId.indexOf('/'); - const fullName = slashIdx >= 0 ? targetId.slice(slashIdx + 1) : targetId; - const simpleName = fullName.split('.').pop() ?? fullName; - - const existing = groups.get(simpleName) ?? []; - existing.push(func); - groups.set(simpleName, existing); + for (const fn of functions) { + if (!fn.targetTypeId) continue; + const target = simplifyType(fn.targetTypeId); + const entries = groups.get(target) ?? []; + entries.push(fn); + groups.set(target, entries); } - - // Sort functions within each group - for (const [, funcs] of groups) { - funcs.sort((a, b) => a.name.localeCompare(b.name)); - } - return groups; } -/* ---- Link helpers --------------------------------------------------- */ - -/** - * Build an absolute href for a TypeScript API module page. - */ export function tsModuleHref(base: string, moduleName: string): string { - const b = base.replace(/\/$/, ''); - return `${b}/reference/api/typescript/${tsModuleSlug(moduleName)}/`; + return `${base.replace(/\/$/, '')}/reference/api/apphost/${tsModuleSlug(moduleName)}/`; } -/** - * Build an absolute href for a TypeScript API type/function page. - */ export function tsItemHref(base: string, moduleName: string, itemName: string): string { - const b = base.replace(/\/$/, ''); - return `${b}/reference/api/typescript/${tsModuleSlug(moduleName)}/${tsSlugify(itemName)}/`; + return `${tsModuleHref(base, moduleName)}${tsSlugify(itemName)}/`; } -/* ---- Signature formatting ------------------------------------------- */ - -/** - * Format a TypeScript function signature for display. - * If the signature has 2+ parameters, each param is placed on its own line - * with 4-space indentation — matching the C# formatting convention. - */ -export function formatTsSignature(sig: string): string { - if (!sig) return sig; - - const openIdx = sig.indexOf('('); - if (openIdx < 0) return sig; - - // Find the matching close paren (respecting nesting) +export function formatTsSignature(signature: string): string { + if (!signature) return signature; + const open = signature.indexOf('('); + if (open < 0) return signature; let depth = 0; - let closeIdx = -1; - for (let i = openIdx; i < sig.length; i++) { - if (sig[i] === '(' || sig[i] === '<') depth++; - else if (sig[i] === ')' || sig[i] === '>') depth--; - if (sig[i] === ')' && depth === 0) { - closeIdx = i; + let close = -1; + for (let index = open; index < signature.length; index++) { + if (signature[index] === '(' || signature[index] === '<') depth++; + if (signature[index] === ')' || signature[index] === '>') depth--; + if (signature[index] === ')' && depth === 0) { + close = index; break; } } - if (closeIdx <= openIdx) return sig; - - const prefix = sig.slice(0, openIdx + 1); - const suffix = sig.slice(closeIdx); - const paramStr = sig.slice(openIdx + 1, closeIdx); + if (close <= open) return signature; - // Split params respecting nested parens/angles (for callback types) const params: string[] = []; let current = ''; depth = 0; - for (const ch of paramStr) { - if (ch === '(' || ch === '<') depth++; - else if (ch === ')' || ch === '>') depth--; - if (ch === ',' && depth === 0) { + for (const character of signature.slice(open + 1, close)) { + if (character === '(' || character === '<') depth++; + if (character === ')' || character === '>') depth--; + if (character === ',' && depth === 0) { params.push(current.trim()); current = ''; } else { - current += ch; + current += character; } } if (current.trim()) params.push(current.trim()); - - // Single param or empty — keep inline - if (params.length <= 1) return sig; - - // Multi-param — wrap each on its own line - const indent = ' '; - return ( - prefix + - '\n' + - params - .map((p, i) => { - const sep = i < params.length - 1 ? ',' : ''; - return indent + p + sep; - }) - .join('\n') + - suffix - ); + if (params.length <= 1) return signature; + return `${signature.slice(0, open + 1)}\n${params + .map((parameter, index) => ` ${parameter}${index < params.length - 1 ? ',' : ''}`) + .join('\n')}${signature.slice(close)}`; } -/** - * Simplify a fully-qualified type reference for display. - * Strips assembly prefixes, assembly-qualified generic metadata, and extracts simple names. - */ export function simplifyType(typeRef: string): string { - // Strip "Assembly/" prefix - const slashIdx = typeRef.indexOf('/'); - let stripped = slashIdx >= 0 ? typeRef.slice(slashIdx + 1) : typeRef; - - // Clean assembly metadata from generic type arguments: - // System.IEquatable`1[[TypeName, Assembly, Version=..., ...]] → System.IEquatable`1[[TypeName]] - stripped = stripped.replace(/\[\[([^\],]+),\s*[^\]]*\]\]/g, '[[$1]]'); - - // For generic types with angle brackets, simplify the outer name only - if (stripped.includes('<')) { - const angleIdx = stripped.indexOf('<'); - const prefix = stripped.slice(0, angleIdx); - const suffix = stripped.slice(angleIdx); - return (prefix.split('.').pop() ?? prefix) + suffix; + const afterSlash = typeRef.includes('/') ? typeRef.slice(typeRef.lastIndexOf('/') + 1) : typeRef; + const genericIndex = afterSlash.indexOf('<'); + if (genericIndex >= 0) { + const prefix = afterSlash.slice(0, genericIndex); + return `${prefix.split('.').pop() ?? prefix}${afterSlash.slice(genericIndex)}`; } - - return stripped.split('.').pop() ?? stripped; + return afterSlash.split('.').pop() ?? afterSlash; } -/** - * Format a callback parameter for display. - */ -export function formatCallbackParam(param: TsFunctionParameter): string { - if (param.callbackSignature) { - return param.callbackSignature; - } - return param.type ?? ''; +export function formatCallbackParam(parameter: TsFunctionParameter): string { + return parameter.callbackSignature ?? parameter.type; } diff --git a/src/frontend/tests/e2e/api-markdown-routes.spec.ts b/src/frontend/tests/e2e/api-markdown-routes.spec.ts index b933a0c15..b1492985c 100644 --- a/src/frontend/tests/e2e/api-markdown-routes.spec.ts +++ b/src/frontend/tests/e2e/api-markdown-routes.spec.ts @@ -22,39 +22,39 @@ const markdownRoutes = [ path: '/reference/api/csharp/communitytoolkit.aspire.hosting.activemq/activemqartemisserverresource/constructors.md', }, { - expectedText: '# TypeScript API Reference', - name: 'TypeScript API index', - path: '/reference/api/typescript.md', + expectedText: '# AppHost API Reference', + name: 'AppHost API index', + path: '/reference/api/apphost.md', }, { expectedText: '# Aspire.Hosting', - name: 'TypeScript module route', - path: '/reference/api/typescript/aspire.hosting.md', + name: 'AppHost module route', + path: '/reference/api/apphost/aspire.hosting.md', }, { expectedText: '# IDistributedApplicationBuilder', - name: 'TypeScript handle route', - path: '/reference/api/typescript/aspire.hosting/idistributedapplicationbuilder.md', + name: 'AppHost handle route', + path: '/reference/api/apphost/aspire.hosting/idistributedapplicationbuilder.md', }, { expectedText: '# CommandOptions', - name: 'TypeScript DTO route', - path: '/reference/api/typescript/aspire.hosting/commandoptions.md', + name: 'AppHost DTO route', + path: '/reference/api/apphost/aspire.hosting/commandoptions.md', }, { expectedText: '# CertificateTrustScope', - name: 'TypeScript enum route', - path: '/reference/api/typescript/aspire.hosting/certificatetrustscope.md', + name: 'AppHost enum route', + path: '/reference/api/apphost/aspire.hosting/certificatetrustscope.md', }, { expectedText: '# addConnectionString', - name: 'TypeScript function route', - path: '/reference/api/typescript/aspire.hosting/addconnectionstring.md', + name: 'AppHost function route', + path: '/reference/api/apphost/aspire.hosting/addconnectionstring.md', }, { expectedText: '# IDistributedApplicationBuilder.addConnectionString', - name: 'TypeScript member route', - path: '/reference/api/typescript/aspire.hosting/idistributedapplicationbuilder/addconnectionstring.md', + name: 'AppHost member route', + path: '/reference/api/apphost/aspire.hosting/idistributedapplicationbuilder/addconnectionstring.md', }, { // Samples reuse the same shared `markdownResponse` helper as the API @@ -85,4 +85,38 @@ for (const route of markdownRoutes) { expect(body).toContain(route.expectedText); expect(body).not.toContain(''); }); -} \ No newline at end of file +} + +test('legacy TypeScript markdown routes preserve the exact canonical target', async ({ request }) => { + const legacyPath = '/reference/api/typescript/aspire.hosting.md'; + const canonicalPath = '/reference/api/apphost/aspire.hosting.md'; + const response = await request.get(legacyPath, { + maxRedirects: 0, + }); + + if (response.status() === 308) { + // Astro dev executes the endpoint and preserves its permanent redirect. + expect(response.headers().location).toBe(canonicalPath); + return; + } + + // Astro preview serves prerendered redirects as static files, so the + // endpoint status and Location header become a 200 redirect document. + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toContain('text/markdown'); + + const body = await response.text(); + + expect(body).toContain(``); + expect(body).toContain( + `` + ); + expect(body).toContain(``); + expect(body).not.toContain('# Aspire.Hosting'); + + const canonicalResponse = await request.get(canonicalPath); + + expect(canonicalResponse.ok(), `${canonicalPath} should return 200.`).toBe(true); + expect(canonicalResponse.headers()['content-type']).toContain('text/markdown'); + expect(await canonicalResponse.text()).toContain('# Aspire.Hosting'); +}); diff --git a/src/frontend/tests/e2e/og-metadata.spec.ts b/src/frontend/tests/e2e/og-metadata.spec.ts index 067761d46..d780d349d 100644 --- a/src/frontend/tests/e2e/og-metadata.spec.ts +++ b/src/frontend/tests/e2e/og-metadata.spec.ts @@ -175,7 +175,7 @@ test('falls back to the site-wide image for generated API reference pages', asyn request, baseURL, }) => { - const response = await request.get('/reference/api/typescript/aspire.hosting.redis/'); + const response = await request.get('/reference/api/apphost/aspire.hosting.redis/'); expect(response.ok(), 'API reference page should return 200').toBe(true); const html = await response.text(); @@ -185,7 +185,7 @@ test('falls back to the site-wide image for generated API reference pages', asyn 'i' ) ); - expect(html).not.toMatch(/\/og\/reference\/api\/typescript\//i); + expect(html).not.toMatch(/\/og\/reference\/api\/apphost\//i); const imageResponse = await request.get(new URL('/og-image.png', baseURL).toString()); expect(imageResponse.ok(), 'the fallback /og-image.png must exist').toBe(true); diff --git a/src/frontend/tests/e2e/site-search.spec.ts b/src/frontend/tests/e2e/site-search.spec.ts index 14c5450c3..9d7f183ca 100644 --- a/src/frontend/tests/e2e/site-search.spec.ts +++ b/src/frontend/tests/e2e/site-search.spec.ts @@ -118,7 +118,7 @@ test.describe('site search dialog', () => { await expect(input).toHaveAttribute('aria-expanded', 'false'); }); - test('arrow nav also cycles through the C#/TypeScript API buttons', async ({ page }) => { + test('arrow nav also cycles through the C# and generated AppHost API buttons', async ({ page }) => { await page.goto('/'); await dismissCookieConsentIfVisible(page); @@ -129,29 +129,28 @@ test.describe('site search dialog', () => { const dialog = page.locator('site-search dialog[open]'); const results = dialog.locator('.pagefind-ui__result-link'); - const csharpBtn = dialog.locator('a[data-api-search-link][data-api-lang="csharp"]'); - const tsBtn = dialog.locator('a[data-api-search-link][data-api-lang="typescript"]'); + const apiButtons = dialog.locator('a[data-api-search-link]'); + const csharpBtn = apiButtons.filter({ has: page.getByText('C# API Reference', { exact: true }) }); + const lastApiButton = apiButtons.last(); await expect(results.first()).toBeVisible({ timeout: 15000 }); await expect(csharpBtn).toBeVisible(); // Press End to jump to the last keyboard target — that should be the - // TypeScript API button (last item in DOM order: results → load-more - // → C# button → TS button). Home/End are deliberately ignored while + // final generated-language API button. Home/End are deliberately ignored while // the caret is in the input (they keep native text-editing behaviour // there), so we ArrowDown into the results first to move focus out // of the search field. const input = dialog.locator('input.pagefind-ui__search-input'); await input.press('ArrowDown'); await page.keyboard.press('End'); - await expect(tsBtn).toHaveAttribute('data-search-active', 'true'); - await expect(tsBtn).toBeFocused(); + await expect(lastApiButton).toHaveAttribute('data-search-active', 'true'); + await expect(lastApiButton).toBeFocused(); - // ArrowUp from TS button should land on the C# button. + // ArrowUp from the last generated language should land on the preceding API button. await page.keyboard.press('ArrowUp'); - await expect(csharpBtn).toHaveAttribute('data-search-active', 'true'); - await expect(csharpBtn).toBeFocused(); - await expect(tsBtn).not.toHaveAttribute('data-search-active', 'true'); + await expect(apiButtons.nth((await apiButtons.count()) - 2)).toBeFocused(); + await expect(lastApiButton).not.toHaveAttribute('data-search-active', 'true'); // Press Home to jump back to the very first result; the API buttons // release the active marker. @@ -180,7 +179,7 @@ test.describe('site search dialog', () => { await expect(results.first()).toBeVisible({ timeout: 15000 }); // Pressing Home/End while focus is in the input must NOT move the - // active marker to the C#/TypeScript API buttons (or any other + // active marker to the dedicated API buttons (or any other // out-of-listbox target). This is the regression that previously // stole the caret away from the search field whenever the user // tried to jump to the start/end of their query text. @@ -197,7 +196,7 @@ test.describe('site search dialog', () => { await expect(tsBtn).not.toBeFocused(); }); - test('typed query is forwarded to the C# and TypeScript API buttons', async ({ page }) => { + test('typed query is forwarded to C# and generated AppHost API buttons', async ({ page }) => { await page.goto('/'); await dismissCookieConsentIfVisible(page); @@ -211,28 +210,34 @@ test.describe('site search dialog', () => { await expect(csharpBtn).toBeVisible(); await expect(tsBtn).toBeVisible(); - // Pre-typing: hrefs should be the bare landing pages. + // Pre-typing: hrefs should preserve each landing page's language selection. await expect(csharpBtn).toHaveAttribute('href', /\/reference\/api\/csharp\/$/); - await expect(tsBtn).toHaveAttribute('href', /\/reference\/api\/typescript\/$/); + await expect(tsBtn).toHaveAttribute( + 'href', + /\/reference\/api\/apphost\/\?aspire-lang=typescript$/ + ); // Use a query the TS API search index actually contains so the hand-off // can be verified end-to-end. await typeSearchQuery(page, 'withBun'); await expect(csharpBtn).toHaveAttribute('href', /\/reference\/api\/csharp\/\?q=withBun$/); - await expect(tsBtn).toHaveAttribute('href', /\/reference\/api\/typescript\/\?q=withBun$/); + await expect(tsBtn).toHaveAttribute( + 'href', + /\/reference\/api\/apphost\/\?aspire-lang=typescript&q=withBun$/ + ); - // Click the TypeScript button — it should land on the TS API landing + // Click the TypeScript button — it should land on the canonical AppHost API landing // page with the query pre-applied via InpageSearchSync. await tsBtn.click(); - await page.waitForURL(/\/reference\/api\/typescript\/\?q=withBun/); + await page.waitForURL(/\/reference\/api\/apphost\/\?aspire-lang=typescript&q=withBun/); await dismissCookieConsentIfVisible(page); - const tsInput = page.locator('#ts-api-search-input'); + const tsInput = page.locator('#apphost-api-search-input'); await expect(tsInput).toHaveValue('withBun'); - const tsResults = page.locator('#ts-api-search-results .api-search-result'); + const tsResults = page.locator('#apphost-api-search-results .api-search-result'); await expect(tsResults.first()).toBeVisible({ timeout: 10000 }); }); @@ -252,6 +257,9 @@ test.describe('site search dialog', () => { await typeSearchQuery(page, ''); await expect(csharpBtn).toHaveAttribute('href', /\/reference\/api\/csharp\/$/); - await expect(tsBtn).toHaveAttribute('href', /\/reference\/api\/typescript\/$/); + await expect(tsBtn).toHaveAttribute( + 'href', + /\/reference\/api\/apphost\/\?aspire-lang=typescript$/ + ); }); }); diff --git a/src/frontend/tests/e2e/ts-api-search.spec.ts b/src/frontend/tests/e2e/ts-api-search.spec.ts index d0a8cd552..d1f2cc19f 100644 --- a/src/frontend/tests/e2e/ts-api-search.spec.ts +++ b/src/frontend/tests/e2e/ts-api-search.spec.ts @@ -1,13 +1,13 @@ import { expect, test } from '@playwright/test'; import { dismissCookieConsentIfVisible, isNarrowViewport } from '@tests/e2e/helpers'; -test('TypeScript API search keeps result names visible on narrow viewports', async ({ page }) => { +test('AppHost API search keeps result names visible on narrow viewports', async ({ page }) => { test.skip(!isNarrowViewport(page), 'This regression only applies to narrow/mobile viewports.'); - await page.goto('/reference/api/typescript/?q=withBun'); + await page.goto('/reference/api/apphost/?q=withBun&aspire-lang=typescript'); await dismissCookieConsentIfVisible(page); - const results = page.locator('#ts-api-search-results .api-search-result'); + const results = page.locator('#apphost-api-search-results .api-search-result'); await expect(results.first()).toBeVisible(); const firstResult = results.first(); @@ -29,4 +29,42 @@ test('TypeScript API search keeps result names visible on narrow viewports', asy return Math.round(box?.width ?? 0); }) .toBeGreaterThan(20); -}); \ No newline at end of file +}); + +test('AppHost API corrects unsupported stored languages after listeners initialize', async ({ + page, +}) => { + await page.addInitScript(() => { + localStorage.setItem('aspire-lang', 'csharp'); + localStorage.setItem('starlight-synced-tabs__aspire-lang', 'C#'); + }); + + await page.goto('/reference/api/apphost/?aspire-lang=csharp'); + await dismissCookieConsentIfVisible(page); + + await expect(page.locator('html')).toHaveAttribute('data-apphost-lang', 'typescript'); + await expect + .poll(() => new URL(page.url()).searchParams.get('aspire-lang')) + .toBe('typescript'); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('aspire-lang'))) + .toBe('typescript'); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('starlight-synced-tabs__aspire-lang'))) + .toBe('TypeScript'); + + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-apphost-lang', 'typescript'); + await expect + .poll(() => new URL(page.url()).searchParams.get('aspire-lang')) + .toBe('typescript'); + + await page.goto('/reference/api/apphost/aspire.hosting/'); + await expect(page.locator('html')).toHaveAttribute('data-apphost-lang', 'typescript'); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('aspire-lang'))) + .toBe('typescript'); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('starlight-synced-tabs__aspire-lang'))) + .toBe('TypeScript'); +}); diff --git a/src/frontend/tests/unit/api-markdown.vitest.test.ts b/src/frontend/tests/unit/api-markdown.vitest.test.ts index 159015258..b28bb08f3 100644 --- a/src/frontend/tests/unit/api-markdown.vitest.test.ts +++ b/src/frontend/tests/unit/api-markdown.vitest.test.ts @@ -13,33 +13,32 @@ import { renderCSharpTypeMarkdown, } from '@utils/csharp-api-markdown'; import { memberKindSlugs, resolveMemberAnchors } from '@utils/packages'; -import { tsSlugify } from '@utils/ts-modules'; import { renderTypeScriptItemMarkdown, renderTypeScriptModuleMarkdown } from '@utils/typescript-api-markdown'; import type { TsApiDocument, TsHandleType } from '@utils/ts-modules'; vi.mock('astro:content', async (importOriginal) => { const actual = await importOriginal(); const csharpPackageModules = import.meta.glob<{ default: any }>('../../src/data/pkgs/Aspire.Hosting.*.json'); - const typeScriptModuleModules = import.meta.glob<{ default: any }>('../../src/data/ts-modules/Aspire.Hosting.*.json'); + const appHostModuleModules = import.meta.glob<{ default: any }>('../../src/data/apphost-modules/Aspire.Hosting.*.json'); const rootHostingPackagePattern = /\/(Aspire\.Hosting\.\d[^/]*\.json)$/; const getRootHostingIds = (modules: Record Promise<{ default: any }>>) => Object.keys(modules) .map((path) => path.match(rootHostingPackagePattern)?.[1]) .filter((id): id is string => Boolean(id)); const csharpPackageIds = getRootHostingIds(csharpPackageModules); - const typeScriptModuleIds = new Set(getRootHostingIds(typeScriptModuleModules)); + const appHostModuleIds = new Set(getRootHostingIds(appHostModuleModules)); const commonPackageIds = csharpPackageIds - .filter((id) => typeScriptModuleIds.has(id)) + .filter((id) => appHostModuleIds.has(id)) .sort((left, right) => left.localeCompare(right, undefined, { numeric: true })); const fixtureId = commonPackageIds[commonPackageIds.length - 1]; if (!fixtureId) { - throw new Error('Expected matching Aspire.Hosting package and TypeScript module fixtures.'); + throw new Error('Expected matching Aspire.Hosting package and AppHost module fixtures.'); } - const [{ default: csharpPackageFixture }, { default: typeScriptModuleFixture }] = await Promise.all([ + const [{ default: csharpPackageFixture }, { default: appHostModuleFixture }] = await Promise.all([ csharpPackageModules[`../../src/data/pkgs/${fixtureId}`](), - typeScriptModuleModules[`../../src/data/ts-modules/${fixtureId}`](), + appHostModuleModules[`../../src/data/apphost-modules/${fixtureId}`](), ]); return { @@ -51,8 +50,8 @@ vi.mock('astro:content', async (importOriginal) => { return [{ id: fixtureId, data: csharpPackageFixture }]; } - if (collectionName === 'tsModules') { - return [{ id: fixtureId, data: typeScriptModuleFixture }]; + if (collectionName === 'apphostModules') { + return [{ id: fixtureId, data: appHostModuleFixture }]; } return await actual.getCollection(collectionName); @@ -78,10 +77,12 @@ const csharpIndexRoute = getRouteModule('../../src/pages/reference/api/csharp.md const csharpPackageRoute = getRouteModule('../../src/pages/reference/api/csharp/[package].md.ts'); const csharpTypeRoute = getRouteModule('../../src/pages/reference/api/csharp/[package]/[type].md.ts'); const csharpMemberKindRoute = getRouteModule('../../src/pages/reference/api/csharp/[package]/[type]/[memberKind].md.ts'); +const appHostIndexRoute = getRouteModule('../../src/pages/reference/api/apphost.md.ts'); +const appHostModuleRoute = getRouteModule('../../src/pages/reference/api/apphost/[module].md.ts'); +const appHostItemRoute = getRouteModule('../../src/pages/reference/api/apphost/[module]/[item].md.ts'); +const appHostMemberRoute = getRouteModule('../../src/pages/reference/api/apphost/[module]/[item]/[member].md.ts'); const typeScriptIndexRoute = getRouteModule('../../src/pages/reference/api/typescript.md.ts'); const typeScriptModuleRoute = getRouteModule('../../src/pages/reference/api/typescript/[module].md.ts'); -const typeScriptItemRoute = getRouteModule('../../src/pages/reference/api/typescript/[module]/[item].md.ts'); -const typeScriptMemberRoute = getRouteModule('../../src/pages/reference/api/typescript/[module]/[item]/[member].md.ts'); describe('API markdown routes', () => { it('returns markdown for the C# API index route', async () => { @@ -135,103 +136,65 @@ describe('API markdown routes', () => { expect(markdown).toContain('```csharp'); }); - it('returns markdown for the TypeScript API index route', async () => { - const markdown = await readMarkdown(typeScriptIndexRoute.GET?.({} as any)); + it('returns markdown for the canonical AppHost API index route', async () => { + const markdown = await readMarkdown(appHostIndexRoute.GET?.({} as any)); - expect(markdown).toContain('# TypeScript API Reference'); - expect(markdown).toMatch(/\/reference\/api\/typescript\/[^)\s]+\.md/); - expect(markdown).not.toContain('·'); - expect(markdown).not.toContain('—'); + expect(markdown).toContain('# AppHost API Reference'); + expect(markdown).toMatch(/\/reference\/api\/apphost\/[^)\s]+\.md/); }); - it('returns markdown for a TypeScript module route', async () => { - const route = await findStaticRoute( - typeScriptModuleRoute.getStaticPaths, - () => true, - 'TypeScript module route' - ); - const markdown = await readMarkdown(typeScriptModuleRoute.GET?.({ props: route.props } as any)); + it('returns markdown for a canonical AppHost module route', async () => { + const route = await findStaticRoute(appHostModuleRoute.getStaticPaths, () => true, 'AppHost module route'); + const markdown = await readMarkdown(appHostModuleRoute.GET?.({ props: route.props } as any)); - expect(markdown).toContain(`# ${route.props.pkg.package.name}`); - expect(markdown).toMatch(new RegExp(`/reference/api/typescript/${route.params.module}/[^)\\s]+\\.md`)); + expect(markdown).toContain(`# ${route.props.document.package.name}`); + expect(markdown).toMatch(new RegExp(`/reference/api/apphost/${route.params.module}/[^)\\s]+\\.md`)); }); - it('returns markdown for a TypeScript handle route', async () => { - const route = await findStaticRoute( - typeScriptItemRoute.getStaticPaths, - (candidate) => - candidate.props.itemKind === 'handle' && - candidate.props.item.capabilities?.some( - (capability: any) => capability.kind === 'Method' || capability.kind === 'InstanceMethod' - ), - 'TypeScript handle route' - ); - const method = route.props.item.capabilities.find( - (capability: any) => capability.kind === 'Method' || capability.kind === 'InstanceMethod' - ); - const markdown = await readMarkdown(typeScriptItemRoute.GET?.({ props: route.props } as any)); + it('renders enabled projections for an AppHost item route', async () => { + const route = await findStaticRoute(appHostItemRoute.getStaticPaths, () => true, 'AppHost item route'); + const markdown = await readMarkdown(appHostItemRoute.GET?.({ props: route.props } as any)); expect(markdown).toContain(`# ${route.props.item.name}`); - expect(markdown).toContain('## Methods'); - expect(markdown).toContain( - `/reference/api/typescript/${route.params.module}/${route.params.item}/${tsSlugify(method.name)}.md` - ); + expect(markdown).toContain('## TypeScript'); + expect(markdown).not.toContain('## Python'); + expect(markdown).not.toContain('## Go'); + expect(markdown).not.toContain('## Java'); + expect(markdown).not.toContain('## Rust'); }); - it('returns markdown for a TypeScript DTO route', async () => { - const route = await findStaticRoute( - typeScriptItemRoute.getStaticPaths, - (candidate) => - candidate.props.itemKind === 'dto' && - (candidate.props.item.fields?.length ?? 0) > 0, - 'TypeScript DTO route' - ); - const markdown = await readMarkdown(typeScriptItemRoute.GET?.({ props: route.props } as any)); + it('returns markdown for a canonical AppHost member route', async () => { + const route = await findStaticRoute(appHostMemberRoute.getStaticPaths, () => true, 'AppHost member route'); + const markdown = await readMarkdown(appHostMemberRoute.GET?.({ props: route.props } as any)); - expect(markdown).toContain(`# ${route.props.item.name}`); - expect(markdown).toContain('## Fields'); - }); - - it('returns markdown for a TypeScript enum route', async () => { - const route = await findStaticRoute( - typeScriptItemRoute.getStaticPaths, - (candidate) => - candidate.props.itemKind === 'enum' && - (candidate.props.item.members?.length ?? 0) > 0, - 'TypeScript enum route' + expect(markdown).toContain(`# ${route.props.handle.name}.${route.props.member.name}`); + expect(markdown).toContain('## TypeScript'); + expect(markdown).toContain( + `/reference/api/apphost/${route.params.module}/${route.params.item}.md` ); - const markdown = await readMarkdown(typeScriptItemRoute.GET?.({ props: route.props } as any)); - - expect(markdown).toContain(`# ${route.props.item.name}`); - expect(markdown).toContain('## Values'); }); - it('returns markdown for a TypeScript function route', async () => { - const route = await findStaticRoute( - typeScriptItemRoute.getStaticPaths, - (candidate) => - candidate.props.itemKind === 'function' && - (candidate.props.item.parameters?.length ?? 0) > 0, - 'TypeScript function route' - ); - const markdown = await readMarkdown(typeScriptItemRoute.GET?.({ props: route.props } as any)); + it('preserves the TypeScript markdown endpoint as a permanent compatibility redirect', async () => { + const response = await typeScriptIndexRoute.GET?.({} as any); - expect(markdown).toContain(`# ${route.props.item.name}`); - expect(markdown).toContain('## Parameters'); - expect(markdown).toContain('## Returns'); + expect(response).toBeInstanceOf(Response); + expect((response as Response).status).toBe(308); + expect((response as Response).headers.get('location')).toBe('/reference/api/apphost.md'); }); - it('returns markdown for a TypeScript member route', async () => { + it('preserves exact TypeScript module markdown redirect semantics', async () => { const route = await findStaticRoute( - typeScriptMemberRoute.getStaticPaths, - () => true, - 'TypeScript member route' + typeScriptModuleRoute.getStaticPaths, + (candidate) => candidate.params.module === 'aspire.hosting', + 'TypeScript module compatibility route' ); - const markdown = await readMarkdown(typeScriptMemberRoute.GET?.({ props: route.props } as any)); + const response = await typeScriptModuleRoute.GET?.({ props: route.props } as any); - expect(markdown).toContain(`# ${route.props.parentType.name}.${route.props.method.name}`); - expect(markdown).toContain('## Signature'); - expect(markdown).toContain(`/reference/api/typescript/${route.params.module}/${route.params.item}.md`); + expect(response).toBeInstanceOf(Response); + expect((response as Response).status).toBe(308); + expect((response as Response).headers.get('location')).toBe( + '/reference/api/apphost/aspire.hosting.md' + ); }); }); diff --git a/src/frontend/tests/unit/api-reference-routes.vitest.test.ts b/src/frontend/tests/unit/api-reference-routes.vitest.test.ts index 08fb64279..dc64b5369 100644 --- a/src/frontend/tests/unit/api-reference-routes.vitest.test.ts +++ b/src/frontend/tests/unit/api-reference-routes.vitest.test.ts @@ -4,6 +4,7 @@ import { isApiReferencePath, stripApiReferenceLocale } from '../../src/utils/api test('isApiReferencePath recognizes API page and markdown routes', () => { expect(isApiReferencePath('/reference/api/csharp/')).toBe(true); + expect(isApiReferencePath('/reference/api/apphost/aspire.hosting.md')).toBe(true); expect(isApiReferencePath('/reference/api/typescript/aspire.hosting.md')).toBe(true); expect(isApiReferencePath('/fr/reference/api/csharp/')).toBe(true); expect(isApiReferencePath('reference/api/csharp/')).toBe(true); @@ -12,6 +13,7 @@ test('isApiReferencePath recognizes API page and markdown routes', () => { test('stripApiReferenceLocale returns the canonical API path for localized API URLs', () => { expect(stripApiReferenceLocale('/fr/reference/api/csharp/')).toBe('/reference/api/csharp/'); + expect(stripApiReferenceLocale('/ja/reference/api/apphost/')).toBe('/reference/api/apphost/'); expect(stripApiReferenceLocale('/ja/reference/api/typescript/')).toBe('/reference/api/typescript/'); expect(stripApiReferenceLocale('/zh-CN/reference/api/csharp/communitytoolkit.aspire.hosting.activemq.md')).toBe( '/reference/api/csharp/communitytoolkit.aspire.hosting.activemq.md' diff --git a/src/frontend/tests/unit/apphost-api.vitest.test.ts b/src/frontend/tests/unit/apphost-api.vitest.test.ts new file mode 100644 index 000000000..0e91068fc --- /dev/null +++ b/src/frontend/tests/unit/apphost-api.vitest.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, test } from 'vitest'; + +import { + getAppHostItemSlug, + getAppHostMemberSlug, + getAppHostTopLevelItems, +} from '../../src/utils/apphost-api-routes'; +import { + getAppHostTypeScriptHtmlTarget, + getAppHostTypeScriptMarkdownTarget, + getAppHostTypeScriptRouteAliases, +} from '../../src/utils/apphost-typescript-route-aliases'; +import { renderAppHostItemMarkdown } from '../../src/utils/apphost-api-markdown'; +import { buildAppHostApiSearchIndex } from '../../src/utils/apphost-api-search'; +import { getAppHostApiSearchStats } from '../../src/utils/apphost-api-search-stats'; +import aliasManifest from '../../src/data/apphost-typescript-route-aliases.json'; +import { + type AppHostApiItem, + type AppHostApiProjection, + type AppHostModuleDocument, + getCapabilitiesForHandle, + projectAppHostModule, + resolveAppHostCapabilityLanguageSupport, + resolveAppHostPackageLanguageSupport, +} from '../../src/utils/apphost-modules'; +import { + getAppHostLanguages, + type AppHostLanguageId, +} from '../../src/utils/apphost-languages'; + +const languageIdentifiers: Partial> = { + typescript: 'withWidget', + python: 'with_widget', + go: 'WithWidget', + java: 'withWidget', + rust: 'with_widget', +}; + +function projections( + overrides: Partial>> = {} +): Partial> { + return Object.fromEntries( + Object.entries(languageIdentifiers).map(([language, identifier]) => [ + language, + { + status: 'supported', + validation: 'source-derived', + identifier, + declaration: `${identifier}()`, + sourceFile: `widget.${language}`, + ...overrides[language], + }, + ]) + ); +} + +function createDocument(): AppHostModuleDocument { + const handle: AppHostApiItem = { + id: 'Sample/Sample.WidgetResource', + kind: 'handle', + name: 'WidgetResource', + fullName: 'Sample.WidgetResource', + projections: projections({ + typescript: { identifier: 'WidgetResource', kind: 'interface' }, + python: { identifier: 'WidgetResource', kind: 'class' }, + go: { identifier: 'WidgetResource', kind: 'interface' }, + java: { identifier: 'WidgetResource', kind: 'class' }, + rust: { identifier: 'WidgetResource', kind: 'handle' }, + }), + }; + const method: AppHostApiItem = { + id: 'Sample/withWidget', + capabilityId: 'Sample/withWidget', + kind: 'capability', + name: 'withWidget', + qualifiedName: 'WidgetResource.withWidget', + capabilityKind: 'Method', + targetTypeId: 'Sample/Sample.WidgetResource', + description: 'Configures the widget.', + parameters: [{ name: 'enabled', type: 'boolean', defaultValue: 'true' }], + projections: projections({ + java: { + identifier: 'withWidget', + reason: 'Union inputs use generated concrete overloads.', + }, + rust: { + status: 'unsupported', + identifier: undefined, + declaration: undefined, + reason: 'Callback defaults cannot be represented faithfully.', + }, + }), + }; + const standalone: AppHostApiItem = { + ...method, + id: 'Sample/addWidget', + capabilityId: 'Sample/addWidget', + name: 'addWidget', + qualifiedName: 'addWidget', + targetTypeId: undefined, + projections: projections({ + typescript: { identifier: 'addWidget' }, + python: { identifier: 'add_widget' }, + go: { identifier: 'AddWidget' }, + java: { identifier: 'addWidget' }, + rust: { identifier: 'add_widget' }, + }), + }; + + return { + schemaVersion: '1.0', + generatorProvenance: { + repository: 'microsoft/aspire', + commit: '62028348b5d02dfc8f8baf03a4472946537b0d16', + lockFile: 'upstream-sources.lock.json', + }, + package: { name: 'Aspire.Hosting.Sample', version: '1.0.0' }, + items: [handle, method, standalone], + }; +} + +describe('semantic AppHost API data', () => { + test('projects one semantic document independently for each language', () => { + const document = createDocument(); + + expect(projectAppHostModule(document, 'typescript').functions).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'withWidget' })]) + ); + expect(projectAppHostModule(document, 'python').functions).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'with_widget' })]) + ); + expect(projectAppHostModule(document, 'go').functions).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'WithWidget' })]) + ); + expect(projectAppHostModule(document, 'rust').functions).not.toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'Sample/withWidget' })]) + ); + }); + + test('resolves C# member names to generated language support', () => { + const support = resolveAppHostCapabilityLanguageSupport(createDocument(), 'WithWidget'); + + expect(support).toMatchObject({ + csharpMemberName: 'WithWidget', + itemIds: ['Sample/withWidget'], + languages: { + typescript: { status: 'supported', identifier: 'withWidget' }, + python: { status: 'supported', identifier: 'with_widget' }, + go: { status: 'supported', identifier: 'WithWidget' }, + java: { + status: 'supported', + identifier: 'withWidget', + reason: 'Union inputs use generated concrete overloads.', + }, + rust: { + status: 'unsupported', + reason: 'Callback defaults cannot be represented faithfully.', + }, + }, + }); + }); + + test('summarizes package support independently of activation state', () => { + const support = resolveAppHostPackageLanguageSupport(createDocument()); + + expect(support.languages.python).toMatchObject({ + status: 'supported', + supportedItems: 3, + totalItems: 3, + }); + expect(support.languages.rust).toMatchObject({ + status: 'limited', + supportedItems: 2, + totalItems: 3, + reasons: ['Callback defaults cannot be represented faithfully.'], + }); + }); + + test('indexes standalone APIs once and handle-only members at canonical language URLs', () => { + const document = createDocument(); + const generatedLanguages = getAppHostLanguages() + .filter((language) => language.generatedApi) + .map((language) => language.id); + const index = buildAppHostApiSearchIndex([document], '', generatedLanguages); + + expect(index.filter((entry) => entry.l === 'typescript' && entry.n === 'addWidget')).toHaveLength(1); + expect(index).toContainEqual( + expect.objectContaining({ + n: 'with_widget', + l: 'python', + t: 'WidgetResource', + m: true, + h: '/reference/api/apphost/aspire.hosting.sample/widgetresource/withwidget/?aspire-lang=python', + }) + ); + expect(index.some((entry) => entry.l === 'rust' && entry.n === 'with_widget' && entry.m)).toBe(false); + expect(getAppHostApiSearchStats(index, 'python')).toEqual({ + packageCount: 1, + capabilityCount: 2, + typeCount: 1, + }); + }); + + test('renders every enabled projection or its explicit unsupported reason in markdown', () => { + const document = createDocument(); + const method = document.items.find((item) => item.id === 'Sample/withWidget'); + expect(method).toBeDefined(); + + const generatedLanguages = getAppHostLanguages().filter((language) => language.generatedApi); + const markdown = renderAppHostItemMarkdown(document, method!, '', generatedLanguages); + + expect(markdown).toContain('## TypeScript'); + expect(markdown).toContain('## Python'); + expect(markdown).toContain('## Go'); + expect(markdown).toContain('## Java'); + expect(markdown).toContain('## Rust'); + expect(markdown).toContain('Limitation: Union inputs use generated concrete overloads.'); + expect(markdown).toContain('Unsupported: Callback defaults cannot be represented faithfully.'); + }); + + test('keeps overloaded member routes unique while using stable TypeScript identifiers', () => { + const document = createDocument(); + const handle = document.items[0]; + const members = getCapabilitiesForHandle(document, handle); + const overload = structuredClone(members[0]); + overload.id = 'Sample/withWidget:string'; + overload.parameters = [{ name: 'name', type: 'string' }]; + document.items.push(overload); + const siblings = getCapabilitiesForHandle(document, handle); + + expect(getAppHostMemberSlug(siblings[0], siblings, handle.name)).not.toBe( + getAppHostMemberSlug(siblings[1], siblings, handle.name) + ); + expect(getAppHostItemSlug(handle, getAppHostTopLevelItems(document))).toBe('widgetresource'); + }); + + test('preserves the complete pre-migration TypeScript route inventory', () => { + const aliases = [ + ...getAppHostTypeScriptRouteAliases(1), + ...getAppHostTypeScriptRouteAliases(2), + ...getAppHostTypeScriptRouteAliases(3), + ]; + + expect(aliasManifest.legacyRouteCount).toBe(3633); + expect(aliases).toHaveLength(aliasManifest.legacyRouteCount); + expect(new Set(aliases.map((alias) => alias.source)).size).toBe( + aliasManifest.legacyRouteCount + ); + expect(aliases.every((alias) => alias.target.startsWith('/reference/api/apphost/'))).toBe(true); + expect(getAppHostTypeScriptHtmlTarget(aliases[0].target).endsWith( + '?aspire-lang=typescript' + )).toBe(true); + expect(getAppHostTypeScriptMarkdownTarget(aliases[0].target).endsWith('.md')).toBe(true); + }); +}); diff --git a/src/frontend/tests/unit/apphost-languages.vitest.test.ts b/src/frontend/tests/unit/apphost-languages.vitest.test.ts index 2ea588fd7..8362ac9bf 100644 --- a/src/frontend/tests/unit/apphost-languages.vitest.test.ts +++ b/src/frontend/tests/unit/apphost-languages.vitest.test.ts @@ -178,6 +178,26 @@ describe('AppHost language registry', () => { expect(homeSource).not.toContain("type AppHostLanguage = 'csharp' | 'typescript'"); }); + test('AppHost API language correction dispatches after sync listeners are ready', () => { + const selectorSource = fs.readFileSync( + path.join(componentsDirectory, 'api-reference', 'AppHostApiLanguageSelector.astro'), + 'utf8' + ); + const datasetUpdate = selectorSource.indexOf( + 'document.documentElement.dataset.apphostLang = language;' + ); + const readyStateBranch = selectorSource.indexOf( + "if (document.readyState === 'loading')" + ); + + expect(datasetUpdate).toBeGreaterThan(-1); + expect(readyStateBranch).toBeGreaterThan(datasetUpdate); + expect(selectorSource).toContain( + "document.addEventListener('DOMContentLoaded', dispatchSelection, { once: true });" + ); + expect(selectorSource).toMatch(/else \{\s*dispatchSelection\(\);\s*\}/); + }); + test('cloud and AI AppHost tabs account for all six languages', () => { const violations: string[] = []; const appHostTabsPattern = diff --git a/src/frontend/tests/unit/container-images-language-support.vitest.test.ts b/src/frontend/tests/unit/container-images-language-support.vitest.test.ts new file mode 100644 index 000000000..be6e44e56 --- /dev/null +++ b/src/frontend/tests/unit/container-images-language-support.vitest.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +import { renderComponent } from './astro-test-utils'; + +const getModulesMock = vi.hoisted(() => vi.fn()); +const resolveCapabilityMock = vi.hoisted(() => vi.fn()); + +vi.mock('@utils/apphost-modules', () => ({ + getAppHostModules: getModulesMock, + resolveAppHostCapabilityLanguageSupport: resolveCapabilityMock, +})); + +vi.mock('@utils/apphost-languages', () => ({ + getEnabledAppHostLanguages: () => [ + { id: 'typescript', generatedApi: true }, + { id: 'csharp', generatedApi: false }, + { id: 'python', generatedApi: true }, + { id: 'go', generatedApi: true }, + { id: 'java', generatedApi: true }, + { id: 'rust', generatedApi: true }, + ], +})); + +import ContainerImages from '@components/ContainerImages.astro'; + +beforeEach(() => { + getModulesMock.mockResolvedValue([ + { + data: { + package: { name: 'Aspire.Hosting.Redis' }, + }, + }, + ]); + resolveCapabilityMock.mockReturnValue({ + csharpMemberName: 'AddRedis', + itemIds: ['capability:Aspire.Hosting.Redis/addRedis'], + languages: { + typescript: { + status: 'supported', + identifier: 'addRedis', + validation: 'source-derived', + }, + python: { + status: 'supported', + identifier: 'add_redis', + validation: 'source-derived', + }, + go: { + status: 'supported', + identifier: 'AddRedis', + validation: 'source-derived', + }, + java: { + status: 'unsupported', + validation: 'source-derived', + reason: 'Synthetic test limitation.', + }, + rust: { + status: 'supported', + identifier: 'add_redis', + validation: 'source-derived', + }, + }, + }); +}); + +test('renders exact projected container API names for enabled languages', async () => { + const html = await renderComponent(ContainerImages, { + props: { + package: 'Aspire.Hosting.Redis', + only: 'Redis', + }, + }); + + expect(html).toContain('data-lang="csharp"'); + expect(html).toContain('AddRedis()'); + expect(html).toContain('data-lang="typescript"'); + expect(html).toContain('addRedis()'); + expect(html).toContain('data-lang="python"'); + expect(html).toContain('add_redis()'); + expect(html).toContain('data-lang="go"'); + expect(html).toContain('data-lang="rust"'); + expect(html).not.toContain('data-lang="java"'); + expect(html).not.toContain('__aspireLangPivot'); +}); diff --git a/src/frontend/tests/unit/custom-components.vitest.test.ts b/src/frontend/tests/unit/custom-components.vitest.test.ts index 66ffa64f3..fe7b5b918 100644 --- a/src/frontend/tests/unit/custom-components.vitest.test.ts +++ b/src/frontend/tests/unit/custom-components.vitest.test.ts @@ -225,6 +225,8 @@ const basicRenderCases: BasicRenderCase[] = [ 'Redis Commander', 'RedisInsight', 'Companion', + 'data-lang="csharp"', + 'AddRedis()', 'ghcr.io', 'Copy to clipboard', 'Source', diff --git a/src/frontend/tests/unit/diagnostics-language.vitest.test.ts b/src/frontend/tests/unit/diagnostics-language.vitest.test.ts index 34d3ffb7f..a92a90a76 100644 --- a/src/frontend/tests/unit/diagnostics-language.vitest.test.ts +++ b/src/frontend/tests/unit/diagnostics-language.vitest.test.ts @@ -15,16 +15,33 @@ const pages = readdirSync(diagnosticsRoot) name, source: readFileSync(new URL(name, diagnosticsRoot), 'utf8'), })); -const modulesRoot = new URL('../../src/data/ts-modules/', import.meta.url); +const modulesRoot = new URL('../../src/data/apphost-modules/', import.meta.url); const apiPackages = new Map>(); for (const file of readdirSync(modulesRoot).filter((file) => file.endsWith('.json'))) { - const module: { package: { name: string }; functions: Array<{ name: string }> } = JSON.parse( - readFileSync(new URL(file, modulesRoot), 'utf8') - ); - for (const api of module.functions) { - const packages = apiPackages.get(api.name) ?? new Set(); + const module: { + package: { name: string }; + items: Array<{ + kind: string; + projections?: { + typescript?: { + status?: string; + identifier?: string; + }; + }; + }>; + } = JSON.parse(readFileSync(new URL(file, modulesRoot), 'utf8')); + for (const item of module.items) { + const projection = item.projections?.typescript; + if ( + item.kind !== 'capability' || + projection?.status !== 'supported' || + !projection.identifier + ) { + continue; + } + const packages = apiPackages.get(projection.identifier) ?? new Set(); packages.add(module.package.name); - apiPackages.set(api.name, packages); + apiPackages.set(projection.identifier, packages); } } diff --git a/src/frontend/tests/unit/integration-card-language-support.vitest.test.ts b/src/frontend/tests/unit/integration-card-language-support.vitest.test.ts new file mode 100644 index 000000000..d0ab099fe --- /dev/null +++ b/src/frontend/tests/unit/integration-card-language-support.vitest.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +import { renderComponent } from './astro-test-utils'; + +const supportMock = vi.hoisted(() => vi.fn()); + +vi.mock('@utils/apphost-modules', () => ({ + getAppHostPackageLanguageSupport: supportMock, +})); + +vi.mock('@utils/apphost-languages', () => ({ + getEnabledAppHostLanguages: () => [ + { id: 'typescript', label: 'TypeScript', icon: 'typescript', generatedApi: true }, + { id: 'csharp', label: 'C#', icon: 'csharp', generatedApi: false }, + { id: 'python', label: 'Python', icon: 'python', generatedApi: true }, + { id: 'go', label: 'Go', icon: 'go', generatedApi: true }, + ], +})); + +import IntegrationCard from '@components/IntegrationCard.astro'; + +const pkg = { + title: 'Aspire.Hosting.Redis', + href: 'https://www.nuget.org/packages/Aspire.Hosting.Redis', + description: 'Redis hosting integration.', + docs: '/integrations/caching/redis/redis-get-started/', +}; + +beforeEach(() => { + supportMock.mockResolvedValue({ + packageName: pkg.title, + languages: { + typescript: { status: 'supported', supportedItems: 5, totalItems: 5, reasons: [] }, + python: { status: 'limited', supportedItems: 4, totalItems: 5, reasons: ['One limitation'] }, + go: { status: 'unsupported', supportedItems: 0, totalItems: 5, reasons: ['Not projected'] }, + }, + }); +}); + +test('renders only enabled languages with usable package projections', async () => { + const html = await renderComponent(IntegrationCard, { props: { pkg } }); + + expect(html).toContain('aspire-lang=csharp'); + expect(html).toContain('aspire-lang=typescript'); + expect(html).toContain('aspire-lang=python'); + expect(html).not.toContain('aspire-lang=go'); +}, 60_000); + +test('keeps C# available when semantic package support is missing', async () => { + supportMock.mockResolvedValue(undefined); + + const html = await renderComponent(IntegrationCard, { props: { pkg } }); + + expect(html).toContain('aspire-lang=csharp'); + expect(html).not.toContain('aspire-lang=typescript'); + expect(html).not.toContain('aspire-lang=python'); + expect(html).not.toContain('aspire-lang=go'); +}, 60_000); diff --git a/src/frontend/tests/unit/normalize-generated-api-data.vitest.test.ts b/src/frontend/tests/unit/normalize-generated-api-data.vitest.test.ts index 46a25df27..14d660c1d 100644 --- a/src/frontend/tests/unit/normalize-generated-api-data.vitest.test.ts +++ b/src/frontend/tests/unit/normalize-generated-api-data.vitest.test.ts @@ -72,7 +72,7 @@ describe('normalizeApiJsonText — C# API (pkgs) shape', () => { }); }); -describe('normalizeApiJsonText — TypeScript API (ts-modules) shape', () => { +describe('normalizeApiJsonText — semantic AppHost API projection shape', () => { const doc = crlf([ '{', ` "description": "Adds a first-class ${NET_ASPIRE} resource.",`, @@ -92,6 +92,18 @@ describe('normalizeApiJsonText — TypeScript API (ts-modules) shape', () => { expect(changes).toBe(3); }); + test('normalizes projection limitation reasons', () => { + const reason = crlf([ + '{', + ` "reason": "Not available in polyglot ${APP_HOST}s."`, + '}', + ]); + + expect(normalizeApiJsonText(reason).text).toContain( + '"reason": "Not available in polyglot AppHosts."' + ); + }); + test('never rewrites code identifiers (signature, returnType, ids)', () => { const { text } = normalizeApiJsonText(doc); expect(text).toContain('"signature": "addContainer(name: string): ContainerResource"'); diff --git a/src/frontend/tests/unit/page-metadata.vitest.test.ts b/src/frontend/tests/unit/page-metadata.vitest.test.ts index a0758d0fe..4237e6f31 100644 --- a/src/frontend/tests/unit/page-metadata.vitest.test.ts +++ b/src/frontend/tests/unit/page-metadata.vitest.test.ts @@ -300,15 +300,15 @@ describe('resolveOgImage', () => { it('falls back to the global image for generated pages routes', () => { const route = createRoute({ - entryId: 'reference/api/typescript/communitytoolkit.aspire.hosting.ollama/addollamalocal', - filePath: 'src/pages/reference/api/typescript/[module]/[item]/index.astro', + entryId: 'reference/api/apphost/communitytoolkit.aspire.hosting.ollama/addollamalocal', + filePath: 'src/pages/reference/api/apphost/[module]/[item]/index.astro', title: 'addOllamaLocal', }); expect( resolveOgImage( route, - 'reference/api/typescript/communitytoolkit.aspire.hosting.ollama/addollamalocal', + 'reference/api/apphost/communitytoolkit.aspire.hosting.ollama/addollamalocal', siteUrl, true ) @@ -317,7 +317,7 @@ describe('resolveOgImage', () => { it('falls back to the global image for virtual API reference routes without file paths', () => { const route = createRoute({ - entryId: 'reference/api/typescript/communitytoolkit.aspire.hosting.ollama/addollamalocal', + entryId: 'reference/api/apphost/communitytoolkit.aspire.hosting.ollama/addollamalocal', filePath: '', title: 'addOllamaLocal', }); @@ -325,7 +325,7 @@ describe('resolveOgImage', () => { expect( resolveOgImage( route, - 'reference/api/typescript/communitytoolkit.aspire.hosting.ollama/addollamalocal', + 'reference/api/apphost/communitytoolkit.aspire.hosting.ollama/addollamalocal', siteUrl, true ) @@ -403,28 +403,28 @@ describe('shouldSkipDynamicOgImage', () => { it('skips generated pages routes', () => { const route = createRoute({ - entryId: 'reference/api/typescript/communitytoolkit.aspire.hosting.ollama/addollamalocal', - filePath: 'src/pages/reference/api/typescript/[module]/[item]/index.astro', + entryId: 'reference/api/apphost/communitytoolkit.aspire.hosting.ollama/addollamalocal', + filePath: 'src/pages/reference/api/apphost/[module]/[item]/index.astro', }); expect( shouldSkipDynamicOgImage( route, - 'reference/api/typescript/communitytoolkit.aspire.hosting.ollama/addollamalocal' + 'reference/api/apphost/communitytoolkit.aspire.hosting.ollama/addollamalocal' ) ).toBe(true); }); it('skips virtual API reference routes without file paths', () => { const route = createRoute({ - entryId: 'reference/api/typescript/communitytoolkit.aspire.hosting.ollama/addollamalocal', + entryId: 'reference/api/apphost/communitytoolkit.aspire.hosting.ollama/addollamalocal', filePath: '', }); expect( shouldSkipDynamicOgImage( route, - 'reference/api/typescript/communitytoolkit.aspire.hosting.ollama/addollamalocal' + 'reference/api/apphost/communitytoolkit.aspire.hosting.ollama/addollamalocal' ) ).toBe(true); }); @@ -552,16 +552,16 @@ describe('getOgMetadata', () => { it('falls back to the static image for generated pages routes', () => { const route = createRoute({ - entryId: 'reference/api/typescript/communitytoolkit.aspire.hosting.ollama/addollamalocal', - filePath: 'src/pages/reference/api/typescript/[module]/[item]/index.astro', + entryId: 'reference/api/apphost/communitytoolkit.aspire.hosting.ollama/addollamalocal', + filePath: 'src/pages/reference/api/apphost/[module]/[item]/index.astro', title: 'addOllamaLocal', description: - 'Method — TypeScript API reference for addOllamaLocal in CommunityToolkit.Aspire.Hosting.Ollama', + 'Method — AppHost API reference for addOllamaLocal in CommunityToolkit.Aspire.Hosting.Ollama', }); const meta = getOgMetadata( route, new URL( - 'https://aspire.dev/reference/api/typescript/communitytoolkit.aspire.hosting.ollama/addollamalocal/' + 'https://aspire.dev/reference/api/apphost/communitytoolkit.aspire.hosting.ollama/addollamalocal/' ), site ); diff --git a/src/frontend/tests/unit/twoslash-types-generator.vitest.test.ts b/src/frontend/tests/unit/twoslash-types-generator.vitest.test.ts index 01c1a0bab..09d37169c 100644 --- a/src/frontend/tests/unit/twoslash-types-generator.vitest.test.ts +++ b/src/frontend/tests/unit/twoslash-types-generator.vitest.test.ts @@ -74,6 +74,13 @@ describe('generate-twoslash-types', () => { expect(output).not.toMatch(/options\?: \{\s*options\?:/); }); + test('emits options for a single optional Dockerfile stage name', () => { + expect(output).toMatch( + /from\(image: string, options\?: \{ stageName\?: string \}\): DockerfileStage;/ + ); + expect(output).toMatch(/from\(image: string, stageName\?: string\): DockerfileStage;/); + }); + test('does not infer ContainerResource from marker interfaces', () => { expect(output).not.toMatch( /export interface \w+[^{]*extends[^{]*(?:ExecutableResource[^{]*ContainerResource|ContainerResource[^{]*ExecutableResource)/ @@ -83,7 +90,7 @@ describe('generate-twoslash-types', () => { test('scopes fallback inheritance to the package when full type names collide', () => { const fixtureRoot = mkdtempSync(path.join(tmpdir(), 'aspire-twoslash-types-')); const packagesDir = path.join(fixtureRoot, 'pkgs'); - const modulesDir = path.join(fixtureRoot, 'ts-modules'); + const modulesDir = path.join(fixtureRoot, 'apphost-modules'); const fixtureOutput = path.join(fixtureRoot, 'twoslash', 'aspire.d.ts'); mkdirSync(packagesDir); mkdirSync(modulesDir); @@ -117,32 +124,50 @@ describe('generate-twoslash-types', () => { ], }); writeJson(path.join(modulesDir, 'A.K3s.1.0.0.json'), { + schemaVersion: '1.0', package: { name: 'A.K3s', version: '1.0.0' }, - handleTypes: [ + items: [ { + id: 'A.K3s/Aspire.Hosting.ApplicationModel.ContainerResource', name: 'ContainerResource', fullName: 'Aspire.Hosting.ApplicationModel.ContainerResource', kind: 'handle', + projections: { + typescript: { status: 'supported', identifier: 'ContainerResource' }, + }, }, { + id: 'A.K3s/Aspire.Hosting.ApplicationModel.K8sManifestResource', name: 'K8sManifestResource', fullName: collidingFullName, kind: 'handle', + projections: { + typescript: { status: 'supported', identifier: 'K8sManifestResource' }, + }, }, ], }); writeJson(path.join(modulesDir, 'B.Kind.1.0.0.json'), { + schemaVersion: '1.0', package: { name: 'B.Kind', version: '1.0.0' }, - handleTypes: [ + items: [ { + id: 'B.Kind/Aspire.Hosting.ApplicationModel.KindDeployedResource', name: 'KindDeployedResource', fullName: 'Aspire.Hosting.ApplicationModel.KindDeployedResource', kind: 'handle', + projections: { + typescript: { status: 'supported', identifier: 'KindDeployedResource' }, + }, }, { + id: 'B.Kind/Aspire.Hosting.ApplicationModel.K8sManifestResource', name: 'K8sManifestResource', fullName: collidingFullName, kind: 'handle', + projections: { + typescript: { status: 'supported', identifier: 'K8sManifestResource' }, + }, }, ], }); @@ -153,7 +178,7 @@ describe('generate-twoslash-types', () => { env: { ...process.env, ASPIRE_API_PKGS_DIR: packagesDir, - ASPIRE_API_TS_MODULES_DIR: modulesDir, + ASPIRE_API_APPHOST_MODULES_DIR: modulesDir, ASPIRE_API_TWOSLASH_FILE: fixtureOutput, }, stdio: 'pipe', @@ -179,7 +204,7 @@ describe('generate-twoslash-types', () => { expect(output).toMatch(/label\?: string/); expect(output).toMatch(/description\?: string/); expect(output).toMatch(/enableDescriptionMarkdown\?: boolean/); - expect(output).toMatch(/options\?: Dict/); + expect(output).toMatch(/options\?: Record/); expect(output).toMatch(/value\?: string/); expect(output).toMatch(/placeholder\?: string/); expect(output).toMatch(/allowCustomChoice\?: boolean/); diff --git a/src/frontend/tests/unit/validate-generated-api-data.vitest.test.ts b/src/frontend/tests/unit/validate-generated-api-data.vitest.test.ts index 6c8d63e0d..7c45af5d8 100644 --- a/src/frontend/tests/unit/validate-generated-api-data.vitest.test.ts +++ b/src/frontend/tests/unit/validate-generated-api-data.vitest.test.ts @@ -38,6 +38,60 @@ function createValidInput(): ValidationInput { }, ], }; + const semanticModule = { + schemaVersion: '1.0', + generatorProvenance: { + repository: 'microsoft/aspire', + commit: '62028348b5d02dfc8f8baf03a4472946537b0d16', + lockFile: 'src/tools/AtsJsonGenerator/upstream-sources.lock.json', + }, + package: structuredClone(pkg.package), + items: [ + { + id: 'Aspire.Hosting.Foo/addFoo', + kind: 'capability' as const, + name: 'addFoo', + projections: { + typescript: { status: 'supported' as const, validation: 'source-derived' as const, identifier: 'addFoo' }, + python: { status: 'supported' as const, validation: 'source-derived' as const, identifier: 'add_foo' }, + go: { status: 'supported' as const, validation: 'source-derived' as const, identifier: 'AddFoo' }, + java: { status: 'supported' as const, validation: 'source-derived' as const, identifier: 'addFoo' }, + rust: { + status: 'unsupported' as const, + validation: 'source-derived' as const, + reason: 'Requires a runtime callback adapter.', + }, + }, + }, + ], + }; + const supportMatrix = { + schemaVersion: '1.0', + generatedFrom: structuredClone(semanticModule.generatorProvenance), + packages: { + 'Aspire.Hosting.Foo@1.0.0': { + package: { name: 'Aspire.Hosting.Foo', version: '1.0.0' }, + items: { + 'Aspire.Hosting.Foo/addFoo': { + kind: 'capability' as const, + name: 'addFoo', + languages: Object.fromEntries( + Object.entries(semanticModule.items[0].projections).map( + ([language, projection]) => [ + language, + { + supported: projection.status === 'supported', + validation: projection.validation, + ...('reason' in projection ? { reason: projection.reason } : {}), + }, + ] + ) + ), + }, + }, + }, + }, + }; return { catalog: [{ title: 'Aspire.Hosting.Foo', version: '1.0.0' }], @@ -82,6 +136,13 @@ function createValidInput(): ValidationInput { }, }, ], + semanticModules: [ + { + fileName: 'Aspire.Hosting.Foo.1.0.0.json', + data: semanticModule, + }, + ], + supportMatrix, declarations: [ 'export interface FooOptions {', ' port?: number;', @@ -117,7 +178,7 @@ describe('validateGeneratedApiData', () => { input.declarations = input.declarations.replace('port?: number', 'port: number'); expect(validateGeneratedApiData(input).errors).toContain( - 'Twoslash DTO FooOptions.port optionality does not match ts-modules metadata.' + 'Twoslash DTO FooOptions.port optionality does not match AppHost TypeScript projection metadata.' ); }); @@ -127,7 +188,7 @@ describe('validateGeneratedApiData', () => { input.declarations = input.declarations.replace('port?: number', 'port?: string[]'); expect(validateGeneratedApiData(input).errors).toContain( - 'Twoslash DTO FooOptions.port type string[] does not match ts-modules metadata String]][].' + 'Twoslash DTO FooOptions.port type string[] does not match AppHost TypeScript projection metadata String]][].' ); }); @@ -266,6 +327,58 @@ describe('validateGeneratedApiData', () => { ); expect(errors).toContain('Missing C# API output for catalog package Aspire.Hosting.Foo@2.0.0.'); }); + + test('requires every semantic item to account for every generated language', () => { + const input = createValidInput(); + Reflect.deleteProperty( + input.semanticModules![0].data.items[0].projections, + 'java' + ); + + expect(validateGeneratedApiData(input).errors).toContain( + 'Aspire.Hosting.Foo.1.0.0.json item Aspire.Hosting.Foo/addFoo has no java projection.' + ); + }); + + test('requires an explicit limitation reason for unsupported projections', () => { + const input = createValidInput(); + input.semanticModules![0].data.items[0].projections.rust.reason = ' '; + + expect(validateGeneratedApiData(input).errors).toContain( + 'Aspire.Hosting.Foo.1.0.0.json item Aspire.Hosting.Foo/addFoo has no rust limitation reason.' + ); + }); + + test('rejects duplicate semantic item identities', () => { + const input = createValidInput(); + input.semanticModules![0].data.items.push( + structuredClone(input.semanticModules![0].data.items[0]) + ); + + expect(validateGeneratedApiData(input).errors).toContain( + 'Aspire.Hosting.Foo.1.0.0.json contains duplicate semantic item Aspire.Hosting.Foo/addFoo.' + ); + }); + + test('reconciles support matrix statuses with semantic projections', () => { + const input = createValidInput(); + input.supportMatrix!.packages['Aspire.Hosting.Foo@1.0.0'].items[ + 'Aspire.Hosting.Foo/addFoo' + ].languages.python.supported = false; + + expect(validateGeneratedApiData(input).errors).toContain( + 'AppHost language support mismatch for Aspire.Hosting.Foo@1.0.0/Aspire.Hosting.Foo/addFoo/python.' + ); + }); + + test('rejects unexpected generator provenance', () => { + const input = createValidInput(); + input.semanticModules![0].data.generatorProvenance.commit = 'unexpected'; + + expect(validateGeneratedApiData(input).errors).toContain( + 'Aspire.Hosting.Foo.1.0.0.json has unexpected AppHost generator provenance.' + ); + }); }); describe('loadJsonFromHead', () => { diff --git a/src/tools/AtsJsonGenerator/Adapters/GoProjectionAdapter.cs b/src/tools/AtsJsonGenerator/Adapters/GoProjectionAdapter.cs new file mode 100644 index 000000000..52c6511ac --- /dev/null +++ b/src/tools/AtsJsonGenerator/Adapters/GoProjectionAdapter.cs @@ -0,0 +1,357 @@ +using System.Text; + +namespace AtsJsonGenerator.Helpers; + +internal sealed class GoProjectionAdapter : ProjectionAdapterBase +{ + private const string SourceFile = "aspire.go"; + private static readonly HashSet s_keywords = new(StringComparer.Ordinal) + { + "break", "default", "func", "interface", "select", "case", "defer", "go", + "map", "struct", "chan", "else", "goto", "package", "switch", "const", + "fallthrough", "if", "range", "type", "continue", "for", "import", "return", "var", + }; + + private readonly Dictionary _handleNames = new(StringComparer.Ordinal); + private readonly Dictionary _dtoNames = new(StringComparer.Ordinal); + private readonly Dictionary _enumNames = new(StringComparer.Ordinal); + private readonly Dictionary _optionsNames = new(StringComparer.Ordinal); + + public GoProjectionAdapter(AtsDumpRoot dump) + : base(dump) + { + var allocator = new IdentifierAllocator(comparer: StringComparer.OrdinalIgnoreCase); + foreach (var handle in dump.HandleTypes + .OrderBy(handle => handle.IsInterface ? 0 : 1) + .ThenBy(handle => handle.AtsTypeId, StringComparer.Ordinal)) + { + var raw = TypeName(handle.AtsTypeId); + var preferred = handle.IsInterface && raw.Length > 1 && raw[0] == 'I' && char.IsUpper(raw[1]) + ? raw[1..] + : raw; + _handleNames[handle.AtsTypeId] = allocator.Reserve(SanitizeIdentifier(preferred, s_keywords)); + } + + foreach (var dto in dump.DtoTypes.OrderBy(dto => dto.TypeId, StringComparer.Ordinal)) + { + _dtoNames[dto.TypeId] = allocator.Reserve(SanitizeIdentifier(dto.Name, s_keywords)); + } + + foreach (var enumType in dump.EnumTypes.OrderBy(enumType => enumType.TypeId, StringComparer.Ordinal)) + { + _enumNames[enumType.TypeId] = allocator.Reserve(SanitizeIdentifier(enumType.Name, s_keywords)); + } + + foreach (var capability in dump.Capabilities.OrderBy(SourceIdentity, StringComparer.Ordinal)) + { + var optional = VisibleParameters(capability) + .Where(parameter => parameter.IsOptional || IsCancellationToken(parameter.Type)) + .ToList(); + if (optional.Count == 0 || IsDirectOptionsParameter(optional)) + { + continue; + } + + var preferred = $"{ToPascalCase(capability.MethodName.Split('.').Last())}Options"; + var qualifier = capability.TargetTypeId is not null + ? GetHandleName(capability.TargetTypeId) + : null; + _optionsNames[capability.CapabilityId] = allocator.Reserve(preferred, qualifier); + } + } + + public override string Language => "go"; + + public override AppHostProjectionModel ProjectCapability(AtsDumpCapability capability) + { + if (HasCallbackDefault(capability)) + { + return Unsupported("Callback defaults cannot be represented faithfully.", SourceFile); + } + + var required = VisibleParameters(capability) + .Where(parameter => !parameter.IsOptional && !IsCancellationToken(parameter.Type)) + .ToList(); + var optional = VisibleParameters(capability) + .Where(parameter => parameter.IsOptional || IsCancellationToken(parameter.Type)) + .ToList(); + var projected = new List(); + var parameterParts = new List(); + + foreach (var parameter in required) + { + var name = SanitizeIdentifier(ToCamelCase(parameter.Name), s_keywords); + var type = parameter.IsCallback ? MapCallback(parameter) : MapType(parameter.Type, TypePosition.Input); + parameterParts.Add($"{name} {type}"); + projected.Add(Parameter(capability, parameter, name, type, optional: false, defaultValue: null, + callbackSignature: parameter.IsCallback ? type : null)); + } + + string? optionsDeclaration = null; + if (optional.Count > 0) + { + var directOptions = IsDirectOptionsParameter(optional) ? optional[0] : null; + var optionsType = directOptions is not null + ? MapDirectOptionsType(directOptions) + : _optionsNames.GetValueOrDefault( + capability.CapabilityId, + $"{ToPascalCase(capability.MethodName.Split('.').Last())}Options"); + parameterParts.Add($"options ...*{optionsType}"); + projected.Add(new AppHostParameterModel + { + Name = "options", + Type = $"...*{optionsType}", + IsOptional = true, + }); + + if (directOptions is null) + { + var fields = optional.Select(parameter => + { + var type = parameter.IsCallback + ? MapCallback(parameter) + : IsCancellationToken(parameter.Type) + ? "*CancellationToken" + : MapType(parameter.Type, TypePosition.Input, optional: true); + var jsonTag = parameter.IsCallback || IsCancellationToken(parameter.Type) + ? "`json:\"-\"`" + : $"`json:\"{parameter.Name},omitempty\"`"; + return $"\t{ToPascalCase(parameter.Name)} {type} {jsonTag}"; + }); + optionsDeclaration = $"type {optionsType} struct {{\n{string.Join("\n", fields)}\n}}"; + } + } + + var identifier = ToPascalCase(capability.MethodName.Split('.').Last()); + var returnType = RenderReturn(capability); + var signature = $"{identifier}({string.Join(", ", parameterParts)}){(string.IsNullOrEmpty(returnType) ? "" : " " + returnType)}"; + var declaration = optionsDeclaration is null + ? signature + : $"{optionsDeclaration}\n\n{signature}"; + var hasUnion = VisibleParameters(capability).Any(parameter => IsUnion(parameter.Type)); + + return new AppHostProjectionModel + { + Status = "supported", + Reason = hasUnion ? "Union inputs are projected as any and validated at runtime." : null, + Identifier = identifier, + Signature = signature, + Declaration = declaration, + SourceFile = SourceFile, + Parameters = projected, + Return = new AppHostReturnModel + { + Type = returnType, + ErrorModel = IsHandle(capability.ReturnType) ? "deferred" : "result", + }, + }; + } + + public override AppHostProjectionModel ProjectHandle(AtsDumpHandleType handle) + { + var name = GetHandleName(handle.AtsTypeId); + var inherited = handle.ImplementedInterfaces.Select(type => GetHandleName(type.TypeId)).ToList(); + var members = inherited.Select(type => $"\t{type}").Append("\tErr() error"); + return new AppHostProjectionModel + { + Status = "supported", + Reason = "Fluent failures use first-error-wins deferred Err().", + Identifier = name, + Declaration = $"type {name} interface {{\n{string.Join("\n", members)}\n}}", + SourceFile = SourceFile, + Kind = "interface", + ImplementedInterfaces = inherited, + }; + } + + public override AppHostProjectionModel ProjectDto(AtsDumpDtoType dto) + { + var identifier = GetDtoName(dto.TypeId); + var fields = dto.Properties.Select(property => + { + var type = property.IsCallback + ? MapCallback(property.CallbackParameters, property.CallbackReturnType) + : MapType(property.Type, TypePosition.Dto, property.IsOptional); + return Field(property, ToPascalCase(property.Name), type, property.IsOptional); + }).ToList(); + var body = string.Join("\n", fields.Select(field => $"\t{field.Name} {field.Type} `json:\"{FindOriginalProperty(dto, field.Name)},omitempty\"`")); + return new AppHostProjectionModel + { + Status = "supported", + Identifier = identifier, + Declaration = $"type {identifier} struct {{\n{body}\n}}", + SourceFile = SourceFile, + Kind = "class", + Fields = fields, + }; + } + + public override AppHostProjectionModel ProjectEnum(AtsDumpEnumType enumType) + { + var identifier = GetEnumName(enumType.TypeId); + var members = Members(enumType, name => identifier + ToPascalCase(name), name => name); + var constants = string.Join("\n", members.Select(member => + $"\t{member.Name} {identifier} = \"{member.Value}\"")); + return new AppHostProjectionModel + { + Status = "supported", + Identifier = identifier, + Declaration = $"type {identifier} string\n\nconst (\n{constants}\n)", + SourceFile = SourceFile, + Kind = "class", + Members = members, + }; + } + + public override AppHostProjectionModel ProjectExportedValue(AtsDumpExportedValue exportedValue) + { + var root = exportedValue.PathSegments.FirstOrDefault() ?? "Values"; + var identifier = string.Join(".", exportedValue.PathSegments); + var leaf = exportedValue.PathSegments.LastOrDefault() ?? "Value"; + var type = MapType(exportedValue.Type, TypePosition.ExportedValue); + var expression = RenderGoExportedValue(exportedValue.Value, exportedValue.Type); + var declaration = $"var {root} = struct {{ {leaf} {type} }}{{ {leaf}: {expression} }}"; + + return new AppHostProjectionModel + { + Status = "supported", + Identifier = identifier, + Declaration = declaration, + SourceFile = SourceFile, + ValueExpression = expression, + Return = new AppHostReturnModel { Type = type, ErrorModel = "none" }, + }; + } + + protected override string MapType(AtsDumpTypeRef? type, TypePosition position, bool optional = false) + { + if (type is null) + { + return "any"; + } + + var category = type.Category.ToLowerInvariant(); + var mapped = category switch + { + "primitive" => MapPrimitive(type.TypeId), + "enum" => GetEnumName(type.TypeId), + "handle" or "type" => GetHandleName(type.TypeId), + "dto" => "*" + GetDtoName(type.TypeId), + "callback" => "func(...any) any", + "array" => $"[]{MapType(type.ElementType, position)}", + "list" => type.IsReadOnly || position is TypePosition.Dto or TypePosition.ExportedValue + ? $"[]{MapType(type.ElementType, position)}" + : $"*List[{MapType(type.ElementType, position)}]", + "dict" => type.IsReadOnly || position is TypePosition.Dto or TypePosition.ExportedValue + ? $"map[{MapType(type.KeyType, position)}]{MapType(type.ValueType, position)}" + : $"*Dict[{MapType(type.KeyType, position)}, {MapType(type.ValueType, position)}]", + "union" => "any", + _ => "any", + }; + + if ((optional || type.IsNullable == true) && !IsNilable(mapped)) + { + mapped = "*" + mapped; + } + + return mapped; + } + + protected override string MapCallback( + IReadOnlyList? parameters, + AtsDumpTypeRef? returnType) + { + if (parameters is null) + { + return "func(...any) any"; + } + + var args = string.Join(", ", parameters.Select(parameter => + $"{SanitizeIdentifier(ToCamelCase(parameter.Name), s_keywords)} {MapType(parameter.Type, TypePosition.Input)}")); + var result = IsVoid(returnType) ? "" : " " + MapType(returnType, TypePosition.Return); + return $"func({args}){result}"; + } + + private string RenderReturn(AtsDumpCapability capability) + { + if (IsVoid(capability.ReturnType)) + { + return "error"; + } + + var type = MapType(capability.ReturnType, TypePosition.Return); + return IsHandle(capability.ReturnType) ? type : $"({type}, error)"; + } + + private string GetHandleName(string typeId) + => _handleNames.GetValueOrDefault(typeId, TypeName(typeId)); + + private string GetDtoName(string typeId) + => _dtoNames.GetValueOrDefault(typeId, TypeName(typeId)); + + private string GetEnumName(string typeId) + => _enumNames.GetValueOrDefault(typeId, TypeName(typeId.Replace("enum:", "", StringComparison.Ordinal))); + + private static bool IsNilable(string type) + => type.StartsWith('*') || type.StartsWith("[]", StringComparison.Ordinal) + || type.StartsWith("map[", StringComparison.Ordinal) + || type.StartsWith("func(", StringComparison.Ordinal) + || type == "any"; + + private static string MapPrimitive(string typeId) => typeId switch + { + "string" or "char" or "Guid" or "Uri" or "DateTime" or "DateTimeOffset" or "DateOnly" or "TimeOnly" => "string", + "number" or "TimeSpan" => "float64", + "boolean" or "bool" => "bool", + "void" => "", + "any" => "any", + "CancellationToken" => "*CancellationToken", + _ => "any", + }; + + private static bool IsDirectOptionsParameter(IReadOnlyList optional) + => optional.Count == 1 && + string.Equals(optional[0].Name, "options", StringComparison.OrdinalIgnoreCase) && + IsOptionsDto(optional[0]); + + private string MapDirectOptionsType(AtsDumpParameter parameter) + { + var type = parameter.Type ?? + throw new InvalidOperationException("A direct options parameter must have a type."); + return IsDto(type) + ? MapType(type, TypePosition.Input).TrimStart('*') + : TypeName(type.TypeId); + } + + private static string FindOriginalProperty(AtsDumpDtoType dto, string projectedName) + => dto.Properties.First(property => ToPascalCase(property.Name) == projectedName).Name; + + private string RenderGoExportedValue(System.Text.Json.JsonElement? value, AtsDumpTypeRef? type) + { + if (value is null || value.Value.ValueKind is System.Text.Json.JsonValueKind.Null or System.Text.Json.JsonValueKind.Undefined) + { + return "nil"; + } + + var element = value.Value; + if (type is null) + { + return element.GetRawText(); + } + + return type.Category.ToLowerInvariant() switch + { + "primitive" => element.ValueKind == System.Text.Json.JsonValueKind.String + ? QuoteString(element.GetString() ?? "") + : element.GetRawText(), + "enum" => $"{GetEnumName(type.TypeId)}({QuoteString(element.GetString() ?? "")})", + "dto" when element.ValueKind == System.Text.Json.JsonValueKind.Object && Dtos.TryGetValue(type.TypeId, out var dto) + => $"&{GetDtoName(type.TypeId)}{{{string.Join(", ", dto.Properties.Where(property => element.TryGetProperty(property.Name, out _)).Select(property => $"{ToPascalCase(property.Name)}: {RenderGoExportedValue(element.GetProperty(property.Name), property.Type)}"))}}}", + "array" or "list" when element.ValueKind == System.Text.Json.JsonValueKind.Array + => $"[]{MapType(type.ElementType, TypePosition.ExportedValue)}{{{string.Join(", ", element.EnumerateArray().Select(item => RenderGoExportedValue(item, type.ElementType)))}}}", + "dict" when element.ValueKind == System.Text.Json.JsonValueKind.Object + => $"map[{MapType(type.KeyType, TypePosition.ExportedValue)}]{MapType(type.ValueType, TypePosition.ExportedValue)}{{{string.Join(", ", element.EnumerateObject().Select(property => $"{QuoteString(property.Name)}: {RenderGoExportedValue(property.Value, type.ValueType)}"))}}}", + _ => element.GetRawText(), + }; + } +} diff --git a/src/tools/AtsJsonGenerator/Adapters/JavaProjectionAdapter.cs b/src/tools/AtsJsonGenerator/Adapters/JavaProjectionAdapter.cs new file mode 100644 index 000000000..cad7abf23 --- /dev/null +++ b/src/tools/AtsJsonGenerator/Adapters/JavaProjectionAdapter.cs @@ -0,0 +1,334 @@ +namespace AtsJsonGenerator.Helpers; + +internal sealed class JavaProjectionAdapter : ProjectionAdapterBase +{ + private const string SourceFile = "Aspire.java"; + private static readonly HashSet s_keywords = new(StringComparer.Ordinal) + { + "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", + "class", "const", "continue", "default", "do", "double", "else", "enum", + "extends", "final", "finally", "float", "for", "goto", "if", "implements", + "import", "instanceof", "int", "interface", "long", "native", "new", "package", + "private", "protected", "public", "return", "short", "static", "strictfp", + "super", "switch", "synchronized", "this", "throw", "throws", "transient", + "try", "void", "volatile", "while", + }; + + private readonly Dictionary _optionsNames = new(StringComparer.Ordinal); + + public JavaProjectionAdapter(AtsDumpRoot dump) + : base(dump) + { + var usedNames = new HashSet( + dump.DtoTypes.Select(dto => dto.Name) + .Concat(dump.EnumTypes.Select(enumType => enumType.Name)) + .Concat(dump.HandleTypes.Select(handle => TypeName(handle.AtsTypeId))), + StringComparer.Ordinal); + foreach (var capability in dump.Capabilities.OrderBy(SourceIdentity, StringComparer.Ordinal)) + { + var optional = VisibleParameters(capability) + .Where(parameter => parameter.IsOptional || parameter.IsNullable) + .ToList(); + if (optional.Count > 1) + { + var methodName = ToPascalCase(capability.MethodName.Split('.').Last()); + var suffix = 0; + string optionsName; + do + { + optionsName = $"{methodName}{(suffix == 0 ? "" : suffix)}Options"; + suffix++; + } + while (!usedNames.Add(optionsName)); + _optionsNames[capability.CapabilityId] = optionsName; + } + } + } + + public override string Language => "java"; + + public override AppHostProjectionModel ProjectCapability(AtsDumpCapability capability) + { + if (HasCallbackDefault(capability)) + { + return Unsupported("Callback defaults cannot be represented faithfully.", SourceFile); + } + + var required = VisibleParameters(capability) + .Where(parameter => !parameter.IsOptional && !parameter.IsNullable) + .ToList(); + var optional = VisibleParameters(capability) + .Where(parameter => parameter.IsOptional || parameter.IsNullable) + .ToList(); + var identifier = SanitizeIdentifier(ToCamelCase(capability.MethodName.Split('.').Last()), s_keywords); + var returnType = capability.ReturnsBuilder && capability.ReturnType is not null + ? TypeName(capability.ReturnType.TypeId) + : IsVoid(capability.ReturnType) + ? "void" + : MapType(capability.ReturnType, TypePosition.Return); + var projected = new List(); + var parameterParts = new List(); + + foreach (var parameter in required) + { + var name = SanitizeIdentifier(ToCamelCase(parameter.Name), s_keywords); + var type = parameter.IsCallback ? MapCallback(parameter) : MapType(parameter.Type, TypePosition.Input); + parameterParts.Add($"{type} {name}"); + projected.Add(Parameter(capability, parameter, name, type, optional: false, defaultValue: null, + callbackSignature: parameter.IsCallback ? type : null)); + } + + string? optionsDeclaration = null; + var overloads = new List(); + if (optional.Count > 1) + { + var optionsName = _optionsNames.GetValueOrDefault( + capability.CapabilityId, + $"{ToPascalCase(capability.MethodName.Split('.').Last())}Options"); + parameterParts.Add($"{optionsName} options"); + projected.Add(new AppHostParameterModel { Name = "options", Type = optionsName, IsOptional = true }); + var fields = optional.Select(parameter => + { + var name = SanitizeIdentifier(ToCamelCase(parameter.Name), s_keywords); + var type = parameter.IsCallback + ? MapCallback(parameter) + : MapType(parameter.Type, TypePosition.Input, optional: true); + return $" private {type} {name};"; + }); + optionsDeclaration = $"final class {optionsName} {{\n{string.Join("\n", fields)}\n}}"; + overloads.Add($"public {returnType} {identifier}({string.Join(", ", parameterParts.Take(parameterParts.Count - 1))})"); + } + else if (optional.Count == 1) + { + var parameter = optional[0]; + var name = SanitizeIdentifier(ToCamelCase(parameter.Name), s_keywords); + var type = parameter.IsCallback + ? MapCallback(parameter) + : MapType(parameter.Type, TypePosition.Input, optional: true); + parameterParts.Add($"{type} {name}"); + projected.Add(Parameter(capability, parameter, name, type, optional: true, defaultValue: null, + callbackSignature: parameter.IsCallback ? type : null)); + overloads.Add($"public {returnType} {identifier}({string.Join(", ", parameterParts.Take(parameterParts.Count - 1))})"); + } + + overloads.AddRange(BuildUnionOverloads(identifier, returnType, required, optional.Count > 1 ? _optionsNames[capability.CapabilityId] : null)); + var signature = $"public {returnType} {identifier}({string.Join(", ", parameterParts)})"; + var declarations = new List(); + if (optionsDeclaration is not null) + { + declarations.Add(optionsDeclaration); + } + declarations.Add(signature + " { ... }"); + declarations.AddRange(overloads.Distinct(StringComparer.Ordinal).Select(overload => overload + " { ... }")); + var callbackFallback = VisibleParameters(capability).Any(parameter => + parameter.IsCallback && (parameter.CallbackParameters?.Count ?? 0) > 4); + var unionFallback = VisibleParameters(capability).Any(parameter => IsUnion(parameter.Type)); + + return new AppHostProjectionModel + { + Status = "supported", + Reason = callbackFallback + ? "Callbacks with more than four parameters use Function." + : unionFallback + ? "Union inputs use AspireUnion plus generated concrete overloads." + : null, + Identifier = identifier, + Signature = signature, + Declaration = string.Join("\n\n", declarations), + SourceFile = SourceFile, + Parameters = projected, + Return = new AppHostReturnModel { Type = returnType, ErrorModel = "exception" }, + }; + } + + public override AppHostProjectionModel ProjectHandle(AtsDumpHandleType handle) + { + var identifier = TypeName(handle.AtsTypeId); + return new AppHostProjectionModel + { + Status = "supported", + Identifier = identifier, + Declaration = $"final class {identifier} extends Handle {{ }}", + SourceFile = SourceFile, + Kind = "class", + ImplementedInterfaces = handle.ImplementedInterfaces.Select(type => TypeName(type.TypeId)).ToList(), + }; + } + + public override AppHostProjectionModel ProjectDto(AtsDumpDtoType dto) + { + var fields = dto.Properties.Select(property => + { + var type = property.IsCallback + ? MapCallback(property.CallbackParameters, property.CallbackReturnType) + : MapType(property.Type, TypePosition.Dto, property.IsOptional); + return Field(property, SanitizeIdentifier(ToCamelCase(property.Name), s_keywords), type, property.IsOptional); + }).ToList(); + var declarations = string.Join("\n", fields.Select(field => $" private {field.Type} {field.Name};")); + return new AppHostProjectionModel + { + Status = "supported", + Reason = dto.Properties.Any(property => property.IsCallback && (property.CallbackParameters?.Count ?? 0) > 4) + ? "Callbacks with more than four parameters use Function." + : null, + Identifier = dto.Name, + Declaration = $"class {dto.Name} implements JsonSerializable {{\n{declarations}\n}}", + SourceFile = SourceFile, + Kind = "class", + Fields = fields, + }; + } + + public override AppHostProjectionModel ProjectEnum(AtsDumpEnumType enumType) + { + var members = Members(enumType, ToUpperSnakeCase, name => name); + var body = string.Join(",\n", members.Select(member => $" {member.Name}(\"{member.Value}\")")); + return new AppHostProjectionModel + { + Status = "supported", + Identifier = enumType.Name, + Declaration = $"enum {enumType.Name} implements WireValueEnum {{\n{body};\n}}", + SourceFile = SourceFile, + Kind = "class", + Members = members, + }; + } + + public override AppHostProjectionModel ProjectExportedValue(AtsDumpExportedValue exportedValue) + { + var root = exportedValue.PathSegments.FirstOrDefault() ?? "Values"; + var leaf = exportedValue.PathSegments.LastOrDefault() ?? "VALUE"; + var identifier = string.Join(".", exportedValue.PathSegments); + var type = MapType(exportedValue.Type, TypePosition.ExportedValue, optional: true); + var expression = RenderJavaExportedValue(exportedValue.Value, exportedValue.Type); + return new AppHostProjectionModel + { + Status = "supported", + Identifier = identifier, + Declaration = $"final class {root} {{ public static final {type} {leaf} = {expression}; }}", + SourceFile = SourceFile, + ValueExpression = expression, + Return = new AppHostReturnModel { Type = type, ErrorModel = "none" }, + }; + } + + protected override string MapType(AtsDumpTypeRef? type, TypePosition position, bool optional = false) + { + if (type is null) + { + return "Object"; + } + + var boxed = optional || position is TypePosition.Dto or TypePosition.ExportedValue || type.IsNullable == true; + return type.Category.ToLowerInvariant() switch + { + "primitive" => MapPrimitive(type.TypeId, boxed), + "enum" or "handle" or "type" or "dto" => TypeName(type.TypeId.Replace("enum:", "", StringComparison.Ordinal)), + "callback" => "Object", + "array" => $"{MapType(type.ElementType, position, type.ElementType?.IsNullable == true)}[]", + "list" => type.IsReadOnly || position is TypePosition.Dto or TypePosition.ExportedValue + ? $"List<{MapType(type.ElementType, position, optional: true)}>" + : $"AspireList<{MapType(type.ElementType, position, optional: true)}>", + "dict" => type.IsReadOnly || position is TypePosition.Dto or TypePosition.ExportedValue + ? $"Map<{MapType(type.KeyType, position, optional: true)}, {MapType(type.ValueType, position, optional: true)}>" + : $"AspireDict<{MapType(type.KeyType, position, optional: true)}, {MapType(type.ValueType, position, optional: true)}>", + "union" => "AspireUnion", + _ => "Object", + }; + } + + protected override string MapCallback( + IReadOnlyList? parameters, + AtsDumpTypeRef? returnType) + { + var count = parameters?.Count ?? 0; + if (count > 4) + { + return "Function"; + } + + var hasReturn = !IsVoid(returnType); + var baseType = hasReturn ? $"AspireFunc{count}" : $"AspireAction{count}"; + var args = (parameters ?? []).Select(parameter => MapType(parameter.Type, TypePosition.Input, optional: true)).ToList(); + if (hasReturn) + { + args.Add(MapType(returnType, TypePosition.Return, optional: true)); + } + + return args.Count == 0 ? baseType : $"{baseType}<{string.Join(", ", args)}>"; + } + + private IEnumerable BuildUnionOverloads( + string methodName, + string returnType, + IReadOnlyList required, + string? optionsType) + { + var union = required.FirstOrDefault(parameter => IsUnion(parameter.Type)); + if (union?.Type?.UnionTypes is not { Count: > 0 } members) + { + return []; + } + + return members.Select(member => + { + var parts = required.Select(parameter => + { + var name = SanitizeIdentifier(ToCamelCase(parameter.Name), s_keywords); + var type = ReferenceEquals(parameter, union) + ? MapType(member, TypePosition.Input) + : parameter.IsCallback + ? MapCallback(parameter) + : MapType(parameter.Type, TypePosition.Input); + return $"{type} {name}"; + }).ToList(); + if (optionsType is not null) + { + parts.Add($"{optionsType} options"); + } + return $"public {returnType} {methodName}({string.Join(", ", parts)})"; + }); + } + + private static string MapPrimitive(string typeId, bool boxed) => typeId switch + { + "string" or "char" or "Guid" or "Uri" or "DateTime" or "DateTimeOffset" or "DateOnly" or "TimeOnly" => "String", + "number" or "TimeSpan" => boxed ? "Number" : "double", + "boolean" or "bool" => boxed ? "Boolean" : "boolean", + "void" => "void", + "any" => "Object", + "CancellationToken" => "CancellationToken", + _ => "Object", + }; + + private string RenderJavaExportedValue(System.Text.Json.JsonElement? value, AtsDumpTypeRef? type) + { + if (value is null || value.Value.ValueKind is System.Text.Json.JsonValueKind.Null or System.Text.Json.JsonValueKind.Undefined) + { + return "null"; + } + + var element = value.Value; + if (type is null) + { + return element.GetRawText(); + } + + return type.Category.ToLowerInvariant() switch + { + "primitive" => element.ValueKind == System.Text.Json.JsonValueKind.String + ? QuoteString(element.GetString() ?? "") + : element.GetRawText(), + "enum" => $"{TypeName(type.TypeId.Replace("enum:", "", StringComparison.Ordinal))}.fromValue({QuoteString(element.GetString() ?? "")})", + "dto" when element.ValueKind == System.Text.Json.JsonValueKind.Object && Dtos.TryGetValue(type.TypeId, out var dto) + => $"new {dto.Name}() {{{{ {string.Join(" ", dto.Properties.Where(property => element.TryGetProperty(property.Name, out _)).Select(property => $"set{ToPascalCase(property.Name)}({RenderJavaExportedValue(element.GetProperty(property.Name), property.Type)});"))} }}}}", + "array" when element.ValueKind == System.Text.Json.JsonValueKind.Array + => $"new {MapType(type.ElementType, TypePosition.ExportedValue, optional: true)}[] {{ {string.Join(", ", element.EnumerateArray().Select(item => RenderJavaExportedValue(item, type.ElementType)))} }}", + "list" when element.ValueKind == System.Text.Json.JsonValueKind.Array + => $"new ArrayList<>(List.of({string.Join(", ", element.EnumerateArray().Select(item => RenderJavaExportedValue(item, type.ElementType)))}))", + "dict" when element.ValueKind == System.Text.Json.JsonValueKind.Object + => $"new HashMap<>(Map.ofEntries({string.Join(", ", element.EnumerateObject().Select(property => $"Map.entry({QuoteString(property.Name)}, {RenderJavaExportedValue(property.Value, type.ValueType)})"))}))", + _ => element.GetRawText(), + }; + } +} diff --git a/src/tools/AtsJsonGenerator/Adapters/ProjectionAdapterBase.cs b/src/tools/AtsJsonGenerator/Adapters/ProjectionAdapterBase.cs new file mode 100644 index 000000000..b91139127 --- /dev/null +++ b/src/tools/AtsJsonGenerator/Adapters/ProjectionAdapterBase.cs @@ -0,0 +1,414 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace AtsJsonGenerator.Helpers; + +internal interface ILanguageProjectionAdapter +{ + string Language { get; } + + AppHostProjectionModel ProjectCapability(AtsDumpCapability capability); + + AppHostProjectionModel ProjectHandle(AtsDumpHandleType handle); + + AppHostProjectionModel ProjectDto(AtsDumpDtoType dto); + + AppHostProjectionModel ProjectEnum(AtsDumpEnumType enumType); + + AppHostProjectionModel ProjectExportedValue(AtsDumpExportedValue exportedValue); +} + +internal abstract class ProjectionAdapterBase : ILanguageProjectionAdapter +{ + protected ProjectionAdapterBase(AtsDumpRoot dump) + { + Dump = dump; + Handles = dump.HandleTypes.ToDictionary(h => h.AtsTypeId, StringComparer.Ordinal); + Dtos = dump.DtoTypes.ToDictionary(d => d.TypeId, StringComparer.Ordinal); + Enums = dump.EnumTypes.ToDictionary(e => e.TypeId, StringComparer.Ordinal); + } + + protected AtsDumpRoot Dump { get; } + + protected IReadOnlyDictionary Handles { get; } + + protected IReadOnlyDictionary Dtos { get; } + + protected IReadOnlyDictionary Enums { get; } + + public abstract string Language { get; } + + public abstract AppHostProjectionModel ProjectCapability(AtsDumpCapability capability); + + public abstract AppHostProjectionModel ProjectHandle(AtsDumpHandleType handle); + + public abstract AppHostProjectionModel ProjectDto(AtsDumpDtoType dto); + + public abstract AppHostProjectionModel ProjectEnum(AtsDumpEnumType enumType); + + public abstract AppHostProjectionModel ProjectExportedValue(AtsDumpExportedValue exportedValue); + + protected abstract string MapType(AtsDumpTypeRef? type, TypePosition position, bool optional = false); + + protected virtual string MapCallback(AtsDumpParameter parameter) + => MapCallback(parameter.CallbackParameters, parameter.CallbackReturnType); + + protected abstract string MapCallback( + IReadOnlyList? parameters, + AtsDumpTypeRef? returnType); + + protected static IEnumerable VisibleParameters(AtsDumpCapability capability) + => capability.Parameters.Where(parameter => + !string.Equals(parameter.Name, capability.TargetParameterName, StringComparison.Ordinal)); + + protected static string TypeName(string typeId) + { + var fullName = AtsTransformer.StripAssemblyPrefix(typeId); + var tick = fullName.IndexOf('`'); + if (tick >= 0) + { + fullName = fullName[..tick]; + } + + var delimiter = Math.Max(fullName.LastIndexOf('.'), fullName.LastIndexOf('+')); + return delimiter >= 0 ? fullName[(delimiter + 1)..] : fullName; + } + + protected static string EnumFullName(AtsDumpEnumType enumType) + => enumType.TypeId.StartsWith("enum:", StringComparison.Ordinal) + ? enumType.TypeId["enum:".Length..] + : AtsTransformer.StripAssemblyPrefix(enumType.TypeId); + + protected static string DocumentationSummary(AtsDumpDocumentation? documentation, string? fallback = null) + => NormalizeDocumentation(documentation?.Summary) ?? NormalizeDocumentation(fallback) ?? ""; + + protected static string? NormalizeDocumentation(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + protected static string? ParameterDescription(AtsDumpCapability capability, string parameterName) + => NormalizeDocumentation( + capability.Documentation?.Parameters + .FirstOrDefault(parameter => string.Equals(parameter.Name, parameterName, StringComparison.Ordinal)) + ?.Description); + + protected static bool IsCancellationToken(AtsDumpTypeRef? type) + => type is not null && + (string.Equals(TypeName(type.TypeId), "CancellationToken", StringComparison.OrdinalIgnoreCase) + || type.TypeId.EndsWith("/System.Threading.CancellationToken", StringComparison.Ordinal)); + + protected static bool IsVoid(AtsDumpTypeRef? type) + => type is null || string.Equals(type.TypeId, "void", StringComparison.OrdinalIgnoreCase); + + protected static bool IsHandle(AtsDumpTypeRef? type) + => string.Equals(type?.Category, "Handle", StringComparison.OrdinalIgnoreCase) + || string.Equals(type?.Category, "Type", StringComparison.OrdinalIgnoreCase); + + protected static bool IsDto(AtsDumpTypeRef? type) + => string.Equals(type?.Category, "Dto", StringComparison.OrdinalIgnoreCase); + + protected static bool IsOptionsDto(AtsDumpParameter parameter) + => !parameter.IsCallback && + parameter.Type is { } type && + (IsDto(type) || + TypeName(type.TypeId).EndsWith("Options", StringComparison.Ordinal)); + + protected static bool IsUnion(AtsDumpTypeRef? type) + => string.Equals(type?.Category, "Union", StringComparison.OrdinalIgnoreCase); + + protected static bool IsArray(AtsDumpTypeRef? type) + => string.Equals(type?.Category, "Array", StringComparison.OrdinalIgnoreCase); + + protected static bool IsList(AtsDumpTypeRef? type) + => string.Equals(type?.Category, "List", StringComparison.OrdinalIgnoreCase); + + protected static bool IsDict(AtsDumpTypeRef? type) + => string.Equals(type?.Category, "Dict", StringComparison.OrdinalIgnoreCase); + + protected static bool HasCallbackDefault(AtsDumpCapability capability) + => VisibleParameters(capability).Any(parameter => + parameter.IsCallback && parameter.DefaultValue is not null); + + protected AppHostProjectionModel Unsupported(string reason, string sourceFile) + => new() + { + Status = "unsupported", + Reason = reason, + SourceFile = sourceFile, + }; + + protected AppHostParameterModel Parameter( + AtsDumpCapability capability, + AtsDumpParameter parameter, + string name, + string type, + bool optional, + string? defaultValue, + string? callbackSignature = null) + => new() + { + Name = name, + Type = type, + IsOptional = optional, + IsNullable = parameter.IsNullable || parameter.Type?.IsNullable == true, + DefaultValue = defaultValue, + IsCallback = parameter.IsCallback, + CallbackSignature = callbackSignature, + Description = ParameterDescription(capability, parameter.Name), + }; + + protected AppHostFieldModel Field( + AtsDumpDtoProperty property, + string name, + string type, + bool optional) + => new() + { + Name = name, + Type = type, + IsOptional = optional, + IsNullable = property.IsNullable || property.Type?.IsNullable == true, + Description = NormalizeDocumentation(property.Documentation?.Summary) + ?? NormalizeDocumentation(property.Description), + }; + + protected static List Members( + AtsDumpEnumType enumType, + Func nameSelector, + Func valueSelector) + { + var docs = enumType.ValueInfos.ToDictionary( + value => value.Name, + value => NormalizeDocumentation(value.Documentation?.Summary), + StringComparer.Ordinal); + + return enumType.Values.Select(value => new AppHostEnumMemberModel + { + Name = nameSelector(value), + Value = valueSelector(value), + Description = docs.GetValueOrDefault(value), + }).ToList(); + } + + protected static string ToCamelCase(string name) + { + if (string.IsNullOrEmpty(name) || char.IsLower(name[0])) + { + return name; + } + + return char.ToLowerInvariant(name[0]) + name[1..]; + } + + protected static string ToPascalCase(string name) + { + if (string.IsNullOrEmpty(name) || char.IsUpper(name[0])) + { + return name; + } + + return char.ToUpperInvariant(name[0]) + name[1..]; + } + + protected static string ToSnakeCase(string name) + { + if (string.IsNullOrEmpty(name)) + { + return name; + } + + var builder = new StringBuilder(name.Length + 8); + for (var index = 0; index < name.Length; index++) + { + var character = name[index]; + if (char.IsUpper(character) && + index > 0 && + (char.IsLower(name[index - 1]) || + (index + 1 < name.Length && char.IsLower(name[index + 1])))) + { + builder.Append('_'); + } + + builder.Append(char.ToLowerInvariant(character)); + } + + return builder.ToString().Replace('-', '_'); + } + + protected static string ToUpperSnakeCase(string name) + => ToSnakeCase(name).ToUpperInvariant(); + + protected static string StripAsyncSuffix(string name) + => name.EndsWith("_async", StringComparison.Ordinal) + ? name[..^"_async".Length] + : name; + + protected static string QuoteString(string value, char quote = '"') + { + var escaped = value.Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace(quote.ToString(), $"\\{quote}", StringComparison.Ordinal) + .Replace("\r", "\\r", StringComparison.Ordinal) + .Replace("\n", "\\n", StringComparison.Ordinal); + return $"{quote}{escaped}{quote}"; + } + + protected static string MapDefault( + string? value, + string nullLiteral, + string trueLiteral, + string falseLiteral, + Func? stringLiteral = null) + { + if (value is null || + string.Equals(value, "null", StringComparison.OrdinalIgnoreCase)) + { + return nullLiteral; + } + + if (bool.TryParse(value, out var boolean)) + { + return boolean ? trueLiteral : falseLiteral; + } + + if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _)) + { + return value; + } + + if (value.Length >= 2 && + ((value[0] == '"' && value[^1] == '"') || + (value[0] == '\'' && value[^1] == '\''))) + { + value = value[1..^1]; + } + + return stringLiteral is null ? QuoteString(value) : stringLiteral(value); + } + + protected string RenderJsonValue( + JsonElement? element, + AtsDumpTypeRef? type, + Func propertyName, + string nullLiteral, + string trueLiteral, + string falseLiteral, + Func stringLiteral, + string arrayOpen = "[", + string arrayClose = "]", + string objectOpen = "{ ", + string objectClose = " }", + string keyValueSeparator = ": ") + { + if (element is null || element.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return nullLiteral; + } + + var value = element.Value; + return value.ValueKind switch + { + JsonValueKind.String => stringLiteral(value.GetString() ?? ""), + JsonValueKind.True => trueLiteral, + JsonValueKind.False => falseLiteral, + JsonValueKind.Number => value.GetRawText(), + JsonValueKind.Array => arrayOpen + string.Join(", ", value.EnumerateArray().Select(item => + RenderJsonValue(item, type?.ElementType, propertyName, nullLiteral, trueLiteral, falseLiteral, stringLiteral, + arrayOpen, arrayClose, objectOpen, objectClose, keyValueSeparator))) + arrayClose, + JsonValueKind.Object => objectOpen + string.Join(", ", value.EnumerateObject().Select(property => + $"{propertyName(property.Name)}{keyValueSeparator}{RenderJsonValue(property.Value, ResolvePropertyType(type, property.Name), propertyName, nullLiteral, trueLiteral, falseLiteral, stringLiteral, arrayOpen, arrayClose, objectOpen, objectClose, keyValueSeparator)}")) + objectClose, + _ => value.GetRawText(), + }; + } + + private AtsDumpTypeRef? ResolvePropertyType(AtsDumpTypeRef? type, string propertyName) + { + if (type is null) + { + return null; + } + + if (IsDict(type)) + { + return type.ValueType; + } + + if (IsDto(type) && Dtos.TryGetValue(type.TypeId, out var dto)) + { + return dto.Properties.FirstOrDefault(property => + string.Equals(property.Name, propertyName, StringComparison.Ordinal))?.Type; + } + + return null; + } + + protected static string SanitizeIdentifier(string value, ISet keywords) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "_"; + } + + var builder = new StringBuilder(value.Length); + foreach (var character in value) + { + builder.Append(char.IsLetterOrDigit(character) || character == '_' ? character : '_'); + } + + if (!char.IsLetter(builder[0]) && builder[0] != '_') + { + builder.Insert(0, '_'); + } + + var result = builder.ToString(); + return keywords.Contains(result) ? result + "_" : result; + } + + protected static string SourceIdentity(AtsDumpCapability capability) + => string.IsNullOrEmpty(capability.CapabilityId) + ? capability.QualifiedMethodName + : capability.CapabilityId; +} + +internal enum TypePosition +{ + Input, + Return, + Dto, + ExportedValue, +} + +internal sealed class IdentifierAllocator +{ + private readonly HashSet _used; + + public IdentifierAllocator(IEnumerable? reserved = null, StringComparer? comparer = null) + { + _used = new HashSet(reserved ?? [], comparer ?? StringComparer.Ordinal); + } + + public string Reserve(string preferred, string? qualifier = null) + { + if (_used.Add(preferred)) + { + return preferred; + } + + if (!string.IsNullOrEmpty(qualifier)) + { + var qualified = qualifier + preferred; + if (_used.Add(qualified)) + { + return qualified; + } + } + + for (var suffix = 1; ; suffix++) + { + var candidate = preferred + suffix.ToString(CultureInfo.InvariantCulture); + if (_used.Add(candidate)) + { + return candidate; + } + } + } +} diff --git a/src/tools/AtsJsonGenerator/Adapters/PythonProjectionAdapter.cs b/src/tools/AtsJsonGenerator/Adapters/PythonProjectionAdapter.cs new file mode 100644 index 000000000..696c5ecd6 --- /dev/null +++ b/src/tools/AtsJsonGenerator/Adapters/PythonProjectionAdapter.cs @@ -0,0 +1,277 @@ +namespace AtsJsonGenerator.Helpers; + +internal sealed class PythonProjectionAdapter : ProjectionAdapterBase +{ + private const string SourceFile = "aspire.py"; + private static readonly HashSet s_keywords = new(StringComparer.Ordinal) + { + "and", "as", "assert", "async", "await", "break", "class", "continue", "def", + "del", "elif", "else", "except", "False", "finally", "for", "from", "global", + "if", "import", "in", "is", "lambda", "None", "nonlocal", "not", "or", + "pass", "raise", "return", "True", "try", "while", "with", "yield", + }; + + public PythonProjectionAdapter(AtsDumpRoot dump) + : base(dump) + { + } + + public override string Language => "python"; + + public override AppHostProjectionModel ProjectCapability(AtsDumpCapability capability) + { + if (HasCallbackDefault(capability)) + { + return Unsupported("Callback defaults cannot be represented faithfully.", SourceFile); + } + + var projected = new List(); + var requiredParts = new List(); + var optionalParts = new List(); + foreach (var parameter in VisibleParameters(capability)) + { + var name = IsCancellationToken(parameter.Type) + ? "timeout" + : SanitizeIdentifier(ToPythonSnakeCase(parameter.Name), s_keywords); + var type = parameter.IsCallback + ? MapCallback(parameter) + : IsCancellationToken(parameter.Type) + ? "int" + : MapType(parameter.Type, TypePosition.Input); + var optional = parameter.IsOptional || parameter.IsNullable || IsCancellationToken(parameter.Type); + var pythonDefault = optional + ? MapDefault(parameter.DefaultValue, "None", "True", "False", value => QuoteString(value)) + : null; + var renderedType = type; + var defaultValue = pythonDefault; + if (optional && pythonDefault == "None" && !AllowsNone(renderedType)) + { + renderedType += " | None"; + } + else if (optional && pythonDefault is not null) + { + if (parameter.IsNullable && !AllowsNone(renderedType)) + { + renderedType += " | None"; + } + + if (parameter.IsNullable || AllowsNone(type)) + { + defaultValue = $"typing.cast({renderedType}, _ASPIRE_UNSET)"; + } + } + var part = optional + ? $"{name}: {renderedType} = {defaultValue}" + : $"{name}: {type}"; + (optional ? optionalParts : requiredParts).Add(part); + projected.Add(Parameter( + capability, + parameter, + name, + renderedType, + optional, + defaultValue, + parameter.IsCallback ? type : null)); + } + + var signatureParts = new List(); + if (capability.TargetTypeId is not null) + { + signatureParts.Add("self"); + } + signatureParts.AddRange(requiredParts); + if (optionalParts.Count > 0) + { + signatureParts.Add("*"); + signatureParts.AddRange(optionalParts); + } + + var identifier = SanitizeIdentifier( + StripAsyncSuffix(ToPythonSnakeCase(capability.MethodName.Split('.').Last())), + s_keywords); + var returnType = IsVoid(capability.ReturnType) + ? "None" + : MapType(capability.ReturnType, TypePosition.Return); + var signature = $"def {identifier}({string.Join(", ", signatureParts)}) -> {returnType}"; + + return new AppHostProjectionModel + { + Status = "supported", + Identifier = identifier, + Signature = signature, + Declaration = signature + ": ...", + SourceFile = SourceFile, + Parameters = projected, + Return = new AppHostReturnModel { Type = returnType, ErrorModel = "exception" }, + }; + } + + public override AppHostProjectionModel ProjectHandle(AtsDumpHandleType handle) + { + var name = TypeName(handle.AtsTypeId); + if (handle.IsInterface && GenericArity(handle.AtsTypeId) > 1) + { + return Unsupported("Python cannot project generic handle interfaces with more than one type argument.", SourceFile); + } + + return new AppHostProjectionModel + { + Status = "supported", + Identifier = name, + Declaration = $"class {name}(Handle): ...", + SourceFile = SourceFile, + Kind = "class", + ImplementedInterfaces = handle.ImplementedInterfaces.Select(type => TypeName(type.TypeId)).ToList(), + }; + } + + public override AppHostProjectionModel ProjectDto(AtsDumpDtoType dto) + { + var fields = dto.Properties.Select(property => + { + var type = property.IsCallback + ? MapCallback(property.CallbackParameters, property.CallbackReturnType) + : MapType(property.Type, TypePosition.Dto); + return Field(property, property.Name, type, optional: true); + }).ToList(); + var body = fields.Count == 0 + ? " pass" + : string.Join("\n", fields.Select(field => $" {field.Name}: {field.Type}")); + + return new AppHostProjectionModel + { + Status = "supported", + Identifier = dto.Name, + Declaration = $"class {dto.Name}(typing.TypedDict, total=False):\n{body}", + SourceFile = SourceFile, + Kind = "class", + Fields = fields, + }; + } + + public override AppHostProjectionModel ProjectEnum(AtsDumpEnumType enumType) + { + var members = Members(enumType, name => name, name => name); + var literals = string.Join(", ", enumType.Values.Select(value => QuoteString(value))); + return new AppHostProjectionModel + { + Status = "supported", + Identifier = enumType.Name, + Declaration = $"{enumType.Name} = typing.Literal[{literals}]", + SourceFile = SourceFile, + Kind = "class", + Members = members, + }; + } + + public override AppHostProjectionModel ProjectExportedValue(AtsDumpExportedValue exportedValue) + { + var path = string.Join(".", exportedValue.PathSegments); + var identifier = exportedValue.PathSegments.LastOrDefault() ?? "value"; + var expression = RenderJsonValue( + exportedValue.Value, + exportedValue.Type, + property => QuoteString(property), + "None", + "True", + "False", + value => QuoteString(value), + arrayOpen: "[", + arrayClose: "]", + objectOpen: "{", + objectClose: "}"); + var type = MapType(exportedValue.Type, TypePosition.ExportedValue); + + return new AppHostProjectionModel + { + Status = "supported", + Identifier = identifier, + Declaration = $"{path} = {expression}", + SourceFile = SourceFile, + ValueExpression = expression, + Return = new AppHostReturnModel { Type = type, ErrorModel = "none" }, + }; + } + + protected override string MapType(AtsDumpTypeRef? type, TypePosition position, bool optional = false) + { + if (type is null) + { + return "typing.Any"; + } + + var category = type.Category.ToLowerInvariant(); + var mapped = category switch + { + "primitive" => MapPrimitive(type.TypeId), + "enum" or "handle" or "type" or "dto" => TypeName(type.TypeId.Replace("enum:", "", StringComparison.Ordinal)), + "callback" => "typing.Callable", + "array" => $"typing.Iterable[{MapType(type.ElementType, position)}]", + "list" => position == TypePosition.Dto + ? $"typing.Iterable[{MapType(type.ElementType, position)}]" + : $"AspireList[{MapType(type.ElementType, position)}]", + "dict" => type.IsReadOnly || position is TypePosition.Dto or TypePosition.ExportedValue + ? $"typing.Mapping[{MapType(type.KeyType, position)}, {MapType(type.ValueType, position)}]" + : $"AspireDict[{MapType(type.KeyType, position)}, {MapType(type.ValueType, position)}]", + "union" => string.Join(" | ", (type.UnionTypes ?? []).Select(member => MapType(member, position)).Distinct(StringComparer.Ordinal)), + _ => "typing.Any", + }; + + if (type.IsNullable == true && + category is "primitive" or "enum" && + type.TypeId is not ("void" or "any" or "CancellationToken") && + !AllowsNone(mapped)) + { + mapped += " | None"; + } + + return mapped; + } + + protected override string MapCallback( + IReadOnlyList? parameters, + AtsDumpTypeRef? returnType) + { + var args = string.Join(", ", (parameters ?? []).Select(parameter => + MapType(parameter.Type, TypePosition.Input))); + var result = IsVoid(returnType) ? "None" : MapType(returnType, TypePosition.Return); + return $"typing.Callable[[{args}], {result}]"; + } + + private static string ToPythonSnakeCase(string value) + => ToSnakeCase(value) + .Replace("environment", "env", StringComparison.Ordinal) + .Replace("configuration", "config", StringComparison.Ordinal) + .Replace("application", "app", StringComparison.Ordinal) + .Replace("variable", "var", StringComparison.Ordinal) + .Replace("directory", "dir", StringComparison.Ordinal); + + private static string MapPrimitive(string typeId) => typeId switch + { + "string" or "char" or "Guid" or "Uri" => "str", + "number" => "int", + "boolean" or "bool" => "bool", + "void" => "None", + "any" => "typing.Any", + "DateTime" or "DateTimeOffset" => "datetime.datetime", + "DateOnly" => "datetime.date", + "TimeOnly" => "datetime.time", + "TimeSpan" => "float", + "CancellationToken" => "CancellationToken", + _ => typeId, + }; + + private static bool AllowsNone(string type) + => type.Split(" | ", StringSplitOptions.TrimEntries).Contains("None", StringComparer.Ordinal); + + private static int GenericArity(string typeId) + { + var tick = typeId.IndexOf('`'); + if (tick < 0 || tick + 1 >= typeId.Length) + { + return 0; + } + + return int.TryParse(typeId.AsSpan(tick + 1, 1), out var arity) ? arity : 0; + } +} diff --git a/src/tools/AtsJsonGenerator/Adapters/RustProjectionAdapter.cs b/src/tools/AtsJsonGenerator/Adapters/RustProjectionAdapter.cs new file mode 100644 index 000000000..57e0fd5a4 --- /dev/null +++ b/src/tools/AtsJsonGenerator/Adapters/RustProjectionAdapter.cs @@ -0,0 +1,243 @@ +namespace AtsJsonGenerator.Helpers; + +internal sealed class RustProjectionAdapter : ProjectionAdapterBase +{ + private const string SourceFile = "lib.rs"; + private static readonly HashSet s_keywords = new(StringComparer.Ordinal) + { + "as", "break", "const", "continue", "crate", "else", "enum", "extern", + "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", + "mod", "move", "mut", "pub", "ref", "return", "self", "Self", "static", + "struct", "super", "trait", "true", "type", "unsafe", "use", "where", "while", + "async", "await", "dyn", + }; + + public RustProjectionAdapter(AtsDumpRoot dump) + : base(dump) + { + } + + public override string Language => "rust"; + + public override AppHostProjectionModel ProjectCapability(AtsDumpCapability capability) + { + if (HasCallbackDefault(capability)) + { + return Unsupported("Callback defaults cannot be represented faithfully.", SourceFile); + } + + var projected = new List(); + var parts = new List(); + if (capability.TargetTypeId is not null) + { + parts.Add("&self"); + } + + foreach (var parameter in VisibleParameters(capability)) + { + var name = SanitizeIdentifier(ToSnakeCase(parameter.Name), s_keywords); + var optional = parameter.IsOptional || IsCancellationToken(parameter.Type); + string type; + if (parameter.IsCallback) + { + type = MapCallback(parameter); + } + else if (IsCancellationToken(parameter.Type)) + { + type = "Option<&CancellationToken>"; + } + else if (IsHandle(parameter.Type)) + { + var handle = TypeName(parameter.Type!.TypeId); + type = optional ? $"Option<&{handle}>" : $"&{handle}"; + } + else + { + type = MapType(parameter.Type, TypePosition.Input, optional); + } + + parts.Add($"{name}: {type}"); + projected.Add(Parameter( + capability, + parameter, + name, + type, + optional, + defaultValue: optional ? "None" : null, + callbackSignature: parameter.IsCallback ? type : null)); + } + + var identifier = SanitizeIdentifier(ToSnakeCase(capability.MethodName.Split('.').Last()), s_keywords); + var innerReturn = IsVoid(capability.ReturnType) + ? "()" + : MapType(capability.ReturnType, TypePosition.Return); + var returnType = $"Result<{innerReturn}, Box>"; + var signature = $"pub fn {identifier}({string.Join(", ", parts)}) -> {returnType}"; + var limitations = new List(); + if (VisibleParameters(capability).Any(parameter => IsUnion(parameter.Type))) + { + limitations.Add("Union values are represented as serde_json::Value."); + } + if (capability.CapabilityId == "Aspire.Hosting/addExecutable") + { + limitations.Add( + "Runtime invocation with executable arguments can fail because the ATS server cannot deserialize the String[] argument contract."); + } + + return new AppHostProjectionModel + { + Status = "supported", + Reason = limitations.Count > 0 ? string.Join(" ", limitations) : null, + Identifier = identifier, + Signature = signature, + Declaration = signature + " { ... }", + SourceFile = SourceFile, + Parameters = projected, + Return = new AppHostReturnModel { Type = returnType, ErrorModel = "result" }, + }; + } + + public override AppHostProjectionModel ProjectHandle(AtsDumpHandleType handle) + { + var identifier = TypeName(handle.AtsTypeId); + return new AppHostProjectionModel + { + Status = "supported", + Identifier = identifier, + Declaration = $"pub struct {identifier} {{ handle: Handle, client: Arc }}", + SourceFile = SourceFile, + Kind = "class", + ImplementedInterfaces = handle.ImplementedInterfaces.Select(type => TypeName(type.TypeId)).ToList(), + }; + } + + public override AppHostProjectionModel ProjectDto(AtsDumpDtoType dto) + { + var fields = dto.Properties.Select(property => + { + var type = property.IsCallback + ? "Value" + : MapType(property.Type, TypePosition.Dto, property.IsOptional); + return Field( + property, + SanitizeIdentifier(ToSnakeCase(property.Name), s_keywords), + type, + property.IsOptional); + }).ToList(); + var body = string.Join("\n", fields.Select(field => $" pub {field.Name}: {field.Type},")); + + return new AppHostProjectionModel + { + Status = "supported", + Reason = dto.Properties.Any(property => property.IsCallback) + ? "DTO callback fields are represented as serde_json::Value because closures are not serde-serializable." + : null, + Identifier = dto.Name, + Declaration = $"#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct {dto.Name} {{\n{body}\n}}", + SourceFile = SourceFile, + Kind = "class", + Fields = fields, + }; + } + + public override AppHostProjectionModel ProjectEnum(AtsDumpEnumType enumType) + { + var members = Members(enumType, ToPascalCase, name => name); + var body = string.Join("\n", members.Select((member, index) => + $"{(index == 0 ? " #[default]\n" : "")} #[serde(rename = \"{member.Value}\")]\n {member.Name},")); + return new AppHostProjectionModel + { + Status = "supported", + Identifier = enumType.Name, + Declaration = $"#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]\npub enum {enumType.Name} {{\n{body}\n}}", + SourceFile = SourceFile, + Kind = "class", + Members = members, + }; + } + + public override AppHostProjectionModel ProjectExportedValue(AtsDumpExportedValue exportedValue) + { + var modules = exportedValue.PathSegments.Take(Math.Max(0, exportedValue.PathSegments.Count - 1)) + .Select(segment => SanitizeIdentifier(ToSnakeCase(segment), s_keywords)) + .ToList(); + var identifier = SanitizeIdentifier( + ToSnakeCase(exportedValue.PathSegments.LastOrDefault() ?? "value"), + s_keywords); + var type = MapType(exportedValue.Type, TypePosition.ExportedValue); + var expression = RenderJsonValue( + exportedValue.Value, + exportedValue.Type, + property => QuoteString(property), + "null", + "true", + "false", + value => QuoteString(value)); + var declaration = $"pub fn {identifier}() -> {type} {{ serde_json::from_value(json!({expression})).expect(\"generated exported value should deserialize\") }}"; + for (var index = modules.Count - 1; index >= 0; index--) + { + declaration = $"pub mod {modules[index]} {{ {declaration} }}"; + } + + return new AppHostProjectionModel + { + Status = "supported", + Identifier = string.Join("::", modules.Append(identifier)), + Declaration = declaration, + SourceFile = SourceFile, + ValueExpression = $"json!({expression})", + Return = new AppHostReturnModel { Type = type, ErrorModel = "none" }, + }; + } + + protected override string MapType(AtsDumpTypeRef? type, TypePosition position, bool optional = false) + { + if (type is null) + { + return "Value"; + } + + var category = type.Category.ToLowerInvariant(); + var mapped = category switch + { + "primitive" => MapPrimitive(type.TypeId, position), + "enum" or "handle" or "type" or "dto" => TypeName(type.TypeId.Replace("enum:", "", StringComparison.Ordinal)), + "callback" => position == TypePosition.Dto + ? "Value" + : "Box) -> Value + Send + Sync>", + "array" => $"Vec<{MapType(type.ElementType, position)}>", + "list" => type.IsReadOnly || position is TypePosition.Dto or TypePosition.ExportedValue + ? $"Vec<{MapType(type.ElementType, position)}>" + : $"AspireList<{MapType(type.ElementType, position)}>", + "dict" => type.IsReadOnly || position is TypePosition.Dto or TypePosition.ExportedValue + ? $"HashMap<{MapType(type.KeyType, position)}, {MapType(type.ValueType, position)}>" + : $"AspireDict<{MapType(type.KeyType, position)}, {MapType(type.ValueType, position)}>", + "union" => "Value", + _ => "Value", + }; + + if ((optional || type.IsNullable == true) && + !mapped.StartsWith("Option<", StringComparison.Ordinal)) + { + mapped = $"Option<{mapped}>"; + } + return mapped; + } + + protected override string MapCallback( + IReadOnlyList? parameters, + AtsDumpTypeRef? returnType) + => "impl Fn(Vec) -> Value + Send + Sync + 'static"; + + private static string MapPrimitive(string typeId, TypePosition position) => typeId switch + { + "string" or "char" or "Guid" or "Uri" or "DateTime" or "DateTimeOffset" or "DateOnly" or "TimeOnly" + => position == TypePosition.Input ? "&str" : "String", + "number" or "TimeSpan" => "f64", + "boolean" or "bool" => "bool", + "void" => "()", + "any" => position == TypePosition.Input ? "&Value" : "Value", + "CancellationToken" => position == TypePosition.Input ? "&CancellationToken" : "CancellationToken", + _ => "Value", + }; +} diff --git a/src/tools/AtsJsonGenerator/Adapters/TypeScriptProjectionAdapter.cs b/src/tools/AtsJsonGenerator/Adapters/TypeScriptProjectionAdapter.cs new file mode 100644 index 000000000..e9eefcae9 --- /dev/null +++ b/src/tools/AtsJsonGenerator/Adapters/TypeScriptProjectionAdapter.cs @@ -0,0 +1,357 @@ +namespace AtsJsonGenerator.Helpers; + +internal sealed class TypeScriptProjectionAdapter : ProjectionAdapterBase +{ + private const string SourceFile = "aspire.mts"; + private readonly Dictionary _optionsNames = new(StringComparer.Ordinal); + + public TypeScriptProjectionAdapter(AtsDumpRoot dump) + : base(dump) + { + var used = dump.DtoTypes.Select(dto => dto.Name) + .Concat(dump.EnumTypes.Select(enumType => enumType.Name)) + .Concat(dump.HandleTypes.Select(handle => TypeName(handle.AtsTypeId))) + .ToHashSet(StringComparer.Ordinal); + + foreach (var capability in dump.Capabilities.OrderBy(SourceIdentity, StringComparer.Ordinal)) + { + var optional = VisibleParameters(capability) + .Where(parameter => parameter.IsOptional || parameter.IsNullable) + .ToList(); + if (optional.Count == 0 || TryGetDirectOptionsParameter(optional, out _)) + { + continue; + } + + var preferred = $"{ToPascalCase(SimpleMethodName(capability.MethodName))}Options"; + var capabilityName = capability.CapabilityId.Split('/').LastOrDefault(); + if (used.Add(preferred)) + { + _optionsNames[capability.CapabilityId] = preferred; + continue; + } + + var alternate = $"{ToPascalCase(capabilityName ?? SimpleMethodName(capability.MethodName))}Options"; + if (used.Add(alternate)) + { + _optionsNames[capability.CapabilityId] = alternate; + continue; + } + + for (var suffix = 1; ; suffix++) + { + var candidate = $"{alternate[..^"Options".Length]}{suffix}Options"; + if (used.Add(candidate)) + { + _optionsNames[capability.CapabilityId] = candidate; + break; + } + } + } + } + + public override string Language => "typescript"; + + public override AppHostProjectionModel ProjectCapability(AtsDumpCapability capability) + { + if (HasCallbackDefault(capability)) + { + return Unsupported("Callback defaults cannot be represented faithfully.", SourceFile); + } + + if (VisibleParameters(capability).Any(parameter => HasEmptyUnion(parameter.Type)) || + HasEmptyUnion(capability.ReturnType)) + { + return Unsupported("Union types must declare at least one member.", SourceFile); + } + + var required = VisibleParameters(capability) + .Where(parameter => !parameter.IsOptional && !parameter.IsNullable) + .ToList(); + var optional = VisibleParameters(capability) + .Where(parameter => parameter.IsOptional || parameter.IsNullable) + .ToList(); + var projected = new List(); + var signatureParts = new List(); + + foreach (var parameter in required) + { + var type = parameter.IsCallback ? MapCallback(parameter) : MapType(parameter.Type, TypePosition.Input); + projected.Add(Parameter(capability, parameter, parameter.Name, type, optional: false, defaultValue: null, + callbackSignature: parameter.IsCallback ? type : null)); + signatureParts.Add($"{parameter.Name}: {type}"); + } + + string? optionsDeclaration = null; + if (optional.Count > 0) + { + if (TryGetDirectOptionsParameter(optional, out var directOptions)) + { + var typeRef = directOptions!.Type ?? + throw new InvalidOperationException("A direct options parameter must have a type."); + var type = IsDto(typeRef) + ? MapType(typeRef, TypePosition.Input) + : TypeName(typeRef.TypeId); + projected.Add(Parameter(capability, directOptions, directOptions.Name, type, optional: true, defaultValue: directOptions.DefaultValue)); + signatureParts.Add($"{directOptions.Name}?: {type}"); + + foreach (var cancellationToken in optional.Where(parameter => IsCancellationToken(parameter.Type))) + { + var cancellationType = MapType(cancellationToken.Type, TypePosition.Input); + projected.Add(Parameter( + capability, + cancellationToken, + cancellationToken.Name, + cancellationType, + optional: true, + defaultValue: cancellationToken.DefaultValue)); + signatureParts.Add($"{cancellationToken.Name}?: {cancellationType}"); + } + } + else + { + var optionsName = _optionsNames.GetValueOrDefault( + capability.CapabilityId, + $"{ToPascalCase(SimpleMethodName(capability.MethodName))}Options"); + var optionFields = optional.Select(parameter => + { + var type = parameter.IsCallback ? MapCallback(parameter) : MapType(parameter.Type, TypePosition.Input); + return $" {parameter.Name}?: {type};"; + }); + optionsDeclaration = $"export interface {optionsName} {{\n{string.Join("\n", optionFields)}\n}}"; + projected.Add(new AppHostParameterModel + { + Name = "options", + Type = optionsName, + IsOptional = true, + }); + signatureParts.Add($"options?: {optionsName}"); + } + } + + var identifier = SimpleMethodName(capability.MethodName); + var returnType = IsVoid(capability.ReturnType) + ? "Promise" + : $"Promise<{MapType(capability.ReturnType, TypePosition.Return)}>"; + var signature = $"{identifier}({string.Join(", ", signatureParts)}): {returnType}"; + var declaration = optionsDeclaration is null + ? signature + : $"{optionsDeclaration}\n\n{signature}"; + + return new AppHostProjectionModel + { + Status = "supported", + Identifier = identifier, + Signature = signature, + Declaration = declaration, + SourceFile = SourceFile, + Parameters = projected, + Return = new AppHostReturnModel + { + Type = returnType, + ErrorModel = "exception", + }, + }; + } + + public override AppHostProjectionModel ProjectHandle(AtsDumpHandleType handle) + { + var name = TypeName(handle.AtsTypeId); + var kind = handle.IsInterface ? "interface" : "handle"; + return new AppHostProjectionModel + { + Status = "supported", + Identifier = name, + Declaration = handle.IsInterface + ? $"export interface {name} {{ }}" + : $"export interface {name} extends HandleReference {{ }}", + SourceFile = SourceFile, + Kind = kind, + ImplementedInterfaces = handle.ImplementedInterfaces.Select(type => TypeName(type.TypeId)).ToList(), + }; + } + + public override AppHostProjectionModel ProjectDto(AtsDumpDtoType dto) + { + if (dto.Properties.Any(property => HasEmptyUnion(property.Type))) + { + return Unsupported("Union types must declare at least one member.", SourceFile); + } + + var fields = dto.Properties.Select(property => + { + var type = property.IsCallback + ? MapCallback(property.CallbackParameters, property.CallbackReturnType) + : MapType(property.Type, TypePosition.Dto); + return Field(property, ToCamelCase(property.Name), type, optional: true); + }).ToList(); + var body = string.Join("\n", fields.Select(field => $" {field.Name}?: {field.Type};")); + + return new AppHostProjectionModel + { + Status = "supported", + Identifier = dto.Name, + Declaration = $"export interface {dto.Name} {{\n{body}\n}}", + SourceFile = SourceFile, + Kind = "interface", + Fields = fields, + }; + } + + public override AppHostProjectionModel ProjectEnum(AtsDumpEnumType enumType) + { + var members = Members(enumType, name => name, name => name); + var body = string.Join("\n", members.Select(member => $" {member.Name} = \"{member.Value}\",")); + return new AppHostProjectionModel + { + Status = "supported", + Identifier = enumType.Name, + Declaration = $"export enum {enumType.Name} {{\n{body}\n}}", + SourceFile = SourceFile, + Kind = "class", + Members = members, + }; + } + + public override AppHostProjectionModel ProjectExportedValue(AtsDumpExportedValue exportedValue) + { + if (HasEmptyUnion(exportedValue.Type)) + { + return Unsupported("Union types must declare at least one member.", SourceFile); + } + + var identifier = exportedValue.PathSegments.LastOrDefault() ?? "value"; + var type = MapType(exportedValue.Type, TypePosition.ExportedValue); + var expression = RenderJsonValue( + exportedValue.Value, + exportedValue.Type, + ToCamelCase, + "null", + "true", + "false", + value => QuoteString(value)); + if (exportedValue.Type is not null && + !string.Equals(exportedValue.Type.Category, "Primitive", StringComparison.OrdinalIgnoreCase)) + { + expression = $"{expression} as {type}"; + } + + return new AppHostProjectionModel + { + Status = "supported", + Identifier = identifier, + Declaration = $"export const {identifier} = {expression};", + SourceFile = SourceFile, + ValueExpression = expression, + Return = new AppHostReturnModel { Type = type, ErrorModel = "none" }, + }; + } + + protected override string MapType(AtsDumpTypeRef? type, TypePosition position, bool optional = false) + { + if (type is null) + { + return "unknown"; + } + + var category = type.Category.ToLowerInvariant(); + var mapped = category switch + { + "primitive" => MapPrimitive(type.TypeId), + "enum" => TypeName(type.TypeId.Replace("enum:", "", StringComparison.Ordinal)), + "handle" or "type" => TypeName(type.TypeId), + "dto" => TypeName(type.TypeId), + "callback" => "Function", + "array" => FormatArray(type, position), + "list" => position == TypePosition.Dto + ? FormatArray(type, position) + : $"AspireList<{MapType(type.ElementType, position)}>", + "dict" => type.IsReadOnly || position is TypePosition.Dto or TypePosition.ExportedValue + ? $"Record<{MapType(type.KeyType, position)}, {MapType(type.ValueType, position)}>" + : $"AspireDict<{MapType(type.KeyType, position)}, {MapType(type.ValueType, position)}>", + "union" => string.Join(" | ", (type.UnionTypes ?? []).Select(member => MapType(member, position)).Distinct(StringComparer.Ordinal)), + _ => "any", + }; + + if (type.IsNullable == true && + category is "primitive" or "enum" && + type.TypeId is not ("void" or "any" or "CancellationToken")) + { + mapped += " | null"; + } + + if (position == TypePosition.Input && IsHandle(type)) + { + mapped = $"Awaitable<{mapped}>"; + } + else if (position == TypePosition.Input && IsCancellationToken(type)) + { + mapped = "AbortSignal | CancellationToken"; + } + + return mapped; + } + + protected override string MapCallback( + IReadOnlyList? parameters, + AtsDumpTypeRef? returnType) + { + var args = string.Join(", ", (parameters ?? []).Select(parameter => + $"{parameter.Name}: {MapType(parameter.Type, TypePosition.Return)}")); + var result = IsVoid(returnType) ? "void" : MapType(returnType, TypePosition.Return); + return $"({args}) => Promise<{result}>"; + } + + private string FormatArray(AtsDumpTypeRef type, TypePosition position) + { + var element = MapType(type.ElementType, position); + return IsUnion(type.ElementType) || type.ElementType?.IsNullable == true + ? $"({element})[]" + : $"{element}[]"; + } + + private static string MapPrimitive(string typeId) => typeId switch + { + "string" or "char" or "Guid" or "Uri" or "DateTime" or "DateTimeOffset" or "DateOnly" or "TimeOnly" => "string", + "number" or "TimeSpan" => "number", + "boolean" or "bool" => "boolean", + "void" => "void", + "any" => "any", + "CancellationToken" => "CancellationToken", + _ => typeId, + }; + + private static string SimpleMethodName(string methodName) + => methodName.Contains('.') ? methodName[(methodName.LastIndexOf('.') + 1)..] : methodName; + + private static bool TryGetDirectOptionsParameter( + IReadOnlyList optional, + out AtsDumpParameter? directOptions) + { + var candidates = optional + .Where(parameter => !IsCancellationToken(parameter.Type)) + .ToList(); + directOptions = candidates.Count == 1 ? candidates[0] : null; + return directOptions is not null && + string.Equals(directOptions.Name, "options", StringComparison.Ordinal) && + IsOptionsDto(directOptions); + } + + private static bool HasEmptyUnion(AtsDumpTypeRef? type) + { + if (type is null) + { + return false; + } + + if (IsUnion(type) && type.UnionTypes is not { Count: > 0 }) + { + return true; + } + + return HasEmptyUnion(type.ElementType) || + HasEmptyUnion(type.KeyType) || + HasEmptyUnion(type.ValueType) || + (type.UnionTypes?.Any(HasEmptyUnion) ?? false); + } +} diff --git a/src/tools/AtsJsonGenerator/BatchGenerateCommand.cs b/src/tools/AtsJsonGenerator/BatchGenerateCommand.cs index d44548624..94fa01dc5 100644 --- a/src/tools/AtsJsonGenerator/BatchGenerateCommand.cs +++ b/src/tools/AtsJsonGenerator/BatchGenerateCommand.cs @@ -28,7 +28,7 @@ internal static class BatchGenerateCommand Description = "One or more pre-generated JSON files from 'aspire sdk dump --format json' to transform.", }; - private static readonly Option s_versionOption = new("--version") + private static readonly Option s_packageVersionOption = new("--package-version") { Description = "Package version to include in the output metadata.", }; @@ -43,6 +43,21 @@ internal static class BatchGenerateCommand Description = "Source commit SHA.", }; + private static readonly Option s_dumpCliVersionOption = new("--dump-cli-version") + { + Description = "Optional Aspire CLI version that produced the input dumps.", + }; + + private static readonly Option s_dumpProductCommitOption = new("--dump-product-commit") + { + Description = "Optional Aspire product commit represented by the input dumps.", + }; + + private static readonly Option s_dumpGeneratedAtOption = new("--dump-generated-at") + { + Description = "Optional timestamp supplied by the dump-producing workflow.", + }; + public static Command GetCommand() { var command = new Command("batch", "Process multiple ATS dump files or discover packages from an Aspire repo clone.") @@ -50,9 +65,12 @@ public static Command GetCommand() s_outputDirOption, s_aspireRepoOption, s_inputFilesOption, - s_versionOption, + s_packageVersionOption, s_sourceRepoOption, s_sourceCommitOption, + s_dumpCliVersionOption, + s_dumpProductCommitOption, + s_dumpGeneratedAtOption, }; command.SetAction(static parseResult => @@ -60,11 +78,23 @@ public static Command GetCommand() var outputDir = parseResult.GetValue(s_outputDirOption)!; var aspireRepo = parseResult.GetValue(s_aspireRepoOption); var inputFiles = parseResult.GetValue(s_inputFilesOption); - var version = parseResult.GetValue(s_versionOption); + var version = parseResult.GetValue(s_packageVersionOption); var sourceRepo = parseResult.GetValue(s_sourceRepoOption); var sourceCommit = parseResult.GetValue(s_sourceCommitOption); - - return RunBatch(outputDir, aspireRepo, inputFiles, version, sourceRepo, sourceCommit); + var dumpCliVersion = parseResult.GetValue(s_dumpCliVersionOption); + var dumpProductCommit = parseResult.GetValue(s_dumpProductCommitOption); + var dumpGeneratedAt = parseResult.GetValue(s_dumpGeneratedAtOption); + + return RunBatch( + outputDir, + aspireRepo, + inputFiles, + version, + sourceRepo, + sourceCommit, + dumpCliVersion, + dumpProductCommit, + dumpGeneratedAt); }); return command; @@ -76,7 +106,10 @@ private static int RunBatch( string[]? inputFiles, string? version, string? sourceRepo, - string? sourceCommit) + string? sourceCommit, + string? dumpCliVersion, + string? dumpProductCommit, + string? dumpGeneratedAt) { if (!Directory.Exists(outputDir)) { @@ -125,7 +158,15 @@ private static int RunBatch( { var outputPath = Path.Combine(outputDir, $"{packageName}.json"); var result = GenerateCommand.TransformFile( - path, outputPath, packageName, version, sourceRepo, sourceCommit); + path, + outputPath, + packageName, + version, + sourceRepo, + sourceCommit, + dumpCliVersion: dumpCliVersion, + dumpProductCommit: dumpProductCommit, + dumpGeneratedAt: dumpGeneratedAt); if (result == 0) { diff --git a/src/tools/AtsJsonGenerator/GenerateCommand.cs b/src/tools/AtsJsonGenerator/GenerateCommand.cs index f11732eac..ff0183262 100644 --- a/src/tools/AtsJsonGenerator/GenerateCommand.cs +++ b/src/tools/AtsJsonGenerator/GenerateCommand.cs @@ -28,7 +28,7 @@ internal static class GenerateCommand Description = "Package name override. Defaults to inferring from the input file name.", }; - private static readonly Option s_versionOption = new("--version") + private static readonly Option s_packageVersionOption = new("--package-version") { Description = "Package version to include in the output metadata.", }; @@ -45,7 +45,27 @@ internal static class GenerateCommand private static readonly Option s_baseOption = new("--base") { - Description = "Path to the core Aspire.Hosting docs-site JSON. When provided, capabilities and types already present in the base are excluded from the output.", + Description = "Path to a base semantic package JSON. ATS identities already present in the base are excluded from the output.", + }; + + private static readonly Option s_supportOutputOption = new("--support-output") + { + Description = "Optional path to write the package support matrix generated from all language projections.", + }; + + private static readonly Option s_dumpCliVersionOption = new("--dump-cli-version") + { + Description = "Optional Aspire CLI version that produced the input dump.", + }; + + private static readonly Option s_dumpProductCommitOption = new("--dump-product-commit") + { + Description = "Optional Aspire product commit represented by the input dump.", + }; + + private static readonly Option s_dumpGeneratedAtOption = new("--dump-generated-at") + { + Description = "Optional timestamp supplied by the dump-producing workflow.", }; public static RootCommand GetCommand() @@ -55,10 +75,14 @@ public static RootCommand GetCommand() s_inputOption, s_outputOption, s_packageNameOption, - s_versionOption, + s_packageVersionOption, s_sourceRepoOption, s_sourceCommitOption, s_baseOption, + s_supportOutputOption, + s_dumpCliVersionOption, + s_dumpProductCommitOption, + s_dumpGeneratedAtOption, }; command.SetAction(static parseResult => @@ -66,12 +90,27 @@ public static RootCommand GetCommand() var input = parseResult.GetValue(s_inputOption)!; var output = parseResult.GetValue(s_outputOption)!; var packageName = parseResult.GetValue(s_packageNameOption); - var version = parseResult.GetValue(s_versionOption); + var version = parseResult.GetValue(s_packageVersionOption); var sourceRepo = parseResult.GetValue(s_sourceRepoOption); var sourceCommit = parseResult.GetValue(s_sourceCommitOption); var basePath = parseResult.GetValue(s_baseOption); - - return TransformFile(input, output, packageName, version, sourceRepo, sourceCommit, basePath); + var supportOutputPath = parseResult.GetValue(s_supportOutputOption); + var dumpCliVersion = parseResult.GetValue(s_dumpCliVersionOption); + var dumpProductCommit = parseResult.GetValue(s_dumpProductCommitOption); + var dumpGeneratedAt = parseResult.GetValue(s_dumpGeneratedAtOption); + + return TransformFile( + input, + output, + packageName, + version, + sourceRepo, + sourceCommit, + basePath, + supportOutputPath, + dumpCliVersion, + dumpProductCommit, + dumpGeneratedAt); }); return command; @@ -84,7 +123,11 @@ internal static int TransformFile( string? version, string? sourceRepo, string? sourceCommit, - string? basePath = null) + string? basePath = null, + string? supportOutputPath = null, + string? dumpCliVersion = null, + string? dumpProductCommit = null, + string? dumpGeneratedAt = null) { if (!File.Exists(inputPath)) { @@ -103,7 +146,26 @@ internal static int TransformFile( return 1; } - var result = AtsTransformer.Transform(dump, packageName, version, sourceRepo, sourceCommit); + AppHostModuleModel result; + try + { + var dumpProvenance = CreateDumpProvenance( + dumpCliVersion, + dumpProductCommit, + dumpGeneratedAt); + result = AtsTransformer.Transform( + dump, + packageName, + version, + sourceRepo, + sourceCommit, + dumpProvenance); + } + catch (Exception exception) + { + Console.Error.WriteLine($"Failed to transform '{inputPath}': {exception.Message}"); + return 1; + } // Deduplicate against the base (core) package if (basePath is not null) @@ -115,24 +177,10 @@ internal static int TransformFile( } var baseJson = File.ReadAllText(basePath); - var baseModel = JsonSerializer.Deserialize(baseJson); + var baseModel = JsonSerializer.Deserialize(baseJson); if (baseModel is not null) { - var baseFuncIds = new HashSet(baseModel.Functions.Select(f => f.CapabilityId)); - var baseHandleIds = new HashSet(baseModel.HandleTypes.Select(h => h.FullName)); - var baseDtoIds = new HashSet(baseModel.DtoTypes.Select(d => d.FullName)); - var baseEnumIds = new HashSet(baseModel.EnumTypes.Select(e => e.FullName)); - - result.Functions.RemoveAll(f => baseFuncIds.Contains(f.CapabilityId)); - result.HandleTypes.RemoveAll(h => baseHandleIds.Contains(h.FullName)); - result.DtoTypes.RemoveAll(d => baseDtoIds.Contains(d.FullName)); - result.EnumTypes.RemoveAll(e => baseEnumIds.Contains(e.FullName)); - - // Also strip base capabilities from handle type capabilities lists - foreach (var handle in result.HandleTypes) - { - handle.Capabilities.RemoveAll(c => baseFuncIds.Contains(c.CapabilityId)); - } + AtsTransformer.DeduplicateAgainstBase(result, baseModel); } } @@ -154,7 +202,52 @@ internal static int TransformFile( }; var wroteFile = StableFileWriter.WriteIfChanged(outputPath, JsonSerializer.Serialize(result, options)); - Console.WriteLine($"{(wroteFile ? "Generated" : "Unchanged")}: {outputPath} ({result.Functions.Count} functions, {result.HandleTypes.Count} handles, {result.DtoTypes.Count} DTOs, {result.EnumTypes.Count} enums)"); + if (supportOutputPath is not null) + { + var supportDirectory = Path.GetDirectoryName(supportOutputPath); + if (!string.IsNullOrEmpty(supportDirectory)) + { + Directory.CreateDirectory(supportDirectory); + } + + var matrix = AtsTransformer.CreateSupportMatrix(result); + StableFileWriter.WriteIfChanged(supportOutputPath, JsonSerializer.Serialize(matrix, options)); + } + + var counts = result.Items + .GroupBy(item => item.Kind, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + Console.WriteLine( + $"{(wroteFile ? "Generated" : "Unchanged")}: {outputPath} " + + $"({counts.GetValueOrDefault("capability")} capabilities, " + + $"{counts.GetValueOrDefault("handle")} handles, " + + $"{counts.GetValueOrDefault("dto")} DTOs, " + + $"{counts.GetValueOrDefault("enum")} enums, " + + $"{counts.GetValueOrDefault("exportedValue")} exported values)"); return 0; } + + private static AppHostDumpProvenanceModel? CreateDumpProvenance( + string? cliVersion, + string? productCommit, + string? generatedAt) + { + cliVersion = NormalizeOptionalValue(cliVersion); + productCommit = NormalizeOptionalValue(productCommit); + generatedAt = NormalizeOptionalValue(generatedAt); + if (cliVersion is null && productCommit is null && generatedAt is null) + { + return null; + } + + return new AppHostDumpProvenanceModel + { + CliVersion = cliVersion, + ProductCommit = productCommit, + GeneratedAt = generatedAt, + }; + } + + private static string? NormalizeOptionalValue(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } diff --git a/src/tools/AtsJsonGenerator/Helpers/AtsTransformer.cs b/src/tools/AtsJsonGenerator/Helpers/AtsTransformer.cs index f7532d387..3c549e1e7 100644 --- a/src/tools/AtsJsonGenerator/Helpers/AtsTransformer.cs +++ b/src/tools/AtsJsonGenerator/Helpers/AtsTransformer.cs @@ -1,289 +1,313 @@ namespace AtsJsonGenerator.Helpers; -/// -/// Transforms the raw aspire sdk dump --format json output into the docs-site JSON model. -/// internal static class AtsTransformer { + internal static readonly string[] Languages = ["typescript", "python", "go", "java", "rust"]; + internal static readonly string[] ValidationLevels = ["source-derived", "upstream-test-validated", "sdk-output-validated"]; + internal const string UpstreamRepository = "microsoft/aspire"; + internal const string UpstreamCommit = "62028348b5d02dfc8f8baf03a4472946537b0d16"; + internal const string UpstreamLockFile = "src/tools/AtsJsonGenerator/upstream-sources.lock.json"; private const string NewAspireRepositoryUrl = "https://github.com/microsoft/aspire"; - /// - /// Transform the deserialized dump output into a . - /// - public static TsPackageModel Transform( + public static AppHostModuleModel Transform( AtsDumpRoot dump, string packageName, string? version = null, string? sourceRepository = null, - string? sourceCommit = null) + string? sourceCommit = null, + AppHostDumpProvenanceModel? dumpProvenance = null) { - sourceRepository = NormalizeSourceRepository(sourceRepository); + ArgumentNullException.ThrowIfNull(dump); + if (string.IsNullOrWhiteSpace(packageName)) + { + throw new InvalidOperationException("Package name must not be empty."); + } - // Fall back to the version from the dump's Packages metadata if not explicitly provided version ??= dump.Packages - .FirstOrDefault(p => string.Equals(p.Name, packageName, StringComparison.OrdinalIgnoreCase)) + .FirstOrDefault(package => string.Equals(package.Name, packageName, StringComparison.OrdinalIgnoreCase)) ?.Version; - - // Transform handle types - var handleModels = dump.HandleTypes - .Select(TransformHandle) - .OrderBy(h => h.FullName) - .ToList(); - - // Build a lookup for associating capabilities with handle types - var handleLookup = handleModels.ToDictionary(h => h.FullName, h => h); - - // Transform capabilities into functions - var functionModels = dump.Capabilities - .Select(TransformCapability) - .OrderBy(f => f.QualifiedName) - .ToList(); - - // Associate capabilities with their target handle types - foreach (var func in functionModels) + var package = new AppHostPackageInfo { - if (func.TargetTypeId is null) - { - continue; - } - - var targetFullName = StripAssemblyPrefix(func.TargetTypeId); - if (handleLookup.TryGetValue(targetFullName, out var handle)) - { - handle.Capabilities.Add(func); - } - } - - // Transform DTO types - var dtoModels = dump.DtoTypes - .Select(TransformDto) - .OrderBy(d => d.FullName) - .ToList(); - - // Transform enum types - var enumModels = dump.EnumTypes - .Select(TransformEnum) - .OrderBy(e => e.FullName) - .ToList(); + Name = packageName, + Version = version, + SourceRepository = NormalizeSourceRepository(sourceRepository), + SourceCommit = sourceCommit, + }; - return new TsPackageModel + ILanguageProjectionAdapter[] adapters = + [ + new TypeScriptProjectionAdapter(dump), + new PythonProjectionAdapter(dump), + new GoProjectionAdapter(dump), + new JavaProjectionAdapter(dump), + new RustProjectionAdapter(dump), + ]; + + var items = new List(); + items.AddRange(dump.Capabilities + .OrderBy(capability => capability.CapabilityId, StringComparer.Ordinal) + .Select(capability => TransformCapability(capability, adapters))); + items.AddRange(dump.HandleTypes + .OrderBy(handle => StripAssemblyPrefix(handle.AtsTypeId), StringComparer.Ordinal) + .Select(handle => TransformHandle(handle, adapters))); + items.AddRange(dump.DtoTypes + .OrderBy(dto => StripAssemblyPrefix(dto.TypeId), StringComparer.Ordinal) + .Select(dto => TransformDto(dto, adapters))); + items.AddRange(dump.EnumTypes + .OrderBy(enumType => EnumFullName(enumType), StringComparer.Ordinal) + .Select(enumType => TransformEnum(enumType, adapters))); + items.AddRange(dump.ExportedValues + .OrderBy(value => string.Join(".", value.PathSegments), StringComparer.Ordinal) + .Select(value => TransformExportedValue(value, adapters))); + + EnsureUniqueIdentities(items); + ValidateProjectionAccounting(items); + + return new AppHostModuleModel { - Package = new TsPackageInfo + GeneratorProvenance = new AppHostGeneratorProvenanceModel { - Name = packageName, - Version = version, - SourceRepository = sourceRepository, - SourceCommit = sourceCommit, + Repository = UpstreamRepository, + Commit = UpstreamCommit, + LockFile = UpstreamLockFile, }, - Functions = functionModels, - HandleTypes = handleModels, - DtoTypes = dtoModels, - EnumTypes = enumModels, + DumpProvenance = dumpProvenance, + Package = package, + Items = items, }; } - private static string? NormalizeSourceRepository(string? sourceRepository) + public static void DeduplicateAgainstBase(AppHostModuleModel model, AppHostModuleModel baseModel) { - if (string.IsNullOrWhiteSpace(sourceRepository)) - { - return sourceRepository; - } - - var trimmed = sourceRepository.Trim(); - - if (Uri.TryCreate(trimmed, UriKind.Absolute, out var repoUri) && - repoUri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase)) - { - var segments = repoUri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 2 && - segments[0].Equals("dotnet", StringComparison.OrdinalIgnoreCase) && - (segments[1].Equals("aspire", StringComparison.OrdinalIgnoreCase) || - segments[1].Equals("aspire.git", StringComparison.OrdinalIgnoreCase))) - { - return NewAspireRepositoryUrl; - } - } - - return trimmed; + var baseIds = baseModel.Items.Select(item => item.Id).ToHashSet(StringComparer.Ordinal); + model.Items.RemoveAll(item => baseIds.Contains(item.Id)); + ValidateProjectionAccounting(model.Items); } - private static TsHandleTypeModel TransformHandle(AtsDumpHandleType h) + public static AppHostSupportMatrixModel CreateSupportMatrix(AppHostModuleModel model) { - var fullName = StripAssemblyPrefix(h.AtsTypeId); - return new TsHandleTypeModel - { - Name = SimpleName(fullName), - FullName = fullName, - IsInterface = h.IsInterface, - ExposeProperties = h.ExposeProperties, - ExposeMethods = h.ExposeMethods, - Description = NormalizeDoc(h.Documentation?.Summary), - Remarks = NormalizeDoc(h.Documentation?.Remarks), - ImplementedInterfaces = h.ImplementedInterfaces - .Select(i => StripAssemblyPrefix(i.TypeId)) - .OrderBy(i => i) - .ToList(), - BaseTypeHierarchy = h.BaseTypeHierarchy - .Select(i => StripAssemblyPrefix(i.TypeId)) - .ToList(), - }; + return SupportMatrixAggregator.Aggregate([model]); } - private static TsFunctionModel TransformCapability(AtsDumpCapability cap) + private static AppHostItemModel TransformCapability( + AtsDumpCapability capability, + IEnumerable adapters) { - // Filter out the context/builder target parameter from visible params - var visibleParams = cap.Parameters - .Where(p => p.Name != cap.TargetParameterName) - .ToList(); + if (string.IsNullOrWhiteSpace(capability.CapabilityId)) + { + throw new InvalidOperationException("Every capability must have a CapabilityId."); + } - // Build a name → description lookup from the new Documentation block, - // covering only the parameters that will be rendered in the docs. - var paramDocLookup = cap.Documentation?.Parameters - .Where(d => !string.IsNullOrWhiteSpace(d.Description)) - .GroupBy(d => d.Name, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.First().Description, StringComparer.Ordinal) + var visibleParameters = capability.Parameters + .Where(parameter => !string.Equals(parameter.Name, capability.TargetParameterName, StringComparison.Ordinal)) + .ToList(); + var docs = capability.Documentation?.Parameters.ToDictionary( + parameter => parameter.Name, + parameter => NormalizeDoc(parameter.Description), + StringComparer.Ordinal) ?? new Dictionary(StringComparer.Ordinal); - var paramModels = visibleParams.Select(p => new TsParameterModel - { - Name = p.Name, - Type = FormatTypeRef(p.Type), - IsOptional = p.IsOptional, - IsNullable = p.IsNullable, - DefaultValue = p.DefaultValue, - IsCallback = p.IsCallback, - CallbackSignature = p.IsCallback ? FormatCallbackSignature(p) : null, - Description = paramDocLookup.TryGetValue(p.Name, out var pd) ? NormalizeDoc(pd) : null, - }).ToList(); - - // Build a TypeScript-style signature - var paramParts = paramModels.Select(p => + return new AppHostItemModel { - var opt = p.IsOptional ? "?" : ""; - var type = p.IsCallback && p.CallbackSignature is not null - ? p.CallbackSignature - : p.Type; - return $"{p.Name}{opt}: {type}"; - }); - - var returnTypeStr = FormatTypeRef(cap.ReturnType); - var sig = $"{cap.MethodName}({string.Join(", ", paramParts)}): {returnTypeStr}"; - - // Prefer the richer Documentation.Summary; fall back to the legacy - // Description field for older dumps that don't ship Documentation. - var description = NormalizeDoc(cap.Documentation?.Summary) ?? NormalizeDoc(cap.Description); + Id = $"capability:{capability.CapabilityId}", + Kind = "capability", + Name = capability.MethodName, + CapabilityId = capability.CapabilityId, + QualifiedName = capability.QualifiedMethodName, + CapabilityKind = capability.CapabilityKind, + Description = NormalizeDoc(capability.Documentation?.Summary) ?? NormalizeDoc(capability.Description), + Remarks = NormalizeDoc(capability.Documentation?.Remarks), + Returns = NormalizeDoc(capability.Documentation?.Returns), + TargetTypeId = capability.TargetTypeId, + ExpandedTargetTypes = capability.ExpandedTargetTypes.Select(type => type.TypeId).ToList(), + ReturnsBuilder = capability.ReturnsBuilder, + Parameters = visibleParameters.Select(parameter => new AppHostParameterModel + { + Name = parameter.Name, + Type = FormatTypeRef(parameter.Type), + IsOptional = parameter.IsOptional, + IsNullable = parameter.IsNullable || parameter.Type?.IsNullable == true, + DefaultValue = parameter.DefaultValue, + IsCallback = parameter.IsCallback, + CallbackSignature = parameter.IsCallback ? FormatCallback(parameter) : null, + Description = docs.GetValueOrDefault(parameter.Name), + }).ToList(), + ReturnType = FormatTypeRef(capability.ReturnType), + Projections = Project(adapters, adapter => adapter.ProjectCapability(capability)), + }; + } - return new TsFunctionModel + private static AppHostItemModel TransformHandle( + AtsDumpHandleType handle, + IEnumerable adapters) + { + var fullName = StripAssemblyPrefix(handle.AtsTypeId); + return new AppHostItemModel { - Name = cap.MethodName, - CapabilityId = cap.CapabilityId, - QualifiedName = cap.QualifiedMethodName, - Description = description, - Remarks = NormalizeDoc(cap.Documentation?.Remarks), - Returns = NormalizeDoc(cap.Documentation?.Returns), - Kind = cap.CapabilityKind, - Signature = sig, - Parameters = paramModels, - ReturnType = returnTypeStr, - ReturnsBuilder = cap.ReturnsBuilder, - TargetTypeId = cap.TargetTypeId, - ExpandedTargetTypes = cap.ExpandedTargetTypes - .Select(t => StripAssemblyPrefix(t.TypeId)) - .ToList(), + Id = $"handle:{fullName}", + Kind = "handle", + Name = SimpleName(fullName), + FullName = fullName, + Description = NormalizeDoc(handle.Documentation?.Summary), + Remarks = NormalizeDoc(handle.Documentation?.Remarks), + IsInterface = handle.IsInterface, + ExposeProperties = handle.ExposeProperties, + ExposeMethods = handle.ExposeMethods, + ImplementedInterfaces = handle.ImplementedInterfaces.Select(type => StripAssemblyPrefix(type.TypeId)).ToList(), + BaseTypeHierarchy = handle.BaseTypeHierarchy.Select(type => StripAssemblyPrefix(type.TypeId)).ToList(), + Projections = Project(adapters, adapter => adapter.ProjectHandle(handle)), }; } - private static TsDtoTypeModel TransformDto(AtsDumpDtoType dto) + private static AppHostItemModel TransformDto( + AtsDumpDtoType dto, + IEnumerable adapters) { var fullName = StripAssemblyPrefix(dto.TypeId); - return new TsDtoTypeModel + return new AppHostItemModel { + Id = $"dto:{fullName}", + Kind = "dto", Name = dto.Name, FullName = fullName, Description = NormalizeDoc(dto.Documentation?.Summary) ?? NormalizeDoc(dto.Description), Remarks = NormalizeDoc(dto.Documentation?.Remarks), - Fields = dto.Properties.Select(p => new TsDtoFieldModel + Fields = dto.Properties.Select(property => new AppHostFieldModel { - Name = p.Name, - Type = FormatTypeRef(p.Type), - // The Aspire TypeScript SDK intentionally emits DTOs as partial - // object shapes, regardless of the raw ATS property's nullability. - IsOptional = true, - Description = NormalizeDoc(p.Documentation?.Summary) ?? NormalizeDoc(p.Description), + Name = property.Name, + Type = property.IsCallback + ? FormatCallback(property.CallbackParameters, property.CallbackReturnType) + : FormatTypeRef(property.Type), + IsOptional = property.IsOptional, + IsNullable = property.IsNullable || property.Type?.IsNullable == true, + Description = NormalizeDoc(property.Documentation?.Summary) ?? NormalizeDoc(property.Description), }).ToList(), + Projections = Project(adapters, adapter => adapter.ProjectDto(dto)), }; } - private static TsEnumTypeModel TransformEnum(AtsDumpEnumType e) + private static AppHostItemModel TransformEnum( + AtsDumpEnumType enumType, + IEnumerable adapters) { - // EnumType TypeId is "enum:Full.Name" — strip "enum:" prefix - var fullName = e.TypeId.StartsWith("enum:", StringComparison.Ordinal) - ? e.TypeId["enum:".Length..] - : e.TypeId; - - // Build per-member docs from ValueInfos, preserving original order. - // Only emitted if at least one member carries non-empty documentation. - List? memberDocs = null; - if (e.ValueInfos.Count > 0) + var fullName = EnumFullName(enumType); + var valueDocs = enumType.ValueInfos.ToDictionary( + value => value.Name, + value => NormalizeDoc(value.Documentation?.Summary), + StringComparer.Ordinal); + return new AppHostItemModel { - var docs = e.ValueInfos.Select(v => new TsEnumMemberDocModel + Id = $"enum:{fullName}", + Kind = "enum", + Name = enumType.Name, + FullName = fullName, + Description = NormalizeDoc(enumType.Documentation?.Summary), + Remarks = NormalizeDoc(enumType.Documentation?.Remarks), + Members = enumType.Values.Select(value => new AppHostEnumMemberModel { - Name = v.Name, - Description = NormalizeDoc(v.Documentation?.Summary), - Remarks = NormalizeDoc(v.Documentation?.Remarks), - }).ToList(); + Name = value, + Value = value, + Description = valueDocs.GetValueOrDefault(value), + }).ToList(), + Projections = Project(adapters, adapter => adapter.ProjectEnum(enumType)), + }; + } - if (docs.Any(d => d.Description is not null || d.Remarks is not null)) - { - memberDocs = docs; - } + private static AppHostItemModel TransformExportedValue( + AtsDumpExportedValue value, + IEnumerable adapters) + { + if (value.PathSegments.Count == 0) + { + throw new InvalidOperationException("Every exported value must have at least one PathSegments entry."); } - return new TsEnumTypeModel + var path = string.Join(".", value.PathSegments); + return new AppHostItemModel { - Name = e.Name, - FullName = fullName, - Description = NormalizeDoc(e.Documentation?.Summary), - Remarks = NormalizeDoc(e.Documentation?.Remarks), - Members = e.Values, - MemberDocs = memberDocs, + Id = $"exportedValue:{path}", + Kind = "exportedValue", + Name = value.PathSegments[^1], + FullName = path, + Description = NormalizeDoc(value.Documentation?.Summary) ?? NormalizeDoc(value.Description), + Remarks = NormalizeDoc(value.Documentation?.Remarks), + PathSegments = value.PathSegments, + Value = value.Value, + ReturnType = FormatTypeRef(value.Type), + Projections = Project(adapters, adapter => adapter.ProjectExportedValue(value)), }; } - /// - /// Treat whitespace-only XML doc strings as missing so they're omitted from - /// the output JSON rather than serialized as empty strings. - /// - private static string? NormalizeDoc(string? value) - => string.IsNullOrWhiteSpace(value) ? null : value; + private static Dictionary Project( + IEnumerable adapters, + Func projector) + { + var projections = new Dictionary(StringComparer.Ordinal); + foreach (var adapter in adapters) + { + projections.Add(adapter.Language, projector(adapter)); + } + return projections; + } - /// - /// Format a callback parameter into a TypeScript-style function type signature. - /// - private static string FormatCallbackSignature(AtsDumpParameter param) + private static void EnsureUniqueIdentities(IEnumerable items) { - if (param.CallbackParameters is null) + var duplicate = items.GroupBy(item => item.Id, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) { - return "() => Promise"; + throw new InvalidOperationException($"Duplicate ATS identity '{duplicate.Key}'."); } + } - var cbParams = param.CallbackParameters.Select(p => - $"{p.Name}: {FormatTypeRef(p.Type)}"); + internal static void ValidateProjectionAccounting(IEnumerable items) + { + foreach (var item in items) + { + foreach (var language in Languages) + { + if (!item.Projections.TryGetValue(language, out var projection)) + { + throw new InvalidOperationException( + $"ATS item '{item.Id}' is missing its '{language}' projection."); + } - // The generated TypeScript SDK always exposes callbacks as async - // (the aspire TS code generator hardcodes `=> Promise` for every - // callback because invocation happens over RPC). Mirror that here so - // the docs signatures match the actual SDK types. - var innerReturnType = param.CallbackReturnType is not null - ? FormatTypeRef(param.CallbackReturnType) - : "void"; + if (projection.Status is not ("supported" or "unsupported")) + { + throw new InvalidOperationException( + $"ATS item '{item.Id}' has invalid '{language}' status '{projection.Status}'."); + } - return $"({string.Join(", ", cbParams)}) => Promise<{innerReturnType}>"; + if (!ValidationLevels.Contains(projection.Validation, StringComparer.Ordinal)) + { + throw new InvalidOperationException( + $"ATS item '{item.Id}' has invalid '{language}' validation level '{projection.Validation}'."); + } + + if (projection.Status == "unsupported" && string.IsNullOrWhiteSpace(projection.Reason)) + { + throw new InvalidOperationException( + $"ATS item '{item.Id}' has an unsupported '{language}' projection without a reason."); + } + + if (projection.Status == "supported" && + (string.IsNullOrWhiteSpace(projection.Identifier) || + string.IsNullOrWhiteSpace(projection.SourceFile))) + { + throw new InvalidOperationException( + $"ATS item '{item.Id}' has an incomplete supported '{language}' projection."); + } + } + + if (item.Projections.Count != Languages.Length) + { + var extras = item.Projections.Keys.Except(Languages, StringComparer.Ordinal); + throw new InvalidOperationException( + $"ATS item '{item.Id}' has unexpected projections: {string.Join(", ", extras)}."); + } + } } - /// - /// Format a type reference for display, simplifying common patterns. - /// internal static string FormatTypeRef(AtsDumpTypeRef? typeRef) { if (typeRef is null) @@ -291,25 +315,92 @@ internal static string FormatTypeRef(AtsDumpTypeRef? typeRef) return "void"; } - return typeRef.Category switch + var category = typeRef.Category.ToLowerInvariant(); + var formatted = category switch { - "Primitive" => typeRef.TypeId, - "Callback" => "callback", - "Array" when typeRef.ElementType is not null => - $"{FormatTypeRef(typeRef.ElementType)}[]", + "primitive" => typeRef.TypeId, + "callback" => "callback", + "array" => $"{FormatTypeRef(typeRef.ElementType)}[]", + "list" => $"list<{FormatTypeRef(typeRef.ElementType)}>", + "dict" => $"map<{FormatTypeRef(typeRef.KeyType)}, {FormatTypeRef(typeRef.ValueType)}>", + "union" => string.Join(" | ", (typeRef.UnionTypes ?? []).Select(FormatTypeRef).Distinct(StringComparer.Ordinal)), _ => SimplifyTypeId(typeRef.TypeId), }; + + if (typeRef.IsNullable == true && formatted is not ("void" or "any")) + { + formatted += " | null"; + } + return formatted; } - /// - /// Simplify a fully-qualified type ID for display. - /// internal static string SimplifyTypeId(string typeId) { var stripped = StripAssemblyPrefix(typeId); return FormatReflectionType(stripped); } + internal static string StripAssemblyPrefix(string typeId) + { + var slashIndex = typeId.IndexOf('/'); + var stripped = slashIndex >= 0 ? typeId[(slashIndex + 1)..] : typeId; + return stripped.Contains("[[", StringComparison.Ordinal) + ? CleanAssemblyQualifiedGenerics(stripped) + : stripped; + } + + private static string FormatCallback(AtsDumpParameter parameter) + => FormatCallback(parameter.CallbackParameters, parameter.CallbackReturnType); + + private static string FormatCallback( + IReadOnlyList? parameters, + AtsDumpTypeRef? returnType) + { + var args = string.Join(", ", (parameters ?? []).Select(parameter => + $"{parameter.Name}: {FormatTypeRef(parameter.Type)}")); + return $"({args}) => {FormatTypeRef(returnType)}"; + } + + private static string? NormalizeSourceRepository(string? sourceRepository) + { + if (string.IsNullOrWhiteSpace(sourceRepository)) + { + return null; + } + + var trimmed = sourceRepository.Trim(); + if (Uri.TryCreate(trimmed, UriKind.Absolute, out var repositoryUri) && + repositoryUri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase)) + { + var segments = repositoryUri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 2 && + segments[0].Equals("dotnet", StringComparison.OrdinalIgnoreCase) && + (segments[1].Equals("aspire", StringComparison.OrdinalIgnoreCase) || + segments[1].Equals("aspire.git", StringComparison.OrdinalIgnoreCase))) + { + return NewAspireRepositoryUrl; + } + } + return trimmed; + } + + private static string EnumFullName(AtsDumpEnumType enumType) + => enumType.TypeId.StartsWith("enum:", StringComparison.Ordinal) + ? enumType.TypeId["enum:".Length..] + : StripAssemblyPrefix(enumType.TypeId); + + private static string? NormalizeDoc(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string SimpleName(string fullName) + { + var generic = fullName.IndexOf('<'); + var prefix = generic >= 0 ? fullName[..generic] : fullName; + var suffix = generic >= 0 ? fullName[generic..] : ""; + var delimiter = Math.Max(prefix.LastIndexOf('.'), prefix.LastIndexOf('+')); + return (delimiter >= 0 ? prefix[(delimiter + 1)..] : prefix) + suffix; + } + private static string FormatReflectionType(string typeName) { var arraySuffix = ""; @@ -328,8 +419,7 @@ private static string FormatReflectionType(string typeName) TrySplitReflectionGenericArguments(typeName, argumentsIndex, out var arguments)) { var genericName = SimpleName(typeName[..arityIndex]); - var formattedArguments = arguments.Select(FormatReflectionType); - return $"{genericName}<{string.Join(",", formattedArguments)}>{arraySuffix}"; + return $"{genericName}<{string.Join(",", arguments.Select(FormatReflectionType))}>{arraySuffix}"; } return $"{FormatPrimitiveType(SimpleName(typeName))}{arraySuffix}"; @@ -343,111 +433,82 @@ private static bool TrySplitReflectionGenericArguments( arguments = []; var argumentStart = startIndex + 2; var depth = 1; - var i = argumentStart; + var index = argumentStart; - while (i < typeName.Length) + while (index < typeName.Length) { - if (i + 1 < typeName.Length && typeName[i] == '[' && typeName[i + 1] == ']') + if (index + 1 < typeName.Length && typeName[index] == '[' && typeName[index + 1] == ']') { - i += 2; + index += 2; continue; } - if (i + 1 < typeName.Length && typeName[i] == '[' && typeName[i + 1] == '[') + if (index + 1 < typeName.Length && typeName[index] == '[' && typeName[index + 1] == '[') { depth++; - i += 2; + index += 2; continue; } - if (i + 1 < typeName.Length && typeName[i] == ']' && typeName[i + 1] == ']') + if (index + 1 < typeName.Length && typeName[index] == ']' && typeName[index + 1] == ']') { depth--; if (depth == 0) { - arguments.Add(typeName[argumentStart..i]); - return i + 2 == typeName.Length; + arguments.Add(typeName[argumentStart..index]); + return index + 2 == typeName.Length; } - i += 2; + index += 2; continue; } if (depth == 1 && - i + 2 < typeName.Length && - typeName[i] == ']' && - typeName[i + 1] == ',' && - typeName[i + 2] == '[') + index + 2 < typeName.Length && + typeName[index] == ']' && + typeName[index + 1] == ',' && + typeName[index + 2] == '[') { - arguments.Add(typeName[argumentStart..i]); - argumentStart = i + 3; - i += 3; + arguments.Add(typeName[argumentStart..index]); + argumentStart = index + 3; + index += 3; continue; } - i++; + index++; } - arguments = []; return false; } - private static string FormatPrimitiveType(string typeName) - { - return typeName switch - { - "String" => "string", - "Boolean" => "boolean", - "Byte" or "SByte" or "Int16" or "UInt16" or "Int32" or "UInt32" or - "Int64" or "UInt64" or "Single" or "Double" or "Decimal" => "number", - "Object" => "unknown", - _ => typeName, - }; - } - - /// - /// Strip the "Assembly/" prefix from a type ID and clean assembly-qualified - /// generic type arguments (e.g., System.IEquatable`1[[TypeName, Assembly, Version=..., ...]]). - /// - internal static string StripAssemblyPrefix(string typeId) + private static string FormatPrimitiveType(string typeName) => typeName switch { - var slashIdx = typeId.IndexOf('/'); - var stripped = slashIdx >= 0 ? typeId[(slashIdx + 1)..] : typeId; - - // Clean assembly metadata from generic type arguments: - // [[TypeName, AssemblyName, Version=..., Culture=..., PublicKeyToken=...]] - // becomes [[TypeName]] - if (stripped.Contains("[[")) - { - stripped = CleanAssemblyQualifiedGenerics(stripped); - } - - return stripped; - } + "String" => "string", + "Boolean" => "boolean", + "Byte" or "SByte" or "Int16" or "UInt16" or "Int32" or "UInt32" or + "Int64" or "UInt64" or "Single" or "Double" or "Decimal" => "number", + "Object" => "any", + _ => typeName, + }; - /// - /// Remove assembly metadata from inside double-bracket generic type arguments. - /// private static string CleanAssemblyQualifiedGenerics(string typeId) { var result = new System.Text.StringBuilder(typeId.Length); - var i = 0; - - while (i < typeId.Length) + var index = 0; + while (index < typeId.Length) { - if (i + 1 < typeId.Length && typeId[i] == '[' && typeId[i + 1] == '[') + if (index + 1 < typeId.Length && + typeId[index] == '[' && + typeId[index + 1] == '[' && + TryCleanGenericArgumentList(typeId, index, out var cleaned, out var nextIndex)) { - if (TryCleanGenericArgumentList(typeId, i, out var cleaned, out var nextIndex)) - { - result.Append(cleaned); - i = nextIndex; - continue; - } + result.Append(cleaned); + index = nextIndex; + continue; } - result.Append(typeId[i]); - i++; + result.Append(typeId[index]); + index++; } - return result.ToString(); } @@ -457,21 +518,19 @@ private static bool TryCleanGenericArgumentList( out string cleaned, out int nextIndex) { - var result = new System.Text.StringBuilder(); - result.Append("[["); - var i = startIndex + 2; - - while (i < typeId.Length) + var result = new System.Text.StringBuilder("[["); + var index = startIndex + 2; + while (index < typeId.Length) { - var argumentStart = i; + var argumentStart = index; var bracketDepth = 0; - while (i < typeId.Length) + while (index < typeId.Length) { - if (typeId[i] == '[') + if (typeId[index] == '[') { bracketDepth++; } - else if (typeId[i] == ']') + else if (typeId[index] == ']') { if (bracketDepth == 0) { @@ -479,39 +538,34 @@ private static bool TryCleanGenericArgumentList( } bracketDepth--; } - i++; + index++; } - if (i >= typeId.Length) + if (index >= typeId.Length) { - cleaned = ""; - nextIndex = startIndex; - return false; + break; } - var argument = typeId[argumentStart..i]; - var assemblySeparator = FindTopLevelComma(argument); - var typeName = assemblySeparator >= 0 ? argument[..assemblySeparator] : argument; + var argument = typeId[argumentStart..index]; + var separator = FindTopLevelComma(argument); + var typeName = separator >= 0 ? argument[..separator] : argument; result.Append(CleanAssemblyQualifiedGenerics(typeName.Trim())); - if (i + 1 < typeId.Length && typeId[i + 1] == ']') + if (index + 1 < typeId.Length && typeId[index + 1] == ']') { result.Append("]]"); cleaned = result.ToString(); - nextIndex = i + 2; + nextIndex = index + 2; return true; } - if (i + 2 < typeId.Length && typeId[i + 1] == ',' && typeId[i + 2] == '[') + if (index + 2 < typeId.Length && typeId[index + 1] == ',' && typeId[index + 2] == '[') { result.Append("],["); - i += 3; + index += 3; continue; } - - cleaned = ""; - nextIndex = startIndex; - return false; + break; } cleaned = ""; @@ -522,39 +576,14 @@ private static bool TryCleanGenericArgumentList( private static int FindTopLevelComma(string value) { var bracketDepth = 0; - for (var i = 0; i < value.Length; i++) + for (var index = 0; index < value.Length; index++) { - if (value[i] == '[') + bracketDepth += value[index] == '[' ? 1 : value[index] == ']' ? -1 : 0; + if (value[index] == ',' && bracketDepth == 0) { - bracketDepth++; - } - else if (value[i] == ']') - { - bracketDepth--; - } - else if (value[i] == ',' && bracketDepth == 0) - { - return i; + return index; } } - return -1; } - - /// - /// Extract the simple name from a fully-qualified name. - /// - private static string SimpleName(string fullName) - { - // Handle generic types: don't split inside angle brackets - if (fullName.Contains('<')) - { - var angleIdx = fullName.IndexOf('<'); - var prefix = fullName[..angleIdx]; - var suffix = fullName[angleIdx..]; - return prefix.Split('.').Last() + suffix; - } - - return fullName.Split('.').Last(); - } } diff --git a/src/tools/AtsJsonGenerator/Helpers/Models.cs b/src/tools/AtsJsonGenerator/Helpers/Models.cs index e5a4ab316..250c7ae0c 100644 --- a/src/tools/AtsJsonGenerator/Helpers/Models.cs +++ b/src/tools/AtsJsonGenerator/Helpers/Models.cs @@ -1,11 +1,9 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace AtsJsonGenerator; -// ════════════════════════════════════════════════════════════════════ -// INPUT MODELS — deserialized from `aspire sdk dump --format json` output -// ════════════════════════════════════════════════════════════════════ - +// Input models for the stable `aspire sdk dump --format json` schema. internal sealed class AtsDumpRoot { [JsonPropertyName("Packages")] @@ -23,14 +21,17 @@ internal sealed class AtsDumpRoot [JsonPropertyName("EnumTypes")] public List EnumTypes { get; init; } = []; + [JsonPropertyName("ExportedValues")] + public List ExportedValues { get; init; } = []; + [JsonPropertyName("Diagnostics")] - public List Diagnostics { get; init; } = []; + public List Diagnostics { get; init; } = []; } internal sealed class AtsDumpPackageRef { [JsonPropertyName("Name")] - public required string Name { get; init; } + public string Name { get; init; } = ""; [JsonPropertyName("Version")] public string? Version { get; init; } @@ -39,16 +40,16 @@ internal sealed class AtsDumpPackageRef internal sealed class AtsDumpCapability { [JsonPropertyName("CapabilityId")] - public required string CapabilityId { get; init; } + public string CapabilityId { get; init; } = ""; [JsonPropertyName("MethodName")] - public required string MethodName { get; init; } + public string MethodName { get; init; } = ""; [JsonPropertyName("OwningTypeName")] public string? OwningTypeName { get; init; } [JsonPropertyName("QualifiedMethodName")] - public required string QualifiedMethodName { get; init; } + public string QualifiedMethodName { get; init; } = ""; [JsonPropertyName("Description")] public string? Description { get; init; } @@ -57,7 +58,7 @@ internal sealed class AtsDumpCapability public AtsDumpDocumentation? Documentation { get; init; } [JsonPropertyName("CapabilityKind")] - public required string CapabilityKind { get; init; } + public string CapabilityKind { get; init; } = ""; [JsonPropertyName("TargetTypeId")] public string? TargetTypeId { get; init; } @@ -81,9 +82,6 @@ internal sealed class AtsDumpCapability public List ExpandedTargetTypes { get; init; } = []; } -/// -/// XML-doc payload emitted by the new Aspire CLI (microsoft/aspire#17044). -/// internal sealed class AtsDumpDocumentation { [JsonPropertyName("Summary")] @@ -102,7 +100,7 @@ internal sealed class AtsDumpDocumentation internal sealed class AtsDumpParameterDoc { [JsonPropertyName("Name")] - public required string Name { get; init; } + public string Name { get; init; } = ""; [JsonPropertyName("Description")] public string? Description { get; init; } @@ -111,10 +109,10 @@ internal sealed class AtsDumpParameterDoc internal sealed class AtsDumpParameter { [JsonPropertyName("Name")] - public required string Name { get; init; } + public string Name { get; init; } = ""; [JsonPropertyName("Type")] - public required AtsDumpTypeRef Type { get; init; } + public AtsDumpTypeRef? Type { get; init; } [JsonPropertyName("IsOptional")] public bool IsOptional { get; init; } @@ -133,39 +131,57 @@ internal sealed class AtsDumpParameter [JsonPropertyName("CallbackReturnType")] public AtsDumpTypeRef? CallbackReturnType { get; init; } + + [JsonPropertyName("Documentation")] + public AtsDumpDocumentation? Documentation { get; init; } } internal sealed class AtsDumpCallbackParam { [JsonPropertyName("Name")] - public required string Name { get; init; } + public string Name { get; init; } = ""; [JsonPropertyName("Type")] - public required AtsDumpTypeRef Type { get; init; } + public AtsDumpTypeRef? Type { get; init; } + + [JsonPropertyName("Documentation")] + public AtsDumpDocumentation? Documentation { get; init; } } internal sealed class AtsDumpTypeRef { [JsonPropertyName("TypeId")] - public required string TypeId { get; init; } + public string TypeId { get; init; } = ""; [JsonPropertyName("Category")] - public required string Category { get; init; } + public string Category { get; init; } = ""; [JsonPropertyName("IsInterface")] public bool IsInterface { get; init; } + [JsonPropertyName("IsNullable")] + public bool? IsNullable { get; init; } + [JsonPropertyName("IsReadOnly")] public bool IsReadOnly { get; init; } [JsonPropertyName("ElementType")] public AtsDumpTypeRef? ElementType { get; init; } + + [JsonPropertyName("KeyType")] + public AtsDumpTypeRef? KeyType { get; init; } + + [JsonPropertyName("ValueType")] + public AtsDumpTypeRef? ValueType { get; init; } + + [JsonPropertyName("UnionTypes")] + public List? UnionTypes { get; init; } } internal sealed class AtsDumpHandleType { [JsonPropertyName("AtsTypeId")] - public required string AtsTypeId { get; init; } + public string AtsTypeId { get; init; } = ""; [JsonPropertyName("IsInterface")] public bool IsInterface { get; init; } @@ -189,10 +205,10 @@ internal sealed class AtsDumpHandleType internal sealed class AtsDumpDtoType { [JsonPropertyName("TypeId")] - public required string TypeId { get; init; } + public string TypeId { get; init; } = ""; [JsonPropertyName("Name")] - public required string Name { get; init; } + public string Name { get; init; } = ""; [JsonPropertyName("Description")] public string? Description { get; init; } @@ -207,14 +223,26 @@ internal sealed class AtsDumpDtoType internal sealed class AtsDumpDtoProperty { [JsonPropertyName("Name")] - public required string Name { get; init; } + public string Name { get; init; } = ""; [JsonPropertyName("Type")] - public required AtsDumpTypeRef Type { get; init; } + public AtsDumpTypeRef? Type { get; init; } [JsonPropertyName("IsOptional")] public bool IsOptional { get; init; } + [JsonPropertyName("IsNullable")] + public bool IsNullable { get; init; } + + [JsonPropertyName("IsCallback")] + public bool IsCallback { get; init; } + + [JsonPropertyName("CallbackParameters")] + public List? CallbackParameters { get; init; } + + [JsonPropertyName("CallbackReturnType")] + public AtsDumpTypeRef? CallbackReturnType { get; init; } + [JsonPropertyName("Description")] public string? Description { get; init; } @@ -225,10 +253,10 @@ internal sealed class AtsDumpDtoProperty internal sealed class AtsDumpEnumType { [JsonPropertyName("TypeId")] - public required string TypeId { get; init; } + public string TypeId { get; init; } = ""; [JsonPropertyName("Name")] - public required string Name { get; init; } + public string Name { get; init; } = ""; [JsonPropertyName("Documentation")] public AtsDumpDocumentation? Documentation { get; init; } @@ -243,65 +271,121 @@ internal sealed class AtsDumpEnumType internal sealed class AtsDumpEnumValueInfo { [JsonPropertyName("Name")] - public required string Name { get; init; } + public string Name { get; init; } = ""; [JsonPropertyName("Documentation")] public AtsDumpDocumentation? Documentation { get; init; } } -// ════════════════════════════════════════════════════════════════════ -// OUTPUT MODELS — serialized to JSON for the docs site -// ════════════════════════════════════════════════════════════════════ +internal sealed class AtsDumpExportedValue +{ + [JsonPropertyName("PathSegments")] + public List PathSegments { get; init; } = []; + + [JsonPropertyName("Type")] + public AtsDumpTypeRef? Type { get; init; } + + [JsonPropertyName("Value")] + public JsonElement? Value { get; init; } + + [JsonPropertyName("Description")] + public string? Description { get; init; } + + [JsonPropertyName("Documentation")] + public AtsDumpDocumentation? Documentation { get; init; } +} -/// -/// Root model for a TypeScript API package JSON file consumed by the docs site. -/// -internal sealed class TsPackageModel +// Language-neutral semantic package model consumed by apphost-modules. +internal sealed class AppHostModuleModel { + [JsonPropertyName("schemaVersion")] + public string SchemaVersion { get; init; } = "1.0"; + + [JsonPropertyName("generatorProvenance")] + public required AppHostGeneratorProvenanceModel GeneratorProvenance { get; init; } + + [JsonPropertyName("dumpProvenance")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AppHostDumpProvenanceModel? DumpProvenance { get; init; } + [JsonPropertyName("package")] - public required TsPackageInfo Package { get; init; } + public required AppHostPackageInfo Package { get; init; } + + [JsonPropertyName("items")] + public List Items { get; init; } = []; +} + +internal sealed class AppHostGeneratorProvenanceModel +{ + [JsonPropertyName("repository")] + public required string Repository { get; init; } - [JsonPropertyName("functions")] - public List Functions { get; init; } = []; + [JsonPropertyName("commit")] + public required string Commit { get; init; } - [JsonPropertyName("handleTypes")] - public List HandleTypes { get; init; } = []; + [JsonPropertyName("lockFile")] + public required string LockFile { get; init; } +} + +internal sealed class AppHostDumpProvenanceModel +{ + [JsonPropertyName("cliVersion")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CliVersion { get; init; } - [JsonPropertyName("dtoTypes")] - public List DtoTypes { get; init; } = []; + [JsonPropertyName("productCommit")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ProductCommit { get; init; } - [JsonPropertyName("enumTypes")] - public List EnumTypes { get; init; } = []; + [JsonPropertyName("generatedAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? GeneratedAt { get; init; } } -internal sealed class TsPackageInfo +internal sealed class AppHostPackageInfo { [JsonPropertyName("name")] public required string Name { get; init; } [JsonPropertyName("version")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Version { get; init; } - [JsonPropertyName("language")] - public string Language => "typescript"; - [JsonPropertyName("sourceRepository")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? SourceRepository { get; init; } [JsonPropertyName("sourceCommit")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? SourceCommit { get; init; } } -internal sealed class TsFunctionModel +internal sealed class AppHostItemModel { + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("kind")] + public required string Kind { get; init; } + [JsonPropertyName("name")] public required string Name { get; init; } + [JsonPropertyName("fullName")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FullName { get; init; } + [JsonPropertyName("capabilityId")] - public required string CapabilityId { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CapabilityId { get; init; } [JsonPropertyName("qualifiedName")] - public required string QualifiedName { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? QualifiedName { get; init; } + + [JsonPropertyName("capabilityKind")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CapabilityKind { get; init; } [JsonPropertyName("description")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -315,29 +399,109 @@ internal sealed class TsFunctionModel [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Returns { get; init; } - [JsonPropertyName("kind")] - public required string Kind { get; init; } + [JsonPropertyName("targetTypeId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TargetTypeId { get; init; } - [JsonPropertyName("signature")] - public required string Signature { get; init; } + [JsonPropertyName("expandedTargetTypes")] + public List ExpandedTargetTypes { get; init; } = []; + + [JsonPropertyName("returnsBuilder")] + public bool ReturnsBuilder { get; init; } [JsonPropertyName("parameters")] - public List Parameters { get; init; } = []; + public List Parameters { get; init; } = []; [JsonPropertyName("returnType")] - public required string ReturnType { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ReturnType { get; init; } - [JsonPropertyName("returnsBuilder")] - public bool ReturnsBuilder { get; init; } + [JsonPropertyName("fields")] + public List Fields { get; init; } = []; - [JsonPropertyName("targetTypeId")] - public string? TargetTypeId { get; init; } + [JsonPropertyName("members")] + public List Members { get; init; } = []; - [JsonPropertyName("expandedTargetTypes")] - public List ExpandedTargetTypes { get; init; } = []; + [JsonPropertyName("pathSegments")] + public List PathSegments { get; init; } = []; + + [JsonPropertyName("value")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Value { get; init; } + + [JsonPropertyName("isInterface")] + public bool IsInterface { get; init; } + + [JsonPropertyName("exposeProperties")] + public bool ExposeProperties { get; init; } + + [JsonPropertyName("exposeMethods")] + public bool ExposeMethods { get; init; } + + [JsonPropertyName("implementedInterfaces")] + public List ImplementedInterfaces { get; init; } = []; + + [JsonPropertyName("baseTypeHierarchy")] + public List BaseTypeHierarchy { get; init; } = []; + + [JsonPropertyName("projections")] + public Dictionary Projections { get; init; } = new(StringComparer.Ordinal); } -internal sealed class TsParameterModel +internal sealed class AppHostProjectionModel +{ + [JsonPropertyName("status")] + public required string Status { get; init; } + + [JsonPropertyName("validation")] + public string Validation { get; init; } = "source-derived"; + + [JsonPropertyName("reason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Reason { get; init; } + + [JsonPropertyName("identifier")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Identifier { get; init; } + + [JsonPropertyName("signature")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Signature { get; init; } + + [JsonPropertyName("declaration")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Declaration { get; init; } + + [JsonPropertyName("sourceFile")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SourceFile { get; init; } + + [JsonPropertyName("kind")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Kind { get; init; } + + [JsonPropertyName("parameters")] + public List Parameters { get; init; } = []; + + [JsonPropertyName("return")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AppHostReturnModel? Return { get; init; } + + [JsonPropertyName("fields")] + public List Fields { get; init; } = []; + + [JsonPropertyName("members")] + public List Members { get; init; } = []; + + [JsonPropertyName("implementedInterfaces")] + public List ImplementedInterfaces { get; init; } = []; + + [JsonPropertyName("valueExpression")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ValueExpression { get; init; } +} + +internal sealed class AppHostParameterModel { [JsonPropertyName("name")] public required string Name { get; init; } @@ -367,129 +531,121 @@ internal sealed class TsParameterModel public string? Description { get; init; } } -internal sealed class TsHandleTypeModel +internal sealed class AppHostFieldModel { [JsonPropertyName("name")] public required string Name { get; init; } - [JsonPropertyName("fullName")] - public required string FullName { get; init; } - - [JsonPropertyName("kind")] - public string Kind => "handle"; - - [JsonPropertyName("isInterface")] - public bool IsInterface { get; init; } + [JsonPropertyName("type")] + public required string Type { get; init; } - [JsonPropertyName("exposeProperties")] - public bool ExposeProperties { get; init; } + [JsonPropertyName("isOptional")] + public bool IsOptional { get; init; } - [JsonPropertyName("exposeMethods")] - public bool ExposeMethods { get; init; } + [JsonPropertyName("isNullable")] + public bool IsNullable { get; init; } [JsonPropertyName("description")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Description { get; init; } - - [JsonPropertyName("remarks")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Remarks { get; init; } - - [JsonPropertyName("implementedInterfaces")] - public List ImplementedInterfaces { get; init; } = []; - - [JsonPropertyName("baseTypeHierarchy")] - public List BaseTypeHierarchy { get; init; } = []; - - /// - /// Capabilities that target this handle type. - /// - [JsonPropertyName("capabilities")] - public List Capabilities { get; init; } = []; } -internal sealed class TsDtoTypeModel +internal sealed class AppHostEnumMemberModel { [JsonPropertyName("name")] public required string Name { get; init; } - [JsonPropertyName("fullName")] - public required string FullName { get; init; } - - [JsonPropertyName("kind")] - public string Kind => "dto"; + [JsonPropertyName("value")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Value { get; init; } [JsonPropertyName("description")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Description { get; init; } - - [JsonPropertyName("remarks")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Remarks { get; init; } - - [JsonPropertyName("fields")] - public List Fields { get; init; } = []; } -internal sealed class TsDtoFieldModel +internal sealed class AppHostReturnModel { - [JsonPropertyName("name")] - public required string Name { get; init; } - [JsonPropertyName("type")] public required string Type { get; init; } - [JsonPropertyName("isOptional")] - public bool IsOptional { get; init; } + [JsonPropertyName("errorModel")] + public required string ErrorModel { get; init; } +} - [JsonPropertyName("description")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Description { get; init; } +internal sealed class AppHostSupportMatrixModel +{ + [JsonPropertyName("schemaVersion")] + public string SchemaVersion { get; init; } = "1.0"; + + [JsonPropertyName("generatedFrom")] + public required AppHostSupportGeneratedFromModel GeneratedFrom { get; init; } + + [JsonPropertyName("packages")] + public Dictionary Packages { get; init; } = new(StringComparer.Ordinal); } -internal sealed class TsEnumTypeModel +internal sealed class AppHostSupportGeneratedFromModel { - [JsonPropertyName("name")] - public required string Name { get; init; } + [JsonPropertyName("repository")] + public required string Repository { get; init; } - [JsonPropertyName("fullName")] - public required string FullName { get; init; } + [JsonPropertyName("commit")] + public required string Commit { get; init; } - [JsonPropertyName("kind")] - public string Kind => "enum"; + [JsonPropertyName("lockFile")] + public required string LockFile { get; init; } - [JsonPropertyName("description")] + [JsonPropertyName("dumpProvenance")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Description { get; init; } + public AppHostDumpProvenanceModel? DumpProvenance { get; init; } +} - [JsonPropertyName("remarks")] +internal sealed class AppHostSupportPackageModel +{ + [JsonPropertyName("package")] + public required AppHostSupportPackageInfoModel Package { get; init; } + + [JsonPropertyName("dumpProvenance")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Remarks { get; init; } + public AppHostDumpProvenanceModel? DumpProvenance { get; init; } - [JsonPropertyName("members")] - public List Members { get; init; } = []; + [JsonPropertyName("items")] + public Dictionary Items { get; init; } = new(StringComparer.Ordinal); +} - /// - /// Per-member XML documentation. Only emitted when at least one member - /// has a non-empty description or remarks. Indexed by name to allow - /// consumers to correlate without breaking the legacy - /// string array. - /// - [JsonPropertyName("memberDocs")] +internal sealed class AppHostSupportPackageInfoModel +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("version")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List? MemberDocs { get; init; } + public string? Version { get; init; } } -internal sealed class TsEnumMemberDocModel +internal sealed class AppHostSupportItemModel { + [JsonPropertyName("kind")] + public required string Kind { get; init; } + [JsonPropertyName("name")] public required string Name { get; init; } - [JsonPropertyName("description")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Description { get; init; } + [JsonPropertyName("languages")] + public Dictionary Languages { get; init; } = new(StringComparer.Ordinal); +} - [JsonPropertyName("remarks")] +internal sealed class AppHostSupportStatusModel +{ + [JsonPropertyName("supported")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public bool Supported { get; init; } + + [JsonPropertyName("validation")] + public string Validation { get; init; } = "source-derived"; + + [JsonPropertyName("reason")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Remarks { get; init; } + public string? Reason { get; init; } } diff --git a/src/tools/AtsJsonGenerator/Helpers/SupportMatrixAggregator.cs b/src/tools/AtsJsonGenerator/Helpers/SupportMatrixAggregator.cs new file mode 100644 index 000000000..7c27c6d60 --- /dev/null +++ b/src/tools/AtsJsonGenerator/Helpers/SupportMatrixAggregator.cs @@ -0,0 +1,169 @@ +using System.Text.Json; + +namespace AtsJsonGenerator.Helpers; + +internal static class SupportMatrixAggregator +{ + public static AppHostSupportMatrixModel Aggregate( + IReadOnlyList stagedModules, + IReadOnlyList? baselineModules = null, + IReadOnlySet? replacedPackageNames = null) + { + foreach (var module in stagedModules) + { + AtsTransformer.ValidateProjectionAccounting(module.Items); + } + foreach (var module in baselineModules ?? []) + { + AtsTransformer.ValidateProjectionAccounting(module.Items); + } + + var packagesToReplace = stagedModules + .Select(module => module.Package.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (replacedPackageNames is not null) + { + packagesToReplace.UnionWith(replacedPackageNames); + } + var modules = (baselineModules ?? []) + .Where(module => !packagesToReplace.Contains(module.Package.Name)) + .Concat(stagedModules) + .OrderBy(PackageIdentity, StringComparer.Ordinal) + .ToList(); + + var duplicate = modules.GroupBy(PackageIdentity, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new InvalidOperationException( + $"Duplicate semantic package identity '{duplicate.Key}'."); + } + var generator = GetGeneratorProvenance(modules); + + return new AppHostSupportMatrixModel + { + GeneratedFrom = new AppHostSupportGeneratedFromModel + { + Repository = generator.Repository, + Commit = generator.Commit, + LockFile = generator.LockFile, + DumpProvenance = GetCommonDumpProvenance(modules), + }, + Packages = modules.ToDictionary( + PackageIdentity, + CreatePackage, + StringComparer.Ordinal), + }; + } + + public static IReadOnlyList ReadModules(string directory) + { + if (!Directory.Exists(directory)) + { + throw new DirectoryNotFoundException( + $"Semantic module directory not found: {directory}"); + } + + var modules = new List(); + foreach (var path in Directory.GetFiles(directory, "*.json", SearchOption.TopDirectoryOnly) + .Order(StringComparer.Ordinal)) + { + try + { + var module = JsonSerializer.Deserialize(File.ReadAllText(path)) + ?? throw new InvalidOperationException("The document was empty."); + modules.Add(module); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Failed to read semantic module '{path}': {exception.Message}", + exception); + } + } + + return modules; + } + + internal static string PackageIdentity(AppHostModuleModel module) + => string.IsNullOrWhiteSpace(module.Package.Version) + ? module.Package.Name + : $"{module.Package.Name}@{module.Package.Version}"; + + private static AppHostGeneratorProvenanceModel GetGeneratorProvenance( + IReadOnlyList modules) + { + if (modules.Count == 0) + { + return new AppHostGeneratorProvenanceModel + { + Repository = AtsTransformer.UpstreamRepository, + Commit = AtsTransformer.UpstreamCommit, + LockFile = AtsTransformer.UpstreamLockFile, + }; + } + + var provenance = modules[0].GeneratorProvenance; + if (modules.Skip(1).Any(module => + !string.Equals(module.GeneratorProvenance.Repository, provenance.Repository, StringComparison.Ordinal) || + !string.Equals(module.GeneratorProvenance.Commit, provenance.Commit, StringComparison.Ordinal) || + !string.Equals(module.GeneratorProvenance.LockFile, provenance.LockFile, StringComparison.Ordinal))) + { + throw new InvalidOperationException( + "Staged semantic modules were produced by different generator revisions."); + } + + return provenance; + } + + private static AppHostDumpProvenanceModel? GetCommonDumpProvenance( + IReadOnlyList modules) + { + if (modules.Count == 0) + { + return null; + } + + var first = modules[0].DumpProvenance; + return modules.All(module => DumpProvenanceEquals(module.DumpProvenance, first)) + ? first + : null; + } + + private static bool DumpProvenanceEquals( + AppHostDumpProvenanceModel? left, + AppHostDumpProvenanceModel? right) + => string.Equals(left?.CliVersion, right?.CliVersion, StringComparison.Ordinal) && + string.Equals(left?.ProductCommit, right?.ProductCommit, StringComparison.Ordinal) && + string.Equals(left?.GeneratedAt, right?.GeneratedAt, StringComparison.Ordinal); + + private static AppHostSupportPackageModel CreatePackage(AppHostModuleModel module) + => new() + { + Package = new AppHostSupportPackageInfoModel + { + Name = module.Package.Name, + Version = module.Package.Version, + }, + DumpProvenance = module.DumpProvenance, + Items = module.Items + .OrderBy(item => item.Id, StringComparer.Ordinal) + .ToDictionary( + item => item.Id, + item => new AppHostSupportItemModel + { + Kind = item.Kind, + Name = item.Name, + Languages = AtsTransformer.Languages.ToDictionary( + language => language, + language => new AppHostSupportStatusModel + { + Supported = item.Projections[language].Status == "supported", + Validation = item.Projections[language].Validation, + Reason = item.Projections[language].Reason, + }, + StringComparer.Ordinal), + }, + StringComparer.Ordinal), + }; +} diff --git a/src/tools/AtsJsonGenerator/Program.cs b/src/tools/AtsJsonGenerator/Program.cs index 993bb3184..504128d78 100644 --- a/src/tools/AtsJsonGenerator/Program.cs +++ b/src/tools/AtsJsonGenerator/Program.cs @@ -2,5 +2,6 @@ var rootCommand = GenerateCommand.GetCommand(); rootCommand.Add(BatchGenerateCommand.GetCommand()); +rootCommand.Add(SupportMatrixCommand.GetCommand()); return await rootCommand.Parse(args).InvokeAsync().ConfigureAwait(false); diff --git a/src/tools/AtsJsonGenerator/README.md b/src/tools/AtsJsonGenerator/README.md index 91c8d6aa8..bbf5961fe 100644 --- a/src/tools/AtsJsonGenerator/README.md +++ b/src/tools/AtsJsonGenerator/README.md @@ -1,100 +1,173 @@ # AtsJsonGenerator -Transforms `aspire sdk dump --format json` output into structured JSON files for the aspire.dev TypeScript API reference pages. +Transforms one `aspire sdk dump --format json` document into one +language-neutral AppHost API package in +`src/frontend/src/data/apphost-modules/`. -## Overview +The generator preserves ATS identities and attaches projections for TypeScript, +Python, Go, Java, and Rust to every capability, handle, DTO, enum, and exported +value. A projection is either `supported` or `unsupported`; unsupported +projections always include a reason. -The Aspire CLI can emit a JSON description of all TypeScript-accessible capabilities for any hosting package via `aspire sdk dump --format json`. This tool transforms that output into a docs-friendly JSON format suitable for consumption by Astro's content collections. +In addition to signatures and declarations, projections expose structured +language-specific data: -The companion `generate-ts-api-json.ps1` script reads the generated C# package JSON files in `src/frontend/src/data/pkgs/` and regenerates TypeScript API JSON using the same `Aspire.Hosting*` and `CommunityToolkit.Aspire.Hosting*` package/version sets. Modules with no generated functions or types are omitted so empty packages don't appear in the TypeScript API reference. +- DTO projections use `fields: [{ name, type, isOptional?, isNullable? }]`. +- Enum projections use `members: [{ name, value? }]`. +- Exported values use `valueExpression`. +- Handle projections use `kind: "interface" | "class" | "handle"` and + `implementedInterfaces`. -## Usage +The shared item retains the original ATS fields independently of these +language projections. -### Single file +## Generate one package -```bash -# First, generate the dump from the Aspire CLI +```powershell aspire sdk dump --format json -o Aspire.Hosting.dump.json -# Then transform it for the docs site -dotnet run --project AtsJsonGenerator.csproj -- \ - --input Aspire.Hosting.dump.json \ - --output ../../frontend/src/data/ts-pkgs/Aspire.Hosting.json \ - --package-name "Aspire.Hosting" \ - --version "13.2.0" \ - --source-repo "https://github.com/microsoft/aspire" +dotnet run --project .\AtsJsonGenerator.csproj -- ` + --input .\Aspire.Hosting.dump.json ` + --output ..\..\frontend\src\data\apphost-modules\Aspire.Hosting.json ` + --support-output .\Aspire.Hosting.support.json ` + --package-name Aspire.Hosting ` + --package-version 13.2.0 ` + --source-repo https://github.com/microsoft/aspire ` + --dump-cli-version 13.2.0 ` + --dump-product-commit 62028348b5d02dfc8f8baf03a4472946537b0d16 ``` -### Batch mode +Use `--base ` for integration packages. Deduplication +removes matching ATS item identities after all five language projections have +been generated, so it cannot produce language-specific drift. -Process multiple pre-generated dump files: +## Generate the package set -```bash -dotnet run --project AtsJsonGenerator.csproj -- batch \ - --input Aspire.Hosting.json Aspire.Hosting.Redis.json \ - --output-dir ../../frontend/src/data/ts-pkgs/ +```powershell +.\generate-apphost-api-json.ps1 ` + -AspireRepoPath D:\GitHub\aspire ` + -SupportOutput ..\..\frontend\src\data\apphost-language-support.json ``` -Or discover and dump all integration packages from a local Aspire repo clone: - -```bash -dotnet run --project AtsJsonGenerator.csproj -- batch \ - --aspire-repo /path/to/microsoft/aspire \ - --output-dir ../../frontend/src/data/ts-pkgs/ \ - --version "13.2.0" +Without arguments, the script reads package names and versions from +`src/frontend/src/data/pkgs/`. Its default output directory is +`src/frontend/src/data/apphost-modules/`. The support output defaults to +`ASPIRE_API_LANGUAGE_SUPPORT_FILE`, when set, or +`src/frontend/src/data/apphost-language-support.json`. + +Full runs aggregate exactly the staged semantic modules. Filtered and explicit +runs merge staged modules with the existing module directory, replacing every +successfully regenerated package while preserving unaffected packages. The +aggregation is performed by the C# `support` command: + +Large interrupted runs can resume in disjoint explicit-package chunks without +redumping core. Pass `-BaseModulePath` with the completed +`Aspire.Hosting..json` semantic module so every integration still +deduplicates against core. `-SkipBuild` is available when the generator was +already built before launching parallel chunks. + +```powershell +dotnet run --project .\AtsJsonGenerator.csproj -- support ` + --input-dir ..\..\frontend\src\data\apphost-modules ` + --output ..\..\frontend\src\data\apphost-language-support.json ``` -## Output Format +`generate-ts-api-json.ps1` remains as a compatibility shim and forwards its +parameters to `generate-apphost-api-json.ps1` with a deprecation warning. -The generated JSON follows this schema: +## Semantic package schema ```json { + "schemaVersion": "1.0", + "generatorProvenance": { + "repository": "microsoft/aspire", + "commit": "62028348b5d02dfc8f8baf03a4472946537b0d16", + "lockFile": "src/tools/AtsJsonGenerator/upstream-sources.lock.json" + }, + "dumpProvenance": { + "cliVersion": "13.2.0", + "productCommit": "62028348b5d02dfc8f8baf03a4472946537b0d16" + }, "package": { "name": "Aspire.Hosting", "version": "13.2.0", - "language": "typescript", - "sourceRepository": "https://github.com/microsoft/aspire" + "sourceRepository": "https://github.com/microsoft/aspire", + "sourceCommit": "..." }, - "functions": [ + "items": [ { + "id": "capability:Aspire.Hosting/addContainer", + "kind": "capability", "name": "addContainer", "capabilityId": "Aspire.Hosting/addContainer", - "qualifiedName": "addContainer", - "description": "Adds a container resource", - "kind": "Method", - "signature": "addContainer(name: string, image: string): ContainerResource", - "parameters": [...], + "parameters": [], "returnType": "ContainerResource", - "returnsBuilder": true, - "targetTypeId": "Aspire.Hosting/...", - "expandedTargetTypes": [...] + "projections": { + "typescript": { + "status": "supported", + "validation": "source-derived", + "identifier": "addContainer", + "signature": "addContainer(name: string): Promise", + "sourceFile": "aspire.mts", + "parameters": [], + "return": { + "type": "Promise", + "errorModel": "exception" + } + }, + "python": { "status": "supported", "validation": "source-derived", "identifier": "add_container", "sourceFile": "aspire.py" }, + "go": { "status": "supported", "validation": "source-derived", "identifier": "AddContainer", "sourceFile": "aspire.go" }, + "java": { "status": "supported", "validation": "source-derived", "identifier": "addContainer", "sourceFile": "Aspire.java" }, + "rust": { "status": "supported", "validation": "source-derived", "identifier": "add_container", "sourceFile": "lib.rs" } + } } - ], - "handleTypes": [ - { - "name": "ContainerResource", - "fullName": "Aspire.Hosting.ApplicationModel.ContainerResource", - "kind": "handle", - "isInterface": false, - "capabilities": [...] - } - ], - "dtoTypes": [...], - "enumTypes": [...] + ] } ``` -## Capability Kinds +The optional support matrix uses this contract: + +```json +{ + "schemaVersion": "1.0", + "generatedFrom": { + "repository": "microsoft/aspire", + "commit": "62028348b5d02dfc8f8baf03a4472946537b0d16", + "lockFile": "src/tools/AtsJsonGenerator/upstream-sources.lock.json", + "dumpProvenance": { + "cliVersion": "13.2.0", + "productCommit": "62028348b5d02dfc8f8baf03a4472946537b0d16" + } + }, + "packages": { + "Aspire.Hosting@13.2.0": { + "package": { + "name": "Aspire.Hosting", + "version": "13.2.0" + }, + "items": { + "capability:Aspire.Hosting/addContainer": { + "kind": "capability", + "name": "addContainer", + "languages": { + "typescript": { "supported": true, "validation": "source-derived" }, + "python": { "supported": true, "validation": "source-derived" }, + "go": { "supported": true, "validation": "source-derived" }, + "java": { "supported": true, "validation": "source-derived" }, + "rust": { "supported": true, "validation": "source-derived" } + } + } + } + } + } +} +``` -- **Method** — Top-level functions called on the builder (e.g., `addContainer`, `withEndpoint`) -- **PropertyGetter** — Property access on handle types (e.g., `EndpointReference.port`) -- **PropertySetter** — Property mutation on handle types (e.g., `ExecuteCommandContext.setResourceName`) -- **InstanceMethod** — Methods called on handle instances (e.g., `DistributedApplication.run`) +Transformation fails if any item is missing a language projection. -## Building +## Validation -```bash -cd src/tools/AtsJsonGenerator -dotnet build +```powershell +dotnet test ..\..\..\tests\AtsJsonGenerator.Tests\AtsJsonGenerator.Tests.csproj ``` diff --git a/src/tools/AtsJsonGenerator/SupportMatrixCommand.cs b/src/tools/AtsJsonGenerator/SupportMatrixCommand.cs new file mode 100644 index 000000000..c1e73e0b3 --- /dev/null +++ b/src/tools/AtsJsonGenerator/SupportMatrixCommand.cs @@ -0,0 +1,100 @@ +using System.CommandLine; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using AtsJsonGenerator.Helpers; + +namespace AtsJsonGenerator; + +internal static class SupportMatrixCommand +{ + private static readonly Option s_inputDirOption = new("--input-dir", "-i") + { + Required = true, + Description = "Directory containing staged semantic AppHost module JSON files.", + }; + + private static readonly Option s_outputOption = new("--output", "-o") + { + Required = true, + Description = "Path to write the aggregated apphost-language-support.json file.", + }; + + private static readonly Option s_baselineDirOption = new("--baseline-dir") + { + Description = "Optional directory containing existing modules to preserve when their package is not staged.", + }; + + private static readonly Option s_replacePackageOption = new("--replace-package") + { + AllowMultipleArgumentsPerToken = true, + Description = "Package names to remove from the baseline even when regeneration produced no module.", + }; + + public static Command GetCommand() + { + var command = new Command( + "support", + "Aggregates semantic AppHost module files into the language support matrix.") + { + s_inputDirOption, + s_outputOption, + s_baselineDirOption, + s_replacePackageOption, + }; + + command.SetAction(static parseResult => + { + var inputDirectory = parseResult.GetValue(s_inputDirOption)!; + var output = parseResult.GetValue(s_outputOption)!; + var baselineDirectory = parseResult.GetValue(s_baselineDirOption); + var replacePackages = parseResult.GetValue(s_replacePackageOption); + return Aggregate(inputDirectory, output, baselineDirectory, replacePackages); + }); + + return command; + } + + internal static int Aggregate( + string inputDirectory, + string outputPath, + string? baselineDirectory = null, + string[]? replacePackages = null) + { + try + { + var staged = SupportMatrixAggregator.ReadModules(inputDirectory); + var baseline = baselineDirectory is null || !Directory.Exists(baselineDirectory) + ? [] + : SupportMatrixAggregator.ReadModules(baselineDirectory); + var replacements = (replacePackages ?? []) + .Where(package => !string.IsNullOrWhiteSpace(package)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var matrix = SupportMatrixAggregator.Aggregate(staged, baseline, replacements); + var outputDirectory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + var options = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + var changed = StableFileWriter.WriteIfChanged( + outputPath, + JsonSerializer.Serialize(matrix, options)); + Console.WriteLine( + $"{(changed ? "Generated" : "Unchanged")}: {outputPath} " + + $"({matrix.Packages.Count} packages)"); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine($"Failed to aggregate support matrix: {exception.Message}"); + return 1; + } + } +} diff --git a/src/tools/AtsJsonGenerator/generate-apphost-api-json.ps1 b/src/tools/AtsJsonGenerator/generate-apphost-api-json.ps1 new file mode 100644 index 000000000..78ae45f59 --- /dev/null +++ b/src/tools/AtsJsonGenerator/generate-apphost-api-json.ps1 @@ -0,0 +1,1092 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Generates language-neutral AppHost API reference JSON files by running + `aspire sdk dump --format json` for each hosting integration with ATS capabilities. + +.DESCRIPTION + Supports two input modes: + 1. -AspireRepoPath: Discovers .csproj files in a local microsoft/aspire clone + 2. -NuGetPackageVersion: Uses Name@Version syntax to resolve packages via NuGet + + For each eligible package, this script: + 1. Runs `aspire sdk dump --format json` to generate raw ATS capabilities JSON + 2. Runs the AtsJsonGenerator tool to transform it into docs-site JSON + 3. Outputs one semantic package document to src/frontend/src/data/apphost-modules/ + +.PARAMETER AspireRepoPath + Path to a local microsoft/aspire repository clone. Discovers projects with + [AspireExport] attributes automatically. + +.PARAMETER NuGetPackageVersion + One or more package references in Name@Version format (e.g. + "Aspire.Hosting.Redis@13.1.2"). Passed to `aspire sdk dump` as the + integration argument. + +.PARAMETER OutputDir + The directory to write the generated JSON files to. + Defaults to /src/frontend/src/data/apphost-modules. + +.PARAMETER SupportOutput + Path to the aggregated language-support JSON file. Defaults to + ASPIRE_API_LANGUAGE_SUPPORT_FILE or + /src/frontend/src/data/apphost-language-support.json. + +.PARAMETER PackageFilter + Optional wildcard filter to process only specific packages (e.g. "*Redis*"). + +.PARAMETER BaseModulePath + Optional previously generated Aspire.Hosting semantic module used to + deduplicate integration packages when core is not part of this invocation. + +.PARAMETER SkipBuild + Skips building AtsJsonGenerator before generation. Use only when the tool + has already been built, such as parallel resumable chunk generation. + +.PARAMETER AspireCliProject + Path to a local Aspire.Cli.csproj to use instead of the globally installed + `aspire` CLI. Useful for testing local CLI changes. When set, the script + invokes `dotnet run --no-launch-profile --project --` instead of `aspire`. + +.PARAMETER DumpCliVersion + Optional Aspire CLI version recorded in dump provenance. + +.PARAMETER DumpGeneratedAt + Optional workflow-supplied generation timestamp recorded in dump provenance. + +.ENVIRONMENT_VARIABLE ASPIRE_CLI_PATH + Path to the installed Aspire CLI executable. Used when -AspireCliProject is + not set; defaults to resolving `aspire` from PATH. + +.EXAMPLE + # From a local Aspire repo clone (uses global CLI) + ./generate-apphost-api-json.ps1 -AspireRepoPath D:\GitHub\aspire + + # Using Name@Version syntax with a local CLI build + ./generate-apphost-api-json.ps1 -NuGetPackageVersion "Aspire.Hosting@13.1.2","Aspire.Hosting.Redis@13.1.2" ` + -AspireCliProject D:\GitHub\aspire\src\Aspire.Cli\Aspire.Cli.csproj + + # Filter to specific packages + ./generate-apphost-api-json.ps1 -AspireRepoPath D:\GitHub\aspire -PackageFilter "*Redis*" +#> + +[CmdletBinding()] +param( + [string]$AspireRepoPath, + + [string[]]$NuGetPackageVersion, + + [string]$OutputDir, + + [string]$SupportOutput, + + [string]$PackageFilter, + + [string]$BaseModulePath, + + [switch]$SkipBuild, + + [string]$AspireCliProject, + + [string]$DumpCliVersion, + + [string]$DumpGeneratedAt +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$NuGetOrgServiceIndex = if ([string]::IsNullOrWhiteSpace($env:ASPIRE_PUBLIC_NUGET_INDEX)) { + "https://api.nuget.org/v3/index.json" +} else { + $env:ASPIRE_PUBLIC_NUGET_INDEX.Trim() +} +$AspireRepoCandidates = @( + $env:ASPIRE_GITHUB_REPO_URL, + "https://github.com/microsoft/aspire" +) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + +$ScriptDir = $PSScriptRoot +$RepoRoot = (Resolve-Path (Join-Path $ScriptDir "..\..\..")).Path +$ToolProject = Join-Path $ScriptDir "AtsJsonGenerator.csproj" +$AspireCliPath = if ([string]::IsNullOrWhiteSpace($env:ASPIRE_CLI_PATH)) { "aspire" } else { $env:ASPIRE_CLI_PATH } + +# Opt-in resilience: when ASPIRE_APPHOST_API_CARRY_FORWARD=1, a package whose ATS dump +# fails does not abort the whole run. Its previously committed module in the final +# output directory is preserved (carried forward) and every package that succeeded +# is still synced. Off by default so CI keeps failing hard on unexpected dump +# errors. Intended for constrained environments where a package's transitive +# restore requires a feed that is unreachable (e.g. an authenticated internal feed). +$CarryForwardOnDumpFailure = ($env:ASPIRE_APPHOST_API_CARRY_FORWARD -eq '1') + +$FinalOutputDir = if ($OutputDir) { + [System.IO.Path]::GetFullPath($OutputDir) +} +else { + Join-Path $RepoRoot "src\frontend\src\data\apphost-modules" +} +$FinalSupportOutput = if ($SupportOutput) { + [System.IO.Path]::GetFullPath($SupportOutput) +} +elseif (-not [string]::IsNullOrWhiteSpace($env:ASPIRE_API_LANGUAGE_SUPPORT_FILE)) { + [System.IO.Path]::GetFullPath($env:ASPIRE_API_LANGUAGE_SUPPORT_FILE) +} +else { + Join-Path $RepoRoot "src\frontend\src\data\apphost-language-support.json" +} +$OutputDir = Join-Path ([System.IO.Path]::GetDirectoryName($FinalOutputDir)) ( + ".$([System.IO.Path]::GetFileName($FinalOutputDir))-staging-$([Guid]::NewGuid().ToString('N'))") +$StagedSupportOutput = "$OutputDir.support.json" +New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null + +$TempDir = Join-Path $OutputDir ".tmp-dumps" +New-Item -ItemType Directory -Path $TempDir -Force | Out-Null + +function Remove-StaleAppHostModuleFiles { + param( + [string]$PackageName, + [string]$CurrentOutputFile, + [string]$OutputDirectory + ) + + if ([string]::IsNullOrWhiteSpace($PackageName) -or [string]::IsNullOrWhiteSpace($CurrentOutputFile)) { + return + } + + $namePattern = "^{0}\.\d.*\.json$" -f [regex]::Escape($PackageName) + $currentPath = [System.IO.Path]::GetFullPath($CurrentOutputFile) + + Get-ChildItem -Path $OutputDirectory -File -Filter '*.json' | Where-Object { + $_.Name -match $namePattern -and [System.IO.Path]::GetFullPath($_.FullName) -ne $currentPath + } | Remove-Item -Force -ErrorAction SilentlyContinue +} + +function Get-JsonArrayPropertyCount { + param( + [psobject]$Object, + [string]$PropertyName + ) + + $property = $Object.PSObject.Properties[$PropertyName] + if ($null -eq $property -or $null -eq $property.Value) { + return 0 + } + + return @($property.Value).Count +} + +function Get-AppHostModuleApiItemCount { + param([psobject]$ModuleJson) + + return Get-JsonArrayPropertyCount -Object $ModuleJson -PropertyName "items" +} + +function Remove-EmptyAppHostModuleFile { + param( + [string]$PackageName, + [string]$OutputFile + ) + + if (-not (Test-Path $OutputFile)) { + throw "Cannot inspect missing AppHost module file at $OutputFile" + } + + $json = Get-Content $OutputFile -Raw | ConvertFrom-Json + if ((Get-AppHostModuleApiItemCount -ModuleJson $json) -gt 0) { + return $false + } + + Remove-Item $OutputFile -Force + Write-Host " Omitted empty AppHost module for $PackageName" -ForegroundColor DarkYellow + return $true +} + +function Normalize-BranchName { + [CmdletBinding()] + param([string]$BranchName) + + if ([string]::IsNullOrWhiteSpace($BranchName)) { + return "" + } + + return $BranchName -replace '^refs/heads/', '' +} + +function Get-CurrentBranchName { + [CmdletBinding()] + param() + + $candidates = @( + $env:BUILD_SOURCEBRANCH, + $env:GITHUB_HEAD_REF, + $env:GITHUB_REF_NAME + ) + + foreach ($candidate in $candidates) { + $normalized = Normalize-BranchName $candidate + if (-not [string]::IsNullOrWhiteSpace($normalized)) { + return $normalized + } + } + + try { + $branch = (& git rev-parse --abbrev-ref HEAD 2>$null) + if ($LASTEXITCODE -eq 0) { + return (Normalize-BranchName (($branch | Out-String).Trim())) + } + } + catch { + } + + return "" +} + +function Test-IsReleaseBranch { + [CmdletBinding()] + param([string]$BranchName) + + return $BranchName.StartsWith("release/", [System.StringComparison]::OrdinalIgnoreCase) +} + +function Get-ReleaseFeedNameFromCommit { + [CmdletBinding()] + param([string]$Commit) + + if ([string]::IsNullOrWhiteSpace($Commit)) { + return $null + } + + $normalizedCommit = $Commit.Trim() + $length = [Math]::Min(8, $normalizedCommit.Length) + return "darc-pub-microsoft-aspire-$($normalizedCommit.Substring(0, $length))" +} + +function ConvertTo-ReleaseFeedServiceIndex { + [CmdletBinding()] + param([string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $null + } + + $trimmed = $Value.Trim() + if ($trimmed -match '^https?://' -and $trimmed -match '/nuget/v3/index\.json/?$') { + return $trimmed.TrimEnd('/') + } + + $feedName = $null + if ($trimmed -match '/_artifacts/feed/([^/?#]+)') { + $feedName = $Matches[1] + } + elseif ($trimmed -match '/_packaging/([^/?#]+)') { + $feedName = $Matches[1] + } + elseif ($trimmed -match '^[A-Za-z0-9][A-Za-z0-9._-]*$') { + $feedName = $trimmed + } + + if ($feedName) { + return "https://pkgs.dev.azure.com/dnceng/public/_packaging/$feedName/nuget/v3/index.json" + } + + return $null +} + +function Resolve-ReleaseBranchCommit { + [CmdletBinding()] + param([string]$BranchName) + + foreach ($repositoryUrl in $AspireRepoCandidates) { + try { + $output = (& git ls-remote $repositoryUrl "refs/heads/$BranchName" 2>$null) + if ($LASTEXITCODE -ne 0) { + continue + } + + $text = ($output | Out-String).Trim() + if ($text -match '^([0-9a-f]{40})\s+') { + return [PSCustomObject]@{ + Repository = $repositoryUrl + Commit = $Matches[1] + } + } + } + catch { + } + } + + return $null +} + +function Resolve-OfficialAspireFeed { + [CmdletBinding()] + param([string]$BranchName) + + if (-not (Test-IsReleaseBranch -BranchName $BranchName)) { + return [PSCustomObject]@{ + BranchName = $BranchName + IsRelease = $false + ServiceIndex = $NuGetOrgServiceIndex + FeedName = $null + Resolution = "default" + DisplayName = "nuget.org" + } + } + + $explicitFeedUrl = ConvertTo-ReleaseFeedServiceIndex -Value $env:ASPIRE_RELEASE_FEED_URL + if ($explicitFeedUrl) { + return [PSCustomObject]@{ + BranchName = $BranchName + IsRelease = $true + ServiceIndex = $explicitFeedUrl + FeedName = $null + Resolution = "ASPIRE_RELEASE_FEED_URL" + DisplayName = $explicitFeedUrl + } + } + + $explicitFeedName = $env:ASPIRE_RELEASE_FEED_NAME + if (-not [string]::IsNullOrWhiteSpace($explicitFeedName)) { + $serviceIndex = ConvertTo-ReleaseFeedServiceIndex -Value $explicitFeedName + return [PSCustomObject]@{ + BranchName = $BranchName + IsRelease = $true + ServiceIndex = $serviceIndex + FeedName = $explicitFeedName.Trim() + Resolution = "ASPIRE_RELEASE_FEED_NAME" + DisplayName = $explicitFeedName.Trim() + } + } + + $explicitCommit = $env:ASPIRE_RELEASE_COMMIT + if ([string]::IsNullOrWhiteSpace($explicitCommit)) { + $explicitCommit = $env:ASPIRE_RELEASE_COMMIT_SHA + } + if ([string]::IsNullOrWhiteSpace($explicitCommit)) { + $explicitCommit = $env:ASPIRE_RELEASE_SOURCE_COMMIT + } + if (-not [string]::IsNullOrWhiteSpace($explicitCommit)) { + $feedName = Get-ReleaseFeedNameFromCommit -Commit $explicitCommit + return [PSCustomObject]@{ + BranchName = $BranchName + IsRelease = $true + ServiceIndex = ConvertTo-ReleaseFeedServiceIndex -Value $feedName + FeedName = $feedName + Resolution = "ASPIRE_RELEASE_COMMIT" + DisplayName = $feedName + SourceCommit = $explicitCommit.Trim() + } + } + + $branchCommit = Resolve-ReleaseBranchCommit -BranchName $BranchName + if (-not $branchCommit) { + throw "Unable to resolve the official Aspire release feed for branch '$BranchName'. Set ASPIRE_RELEASE_FEED_URL, ASPIRE_RELEASE_FEED_NAME, or ASPIRE_RELEASE_COMMIT while microsoft/aspire is the active source repo." + } + + $feedName = Get-ReleaseFeedNameFromCommit -Commit $branchCommit.Commit + return [PSCustomObject]@{ + BranchName = $BranchName + IsRelease = $true + ServiceIndex = ConvertTo-ReleaseFeedServiceIndex -Value $feedName + FeedName = $feedName + Resolution = "branch head" + DisplayName = $feedName + SourceCommit = $branchCommit.Commit + SourceRepository = $branchCommit.Repository + } +} + +function New-TemporaryNuGetConfigDirectory { + [CmdletBinding()] + param([string[]]$RestoreSources) + + $sourceEntries = @($RestoreSources | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) + if ($sourceEntries.Count -eq 0) { + return $null + } + + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "atsjson-nuget-$([System.Guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + + $configLines = @( + '', + '', + ' ', + ' ' + ) + + for ($sourceIndex = 0; $sourceIndex -lt $sourceEntries.Count; $sourceIndex++) { + $source = [System.Security.SecurityElement]::Escape($sourceEntries[$sourceIndex]) + $configLines += " " + } + + $configLines += ' ' + $configLines += '' + $configLines | Set-Content (Join-Path $tempDir 'NuGet.Config') -Encoding UTF8 + + return $tempDir +} + +# ── Route 0: Auto-detect from generated C# package JSON ─────────────────────── +function Test-IsAppHostSdkPackage { + [CmdletBinding()] + param([string]$PackageName) + + if ([string]::IsNullOrWhiteSpace($PackageName)) { + return $false + } + + return ( + $PackageName.Equals("Aspire.Hosting", [System.StringComparison]::OrdinalIgnoreCase) -or + $PackageName.StartsWith("Aspire.Hosting.", [System.StringComparison]::OrdinalIgnoreCase) -or + $PackageName.StartsWith("CommunityToolkit.Aspire.Hosting.", [System.StringComparison]::OrdinalIgnoreCase) + ) +} + +function Get-CSharpHostingPackageMetadata { + $packageJsonDir = if (-not [string]::IsNullOrWhiteSpace($env:ASPIRE_API_PKGS_DIR)) { + [System.IO.Path]::GetFullPath($env:ASPIRE_API_PKGS_DIR) + } + else { + Join-Path $RepoRoot "src\frontend\src\data\pkgs" + } + if (-not (Test-Path $packageJsonDir)) { + return @() + } + + return @( + Get-ChildItem -Path $packageJsonDir -Filter '*.json' -File | + ForEach-Object { + $json = Get-Content $_.FullName -Raw | ConvertFrom-Json + if ((Test-IsAppHostSdkPackage -PackageName $json.package.name) -and + -not [string]::IsNullOrWhiteSpace($json.package.version)) { + $sourceRepository = $json.package.PSObject.Properties["sourceRepository"] + $sourceCommit = $json.package.PSObject.Properties["sourceCommit"] + [PSCustomObject]@{ + Name = [string]$json.package.name + Version = [string]$json.package.version + SourceRepository = if ($sourceRepository) { [string]$sourceRepository.Value } else { $null } + SourceCommit = if ($sourceCommit) { [string]$sourceCommit.Value } else { $null } + Path = $_.FullName + } + } + } + ) +} + +function Get-PackageSourceRepository { + [CmdletBinding()] + param([string]$PackageName) + + if ( + $PackageName.Equals("Aspire.Hosting", [System.StringComparison]::OrdinalIgnoreCase) -or + $PackageName.StartsWith("Aspire.Hosting.", [System.StringComparison]::OrdinalIgnoreCase) + ) { + return "https://github.com/microsoft/aspire" + } + + if ($PackageName.StartsWith("CommunityToolkit.Aspire.Hosting.", [System.StringComparison]::OrdinalIgnoreCase)) { + return "https://github.com/CommunityToolkit/Aspire" + } + + return $null +} + +$generatedPackageMetadata = @(Get-CSharpHostingPackageMetadata) +$packageMetadataBySpec = @{} +foreach ($metadata in $generatedPackageMetadata) { + $spec = "$($metadata.Name)@$($metadata.Version)" + if ($packageMetadataBySpec.ContainsKey($spec)) { + throw "Duplicate generated C# package metadata for '$spec': '$($packageMetadataBySpec[$spec].Path)' and '$($metadata.Path)'." + } + $packageMetadataBySpec[$spec] = $metadata +} + +$hasExplicitNuGetPackageVersion = $NuGetPackageVersion -and $NuGetPackageVersion.Count -gt 0 +if (-not $AspireRepoPath -and -not $hasExplicitNuGetPackageVersion) { + Write-Host "No -AspireRepoPath or -NuGetPackageVersion provided. Auto-detecting from generated C# package JSON..." -ForegroundColor Cyan + + $hostingPackages = @($generatedPackageMetadata) + + if ($hostingPackages.Count -eq 0) { + Write-Error "No AppHost SDK package JSON files found in src/frontend/src/data/pkgs" + return + } + + $duplicatePackages = @($hostingPackages | Group-Object Name | Where-Object Count -gt 1) + if ($duplicatePackages.Count -gt 0) { + $details = $duplicatePackages | ForEach-Object { + "$($_.Name): $((@($_.Group) | ForEach-Object { "$($_.Version) [$($_.Path)]" }) -join ', ')" + } + throw "Multiple generated C# package versions prevent deterministic AppHost API generation:`n $($details -join "`n ")" + } + + # Build Name@Version entries directly from the generated C# package data. + $NuGetPackageVersion = @($hostingPackages | ForEach-Object { "$($_.Name)@$($_.Version)" }) + + if ($NuGetPackageVersion.Count -eq 0) { + Write-Error "No versioned AppHost SDK package JSON files found in $packageJsonDir" + return + } + + Write-Host " Found $($NuGetPackageVersion.Count) AppHost SDK packages to process" -ForegroundColor DarkGray +} + +# ── Helper: invoke the aspire CLI ────────────────────────────────────────────── + +function Invoke-AspireCli { + param( + [string[]]$Arguments, + [string]$WorkingDirectory, + [string]$StderrFile + ) + + $previousRestoreSources = $env:RestoreSources + $previousRestoreIgnoreFailedSources = $env:RestoreIgnoreFailedSources + $previousRestoreDisableParallel = $env:RestoreDisableParallel + if (-not $AspireRepoPath) { + $env:RestoreSources = $NuGetOrgServiceIndex + $env:RestoreIgnoreFailedSources = "true" + $env:RestoreDisableParallel = "true" + } + + try { + if ($AspireCliProject) { + # Run via dotnet run against a local Aspire.Cli.csproj (assumes pre-built) + $allArgs = @("run", "--no-launch-profile", "--no-build", "--project", $AspireCliProject, "--") + $Arguments + $proc = Start-Process -FilePath "dotnet" -ArgumentList $allArgs -WorkingDirectory $WorkingDirectory ` + -Wait -NoNewWindow -PassThru -RedirectStandardError $StderrFile + } else { + # Use the installed aspire CLI + $proc = Start-Process -FilePath $AspireCliPath -ArgumentList $Arguments -WorkingDirectory $WorkingDirectory ` + -Wait -NoNewWindow -PassThru -RedirectStandardError $StderrFile + } + } + finally { + $env:RestoreSources = $previousRestoreSources + $env:RestoreIgnoreFailedSources = $previousRestoreIgnoreFailedSources + $env:RestoreDisableParallel = $previousRestoreDisableParallel + } + + # Filter out update notices from stderr + if (Test-Path $StderrFile) { + $stderrContent = Get-Content $StderrFile -Raw -ErrorAction SilentlyContinue + if ($stderrContent -and $stderrContent -match "A new version of the Aspire CLI is available") { + # Strip the update notice lines and rewrite + $filtered = ($stderrContent -split "`r?`n" | Where-Object { + $_ -notmatch "A new version of the Aspire CLI is available" -and + $_ -notmatch "To update, run:" -and + $_ -notmatch "For more information, see:" + }) -join "`n" + Set-Content -Path $StderrFile -Value $filtered.Trim() -ErrorAction SilentlyContinue + } + } + + return $proc +} + +# ── Collect packages to process ──────────────────────────────────────────────── + +# Each entry: @{ Name = "Aspire.Hosting.Redis"; Version = "13.1.2"; DumpArgs = @("arg") } +$Packages = @() + +# ── Route 1: Discover from local Aspire repo clone ──────────────────────────── +if ($AspireRepoPath) { + $SrcDir = Join-Path $AspireRepoPath "src" + if (-not (Test-Path $SrcDir)) { + Write-Error "Aspire repo src/ directory not found at $SrcDir" + return + } + + # Core Aspire.Hosting must be passed explicitly; the default dump is empty. + $coreDir = Join-Path $SrcDir "Aspire.Hosting" + if (Test-Path $coreDir) { + $coreCsproj = Join-Path $coreDir "Aspire.Hosting.csproj" + if (-not (Test-Path $coreCsproj)) { + Write-Warning "Skipping Aspire.Hosting — project file not found at $coreCsproj" + } + else { + $Packages += @{ + Name = "Aspire.Hosting" + DumpArgs = @($coreCsproj) + SourceRepository = Get-PackageSourceRepository -PackageName "Aspire.Hosting" + SourceCommit = $null + } + } + } + + # Only hosting packages expose ATS capabilities for generated AppHost SDKs. + # Client/component packages (e.g. Aspire.Azure.Data.Tables) are not applicable. + $hostingDirs = Get-ChildItem -Path $SrcDir -Directory -Filter "Aspire.Hosting.*" | Where-Object { + $_.Name -notmatch "(Analyzers|CodeGeneration|RemoteHost|Tests)" + } + + foreach ($dir in $hostingDirs) { + $csproj = Join-Path $dir.FullName "$($dir.Name).csproj" + if (-not (Test-Path $csproj)) { continue } + + $csFiles = Get-ChildItem -Path $dir.FullName -Filter "*.cs" -Recurse | Where-Object { + $_.FullName -notmatch "\\(obj|bin)\\" + } + + $hasExport = $false + foreach ($f in $csFiles) { + if (Select-String -Path $f.FullName -Pattern "\[AspireExport\(" -Quiet) { + $hasExport = $true + break + } + } + + if ($hasExport) { + $Packages += @{ + Name = $dir.Name + DumpArgs = @($csproj) + SourceRepository = Get-PackageSourceRepository -PackageName $dir.Name + SourceCommit = $null + } + } + } +} + +# ── Route 2: Name@Version NuGet package references ──────────────────────────── +# Only Aspire.Hosting.* packages expose ATS capabilities for generated AppHost SDKs. +# Client/component packages (e.g. Aspire.Azure.Data.Tables) are not applicable. +if ($NuGetPackageVersion -and $NuGetPackageVersion.Count -gt 0) { + foreach ($spec in $NuGetPackageVersion) { + if ($spec -notmatch '^(.+)@(.+)$') { + Write-Warning "Invalid format '$spec' — expected Name@Version (e.g. Aspire.Hosting.Redis@13.1.2)" + continue + } + $pkgName = $Matches[1] + $pkgVersion = $Matches[2] + $packageMetadata = $packageMetadataBySpec["$pkgName@$pkgVersion"] + if ($null -eq $packageMetadata) { + throw "No exact generated C# package metadata was found for '$pkgName@$pkgVersion'. Run generate-package-json.ps1 first." + } + + if (-not (Test-IsAppHostSdkPackage -PackageName $pkgName)) { + Write-Warning "Skipping $pkgName — only Aspire.Hosting* and CommunityToolkit.Aspire.Hosting* packages have ATS capabilities" + continue + } + + $Packages += @{ + Name = $pkgName + Version = $pkgVersion + DumpArgs = @("$pkgName@$pkgVersion") + SourceRepository = $packageMetadata.SourceRepository + SourceCommit = $packageMetadata.SourceCommit + } + } +} + +if ($PackageFilter) { + $Packages = $Packages | Where-Object { $_.Name -like $PackageFilter } +} + +$branchName = Get-CurrentBranchName +$officialFeed = Resolve-OfficialAspireFeed -BranchName $branchName +$aspireCliWorkingDirectory = $null + +if (-not $AspireRepoPath) { + $restoreSources = if ($officialFeed.IsRelease) { + @($officialFeed.ServiceIndex, $NuGetOrgServiceIndex) + } + else { + @($NuGetOrgServiceIndex) + } + + $aspireCliWorkingDirectory = New-TemporaryNuGetConfigDirectory -RestoreSources $restoreSources + + if ($officialFeed.IsRelease) { + Write-Host "Release branch detected ($($officialFeed.BranchName)). AppHost module generation will resolve official Aspire packages from $($officialFeed.DisplayName)." -ForegroundColor Cyan + } +} + +Write-Host "Found $($Packages.Count) packages to process" +if ($AspireCliProject) { + Write-Host "Using local CLI: $AspireCliProject" -ForegroundColor DarkGray +} else { + Write-Host "Using Aspire CLI: $AspireCliPath" -ForegroundColor DarkGray +} + +# ── Build the tool ───────────────────────────────────────────────────────────── + +Write-Host "" +if (-not $SkipBuild) { + Write-Host "Building AtsJsonGenerator..." -ForegroundColor Cyan + & dotnet build $ToolProject --nologo -v q 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to build AtsJsonGenerator" -ForegroundColor Red + Remove-Item -Path $OutputDir -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $StagedSupportOutput -Force -ErrorAction SilentlyContinue + exit 1 + } +} +else { + Write-Host "Using prebuilt AtsJsonGenerator..." -ForegroundColor DarkGray +} + +# ── Generate ATS dumps ───────────────────────────────────────────────────────── + +# Process core Aspire.Hosting first so we can use it as a base for dedup +$corePackages = @($Packages | Where-Object { $_.Name -eq "Aspire.Hosting" }) +$integrationPackages = @($Packages | Where-Object { $_.Name -ne "Aspire.Hosting" }) + +$success = 0 +$failed = 0 +$skipped = 0 +$failedPackageNames = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase) +$skippedPackageNames = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase) +$coreOutputFile = if ($BaseModulePath) { + $resolvedBaseModulePath = [System.IO.Path]::GetFullPath($BaseModulePath) + if (-not (Test-Path $resolvedBaseModulePath -PathType Leaf)) { + throw "Base AppHost module not found: $resolvedBaseModulePath" + } + $resolvedBaseModulePath +} +else { + $null +} + +# Process core first +foreach ($pkg in $corePackages) { + $name = $pkg.Name + $version = $pkg.Version + $fileBaseName = if ($version) { "$name.$version" } else { $name } + $dumpFile = Join-Path $TempDir "$name.json" + $outputFile = Join-Path $OutputDir "$fileBaseName.json" + + Write-Host "" + Write-Host "[$name] (core — processed first)" -ForegroundColor Cyan + + # Step 1: Run aspire sdk dump --format json + Write-Host " Dumping ATS capabilities..." + try { + $dumpArgs = @("sdk", "dump", "--format", "json", "--non-interactive", "--nologo", "-o", $dumpFile) + $dumpArgs += $pkg.DumpArgs + + $workDir = if ($AspireRepoPath) { $AspireRepoPath } elseif ($aspireCliWorkingDirectory) { $aspireCliWorkingDirectory } else { $PWD.Path } + $proc = Invoke-AspireCli -Arguments $dumpArgs -WorkingDirectory $workDir ` + -StderrFile (Join-Path $TempDir "$name.stderr.txt") + + if ($proc.ExitCode -ne 0 -and -not (Test-Path $dumpFile)) { + $stderr = Get-Content (Join-Path $TempDir "$name.stderr.txt") -Raw -ErrorAction SilentlyContinue + Write-Warning " aspire sdk dump failed (exit $($proc.ExitCode))" + if ($stderr) { Write-Warning " $stderr" } + $failed++ + [void]$failedPackageNames.Add($name) + continue + } + + if (-not (Test-Path $dumpFile)) { + Write-Warning " Dump file not created" + $failed++ + [void]$failedPackageNames.Add($name) + continue + } + + if ($proc.ExitCode -ne 0) { + Write-Host " aspire sdk dump exited with $($proc.ExitCode) but output was generated — continuing" -ForegroundColor DarkYellow + } + } + catch { + Write-Warning " Error running aspire sdk dump: $_" + $failed++ + [void]$failedPackageNames.Add($name) + continue + } + + # Step 2: Transform (no --base for core) + Write-Host " Transforming to docs JSON..." + try { + $transformArgs = @( + "run", "--project", $ToolProject, "--no-build", "--", + "--input", $dumpFile, + "--output", $outputFile, + "--package-name", $name + ) + if (-not [string]::IsNullOrWhiteSpace($version)) { + $transformArgs += @("--package-version", $version) + } + $sourceRepository = $pkg.SourceRepository + if ($sourceRepository) { + $transformArgs += @("--source-repo", $sourceRepository) + } + if (-not [string]::IsNullOrWhiteSpace($pkg.SourceCommit)) { + $transformArgs += @( + "--source-commit", $pkg.SourceCommit, + "--dump-product-commit", $pkg.SourceCommit + ) + } + if (-not [string]::IsNullOrWhiteSpace($DumpCliVersion)) { + $transformArgs += @("--dump-cli-version", $DumpCliVersion) + } + if (-not [string]::IsNullOrWhiteSpace($DumpGeneratedAt)) { + $transformArgs += @("--dump-generated-at", $DumpGeneratedAt) + } + + & dotnet @transformArgs 2>&1 | ForEach-Object { + if ($_ -match "Generated:") { + Write-Host " $_" -ForegroundColor Green + } elseif ($_ -match "FAILED|Error") { + Write-Host " $_" -ForegroundColor Red + } + } + + if ($LASTEXITCODE -eq 0) { + $success++ + $coreOutputFile = $outputFile + if ($version) { + Remove-StaleAppHostModuleFiles -PackageName $name -CurrentOutputFile $outputFile -OutputDirectory $OutputDir + } + } else { + Write-Warning " Transform failed" + $failed++ + [void]$failedPackageNames.Add($name) + } + } + catch { + Write-Warning " Error transforming: $_" + $failed++ + [void]$failedPackageNames.Add($name) + } +} + +# Verify core was generated or supplied (needed as base for dedup) +if (-not $coreOutputFile -or -not (Test-Path $coreOutputFile)) { + Write-Warning "Core Aspire.Hosting.json not generated — skipping dedup for integrations" + $coreOutputFile = $null +} + +# Process integration packages with --base for dedup +foreach ($pkg in $integrationPackages | Sort-Object { $_.Name }) { + $name = $pkg.Name + $version = $pkg.Version + $fileBaseName = if ($version) { "$name.$version" } else { $name } + $dumpFile = Join-Path $TempDir "$name.json" + $outputFile = Join-Path $OutputDir "$fileBaseName.json" + + Write-Host "" + Write-Host "[$name]" -ForegroundColor Cyan + + # Step 1: Run aspire sdk dump --format json + Write-Host " Dumping ATS capabilities..." + try { + $dumpArgs = @("sdk", "dump", "--format", "json", "--non-interactive", "--nologo", "-o", $dumpFile) + $dumpArgs += $pkg.DumpArgs + + $workDir = if ($AspireRepoPath) { $AspireRepoPath } elseif ($aspireCliWorkingDirectory) { $aspireCliWorkingDirectory } else { $PWD.Path } + $proc = Invoke-AspireCli -Arguments $dumpArgs -WorkingDirectory $workDir ` + -StderrFile (Join-Path $TempDir "$name.stderr.txt") + + if ($proc.ExitCode -ne 0 -and -not (Test-Path $dumpFile)) { + $stderr = Get-Content (Join-Path $TempDir "$name.stderr.txt") -Raw -ErrorAction SilentlyContinue + Write-Warning " aspire sdk dump failed (exit $($proc.ExitCode))" + if ($stderr) { Write-Warning " $stderr" } + $failed++ + [void]$failedPackageNames.Add($name) + continue + } + + if (-not (Test-Path $dumpFile)) { + Write-Warning " Dump file not created" + $failed++ + [void]$failedPackageNames.Add($name) + continue + } + + if ($proc.ExitCode -ne 0) { + Write-Host " aspire sdk dump exited with $($proc.ExitCode) but output was generated — continuing" -ForegroundColor DarkYellow + } + } + catch { + Write-Warning " Error running aspire sdk dump: $_" + $failed++ + [void]$failedPackageNames.Add($name) + continue + } + + # Step 2: Transform with AtsJsonGenerator (with --base dedup) + Write-Host " Transforming to docs JSON..." + try { + $transformArgs = @( + "run", "--project", $ToolProject, "--no-build", "--", + "--input", $dumpFile, + "--output", $outputFile, + "--package-name", $name + ) + if (-not [string]::IsNullOrWhiteSpace($version)) { + $transformArgs += @("--package-version", $version) + } + $sourceRepository = $pkg.SourceRepository + if ($sourceRepository) { + $transformArgs += @("--source-repo", $sourceRepository) + } + if (-not [string]::IsNullOrWhiteSpace($pkg.SourceCommit)) { + $transformArgs += @( + "--source-commit", $pkg.SourceCommit, + "--dump-product-commit", $pkg.SourceCommit + ) + } + if (-not [string]::IsNullOrWhiteSpace($DumpCliVersion)) { + $transformArgs += @("--dump-cli-version", $DumpCliVersion) + } + if (-not [string]::IsNullOrWhiteSpace($DumpGeneratedAt)) { + $transformArgs += @("--dump-generated-at", $DumpGeneratedAt) + } + + # Dedup against core if available + if ($coreOutputFile) { + $transformArgs += @("--base", $coreOutputFile) + } + + & dotnet @transformArgs 2>&1 | ForEach-Object { + if ($_ -match "Generated:") { + Write-Host " $_" -ForegroundColor Green + } elseif ($_ -match "FAILED|Error") { + Write-Host " $_" -ForegroundColor Red + } + } + + if ($LASTEXITCODE -eq 0) { + if ($version) { + Remove-StaleAppHostModuleFiles -PackageName $name -CurrentOutputFile $outputFile -OutputDirectory $OutputDir + } + if (Remove-EmptyAppHostModuleFile -PackageName $name -OutputFile $outputFile) { + $skipped++ + [void]$skippedPackageNames.Add($name) + } else { + $success++ + } + } else { + Write-Warning " Transform failed" + $failed++ + [void]$failedPackageNames.Add($name) + } + } + catch { + Write-Warning " Error transforming: $_" + $failed++ + [void]$failedPackageNames.Add($name) + } +} + +# ── Reconcile and cleanup ─────────────────────────────────────────────────────── + +Write-Host "" +Write-Host "════════════════════════════════════════════════════" -ForegroundColor White +Write-Host "Complete: $success succeeded, $failed failed, $skipped skipped" -ForegroundColor $(if ($failed -gt 0) { "Yellow" } else { "Green" }) +if ($failedPackageNames.Count -gt 0) { + Write-Host "Failed packages: $(($failedPackageNames | Sort-Object) -join ', ')" -ForegroundColor Red +} +if ($skippedPackageNames.Count -gt 0) { + Write-Host "Skipped packages: $(($skippedPackageNames | Sort-Object) -join ', ')" -ForegroundColor Yellow +} + +# Clean up temp files +if (Test-Path $TempDir) { + Remove-Item $TempDir -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($aspireCliWorkingDirectory -and (Test-Path $aspireCliWorkingDirectory)) { + Remove-Item $aspireCliWorkingDirectory -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($failed -gt 0 -and -not $CarryForwardOnDumpFailure) { + Remove-Item -Path $OutputDir -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $StagedSupportOutput -Force -ErrorAction SilentlyContinue + exit 1 +} +if ($failed -gt 0) { + Write-Host "Carry-forward: $failed package(s) failed to dump; preserving their existing committed modules and syncing the rest." -ForegroundColor Yellow +} + +New-Item -ItemType Directory -Path $FinalOutputDir -Force | Out-Null +$stagedFiles = @(Get-ChildItem -Path $OutputDir -Filter "*.json" -File) +$stagedNames = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase) +foreach ($file in $stagedFiles) { + [void]$stagedNames.Add($file.Name) +} + +$isFullReconciliation = -not $AspireRepoPath -and -not $PackageFilter -and -not $hasExplicitNuGetPackageVersion + +Write-Host "Aggregating AppHost language support..." -ForegroundColor Cyan +$supportArgs = @( + "run", "--project", $ToolProject, "--no-build", "--", + "support", + "--input-dir", $OutputDir, + "--output", $StagedSupportOutput +) +if (-not $isFullReconciliation -and (Test-Path $FinalOutputDir)) { + $supportArgs += @("--baseline-dir", $FinalOutputDir) + $packagesToReplace = @($Packages | Where-Object { + -not $failedPackageNames.Contains($_.Name) + } | ForEach-Object { + $_.Name + } | Sort-Object -Unique) + if ($packagesToReplace.Count -gt 0) { + $supportArgs += "--replace-package" + $supportArgs += $packagesToReplace + } +} + +& dotnet @supportArgs +if ($LASTEXITCODE -ne 0 -or -not (Test-Path $StagedSupportOutput)) { + Write-Host "Failed to aggregate AppHost language support" -ForegroundColor Red + Remove-Item -Path $OutputDir -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $StagedSupportOutput -Force -ErrorAction SilentlyContinue + exit 1 +} + +if ($isFullReconciliation) { + foreach ($existingFile in Get-ChildItem -Path $FinalOutputDir -Filter "*.json" -File) { + if (-not $stagedNames.Contains($existingFile.Name)) { + if ($failedPackageNames.Count -gt 0) { + $belongsToFailed = $false + foreach ($failedName in $failedPackageNames) { + if ($existingFile.Name -match ("^{0}(?:\.\d.*)?\.json$" -f [regex]::Escape($failedName))) { + $belongsToFailed = $true + break + } + } + if ($belongsToFailed) { continue } + } + Remove-Item -Path $existingFile.FullName -Force + Write-Host "Removed stale module: $($existingFile.Name)" -ForegroundColor DarkYellow + } + } +} +else { + foreach ($pkg in $Packages) { + if ($failedPackageNames.Contains($pkg.Name)) { continue } + $packageFilePattern = "^{0}(?:\.\d.*)?\.json$" -f [regex]::Escape($pkg.Name) + $stagedForPackage = @($stagedFiles | Where-Object { + $_.Name -match $packageFilePattern + }) + foreach ($existingFile in Get-ChildItem -Path $FinalOutputDir -Filter "*.json" -File | Where-Object { + $_.Name -match $packageFilePattern + }) { + if ($existingFile.Name -notin $stagedForPackage.Name) { + Remove-Item -Path $existingFile.FullName -Force + Write-Host "Removed stale module: $($existingFile.Name)" -ForegroundColor DarkYellow + } + } + } +} + +foreach ($file in $stagedFiles) { + Copy-Item -Path $file.FullName -Destination (Join-Path $FinalOutputDir $file.Name) -Force +} + +$supportDirectory = [System.IO.Path]::GetDirectoryName($FinalSupportOutput) +if (-not [string]::IsNullOrWhiteSpace($supportDirectory)) { + New-Item -ItemType Directory -Path $supportDirectory -Force | Out-Null +} +Copy-Item -Path $StagedSupportOutput -Destination $FinalSupportOutput -Force +Write-Host "Updated language support: $FinalSupportOutput" -ForegroundColor Green + +Remove-Item -Path $OutputDir -Recurse -Force +Remove-Item -Path $StagedSupportOutput -Force -ErrorAction SilentlyContinue diff --git a/src/tools/AtsJsonGenerator/generate-ts-api-json.ps1 b/src/tools/AtsJsonGenerator/generate-ts-api-json.ps1 index c7508c053..b23ab1103 100644 --- a/src/tools/AtsJsonGenerator/generate-ts-api-json.ps1 +++ b/src/tools/AtsJsonGenerator/generate-ts-api-json.ps1 @@ -1,964 +1,23 @@ #!/usr/bin/env pwsh <# .SYNOPSIS - Generates TypeScript API reference JSON files by running `aspire sdk dump --format json` - for each Aspire or Community Toolkit hosting integration that has ATS capabilities. - -.DESCRIPTION - Supports two input modes: - 1. -AspireRepoPath: Discovers .csproj files in a local microsoft/aspire clone - 2. -NuGetPackageVersion: Uses Name@Version syntax to resolve packages via NuGet - - For each eligible package, this script: - 1. Runs `aspire sdk dump --format json` to generate raw ATS capabilities JSON - 2. Runs the AtsJsonGenerator tool to transform it into docs-site JSON - 3. Outputs to src/frontend/src/data/ts-modules/ - -.PARAMETER AspireRepoPath - Path to a local microsoft/aspire repository clone. Discovers projects with - [AspireExport] attributes automatically. - -.PARAMETER NuGetPackageVersion - One or more package references in Name@Version format (e.g. - "Aspire.Hosting.Redis@13.1.2"). Passed to `aspire sdk dump` as the - integration argument. - -.PARAMETER OutputDir - The directory to write the generated JSON files to. - Defaults to /src/frontend/src/data/ts-modules. - -.PARAMETER PackageFilter - Optional wildcard filter to process only specific packages (e.g. "*Redis*"). - -.PARAMETER AspireCliProject - Path to a local Aspire.Cli.csproj to use instead of the globally installed - `aspire` CLI. Useful for testing local CLI changes. When set, the script - invokes `dotnet run --no-launch-profile --project --` instead of `aspire`. - -.ENVIRONMENT_VARIABLE ASPIRE_CLI_PATH - Path to the installed Aspire CLI executable. Used when -AspireCliProject is - not set; defaults to resolving `aspire` from PATH. - -.EXAMPLE - # From a local Aspire repo clone (uses global CLI) - ./generate-ts-api-json.ps1 -AspireRepoPath D:\GitHub\aspire - - # Using Name@Version syntax with a local CLI build - ./generate-ts-api-json.ps1 -NuGetPackageVersion "Aspire.Hosting@13.1.2","Aspire.Hosting.Redis@13.1.2" ` - -AspireCliProject D:\GitHub\aspire\src\Aspire.Cli\Aspire.Cli.csproj - - # Filter to specific packages - ./generate-ts-api-json.ps1 -AspireRepoPath D:\GitHub\aspire -PackageFilter "*Redis*" + Compatibility shim for generate-apphost-api-json.ps1. #> [CmdletBinding()] param( [string]$AspireRepoPath, - [string[]]$NuGetPackageVersion, - [string]$OutputDir, - + [string]$SupportOutput, [string]$PackageFilter, - - [string]$AspireCliProject + [string]$BaseModulePath, + [switch]$SkipBuild, + [string]$AspireCliProject, + [string]$DumpCliVersion, + [string]$DumpGeneratedAt ) -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -$NuGetOrgServiceIndex = if ([string]::IsNullOrWhiteSpace($env:ASPIRE_PUBLIC_NUGET_INDEX)) { - "https://api.nuget.org/v3/index.json" -} else { - $env:ASPIRE_PUBLIC_NUGET_INDEX.Trim() -} -$AspireRepoCandidates = @( - $env:ASPIRE_GITHUB_REPO_URL, - "https://github.com/microsoft/aspire" -) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - -$ScriptDir = $PSScriptRoot -$RepoRoot = (Resolve-Path (Join-Path $ScriptDir "..\..\..")).Path -$ToolProject = Join-Path $ScriptDir "AtsJsonGenerator.csproj" -$AspireCliPath = if ([string]::IsNullOrWhiteSpace($env:ASPIRE_CLI_PATH)) { "aspire" } else { $env:ASPIRE_CLI_PATH } - -# Opt-in resilience: when ASPIRE_TS_API_CARRY_FORWARD=1, a package whose ATS dump -# fails does not abort the whole run. Its previously committed module in the final -# output directory is preserved (carried forward) and every package that succeeded -# is still synced. Off by default so CI keeps failing hard on unexpected dump -# errors. Intended for constrained environments where a package's transitive -# restore requires a feed that is unreachable (e.g. an authenticated internal feed). -$CarryForwardOnDumpFailure = ($env:ASPIRE_TS_API_CARRY_FORWARD -eq '1') - -$FinalOutputDir = if ($OutputDir) { - [System.IO.Path]::GetFullPath($OutputDir) -} -else { - Join-Path $RepoRoot "src\frontend\src\data\ts-modules" -} -$OutputDir = Join-Path ([System.IO.Path]::GetDirectoryName($FinalOutputDir)) ( - ".$([System.IO.Path]::GetFileName($FinalOutputDir))-staging-$([Guid]::NewGuid().ToString('N'))") -New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null - -$TempDir = Join-Path $OutputDir ".tmp-dumps" -New-Item -ItemType Directory -Path $TempDir -Force | Out-Null - -function Remove-StaleTsModuleFiles { - param( - [string]$PackageName, - [string]$CurrentOutputFile, - [string]$OutputDirectory - ) - - if ([string]::IsNullOrWhiteSpace($PackageName) -or [string]::IsNullOrWhiteSpace($CurrentOutputFile)) { - return - } - - $namePattern = "^{0}\.\d.*\.json$" -f [regex]::Escape($PackageName) - $currentPath = [System.IO.Path]::GetFullPath($CurrentOutputFile) - - Get-ChildItem -Path $OutputDirectory -File -Filter '*.json' | Where-Object { - $_.Name -match $namePattern -and [System.IO.Path]::GetFullPath($_.FullName) -ne $currentPath - } | Remove-Item -Force -ErrorAction SilentlyContinue -} - -function Get-JsonArrayPropertyCount { - param( - [psobject]$Object, - [string]$PropertyName - ) - - $property = $Object.PSObject.Properties[$PropertyName] - if ($null -eq $property -or $null -eq $property.Value) { - return 0 - } - - return @($property.Value).Count -} - -function Get-TsModuleApiItemCount { - param([psobject]$ModuleJson) - - $itemCount = 0 - foreach ($propertyName in @("functions", "handleTypes", "dtoTypes", "enumTypes")) { - $itemCount += Get-JsonArrayPropertyCount -Object $ModuleJson -PropertyName $propertyName - } - - return $itemCount -} - -function Remove-EmptyTsModuleFile { - param( - [string]$PackageName, - [string]$OutputFile - ) - - if (-not (Test-Path $OutputFile)) { - throw "Cannot inspect missing TypeScript module file at $OutputFile" - } - - $json = Get-Content $OutputFile -Raw | ConvertFrom-Json - if ((Get-TsModuleApiItemCount -ModuleJson $json) -gt 0) { - return $false - } - - Remove-Item $OutputFile -Force - Write-Host " Omitted empty TypeScript module for $PackageName" -ForegroundColor DarkYellow - return $true -} - -function Normalize-BranchName { - [CmdletBinding()] - param([string]$BranchName) - - if ([string]::IsNullOrWhiteSpace($BranchName)) { - return "" - } - - return $BranchName -replace '^refs/heads/', '' -} - -function Get-CurrentBranchName { - [CmdletBinding()] - param() - - $candidates = @( - $env:BUILD_SOURCEBRANCH, - $env:GITHUB_HEAD_REF, - $env:GITHUB_REF_NAME - ) - - foreach ($candidate in $candidates) { - $normalized = Normalize-BranchName $candidate - if (-not [string]::IsNullOrWhiteSpace($normalized)) { - return $normalized - } - } - - try { - $branch = (& git rev-parse --abbrev-ref HEAD 2>$null) - if ($LASTEXITCODE -eq 0) { - return (Normalize-BranchName (($branch | Out-String).Trim())) - } - } - catch { - } - - return "" -} - -function Test-IsReleaseBranch { - [CmdletBinding()] - param([string]$BranchName) - - return $BranchName.StartsWith("release/", [System.StringComparison]::OrdinalIgnoreCase) -} - -function Get-ReleaseFeedNameFromCommit { - [CmdletBinding()] - param([string]$Commit) - - if ([string]::IsNullOrWhiteSpace($Commit)) { - return $null - } - - $normalizedCommit = $Commit.Trim() - $length = [Math]::Min(8, $normalizedCommit.Length) - return "darc-pub-microsoft-aspire-$($normalizedCommit.Substring(0, $length))" -} - -function ConvertTo-ReleaseFeedServiceIndex { - [CmdletBinding()] - param([string]$Value) - - if ([string]::IsNullOrWhiteSpace($Value)) { - return $null - } - - $trimmed = $Value.Trim() - if ($trimmed -match '^https?://' -and $trimmed -match '/nuget/v3/index\.json/?$') { - return $trimmed.TrimEnd('/') - } - - $feedName = $null - if ($trimmed -match '/_artifacts/feed/([^/?#]+)') { - $feedName = $Matches[1] - } - elseif ($trimmed -match '/_packaging/([^/?#]+)') { - $feedName = $Matches[1] - } - elseif ($trimmed -match '^[A-Za-z0-9][A-Za-z0-9._-]*$') { - $feedName = $trimmed - } - - if ($feedName) { - return "https://pkgs.dev.azure.com/dnceng/public/_packaging/$feedName/nuget/v3/index.json" - } - - return $null -} - -function Resolve-ReleaseBranchCommit { - [CmdletBinding()] - param([string]$BranchName) - - foreach ($repositoryUrl in $AspireRepoCandidates) { - try { - $output = (& git ls-remote $repositoryUrl "refs/heads/$BranchName" 2>$null) - if ($LASTEXITCODE -ne 0) { - continue - } - - $text = ($output | Out-String).Trim() - if ($text -match '^([0-9a-f]{40})\s+') { - return [PSCustomObject]@{ - Repository = $repositoryUrl - Commit = $Matches[1] - } - } - } - catch { - } - } - - return $null -} - -function Resolve-OfficialAspireFeed { - [CmdletBinding()] - param([string]$BranchName) - - if (-not (Test-IsReleaseBranch -BranchName $BranchName)) { - return [PSCustomObject]@{ - BranchName = $BranchName - IsRelease = $false - ServiceIndex = $NuGetOrgServiceIndex - FeedName = $null - Resolution = "default" - DisplayName = "nuget.org" - } - } - - $explicitFeedUrl = ConvertTo-ReleaseFeedServiceIndex -Value $env:ASPIRE_RELEASE_FEED_URL - if ($explicitFeedUrl) { - return [PSCustomObject]@{ - BranchName = $BranchName - IsRelease = $true - ServiceIndex = $explicitFeedUrl - FeedName = $null - Resolution = "ASPIRE_RELEASE_FEED_URL" - DisplayName = $explicitFeedUrl - } - } - - $explicitFeedName = $env:ASPIRE_RELEASE_FEED_NAME - if (-not [string]::IsNullOrWhiteSpace($explicitFeedName)) { - $serviceIndex = ConvertTo-ReleaseFeedServiceIndex -Value $explicitFeedName - return [PSCustomObject]@{ - BranchName = $BranchName - IsRelease = $true - ServiceIndex = $serviceIndex - FeedName = $explicitFeedName.Trim() - Resolution = "ASPIRE_RELEASE_FEED_NAME" - DisplayName = $explicitFeedName.Trim() - } - } - - $explicitCommit = $env:ASPIRE_RELEASE_COMMIT - if ([string]::IsNullOrWhiteSpace($explicitCommit)) { - $explicitCommit = $env:ASPIRE_RELEASE_COMMIT_SHA - } - if ([string]::IsNullOrWhiteSpace($explicitCommit)) { - $explicitCommit = $env:ASPIRE_RELEASE_SOURCE_COMMIT - } - if (-not [string]::IsNullOrWhiteSpace($explicitCommit)) { - $feedName = Get-ReleaseFeedNameFromCommit -Commit $explicitCommit - return [PSCustomObject]@{ - BranchName = $BranchName - IsRelease = $true - ServiceIndex = ConvertTo-ReleaseFeedServiceIndex -Value $feedName - FeedName = $feedName - Resolution = "ASPIRE_RELEASE_COMMIT" - DisplayName = $feedName - SourceCommit = $explicitCommit.Trim() - } - } - - $branchCommit = Resolve-ReleaseBranchCommit -BranchName $BranchName - if (-not $branchCommit) { - throw "Unable to resolve the official Aspire release feed for branch '$BranchName'. Set ASPIRE_RELEASE_FEED_URL, ASPIRE_RELEASE_FEED_NAME, or ASPIRE_RELEASE_COMMIT while microsoft/aspire is the active source repo." - } - - $feedName = Get-ReleaseFeedNameFromCommit -Commit $branchCommit.Commit - return [PSCustomObject]@{ - BranchName = $BranchName - IsRelease = $true - ServiceIndex = ConvertTo-ReleaseFeedServiceIndex -Value $feedName - FeedName = $feedName - Resolution = "branch head" - DisplayName = $feedName - SourceCommit = $branchCommit.Commit - SourceRepository = $branchCommit.Repository - } -} - -function New-TemporaryNuGetConfigDirectory { - [CmdletBinding()] - param([string[]]$RestoreSources) - - $sourceEntries = @($RestoreSources | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) - if ($sourceEntries.Count -eq 0) { - return $null - } - - $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "atsjson-nuget-$([System.Guid]::NewGuid().ToString('N').Substring(0,8))" - New-Item -ItemType Directory -Path $tempDir -Force | Out-Null - - $configLines = @( - '', - '', - ' ', - ' ' - ) - - for ($sourceIndex = 0; $sourceIndex -lt $sourceEntries.Count; $sourceIndex++) { - $source = [System.Security.SecurityElement]::Escape($sourceEntries[$sourceIndex]) - $configLines += " " - } - - $configLines += ' ' - $configLines += '' - $configLines | Set-Content (Join-Path $tempDir 'NuGet.Config') -Encoding UTF8 - - return $tempDir -} - -# ── Route 0: Auto-detect from generated C# package JSON ─────────────────────── -function Test-IsTypeScriptSdkPackage { - [CmdletBinding()] - param([string]$PackageName) - - if ([string]::IsNullOrWhiteSpace($PackageName)) { - return $false - } - - return ( - $PackageName.Equals("Aspire.Hosting", [System.StringComparison]::OrdinalIgnoreCase) -or - $PackageName.StartsWith("Aspire.Hosting.", [System.StringComparison]::OrdinalIgnoreCase) -or - $PackageName.StartsWith("CommunityToolkit.Aspire.Hosting.", [System.StringComparison]::OrdinalIgnoreCase) - ) -} - -function Get-CSharpHostingPackageMetadata { - $packageJsonDir = if (-not [string]::IsNullOrWhiteSpace($env:ASPIRE_API_PKGS_DIR)) { - [System.IO.Path]::GetFullPath($env:ASPIRE_API_PKGS_DIR) - } - else { - Join-Path $RepoRoot "src\frontend\src\data\pkgs" - } - if (-not (Test-Path $packageJsonDir)) { - return @() - } - - return @( - Get-ChildItem -Path $packageJsonDir -Filter '*.json' -File | - ForEach-Object { - $json = Get-Content $_.FullName -Raw | ConvertFrom-Json - if ((Test-IsTypeScriptSdkPackage -PackageName $json.package.name) -and - -not [string]::IsNullOrWhiteSpace($json.package.version)) { - $sourceRepository = $json.package.PSObject.Properties["sourceRepository"] - $sourceCommit = $json.package.PSObject.Properties["sourceCommit"] - [PSCustomObject]@{ - Name = [string]$json.package.name - Version = [string]$json.package.version - SourceRepository = if ($sourceRepository) { [string]$sourceRepository.Value } else { $null } - SourceCommit = if ($sourceCommit) { [string]$sourceCommit.Value } else { $null } - Path = $_.FullName - } - } - } - ) -} - -function Get-PackageSourceRepository { - [CmdletBinding()] - param([string]$PackageName) - - if ( - $PackageName.Equals("Aspire.Hosting", [System.StringComparison]::OrdinalIgnoreCase) -or - $PackageName.StartsWith("Aspire.Hosting.", [System.StringComparison]::OrdinalIgnoreCase) - ) { - return "https://github.com/microsoft/aspire" - } - - if ($PackageName.StartsWith("CommunityToolkit.Aspire.Hosting.", [System.StringComparison]::OrdinalIgnoreCase)) { - return "https://github.com/CommunityToolkit/Aspire" - } - - return $null -} - -$generatedPackageMetadata = @(Get-CSharpHostingPackageMetadata) -$packageMetadataBySpec = @{} -foreach ($metadata in $generatedPackageMetadata) { - $spec = "$($metadata.Name)@$($metadata.Version)" - if ($packageMetadataBySpec.ContainsKey($spec)) { - throw "Duplicate generated C# package metadata for '$spec': '$($packageMetadataBySpec[$spec].Path)' and '$($metadata.Path)'." - } - $packageMetadataBySpec[$spec] = $metadata -} - -$hasExplicitNuGetPackageVersion = $NuGetPackageVersion -and $NuGetPackageVersion.Count -gt 0 -if (-not $AspireRepoPath -and -not $hasExplicitNuGetPackageVersion) { - Write-Host "No -AspireRepoPath or -NuGetPackageVersion provided. Auto-detecting from generated C# package JSON..." -ForegroundColor Cyan - - $hostingPackages = @($generatedPackageMetadata) - - if ($hostingPackages.Count -eq 0) { - Write-Error "No TypeScript SDK package JSON files found in src/frontend/src/data/pkgs" - return - } - - $duplicatePackages = @($hostingPackages | Group-Object Name | Where-Object Count -gt 1) - if ($duplicatePackages.Count -gt 0) { - $details = $duplicatePackages | ForEach-Object { - "$($_.Name): $((@($_.Group) | ForEach-Object { "$($_.Version) [$($_.Path)]" }) -join ', ')" - } - throw "Multiple generated C# package versions prevent deterministic TypeScript API generation:`n $($details -join "`n ")" - } - - # Build Name@Version entries directly from the generated C# package data. - $NuGetPackageVersion = @($hostingPackages | ForEach-Object { "$($_.Name)@$($_.Version)" }) - - if ($NuGetPackageVersion.Count -eq 0) { - Write-Error "No versioned TypeScript SDK package JSON files found in $packageJsonDir" - return - } - - Write-Host " Found $($NuGetPackageVersion.Count) TypeScript SDK packages to process" -ForegroundColor DarkGray -} - -# ── Helper: invoke the aspire CLI ────────────────────────────────────────────── - -function Invoke-AspireCli { - param( - [string[]]$Arguments, - [string]$WorkingDirectory, - [string]$StderrFile - ) - - if ($AspireCliProject) { - # Run via dotnet run against a local Aspire.Cli.csproj (assumes pre-built) - $allArgs = @("run", "--no-launch-profile", "--no-build", "--project", $AspireCliProject, "--") + $Arguments - $proc = Start-Process -FilePath "dotnet" -ArgumentList $allArgs -WorkingDirectory $WorkingDirectory ` - -Wait -NoNewWindow -PassThru -RedirectStandardError $StderrFile - } else { - # Use the installed aspire CLI - $proc = Start-Process -FilePath $AspireCliPath -ArgumentList $Arguments -WorkingDirectory $WorkingDirectory ` - -Wait -NoNewWindow -PassThru -RedirectStandardError $StderrFile - } - - # Filter out update notices from stderr - if (Test-Path $StderrFile) { - $stderrContent = Get-Content $StderrFile -Raw -ErrorAction SilentlyContinue - if ($stderrContent -and $stderrContent -match "A new version of the Aspire CLI is available") { - # Strip the update notice lines and rewrite - $filtered = ($stderrContent -split "`r?`n" | Where-Object { - $_ -notmatch "A new version of the Aspire CLI is available" -and - $_ -notmatch "To update, run:" -and - $_ -notmatch "For more information, see:" - }) -join "`n" - Set-Content -Path $StderrFile -Value $filtered.Trim() -ErrorAction SilentlyContinue - } - } - - return $proc -} - -# ── Collect packages to process ──────────────────────────────────────────────── - -# Each entry: @{ Name = "Aspire.Hosting.Redis"; Version = "13.1.2"; DumpArgs = @("arg") } -$Packages = @() - -# ── Route 1: Discover from local Aspire repo clone ──────────────────────────── -if ($AspireRepoPath) { - $SrcDir = Join-Path $AspireRepoPath "src" - if (-not (Test-Path $SrcDir)) { - Write-Error "Aspire repo src/ directory not found at $SrcDir" - return - } - - # Core Aspire.Hosting must be passed explicitly; the default dump is empty. - $coreDir = Join-Path $SrcDir "Aspire.Hosting" - if (Test-Path $coreDir) { - $coreCsproj = Join-Path $coreDir "Aspire.Hosting.csproj" - if (-not (Test-Path $coreCsproj)) { - Write-Warning "Skipping Aspire.Hosting — project file not found at $coreCsproj" - } - else { - $Packages += @{ - Name = "Aspire.Hosting" - DumpArgs = @($coreCsproj) - SourceRepository = Get-PackageSourceRepository -PackageName "Aspire.Hosting" - SourceCommit = $null - } - } - } - - # Only hosting packages expose ATS capabilities for the TypeScript SDK. - # Client/component packages (e.g. Aspire.Azure.Data.Tables) are not applicable. - $hostingDirs = Get-ChildItem -Path $SrcDir -Directory -Filter "Aspire.Hosting.*" | Where-Object { - $_.Name -notmatch "(Analyzers|CodeGeneration|RemoteHost|Tests)" - } - - foreach ($dir in $hostingDirs) { - $csproj = Join-Path $dir.FullName "$($dir.Name).csproj" - if (-not (Test-Path $csproj)) { continue } - - $csFiles = Get-ChildItem -Path $dir.FullName -Filter "*.cs" -Recurse | Where-Object { - $_.FullName -notmatch "\\(obj|bin)\\" - } - - $hasExport = $false - foreach ($f in $csFiles) { - if (Select-String -Path $f.FullName -Pattern "\[AspireExport\(" -Quiet) { - $hasExport = $true - break - } - } - - if ($hasExport) { - $Packages += @{ - Name = $dir.Name - DumpArgs = @($csproj) - SourceRepository = Get-PackageSourceRepository -PackageName $dir.Name - SourceCommit = $null - } - } - } -} - -# ── Route 2: Name@Version NuGet package references ──────────────────────────── -# Only Aspire.Hosting.* packages expose ATS capabilities for the TypeScript SDK. -# Client/component packages (e.g. Aspire.Azure.Data.Tables) are not applicable. -if ($NuGetPackageVersion -and $NuGetPackageVersion.Count -gt 0) { - foreach ($spec in $NuGetPackageVersion) { - if ($spec -notmatch '^(.+)@(.+)$') { - Write-Warning "Invalid format '$spec' — expected Name@Version (e.g. Aspire.Hosting.Redis@13.1.2)" - continue - } - $pkgName = $Matches[1] - $pkgVersion = $Matches[2] - $packageMetadata = $packageMetadataBySpec["$pkgName@$pkgVersion"] - if ($null -eq $packageMetadata) { - throw "No exact generated C# package metadata was found for '$pkgName@$pkgVersion'. Run generate-package-json.ps1 first." - } - - if (-not (Test-IsTypeScriptSdkPackage -PackageName $pkgName)) { - Write-Warning "Skipping $pkgName — only Aspire.Hosting* and CommunityToolkit.Aspire.Hosting* packages have ATS capabilities" - continue - } - - $Packages += @{ - Name = $pkgName - Version = $pkgVersion - DumpArgs = @("$pkgName@$pkgVersion") - SourceRepository = $packageMetadata.SourceRepository - SourceCommit = $packageMetadata.SourceCommit - } - } -} - -if ($PackageFilter) { - $Packages = $Packages | Where-Object { $_.Name -like $PackageFilter } -} - -$branchName = Get-CurrentBranchName -$officialFeed = Resolve-OfficialAspireFeed -BranchName $branchName -$aspireCliWorkingDirectory = $null - -if (-not $AspireRepoPath) { - $restoreSources = if ($officialFeed.IsRelease) { - @($officialFeed.ServiceIndex, $NuGetOrgServiceIndex) - } - else { - @($NuGetOrgServiceIndex) - } - - $aspireCliWorkingDirectory = New-TemporaryNuGetConfigDirectory -RestoreSources $restoreSources - - if ($officialFeed.IsRelease) { - Write-Host "Release branch detected ($($officialFeed.BranchName)). TypeScript module generation will resolve official Aspire packages from $($officialFeed.DisplayName)." -ForegroundColor Cyan - } -} - -Write-Host "Found $($Packages.Count) packages to process" -if ($AspireCliProject) { - Write-Host "Using local CLI: $AspireCliProject" -ForegroundColor DarkGray -} else { - Write-Host "Using Aspire CLI: $AspireCliPath" -ForegroundColor DarkGray -} - -# ── Build the tool ───────────────────────────────────────────────────────────── - -Write-Host "" -Write-Host "Building AtsJsonGenerator..." -ForegroundColor Cyan -& dotnet build $ToolProject --nologo -v q 2>&1 | Out-Null -if ($LASTEXITCODE -ne 0) { - Write-Host "Failed to build AtsJsonGenerator" -ForegroundColor Red - Remove-Item -Path $OutputDir -Recurse -Force -ErrorAction SilentlyContinue - exit 1 -} - -# ── Generate ATS dumps ───────────────────────────────────────────────────────── - -# Process core Aspire.Hosting first so we can use it as a base for dedup -$corePackages = @($Packages | Where-Object { $_.Name -eq "Aspire.Hosting" }) -$integrationPackages = @($Packages | Where-Object { $_.Name -ne "Aspire.Hosting" }) - -$success = 0 -$failed = 0 -$skipped = 0 -$failedPackageNames = [System.Collections.Generic.HashSet[string]]::new( - [StringComparer]::OrdinalIgnoreCase) -$skippedPackageNames = [System.Collections.Generic.HashSet[string]]::new( - [StringComparer]::OrdinalIgnoreCase) -$coreOutputFile = $null - -# Process core first -foreach ($pkg in $corePackages) { - $name = $pkg.Name - $version = $pkg.Version - $fileBaseName = if ($version) { "$name.$version" } else { $name } - $dumpFile = Join-Path $TempDir "$name.json" - $outputFile = Join-Path $OutputDir "$fileBaseName.json" - - Write-Host "" - Write-Host "[$name] (core — processed first)" -ForegroundColor Cyan - - # Step 1: Run aspire sdk dump --format json - Write-Host " Dumping ATS capabilities..." - try { - $dumpArgs = @("sdk", "dump", "--format", "json", "--non-interactive", "--nologo", "-o", $dumpFile) - $dumpArgs += $pkg.DumpArgs - - $workDir = if ($AspireRepoPath) { $AspireRepoPath } elseif ($aspireCliWorkingDirectory) { $aspireCliWorkingDirectory } else { $PWD.Path } - $proc = Invoke-AspireCli -Arguments $dumpArgs -WorkingDirectory $workDir ` - -StderrFile (Join-Path $TempDir "$name.stderr.txt") - - if ($proc.ExitCode -ne 0 -and -not (Test-Path $dumpFile)) { - $stderr = Get-Content (Join-Path $TempDir "$name.stderr.txt") -Raw -ErrorAction SilentlyContinue - Write-Warning " aspire sdk dump failed (exit $($proc.ExitCode))" - if ($stderr) { Write-Warning " $stderr" } - $failed++ - [void]$failedPackageNames.Add($name) - continue - } - - if (-not (Test-Path $dumpFile)) { - Write-Warning " Dump file not created" - $failed++ - [void]$failedPackageNames.Add($name) - continue - } - - if ($proc.ExitCode -ne 0) { - Write-Host " aspire sdk dump exited with $($proc.ExitCode) but output was generated — continuing" -ForegroundColor DarkYellow - } - } - catch { - Write-Warning " Error running aspire sdk dump: $_" - $failed++ - [void]$failedPackageNames.Add($name) - continue - } - - # Step 2: Transform (no --base for core) - Write-Host " Transforming to docs JSON..." - try { - $transformArgs = @( - "run", "--project", $ToolProject, "--no-build", "--", - "--input", $dumpFile, - "--output", $outputFile, - "--package-name", $name - ) - $sourceRepository = $pkg.SourceRepository - if ($sourceRepository) { - $transformArgs += @("--source-repo", $sourceRepository) - } - if (-not [string]::IsNullOrWhiteSpace($pkg.SourceCommit)) { - $transformArgs += @("--source-commit", $pkg.SourceCommit) - } - - & dotnet @transformArgs 2>&1 | ForEach-Object { - if ($_ -match "Generated:") { - Write-Host " $_" -ForegroundColor Green - } elseif ($_ -match "FAILED|Error") { - Write-Host " $_" -ForegroundColor Red - } - } - - if ($LASTEXITCODE -eq 0) { - $success++ - $coreOutputFile = $outputFile - if ($version) { - Remove-StaleTsModuleFiles -PackageName $name -CurrentOutputFile $outputFile -OutputDirectory $OutputDir - } - } else { - Write-Warning " Transform failed" - $failed++ - [void]$failedPackageNames.Add($name) - } - } - catch { - Write-Warning " Error transforming: $_" - $failed++ - [void]$failedPackageNames.Add($name) - } -} - -# Verify core was generated (needed as base for dedup) -if (-not $coreOutputFile -or -not (Test-Path $coreOutputFile)) { - Write-Warning "Core Aspire.Hosting.json not generated — skipping dedup for integrations" - $coreOutputFile = $null -} - -# Process integration packages with --base for dedup -foreach ($pkg in $integrationPackages | Sort-Object { $_.Name }) { - $name = $pkg.Name - $version = $pkg.Version - $fileBaseName = if ($version) { "$name.$version" } else { $name } - $dumpFile = Join-Path $TempDir "$name.json" - $outputFile = Join-Path $OutputDir "$fileBaseName.json" - - Write-Host "" - Write-Host "[$name]" -ForegroundColor Cyan - - # Step 1: Run aspire sdk dump --format json - Write-Host " Dumping ATS capabilities..." - try { - $dumpArgs = @("sdk", "dump", "--format", "json", "--non-interactive", "--nologo", "-o", $dumpFile) - $dumpArgs += $pkg.DumpArgs - - $workDir = if ($AspireRepoPath) { $AspireRepoPath } elseif ($aspireCliWorkingDirectory) { $aspireCliWorkingDirectory } else { $PWD.Path } - $proc = Invoke-AspireCli -Arguments $dumpArgs -WorkingDirectory $workDir ` - -StderrFile (Join-Path $TempDir "$name.stderr.txt") - - if ($proc.ExitCode -ne 0 -and -not (Test-Path $dumpFile)) { - $stderr = Get-Content (Join-Path $TempDir "$name.stderr.txt") -Raw -ErrorAction SilentlyContinue - Write-Warning " aspire sdk dump failed (exit $($proc.ExitCode))" - if ($stderr) { Write-Warning " $stderr" } - $failed++ - [void]$failedPackageNames.Add($name) - continue - } - - if (-not (Test-Path $dumpFile)) { - Write-Warning " Dump file not created" - $failed++ - [void]$failedPackageNames.Add($name) - continue - } - - if ($proc.ExitCode -ne 0) { - Write-Host " aspire sdk dump exited with $($proc.ExitCode) but output was generated — continuing" -ForegroundColor DarkYellow - } - } - catch { - Write-Warning " Error running aspire sdk dump: $_" - $failed++ - [void]$failedPackageNames.Add($name) - continue - } - - # Step 2: Transform with AtsJsonGenerator (with --base dedup) - Write-Host " Transforming to docs JSON..." - try { - $transformArgs = @( - "run", "--project", $ToolProject, "--no-build", "--", - "--input", $dumpFile, - "--output", $outputFile, - "--package-name", $name - ) - $sourceRepository = $pkg.SourceRepository - if ($sourceRepository) { - $transformArgs += @("--source-repo", $sourceRepository) - } - if (-not [string]::IsNullOrWhiteSpace($pkg.SourceCommit)) { - $transformArgs += @("--source-commit", $pkg.SourceCommit) - } - - # Dedup against core if available - if ($coreOutputFile) { - $transformArgs += @("--base", $coreOutputFile) - } - - & dotnet @transformArgs 2>&1 | ForEach-Object { - if ($_ -match "Generated:") { - Write-Host " $_" -ForegroundColor Green - } elseif ($_ -match "FAILED|Error") { - Write-Host " $_" -ForegroundColor Red - } - } - - if ($LASTEXITCODE -eq 0) { - if ($version) { - Remove-StaleTsModuleFiles -PackageName $name -CurrentOutputFile $outputFile -OutputDirectory $OutputDir - } - if (Remove-EmptyTsModuleFile -PackageName $name -OutputFile $outputFile) { - $skipped++ - [void]$skippedPackageNames.Add($name) - } else { - $success++ - } - } else { - Write-Warning " Transform failed" - $failed++ - [void]$failedPackageNames.Add($name) - } - } - catch { - Write-Warning " Error transforming: $_" - $failed++ - [void]$failedPackageNames.Add($name) - } -} - -# ── Reconcile and cleanup ─────────────────────────────────────────────────────── - -Write-Host "" -Write-Host "════════════════════════════════════════════════════" -ForegroundColor White -Write-Host "Complete: $success succeeded, $failed failed, $skipped skipped" -ForegroundColor $(if ($failed -gt 0) { "Yellow" } else { "Green" }) -if ($failedPackageNames.Count -gt 0) { - Write-Host "Failed packages: $(($failedPackageNames | Sort-Object) -join ', ')" -ForegroundColor Red -} -if ($skippedPackageNames.Count -gt 0) { - Write-Host "Skipped packages: $(($skippedPackageNames | Sort-Object) -join ', ')" -ForegroundColor Yellow -} - -# Clean up temp files -if (Test-Path $TempDir) { - Remove-Item $TempDir -Recurse -Force -ErrorAction SilentlyContinue -} - -if ($aspireCliWorkingDirectory -and (Test-Path $aspireCliWorkingDirectory)) { - Remove-Item $aspireCliWorkingDirectory -Recurse -Force -ErrorAction SilentlyContinue -} - -if ($failed -gt 0 -and -not $CarryForwardOnDumpFailure) { - Remove-Item -Path $OutputDir -Recurse -Force -ErrorAction SilentlyContinue - exit 1 -} -if ($failed -gt 0) { - Write-Host "Carry-forward: $failed package(s) failed to dump; preserving their existing committed modules and syncing the rest." -ForegroundColor Yellow -} - -New-Item -ItemType Directory -Path $FinalOutputDir -Force | Out-Null -$stagedFiles = @(Get-ChildItem -Path $OutputDir -Filter "*.json" -File) -$stagedNames = [System.Collections.Generic.HashSet[string]]::new( - [StringComparer]::OrdinalIgnoreCase) -foreach ($file in $stagedFiles) { - [void]$stagedNames.Add($file.Name) -} - -$isFullReconciliation = -not $AspireRepoPath -and -not $PackageFilter -and -not $hasExplicitNuGetPackageVersion -if ($isFullReconciliation) { - foreach ($existingFile in Get-ChildItem -Path $FinalOutputDir -Filter "*.json" -File) { - if (-not $stagedNames.Contains($existingFile.Name)) { - if ($failedPackageNames.Count -gt 0) { - $belongsToFailed = $false - foreach ($failedName in $failedPackageNames) { - if ($existingFile.Name -match ("^{0}(?:\.\d.*)?\.json$" -f [regex]::Escape($failedName))) { - $belongsToFailed = $true - break - } - } - if ($belongsToFailed) { continue } - } - Remove-Item -Path $existingFile.FullName -Force - Write-Host "Removed stale module: $($existingFile.Name)" -ForegroundColor DarkYellow - } - } -} -else { - foreach ($pkg in $Packages) { - if ($failedPackageNames.Contains($pkg.Name)) { continue } - $packageFilePattern = "^{0}(?:\.\d.*)?\.json$" -f [regex]::Escape($pkg.Name) - $stagedForPackage = @($stagedFiles | Where-Object { - $_.Name -match $packageFilePattern - }) - foreach ($existingFile in Get-ChildItem -Path $FinalOutputDir -Filter "*.json" -File | Where-Object { - $_.Name -match $packageFilePattern - }) { - if ($existingFile.Name -notin $stagedForPackage.Name) { - Remove-Item -Path $existingFile.FullName -Force - Write-Host "Removed stale module: $($existingFile.Name)" -ForegroundColor DarkYellow - } - } - } -} - -foreach ($file in $stagedFiles) { - Copy-Item -Path $file.FullName -Destination (Join-Path $FinalOutputDir $file.Name) -Force -} -Remove-Item -Path $OutputDir -Recurse -Force +Write-Warning "generate-ts-api-json.ps1 is deprecated; use generate-apphost-api-json.ps1." +& (Join-Path $PSScriptRoot "generate-apphost-api-json.ps1") @PSBoundParameters +exit $LASTEXITCODE diff --git a/src/tools/AtsJsonGenerator/upstream-sources.lock.json b/src/tools/AtsJsonGenerator/upstream-sources.lock.json new file mode 100644 index 000000000..f89237330 --- /dev/null +++ b/src/tools/AtsJsonGenerator/upstream-sources.lock.json @@ -0,0 +1,87 @@ +{ + "repository": "microsoft/aspire", + "commit": "62028348b5d02dfc8f8baf03a4472946537b0d16", + "aggregateSha256": "cbf8fbf1459cb22e56dab6f56b1f1af626c716c56db8397c2b9b5454bd11e0c1", + "sources": [ + { + "path": "src/Shared/CodeGeneration/AtsOptionsFlattening.cs", + "blob": "b93548dead50e2d8d673bca235ae3f2004193add" + }, + { + "path": "src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs", + "blob": "98d634b60ad8f04c8fb64d442d6244908d43cbb1" + }, + { + "path": "src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptApiProjector.cs", + "blob": "075e60d08b9ada5d41725ac00e686ba6475e700a" + }, + { + "path": "tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs", + "blob": "447d19744402146270e425da3e046db5fb94fba1" + }, + { + "path": "src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs", + "blob": "18fc2696e3ed6dda14dbba2b3f0c25c5c5a617ba" + }, + { + "path": "src/Aspire.Hosting.CodeGeneration.Python/PythonModuleBuilder.cs", + "blob": "ec1e37facc62e3b950dc55f974d0f292994e8b66" + }, + { + "path": "tests/Aspire.Hosting.CodeGeneration.Python.Tests/AtsPythonCodeGeneratorTests.cs", + "blob": "7d0be4da96c02e61e8af737f0aa002ce7ccdfea1" + }, + { + "path": "tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py", + "blob": "58710bef30ead66e4f739b8dbc73fe02f1dee49f" + }, + { + "path": "src/Aspire.Hosting.CodeGeneration.Go/AtsGoCodeGenerator.cs", + "blob": "77b20a264218676b4c372dfb6349aad7bcf7a240" + }, + { + "path": "src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go", + "blob": "f4e7b8d22dc00ac73005ac5e5e850c27330b5dc7" + }, + { + "path": "tests/Aspire.Hosting.CodeGeneration.Go.Tests/AtsGoCodeGeneratorTests.cs", + "blob": "c141efecce60c77299f7cf050a6570f09c7b85fa" + }, + { + "path": "tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/AtsGeneratedAspire.verified.go", + "blob": "dbcf2b90e31990d2c0e595a2b75e7439bc00e743" + }, + { + "path": "src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs", + "blob": "485f1336a53a1fb7e56c080a2b74fe1f27317460" + }, + { + "path": "src/Aspire.Hosting.CodeGeneration.Java/Resources/Transport.java", + "blob": "608bb2c878ce7bb537a343089c80147ffb49bae0" + }, + { + "path": "tests/Aspire.Hosting.CodeGeneration.Java.Tests/AtsJavaCodeGeneratorTests.cs", + "blob": "c8313998fd0fd167a14e246610ecee2ac72db228" + }, + { + "path": "tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/AtsGeneratedAspire.verified.java", + "blob": "9079b1d84aedec46605c5a7ecf80c69c64044e77" + }, + { + "path": "src/Aspire.Hosting.CodeGeneration.Rust/AtsRustCodeGenerator.cs", + "blob": "2c219bf4f69c64569e27abbf576d53f5c6b2e9cd" + }, + { + "path": "tests/Aspire.Hosting.CodeGeneration.Rust.Tests/AtsRustCodeGeneratorTests.cs", + "blob": "0a2725bcc53c1e8de80da08282cc63ecedb829bd" + }, + { + "path": "tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs", + "blob": "25d0bcf6ed03b370114770e48de95ced89f88d00" + }, + { + "path": "src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs", + "blob": "c61accb20133302f91ffeb6f8576a4130f12d7ea" + } + ] +} diff --git a/tests/AtsJsonGenerator.Tests/AdapterGoldenTests.cs b/tests/AtsJsonGenerator.Tests/AdapterGoldenTests.cs new file mode 100644 index 000000000..5549e7684 --- /dev/null +++ b/tests/AtsJsonGenerator.Tests/AdapterGoldenTests.cs @@ -0,0 +1,64 @@ +using System.Text; +using AtsJsonGenerator.Helpers; + +namespace AtsJsonGenerator.Tests; + +public sealed class AdapterGoldenTests +{ + [Theory] + [InlineData("typescript")] + [InlineData("python")] + [InlineData("go")] + [InlineData("java")] + [InlineData("rust")] + public void AdapterProjection_MatchesGolden(string language) + { + var model = AtsTransformer.Transform( + AtsJsonGeneratorTests.LoadFixture(), + "Contoso.Hosting.Widgets"); + var actual = BuildGolden(model, language); + var expectedPath = Path.Combine(AppContext.BaseDirectory, "Golden", language + ".golden"); + var expected = File.ReadAllText(expectedPath).Replace("\r\n", "\n", StringComparison.Ordinal); + + Assert.Equal(expected, actual); + } + + private static string BuildGolden(AppHostModuleModel model, string language) + { + var builder = new StringBuilder(); + foreach (var item in model.Items) + { + var projection = item.Projections[language]; + builder.Append(item.Id).Append('\t') + .Append(projection.Status).Append('\t') + .Append(Escape(projection.Identifier)).Append('\t') + .Append(Escape(projection.Signature)).Append('\t') + .Append(Escape(projection.Declaration)).Append('\t') + .Append(string.Join(",", projection.Parameters.Select(parameter => + $"{parameter.Name}:{parameter.Type}:{(parameter.IsOptional ? "optional" : "required")}:{parameter.DefaultValue ?? "-"}"))) + .Append('\t') + .Append(projection.Return is null + ? "" + : $"{projection.Return.Type}:{projection.Return.ErrorModel}") + .Append('\t') + .Append(string.Join(",", projection.Fields.Select(field => + $"{field.Name}:{field.Type}:{(field.IsOptional ? "optional" : "required")}"))) + .Append('\t') + .Append(string.Join(",", projection.Members.Select(member => + $"{member.Name}={member.Value}"))) + .Append('\t') + .Append(Escape(projection.ValueExpression)).Append('\t') + .Append(Escape(projection.Reason)) + .Append('\n'); + } + + return builder.ToString(); + } + + private static string Escape(string? value) + => value?.Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\t", "\\t", StringComparison.Ordinal) + .Replace("\r", "\\r", StringComparison.Ordinal) + .Replace("\n", "\\n", StringComparison.Ordinal) + ?? ""; +} diff --git a/tests/AtsJsonGenerator.Tests/AdapterSemanticNuanceTests.cs b/tests/AtsJsonGenerator.Tests/AdapterSemanticNuanceTests.cs new file mode 100644 index 000000000..3bbf46bac --- /dev/null +++ b/tests/AtsJsonGenerator.Tests/AdapterSemanticNuanceTests.cs @@ -0,0 +1,239 @@ +using AtsJsonGenerator.Helpers; + +namespace AtsJsonGenerator.Tests; + +public sealed class AdapterSemanticNuanceTests +{ + [Fact] + public void Python_NullableNonNullDefaultUsesOmissionSentinel() + { + var dump = CreateSingleCapabilityDump( + "ConfigureLabel", + new AtsDumpParameter + { + Name = "label", + Type = new AtsDumpTypeRef + { + TypeId = "string", + Category = "Primitive", + IsNullable = true, + }, + IsOptional = true, + IsNullable = true, + DefaultValue = "fallback", + }); + + var projection = AtsTransformer.Transform(dump, "Contoso") + .Items.Single() + .Projections["python"]; + + Assert.Contains( + "label: str | None = typing.cast(str | None, _ASPIRE_UNSET)", + projection.Signature); + Assert.Equal( + "typing.cast(str | None, _ASPIRE_UNSET)", + Assert.Single(projection.Parameters).DefaultValue); + } + + [Fact] + public void Python_UsesPinnedGeneratorAbbreviations() + { + var dump = CreateSingleCapabilityDump( + "AddDockerComposeEnvironmentAsync", + new AtsDumpParameter + { + Name = "configurationDirectory", + Type = new AtsDumpTypeRef { TypeId = "string", Category = "Primitive" }, + }); + + var projection = AtsTransformer.Transform(dump, "Contoso") + .Items.Single() + .Projections["python"]; + + Assert.Equal("add_docker_compose_env", projection.Identifier); + Assert.Contains("config_dir: str", projection.Signature); + } + + [Fact] + public void Go_UsesDirectDtoOrWrapperOptionsAndDeferredFirstError() + { + var model = AtsTransformer.Transform( + AtsJsonGeneratorTests.LoadFixture(), + "Contoso.Hosting.Widgets"); + + var direct = model.Items.Single(item => + item.Id == "capability:Contoso.Hosting.Widgets/withSettings") + .Projections["go"]; + Assert.Contains("options ...*WidgetOptions", direct.Signature); + Assert.DoesNotContain("type WithSettingsOptions", direct.Declaration); + Assert.Equal("deferred", direct.Return?.ErrorModel); + + var wrapper = model.Items.Single(item => + item.Id == "capability:Contoso.Hosting.Widgets/addWidget") + .Projections["go"]; + Assert.Contains("type AddWidgetAsyncOptions struct", wrapper.Declaration); + Assert.Contains("Port *float64 `json:\"port,omitempty\"`", wrapper.Declaration); + Assert.Contains("OnReady func(resource WidgetResource) `json:\"-\"`", wrapper.Declaration); + Assert.Contains("CancellationToken *CancellationToken `json:\"-\"`", wrapper.Declaration); + + var handle = model.Items.Single(item => item.Id == "handle:Contoso.WidgetResource") + .Projections["go"]; + Assert.Contains("Err() error", handle.Declaration); + Assert.Equal("Fluent failures use first-error-wins deferred Err().", handle.Reason); + } + + [Fact] + public void TypeScriptAndGo_TreatStableDumpOptionsHandlesAsDirectOptions() + { + var dump = CreateSingleCapabilityDump( + "AddProject", + new AtsDumpParameter + { + Name = "options", + Type = new AtsDumpTypeRef + { + TypeId = "Aspire.ProjectResourceOptions", + Category = "Handle", + }, + IsOptional = true, + }, + additionalParameter: + new AtsDumpParameter + { + Name = "cancellationToken", + Type = new AtsDumpTypeRef + { + TypeId = "cancellationToken", + Category = "Primitive", + }, + IsOptional = true, + }); + + var projections = AtsTransformer.Transform(dump, "Aspire.Hosting") + .Items.Single() + .Projections; + + var typeScript = projections["typescript"]; + Assert.Contains("options?: ProjectResourceOptions", typeScript.Signature); + Assert.Contains("cancellationToken?: AbortSignal", typeScript.Signature); + Assert.DoesNotContain("AddProjectOptions", typeScript.Declaration); + Assert.DoesNotContain("Awaitable", typeScript.Declaration); + + var go = projections["go"]; + Assert.Contains("options ...*AddProjectOptions", go.Signature); + Assert.Contains("type AddProjectOptions struct", go.Declaration); + } + + [Fact] + public void Java_SuffixesKeywordsAndPreservesEnumWireValues() + { + var dump = CreateSingleCapabilityDump( + "return", + new AtsDumpParameter + { + Name = "class", + Type = new AtsDumpTypeRef { TypeId = "string", Category = "Primitive" }, + }); + var capability = AtsTransformer.Transform(dump, "Contoso") + .Items.Single() + .Projections["java"]; + + Assert.Equal("return_", capability.Identifier); + Assert.Contains("String class_", capability.Signature); + + var enumProjection = AtsTransformer.Transform( + AtsJsonGeneratorTests.LoadFixture(), + "Contoso.Hosting.Widgets") + .Items.Single(item => item.Id == "enum:Contoso.WidgetMode") + .Projections["java"]; + Assert.Contains(enumProjection.Members, member => + member.Name == "SAFE_MODE" && Equals(member.Value, "SafeMode")); + + var collidedOptions = AtsTransformer.Transform( + AtsJsonGeneratorTests.LoadFixture(), + "Contoso.Hosting.Widgets") + .Items.Single(item => item.Id == "capability:Contoso.Hosting.Widgets/configureText") + .Projections["java"]; + Assert.Contains("Configure1Options", collidedOptions.Declaration); + Assert.DoesNotContain("ConfigureOptions1", collidedOptions.Declaration); + } + + [Fact] + public void Rust_UsesPositionalOptionsAndValueForUnionAndUnknown() + { + var fixture = AtsTransformer.Transform( + AtsJsonGeneratorTests.LoadFixture(), + "Contoso.Hosting.Widgets"); + var options = fixture.Items.Single(item => + item.Id == "capability:Contoso.Hosting.Widgets/configureCount") + .Projections["rust"]; + Assert.Contains("count: Option, mode: Option", options.Signature); + Assert.DoesNotContain("Options", options.Signature); + + var union = fixture.Items.Single(item => + item.Id == "capability:Contoso.Hosting.Widgets/chooseTarget") + .Projections["rust"]; + Assert.Contains("target: Value", union.Signature); + + var unknownDump = CreateSingleCapabilityDump( + "UseUnknown", + new AtsDumpParameter + { + Name = "input", + Type = new AtsDumpTypeRef { TypeId = "Contoso.External", Category = "Unknown" }, + }); + var unknown = AtsTransformer.Transform(unknownDump, "Contoso") + .Items.Single() + .Projections["rust"]; + Assert.Contains("input: Value", unknown.Signature); + + var enumProjection = fixture.Items.Single(item => item.Id == "enum:Contoso.WidgetMode") + .Projections["rust"]; + Assert.Contains("PartialEq, Eq", enumProjection.Declaration); + } + + [Fact] + public void Rust_DocumentsAddExecutableRuntimeArgumentLimitation() + { + var dump = CreateSingleCapabilityDump( + "addExecutable", + new AtsDumpParameter + { + Name = "args", + Type = new AtsDumpTypeRef + { + TypeId = "array", + Category = "Array", + ElementType = new AtsDumpTypeRef { TypeId = "string", Category = "Primitive" }, + }, + }, + "Aspire.Hosting/addExecutable"); + + var projection = AtsTransformer.Transform(dump, "Aspire.Hosting") + .Items.Single() + .Projections["rust"]; + + Assert.Contains("ATS server cannot deserialize the String[] argument contract", projection.Reason); + } + + private static AtsDumpRoot CreateSingleCapabilityDump( + string methodName, + AtsDumpParameter parameter, + string? capabilityId = null, + AtsDumpParameter? additionalParameter = null) + => new() + { + Capabilities = + [ + new AtsDumpCapability + { + CapabilityId = capabilityId ?? $"Contoso/{methodName}", + MethodName = methodName, + QualifiedMethodName = $"Contoso.Extensions.{methodName}", + CapabilityKind = "Method", + Parameters = additionalParameter is null ? [parameter] : [parameter, additionalParameter], + ReturnType = new AtsDumpTypeRef { TypeId = "void", Category = "Primitive" }, + }, + ], + }; +} diff --git a/tests/AtsJsonGenerator.Tests/AtsJsonGenerator.Tests.csproj b/tests/AtsJsonGenerator.Tests/AtsJsonGenerator.Tests.csproj index 24918e170..2124492fa 100644 --- a/tests/AtsJsonGenerator.Tests/AtsJsonGenerator.Tests.csproj +++ b/tests/AtsJsonGenerator.Tests/AtsJsonGenerator.Tests.csproj @@ -21,4 +21,18 @@ + + + + + + + + diff --git a/tests/AtsJsonGenerator.Tests/AtsJsonGeneratorTests.cs b/tests/AtsJsonGenerator.Tests/AtsJsonGeneratorTests.cs index 8b108589f..8893fdfe9 100644 --- a/tests/AtsJsonGenerator.Tests/AtsJsonGeneratorTests.cs +++ b/tests/AtsJsonGenerator.Tests/AtsJsonGeneratorTests.cs @@ -1,3 +1,4 @@ +using System.CommandLine; using System.Text.Json; using AtsJsonGenerator.Helpers; @@ -6,411 +7,305 @@ namespace AtsJsonGenerator.Tests; public sealed class AtsJsonGeneratorTests { [Fact] - public void TransformFile_InfersMetadataAndDeduplicatesAgainstBaseModel() + public void Transform_ReconcilesEveryAtsIdentityAndProjection() { - using var tempDirectory = new TempDirectory(); - - var inputPath = Path.Combine(tempDirectory.Path, "Contoso.Tools.json"); - var outputPath = Path.Combine(tempDirectory.Path, "output", "Contoso.Tools.json"); - var basePath = Path.Combine(tempDirectory.Path, "base.json"); - - var dump = new AtsDumpRoot - { - Packages = - [ - new AtsDumpPackageRef - { - Name = "Contoso.Tools", - Version = "2.4.0", - }, - ], - HandleTypes = - [ - new AtsDumpHandleType - { - AtsTypeId = "Contoso.Assembly/Contoso.Builder", - ExposeMethods = true, - ExposeProperties = true, - BaseTypeHierarchy = - [ - new AtsDumpTypeRef - { - TypeId = "Contoso.Assembly/Contoso.BaseBuilder", - Category = "Type", - }, - new AtsDumpTypeRef - { - TypeId = "Contoso.Assembly/Contoso.RootBuilder", - Category = "Type", - }, - ], - }, - ], - Capabilities = - [ - new AtsDumpCapability - { - CapabilityId = "shared-capability", - MethodName = "UseShared", - QualifiedMethodName = "Contoso.Builder.UseShared", - CapabilityKind = "method", - TargetTypeId = "Contoso.Assembly/Contoso.Builder", - TargetParameterName = "builder", - Parameters = - [ - new AtsDumpParameter - { - Name = "builder", - Type = new AtsDumpTypeRef - { - TypeId = "Contoso.Assembly/Contoso.Builder", - Category = "Type", - }, - }, - ], - ReturnType = new AtsDumpTypeRef - { - TypeId = "void", - Category = "Primitive", - }, - ExpandedTargetTypes = - [ - new AtsDumpTypeRef - { - TypeId = "Contoso.Assembly/Contoso.Builder", - Category = "Type", - }, - ], - }, - new AtsDumpCapability - { - CapabilityId = "unique-capability", - MethodName = "UseUnique", - QualifiedMethodName = "Contoso.Builder.UseUnique", - CapabilityKind = "method", - TargetTypeId = "Contoso.Assembly/Contoso.Builder", - TargetParameterName = "builder", - Parameters = - [ - new AtsDumpParameter - { - Name = "builder", - Type = new AtsDumpTypeRef - { - TypeId = "Contoso.Assembly/Contoso.Builder", - Category = "Type", - }, - }, - new AtsDumpParameter - { - Name = "name", - Type = new AtsDumpTypeRef - { - TypeId = "string", - Category = "Primitive", - }, - }, - ], - ReturnType = new AtsDumpTypeRef - { - TypeId = "void", - Category = "Primitive", - }, - ExpandedTargetTypes = - [ - new AtsDumpTypeRef - { - TypeId = "Contoso.Assembly/Contoso.Builder", - Category = "Type", - }, - ], - }, - ], - DtoTypes = - [ - new AtsDumpDtoType - { - TypeId = "Contoso.Assembly/Contoso.SharedOptions", - Name = "SharedOptions", - }, - new AtsDumpDtoType - { - TypeId = "Contoso.Assembly/Contoso.UniqueOptions", - Name = "UniqueOptions", - Properties = - [ - new AtsDumpDtoProperty - { - Name = "Names", - IsOptional = true, - Type = new AtsDumpTypeRef - { - TypeId = "string", - Category = "Array", - ElementType = new AtsDumpTypeRef - { - TypeId = "string", - Category = "Primitive", - }, - }, - }, - new AtsDumpDtoProperty - { - Name = "Region", - IsOptional = false, - Type = new AtsDumpTypeRef - { - TypeId = "string", - Category = "Primitive", - }, - }, - ], - }, - ], - EnumTypes = - [ - new AtsDumpEnumType - { - TypeId = "enum:Contoso.SharedMode", - Name = "SharedMode", - Values = ["One"], - }, - new AtsDumpEnumType - { - TypeId = "enum:Contoso.UniqueMode", - Name = "UniqueMode", - Values = ["Alpha", "Beta"], - }, - ], - }; - - var baseModel = new TsPackageModel + var dump = LoadFixture(); + + var result = AtsTransformer.Transform( + dump, + "Contoso.Hosting.Widgets", + sourceRepository: "https://github.com/dotnet/aspire", + sourceCommit: "62028348b5d02dfc8f8baf03a4472946537b0d16"); + + Assert.Equal("1.0", result.SchemaVersion); + Assert.Equal(AtsTransformer.UpstreamRepository, result.GeneratorProvenance.Repository); + Assert.Equal(AtsTransformer.UpstreamCommit, result.GeneratorProvenance.Commit); + Assert.Equal(AtsTransformer.UpstreamLockFile, result.GeneratorProvenance.LockFile); + Assert.Null(result.DumpProvenance); + Assert.Equal("1.2.3", result.Package.Version); + Assert.Equal("https://github.com/microsoft/aspire", result.Package.SourceRepository); + Assert.Equal(dump.Capabilities.Count, result.Items.Count(item => item.Kind == "capability")); + Assert.Equal(dump.HandleTypes.Count, result.Items.Count(item => item.Kind == "handle")); + Assert.Equal(dump.DtoTypes.Count, result.Items.Count(item => item.Kind == "dto")); + Assert.Equal(dump.EnumTypes.Count, result.Items.Count(item => item.Kind == "enum")); + Assert.Equal(dump.ExportedValues.Count, result.Items.Count(item => item.Kind == "exportedValue")); + + var expectedIdentities = dump.Capabilities.Select(capability => $"capability:{capability.CapabilityId}") + .Concat(dump.HandleTypes.Select(handle => $"handle:{AtsTransformer.StripAssemblyPrefix(handle.AtsTypeId)}")) + .Concat(dump.DtoTypes.Select(dto => $"dto:{AtsTransformer.StripAssemblyPrefix(dto.TypeId)}")) + .Concat(dump.EnumTypes.Select(enumType => $"enum:{enumType.TypeId["enum:".Length..]}")) + .Concat(dump.ExportedValues.Select(value => $"exportedValue:{string.Join(".", value.PathSegments)}")) + .Order(StringComparer.Ordinal); + Assert.Equal(expectedIdentities, result.Items.Select(item => item.Id).Order(StringComparer.Ordinal)); + + foreach (var item in result.Items) { - Package = new TsPackageInfo + Assert.Equal(AtsTransformer.Languages, item.Projections.Keys); + foreach (var projection in item.Projections.Values) { - Name = "Aspire.Hosting", - }, - Functions = - [ - new TsFunctionModel + Assert.Contains(projection.Status, new[] { "supported", "unsupported" }); + Assert.Equal("source-derived", projection.Validation); + if (projection.Status == "unsupported") { - Name = "UseShared", - CapabilityId = "shared-capability", - QualifiedName = "Contoso.Builder.UseShared", - Kind = "method", - Signature = "UseShared(): void", - ReturnType = "void", - }, - ], - DtoTypes = - [ - new TsDtoTypeModel + Assert.False(string.IsNullOrWhiteSpace(projection.Reason)); + } + else { - Name = "SharedOptions", - FullName = "Contoso.SharedOptions", - }, - ], - EnumTypes = - [ - new TsEnumTypeModel - { - Name = "SharedMode", - FullName = "Contoso.SharedMode", - Members = ["One"], - }, - ], - }; - - File.WriteAllText(inputPath, JsonSerializer.Serialize(dump)); - File.WriteAllText(basePath, JsonSerializer.Serialize(baseModel)); - - var exitCode = GenerateCommand.TransformFile( - inputPath, - outputPath, - packageName: null, - version: null, - sourceRepo: "https://github.com/microsoft/aspire", - sourceCommit: "abc123", - basePath: basePath); + Assert.False(string.IsNullOrWhiteSpace(projection.Identifier)); + Assert.False(string.IsNullOrWhiteSpace(projection.SourceFile)); + } + } + } + } - Assert.Equal(0, exitCode); + [Fact] + public void Transform_ConsumesCompleteStableDumpSchema() + { + var dump = LoadFixture(); + var labels = Assert.Single( + dump.Capabilities, + capability => capability.CapabilityId.EndsWith("/getLabels", StringComparison.Ordinal)); + Assert.Equal("string", labels.ReturnType!.KeyType!.TypeId); + Assert.Equal("string", labels.ReturnType.ValueType!.TypeId); + + var target = Assert.Single( + dump.Capabilities, + capability => capability.CapabilityId.EndsWith("/chooseTarget", StringComparison.Ordinal)) + .Parameters.Single(parameter => parameter.Name == "target"); + Assert.Equal(2, target.Type!.UnionTypes!.Count); + + var exported = Assert.Single( + dump.ExportedValues, + value => value.PathSegments.SequenceEqual(["WidgetDefaults", "Options"])); + Assert.Equal("WidgetDefaults.Options", string.Join(".", exported.PathSegments)); + Assert.Equal(JsonValueKind.Object, exported.Value!.Value.ValueKind); + Assert.Null(exported.Description); + } - var result = JsonSerializer.Deserialize(File.ReadAllText(outputPath)); + [Fact] + public void Transform_EmitsStructuredTypeAndValueProjectionFields() + { + var result = AtsTransformer.Transform(LoadFixture(), "Contoso.Hosting.Widgets"); - Assert.NotNull(result); - Assert.Equal("Contoso.Tools", result.Package.Name); - Assert.Equal("2.4.0", result.Package.Version); - Assert.Equal("https://github.com/microsoft/aspire", result.Package.SourceRepository); - Assert.Equal("abc123", result.Package.SourceCommit); - - var function = Assert.Single(result.Functions); - Assert.Equal("unique-capability", function.CapabilityId); - Assert.Equal("UseUnique(name: string): void", function.Signature); - - var handle = Assert.Single(result.HandleTypes); - Assert.Equal("Contoso.Builder", handle.FullName); - Assert.Equal( - ["Contoso.BaseBuilder", "Contoso.RootBuilder"], - handle.BaseTypeHierarchy); - var handleCapability = Assert.Single(handle.Capabilities); - Assert.Equal("unique-capability", handleCapability.CapabilityId); - - var dto = Assert.Single(result.DtoTypes); - Assert.Equal("Contoso.UniqueOptions", dto.FullName); - Assert.Collection( - dto.Fields, - field => - { - Assert.Equal("Names", field.Name); - Assert.Equal("string[]", field.Type); - Assert.True(field.IsOptional); - }, - field => + var dto = result.Items.Single(item => item.Id == "dto:Contoso.WidgetOptions"); + Assert.All(dto.Projections.Values, projection => + { + Assert.False(string.IsNullOrWhiteSpace(projection.Identifier)); + Assert.NotEmpty(projection.Fields); + Assert.All(projection.Fields, field => { - Assert.Equal("Region", field.Name); - Assert.Equal("string", field.Type); - Assert.True(field.IsOptional); + Assert.False(string.IsNullOrWhiteSpace(field.Name)); + Assert.False(string.IsNullOrWhiteSpace(field.Type)); }); + }); - var enumType = Assert.Single(result.EnumTypes); - Assert.Equal("Contoso.UniqueMode", enumType.FullName); + var enumType = result.Items.Single(item => item.Id == "enum:Contoso.WidgetMode"); + Assert.All(enumType.Projections.Values, projection => + { + Assert.Equal(2, projection.Members.Count); + Assert.All(projection.Members, member => + Assert.NotNull(member.Value)); + }); + + var exportedValue = result.Items.Single(item => + item.Id == "exportedValue:WidgetDefaults.Options"); + Assert.All(exportedValue.Projections.Values, projection => + Assert.False(string.IsNullOrWhiteSpace(projection.ValueExpression))); + + var handle = result.Items.Single(item => item.Id == "handle:Contoso.WidgetResource"); + Assert.All(handle.Projections.Values, projection => + { + Assert.Contains(projection.Kind, new[] { "interface", "class", "handle" }); + Assert.NotNull(projection.ImplementedInterfaces); + }); } [Fact] - public void TransformFile_ReturnsErrorWhenInputIsMissing() + public void TransformFile_WritesSemanticPackageSupportMatrixAndLf() { - using var tempDirectory = new TempDirectory(); + using var directory = new TestDirectory(); + var output = Path.Combine(directory.Path, "apphost-modules", "Contoso.Hosting.Widgets.json"); + var support = Path.Combine(directory.Path, "support", "Contoso.Hosting.Widgets.json"); var exitCode = GenerateCommand.TransformFile( - Path.Combine(tempDirectory.Path, "missing.json"), - Path.Combine(tempDirectory.Path, "output.json"), - packageName: "Contoso.Tools", + FixturePath, + output, + packageName: "Contoso.Hosting.Widgets", version: null, sourceRepo: null, - sourceCommit: null); + sourceCommit: null, + supportOutputPath: support, + dumpCliVersion: "13.2.0-preview.1", + dumpProductCommit: "abcdef123456", + dumpGeneratedAt: "2026-09-09T09:30:00Z"); - Assert.Equal(1, exitCode); + Assert.Equal(0, exitCode); + Assert.DoesNotContain("\r", File.ReadAllText(output)); + var supportJson = File.ReadAllText(support); + Assert.DoesNotContain("\r", supportJson); + Assert.Contains("\"supported\": false", supportJson); + + var package = JsonSerializer.Deserialize(File.ReadAllText(output)); + var matrix = JsonSerializer.Deserialize(supportJson); + Assert.NotNull(package); + Assert.NotNull(matrix); + Assert.Equal("13.2.0-preview.1", package.DumpProvenance?.CliVersion); + Assert.Equal("abcdef123456", package.DumpProvenance?.ProductCommit); + Assert.Equal("2026-09-09T09:30:00Z", package.DumpProvenance?.GeneratedAt); + Assert.Equal(AtsTransformer.UpstreamRepository, matrix.GeneratedFrom.Repository); + Assert.Equal(AtsTransformer.UpstreamCommit, matrix.GeneratedFrom.Commit); + Assert.Equal(AtsTransformer.UpstreamLockFile, matrix.GeneratedFrom.LockFile); + Assert.Equal(package.DumpProvenance?.CliVersion, matrix.GeneratedFrom.DumpProvenance?.CliVersion); + Assert.Equal(package.DumpProvenance?.ProductCommit, matrix.GeneratedFrom.DumpProvenance?.ProductCommit); + + var supportPackage = Assert.Single(matrix.Packages); + Assert.Equal("Contoso.Hosting.Widgets@1.2.3", supportPackage.Key); + Assert.Equal(package.Items.Count, supportPackage.Value.Items.Count); + Assert.All(supportPackage.Value.Items.Values, entry => + { + Assert.Equal(AtsTransformer.Languages, entry.Languages.Keys); + Assert.All(entry.Languages.Values, language => + Assert.Equal("source-derived", language.Validation)); + }); + + var unsupported = supportPackage.Value.Items[ + "capability:Contoso.Hosting.Widgets/legacyCallback"]; + Assert.All(unsupported.Languages.Values, status => + { + Assert.False(status.Supported); + Assert.Equal("Callback defaults cannot be represented faithfully.", status.Reason); + }); } [Fact] - public void TransformFile_NormalizesToLfAndSkipsRewritingUnchangedOutput() + public async Task RootCommand_PackageVersionGeneratesOutputFile() { - using var tempDirectory = new TempDirectory(); + using var directory = new TestDirectory(); + var output = Path.Combine(directory.Path, "Contoso.Hosting.Widgets.json"); + var command = GenerateCommand.GetCommand(); + + var exitCode = await command.Parse( + [ + "--input", FixturePath, + "--output", output, + "--package-name", "Contoso.Hosting.Widgets", + "--package-version", "13.5.3", + ]) + .InvokeAsync(); - var inputPath = Path.Combine(tempDirectory.Path, "Contoso.Tools.json"); - var outputPath = Path.Combine(tempDirectory.Path, "output", "Contoso.Tools.json"); + Assert.Equal(0, exitCode); + Assert.True(File.Exists(output)); + var model = JsonSerializer.Deserialize(File.ReadAllText(output)); + Assert.NotNull(model); + Assert.Equal("13.5.3", model.Package.Version); + } - var dump = new AtsDumpRoot + [Fact] + public void BaseDeduplication_UsesSharedAtsIdentityAcrossAllProjections() + { + using var directory = new TestDirectory(); + var source = AtsTransformer.Transform(LoadFixture(), "Contoso.Hosting.Widgets"); + var baseModel = new AppHostModuleModel { - Packages = + GeneratorProvenance = source.GeneratorProvenance, + Package = new AppHostPackageInfo { Name = "Aspire.Hosting" }, + Items = [ - new AtsDumpPackageRef - { - Name = "Contoso.Tools", - Version = "2.4.0", - }, - ], - Capabilities = - [ - new AtsDumpCapability - { - CapabilityId = "unique-capability", - MethodName = "UseUnique", - QualifiedMethodName = "Contoso.Builder.UseUnique", - CapabilityKind = "method", - TargetTypeId = "Contoso.Assembly/Contoso.Builder", - TargetParameterName = "builder", - Parameters = - [ - new AtsDumpParameter - { - Name = "builder", - Type = new AtsDumpTypeRef - { - TypeId = "Contoso.Assembly/Contoso.Builder", - Category = "Type", - }, - }, - ], - ReturnType = new AtsDumpTypeRef - { - TypeId = "void", - Category = "Primitive", - }, - ExpandedTargetTypes = - [ - new AtsDumpTypeRef - { - TypeId = "Contoso.Assembly/Contoso.Builder", - Category = "Type", - }, - ], - }, - ], - HandleTypes = - [ - new AtsDumpHandleType - { - AtsTypeId = "Contoso.Assembly/Contoso.Builder", - ExposeMethods = true, - ExposeProperties = true, - }, + source.Items.Single(item => item.Id == "capability:Contoso.Hosting.Widgets/addWidget"), + source.Items.Single(item => item.Id == "dto:Contoso.WidgetOptions"), + source.Items.Single(item => item.Id == "exportedValue:WidgetDefaults.Mode"), ], }; + var basePath = Path.Combine(directory.Path, "base.json"); + var outputPath = Path.Combine(directory.Path, "output.json"); + File.WriteAllText(basePath, JsonSerializer.Serialize(baseModel)); - File.WriteAllText(inputPath, JsonSerializer.Serialize(dump)); - - var firstExitCode = GenerateCommand.TransformFile( - inputPath, + var exitCode = GenerateCommand.TransformFile( + FixturePath, outputPath, - packageName: null, + packageName: "Contoso.Hosting.Widgets", version: null, - sourceRepo: "https://github.com/microsoft/aspire", - sourceCommit: "abc123"); + sourceRepo: null, + sourceCommit: null, + basePath: basePath); - Assert.Equal(0, firstExitCode); + Assert.Equal(0, exitCode); + var result = JsonSerializer.Deserialize(File.ReadAllText(outputPath)); + Assert.NotNull(result); + Assert.DoesNotContain(result.Items, item => baseModel.Items.Any(baseItem => baseItem.Id == item.Id)); + Assert.All(result.Items, item => Assert.Equal(AtsTransformer.Languages, item.Projections.Keys)); + } - var initialContent = File.ReadAllText(outputPath); - Assert.DoesNotContain("\r", initialContent); + [Fact] + public void TransformFile_DoesNotRewriteUnchangedOutput() + { + using var directory = new TestDirectory(); + var output = Path.Combine(directory.Path, "module.json"); - File.WriteAllText(outputPath, initialContent.Replace("\n", "\r\n", StringComparison.Ordinal)); - File.SetLastWriteTimeUtc(outputPath, new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc)); - var crlfWriteTime = File.GetLastWriteTimeUtc(outputPath); + Assert.Equal(0, GenerateCommand.TransformFile( + FixturePath, + output, + packageName: "Contoso.Hosting.Widgets", + version: null, + sourceRepo: null, + sourceCommit: null)); - var secondExitCode = GenerateCommand.TransformFile( - inputPath, - outputPath, - packageName: null, + var stableTimestamp = new DateTime(2001, 1, 2, 3, 4, 5, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(output, stableTimestamp); + + Assert.Equal(0, GenerateCommand.TransformFile( + FixturePath, + output, + packageName: "Contoso.Hosting.Widgets", version: null, - sourceRepo: "https://github.com/microsoft/aspire", - sourceCommit: "abc123"); + sourceRepo: null, + sourceCommit: null)); - Assert.Equal(0, secondExitCode); + Assert.Equal(stableTimestamp, File.GetLastWriteTimeUtc(output)); + } + + [Fact] + public void ProjectionAccounting_FailsWhenAnyLanguageIsAbsent() + { + var result = AtsTransformer.Transform(LoadFixture(), "Contoso.Hosting.Widgets"); + result.Items[0].Projections.Remove("rust"); - var normalizedContent = File.ReadAllText(outputPath); - Assert.DoesNotContain("\r", normalizedContent); - Assert.NotEqual(crlfWriteTime, File.GetLastWriteTimeUtc(outputPath)); + var exception = Assert.Throws( + () => AtsTransformer.ValidateProjectionAccounting(result.Items)); - File.SetLastWriteTimeUtc(outputPath, new DateTime(2001, 1, 2, 0, 0, 0, DateTimeKind.Utc)); - var unchangedWriteTime = File.GetLastWriteTimeUtc(outputPath); + Assert.Contains("missing its 'rust' projection", exception.Message); + } - var thirdExitCode = GenerateCommand.TransformFile( - inputPath, - outputPath, - packageName: null, + [Fact] + public void TransformFile_ReturnsErrorWhenInputIsMissing() + { + using var directory = new TestDirectory(); + var exitCode = GenerateCommand.TransformFile( + Path.Combine(directory.Path, "missing.json"), + Path.Combine(directory.Path, "output.json"), + packageName: "Contoso.Tools", version: null, - sourceRepo: "https://github.com/microsoft/aspire", - sourceCommit: "abc123"); + sourceRepo: null, + sourceCommit: null); - Assert.Equal(0, thirdExitCode); - Assert.Equal(unchangedWriteTime, File.GetLastWriteTimeUtc(outputPath)); + Assert.Equal(1, exitCode); } - private sealed class TempDirectory : IDisposable + internal static AtsDumpRoot LoadFixture() + => JsonSerializer.Deserialize(File.ReadAllText(FixturePath)) + ?? throw new InvalidOperationException("Synthetic ATS fixture could not be read."); + + internal static string FixturePath + => Path.Combine(AppContext.BaseDirectory, "Fixtures", "synthetic-ats.json"); + + private sealed class TestDirectory : IDisposable { - public TempDirectory() + public TestDirectory() { - Path = Directory.CreateTempSubdirectory("ats-json-generator-tests-").FullName; + Path = System.IO.Path.Combine( + AppContext.BaseDirectory, + "test-output-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); } public string Path { get; } diff --git a/tests/AtsJsonGenerator.Tests/Fixtures/synthetic-ats.json b/tests/AtsJsonGenerator.Tests/Fixtures/synthetic-ats.json new file mode 100644 index 000000000..96506def0 --- /dev/null +++ b/tests/AtsJsonGenerator.Tests/Fixtures/synthetic-ats.json @@ -0,0 +1,527 @@ +{ + "Packages": [ + { + "Name": "Contoso.Hosting.Widgets", + "Version": "1.2.3" + } + ], + "Capabilities": [ + { + "CapabilityId": "Contoso.Hosting.Widgets/addWidget", + "MethodName": "AddWidgetAsync", + "QualifiedMethodName": "Contoso.WidgetBuilderExtensions.AddWidgetAsync", + "Description": "Adds a widget.", + "CapabilityKind": "Method", + "TargetTypeId": "Contoso.Hosting.Widgets/Contoso.IWidgetBuilder", + "TargetParameterName": "builder", + "Parameters": [ + { + "Name": "builder", + "Type": { + "TypeId": "Contoso.Hosting.Widgets/Contoso.IWidgetBuilder", + "Category": "Handle", + "IsInterface": true + } + }, + { + "Name": "name", + "Type": { + "TypeId": "string", + "Category": "Primitive" + } + }, + { + "Name": "port", + "Type": { + "TypeId": "number", + "Category": "Primitive" + }, + "IsOptional": true, + "DefaultValue": "8080" + }, + { + "Name": "onReady", + "Type": { + "TypeId": "callback", + "Category": "Callback" + }, + "IsOptional": true, + "IsCallback": true, + "CallbackParameters": [ + { + "Name": "resource", + "Type": { + "TypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "Category": "Handle" + } + } + ], + "CallbackReturnType": { + "TypeId": "void", + "Category": "Primitive" + } + }, + { + "Name": "cancellationToken", + "Type": { + "TypeId": "CancellationToken", + "Category": "Primitive" + }, + "IsOptional": true + } + ], + "ReturnType": { + "TypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "Category": "Handle" + }, + "ExpandedTargetTypes": [ + { + "TypeId": "Contoso.Hosting.Widgets/Contoso.IWidgetBuilder", + "Category": "Handle", + "IsInterface": true + } + ], + "Documentation": { + "Summary": "Adds a configured widget.", + "Returns": "The widget resource.", + "Parameters": [ + { + "Name": "name", + "Description": "The widget name." + } + ] + } + }, + { + "CapabilityId": "Contoso.Hosting.Widgets/withSettings", + "MethodName": "WithSettings", + "QualifiedMethodName": "Contoso.WidgetExtensions.WithSettings", + "CapabilityKind": "Method", + "TargetTypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "TargetParameterName": "resource", + "ReturnsBuilder": true, + "Parameters": [ + { + "Name": "resource", + "Type": { + "TypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "Category": "Handle" + } + }, + { + "Name": "options", + "Type": { + "TypeId": "Contoso.Hosting.Widgets/Contoso.WidgetOptions", + "Category": "Dto" + }, + "IsOptional": true + } + ], + "ReturnType": { + "TypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "Category": "Handle" + } + }, + { + "CapabilityId": "Contoso.Hosting.Widgets/chooseTarget", + "MethodName": "ChooseTarget", + "QualifiedMethodName": "Contoso.WidgetExtensions.ChooseTarget", + "CapabilityKind": "Method", + "TargetTypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "TargetParameterName": "resource", + "Parameters": [ + { + "Name": "resource", + "Type": { + "TypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "Category": "Handle" + } + }, + { + "Name": "target", + "Type": { + "TypeId": "union:string|WidgetResource", + "Category": "Union", + "UnionTypes": [ + { + "TypeId": "string", + "Category": "Primitive" + }, + { + "TypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "Category": "Handle" + } + ] + } + } + ], + "ReturnType": { + "TypeId": "void", + "Category": "Primitive" + } + }, + { + "CapabilityId": "Contoso.Hosting.Widgets/getLabels", + "MethodName": "Labels", + "QualifiedMethodName": "Contoso.WidgetResource.Labels", + "CapabilityKind": "PropertyGetter", + "TargetTypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "TargetParameterName": "resource", + "Parameters": [ + { + "Name": "resource", + "Type": { + "TypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "Category": "Handle" + } + } + ], + "ReturnType": { + "TypeId": "dict:string:string", + "Category": "Dict", + "IsReadOnly": true, + "KeyType": { + "TypeId": "string", + "Category": "Primitive" + }, + "ValueType": { + "TypeId": "string", + "Category": "Primitive" + } + } + }, + { + "CapabilityId": "Contoso.Hosting.Widgets/configureText", + "MethodName": "Configure", + "QualifiedMethodName": "Contoso.WidgetExtensions.ConfigureText", + "CapabilityKind": "Method", + "TargetTypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "TargetParameterName": "resource", + "Parameters": [ + { + "Name": "resource", + "Type": { + "TypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "Category": "Handle" + } + }, + { + "Name": "text", + "Type": { + "TypeId": "string", + "Category": "Primitive" + }, + "IsOptional": true + }, + { + "Name": "enabled", + "Type": { + "TypeId": "boolean", + "Category": "Primitive" + }, + "IsOptional": true, + "DefaultValue": "true" + } + ], + "ReturnType": { + "TypeId": "void", + "Category": "Primitive" + } + }, + { + "CapabilityId": "Contoso.Hosting.Widgets/configureCount", + "MethodName": "Configure", + "QualifiedMethodName": "Contoso.WidgetExtensions.ConfigureCount", + "CapabilityKind": "Method", + "TargetTypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "TargetParameterName": "resource", + "Parameters": [ + { + "Name": "resource", + "Type": { + "TypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "Category": "Handle" + } + }, + { + "Name": "count", + "Type": { + "TypeId": "number", + "Category": "Primitive" + }, + "IsOptional": true + }, + { + "Name": "mode", + "Type": { + "TypeId": "enum:Contoso.WidgetMode", + "Category": "Enum" + }, + "IsOptional": true + } + ], + "ReturnType": { + "TypeId": "void", + "Category": "Primitive" + } + }, + { + "CapabilityId": "Contoso.Hosting.Widgets/complexCallback", + "MethodName": "RegisterComplexCallback", + "QualifiedMethodName": "Contoso.WidgetExtensions.RegisterComplexCallback", + "CapabilityKind": "Method", + "TargetTypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "TargetParameterName": "resource", + "Parameters": [ + { + "Name": "resource", + "Type": { + "TypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "Category": "Handle" + } + }, + { + "Name": "callback", + "Type": { + "TypeId": "callback", + "Category": "Callback" + }, + "IsCallback": true, + "CallbackParameters": [ + { "Name": "one", "Type": { "TypeId": "string", "Category": "Primitive" } }, + { "Name": "two", "Type": { "TypeId": "number", "Category": "Primitive" } }, + { "Name": "three", "Type": { "TypeId": "boolean", "Category": "Primitive" } }, + { "Name": "four", "Type": { "TypeId": "string", "Category": "Primitive" } }, + { "Name": "five", "Type": { "TypeId": "string", "Category": "Primitive" } } + ], + "CallbackReturnType": { + "TypeId": "boolean", + "Category": "Primitive" + } + } + ], + "ReturnType": { + "TypeId": "void", + "Category": "Primitive" + } + }, + { + "CapabilityId": "Contoso.Hosting.Widgets/legacyCallback", + "MethodName": "LegacyCallback", + "QualifiedMethodName": "Contoso.WidgetExtensions.LegacyCallback", + "CapabilityKind": "Method", + "TargetTypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "TargetParameterName": "resource", + "Parameters": [ + { + "Name": "resource", + "Type": { + "TypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "Category": "Handle" + } + }, + { + "Name": "callback", + "Type": { + "TypeId": "callback", + "Category": "Callback" + }, + "IsOptional": true, + "IsCallback": true, + "DefaultValue": "default", + "CallbackParameters": [], + "CallbackReturnType": { + "TypeId": "void", + "Category": "Primitive" + } + } + ], + "ReturnType": { + "TypeId": "void", + "Category": "Primitive" + } + } + ], + "HandleTypes": [ + { + "AtsTypeId": "Contoso.Hosting.Widgets/Contoso.IWidgetBuilder", + "IsInterface": true, + "ExposeMethods": true, + "ImplementedInterfaces": [], + "BaseTypeHierarchy": [] + }, + { + "AtsTypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "ExposeMethods": true, + "ExposeProperties": true, + "ImplementedInterfaces": [ + { + "TypeId": "Contoso.Hosting.Widgets/Contoso.IWidgetBuilder", + "Category": "Handle", + "IsInterface": true + } + ], + "BaseTypeHierarchy": [] + } + ], + "DtoTypes": [ + { + "TypeId": "Contoso.Hosting.Widgets/Contoso.WidgetOptions", + "Name": "WidgetOptions", + "Description": "Widget settings.", + "Properties": [ + { + "Name": "Name", + "Type": { + "TypeId": "string", + "Category": "Primitive" + } + }, + { + "Name": "Enabled", + "Type": { + "TypeId": "boolean", + "Category": "Primitive" + }, + "IsOptional": true + }, + { + "Name": "Maybe", + "Type": { + "TypeId": "string", + "Category": "Primitive", + "IsNullable": true + }, + "IsOptional": true, + "IsNullable": true + }, + { + "Name": "Tags", + "Type": { + "TypeId": "array:string", + "Category": "Array", + "ElementType": { + "TypeId": "string", + "Category": "Primitive" + } + }, + "IsOptional": true + }, + { + "Name": "Labels", + "Type": { + "TypeId": "dict:string:string", + "Category": "Dict", + "KeyType": { + "TypeId": "string", + "Category": "Primitive" + }, + "ValueType": { + "TypeId": "string", + "Category": "Primitive" + } + }, + "IsOptional": true + }, + { + "Name": "Target", + "Type": { + "TypeId": "union:string|WidgetResource", + "Category": "Union", + "UnionTypes": [ + { + "TypeId": "string", + "Category": "Primitive" + }, + { + "TypeId": "Contoso.Hosting.Widgets/Contoso.WidgetResource", + "Category": "Handle" + } + ] + }, + "IsOptional": true + }, + { + "Name": "Prepare", + "Type": { + "TypeId": "callback", + "Category": "Callback" + }, + "IsOptional": true, + "IsCallback": true, + "CallbackParameters": [ + { + "Name": "name", + "Type": { + "TypeId": "string", + "Category": "Primitive" + } + } + ], + "CallbackReturnType": { + "TypeId": "void", + "Category": "Primitive" + } + } + ] + } + ], + "EnumTypes": [ + { + "TypeId": "enum:Contoso.WidgetMode", + "Name": "WidgetMode", + "Values": [ + "Fast", + "SafeMode" + ], + "ValueInfos": [ + { + "Name": "Fast", + "Documentation": { + "Summary": "Fast mode." + } + }, + { + "Name": "SafeMode" + } + ] + } + ], + "ExportedValues": [ + { + "PathSegments": [ + "WidgetDefaults", + "Mode" + ], + "Type": { + "TypeId": "enum:Contoso.WidgetMode", + "Category": "Enum" + }, + "Value": "Fast", + "Description": "The default widget mode." + }, + { + "PathSegments": [ + "WidgetDefaults", + "Options" + ], + "Type": { + "TypeId": "Contoso.Hosting.Widgets/Contoso.WidgetOptions", + "Category": "Dto" + }, + "Value": { + "Name": "default", + "Enabled": true, + "Maybe": null, + "Tags": [ + "one", + "two" + ], + "Labels": { + "tier": "test" + } + } + } + ], + "Diagnostics": [] +} diff --git a/tests/AtsJsonGenerator.Tests/GenerationScriptContractTests.cs b/tests/AtsJsonGenerator.Tests/GenerationScriptContractTests.cs new file mode 100644 index 000000000..fc29651ec --- /dev/null +++ b/tests/AtsJsonGenerator.Tests/GenerationScriptContractTests.cs @@ -0,0 +1,61 @@ +namespace AtsJsonGenerator.Tests; + +public sealed class GenerationScriptContractTests +{ + [Fact] + public void GenerationScript_PassesPackageVersionForCoreAndIntegrations() + { + var script = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "generate-apphost-api-json.ps1")); + + Assert.Equal(2, CountOccurrences(script, "$version = $pkg.Version")); + Assert.Equal( + 2, + CountOccurrences( + script, + "$transformArgs += @(\"--package-version\", $version)")); + Assert.DoesNotContain("\"--version\", $version", script, StringComparison.Ordinal); + } + + [Fact] + public void GenerationScript_IsolatesPublicPackageRestoreAndDisablesParallelism() + { + var script = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "generate-apphost-api-json.ps1")); + + Assert.Contains("$env:RestoreSources = $NuGetOrgServiceIndex", script, StringComparison.Ordinal); + Assert.Contains("$env:RestoreIgnoreFailedSources = \"true\"", script, StringComparison.Ordinal); + Assert.Contains("$env:RestoreDisableParallel = \"true\"", script, StringComparison.Ordinal); + Assert.Contains("$env:RestoreDisableParallel = $previousRestoreDisableParallel", script, StringComparison.Ordinal); + } + + [Fact] + public void GenerationScript_AcceptsAnExternalCoreModuleForResumableChunks() + { + var script = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "generate-apphost-api-json.ps1")); + + Assert.Contains("[string]$BaseModulePath", script, StringComparison.Ordinal); + Assert.Contains("[System.IO.Path]::GetFullPath($BaseModulePath)", script, StringComparison.Ordinal); + Assert.Contains("$transformArgs += @(\"--base\", $coreOutputFile)", script, StringComparison.Ordinal); + Assert.Contains("[switch]$SkipBuild", script, StringComparison.Ordinal); + Assert.Contains("if (-not $SkipBuild)", script, StringComparison.Ordinal); + + var compatibilityScript = File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "generate-ts-api-json.ps1")); + Assert.Contains("[string]$BaseModulePath", compatibilityScript, StringComparison.Ordinal); + Assert.Contains("[switch]$SkipBuild", compatibilityScript, StringComparison.Ordinal); + } + + private static int CountOccurrences(string value, string search) + { + var count = 0; + var index = 0; + while ((index = value.IndexOf(search, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += search.Length; + } + return count; + } +} diff --git a/tests/AtsJsonGenerator.Tests/Golden/go.golden b/tests/AtsJsonGenerator.Tests/Golden/go.golden new file mode 100644 index 000000000..aef0b932e --- /dev/null +++ b/tests/AtsJsonGenerator.Tests/Golden/go.golden @@ -0,0 +1,14 @@ +capability:Contoso.Hosting.Widgets/addWidget supported AddWidgetAsync AddWidgetAsync(name string, options ...*AddWidgetAsyncOptions) WidgetResource type AddWidgetAsyncOptions struct {\n\tPort *float64 `json:"port,omitempty"`\n\tOnReady func(resource WidgetResource) `json:"-"`\n\tCancellationToken *CancellationToken `json:"-"`\n}\n\nAddWidgetAsync(name string, options ...*AddWidgetAsyncOptions) WidgetResource name:string:required:-,options:...*AddWidgetAsyncOptions:optional:- WidgetResource:deferred +capability:Contoso.Hosting.Widgets/chooseTarget supported ChooseTarget ChooseTarget(target any) error ChooseTarget(target any) error target:any:required:- error:result Union inputs are projected as any and validated at runtime. +capability:Contoso.Hosting.Widgets/complexCallback supported RegisterComplexCallback RegisterComplexCallback(callback func(one string, two float64, three bool, four string, five string) bool) error RegisterComplexCallback(callback func(one string, two float64, three bool, four string, five string) bool) error callback:func(one string, two float64, three bool, four string, five string) bool:required:- error:result +capability:Contoso.Hosting.Widgets/configureCount supported Configure Configure(options ...*ConfigureOptions) error type ConfigureOptions struct {\n\tCount *float64 `json:"count,omitempty"`\n\tMode *WidgetMode `json:"mode,omitempty"`\n}\n\nConfigure(options ...*ConfigureOptions) error options:...*ConfigureOptions:optional:- error:result +capability:Contoso.Hosting.Widgets/configureText supported Configure Configure(options ...*WidgetResourceConfigureOptions) error type WidgetResourceConfigureOptions struct {\n\tText *string `json:"text,omitempty"`\n\tEnabled *bool `json:"enabled,omitempty"`\n}\n\nConfigure(options ...*WidgetResourceConfigureOptions) error options:...*WidgetResourceConfigureOptions:optional:- error:result +capability:Contoso.Hosting.Widgets/getLabels supported Labels Labels() (map[string]string, error) Labels() (map[string]string, error) (map[string]string, error):result +capability:Contoso.Hosting.Widgets/legacyCallback unsupported Callback defaults cannot be represented faithfully. +capability:Contoso.Hosting.Widgets/withSettings supported WithSettings WithSettings(options ...*WidgetOptions) WidgetResource WithSettings(options ...*WidgetOptions) WidgetResource options:...*WidgetOptions:optional:- WidgetResource:deferred +handle:Contoso.IWidgetBuilder supported WidgetBuilder type WidgetBuilder interface {\n\tErr() error\n} Fluent failures use first-error-wins deferred Err(). +handle:Contoso.WidgetResource supported WidgetResource type WidgetResource interface {\n\tWidgetBuilder\n\tErr() error\n} Fluent failures use first-error-wins deferred Err(). +dto:Contoso.WidgetOptions supported WidgetOptions type WidgetOptions struct {\n\tName string `json:"Name,omitempty"`\n\tEnabled *bool `json:"Enabled,omitempty"`\n\tMaybe *string `json:"Maybe,omitempty"`\n\tTags []string `json:"Tags,omitempty"`\n\tLabels map[string]string `json:"Labels,omitempty"`\n\tTarget any `json:"Target,omitempty"`\n\tPrepare func(name string) `json:"Prepare,omitempty"`\n} Name:string:required,Enabled:*bool:optional,Maybe:*string:optional,Tags:[]string:optional,Labels:map[string]string:optional,Target:any:optional,Prepare:func(name string):optional +enum:Contoso.WidgetMode supported WidgetMode type WidgetMode string\n\nconst (\n\tWidgetModeFast WidgetMode = "Fast"\n\tWidgetModeSafeMode WidgetMode = "SafeMode"\n) WidgetModeFast=Fast,WidgetModeSafeMode=SafeMode +exportedValue:WidgetDefaults.Mode supported WidgetDefaults.Mode var WidgetDefaults = struct { Mode WidgetMode }{ Mode: WidgetMode("Fast") } WidgetMode:none WidgetMode("Fast") +exportedValue:WidgetDefaults.Options supported WidgetDefaults.Options var WidgetDefaults = struct { Options *WidgetOptions }{ Options: &WidgetOptions{Name: "default", Enabled: true, Maybe: nil, Tags: []string{"one", "two"}, Labels: map[string]string{"tier": "test"}} } *WidgetOptions:none &WidgetOptions{Name: "default", Enabled: true, Maybe: nil, Tags: []string{"one", "two"}, Labels: map[string]string{"tier": "test"}} diff --git a/tests/AtsJsonGenerator.Tests/Golden/java.golden b/tests/AtsJsonGenerator.Tests/Golden/java.golden new file mode 100644 index 000000000..7504fea0f --- /dev/null +++ b/tests/AtsJsonGenerator.Tests/Golden/java.golden @@ -0,0 +1,14 @@ +capability:Contoso.Hosting.Widgets/addWidget supported addWidgetAsync public WidgetResource addWidgetAsync(String name, AddWidgetAsyncOptions options) final class AddWidgetAsyncOptions {\n private Number port;\n private AspireAction1 onReady;\n private CancellationToken cancellationToken;\n}\n\npublic WidgetResource addWidgetAsync(String name, AddWidgetAsyncOptions options) { ... }\n\npublic WidgetResource addWidgetAsync(String name) { ... } name:String:required:-,options:AddWidgetAsyncOptions:optional:- WidgetResource:exception +capability:Contoso.Hosting.Widgets/chooseTarget supported chooseTarget public void chooseTarget(AspireUnion target) public void chooseTarget(AspireUnion target) { ... }\n\npublic void chooseTarget(String target) { ... }\n\npublic void chooseTarget(WidgetResource target) { ... } target:AspireUnion:required:- void:exception Union inputs use AspireUnion plus generated concrete overloads. +capability:Contoso.Hosting.Widgets/complexCallback supported registerComplexCallback public void registerComplexCallback(Function callback) public void registerComplexCallback(Function callback) { ... } callback:Function:required:- void:exception Callbacks with more than four parameters use Function. +capability:Contoso.Hosting.Widgets/configureCount supported configure public void configure(ConfigureOptions options) final class ConfigureOptions {\n private Number count;\n private WidgetMode mode;\n}\n\npublic void configure(ConfigureOptions options) { ... }\n\npublic void configure() { ... } options:ConfigureOptions:optional:- void:exception +capability:Contoso.Hosting.Widgets/configureText supported configure public void configure(Configure1Options options) final class Configure1Options {\n private String text;\n private Boolean enabled;\n}\n\npublic void configure(Configure1Options options) { ... }\n\npublic void configure() { ... } options:Configure1Options:optional:- void:exception +capability:Contoso.Hosting.Widgets/getLabels supported labels public Map labels() public Map labels() { ... } Map:exception +capability:Contoso.Hosting.Widgets/legacyCallback unsupported Callback defaults cannot be represented faithfully. +capability:Contoso.Hosting.Widgets/withSettings supported withSettings public WidgetResource withSettings(WidgetOptions options) public WidgetResource withSettings(WidgetOptions options) { ... }\n\npublic WidgetResource withSettings() { ... } options:WidgetOptions:optional:- WidgetResource:exception +handle:Contoso.IWidgetBuilder supported IWidgetBuilder final class IWidgetBuilder extends Handle { } +handle:Contoso.WidgetResource supported WidgetResource final class WidgetResource extends Handle { } +dto:Contoso.WidgetOptions supported WidgetOptions class WidgetOptions implements JsonSerializable {\n private String name;\n private Boolean enabled;\n private String maybe;\n private String[] tags;\n private Map labels;\n private AspireUnion target;\n private AspireAction1 prepare;\n} name:String:required,enabled:Boolean:optional,maybe:String:optional,tags:String[]:optional,labels:Map:optional,target:AspireUnion:optional,prepare:AspireAction1:optional +enum:Contoso.WidgetMode supported WidgetMode enum WidgetMode implements WireValueEnum {\n FAST("Fast"),\n SAFE_MODE("SafeMode");\n} FAST=Fast,SAFE_MODE=SafeMode +exportedValue:WidgetDefaults.Mode supported WidgetDefaults.Mode final class WidgetDefaults { public static final WidgetMode Mode = WidgetMode.fromValue("Fast"); } WidgetMode:none WidgetMode.fromValue("Fast") +exportedValue:WidgetDefaults.Options supported WidgetDefaults.Options final class WidgetDefaults { public static final WidgetOptions Options = new WidgetOptions() {{ setName("default"); setEnabled(true); setMaybe(null); setTags(new String[] { "one", "two" }); setLabels(new HashMap<>(Map.ofEntries(Map.entry("tier", "test")))); }}; } WidgetOptions:none new WidgetOptions() {{ setName("default"); setEnabled(true); setMaybe(null); setTags(new String[] { "one", "two" }); setLabels(new HashMap<>(Map.ofEntries(Map.entry("tier", "test")))); }} diff --git a/tests/AtsJsonGenerator.Tests/Golden/python.golden b/tests/AtsJsonGenerator.Tests/Golden/python.golden new file mode 100644 index 000000000..07fb6962f --- /dev/null +++ b/tests/AtsJsonGenerator.Tests/Golden/python.golden @@ -0,0 +1,14 @@ +capability:Contoso.Hosting.Widgets/addWidget supported add_widget def add_widget(self, name: str, *, port: int = 8080, on_ready: typing.Callable[[WidgetResource], None] | None = None, timeout: int | None = None) -> WidgetResource def add_widget(self, name: str, *, port: int = 8080, on_ready: typing.Callable[[WidgetResource], None] | None = None, timeout: int | None = None) -> WidgetResource: ... name:str:required:-,port:int:optional:8080,on_ready:typing.Callable[[WidgetResource], None] | None:optional:None,timeout:int | None:optional:None WidgetResource:exception +capability:Contoso.Hosting.Widgets/chooseTarget supported choose_target def choose_target(self, target: str | WidgetResource) -> None def choose_target(self, target: str | WidgetResource) -> None: ... target:str | WidgetResource:required:- None:exception +capability:Contoso.Hosting.Widgets/complexCallback supported register_complex_callback def register_complex_callback(self, callback: typing.Callable[[str, int, bool, str, str], bool]) -> None def register_complex_callback(self, callback: typing.Callable[[str, int, bool, str, str], bool]) -> None: ... callback:typing.Callable[[str, int, bool, str, str], bool]:required:- None:exception +capability:Contoso.Hosting.Widgets/configureCount supported configure def configure(self, *, count: int | None = None, mode: WidgetMode | None = None) -> None def configure(self, *, count: int | None = None, mode: WidgetMode | None = None) -> None: ... count:int | None:optional:None,mode:WidgetMode | None:optional:None None:exception +capability:Contoso.Hosting.Widgets/configureText supported configure def configure(self, *, text: str | None = None, enabled: bool = True) -> None def configure(self, *, text: str | None = None, enabled: bool = True) -> None: ... text:str | None:optional:None,enabled:bool:optional:True None:exception +capability:Contoso.Hosting.Widgets/getLabels supported labels def labels(self) -> typing.Mapping[str, str] def labels(self) -> typing.Mapping[str, str]: ... typing.Mapping[str, str]:exception +capability:Contoso.Hosting.Widgets/legacyCallback unsupported Callback defaults cannot be represented faithfully. +capability:Contoso.Hosting.Widgets/withSettings supported with_settings def with_settings(self, *, options: WidgetOptions | None = None) -> WidgetResource def with_settings(self, *, options: WidgetOptions | None = None) -> WidgetResource: ... options:WidgetOptions | None:optional:None WidgetResource:exception +handle:Contoso.IWidgetBuilder supported IWidgetBuilder class IWidgetBuilder(Handle): ... +handle:Contoso.WidgetResource supported WidgetResource class WidgetResource(Handle): ... +dto:Contoso.WidgetOptions supported WidgetOptions class WidgetOptions(typing.TypedDict, total=False):\n Name: str\n Enabled: bool\n Maybe: str | None\n Tags: typing.Iterable[str]\n Labels: typing.Mapping[str, str]\n Target: str | WidgetResource\n Prepare: typing.Callable[[str], None] Name:str:optional,Enabled:bool:optional,Maybe:str | None:optional,Tags:typing.Iterable[str]:optional,Labels:typing.Mapping[str, str]:optional,Target:str | WidgetResource:optional,Prepare:typing.Callable[[str], None]:optional +enum:Contoso.WidgetMode supported WidgetMode WidgetMode = typing.Literal["Fast", "SafeMode"] Fast=Fast,SafeMode=SafeMode +exportedValue:WidgetDefaults.Mode supported Mode WidgetDefaults.Mode = "Fast" WidgetMode:none "Fast" +exportedValue:WidgetDefaults.Options supported Options WidgetDefaults.Options = {"Name": "default", "Enabled": True, "Maybe": None, "Tags": ["one", "two"], "Labels": {"tier": "test"}} WidgetOptions:none {"Name": "default", "Enabled": True, "Maybe": None, "Tags": ["one", "two"], "Labels": {"tier": "test"}} diff --git a/tests/AtsJsonGenerator.Tests/Golden/rust.golden b/tests/AtsJsonGenerator.Tests/Golden/rust.golden new file mode 100644 index 000000000..78facf7c0 --- /dev/null +++ b/tests/AtsJsonGenerator.Tests/Golden/rust.golden @@ -0,0 +1,14 @@ +capability:Contoso.Hosting.Widgets/addWidget supported add_widget_async pub fn add_widget_async(&self, name: &str, port: Option, on_ready: impl Fn(Vec) -> Value + Send + Sync + 'static, cancellation_token: Option<&CancellationToken>) -> Result> pub fn add_widget_async(&self, name: &str, port: Option, on_ready: impl Fn(Vec) -> Value + Send + Sync + 'static, cancellation_token: Option<&CancellationToken>) -> Result> { ... } name:&str:required:-,port:Option:optional:None,on_ready:impl Fn(Vec) -> Value + Send + Sync + 'static:optional:None,cancellation_token:Option<&CancellationToken>:optional:None Result>:result +capability:Contoso.Hosting.Widgets/chooseTarget supported choose_target pub fn choose_target(&self, target: Value) -> Result<(), Box> pub fn choose_target(&self, target: Value) -> Result<(), Box> { ... } target:Value:required:- Result<(), Box>:result Union values are represented as serde_json::Value. +capability:Contoso.Hosting.Widgets/complexCallback supported register_complex_callback pub fn register_complex_callback(&self, callback: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result<(), Box> pub fn register_complex_callback(&self, callback: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result<(), Box> { ... } callback:impl Fn(Vec) -> Value + Send + Sync + 'static:required:- Result<(), Box>:result +capability:Contoso.Hosting.Widgets/configureCount supported configure pub fn configure(&self, count: Option, mode: Option) -> Result<(), Box> pub fn configure(&self, count: Option, mode: Option) -> Result<(), Box> { ... } count:Option:optional:None,mode:Option:optional:None Result<(), Box>:result +capability:Contoso.Hosting.Widgets/configureText supported configure pub fn configure(&self, text: Option<&str>, enabled: Option) -> Result<(), Box> pub fn configure(&self, text: Option<&str>, enabled: Option) -> Result<(), Box> { ... } text:Option<&str>:optional:None,enabled:Option:optional:None Result<(), Box>:result +capability:Contoso.Hosting.Widgets/getLabels supported labels pub fn labels(&self) -> Result, Box> pub fn labels(&self) -> Result, Box> { ... } Result, Box>:result +capability:Contoso.Hosting.Widgets/legacyCallback unsupported Callback defaults cannot be represented faithfully. +capability:Contoso.Hosting.Widgets/withSettings supported with_settings pub fn with_settings(&self, options: Option) -> Result> pub fn with_settings(&self, options: Option) -> Result> { ... } options:Option:optional:None Result>:result +handle:Contoso.IWidgetBuilder supported IWidgetBuilder pub struct IWidgetBuilder { handle: Handle, client: Arc } +handle:Contoso.WidgetResource supported WidgetResource pub struct WidgetResource { handle: Handle, client: Arc } +dto:Contoso.WidgetOptions supported WidgetOptions #[derive(Debug, Clone, Serialize, Deserialize)]\npub struct WidgetOptions {\n pub name: String,\n pub enabled: Option,\n pub maybe: Option,\n pub tags: Option>,\n pub labels: Option>,\n pub target: Option,\n pub prepare: Value,\n} name:String:required,enabled:Option:optional,maybe:Option:optional,tags:Option>:optional,labels:Option>:optional,target:Option:optional,prepare:Value:optional DTO callback fields are represented as serde_json::Value because closures are not serde-serializable. +enum:Contoso.WidgetMode supported WidgetMode #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]\npub enum WidgetMode {\n #[default]\n #[serde(rename = "Fast")]\n Fast,\n #[serde(rename = "SafeMode")]\n SafeMode,\n} Fast=Fast,SafeMode=SafeMode +exportedValue:WidgetDefaults.Mode supported widget_defaults::mode pub mod widget_defaults { pub fn mode() -> WidgetMode { serde_json::from_value(json!("Fast")).expect("generated exported value should deserialize") } } WidgetMode:none json!("Fast") +exportedValue:WidgetDefaults.Options supported widget_defaults::options pub mod widget_defaults { pub fn options() -> WidgetOptions { serde_json::from_value(json!({ "Name": "default", "Enabled": true, "Maybe": null, "Tags": ["one", "two"], "Labels": { "tier": "test" } })).expect("generated exported value should deserialize") } } WidgetOptions:none json!({ "Name": "default", "Enabled": true, "Maybe": null, "Tags": ["one", "two"], "Labels": { "tier": "test" } }) diff --git a/tests/AtsJsonGenerator.Tests/Golden/typescript.golden b/tests/AtsJsonGenerator.Tests/Golden/typescript.golden new file mode 100644 index 000000000..6ecf48afb --- /dev/null +++ b/tests/AtsJsonGenerator.Tests/Golden/typescript.golden @@ -0,0 +1,14 @@ +capability:Contoso.Hosting.Widgets/addWidget supported AddWidgetAsync AddWidgetAsync(name: string, options?: AddWidgetAsyncOptions): Promise export interface AddWidgetAsyncOptions {\n port?: number;\n onReady?: (resource: WidgetResource) => Promise;\n cancellationToken?: AbortSignal | CancellationToken;\n}\n\nAddWidgetAsync(name: string, options?: AddWidgetAsyncOptions): Promise name:string:required:-,options:AddWidgetAsyncOptions:optional:- Promise:exception +capability:Contoso.Hosting.Widgets/chooseTarget supported ChooseTarget ChooseTarget(target: string | Awaitable): Promise ChooseTarget(target: string | Awaitable): Promise target:string | Awaitable:required:- Promise:exception +capability:Contoso.Hosting.Widgets/complexCallback supported RegisterComplexCallback RegisterComplexCallback(callback: (one: string, two: number, three: boolean, four: string, five: string) => Promise): Promise RegisterComplexCallback(callback: (one: string, two: number, three: boolean, four: string, five: string) => Promise): Promise callback:(one: string, two: number, three: boolean, four: string, five: string) => Promise:required:- Promise:exception +capability:Contoso.Hosting.Widgets/configureCount supported Configure Configure(options?: ConfigureOptions): Promise export interface ConfigureOptions {\n count?: number;\n mode?: WidgetMode;\n}\n\nConfigure(options?: ConfigureOptions): Promise options:ConfigureOptions:optional:- Promise:exception +capability:Contoso.Hosting.Widgets/configureText supported Configure Configure(options?: ConfigureTextOptions): Promise export interface ConfigureTextOptions {\n text?: string;\n enabled?: boolean;\n}\n\nConfigure(options?: ConfigureTextOptions): Promise options:ConfigureTextOptions:optional:- Promise:exception +capability:Contoso.Hosting.Widgets/getLabels supported Labels Labels(): Promise> Labels(): Promise> Promise>:exception +capability:Contoso.Hosting.Widgets/legacyCallback unsupported Callback defaults cannot be represented faithfully. +capability:Contoso.Hosting.Widgets/withSettings supported WithSettings WithSettings(options?: WidgetOptions): Promise WithSettings(options?: WidgetOptions): Promise options:WidgetOptions:optional:- Promise:exception +handle:Contoso.IWidgetBuilder supported IWidgetBuilder export interface IWidgetBuilder { } +handle:Contoso.WidgetResource supported WidgetResource export interface WidgetResource extends HandleReference { } +dto:Contoso.WidgetOptions supported WidgetOptions export interface WidgetOptions {\n name?: string;\n enabled?: boolean;\n maybe?: string | null;\n tags?: string[];\n labels?: Record;\n target?: string | WidgetResource;\n prepare?: (name: string) => Promise;\n} name:string:optional,enabled:boolean:optional,maybe:string | null:optional,tags:string[]:optional,labels:Record:optional,target:string | WidgetResource:optional,prepare:(name: string) => Promise:optional +enum:Contoso.WidgetMode supported WidgetMode export enum WidgetMode {\n Fast = "Fast",\n SafeMode = "SafeMode",\n} Fast=Fast,SafeMode=SafeMode +exportedValue:WidgetDefaults.Mode supported Mode export const Mode = "Fast" as WidgetMode; WidgetMode:none "Fast" as WidgetMode +exportedValue:WidgetDefaults.Options supported Options export const Options = { name: "default", enabled: true, maybe: null, tags: ["one", "two"], labels: { tier: "test" } } as WidgetOptions; WidgetOptions:none { name: "default", enabled: true, maybe: null, tags: ["one", "two"], labels: { tier: "test" } } as WidgetOptions diff --git a/tests/AtsJsonGenerator.Tests/SupportMatrixAggregatorTests.cs b/tests/AtsJsonGenerator.Tests/SupportMatrixAggregatorTests.cs new file mode 100644 index 000000000..863551a73 --- /dev/null +++ b/tests/AtsJsonGenerator.Tests/SupportMatrixAggregatorTests.cs @@ -0,0 +1,190 @@ +using System.Text.Json; +using AtsJsonGenerator.Helpers; + +namespace AtsJsonGenerator.Tests; + +public sealed class SupportMatrixAggregatorTests +{ + [Fact] + public void AggregateCommand_FullReconciliationContainsExactlyStagedModules() + { + using var directory = new TestDirectory(); + var staged = Directory.CreateDirectory(Path.Combine(directory.Path, "staged")).FullName; + var output = Path.Combine(directory.Path, "apphost-language-support.json"); + WriteModule(staged, "z.json", CreateModule("Zulu.Hosting", "2.0.0", "Zulu/add")); + WriteModule(staged, "a.json", CreateModule("Alpha.Hosting", "1.0.0", "Alpha/add")); + + Assert.Equal(0, SupportMatrixCommand.Aggregate(staged, output)); + + var json = File.ReadAllText(output); + var matrix = JsonSerializer.Deserialize(json); + Assert.NotNull(matrix); + Assert.Equal(["Alpha.Hosting@1.0.0", "Zulu.Hosting@2.0.0"], matrix.Packages.Keys); + Assert.True( + json.IndexOf("\"Alpha.Hosting@1.0.0\"", StringComparison.Ordinal) < + json.IndexOf("\"Zulu.Hosting@2.0.0\"", StringComparison.Ordinal)); + } + + [Fact] + public void AggregateCommand_PartialReconciliationPreservesUnaffectedAndReplacesByPackageName() + { + using var directory = new TestDirectory(); + var staged = Directory.CreateDirectory(Path.Combine(directory.Path, "staged")).FullName; + var baseline = Directory.CreateDirectory(Path.Combine(directory.Path, "baseline")).FullName; + var output = Path.Combine(directory.Path, "apphost-language-support.json"); + + WriteModule(baseline, "alpha.json", CreateModule("Alpha.Hosting", "1.0.0", "Alpha/add")); + WriteModule(baseline, "beta-old.json", CreateModule("Beta.Hosting", "1.0.0", "Beta/old")); + WriteModule(baseline, "removed.json", CreateModule("Removed.Hosting", "1.0.0", "Removed/old")); + WriteModule(staged, "beta-new.json", CreateModule("Beta.Hosting", "2.0.0", "Beta/new")); + WriteModule(staged, "gamma.json", CreateModule("Gamma.Hosting", "1.0.0", "Gamma/add")); + + Assert.Equal( + 0, + SupportMatrixCommand.Aggregate( + staged, + output, + baseline, + ["Beta.Hosting", "Gamma.Hosting", "Removed.Hosting"])); + + var matrix = JsonSerializer.Deserialize(File.ReadAllText(output)); + Assert.NotNull(matrix); + Assert.Equal( + ["Alpha.Hosting@1.0.0", "Beta.Hosting@2.0.0", "Gamma.Hosting@1.0.0"], + matrix.Packages.Keys); + Assert.DoesNotContain("Beta.Hosting@1.0.0", matrix.Packages.Keys); + Assert.DoesNotContain("Removed.Hosting@1.0.0", matrix.Packages.Keys); + Assert.Contains( + "capability:Beta/new", + matrix.Packages["Beta.Hosting@2.0.0"].Items.Keys); + } + + [Fact] + public void AggregateCommand_IsStableAndPreservesPerPackageDumpProvenance() + { + using var directory = new TestDirectory(); + var staged = Directory.CreateDirectory(Path.Combine(directory.Path, "staged")).FullName; + var output = Path.Combine(directory.Path, "apphost-language-support.json"); + WriteModule(staged, "alpha.json", CreateModule("Alpha.Hosting", "1.0.0", "Alpha/add")); + + Assert.Equal(0, SupportMatrixCommand.Aggregate(staged, output)); + var timestamp = new DateTime(2002, 2, 3, 4, 5, 6, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(output, timestamp); + Assert.Equal(0, SupportMatrixCommand.Aggregate(staged, output)); + Assert.Equal(timestamp, File.GetLastWriteTimeUtc(output)); + + var matrix = JsonSerializer.Deserialize(File.ReadAllText(output)); + var package = Assert.Single(matrix!.Packages).Value; + Assert.Equal("13.2.0", package.DumpProvenance?.CliVersion); + Assert.Equal("product-commit", package.DumpProvenance?.ProductCommit); + } + + [Fact] + public void AggregateCommand_PartialReconciliationOnlyPublishesCommonRootDumpProvenance() + { + using var directory = new TestDirectory(); + var staged = Directory.CreateDirectory(Path.Combine(directory.Path, "staged")).FullName; + var baseline = Directory.CreateDirectory(Path.Combine(directory.Path, "baseline")).FullName; + var output = Path.Combine(directory.Path, "apphost-language-support.json"); + + WriteModule( + baseline, + "alpha.json", + CreateModule("Alpha.Hosting", "1.0.0", "Alpha/add", "13.1.0", "old-product")); + WriteModule( + staged, + "beta.json", + CreateModule("Beta.Hosting", "2.0.0", "Beta/add", "13.2.0", "new-product")); + + Assert.Equal( + 0, + SupportMatrixCommand.Aggregate(staged, output, baseline, ["Beta.Hosting"])); + + var matrix = JsonSerializer.Deserialize(File.ReadAllText(output)); + Assert.NotNull(matrix); + Assert.Null(matrix.GeneratedFrom.DumpProvenance); + Assert.Equal( + "old-product", + matrix.Packages["Alpha.Hosting@1.0.0"].DumpProvenance?.ProductCommit); + Assert.Equal( + "new-product", + matrix.Packages["Beta.Hosting@2.0.0"].DumpProvenance?.ProductCommit); + } + + private static AppHostModuleModel CreateModule( + string packageName, + string version, + string capabilityId, + string cliVersion = "13.2.0", + string productCommit = "product-commit") + => new() + { + GeneratorProvenance = new AppHostGeneratorProvenanceModel + { + Repository = AtsTransformer.UpstreamRepository, + Commit = AtsTransformer.UpstreamCommit, + LockFile = AtsTransformer.UpstreamLockFile, + }, + DumpProvenance = new AppHostDumpProvenanceModel + { + CliVersion = cliVersion, + ProductCommit = productCommit, + }, + Package = new AppHostPackageInfo + { + Name = packageName, + Version = version, + }, + Items = + [ + new AppHostItemModel + { + Id = $"capability:{capabilityId}", + Kind = "capability", + Name = capabilityId.Split('/')[^1], + CapabilityId = capabilityId, + Projections = AtsTransformer.Languages.ToDictionary( + language => language, + language => new AppHostProjectionModel + { + Status = "supported", + Identifier = capabilityId.Split('/')[^1], + SourceFile = language + ".generated", + }, + StringComparer.Ordinal), + }, + ], + }; + + private static void WriteModule( + string directory, + string fileName, + AppHostModuleModel module) + => File.WriteAllText( + Path.Combine(directory, fileName), + JsonSerializer.Serialize(module)); + + private sealed class TestDirectory : IDisposable + { + public TestDirectory() + { + Path = System.IO.Path.Combine( + AppContext.BaseDirectory, + "support-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch + { + } + } + } +} diff --git a/tests/AtsJsonGenerator.Tests/UpstreamDriftTests.cs b/tests/AtsJsonGenerator.Tests/UpstreamDriftTests.cs new file mode 100644 index 000000000..0c6bbb0da --- /dev/null +++ b/tests/AtsJsonGenerator.Tests/UpstreamDriftTests.cs @@ -0,0 +1,68 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace AtsJsonGenerator.Tests; + +public sealed class UpstreamDriftTests +{ + [Fact] + public void UpstreamLock_MatchesPinnedGeneratorAndCliBlobs() + { + var lockPath = Path.Combine(AppContext.BaseDirectory, "upstream-sources.lock.json"); + using var document = JsonDocument.Parse(File.ReadAllText(lockPath)); + var root = document.RootElement; + + Assert.Equal("microsoft/aspire", root.GetProperty("repository").GetString()); + Assert.Equal("62028348b5d02dfc8f8baf03a4472946537b0d16", root.GetProperty("commit").GetString()); + var aggregateSha256 = root.GetProperty("aggregateSha256").GetString(); + Assert.Equal( + "cbf8fbf1459cb22e56dab6f56b1f1af626c716c56db8397c2b9b5454bd11e0c1", + aggregateSha256); + + var expected = new Dictionary(StringComparer.Ordinal) + { + ["src/Shared/CodeGeneration/AtsOptionsFlattening.cs"] = "b93548dead50e2d8d673bca235ae3f2004193add", + ["src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs"] = "98d634b60ad8f04c8fb64d442d6244908d43cbb1", + ["src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptApiProjector.cs"] = "075e60d08b9ada5d41725ac00e686ba6475e700a", + ["tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs"] = "447d19744402146270e425da3e046db5fb94fba1", + ["src/Aspire.Hosting.CodeGeneration.Python/AtsPythonCodeGenerator.cs"] = "18fc2696e3ed6dda14dbba2b3f0c25c5c5a617ba", + ["src/Aspire.Hosting.CodeGeneration.Python/PythonModuleBuilder.cs"] = "ec1e37facc62e3b950dc55f974d0f292994e8b66", + ["tests/Aspire.Hosting.CodeGeneration.Python.Tests/AtsPythonCodeGeneratorTests.cs"] = "7d0be4da96c02e61e8af737f0aa002ce7ccdfea1", + ["tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py"] = "58710bef30ead66e4f739b8dbc73fe02f1dee49f", + ["src/Aspire.Hosting.CodeGeneration.Go/AtsGoCodeGenerator.cs"] = "77b20a264218676b4c372dfb6349aad7bcf7a240", + ["src/Aspire.Hosting.CodeGeneration.Go/Resources/base.go"] = "f4e7b8d22dc00ac73005ac5e5e850c27330b5dc7", + ["tests/Aspire.Hosting.CodeGeneration.Go.Tests/AtsGoCodeGeneratorTests.cs"] = "c141efecce60c77299f7cf050a6570f09c7b85fa", + ["tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/AtsGeneratedAspire.verified.go"] = "dbcf2b90e31990d2c0e595a2b75e7439bc00e743", + ["src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs"] = "485f1336a53a1fb7e56c080a2b74fe1f27317460", + ["src/Aspire.Hosting.CodeGeneration.Java/Resources/Transport.java"] = "608bb2c878ce7bb537a343089c80147ffb49bae0", + ["tests/Aspire.Hosting.CodeGeneration.Java.Tests/AtsJavaCodeGeneratorTests.cs"] = "c8313998fd0fd167a14e246610ecee2ac72db228", + ["tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/AtsGeneratedAspire.verified.java"] = "9079b1d84aedec46605c5a7ecf80c69c64044e77", + ["src/Aspire.Hosting.CodeGeneration.Rust/AtsRustCodeGenerator.cs"] = "2c219bf4f69c64569e27abbf576d53f5c6b2e9cd", + ["tests/Aspire.Hosting.CodeGeneration.Rust.Tests/AtsRustCodeGeneratorTests.cs"] = "0a2725bcc53c1e8de80da08282cc63ecedb829bd", + ["tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs"] = "25d0bcf6ed03b370114770e48de95ced89f88d00", + ["src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs"] = "c61accb20133302f91ffeb6f8576a4130f12d7ea", + }; + + var actual = root.GetProperty("sources") + .EnumerateArray() + .ToDictionary( + source => source.GetProperty("path").GetString()!, + source => source.GetProperty("blob").GetString()!, + StringComparer.Ordinal); + + Assert.Equal(expected.OrderBy(pair => pair.Key), actual.OrderBy(pair => pair.Key)); + Assert.All(actual.Values, blob => + Assert.Matches("^[0-9a-f]{40}$", blob)); + + var canonicalEntries = string.Join( + "\n", + actual.OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => $"{pair.Key}\t{pair.Value}")); + var computedAggregate = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(canonicalEntries))) + .ToLowerInvariant(); + + Assert.Equal(aggregateSha256, computedAggregate); + } +}