diff --git a/.github/forbidden-words.json b/.github/forbidden-words.json index 34a9c7c06..5d4fb086d 100644 --- a/.github/forbidden-words.json +++ b/.github/forbidden-words.json @@ -18,6 +18,11 @@ "replacement": "Aspire", "message": "Use \"Aspire\" instead of \"dotnet aspire\"." }, + { + "pattern": "(?<=polyglot )\\bapp hosts\\b", + "replacement": "AppHosts", + "message": "Use \"polyglot AppHosts\" instead of \"polyglot app hosts\"." + }, { "pattern": "\\bapp host\\b", "replacement": "AppHost", diff --git a/.github/workflows/update-integration-data.yml b/.github/workflows/update-integration-data.yml index 127d1513b..d15827624 100644 --- a/.github/workflows/update-integration-data.yml +++ b/.github/workflows/update-integration-data.yml @@ -211,6 +211,7 @@ jobs: echo "- Changed: \`${{ steps.update.outputs.changed || 'unknown' }}\`" echo "- Versions changed: \`${{ steps.update.outputs.versions_changed || 'n/a' }}\`" echo "- API regen ran: \`${{ steps.update.outputs.regen_ran || 'n/a' }}\`" + echo "- Semantic validation: \`${{ steps.update.outputs.semantic_validation || 'not run' }}\`" if [ -n "${{ steps.update.outputs.icon_warnings }}" ]; then echo "- Icon warnings: \`${{ steps.update.outputs.icon_warnings }}\`" fi diff --git a/src/frontend/package.json b/src/frontend/package.json index 87654500f..fc47d7329 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -55,7 +55,8 @@ "update:ts-api": "tsx ./scripts/update-ts-api.ts", "update:github-stats": "tsx ./scripts/update-github-stats.ts", "update:samples": "tsx ./scripts/update-samples.ts", - "normalize:api-data": "tsx ./scripts/normalize-generated-api-data.ts" + "normalize:api-data": "tsx ./scripts/normalize-generated-api-data.ts", + "validate:api-data": "tsx ./scripts/validate-generated-api-data.ts" }, "dependencies": { "@astro-community/astro-embed-vimeo": "^0.3.12", diff --git a/src/frontend/scripts/aspire-terminology.ts b/src/frontend/scripts/aspire-terminology.ts index a313c6447..f749fc593 100644 --- a/src/frontend/scripts/aspire-terminology.ts +++ b/src/frontend/scripts/aspire-terminology.ts @@ -53,6 +53,10 @@ const terminologyRules: readonly TerminologyRule[] = [ replacement: 'Aspire', article: 'an', }, + { + term: String.raw`(?<=polyglot${horizontalWhitespace})app${horizontalWhitespace}hosts`, + replacement: 'AppHosts', + }, { term: String.raw`app${horizontalWhitespace}host`, replacement: 'AppHost', diff --git a/src/frontend/scripts/generate-twoslash-types.ts b/src/frontend/scripts/generate-twoslash-types.ts index f8a22d146..d50fd0733 100644 --- a/src/frontend/scripts/generate-twoslash-types.ts +++ b/src/frontend/scripts/generate-twoslash-types.ts @@ -8,15 +8,21 @@ * after regenerating). */ -import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const MODULES_DIR = resolve(__dirname, '..', 'src', 'data', 'ts-modules'); -const PKGS_DIR = resolve(__dirname, '..', 'src', 'data', 'pkgs'); -const OUTPUT_DIR = resolve(__dirname, '..', 'src', 'data', 'twoslash'); -const OUTPUT_FILE = resolve(OUTPUT_DIR, 'aspire.d.ts'); +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 PKGS_DIR = process.env.ASPIRE_API_PKGS_DIR + ? resolve(process.env.ASPIRE_API_PKGS_DIR) + : resolve(__dirname, '..', 'src', 'data', 'pkgs'); +const OUTPUT_FILE = process.env.ASPIRE_API_TWOSLASH_FILE + ? resolve(process.env.ASPIRE_API_TWOSLASH_FILE) + : resolve(__dirname, '..', 'src', 'data', 'twoslash', 'aspire.d.ts'); +const OUTPUT_DIR = dirname(OUTPUT_FILE); interface Parameter { name: string; @@ -43,6 +49,7 @@ interface FunctionEntry { interface DtoField { name: string; type: string; + isOptional?: boolean; } interface DtoType { @@ -65,6 +72,7 @@ interface HandleType { kind: 'handle'; exposeProperties?: boolean; implementedInterfaces?: string[]; + baseTypeHierarchy?: string[]; capabilities?: FunctionEntry[]; } @@ -78,6 +86,7 @@ interface ModuleJson { interface PkgTypeEntry { name: string; + fullName: string; kind: string; baseType?: string; } @@ -98,6 +107,10 @@ function lastDotted(id: string): string { return parts[parts.length - 1]; } +function withoutAssemblyPrefix(id: string): string { + return id.includes('/') ? id.slice(id.lastIndexOf('/') + 1) : id; +} + function cleanType(raw: string | undefined): string { if (!raw) return 'unknown'; let s = raw.trim(); @@ -282,6 +295,7 @@ 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?)`) @@ -324,25 +338,26 @@ const modules: ModuleJson[] = files.map( console.log(`šŸ“š Loaded ${modules.length} module JSON files`); -// Load class-inheritance metadata from the richer pkgs/*.json dumps. The -// ts-modules JSON captures implemented interfaces but not class-level `extends`, -// so resource types like ViteAppResource lose inherited methods such as -// publishAsDockerFile. Map each class short-name to its base class short-name. -const classBaseByName = new Map(); -try { +// Load class-inheritance metadata from the richer pkgs/*.json dumps. Older +// ts-modules snapshots omitted BaseTypeHierarchy, so this remains the fallback. +// Key by full type name: short names are not unique across integration packages. +const classBaseByFullName = new Map(); +if (existsSync(PKGS_DIR)) { const pkgFiles = readdirSync(PKGS_DIR).filter((f) => f.endsWith('.json')); for (const f of pkgFiles) { const pkg = JSON.parse(readFileSync(resolve(PKGS_DIR, f), 'utf8')) as PkgJson; for (const t of pkg.types ?? []) { if (t.kind !== 'class' || !t.baseType) continue; - const base = lastDotted(t.baseType).split('<')[0]; - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(base)) continue; - if (!classBaseByName.has(t.name)) classBaseByName.set(t.name, base); + const existing = classBaseByFullName.get(t.fullName); + if (existing && existing !== t.baseType) { + throw new Error( + `Conflicting base types for ${t.fullName}: ${existing} and ${t.baseType}` + ); + } + classBaseByFullName.set(t.fullName, t.baseType); } } - console.log(` + ${classBaseByName.size} class-inheritance links from pkgs/`); -} catch { - // pkgs directory is optional — older snapshots may not include it. + console.log(` + ${classBaseByFullName.size} class-inheritance links from pkgs/`); } // ---------- collect ---------- @@ -354,11 +369,6 @@ const handleTypes: HandleType[] = []; const handleByName = new Map(); const dtoByName = new Map(); const enumByName = new Map(); -// Track which package each handle originates from, so we can decide whether -// to inject a ContainerResource base (integration packages ship container-backed -// resources whose .NET class extends ContainerResource, but the JSON dump only -// lists implemented interfaces — no class inheritance). -const handlePackage = new Map(); // target short name -> methods const methodsByTarget = new Map(); @@ -383,7 +393,9 @@ export declare function refExpr(strings: TemplateStringsArray, ...values: unknow ]; const POST_SNAPSHOT_DECLARATIONS = [ - `/** + { + name: 'InputType', + declaration: `/** * Enum Aspire.Hosting.ApplicationModel.InputType */ export type InputType = "Text" | "Number" | "Choice" | "SecretText"; @@ -393,28 +405,35 @@ export declare const InputType: { readonly Choice: "Choice"; readonly SecretText: "SecretText"; };`, - `export interface ParameterCustomInputOptions { + }, + { + name: 'ParameterCustomInputOptions', + declaration: `export interface ParameterCustomInputOptions { inputType?: InputType; label?: string; placeholder?: string; options?: Record; }`, - `export interface BeforePublishEvent extends IDistributedApplicationEvent { + }, + { + name: 'BeforePublishEvent', + declaration: `export interface BeforePublishEvent extends IDistributedApplicationEvent { model: PropertyAccessor; services: PropertyAccessor; }`, - `export interface AfterPublishEvent extends IDistributedApplicationEvent { + }, + { + name: 'AfterPublishEvent', + declaration: `export interface AfterPublishEvent extends IDistributedApplicationEvent { model: PropertyAccessor; services: PropertyAccessor; }`, + }, ]; -const POST_SNAPSHOT_DECLARATION_TYPE_NAMES = new Set([ - 'BeforePublishEvent', - 'AfterPublishEvent', - 'InputType', - 'ParameterCustomInputOptions', -]); +const POST_SNAPSHOT_DECLARATION_TYPE_NAMES = new Set( + POST_SNAPSHOT_DECLARATIONS.map(({ name }) => name) +); const POST_SNAPSHOT_AUGMENTATIONS = [ `export interface IDistributedApplicationBuilder { @@ -508,7 +527,6 @@ for (const mod of modules) { if (!handleByName.has(h.name)) { handleByName.set(h.name, h); handleTypes.push(h); - handlePackage.set(h.name, mod.package.name); } } @@ -633,12 +651,8 @@ const BUILT_IN = new Set([ ]); const declaredTypes = new Set([ - ...dtoTypes - .filter((d) => !POST_SNAPSHOT_DECLARATION_TYPE_NAMES.has(d.name)) - .map((d) => d.name), - ...enumTypes - .filter((e) => !POST_SNAPSHOT_DECLARATION_TYPE_NAMES.has(e.name)) - .map((e) => e.name), + ...dtoTypes.map((d) => d.name), + ...enumTypes.map((e) => e.name), ...handleTypes.map((h) => h.name), ...methodsByTarget.keys(), ...POST_SNAPSHOT_DECLARATION_TYPE_NAMES, @@ -672,12 +686,17 @@ parts.push(` set(key: string, value: unknown): Promise;`); parts.push(`};`); parts.push(``); parts.push(`// ---- enums ----`); -for (const declaration of POST_SNAPSHOT_DECLARATIONS) { +const generatedDeclarationTypeNames = new Set([ + ...dtoTypes.map((dto) => dto.name), + ...enumTypes.map((enumType) => enumType.name), + ...handleTypes.map((handle) => handle.name), +]); +for (const { name, declaration } of POST_SNAPSHOT_DECLARATIONS) { + if (generatedDeclarationTypeNames.has(name)) continue; parts.push(declaration); parts.push(''); } for (const en of enumTypes) { - if (POST_SNAPSHOT_DECLARATION_TYPE_NAMES.has(en.name)) continue; parts.push(jsdoc([`Enum ${en.fullName}`])); const members = en.members.map((m) => JSON.stringify(m)).join(' | ') || 'string'; parts.push(`export type ${en.name} = ${members};`); @@ -694,40 +713,20 @@ for (const en of enumTypes) { parts.push(`// ---- DTOs ----`); for (const dto of dtoTypes) { - if (POST_SNAPSHOT_DECLARATION_TYPE_NAMES.has(dto.name)) continue; parts.push(jsdoc([`DTO ${dto.fullName}`])); parts.push(`export interface ${dto.name} {`); for (const f of dto.fields) { const t = cleanType(f.type); extractTypeIdentifiers(t, referencedTypes); scanExprForGenerics(t); - parts.push(` ${camelCase(f.name)}: ${t};`); + const optional = f.isOptional ? '?' : ''; + parts.push(` ${camelCase(f.name)}${optional}: ${t};`); } parts.push(`}`); parts.push(''); } parts.push(`// ---- handle types ----`); -// Integration packages whose resources are NOT container-backed. Resources -// from these packages implement the same IComputeResource/IResourceWithArgs/ -// IResourceWithEndpoints trio that container-backed resources do, but their -// .NET class does not derive from ContainerResource — so we must not inject -// it into the TS extends clause. -const NON_CONTAINER_PACKAGES = new Set([ - 'Aspire.Hosting', // core primitives (ProjectResource, ExecutableResource, ...) - 'Aspire.Hosting.JavaScript', - 'Aspire.Hosting.Python', - 'Aspire.Hosting.DevTunnels', - 'Aspire.Hosting.Maui', -]); -// Specific handles to exclude even if their package is container-friendly. -// AzureFunctionsProjectResource is a project handle that lives in the Functions -// package alongside the Storage emulator (which IS container-backed). -const NON_CONTAINER_HANDLES = new Set([ - 'AzureFunctionsProjectResource', - 'AzurePromptAgentResource', -]); - const EXTRA_HANDLE_MEMBERS: Record = { AzureResourceInfrastructure: [ ' /** Gets the provisionable Azure resources produced by the infrastructure callback. */', @@ -739,17 +738,22 @@ const EXTRA_HANDLE_MEMBERS: Record = { ], }; -function isContainerBacked(h: HandleType): boolean { - if (h.name === 'ContainerResource') return false; - if (NON_CONTAINER_HANDLES.has(h.name)) return false; - const pkg = handlePackage.get(h.name); - if (pkg && NON_CONTAINER_PACKAGES.has(pkg)) return false; - const ifaces = new Set((h.implementedInterfaces ?? []).map((i) => lastDotted(i).split('<')[0])); - return ( - ifaces.has('IComputeResource') && - ifaces.has('IResourceWithArgs') && - ifaces.has('IResourceWithEndpoints') - ); +function getBaseTypeHierarchy(h: HandleType): string[] { + if ((h.baseTypeHierarchy?.length ?? 0) > 0) { + return h.baseTypeHierarchy!; + } + + const hierarchy: string[] = []; + const seen = new Set([h.fullName]); + let ancestor = classBaseByFullName.get(h.fullName); + while (ancestor) { + const identity = withoutAssemblyPrefix(ancestor); + if (seen.has(identity)) break; + seen.add(identity); + hierarchy.push(identity); + ancestor = classBaseByFullName.get(identity); + } + return hierarchy; } for (const h of handleTypes) { @@ -758,23 +762,16 @@ for (const h of handleTypes) { .map((i) => i.split('<')[0]) .filter((i) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(i) && i !== h.name); const uniqueParents = [...new Set(parents)]; - if (isContainerBacked(h) && !uniqueParents.includes('ContainerResource')) { - // Put ContainerResource first so chains like `.withImageTag(...).withLifetime(...)` - // resolve to inherited members before the marker interfaces contribute noise. - uniqueParents.unshift('ContainerResource'); - } - // Chase the class-inheritance chain so methods defined on a parent class - // (e.g. publishAsDockerFile on ExecutableResource) are visible on subclasses - // (e.g. ViteAppResource). The ts-modules dump only lists implemented - // interfaces, so without this step subclasses lose inherited members. - let ancestor = classBaseByName.get(h.name); - const seen = new Set([h.name]); - while (ancestor && !seen.has(ancestor)) { - seen.add(ancestor); - if (handleByName.has(ancestor) && !uniqueParents.includes(ancestor)) { - uniqueParents.unshift(ancestor); + + for (const ancestor of getBaseTypeHierarchy(h)) { + const ancestorName = cleanType(ancestor).split('<')[0]; + if ( + ancestorName !== h.name && + handleByName.has(ancestorName) && + !uniqueParents.includes(ancestorName) + ) { + uniqueParents.unshift(ancestorName); } - ancestor = classBaseByName.get(ancestor); } const implementsClause = uniqueParents.length > 0 ? ` extends ${uniqueParents.join(', ')}` : ''; for (const i of uniqueParents) referencedTypes.add(i); diff --git a/src/frontend/scripts/normalize-generated-api-data.ts b/src/frontend/scripts/normalize-generated-api-data.ts index b7afcc8ed..10332bdbf 100644 --- a/src/frontend/scripts/normalize-generated-api-data.ts +++ b/src/frontend/scripts/normalize-generated-api-data.ts @@ -48,9 +48,10 @@ export const TS_MODULES_DIR = path.join(DATA_DIR, 'ts-modules'); // left intact, hence the kind gating below. `description`/`returns`/`remarks` // are always prose (and appear as string values only in the TS API + member // summaries; the C# doc arrays open with `[` and are skipped, their inner text -// nodes handled by the `text` rule). +// nodes handled by the `text` rule). `Reason` is the prose payload of +// `AspireExportIgnoreAttribute`. const nodeLine = - /^[ \t]*"kind"[ \t]*:[ \t]*"([^"]*)"|^([ \t]*")(text|description|returns|remarks)("[ \t]*:[ \t]*")((?:[^"\\]|\\.)*)(")/gm; + /^[ \t]*"kind"[ \t]*:[ \t]*"([^"]*)"|^([ \t]*")(text|description|returns|remarks|Reason)("[ \t]*:[ \t]*")((?:[^"\\]|\\.)*)(")/gm; /** * Rewrite deprecated Aspire terminology in the prose fields of a generated API @@ -135,10 +136,20 @@ function main(): void { const explicit = args.includes('--pkgs') || args.includes('--ts-modules'); const targets: Array<{ label: string; dir: string }> = []; if (!explicit || args.includes('--pkgs')) { - targets.push({ label: 'pkgs', dir: PKGS_DIR }); + targets.push({ + label: 'pkgs', + dir: process.env.ASPIRE_API_PKGS_DIR + ? path.resolve(process.env.ASPIRE_API_PKGS_DIR) + : PKGS_DIR, + }); } if (!explicit || args.includes('--ts-modules')) { - targets.push({ label: 'ts-modules', dir: TS_MODULES_DIR }); + 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, + }); } let total = 0; diff --git a/src/frontend/scripts/update-integration-data.ps1 b/src/frontend/scripts/update-integration-data.ps1 index 86b40042e..fa196d361 100644 --- a/src/frontend/scripts/update-integration-data.ps1 +++ b/src/frontend/scripts/update-integration-data.ps1 @@ -19,8 +19,10 @@ 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 - 4. Scope check — the working tree must only contain allowed data files. - 5. Emits a summary + PR title/body. In CI, writes step outputs to + 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. + 6. Emits a summary + PR title/body. In CI, writes step outputs to $GITHUB_OUTPUT and the PR body to a file; locally prints a summary. Exit codes: @@ -28,9 +30,8 @@ 1 a required phase failed (update:all, TS API regen, out-of-scope diff). The caller must NOT open a PR on a non-zero exit. - Per-package failures inside generate-package-json.ps1 are tolerated (they - are common for meta-packages without a public API surface); their counts are - reported in the PR body but do not fail the run. + Packages without a public API surface are reported as explicit skips. Any + generation failure or semantic validation error fails the run. .PARAMETER SkipRegen Skip the API-reference regeneration phase even when versions changed. Useful @@ -79,6 +80,9 @@ $AllowedPaths = @( ) $IsCI = [bool]$env:GITHUB_ACTIONS +$script:ApiStageRoot = $null +$script:PreserveApiStage = $false +$script:PreviousApiEnvironment = @{} function Write-Section([string]$Text) { Write-Host "" @@ -124,6 +128,128 @@ function Test-PathAllowed { return $false } +function Restore-ApiEnvironment { + foreach ($name in @( + 'ASPIRE_API_PKGS_DIR', + 'ASPIRE_API_TS_MODULES_DIR', + 'ASPIRE_API_TWOSLASH_FILE' + )) { + $previousValue = $script:PreviousApiEnvironment[$name] + if ($null -eq $previousValue) { + Remove-Item "Env:$name" -ErrorAction SilentlyContinue + } + else { + Set-Item "Env:$name" $previousValue + } + } +} + +function Remove-ApiStage { + Restore-ApiEnvironment + if ($script:ApiStageRoot -and (Test-Path $script:ApiStageRoot)) { + if ($script:PreserveApiStage) { + Write-Warning "Preserving API generation recovery data at $($script:ApiStageRoot)." + } + else { + Remove-Item -Path $script:ApiStageRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + $script:ApiStageRoot = $null + $script:PreserveApiStage = $false +} + +function Stop-ApiRegeneration { + param([Parameter(Mandatory)][string]$Message) + + Remove-ApiStage + Write-Host $Message -ForegroundColor Red + exit 1 +} + +function Publish-GeneratedApiData { + param( + [Parameter(Mandatory)][string]$PackageSource, + [Parameter(Mandatory)][string]$ModuleSource, + [Parameter(Mandatory)][string]$TwoslashSource + ) + + $moves = @( + [PSCustomObject]@{ + Source = $PackageSource + Destination = Join-Path $DataDir 'pkgs' + Backup = Join-Path $script:ApiStageRoot 'backup-pkgs' + }, + [PSCustomObject]@{ + Source = $ModuleSource + Destination = Join-Path $DataDir 'ts-modules' + Backup = Join-Path $script:ApiStageRoot 'backup-ts-modules' + }, + [PSCustomObject]@{ + Source = $TwoslashSource + Destination = Join-Path $DataDir 'twoslash' 'aspire.d.ts' + Backup = Join-Path $script:ApiStageRoot 'backup-aspire.d.ts' + } + ) + $completed = [System.Collections.Generic.List[object]]::new() + + try { + foreach ($move in $moves) { + 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)" + } + } + + foreach ($move in $moves) { + Move-Item -LiteralPath $move.Destination -Destination $move.Backup + $completed.Add($move) + Move-Item -LiteralPath $move.Source -Destination $move.Destination + } + } + catch { + $publishError = $_ + $rollbackErrors = [System.Collections.Generic.List[string]]::new() + for ($index = $completed.Count - 1; $index -ge 0; $index--) { + $move = $completed[$index] + try { + if (Test-Path $move.Destination) { + if (Test-Path $move.Source) { + throw "Staged path already exists: $($move.Source)" + } + Move-Item -LiteralPath $move.Destination -Destination $move.Source + } + } + catch { + $rollbackErrors.Add("$($move.Destination) -> $($move.Source): $_") + } + + try { + if (Test-Path $move.Backup) { + if (Test-Path $move.Destination) { + throw "Destination is still occupied: $($move.Destination)" + } + Move-Item -LiteralPath $move.Backup -Destination $move.Destination + } + } + catch { + $rollbackErrors.Add("$($move.Backup) -> $($move.Destination): $_") + } + } + + if ($rollbackErrors.Count -gt 0) { + $script:PreserveApiStage = $true + throw "Publishing failed: $publishError Rollback was incomplete: $($rollbackErrors -join '; '). Recovery data remains under $($script:ApiStageRoot)." + } + + throw "Publishing failed: $publishError The original generated data was restored." + } +} + # ── Phase 1: data update ──────────────────────────────────────────────────── Write-Section 'Phase 1 — pnpm update:all' Push-Location $FrontendDir @@ -166,6 +292,7 @@ if (-not $anyChanges) { Set-Output 'changed' 'false' Set-Output 'versions_changed' 'false' Set-Output 'regen_ran' 'false' + Set-Output 'semantic_validation' 'not run' exit 0 } @@ -229,23 +356,59 @@ else { # ── Phase 3: conditional API-reference regeneration ───────────────────────── $regenRan = $false $pkgSummary = '' +$pkgSkippedPackages = '' $tsApiSummary = '' +$tsSkippedPackages = '' $twoslashSummary = '' +$semanticSummary = '' if ($versionsChanged -and -not $SkipRegen) { Write-Section 'Phase 3 — API reference regeneration' - # 3a. C# API JSON. Per-package failures are tolerated (meta-packages). + $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' + $twoslashStageFile = Join-Path $script:ApiStageRoot 'twoslash' 'aspire.d.ts' + foreach ($name in @( + 'ASPIRE_API_PKGS_DIR', + 'ASPIRE_API_TS_MODULES_DIR', + '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_TWOSLASH_FILE = $twoslashStageFile + + # 3a. C# API JSON. Write-Host "→ generate-package-json.ps1 (C# API → pkgs/)" -ForegroundColor Cyan - $pkgArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $PkgGenScript) + $pkgArgs = @( + '-NoProfile', '-ExecutionPolicy', 'Bypass', + '-File', $PkgGenScript, + '-OutputDir', $pkgStageDir + ) if ($Framework) { $pkgArgs += @('-Framework', $Framework) } $pkgLog = & pwsh @pkgArgs 2>&1 | Tee-Object -Variable pkgTeed | Out-String if ($LASTEXITCODE -ne 0) { - Write-Error "generate-package-json.ps1 failed hard (exit $LASTEXITCODE). Aborting; no PR will be opened." - exit 1 + Stop-ApiRegeneration "generate-package-json.ps1 failed (exit $LASTEXITCODE).`n$pkgLog`nAborting; no PR will be opened." } $pkgDone = ($pkgLog -split "`n" | Where-Object { $_ -match 'Done!\s+Success:' } | Select-Object -First 1) - $pkgSummary = if ($pkgDone) { ($pkgDone -replace '.*Done!\s+', '').Trim() } else { 'summary unavailable' } + if (-not $pkgDone -or + $pkgDone -notmatch 'Done!\s+Success:\s+(?\d+)\s+\|\s+Failed:\s+(?\d+)\s+\|\s+Skipped:\s+(?\d+)') { + Stop-ApiRegeneration "C# API generation did not emit a valid completion summary. Aborting; no PR will be opened." + } + $pkgSummary = "Success: $($Matches.Succeeded) | Failed: $($Matches.Failed) | Skipped: $($Matches.Skipped)" + if ([int]$Matches.Failed -gt 0) { + Stop-ApiRegeneration "C# API generation reported $($Matches.Failed) package failure(s). Aborting; no PR will be opened." + } + $pkgSkippedLine = ($pkgLog -split "`n" | + Where-Object { $_ -match '^\s*Skipped packages:' } | + Select-Object -First 1) + $pkgSkippedPackages = if ($pkgSkippedLine) { + ($pkgSkippedLine -replace '^\s*Skipped packages:\s*', '').Trim() + } + else { '' } Write-Host " C# API: $pkgSummary" # 3a-normalize. Enforce Aspire terminology in the freshly generated C# API @@ -262,8 +425,7 @@ if ($versionsChanged -and -not $SkipRegen) { Pop-Location } if ($pkgNormExit -ne 0) { - Write-Error "normalize:api-data (pkgs) failed (exit $pkgNormExit).`n$pkgNormLog`nAborting; no PR will be opened." - exit 1 + 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 @@ -281,12 +443,11 @@ if ($versionsChanged -and -not $SkipRegen) { if ($tsExit -ne 0) { # Distinguish phase-2 vs phase-3 failure using the script's log markers. if ($tsLog -match 'Twoslash type generation failed') { - Write-Error "Twoslash bundle generation failed. Aborting; no PR will be opened." + Stop-ApiRegeneration "Twoslash bundle generation failed.`n$tsLog`nAborting; no PR will be opened." } else { - Write-Error "TypeScript API generation failed. Aborting; no PR will be opened." + Stop-ApiRegeneration "TypeScript API generation failed.`n$tsLog`nAborting; no PR will be opened." } - exit 1 } $tsApiDone = ($tsLog -split "`n" | @@ -294,24 +455,56 @@ if ($versionsChanged -and -not $SkipRegen) { Select-Object -First 1) if (-not $tsApiDone -or $tsApiDone -notmatch 'Complete:\s+(?\d+)\s+succeeded,\s+(?\d+)\s+failed,\s+(?\d+)\s+skipped') { - Write-Error "TypeScript API generation did not emit a valid completion summary. Aborting; no PR will be opened." - exit 1 + Stop-ApiRegeneration "TypeScript 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" if ([int]$Matches.Failed -gt 0) { - Write-Error "TypeScript API generation reported $($Matches.Failed) package failure(s). Aborting; no PR will be opened." - exit 1 + Stop-ApiRegeneration "TypeScript API generation reported $($Matches.Failed) package failure(s). Aborting; no PR will be opened." } + $tsSkippedLine = ($tsLog -split "`n" | + Where-Object { $_ -match '^\s*Skipped packages:' } | + Select-Object -First 1) + $tsSkippedPackages = if ($tsSkippedLine) { + ($tsSkippedLine -replace '^\s*Skipped packages:\s*', '').Trim() + } + else { '' } $twoslashSummary = 'succeeded' + + # 3c. Cross-artifact semantic validation. This runs only after every + # generator has completed and must pass before the workflow can publish. + Write-Host "→ pnpm validate:api-data (semantic regression gate)" -ForegroundColor Cyan + Push-Location $FrontendDir + try { + $validationLog = & pnpm run validate:api-data 2>&1 | Tee-Object -Variable validationTeed | Out-String + $validationExit = $LASTEXITCODE + } + finally { + Pop-Location + } + if ($validationExit -ne 0) { + Stop-ApiRegeneration "Generated API semantic validation failed.`n$validationLog`nAborting; no PR will be opened." + } + $semanticSummary = 'passed' + + try { + Publish-GeneratedApiData ` + -PackageSource $pkgStageDir ` + -ModuleSource $tsStageDir ` + -TwoslashSource $twoslashStageFile + } + catch { + Stop-ApiRegeneration "Publishing validated API data failed: $_" + } + Remove-ApiStage $regenRan = $true } elseif ($versionsChanged -and $SkipRegen) { Write-Warning "Versions changed but -SkipRegen was set; regeneration skipped (local/dev use only)." } -# ── Phase 4: scope check ──────────────────────────────────────────────────── -Write-Section 'Phase 4 — scope check' +# ── Phase 5: scope check ──────────────────────────────────────────────────── +Write-Section 'Phase 5 — scope check' $allStatus = @(Invoke-Git @('status', '--porcelain') | Where-Object { $_ -and $_.Trim().Length -gt 0 }) $outOfScope = [System.Collections.Generic.List[string]]::new() foreach ($line in $allStatus) { @@ -396,6 +589,13 @@ if ($regenRan) { [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("- 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``") + } } else { [void]$sb.AppendLine("_No integration package versions changed in this run — API reference regeneration was skipped._") @@ -429,6 +629,7 @@ Set-Content -Path $prBodyFile -Value $prBody -Encoding utf8 Set-Output 'changed' 'true' Set-Output 'versions_changed' ($versionsChanged.ToString().ToLowerInvariant()) Set-Output 'regen_ran' ($regenRan.ToString().ToLowerInvariant()) +Set-Output 'semantic_validation' $(if ($regenRan) { $semanticSummary } else { 'not run' }) Set-Output 'pr_title' $prTitle Set-Output 'pr_body_file' $prBodyFile Set-Output 'icon_warnings' $iconWarnings diff --git a/src/frontend/scripts/update-ts-api.ts b/src/frontend/scripts/update-ts-api.ts index 4b539b183..0bbb9917f 100644 --- a/src/frontend/scripts/update-ts-api.ts +++ b/src/frontend/scripts/update-ts-api.ts @@ -18,7 +18,7 @@ * tsx ./scripts/update-ts-api.ts /path/aspire # from repo clone */ -import { execSync, execFileSync } from 'child_process'; +import { execFileSync } from 'child_process'; import { existsSync } from 'fs'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; @@ -60,7 +60,7 @@ function main(): void { process.exit(1); } - let psArgs: string; + const psArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', SCRIPT_PATH]; if (aspireRepoPath) { const resolvedPath = resolve(aspireRepoPath); if (!existsSync(resolvedPath)) { @@ -68,17 +68,20 @@ function main(): void { process.exit(1); } console.log(`šŸ”„ Generating TypeScript API reference data from ${resolvedPath}...`); - psArgs = `-AspireRepoPath "${resolvedPath}"`; + psArgs.push('-AspireRepoPath', resolvedPath); } else { console.log('šŸ”„ Generating TypeScript API reference data from installed Aspire CLI...'); - psArgs = ''; + } + + 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) { + psArgs.push('-OutputDir', outputDir); } try { - execSync( - `pwsh -NoProfile -ExecutionPolicy Bypass -File "${SCRIPT_PATH}" ${psArgs}`.trim(), - { stdio: 'inherit', cwd: resolve(__dirname, '..') } - ); + execFileSync('pwsh', psArgs, { stdio: 'inherit', cwd: resolve(__dirname, '..') }); console.log('āœ… TypeScript API reference data updated.'); } catch (error: unknown) { console.error('āŒ Generation failed:', getErrorMessage(error)); @@ -91,7 +94,7 @@ function main(): void { // JSDoc/XML docs may carry. Reuses the single source of truth in // aspire-terminology.ts. console.log('šŸ”„ Normalizing Aspire terminology in ts-modules JSON...'); - const { changes: tsModuleChanges } = normalizeApiDir(TS_MODULES_DIR); + const { changes: tsModuleChanges } = normalizeApiDir(outputDir); console.log(`āœ… Normalized ${tsModuleChanges} occurrence(s) in ts-modules JSON.`); // Refresh the twoslash .d.ts bundle so docs hover tooltips stay in sync diff --git a/src/frontend/scripts/validate-generated-api-data.ts b/src/frontend/scripts/validate-generated-api-data.ts new file mode 100644 index 000000000..5ed77b7b6 --- /dev/null +++ b/src/frontend/scripts/validate-generated-api-data.ts @@ -0,0 +1,677 @@ +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +interface CatalogEntry { + title: string; + version: string; +} + +interface PackageMetadata { + name: string; + version: string; + sourceRepository?: string; + sourceCommit?: string; +} + +interface ApiAttribute { + name: string; + constructorArguments?: unknown[]; + arguments?: Record; + namedArguments?: Record; +} + +interface ApiParameter { + name: string; + type?: string; + modifier?: string; + attributes?: ApiAttribute[]; +} + +interface ApiMember { + name: string; + kind?: string; + signature?: string; + genericParameters?: unknown[]; + attributes?: ApiAttribute[]; + parameters?: ApiParameter[]; +} + +interface ApiType { + name: string; + fullName: string; + kind: string; + baseType?: string; + attributes?: ApiAttribute[]; + members?: ApiMember[]; +} + +interface PackageJson { + package: PackageMetadata; + types?: ApiType[]; +} + +interface DtoField { + name: string; + type: string; + isOptional?: boolean; +} + +interface DtoType { + name: string; + fields?: DtoField[]; +} + +interface HandleType { + name: string; + fullName: string; + implementedInterfaces?: string[]; + baseTypeHierarchy?: string[]; +} + +interface TsModuleJson { + package: PackageMetadata; + functions?: unknown[]; + dtoTypes?: DtoType[]; + handleTypes?: HandleType[]; +} + +export interface GeneratedFile { + fileName: string; + data: T; + baseline?: T; +} + +export interface ValidationInput { + catalog: CatalogEntry[]; + packages: GeneratedFile[]; + modules: GeneratedFile[]; + declarations: string; +} + +export interface ValidationResult { + errors: string[]; + checks: string[]; +} + +interface ParsedInterface { + parents: string[]; + properties: Map; +} + +interface AttributePayloadShape { + constructorArgumentCount: number; + namedArgumentNames: string[]; +} + +const relevantAttribute = /(?:^|\.)Aspire(?:Export(?:Ignore)?|Dto|Union)Attribute$/; + +function identity(metadata: PackageMetadata): string { + return `${metadata.name}@${metadata.version}`; +} + +function expectedFileName(metadata: PackageMetadata): string { + return `${metadata.name}.${metadata.version}.json`; +} + +function shortTypeName(typeId: string): string { + let normalized = typeId.trim(); + normalized = normalized.replace( + /[A-Za-z_][A-Za-z0-9_]*(?:[./][A-Za-z_][A-Za-z0-9_]*)+/g, + (value) => { + const withoutAssembly = value.includes('/') + ? value.slice(value.lastIndexOf('/') + 1) + : value; + const parts = withoutAssembly.split('.'); + return parts[parts.length - 1]; + } + ); + normalized = normalized.replace(/\]\]+/g, ''); + const withoutAssembly = normalized.includes('/') + ? normalized.slice(normalized.lastIndexOf('/') + 1) + : normalized; + const withoutGeneric = withoutAssembly.split('<')[0]; + const parts = withoutGeneric.split('.'); + return parts[parts.length - 1]; +} + +function camelCase(name: string): string { + return name.length === 0 ? name : name[0].toLowerCase() + name.slice(1); +} + +function normalizeTypeScriptType(typeName: string): string { + return typeName + .trim() + .replace(/[A-Za-z_][A-Za-z0-9_]*(?:[./][A-Za-z_][A-Za-z0-9_]*)+/g, (value) => { + const withoutAssembly = value.includes('/') + ? value.slice(value.lastIndexOf('/') + 1) + : value; + const parts = withoutAssembly.split('.'); + return parts[parts.length - 1]; + }) + .replace(/\s+/g, ''); +} + +function normalizeClrType(typeName: string): string { + const slashIndex = typeName.indexOf('/'); + return (slashIndex >= 0 ? typeName.slice(slashIndex + 1) : typeName) + .replace(/\s+/g, '') + .replace(/`\d+\[\[/g, '<') + .replace(/\],\[/g, ',') + .replace(/\]\]/g, '>'); +} + +function addUnique( + files: GeneratedFile[], + metadata: (data: T) => PackageMetadata, + label: string, + errors: string[] +): Map> { + const byIdentity = new Map>(); + for (const file of files) { + const packageMetadata = metadata(file.data); + const key = identity(packageMetadata); + if (byIdentity.has(key)) { + errors.push(`${label} contains duplicate package identity ${key}.`); + continue; + } + byIdentity.set(key, file); + if (file.fileName !== expectedFileName(packageMetadata)) { + errors.push( + `${label} file ${file.fileName} does not match its package identity; expected ${expectedFileName(packageMetadata)}.` + ); + } + } + return byIdentity; +} + +function isPackageOutputExpected(name: string): boolean { + return ( + !name.startsWith('Aspire.Hosting.CodeGeneration.') && + name !== 'Aspire.Hosting.Integration.Analyzers' + ); +} + +function attributesForOwner( + result: Map, + owner: string, + attributes: ApiAttribute[] | undefined +): void { + const indexes = new Map(); + for (const attribute of attributes ?? []) { + if (!relevantAttribute.test(attribute.name)) continue; + const index = indexes.get(attribute.name) ?? 0; + indexes.set(attribute.name, index + 1); + result.set(`${owner}|${attribute.name}|${index}`, { + constructorArgumentCount: attribute.constructorArguments?.length ?? 0, + namedArgumentNames: [ + ...new Set([ + ...Object.keys(attribute.arguments ?? {}), + ...Object.keys(attribute.namedArguments ?? {}), + ]), + ].sort(), + }); + } +} + +function memberOwnerKey(typeOwner: string, member: ApiMember): string { + const genericArity = member.genericParameters?.length ?? 0; + const parameterTypes = (member.parameters ?? []) + .map((parameter) => + `${parameter.modifier ? `${parameter.modifier} ` : ''}${parameter.type ?? '?'}` + ) + .join(','); + return `${typeOwner}/member:${member.kind ?? 'member'}:${member.name}${genericArity > 0 ? `\`${genericArity}` : ''}(${parameterTypes})`; +} + +function collectAttributePayloads(pkg: PackageJson): Map { + const result = new Map(); + for (const type of pkg.types ?? []) { + const typeOwner = `type:${type.fullName}`; + attributesForOwner(result, typeOwner, type.attributes); + for (const member of type.members ?? []) { + const memberOwner = memberOwnerKey(typeOwner, member); + attributesForOwner(result, memberOwner, member.attributes); + for (const [index, parameter] of (member.parameters ?? []).entries()) { + attributesForOwner( + result, + `${memberOwner}/parameter:${index}:${parameter.name}`, + parameter.attributes + ); + } + } + } + return result; +} + +function hasExportedApi(pkg: PackageJson): boolean { + const visit = (attributes: ApiAttribute[] | undefined): boolean => + (attributes ?? []).some((attribute) => /(?:^|\.)AspireExportAttribute$/.test(attribute.name)); + + return (pkg.types ?? []).some( + (type) => + visit(type.attributes) || + (type.members ?? []).some( + (member) => + visit(member.attributes) || + (member.parameters ?? []).some((parameter) => visit(parameter.attributes)) + ) + ); +} + +function splitTopLevel(value: string): string[] { + const result: string[] = []; + let depth = 0; + let start = 0; + for (let index = 0; index < value.length; index++) { + if (value[index] === '<') depth++; + if (value[index] === '>') depth--; + if (value[index] === ',' && depth === 0) { + result.push(value.slice(start, index).trim()); + start = index + 1; + } + } + const tail = value.slice(start).trim(); + if (tail) result.push(tail); + return result; +} + +function parseInterfaces(declarations: string): Map { + const header = /^export interface ([A-Za-z_][A-Za-z0-9_]*)/gm; + const matches = [...declarations.matchAll(header)]; + const result = new Map(); + + for (const [index, match] of matches.entries()) { + const lineStart = match.index ?? 0; + const lineEnd = declarations.indexOf('\n', lineStart); + const headerLine = declarations.slice( + lineStart, + lineEnd === -1 ? declarations.length : lineEnd + ); + let cursor = match[0].length; + if (headerLine[cursor] === '<') { + let depth = 0; + do { + if (headerLine[cursor] === '<') depth++; + if (headerLine[cursor] === '>') depth--; + cursor++; + } while (cursor < headerLine.length && depth > 0); + } + const remainder = headerLine.slice(cursor).trim(); + const extendsText = remainder.startsWith('extends ') + ? remainder.slice('extends '.length, remainder.lastIndexOf('{')).trim() + : ''; + const bodyStart = lineEnd === -1 ? declarations.length : lineEnd + 1; + const bodyEnd = matches[index + 1]?.index ?? declarations.length; + const previous = result.get(match[1]); + const parents = new Set(previous?.parents ?? []); + for (const parent of splitTopLevel(extendsText).map(shortTypeName)) { + parents.add(parent); + } + const properties = new Map(previous?.properties ?? []); + const body = declarations.slice(bodyStart, bodyEnd); + for (const property of body.matchAll( + /^\s{2}([A-Za-z_][A-Za-z0-9_]*)(\?)?:\s*(.+);$/gm + )) { + properties.set(property[1], { + optional: property[2] === '?', + type: property[3].trim(), + }); + } + + result.set(match[1], { + parents: [...parents], + properties, + }); + } + return result; +} + +function setDifference(left: Set, right: Set): string[] { + return [...left].filter((value) => !right.has(value)).sort(); +} + +function resolveBaseHierarchy( + module: TsModuleJson, + handle: HandleType, + packageByIdentity: Map> +): string[] { + if ((handle.baseTypeHierarchy?.length ?? 0) > 0) { + return handle.baseTypeHierarchy!; + } + + const pkg = packageByIdentity.get(identity(module.package))?.data; + const baseByFullName = new Map( + (pkg?.types ?? []) + .filter((type) => type.kind === 'class' && type.baseType) + .map((type) => [type.fullName, type.baseType!] as const) + ); + const hierarchy: string[] = []; + const seen = new Set([handle.fullName]); + let ancestor = baseByFullName.get(handle.fullName); + while (ancestor && !seen.has(ancestor)) { + hierarchy.push(ancestor); + seen.add(ancestor); + ancestor = baseByFullName.get(ancestor); + } + return hierarchy; +} + +export function validateGeneratedApiData(input: ValidationInput): ValidationResult { + const errors: string[] = []; + const catalogByName = new Map(); + for (const entry of input.catalog) { + if (catalogByName.has(entry.title)) { + errors.push(`Integration catalog contains duplicate package ${entry.title}.`); + } else { + catalogByName.set(entry.title, entry); + } + } + + const packageByIdentity = addUnique(input.packages, (pkg) => pkg.package, 'pkgs', errors); + const moduleByIdentity = addUnique(input.modules, (module) => module.package, 'ts-modules', errors); + + for (const entry of input.catalog) { + if (!isPackageOutputExpected(entry.title)) continue; + const key = `${entry.title}@${entry.version}`; + if (!packageByIdentity.has(key)) { + errors.push(`Missing C# API output for catalog package ${key}.`); + } + } + + for (const file of input.packages) { + const metadata = file.data.package; + const catalogEntry = catalogByName.get(metadata.name); + if (!catalogEntry) { + errors.push(`C# API output ${identity(metadata)} is not present in the integration catalog.`); + } else if (catalogEntry.version !== metadata.version) { + errors.push( + `Stale C# API output ${identity(metadata)}; catalog version is ${catalogEntry.version}.` + ); + } + if (!metadata.sourceRepository) { + errors.push(`C# API output ${identity(metadata)} is missing its source repository.`); + } + + if (file.baseline && identity(file.baseline.package) === identity(metadata)) { + const before = collectAttributePayloads(file.baseline); + const after = collectAttributePayloads(file.data); + for (const [attributeKey, payload] of before) { + const generatedPayload = after.get(attributeKey); + const lostConstructorArguments = + !generatedPayload || + generatedPayload.constructorArgumentCount < payload.constructorArgumentCount; + const lostNamedArguments = + !generatedPayload || + payload.namedArgumentNames.some( + (name) => !generatedPayload.namedArgumentNames.includes(name) + ); + if (lostConstructorArguments || lostNamedArguments) { + errors.push( + `Attribute payload changed or disappeared for ${identity(metadata)} at ${attributeKey}.` + ); + } + } + } + } + + for (const file of input.modules) { + 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.`); + } else if (catalogEntry.version !== metadata.version) { + errors.push( + `Stale TypeScript API output ${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.`); + } + + const matchingPackage = packageByIdentity.get(identity(metadata))?.data; + if (!matchingPackage) { + errors.push(`TypeScript API output ${identity(metadata)} has no exact C# API output.`); + continue; + } + if (metadata.sourceRepository !== matchingPackage.package.sourceRepository) { + errors.push( + `Source repository mismatch for ${identity(metadata)}: C# has ${matchingPackage.package.sourceRepository ?? '(missing)'}, TypeScript has ${metadata.sourceRepository ?? '(missing)'}.` + ); + } + if (metadata.sourceCommit !== matchingPackage.package.sourceCommit) { + errors.push( + `Source commit mismatch for ${identity(metadata)}: C# has ${matchingPackage.package.sourceCommit ?? '(missing)'}, TypeScript has ${metadata.sourceCommit ?? '(missing)'}.` + ); + } + } + + 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)}.`); + } + } + + const parsedInterfaces = parseInterfaces(input.declarations); + const selectedDtos = new Map(); + const selectedHandles = new Map(); + for (const file of [...input.modules].sort((left, right) => + left.fileName.localeCompare(right.fileName) + )) { + for (const dto of file.data.dtoTypes ?? []) { + if (!selectedDtos.has(dto.name)) selectedDtos.set(dto.name, dto); + } + for (const handle of file.data.handleTypes ?? []) { + if (!selectedHandles.has(handle.name)) { + selectedHandles.set(handle.name, { handle, module: file.data }); + } + } + } + + for (const dto of selectedDtos.values()) { + const declaration = parsedInterfaces.get(dto.name); + if (!declaration) { + errors.push(`Twoslash DTO ${dto.name} is missing its declaration.`); + continue; + } + for (const field of dto.fields ?? []) { + const propertyName = camelCase(field.name); + const property = declaration.properties.get(propertyName); + if (!field.isOptional) { + errors.push( + `TypeScript DTO ${dto.name}.${propertyName} is required, but the SDK emits every DTO field as optional.` + ); + } else if (!property) { + 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.` + ); + } + if ( + property && + normalizeTypeScriptType(property.type) !== normalizeTypeScriptType(field.type) + ) { + errors.push( + `Twoslash DTO ${dto.name}.${propertyName} type ${property.type} does not match ts-modules metadata ${field.type}.` + ); + } + } + } + + const knownHandles = new Set(selectedHandles.keys()); + for (const { handle, module } of selectedHandles.values()) { + const declaration = parsedInterfaces.get(handle.name); + if (!declaration) { + errors.push(`Twoslash handle ${handle.name} is missing its declaration.`); + continue; + } + const matchingPackage = packageByIdentity.get(identity(module.package))?.data; + const packageType = (matchingPackage?.types ?? []).find( + (type) => type.fullName === handle.fullName + ); + const generatedDirectBase = handle.baseTypeHierarchy?.[0]; + if (packageType?.baseType) { + if (!generatedDirectBase) { + errors.push( + `TypeScript handle ${handle.name} is missing base hierarchy metadata for C# base type ${packageType.baseType}.` + ); + } else if ( + normalizeClrType(generatedDirectBase) !== normalizeClrType(packageType.baseType) + ) { + errors.push( + `TypeScript handle ${handle.name} base type ${generatedDirectBase} does not match C# metadata ${packageType.baseType}.` + ); + } + } + const expectedParents = new Set( + (handle.implementedInterfaces ?? []) + .map(shortTypeName) + .filter( + (name) => + /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && + name !== handle.name + ) + ); + for (const ancestor of resolveBaseHierarchy(module, handle, packageByIdentity)) { + const name = shortTypeName(ancestor); + if (name !== handle.name && knownHandles.has(name)) expectedParents.add(name); + } + const actualParents = new Set(declaration.parents); + const missing = setDifference(expectedParents, actualParents); + const unexpected = setDifference(actualParents, expectedParents); + if (missing.length > 0 || unexpected.length > 0) { + errors.push( + `Twoslash handle ${handle.name} has incorrect inheritance (missing: ${missing.join(', ') || 'none'}; unexpected: ${unexpected.join(', ') || 'none'}).` + ); + } + } + + if (/options\?:\s*\{\s*options\?:/s.test(input.declarations)) { + errors.push('Twoslash declarations contain a nested single-DTO options wrapper.'); + } + + return { + errors, + checks: [ + `${catalogByName.size} catalog package identities reconciled`, + `${moduleByIdentity.size} TypeScript modules matched to C# provenance`, + `${selectedDtos.size} DTO shapes checked`, + `${selectedHandles.size} handle inheritance chains checked`, + 'attribute payload regressions checked against HEAD', + ], + }; +} + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const frontendDir = path.resolve(scriptDir, '..'); +const repoRoot = path.resolve(frontendDir, '..', '..'); +const dataDir = path.join(frontendDir, 'src', 'data'); +const canonicalPackageDir = path.join(dataDir, 'pkgs'); + +export interface GitCommandResult { + status: number | null; + stdout: string; + stderr: string; + error?: Error; +} + +export type GitCommandRunner = (arguments_: string[]) => GitCommandResult; + +function runGit(arguments_: string[]): GitCommandResult { + const result = spawnSync('git', arguments_, { + encoding: 'utf8', + maxBuffer: 128 * 1024 * 1024, + }); + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + error: result.error, + }; +} + +function assertGitSucceeded( + command: string, + relativePath: string, + result: GitCommandResult +): void { + if (result.status === 0) return; + + const detail = result.error?.message ?? result.stderr.trim() ?? ''; + throw new Error( + `Unable to read HEAD baseline for ${relativePath}: git ${command} failed${detail ? `: ${detail}` : '.'}` + ); +} + +export function loadJsonFromHead( + root: string, + relativePath: string, + git: GitCommandRunner = runGit +): T | undefined { + const treeArguments = ['-C', root, 'ls-tree', '--name-only', 'HEAD', '--', relativePath]; + const treeResult = git(treeArguments); + assertGitSucceeded('ls-tree', relativePath, treeResult); + if (treeResult.stdout.trim().length === 0) { + return undefined; + } + + const showResult = git(['-C', root, 'show', `HEAD:${relativePath}`]); + assertGitSucceeded('show', relativePath, showResult); + return JSON.parse(showResult.stdout) as T; +} + +function loadJsonFiles(directory: string, baselineDirectory?: string): GeneratedFile[] { + return fs + .readdirSync(directory) + .filter((fileName) => fileName.endsWith('.json')) + .sort() + .map((fileName) => { + const data = JSON.parse(fs.readFileSync(path.join(directory, fileName), 'utf8')) as T; + let baseline: T | undefined; + if (baselineDirectory) { + const relativePath = path + .relative(repoRoot, path.join(baselineDirectory, fileName)) + .replaceAll(path.sep, '/'); + baseline = loadJsonFromHead(repoRoot, relativePath); + } + return { fileName, data, baseline }; + }); +} + +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 declarationsFile = process.env.ASPIRE_API_TWOSLASH_FILE + ? path.resolve(process.env.ASPIRE_API_TWOSLASH_FILE) + : path.join(dataDir, 'twoslash', 'aspire.d.ts'); + const result = validateGeneratedApiData({ + catalog: JSON.parse( + fs.readFileSync(path.join(dataDir, 'aspire-integrations.json'), 'utf8') + ) as CatalogEntry[], + packages: loadJsonFiles(packageDir, canonicalPackageDir), + modules: loadJsonFiles(moduleDir), + declarations: fs.readFileSync(declarationsFile, 'utf8'), + }); + + if (result.errors.length > 0) { + console.error(`Generated API validation failed with ${result.errors.length} error(s):`); + for (const error of result.errors) console.error(` - ${error}`); + process.exitCode = 1; + return; + } + + console.log('Generated API semantic validation passed:'); + for (const check of result.checks) console.log(` - ${check}`); +} + +const isMainModule = process.argv[1] + ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) + : false; + +if (isMainModule) main(); diff --git a/src/frontend/src/components/api-reference/MemberCard.astro b/src/frontend/src/components/api-reference/MemberCard.astro index ab100ec76..1ca934d3d 100644 --- a/src/frontend/src/components/api-reference/MemberCard.astro +++ b/src/frontend/src/components/api-reference/MemberCard.astro @@ -31,6 +31,8 @@ interface Props { parentType?: ParentTypeInfo; sourceBaseUrl?: string; diagnosticSlugs?: Set; + anchorAliases?: string[]; + exactAnchor?: string; } const { @@ -40,6 +42,8 @@ const { parentType, sourceBaseUrl, diagnosticSlugs, + anchorAliases = [], + exactAnchor, } = Astro.props; const base = import.meta.env.BASE_URL.replace(/\/$/, ''); @@ -95,7 +99,12 @@ const returnLink = member.returnType const rawDisplayName = memberDisplayName(member); const displayName = member.name === '.ctor' ? rawDisplayName.replace('.ctor', 'Constructor') : rawDisplayName; -const anchorSlug = memberSlug(member); +const anchorSlug = exactAnchor ?? memberSlug(member); +const aliases = anchorAliases.filter((alias) => alias !== anchorSlug); +const primaryAnchorSlug = aliases[0] ?? anchorSlug; +const hiddenAnchorSlugs = primaryAnchorSlug === anchorSlug + ? [] + : [anchorSlug, ...aliases.slice(1)]; const isCtor = member.name === '.ctor'; const returnShort = member.returnType ? shortTypeName(member.returnType) : member.returnType; const returnColorClass = returnShort ? `mc-param-type-${typeColorIndex(returnShort)}` : ''; @@ -152,7 +161,10 @@ if (formattedSigRaw && (member.kind === 'property' || member.kind === 'indexer') } --- -
+
+ {hiddenAnchorSlugs.map((slug) => ( +