From ceff4366b513255913d31f2aa33ad57bab1f8f50 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:56:04 -0500 Subject: [PATCH 1/6] fix: Normalize terminology in sample updates Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: febf329c-97d8-41aa-bd42-43273bc57fe7 --- src/frontend/scripts/aspire-terminology.ts | 8 +++ src/frontend/scripts/update-integrations.ts | 6 +- src/frontend/scripts/update-samples.ts | 36 ++++++++--- src/frontend/src/utils/samples.ts | 6 +- .../tests/unit/update-samples.vitest.test.ts | 62 +++++++++++++++++++ 5 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 src/frontend/scripts/aspire-terminology.ts create mode 100644 src/frontend/tests/unit/update-samples.vitest.test.ts diff --git a/src/frontend/scripts/aspire-terminology.ts b/src/frontend/scripts/aspire-terminology.ts new file mode 100644 index 000000000..be90c53d6 --- /dev/null +++ b/src/frontend/scripts/aspire-terminology.ts @@ -0,0 +1,8 @@ +export function normalizeAspireTerminology(text: string): string { + return text + .replace(/\b([Aa]) \.NET Aspire\b/gi, (_match, article: string) => + article === 'A' ? 'An Aspire' : 'an Aspire' + ) + .replace(/\.NET Aspire/gi, 'Aspire') + .replace(/\bapp host\b/gi, 'AppHost'); +} diff --git a/src/frontend/scripts/update-integrations.ts b/src/frontend/scripts/update-integrations.ts index 06787b78a..56fc3b533 100644 --- a/src/frontend/scripts/update-integrations.ts +++ b/src/frontend/scripts/update-integrations.ts @@ -8,6 +8,7 @@ import { isOfficialAspirePackage, resolveOfficialAspirePackageSource, } from './aspire-package-source'; +import { normalizeAspireTerminology } from './aspire-terminology'; const OFFICIAL_NUGET_ORG_QUERIES = ['owner:aspire', 'Aspire.Hosting.']; const OFFICIAL_RELEASE_FEED_QUERIES = ['Aspire.']; @@ -285,9 +286,8 @@ function filterAndTransform(pkgs: PackageRecord[]): IntegrationOutput[] { }) .map((pkg) => ({ title: pkg.id, - description: pkg.description - ?.replace(/\bA \.NET Aspire\b/gi, 'An Aspire') - .replace(/\.NET Aspire/gi, 'Aspire'), + description: + pkg.description === undefined ? undefined : normalizeAspireTerminology(pkg.description), icon: resolveIconUrl(pkg), href: `https://www.nuget.org/packages/${pkg.id}`, tags: pkg.tags?.map((tag) => tag.toLowerCase()) ?? [], diff --git a/src/frontend/scripts/update-samples.ts b/src/frontend/scripts/update-samples.ts index c0b24a2c0..25bfa8838 100644 --- a/src/frontend/scripts/update-samples.ts +++ b/src/frontend/scripts/update-samples.ts @@ -1,9 +1,12 @@ import fs from 'fs'; import path from 'path'; import { pipeline } from 'stream/promises'; +import { fileURLToPath } from 'url'; import fetch from 'node-fetch'; +import { normalizeAspireTerminology } from './aspire-terminology'; + const REPO = 'microsoft/aspire-samples'; const DEFAULT_BRANCH = 'main'; // `BRANCH` controls which ref of `microsoft/aspire-samples` is fetched (README, @@ -97,7 +100,7 @@ interface GitTreeResponse { truncated?: boolean; } -interface SampleResult { +export interface SampleResult { name: string; title: string; description: string | null; @@ -111,6 +114,19 @@ interface SampleResult { appHostCode: string | null; } +export function normalizeSampleTerminology(sample: SampleResult): SampleResult { + return { + ...sample, + title: normalizeAspireTerminology(sample.title), + description: + sample.description === null ? null : normalizeAspireTerminology(sample.description), + readme: normalizeAspireTerminology(sample.readme), + readmeRaw: normalizeAspireTerminology(sample.readmeRaw), + appHostCode: + sample.appHostCode === null ? null : normalizeAspireTerminology(sample.appHostCode), + }; +} + type AppHostKind = 'typescript' | 'csproj' | 'file-based'; interface AppHostInfo { @@ -531,7 +547,7 @@ async function processSample( const thumbnail = extractThumbnail(name, readme); const href = `${TREE_BASE}/${SAMPLES_DIR}/${name}`; - return { + return normalizeSampleTerminology({ name, title, description, @@ -543,7 +559,7 @@ async function processSample( appHost: appHostInfo?.kind ?? null, appHostPath: appHostInfo?.entryPath ?? null, appHostCode, - }; + }); } async function main(): Promise { @@ -577,7 +593,13 @@ async function main(): Promise { console.log(`\nāœ… Saved ${results.length} samples to ${OUTPUT_PATH}`); } -main().catch((error: unknown) => { - console.error('āŒ Error:', getErrorMessage(error)); - process.exit(1); -}); +const isMainModule = process.argv[1] + ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) + : false; + +if (isMainModule) { + void main().catch((error: unknown) => { + console.error('āŒ Error:', getErrorMessage(error)); + process.exitCode = 1; + }); +} diff --git a/src/frontend/src/utils/samples.ts b/src/frontend/src/utils/samples.ts index f83f731f0..ff27849a7 100644 --- a/src/frontend/src/utils/samples.ts +++ b/src/frontend/src/utils/samples.ts @@ -188,9 +188,9 @@ interface BuildSampleMarkdownOptions { * page-actions plugin's "Copy Markdown" and "View Markdown" actions return a * portable, LLM-friendly document. * - * The base content is the original upstream README (readmeRaw); relative image - * paths are rewritten to absolute GitHub raw URLs so they resolve when the - * markdown is opened in a browser or pasted into another tool. + * The base content is the terminology-normalized upstream README (readmeRaw); + * relative image paths are rewritten to absolute GitHub raw URLs so they + * resolve when the markdown is opened in a browser or pasted into another tool. */ export function buildSampleMarkdown(sample: Sample, options: BuildSampleMarkdownOptions): string { const body = rewriteSampleImageUrls(sample.readmeRaw ?? sample.readme, sample.name); diff --git a/src/frontend/tests/unit/update-samples.vitest.test.ts b/src/frontend/tests/unit/update-samples.vitest.test.ts new file mode 100644 index 000000000..48b459e3f --- /dev/null +++ b/src/frontend/tests/unit/update-samples.vitest.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'vitest'; + +import samples from '@data/samples.json'; + +import { type SampleResult, normalizeSampleTerminology } from '../../scripts/update-samples'; + +describe('sample terminology normalization', () => { + test('normalizes every generated text field', () => { + const sample: SampleResult = { + name: 'terminology-sample', + title: '.NET Aspire app host sample', + description: 'A .NET Aspire App Host project.', + href: 'https://github.com/microsoft/aspire-samples/tree/main/samples/terminology-sample', + readme: '# .NET Aspire sample\n\nRun the app host.', + readmeRaw: '# .NET Aspire sample\n\nRun the APP HOST.', + tags: ['csharp'], + thumbnail: null, + appHost: 'csproj', + appHostPath: 'Terminology.AppHost/AppHost.cs', + appHostCode: '// Keep the container running between app host sessions.', + }; + + expect(normalizeSampleTerminology(sample)).toEqual({ + ...sample, + title: 'Aspire AppHost sample', + description: 'An Aspire AppHost project.', + readme: '# Aspire sample\n\nRun the AppHost.', + readmeRaw: '# Aspire sample\n\nRun the AppHost.', + appHostCode: '// Keep the container running between AppHost sessions.', + }); + }); + + test('does not rewrite related words that are not deprecated terms', () => { + const sample: SampleResult = { + name: 'hosting-sample', + title: 'App hosting sample', + description: null, + href: 'https://github.com/microsoft/aspire-samples/tree/main/samples/hosting-sample', + readme: 'This sample demonstrates app hosting.', + readmeRaw: 'This sample demonstrates app hosting.', + tags: [], + thumbnail: null, + appHost: null, + appHostPath: null, + appHostCode: null, + }; + + expect(normalizeSampleTerminology(sample)).toEqual(sample); + }); + + test('keeps generated sample data free of deprecated terminology', () => { + const textFields = ['title', 'description', 'readme', 'readmeRaw', 'appHostCode'] as const; + const deprecatedTerminology = /\.NET Aspire|\bapp host\b/i; + const violations = samples.flatMap((sample) => + textFields + .filter((field) => deprecatedTerminology.test(sample[field] ?? '')) + .map((field) => `${sample.name}.${field}`) + ); + + expect(violations).toEqual([]); + }); +}); From ec1ec43481ddfce284d53cddf6c65859beb613c5 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:32:06 -0500 Subject: [PATCH 2/6] test: Preserve spacing in terminology updates Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: febf329c-97d8-41aa-bd42-43273bc57fe7 --- src/frontend/scripts/aspire-terminology.ts | 17 +++++++-- .../tests/unit/update-samples.vitest.test.ts | 35 +++++++++++++++---- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/frontend/scripts/aspire-terminology.ts b/src/frontend/scripts/aspire-terminology.ts index be90c53d6..b21093df1 100644 --- a/src/frontend/scripts/aspire-terminology.ts +++ b/src/frontend/scripts/aspire-terminology.ts @@ -1,8 +1,19 @@ +const horizontalWhitespace = String.raw`[ \t]+`; +const legacyAspirePattern = String.raw`\.NET${horizontalWhitespace}Aspire`; +const legacyAppHostPattern = String.raw`\bapp${horizontalWhitespace}host\b`; + +const aspireWithArticle = new RegExp( + String.raw`\b([Aa])${horizontalWhitespace}${legacyAspirePattern}\b`, + 'gi' +); +const legacyAspire = new RegExp(legacyAspirePattern, 'gi'); +const legacyAppHost = new RegExp(legacyAppHostPattern, 'gi'); + export function normalizeAspireTerminology(text: string): string { return text - .replace(/\b([Aa]) \.NET Aspire\b/gi, (_match, article: string) => + .replace(aspireWithArticle, (_match, article: string) => article === 'A' ? 'An Aspire' : 'an Aspire' ) - .replace(/\.NET Aspire/gi, 'Aspire') - .replace(/\bapp host\b/gi, 'AppHost'); + .replace(legacyAspire, 'Aspire') + .replace(legacyAppHost, 'AppHost'); } diff --git a/src/frontend/tests/unit/update-samples.vitest.test.ts b/src/frontend/tests/unit/update-samples.vitest.test.ts index 48b459e3f..025f2c0bd 100644 --- a/src/frontend/tests/unit/update-samples.vitest.test.ts +++ b/src/frontend/tests/unit/update-samples.vitest.test.ts @@ -2,22 +2,40 @@ import { describe, expect, test } from 'vitest'; import samples from '@data/samples.json'; +import { normalizeAspireTerminology } from '../../scripts/aspire-terminology'; import { type SampleResult, normalizeSampleTerminology } from '../../scripts/update-samples'; +const legacyAspireName = ['.NET', 'Aspire'].join(' '); +const legacyAppHostName = ['app', 'host'].join(' '); + +describe('Aspire terminology normalization', () => { + test.each([ + ['uppercase article', `A ${legacyAspireName} project`, 'An Aspire project'], + ['lowercase article', `Build a ${legacyAspireName} project`, 'Build an Aspire project'], + [ + 'extra horizontal whitespace', + `A ${['.NET', 'Aspire'].join('\t')} project`, + 'An Aspire project', + ], + ])('uses one space for the %s case', (_scenario, input, expected) => { + expect(normalizeAspireTerminology(input)).toBe(expected); + }); +}); + describe('sample terminology normalization', () => { test('normalizes every generated text field', () => { const sample: SampleResult = { name: 'terminology-sample', - title: '.NET Aspire app host sample', - description: 'A .NET Aspire App Host project.', + title: `${legacyAspireName} ${legacyAppHostName} sample`, + description: `A ${legacyAspireName} ${legacyAppHostName} project.`, href: 'https://github.com/microsoft/aspire-samples/tree/main/samples/terminology-sample', - readme: '# .NET Aspire sample\n\nRun the app host.', - readmeRaw: '# .NET Aspire sample\n\nRun the APP HOST.', + readme: `# ${legacyAspireName} sample\n\nRun the ${legacyAppHostName}.`, + readmeRaw: `# ${legacyAspireName} sample\n\nRun the ${legacyAppHostName.toUpperCase()}.`, tags: ['csharp'], thumbnail: null, appHost: 'csproj', appHostPath: 'Terminology.AppHost/AppHost.cs', - appHostCode: '// Keep the container running between app host sessions.', + appHostCode: `// Keep the container running between ${legacyAppHostName} sessions.`, }; expect(normalizeSampleTerminology(sample)).toEqual({ @@ -50,7 +68,12 @@ describe('sample terminology normalization', () => { test('keeps generated sample data free of deprecated terminology', () => { const textFields = ['title', 'description', 'readme', 'readmeRaw', 'appHostCode'] as const; - const deprecatedTerminology = /\.NET Aspire|\bapp host\b/i; + const deprecatedTerminology = new RegExp( + [legacyAspireName, legacyAppHostName] + .map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|'), + 'i' + ); const violations = samples.flatMap((sample) => textFields .filter((field) => deprecatedTerminology.test(sample[field] ?? '')) From 16cf407b3a6370410fa6c2f110b32301edc8f005 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:51:49 -0500 Subject: [PATCH 3/6] fix: Handle Markdown wrappers and word boundaries in terminology normalizer Address review feedback on #1397: - Require .NET to sit at a non-word boundary so tokens like ASP.NET Aspire and Microsoft.NET Aspire are left intact instead of corrupted into ASPAspire / MicrosoftAspire. - Consume Markdown emphasis/link openers (**, [) between the article and the term so 'a **.NET Aspire**' and 'a [.NET Aspire](url)' correct the article to 'an'. - Add regression tests for bold/link article correction and word-boundary cases. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 020561bf-2df6-4f8b-a730-1c72d0f6cc5e --- src/frontend/scripts/aspire-terminology.ts | 17 +++++++++++++---- .../tests/unit/update-samples.vitest.test.ts | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/frontend/scripts/aspire-terminology.ts b/src/frontend/scripts/aspire-terminology.ts index b21093df1..6016d1b65 100644 --- a/src/frontend/scripts/aspire-terminology.ts +++ b/src/frontend/scripts/aspire-terminology.ts @@ -1,9 +1,16 @@ const horizontalWhitespace = String.raw`[ \t]+`; -const legacyAspirePattern = String.raw`\.NET${horizontalWhitespace}Aspire`; +// Require `.NET` to sit at a non-word boundary so longer tokens such as +// `ASP.NET Aspire` or `Microsoft.NET Aspire` are left intact instead of being +// corrupted into `ASPAspire` / `MicrosoftAspire`. +const legacyAspirePattern = String.raw`(? - article === 'A' ? 'An Aspire' : 'an Aspire' + .replace( + aspireWithArticle, + (_match, article: string, opener: string) => + `${article === 'A' ? 'An' : 'an'} ${opener}Aspire` ) .replace(legacyAspire, 'Aspire') .replace(legacyAppHost, 'AppHost'); diff --git a/src/frontend/tests/unit/update-samples.vitest.test.ts b/src/frontend/tests/unit/update-samples.vitest.test.ts index 025f2c0bd..15199c01f 100644 --- a/src/frontend/tests/unit/update-samples.vitest.test.ts +++ b/src/frontend/tests/unit/update-samples.vitest.test.ts @@ -20,6 +20,24 @@ describe('Aspire terminology normalization', () => { ])('uses one space for the %s case', (_scenario, input, expected) => { expect(normalizeAspireTerminology(input)).toBe(expected); }); + + test.each([ + ['bold wrapper', `A **${legacyAspireName}** project`, 'An **Aspire** project'], + [ + 'inline link', + `This is a [${legacyAspireName}](https://aspire.dev/) sample`, + 'This is an [Aspire](https://aspire.dev/) sample', + ], + ])('corrects the article across a %s', (_scenario, input, expected) => { + expect(normalizeAspireTerminology(input)).toBe(expected); + }); + + test.each([ + ['a prefixed framework token', `ASP${legacyAspireName} guidance`], + ['a dotted namespace', `Ship Microsoft${legacyAspireName} today`], + ])('leaves %s untouched', (_scenario, input) => { + expect(normalizeAspireTerminology(input)).toBe(input); + }); }); describe('sample terminology normalization', () => { From 5273ce9ae51636f9416d9bf976c0a1da7dd6d0ae Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:01:02 -0500 Subject: [PATCH 4/6] refactor: Make terminology normalizer data-driven Replace the hardcoded replace-chain with a small TerminologyRule table so a new deprecated term is a single entry (pattern/replacement/optional article). Article correction, Markdown-wrapper tolerance, and word-boundary guarding are now applied generically per rule. Also add the 'dotnet aspire' -> 'Aspire' rule that was present in .github/forbidden-words.json but missing from the normalizer, and cover it with tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 020561bf-2df6-4f8b-a730-1c72d0f6cc5e --- src/frontend/scripts/aspire-terminology.ts | 85 ++++++++++++++----- .../tests/unit/update-samples.vitest.test.ts | 9 ++ 2 files changed, 71 insertions(+), 23 deletions(-) diff --git a/src/frontend/scripts/aspire-terminology.ts b/src/frontend/scripts/aspire-terminology.ts index 6016d1b65..965027cdf 100644 --- a/src/frontend/scripts/aspire-terminology.ts +++ b/src/frontend/scripts/aspire-terminology.ts @@ -1,28 +1,67 @@ const horizontalWhitespace = String.raw`[ \t]+`; -// Require `.NET` to sit at a non-word boundary so longer tokens such as -// `ASP.NET Aspire` or `Microsoft.NET Aspire` are left intact instead of being -// corrupted into `ASPAspire` / `MicrosoftAspire`. -const legacyAspirePattern = String.raw`(? `an Aspire`). + */ + readonly article?: 'a' | 'an'; +} + +// Adding a rule here normalizes a new term everywhere generated sample and +// integration data is written. Keep this list in sync with the deprecated terms +// in `.github/forbidden-words.json`. +const terminologyRules: readonly TerminologyRule[] = [ + { + pattern: String.raw`(? - `${article === 'A' ? 'An' : 'an'} ${opener}Aspire` - ) - .replace(legacyAspire, 'Aspire') - .replace(legacyAppHost, 'AppHost'); + return terminologyRules.reduce(applyRule, text); +} + +function applyRule(text: string, rule: TerminologyRule): string { + const corrected = rule.article ? correctArticle(text, rule, rule.article) : text; + return corrected.replace(new RegExp(rule.pattern, 'gi'), rule.replacement); +} + +// Rewrite a preceding indefinite article when the replacement changes the +// leading sound, tolerating Markdown wrappers between the article and the term. +function correctArticle(text: string, rule: TerminologyRule, article: 'a' | 'an'): string { + const capitalized = article === 'a' ? 'A' : 'An'; + const withArticle = new RegExp( + String.raw`\b([Aa])${horizontalWhitespace}(${markdownOpeners})${rule.pattern}\b`, + 'gi' + ); + return text.replace( + withArticle, + (_match, matchedArticle: string, opener: string) => + `${matchedArticle === 'A' ? capitalized : article} ${opener}${rule.replacement}` + ); } diff --git a/src/frontend/tests/unit/update-samples.vitest.test.ts b/src/frontend/tests/unit/update-samples.vitest.test.ts index 15199c01f..c557bfc9f 100644 --- a/src/frontend/tests/unit/update-samples.vitest.test.ts +++ b/src/frontend/tests/unit/update-samples.vitest.test.ts @@ -6,6 +6,7 @@ import { normalizeAspireTerminology } from '../../scripts/aspire-terminology'; import { type SampleResult, normalizeSampleTerminology } from '../../scripts/update-samples'; const legacyAspireName = ['.NET', 'Aspire'].join(' '); +const legacyDotnetAspireName = ['dotnet', 'aspire'].join(' '); const legacyAppHostName = ['app', 'host'].join(' '); describe('Aspire terminology normalization', () => { @@ -38,6 +39,14 @@ describe('Aspire terminology normalization', () => { ])('leaves %s untouched', (_scenario, input) => { expect(normalizeAspireTerminology(input)).toBe(input); }); + + test.each([ + ['uppercase article', `A ${legacyDotnetAspireName} sample`, 'An Aspire sample'], + ['lowercase article', `Build a ${legacyDotnetAspireName} sample`, 'Build an Aspire sample'], + ['no article', `Deploy the ${legacyDotnetAspireName} app`, 'Deploy the Aspire app'], + ])('normalizes the "dotnet aspire" spelling (%s)', (_scenario, input, expected) => { + expect(normalizeAspireTerminology(input)).toBe(expected); + }); }); describe('sample terminology normalization', () => { From 30923d83bfc7eadbf6289ec7256a30d0400cded8 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:27:20 -0500 Subject: [PATCH 5/6] fix: Bound terminology terms by alphanumeric edges and cover Markdown wrappers Replaces the per-rule mix of \\b\/\(? Copilot-Session: 020561bf-2df6-4f8b-a730-1c72d0f6cc5e --- src/frontend/scripts/aspire-terminology.ts | 43 +++++++++++++------ .../tests/unit/update-samples.vitest.test.ts | 6 ++- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/frontend/scripts/aspire-terminology.ts b/src/frontend/scripts/aspire-terminology.ts index 965027cdf..5a85995f6 100644 --- a/src/frontend/scripts/aspire-terminology.ts +++ b/src/frontend/scripts/aspire-terminology.ts @@ -1,17 +1,26 @@ const horizontalWhitespace = String.raw`[ \t]+`; -// Markdown emphasis/link openers can sit between an article and a term in raw -// README/AppHost content (e.g. `a **.NET Aspire**` or `a [.NET Aspire](url)`), so -// the article corrector consumes them to stay grammatical after the term shrinks. -const markdownOpeners = String.raw`[*\[]*`; + +// A deprecated term only matches when it isn't fused to an adjacent alphanumeric +// character, so longer tokens like `ASP.NET Aspire` or `.NET AspireX` stay intact +// while `_`, `*`, backticks, and `[` still count as boundaries (Markdown emphasis, +// inline code, and links wrap terms with those characters). +const termStart = String.raw`(? { `This is a [${legacyAspireName}](https://aspire.dev/) sample`, 'This is an [Aspire](https://aspire.dev/) sample', ], + ['underscore emphasis', `A _${legacyAspireName}_ project`, 'An _Aspire_ project'], + ['inline code', `A \`${legacyAspireName}\` project`, `An \`Aspire\` project`], ])('corrects the article across a %s', (_scenario, input, expected) => { expect(normalizeAspireTerminology(input)).toBe(expected); }); @@ -36,6 +38,8 @@ describe('Aspire terminology normalization', () => { test.each([ ['a prefixed framework token', `ASP${legacyAspireName} guidance`], ['a dotted namespace', `Ship Microsoft${legacyAspireName} today`], + ['a suffixed token', `${legacyAspireName}Server guidance`], + ['a suffixed token after an article', `A ${legacyAspireName}Server sample`], ])('leaves %s untouched', (_scenario, input) => { expect(normalizeAspireTerminology(input)).toBe(input); }); @@ -96,7 +100,7 @@ describe('sample terminology normalization', () => { test('keeps generated sample data free of deprecated terminology', () => { const textFields = ['title', 'description', 'readme', 'readmeRaw', 'appHostCode'] as const; const deprecatedTerminology = new RegExp( - [legacyAspireName, legacyAppHostName] + [legacyAspireName, legacyDotnetAspireName, legacyAppHostName] .map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) .join('|'), 'i' From aba2cb5b006f9531f1857b68b9a21e91bbd71627 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:29:31 -0500 Subject: [PATCH 6/6] fix: Skip code regions and leave C# untouched in terminology normalizer The normalizer ran a plain pass over raw Markdown and appHostCode, which could rewrite runnable sample commands (e.g. 'dotnet aspire run' -> 'Aspire run') inside fenced/inline code and corrupt compilable C#. Now fenced blocks and inline code are copied through verbatim, appHostCode is left untouched (it renders as C#, not prose), and the entry point is null-safe. Adds code-skip, mixed prose+code, idempotence, and null tests, and replaces the deprecated-term scan with an idempotence-based invariant. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 020561bf-2df6-4f8b-a730-1c72d0f6cc5e --- src/frontend/scripts/aspire-terminology.ts | 53 ++++++++++++++--- src/frontend/scripts/update-samples.ts | 5 +- .../tests/unit/update-samples.vitest.test.ts | 57 ++++++++++++++----- 3 files changed, 91 insertions(+), 24 deletions(-) diff --git a/src/frontend/scripts/aspire-terminology.ts b/src/frontend/scripts/aspire-terminology.ts index 5a85995f6..61764d095 100644 --- a/src/frontend/scripts/aspire-terminology.ts +++ b/src/frontend/scripts/aspire-terminology.ts @@ -2,16 +2,24 @@ const horizontalWhitespace = String.raw`[ \t]+`; // A deprecated term only matches when it isn't fused to an adjacent alphanumeric // character, so longer tokens like `ASP.NET Aspire` or `.NET AspireX` stay intact -// while `_`, `*`, backticks, and `[` still count as boundaries (Markdown emphasis, -// inline code, and links wrap terms with those characters). +// while punctuation and Markdown wrappers (`_`, `*`, `[`) still count as +// boundaries around a standalone term. const termStart = String.raw`(? { 'This is an [Aspire](https://aspire.dev/) sample', ], ['underscore emphasis', `A _${legacyAspireName}_ project`, 'An _Aspire_ project'], - ['inline code', `A \`${legacyAspireName}\` project`, `An \`Aspire\` project`], ])('corrects the article across a %s', (_scenario, input, expected) => { expect(normalizeAspireTerminology(input)).toBe(expected); }); @@ -51,6 +50,34 @@ describe('Aspire terminology normalization', () => { ])('normalizes the "dotnet aspire" spelling (%s)', (_scenario, input, expected) => { expect(normalizeAspireTerminology(input)).toBe(expected); }); + + test.each([ + ['a fenced block', '```bash\n' + `${legacyDotnetAspireName} run` + '\n```'], + ['an inline code command', `Run \`${legacyDotnetAspireName} run\` to start.`], + ['a fenced legacy brand', '```\n' + legacyAspireName + '\n```'], + ])('preserves %s so sample commands stay runnable', (_scenario, input) => { + expect(normalizeAspireTerminology(input)).toBe(input); + }); + + test('normalizes prose while preserving adjacent code', () => { + const input = `Build a ${legacyAspireName} app, then run \`${legacyDotnetAspireName} run\`.`; + expect(normalizeAspireTerminology(input)).toBe( + 'Build an Aspire app, then run `dotnet aspire run`.' + ); + }); + + test.each([ + ['prose', `A ${legacyAspireName} ${legacyAppHostName} sample`], + ['mixed prose and code', `Run \`${legacyDotnetAspireName} run\` in a ${legacyAspireName} app`], + ])('is idempotent for %s', (_scenario, input) => { + const once = normalizeAspireTerminology(input); + expect(normalizeAspireTerminology(once)).toBe(once); + }); + + test('passes null and undefined through unchanged', () => { + expect(normalizeAspireTerminology(null)).toBeNull(); + expect(normalizeAspireTerminology(undefined)).toBeUndefined(); + }); }); describe('sample terminology normalization', () => { @@ -61,7 +88,12 @@ describe('sample terminology normalization', () => { description: `A ${legacyAspireName} ${legacyAppHostName} project.`, href: 'https://github.com/microsoft/aspire-samples/tree/main/samples/terminology-sample', readme: `# ${legacyAspireName} sample\n\nRun the ${legacyAppHostName}.`, - readmeRaw: `# ${legacyAspireName} sample\n\nRun the ${legacyAppHostName.toUpperCase()}.`, + readmeRaw: + `# ${legacyAspireName} sample\n\n` + + `Run the ${legacyAppHostName.toUpperCase()}.\n\n` + + '```bash\n' + + `${legacyDotnetAspireName} run\n` + + '```\n', tags: ['csharp'], thumbnail: null, appHost: 'csproj', @@ -74,8 +106,8 @@ describe('sample terminology normalization', () => { title: 'Aspire AppHost sample', description: 'An Aspire AppHost project.', readme: '# Aspire sample\n\nRun the AppHost.', - readmeRaw: '# Aspire sample\n\nRun the AppHost.', - appHostCode: '// Keep the container running between AppHost sessions.', + readmeRaw: + '# Aspire sample\n\n' + 'Run the AppHost.\n\n' + '```bash\n' + 'dotnet aspire run\n' + '```\n', }); }); @@ -97,17 +129,14 @@ describe('sample terminology normalization', () => { expect(normalizeSampleTerminology(sample)).toEqual(sample); }); - test('keeps generated sample data free of deprecated terminology', () => { - const textFields = ['title', 'description', 'readme', 'readmeRaw', 'appHostCode'] as const; - const deprecatedTerminology = new RegExp( - [legacyAspireName, legacyDotnetAspireName, legacyAppHostName] - .map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - .join('|'), - 'i' - ); + test('keeps generated sample prose normalized', () => { + const proseFields = ['title', 'description', 'readme', 'readmeRaw'] as const; const violations = samples.flatMap((sample) => - textFields - .filter((field) => deprecatedTerminology.test(sample[field] ?? '')) + proseFields + .filter((field) => { + const value = sample[field]; + return value != null && normalizeAspireTerminology(value) !== value; + }) .map((field) => `${sample.name}.${field}`) );