From 2b4b86a0117c24dc0bd926e20c0eaf77e099284c Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:41:56 -0500 Subject: [PATCH 1/2] fix: normalize Aspire terminology in generated API reference data The Forbidden Words check fails on the bot's integration-data PRs (e.g. microsoft/aspire.dev#1411) because the deprecated terms live in ingestion paths that #1397's normalizer never covered: - src/data/pkgs/*.json (C# API docs; kind:"text" prose nodes) - src/data/ts-modules/*.json (TS API docs; description/returns/remarks) - sample appHostCode (code comments; previously excluded) #1397 only normalized sample title/description/readme[Raw] and the integration package description, so regenerated API reference prose and sample AppHost comments kept reintroducing the deprecated terms verbatim from upstream XML/JSDoc docs. This extends normalization to those paths, reusing the single source of truth in aspire-terminology.ts (kept in sync with .github/forbidden-words.json): - aspire-terminology.ts: add normalizeAspireTerminologyInCode(), which rewrites deprecated terms in code comments only, preserving strings, char/template literals, and executable code so samples still compile. - normalize-generated-api-data.ts: format-preserving raw-text pass over the generated API JSON. Normalizes prose only (kind:"text" nodes and description/returns/remarks), leaving code/cref/langword nodes and all .NET-escaped bytes (CRLF, astral \u escapes) byte-for-byte identical. - update-ts-api.ts: normalize ts-modules before the twoslash bundle is derived, so aspire.d.ts hover tooltips are normalized too. - update-integration-data.ps1: normalize pkgs after the C# API generator. - update-samples.ts: normalize appHostCode comments. The committed data is already clean (a plain grep over-counts the plural "app hosts", which the boundary-anchored rule never matches), so this is a scripts+tests change with no data churn. Adds unit tests for both new normalizers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d775be1b-bee3-432e-bbfc-7a4861f3bc05 --- src/frontend/package.json | 3 +- src/frontend/scripts/aspire-terminology.ts | 44 +++++ .../scripts/normalize-generated-api-data.ts | 169 ++++++++++++++++++ .../scripts/update-integration-data.ps1 | 18 ++ src/frontend/scripts/update-samples.ts | 11 +- src/frontend/scripts/update-ts-api.ts | 11 ++ ...ormalize-generated-api-data.vitest.test.ts | 129 +++++++++++++ .../tests/unit/update-samples.vitest.test.ts | 62 ++++++- 8 files changed, 441 insertions(+), 6 deletions(-) create mode 100644 src/frontend/scripts/normalize-generated-api-data.ts create mode 100644 src/frontend/tests/unit/normalize-generated-api-data.vitest.test.ts diff --git a/src/frontend/package.json b/src/frontend/package.json index cf49a837e..6e815c7a0 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -54,7 +54,8 @@ "update:integrations": "tsx ./scripts/update-integrations.ts", "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" + "update:samples": "tsx ./scripts/update-samples.ts", + "normalize:api-data": "tsx ./scripts/normalize-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 61764d095..df239fe77 100644 --- a/src/frontend/scripts/aspire-terminology.ts +++ b/src/frontend/scripts/aspire-terminology.ts @@ -21,6 +21,16 @@ const markdownOpeners = String.raw`[*\[_]*`; // preserving code exactly matters. const codeRegion = /```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`/g; +// Raw sample source (`appHostCode`) has no Markdown fences to protect, so +// `normalizeProse`'s code skipping doesn't apply. This tokenizer instead splits +// C#/TypeScript source into, in priority order at each position: block comments, +// line comments, and string/char/template literals. Only the comment tokens are +// rewritten (see `normalizeAspireTerminologyInCode`); every literal — and all +// executable code between tokens — is copied through verbatim so identifiers, +// string values, and CLI command samples stay intact. +const codeCommentToken = + /(\/\*[\s\S]*?\*\/)|(\/\/[^\n]*)|(@?\$?"(?:[^"\\]|\\.|"")*")|(`(?:[^`\\]|\\.)*`)|('(?:[^'\\]|\\.)*')/g; + interface TerminologyRule { /** * Case-insensitive regex source matching the deprecated term core, without @@ -73,6 +83,40 @@ export function normalizeAspireTerminology( return normalizeProse(text); } +// Normalize terminology inside C#/TypeScript source by rewriting **code comments +// only**. Comments are ignored by the compiler, so this can never corrupt +// compilable code, yet it still fixes the deprecated terms that render in a +// sample's code block (and would otherwise trip the forbidden-words check). +// Strings, char/template literals, and executable code are preserved exactly. +export function normalizeAspireTerminologyInCode(code: string): string; +export function normalizeAspireTerminologyInCode( + code: string | null | undefined +): string | null | undefined; +export function normalizeAspireTerminologyInCode( + code: string | null | undefined +): string | null | undefined { + if (code == null) { + return code; + } + + let result = ''; + let lastIndex = 0; + + for (const match of code.matchAll(codeCommentToken)) { + const token = match[0]; + // Executable code between tokens is copied verbatim: the deprecated phrases + // all contain a space, so they can never be valid identifiers there anyway. + result += code.slice(lastIndex, match.index); + // Groups 1 (block) and 2 (line) are comments; the remaining groups are + // string-like literals that must stay byte-for-byte identical. + const isComment = match[1] !== undefined || match[2] !== undefined; + result += isComment ? applyRules(token) : token; + lastIndex = (match.index ?? 0) + token.length; + } + + return result + code.slice(lastIndex); +} + // Apply every terminology rule to prose only, copying fenced and inline code // regions through untouched so sample commands stay runnable. function normalizeProse(text: string): string { diff --git a/src/frontend/scripts/normalize-generated-api-data.ts b/src/frontend/scripts/normalize-generated-api-data.ts new file mode 100644 index 000000000..b7afcc8ed --- /dev/null +++ b/src/frontend/scripts/normalize-generated-api-data.ts @@ -0,0 +1,169 @@ +/** + * 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`). + * + * 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 + * 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 + * `aspire-terminology.ts` (kept in sync with `.github/forbidden-words.json`). + * + * It transforms the raw file text (never a JSON re-serialization) so that files + * without deprecated terms stay byte-for-byte identical to the generators' + * output — the .NET serializer escapes some characters (astral code points, + * U+00A0, ...) that `JSON.stringify` would emit raw, and a round-trip would + * churn every such line. The deprecated phrases are pure ASCII with no + * JSON-escaped characters, so applying the terminology rules directly to the + * escaped string content yields exactly the correctly-escaped normalized value. + * + * 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 + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +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'); + +// Per-line matcher (multiline) for EITHER a documentation node's `kind` marker +// OR a prose string field. Groups: +// 1 = kind value (kind-marker alternative) +// 2 = indent + opening quote of the key +// 3 = prose key name +// 4 = closing key quote, colon, opening value quote +// 5 = escaped value content +// 6 = closing value quote +// JSON strings can't contain literal newlines, so every value sits on one line. +// 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 +// summaries; the C# doc arrays open with `[` and are skipped, their inner text +// nodes handled by the `text` rule). +const nodeLine = + /^[ \t]*"kind"[ \t]*:[ \t]*"([^"]*)"|^([ \t]*")(text|description|returns|remarks)("[ \t]*:[ \t]*")((?:[^"\\]|\\.)*)(")/gm; + +/** + * Rewrite deprecated Aspire terminology in the prose fields of a generated API + * JSON document, preserving every byte outside the changed phrases. + */ +export function normalizeApiJsonText(raw: string): { text: string; changes: number } { + let result = ''; + let lastIndex = 0; + let lastKind: string | null = null; + let changes = 0; + + for (const match of raw.matchAll(nodeLine)) { + const start = match.index ?? 0; + result += raw.slice(lastIndex, start); + lastIndex = start + match[0].length; + + if (match[1] !== undefined) { + // `kind` marker — remember it so the next `text` field can be classified. + lastKind = match[1]; + result += match[0]; + continue; + } + + const [, , open, key, separator, value, close] = match; + const isProse = key !== 'text' || lastKind === 'text'; + if (!isProse) { + result += match[0]; + continue; + } + + const normalized = normalizeAspireTerminology(value); + if (normalized !== value) { + changes++; + } + result += `${open}${key}${separator}${normalized}${close}`; + } + + return { text: result + raw.slice(lastIndex), changes }; +} + +/** Normalize a single API JSON file in place; returns the number of changes. */ +export function normalizeApiFile(filePath: string): number { + const raw = fs.readFileSync(filePath, 'utf8'); + const { text, changes } = normalizeApiJsonText(raw); + if (changes > 0) { + fs.writeFileSync(filePath, text); + } + return changes; +} + +/** Normalize every `*.json` file in a directory; returns per-run counts. */ +export function normalizeApiDir(dir: string): { + files: number; + changes: number; + changedFiles: string[]; +} { + if (!fs.existsSync(dir)) { + return { files: 0, changes: 0, changedFiles: [] }; + } + + const changedFiles: string[] = []; + let files = 0; + let changes = 0; + + for (const name of fs.readdirSync(dir).sort()) { + if (!name.endsWith('.json')) { + continue; + } + files++; + const fileChanges = normalizeApiFile(path.join(dir, name)); + if (fileChanges > 0) { + changes += fileChanges; + changedFiles.push(name); + } + } + + return { files, changes, changedFiles }; +} + +function main(): void { + const args = process.argv.slice(2); + 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 }); + } + if (!explicit || args.includes('--ts-modules')) { + targets.push({ label: 'ts-modules', dir: TS_MODULES_DIR }); + } + + let total = 0; + for (const { label, dir } of targets) { + const { files, changes, changedFiles } = normalizeApiDir(dir); + total += changes; + console.log( + ` ${label}: normalized ${changes} occurrence(s) across ${changedFiles.length}/${files} file(s)` + ); + for (const file of changedFiles) { + console.log(` • ${file}`); + } + } + + console.log( + total > 0 + ? `✅ Aspire terminology normalized (${total} occurrence(s)).` + : '✅ Aspire terminology already normalized.' + ); +} + +const isMainModule = process.argv[1] + ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) + : false; + +if (isMainModule) { + main(); +} diff --git a/src/frontend/scripts/update-integration-data.ps1 b/src/frontend/scripts/update-integration-data.ps1 index 15445f7aa..86b40042e 100644 --- a/src/frontend/scripts/update-integration-data.ps1 +++ b/src/frontend/scripts/update-integration-data.ps1 @@ -248,6 +248,24 @@ if ($versionsChanged -and -not $SkipRegen) { $pkgSummary = if ($pkgDone) { ($pkgDone -replace '.*Done!\s+', '').Trim() } else { 'summary unavailable' } Write-Host " C# API: $pkgSummary" + # 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 + # twoslash bundle), so only pkgs/ is handled here. + Write-Host "→ pnpm normalize:api-data --pkgs (Aspire terminology → pkgs/)" -ForegroundColor Cyan + Push-Location $FrontendDir + try { + $pkgNormLog = & pnpm run normalize:api-data -- --pkgs 2>&1 | Tee-Object -Variable pkgNormTeed | Out-String + $pkgNormExit = $LASTEXITCODE + } + finally { + 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 + } + # 3b. TS API JSON (+ 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. diff --git a/src/frontend/scripts/update-samples.ts b/src/frontend/scripts/update-samples.ts index b3018dac6..8e3c224f7 100644 --- a/src/frontend/scripts/update-samples.ts +++ b/src/frontend/scripts/update-samples.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from 'url'; import fetch from 'node-fetch'; -import { normalizeAspireTerminology } from './aspire-terminology'; +import { normalizeAspireTerminology, normalizeAspireTerminologyInCode } from './aspire-terminology'; const REPO = 'microsoft/aspire-samples'; const DEFAULT_BRANCH = 'main'; @@ -114,9 +114,10 @@ export interface SampleResult { appHostCode: string | null; } -// `appHostCode` is intentionally excluded: it is rendered as C# (not prose), so -// rewriting terminology inside identifiers, string literals, or comments would -// risk corrupting compilable code without fixing any user-facing prose. +// `appHostCode` is rendered as C# (not prose), so only its **comments** are +// normalized — identifiers, string literals, and CLI commands stay byte-for-byte +// identical, keeping the code compilable while still fixing deprecated terms that +// would otherwise render in the sample's code block and trip forbidden-words CI. export function normalizeSampleTerminology(sample: SampleResult): SampleResult { return { ...sample, @@ -125,6 +126,8 @@ export function normalizeSampleTerminology(sample: SampleResult): SampleResult { sample.description === null ? null : normalizeAspireTerminology(sample.description), readme: normalizeAspireTerminology(sample.readme), readmeRaw: normalizeAspireTerminology(sample.readmeRaw), + appHostCode: + sample.appHostCode === null ? null : normalizeAspireTerminologyInCode(sample.appHostCode), }; } diff --git a/src/frontend/scripts/update-ts-api.ts b/src/frontend/scripts/update-ts-api.ts index 8f6f522b2..4b539b183 100644 --- a/src/frontend/scripts/update-ts-api.ts +++ b/src/frontend/scripts/update-ts-api.ts @@ -23,6 +23,8 @@ import { existsSync } from 'fs'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; +import { normalizeApiDir, TS_MODULES_DIR } from './normalize-generated-api-data'; + const __dirname = dirname(fileURLToPath(import.meta.url)); const SCRIPT_PATH = resolve( __dirname, @@ -83,6 +85,15 @@ function main(): void { process.exit(1); } + // Enforce Aspire terminology in the freshly generated ts-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...'); + const { changes: tsModuleChanges } = normalizeApiDir(TS_MODULES_DIR); + console.log(`✅ Normalized ${tsModuleChanges} occurrence(s) in ts-modules JSON.`); + // Refresh the twoslash .d.ts bundle so docs hover tooltips stay in sync // with the regenerated ts-modules JSON. The bundle is source-controlled // at src/data/twoslash/aspire.d.ts — commit the diff alongside the JSON. 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 new file mode 100644 index 000000000..dac576611 --- /dev/null +++ b/src/frontend/tests/unit/normalize-generated-api-data.vitest.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from 'vitest'; + +import { normalizeApiJsonText } from '../../scripts/normalize-generated-api-data'; + +// Build deprecated terms from tokens so this file never contains a literal +// forbidden phrase that the Forbidden Words check would flag. +const APP_HOST = ['app', 'host'].join(' '); +const NET_ASPIRE = ['.NET', 'Aspire'].join(' '); +const DOTNET_ASPIRE = ['dotnet', 'aspire'].join(' '); + +/** Join lines with CRLF to mirror the .NET/AtsJson generators' output. */ +const crlf = (lines: string[]): string => lines.join('\r\n'); + +describe('normalizeApiJsonText — C# API (pkgs) shape', () => { + const doc = crlf([ + '{', + ' "documentation": {', + ' "summary": [', + ' {', + ' "kind": "text",', + ` "text": " relative to the ${APP_HOST} project directory. "`, + ' },', + ' {', + ' "kind": "text",', + ` "text": "Not available in polyglot ${APP_HOST}s."`, + ' },', + ' {', + ' "kind": "code",', + ` "text": "${DOTNET_ASPIRE} run"`, + ' },', + ' {', + ' "kind": "cref",', + ' "cref": "T:Aspire.Hosting.Foo",', + ` "text": "${APP_HOST}"`, + ' }', + ' ]', + ' }', + '}', + ]); + + test('normalizes prose inside kind:"text" nodes', () => { + const { text } = normalizeApiJsonText(doc); + expect(text).toContain('"text": " relative to the AppHost project directory. "'); + }); + + test('leaves plural "app hosts" untouched (matches the forbidden-words boundary)', () => { + const { text } = normalizeApiJsonText(doc); + expect(text).toContain(`"text": "Not available in polyglot ${APP_HOST}s."`); + }); + + test('never rewrites the text of code-bearing nodes (code, cref)', () => { + const { text } = normalizeApiJsonText(doc); + expect(text).toContain(`"text": "${DOTNET_ASPIRE} run"`); + expect(text).toContain(`"cref": "T:Aspire.Hosting.Foo"`); + // The cref node's display text is the only deprecated form left intact. + expect(text).toContain(`"text": "${APP_HOST}"`); + }); + + test('counts exactly the changed occurrences', () => { + expect(normalizeApiJsonText(doc).changes).toBe(1); + }); +}); + +describe('normalizeApiJsonText — TypeScript API (ts-modules) shape', () => { + const doc = crlf([ + '{', + ` "description": "Adds a first-class ${NET_ASPIRE} resource.",`, + ` "returns": "The ${NET_ASPIRE} resource builder.",`, + ` "remarks": "Only for a ${DOTNET_ASPIRE} app.",`, + ' "signature": "addContainer(name: string): ContainerResource",', + ' "returnType": "ContainerResource",', + ' "targetTypeId": "Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder"', + '}', + ]); + + test('normalizes description, returns, and remarks prose', () => { + const { text, changes } = normalizeApiJsonText(doc); + expect(text).toContain('"description": "Adds a first-class Aspire resource."'); + expect(text).toContain('"returns": "The Aspire resource builder."'); + expect(text).toContain('"remarks": "Only for an Aspire app."'); + expect(changes).toBe(3); + }); + + test('never rewrites code identifiers (signature, returnType, ids)', () => { + const { text } = normalizeApiJsonText(doc); + expect(text).toContain('"signature": "addContainer(name: string): ContainerResource"'); + expect(text).toContain('"returnType": "ContainerResource"'); + expect(text).toContain( + '"targetTypeId": "Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder"' + ); + }); +}); + +describe('normalizeApiJsonText — byte preservation', () => { + test('preserves CRLF line endings and escaped astral characters', () => { + const doc = crlf([ + '{', + ' "kind": "text",', + ` "text": "\\uD83D\\uDCE6 uses the ${APP_HOST} directory"`, + '}', + ]); + const { text } = normalizeApiJsonText(doc); + // astral escape untouched, CRLF intact, only the phrase changed. + expect(text).toBe( + crlf(['{', ' "kind": "text",', ' "text": "\\uD83D\\uDCE6 uses the AppHost directory"', '}']) + ); + expect(text.includes('\r\n')).toBe(true); + }); + + test('returns the input byte-for-byte when there is nothing to normalize', () => { + const clean = crlf([ + '{', + ' "kind": "text",', + ' "text": "Configures the AppHost with an Aspire resource."', + '}', + ]); + const { text, changes } = normalizeApiJsonText(clean); + expect(changes).toBe(0); + expect(text).toBe(clean); + }); + + test('is idempotent', () => { + const doc = crlf(['{', ' "kind": "text",', ` "text": "the ${APP_HOST} runs"`, '}']); + const once = normalizeApiJsonText(doc).text; + const twice = normalizeApiJsonText(once); + expect(twice.changes).toBe(0); + expect(twice.text).toBe(once); + }); +}); diff --git a/src/frontend/tests/unit/update-samples.vitest.test.ts b/src/frontend/tests/unit/update-samples.vitest.test.ts index ecab6d314..fe9601e37 100644 --- a/src/frontend/tests/unit/update-samples.vitest.test.ts +++ b/src/frontend/tests/unit/update-samples.vitest.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from 'vitest'; import samples from '@data/samples.json'; -import { normalizeAspireTerminology } from '../../scripts/aspire-terminology'; +import { + normalizeAspireTerminology, + normalizeAspireTerminologyInCode, +} from '../../scripts/aspire-terminology'; import { type SampleResult, normalizeSampleTerminology } from '../../scripts/update-samples'; const legacyAspireName = ['.NET', 'Aspire'].join(' '); @@ -80,6 +83,62 @@ describe('Aspire terminology normalization', () => { }); }); +describe('Aspire terminology normalization in code', () => { + test.each([ + [ + 'a line comment', + `// Keep the container running between ${legacyAppHostName} sessions.`, + '// Keep the container running between AppHost sessions.', + ], + [ + 'a trailing comment after code', + `builder.Build().Run(); // starts the ${legacyAppHostName}`, + 'builder.Build().Run(); // starts the AppHost', + ], + [ + 'a block comment with an article', + `/* A ${legacyAspireName} ${legacyAppHostName}. */`, + '/* An Aspire AppHost. */', + ], + ])('normalizes deprecated terms inside %s', (_scenario, input, expected) => { + expect(normalizeAspireTerminologyInCode(input)).toBe(expected); + }); + + test.each([ + ['a double-quoted string literal', `var cmd = "${legacyDotnetAspireName} run";`], + ['a C# verbatim string', `var path = @"C:\\${legacyAppHostName}\\bin";`], + ['a TS template literal', `const label = \`the ${legacyAppHostName} process\`;`], + ['a bare identifier expression', 'var appHost = builder.Build();'], + ])('preserves %s so the code still compiles', (_scenario, input) => { + expect(normalizeAspireTerminologyInCode(input)).toBe(input); + }); + + test('rewrites comments while preserving an adjacent string literal', () => { + const input = + `// Launch the ${legacyAppHostName}.\n` + + `builder.AddExecutable("cli", "${legacyDotnetAspireName}");`; + expect(normalizeAspireTerminologyInCode(input)).toBe( + '// Launch the AppHost.\n' + `builder.AddExecutable("cli", "${legacyDotnetAspireName}");` + ); + }); + + test('leaves plural "app hosts" untouched to match the forbidden-words boundary', () => { + const input = `// Works across polyglot ${legacyAppHostName}s.`; + expect(normalizeAspireTerminologyInCode(input)).toBe(input); + }); + + test('is idempotent', () => { + const input = `// A ${legacyAspireName} ${legacyAppHostName}; run \`${legacyDotnetAspireName} run\`.`; + const once = normalizeAspireTerminologyInCode(input); + expect(normalizeAspireTerminologyInCode(once)).toBe(once); + }); + + test('passes null and undefined through unchanged', () => { + expect(normalizeAspireTerminologyInCode(null)).toBeNull(); + expect(normalizeAspireTerminologyInCode(undefined)).toBeUndefined(); + }); +}); + describe('sample terminology normalization', () => { test('normalizes every generated text field', () => { const sample: SampleResult = { @@ -108,6 +167,7 @@ describe('sample terminology normalization', () => { readme: '# Aspire sample\n\nRun the AppHost.', readmeRaw: '# Aspire sample\n\n' + 'Run the AppHost.\n\n' + '```bash\n' + 'dotnet aspire run\n' + '```\n', + appHostCode: '// Keep the container running between AppHost sessions.', }); }); From d5818ab096353a67dd0d00a40997afb551a5c6cb Mon Sep 17 00:00:00 2001 From: David Pine Date: Fri, 31 Jul 2026 08:27:20 -0500 Subject: [PATCH 2/2] fix: preserve commands in code comments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/frontend/scripts/aspire-terminology.ts | 2 +- .../tests/unit/update-samples.vitest.test.ts | 24 +++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/frontend/scripts/aspire-terminology.ts b/src/frontend/scripts/aspire-terminology.ts index df239fe77..b0b09ef00 100644 --- a/src/frontend/scripts/aspire-terminology.ts +++ b/src/frontend/scripts/aspire-terminology.ts @@ -110,7 +110,7 @@ export function normalizeAspireTerminologyInCode( // Groups 1 (block) and 2 (line) are comments; the remaining groups are // string-like literals that must stay byte-for-byte identical. const isComment = match[1] !== undefined || match[2] !== undefined; - result += isComment ? applyRules(token) : token; + result += isComment ? normalizeProse(token) : token; lastIndex = (match.index ?? 0) + token.length; } diff --git a/src/frontend/tests/unit/update-samples.vitest.test.ts b/src/frontend/tests/unit/update-samples.vitest.test.ts index fe9601e37..f51dfa803 100644 --- a/src/frontend/tests/unit/update-samples.vitest.test.ts +++ b/src/frontend/tests/unit/update-samples.vitest.test.ts @@ -127,10 +127,30 @@ describe('Aspire terminology normalization in code', () => { expect(normalizeAspireTerminologyInCode(input)).toBe(input); }); - test('is idempotent', () => { + test('preserves inline code and is idempotent', () => { const input = `// A ${legacyAspireName} ${legacyAppHostName}; run \`${legacyDotnetAspireName} run\`.`; + const expected = `// An Aspire AppHost; run \`${legacyDotnetAspireName} run\`.`; const once = normalizeAspireTerminologyInCode(input); - expect(normalizeAspireTerminologyInCode(once)).toBe(once); + expect(once).toBe(expected); + expect(normalizeAspireTerminologyInCode(once)).toBe(expected); + }); + + test('preserves fenced code inside block comments', () => { + const input = + `/* Configure the ${legacyAppHostName} with this command:\n` + + '```bash\n' + + `${legacyDotnetAspireName} run\n` + + '```\n' + + `Then start the ${legacyAppHostName}.\n` + + '*/'; + expect(normalizeAspireTerminologyInCode(input)).toBe( + '/* Configure the AppHost with this command:\n' + + '```bash\n' + + `${legacyDotnetAspireName} run\n` + + '```\n' + + 'Then start the AppHost.\n' + + '*/' + ); }); test('passes null and undefined through unchanged', () => {