From 45084ee07cca14b6e3ab023d367ae3240c0a3820 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 14 Sep 2026 11:32:04 -0400 Subject: [PATCH 1/2] feat(spec-dashboard): support explicit emitter coverage groups --- packages/spec-dashboard/README.md | 32 ++ packages/spec-dashboard/src/apis.ts | 12 + .../components/coverage-overview.module.css | 38 +++ .../src/components/coverage-overview.test.tsx | 285 ++++++++++++++++++ .../src/components/coverage-overview.tsx | 167 ++++++---- .../src/components/dashboard-az-storage.tsx | 1 + .../src/components/dashboard.tsx | 6 +- packages/spec-dashboard/src/index.ts | 2 +- .../src/utils/coverage-utils.ts | 8 +- 9 files changed, 493 insertions(+), 58 deletions(-) create mode 100644 packages/spec-dashboard/src/components/coverage-overview.test.tsx diff --git a/packages/spec-dashboard/README.md b/packages/spec-dashboard/README.md index db83182fef9..3eb9d18b73c 100644 --- a/packages/spec-dashboard/README.md +++ b/packages/spec-dashboard/README.md @@ -1,5 +1,37 @@ # Spector Dashboard +## Coverage overview groups + +Use `groupEmitters` to combine emitters into named overview cards: + +```tsx + +``` + +Each card shows the union of its emitters' coverage, with individual percentages underneath. +A scenario counts once per table and is covered if any member reports `pass`, `not-applicable`, +or `not-supported`. Two emitters each covering 50 of 100 scenarios, with 25 shared successes, +produce **75%** combined coverage. Counts follow the selected tier and use each table's reports; +equally named scenarios in different tables/spec sets remain separate. + +Groups and emitter rows follow the configured order; ungrouped emitters keep separate cards +afterward. `emitterDisplayNames` controls row and table labels without affecting grouping. +These options are also available on `DashboardFromAzureStorage`'s `options` and do not change +table columns or report loading. + ## Dev ```bash diff --git a/packages/spec-dashboard/src/apis.ts b/packages/spec-dashboard/src/apis.ts index e99f9e7d112..366c6d4524c 100644 --- a/packages/spec-dashboard/src/apis.ts +++ b/packages/spec-dashboard/src/apis.ts @@ -19,6 +19,13 @@ export interface TableDefinition { emitterNames?: string[]; } +export interface EmitterGroup { + /** Overview card label. */ + readonly name: string; + /** Emitter package names, in display order. */ + readonly emitters: readonly string[]; +} + export interface CoverageFromAzureStorageOptions { readonly storageAccountName: string; readonly containerName: string; @@ -34,6 +41,11 @@ export interface CoverageFromAzureStorageOptions { readonly showOverview?: boolean; /** Optional friendly display names for emitters. Key is the emitter package name, value is the display name. */ readonly emitterDisplayNames?: Readonly>; + /** + * Overview groups in display order. Count each scenario once per table, covered if any member covers it. + * Does not change table columns or report loading. + */ + readonly groupEmitters?: readonly EmitterGroup[]; } export interface GeneratorCoverageSuiteReport extends CoverageReport { diff --git a/packages/spec-dashboard/src/components/coverage-overview.module.css b/packages/spec-dashboard/src/components/coverage-overview.module.css index f13209fca63..c05b3cbb6b1 100644 --- a/packages/spec-dashboard/src/components/coverage-overview.module.css +++ b/packages/spec-dashboard/src/components/coverage-overview.module.css @@ -18,7 +18,13 @@ gap: 16px; } +.overview { + display: flex; + min-width: 0; +} + .card { + flex: 1; background-color: var(--colorNeutralBackground1); padding: 16px 16px 20px; text-align: center; @@ -27,6 +33,7 @@ align-items: center; gap: 4px; min-height: 100px; + min-width: 0; } .card-name { @@ -35,4 +42,35 @@ display: flex; align-items: center; text-align: center; + overflow-wrap: anywhere; +} + +.emitters { + align-self: stretch; + margin: 8px 0 0; + padding-top: 8px; + border-top: 1px solid var(--colorNeutralStroke2); + display: flex; + flex-direction: column; + gap: 6px; +} + +.emitter-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.emitter-name { + min-width: 0; + color: var(--colorNeutralForeground2); + text-align: left; + overflow-wrap: anywhere; +} + +.emitter-percentage { + margin: 0; + flex-shrink: 0; + font-variant-numeric: tabular-nums; } diff --git a/packages/spec-dashboard/src/components/coverage-overview.test.tsx b/packages/spec-dashboard/src/components/coverage-overview.test.tsx new file mode 100644 index 00000000000..2e05c1bfac2 --- /dev/null +++ b/packages/spec-dashboard/src/components/coverage-overview.test.tsx @@ -0,0 +1,285 @@ +import { SSRProvider } from "@fluentui/react-components"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { CoverageSummary, GeneratorCoverageSuiteReport } from "../apis.js"; +import { CoverageOverview, type CoverageOverviewProps } from "./coverage-overview.js"; +import { Dashboard } from "./dashboard.js"; + +const dataEmitter = "@azure-typespec/http-client-csharp"; +const managementEmitter = "@azure-typespec/http-client-csharp-mgmt"; +const groupEmitters = [{ name: "C#", emitters: [dataEmitter, managementEmitter] }]; +const emitterDisplayNames = { + [dataEmitter]: "Data plane", + [managementEmitter]: "Management plane", +}; + +function createReport( + results: GeneratorCoverageSuiteReport["results"], + name = "Emitter", +): GeneratorCoverageSuiteReport { + return { + generatorMetadata: { name, version: "1.0.0", mode: "standard" }, + scenariosMetadata: { packageName: "test", version: "1.0.0", commit: "abc123" }, + createdAt: "2026-01-01T00:00:00Z", + results, + }; +} + +function createSummary( + scenarioNames: string[], + generatorReports: CoverageSummary["generatorReports"], +): CoverageSummary { + return { + tableName: "Test", + manifest: { + packageName: "test", + commit: "abc123", + version: "1.0.0", + scenarios: scenarioNames.map((name) => ({ + name, + scenarioDoc: `Doc for ${name}`, + location: { + path: "test.tsp", + start: { line: 1, character: 1 }, + end: { line: 2, character: 1 }, + }, + })), + }, + generatorReports, + }; +} + +function renderCards(element: ReactNode): string[] { + const html = renderToStaticMarkup({element}); + return Array.from(html.matchAll(/]*>(.*?)<\/article>/gs), ([, content]) => + content + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim(), + ); +} + +function renderOverview(props: CoverageOverviewProps): string[] { + return renderCards(); +} + +const coverageSummaries = [ + createSummary(["data1", "data2", "data3", "data4"], { + [dataEmitter]: createReport({ + data1: "pass", + data2: "pass", + data3: "not-applicable", + data4: "fail", + }), + }), + createSummary(["mgmt1", "mgmt2"], { + [managementEmitter]: createReport({ mgmt1: "fail", mgmt2: "not-implemented" }), + }), +]; + +describe("CoverageOverview", () => { + it("combines disjoint scenario sets and preserves per-emitter rows", () => { + expect(renderOverview({ coverageSummaries, groupEmitters, emitterDisplayNames })).toEqual([ + "C# 50% Data plane 75% Management plane 0%", + ]); + }); + + it.each([ + ["fail", 0], + ["pass", 100], + ] as const)( + "counts a covered scenario once when the other emitter reports %s", + (status, percentage) => { + expect( + renderOverview({ + coverageSummaries: [ + createSummary(["shared"], { + [dataEmitter]: createReport({ shared: "pass" }), + [managementEmitter]: createReport({ shared: status }), + }), + ], + groupEmitters, + emitterDisplayNames, + }), + ).toEqual([`C# 100% Data plane 100% Management plane ${percentage}%`]); + }, + ); + + it("unions complementary coverage without double-counting shared successes", () => { + expect( + renderOverview({ + coverageSummaries: [ + createSummary(["one", "two", "three", "four"], { + [dataEmitter]: createReport({ one: "pass", two: "pass", three: "fail", four: "fail" }), + [managementEmitter]: createReport({ one: "fail", two: "pass", three: "pass" }), + }), + ], + groupEmitters, + emitterDisplayNames, + }), + ).toEqual(["C# 75% Data plane 50% Management plane 50%"]); + }); + + it("aggregates each emitter across summaries before displaying its coverage", () => { + expect( + renderOverview({ + coverageSummaries: [ + createSummary(["shared"], { + [dataEmitter]: createReport({ shared: "pass" }), + }), + createSummary(["shared", "other1", "other2"], { + [dataEmitter]: createReport({ shared: "fail", other1: "fail", other2: "fail" }), + }), + ], + groupEmitters, + emitterDisplayNames, + }), + ).toEqual(["C# 25% Data plane 25%"]); + }); + + it("does not group emitters just because they share a display name", () => { + expect( + renderOverview({ + coverageSummaries, + emitterDisplayNames: { [dataEmitter]: "C#", [managementEmitter]: "C#" }, + }), + ).toEqual(["C# 75%", "C# 0%"]); + }); + + it("keeps ungrouped emitters separate even when their name matches a group", () => { + expect( + renderOverview({ + coverageSummaries: [ + createSummary(["shared"], { + [dataEmitter]: createReport({ shared: "pass" }), + "C#": createReport({ shared: "fail" }, "C#"), + }), + ], + groupEmitters, + emitterDisplayNames, + }), + ).toEqual(["C# 100% Data plane 100%", "C# 0%"]); + }); + + it("uses configured group and emitter order, followed by ungrouped emitters", () => { + expect( + renderOverview({ + coverageSummaries: [ + createSummary(["shared"], { + [managementEmitter]: createReport({ shared: "fail" }), + [dataEmitter]: createReport({ shared: "pass" }), + python: createReport({ shared: "pass" }, "Python"), + js: createReport({ shared: "pass" }, "JavaScript"), + }), + ], + groupEmitters: [{ name: "Python SDKs", emitters: ["python"] }, ...groupEmitters], + emitterDisplayNames, + }), + ).toEqual([ + "Python SDKs 100% Python 100%", + "C# 100% Data plane 100% Management plane 0%", + "JavaScript 100%", + ]); + }); + + it("counts completed statuses but not missing results or missing reports", () => { + expect( + renderOverview({ + coverageSummaries: [ + createSummary(["pass", "na", "ns", "fail", "ni", "missing"], { + [dataEmitter]: createReport({ + pass: "pass", + na: "not-applicable", + ns: "not-supported", + fail: "fail", + ni: "not-implemented", + stale: "pass", + }), + [managementEmitter]: undefined, + }), + ], + groupEmitters, + emitterDisplayNames, + }), + ).toEqual(["C# 50% Data plane 50% Management plane 0%"]); + }); + + it("preserves display-name fallbacks in emitter rows", () => { + expect( + renderOverview({ + coverageSummaries: [ + createSummary(["shared"], { + [dataEmitter]: createReport({ shared: "pass" }, "SDK"), + "@typespec/http-client-python": undefined, + custom: undefined, + }), + ], + groupEmitters: [ + { + name: "Clients", + emitters: [dataEmitter, "@typespec/http-client-python", "custom"], + }, + ], + }), + ).toEqual(["Clients 100% SDK 100% Python 0% custom 0%"]); + }); + + it("handles groups with no scenarios without NaN", () => { + expect( + renderOverview({ + coverageSummaries: [createSummary([], { [dataEmitter]: createReport({}) })], + groupEmitters, + emitterDisplayNames, + }), + ).toEqual(["C# 0% Data plane 0%"]); + }); + + it("does not create cards for configured emitters absent from the summaries", () => { + expect( + renderToStaticMarkup( + , + ), + ).toBe(""); + }); + + it("ignores summaries that have no emitters in the group", () => { + expect( + renderOverview({ + coverageSummaries: [ + createSummary(["shared"], { [dataEmitter]: createReport({ shared: "pass" }) }), + createSummary(["other"], { python: createReport({ other: "fail" }, "Python") }), + ], + groupEmitters, + emitterDisplayNames, + }), + ).toEqual(["C# 100% Data plane 100%", "Python 0%"]); + }); + + it("ignores empty groups and does not repeat emitters listed twice in a group", () => { + expect( + renderOverview({ + coverageSummaries, + groupEmitters: [ + { name: "Empty", emitters: [] }, + { name: "C#", emitters: [dataEmitter, managementEmitter, dataEmitter] }, + ], + emitterDisplayNames, + }), + ).toEqual(["C# 50% Data plane 75% Management plane 0%"]); + }); +}); + +describe("Dashboard overview", () => { + it("passes explicit groups through to the overview", () => { + const props = { coverageSummaries, groupEmitters, emitterDisplayNames, showOverview: true }; + expect(renderCards()).toEqual([ + "C# 50% Data plane 75% Management plane 0%", + ]); + }); + + it("does not show the overview unless enabled", () => { + const props = { coverageSummaries, groupEmitters, emitterDisplayNames }; + expect(renderCards()).toEqual([]); + }); +}); diff --git a/packages/spec-dashboard/src/components/coverage-overview.tsx b/packages/spec-dashboard/src/components/coverage-overview.tsx index 7e8d8d54506..34cfd594dc8 100644 --- a/packages/spec-dashboard/src/components/coverage-overview.tsx +++ b/packages/spec-dashboard/src/components/coverage-overview.tsx @@ -1,25 +1,28 @@ import { Card, Text } from "@fluentui/react-components"; import type { FunctionComponent } from "react"; import { useMemo } from "react"; -import type { CoverageSummary } from "../apis.js"; +import type { CoverageSummary, EmitterGroup } from "../apis.js"; import { GroupRatioColors, GroupRatios } from "../constants.js"; +import { isScenarioCompleted } from "../utils/coverage-utils.js"; import style from "./coverage-overview.module.css"; interface EmitterOverview { name: string; displayName: string; - coverageRatio: number; + totalScenarios: number; + coveredScenarios: number; +} + +interface OverviewGroup extends EmitterOverview { + emitters: EmitterOverview[]; } export interface CoverageOverviewProps { coverageSummaries: CoverageSummary[]; emitterDisplayNames?: Record; + groupEmitters?: readonly EmitterGroup[]; } -/** - * Extracts a display-friendly name from a full emitter package name. - * e.g. "@typespec/http-client-python" → "Python" - */ function getEmitterDisplayName( emitterName: string, report: CoverageSummary["generatorReports"][string], @@ -31,7 +34,6 @@ function getEmitterDisplayName( if (report?.generatorMetadata?.name) { return report.generatorMetadata.name; } - // Strip common prefix patterns const match = emitterName.match(/http-client-(\w+)$/); if (match) { return match[1].charAt(0).toUpperCase() + match[1].slice(1); @@ -39,9 +41,6 @@ function getEmitterDisplayName( return emitterName; } -/** - * Gets the accent color for a coverage ratio using the same thresholds as the coverage tables. - */ function getOverviewColor(ratio: number): string { for (const [key, threshold] of Object.entries(GroupRatios)) { if (ratio >= threshold) { @@ -51,56 +50,68 @@ function getOverviewColor(ratio: number): string { return GroupRatioColors.zero; } -/** - * Displays a section with a grid of cards showing per-emitter coverage overview. - */ export const CoverageOverview: FunctionComponent = ({ coverageSummaries, emitterDisplayNames, + groupEmitters, }) => { - const emitterOverviews = useMemo(() => { - // Aggregate scenarios per emitter across all summaries - const emitterMap = new Map< - string, - { - totalScenarios: number; - coveredScenarios: number; - report: CoverageSummary["generatorReports"][string]; - } - >(); + const overviews = useMemo(() => { + const emitterOverviews = new Map(); for (const summary of coverageSummaries) { for (const [emitterName, report] of Object.entries(summary.generatorReports)) { - if (!emitterMap.has(emitterName)) { - emitterMap.set(emitterName, { totalScenarios: 0, coveredScenarios: 0, report }); + let emitter = emitterOverviews.get(emitterName); + if (!emitter) { + emitter = { + name: emitterName, + displayName: getEmitterDisplayName(emitterName, report, emitterDisplayNames), + totalScenarios: 0, + coveredScenarios: 0, + }; + emitterOverviews.set(emitterName, emitter); } - const entry = emitterMap.get(emitterName)!; - const scenarios = summary.manifest.scenarios; - entry.totalScenarios += scenarios.length; + emitter.totalScenarios += summary.manifest.scenarios.length; if (report) { - for (const scenario of scenarios) { - const status = report.results[scenario.name]; - if (status === "pass" || status === "not-applicable" || status === "not-supported") { - entry.coveredScenarios++; + for (const scenario of summary.manifest.scenarios) { + if (isScenarioCompleted(report.results[scenario.name])) { + emitter.coveredScenarios++; } } } } } - const overviews: EmitterOverview[] = []; - for (const [emitterName, data] of emitterMap) { + const overviews: OverviewGroup[] = []; + const groupedEmitters = new Set(); + for (const [index, group] of (groupEmitters ?? []).entries()) { + const emitters = Array.from(new Set(group.emitters)) + .map((name) => emitterOverviews.get(name)) + .filter((emitter) => emitter !== undefined); + if (emitters.length === 0) { + continue; + } + overviews.push({ - name: emitterName, - displayName: getEmitterDisplayName(emitterName, data.report, emitterDisplayNames), - coverageRatio: data.totalScenarios > 0 ? data.coveredScenarios / data.totalScenarios : 0, + name: `group:${index}`, + displayName: group.name, + ...getCombinedCoverage(coverageSummaries, group.emitters), + emitters, }); + for (const emitter of emitters) { + groupedEmitters.add(emitter.name); + } + } + + for (const emitter of emitterOverviews.values()) { + if (!groupedEmitters.has(emitter.name)) { + overviews.push({ ...emitter, name: `emitter:${emitter.name}`, emitters: [] }); + } } return overviews; - }, [coverageSummaries, emitterDisplayNames]); + }, [coverageSummaries, emitterDisplayNames, groupEmitters]); - if (emitterOverviews.length === 0) { + if (overviews.length === 0) { return null; } @@ -110,30 +121,78 @@ export const CoverageOverview: FunctionComponent = ({ Coverage Overview
- {emitterOverviews.map((emitter) => ( - + {overviews.map((overview) => ( + ))}
); }; -interface EmitterOverviewCardProps { - emitter: EmitterOverview; +function getCombinedCoverage( + coverageSummaries: CoverageSummary[], + emitterNames: readonly string[], +): Pick { + let totalScenarios = 0; + let coveredScenarios = 0; + for (const summary of coverageSummaries) { + const reports = emitterNames + .filter((name) => Object.hasOwn(summary.generatorReports, name)) + .map((name) => summary.generatorReports[name]); + if (reports.length === 0) { + continue; + } + + // Scenario names are local to a summary, not global across spec sets. + const scenarioNames = new Set(summary.manifest.scenarios.map((scenario) => scenario.name)); + totalScenarios += scenarioNames.size; + for (const name of scenarioNames) { + if (reports.some((report) => isScenarioCompleted(report?.results[name]))) { + coveredScenarios++; + } + } + } + return { totalScenarios, coveredScenarios }; } -const EmitterOverviewCard: FunctionComponent = ({ emitter }) => { - const accentColor = getOverviewColor(emitter.coverageRatio); - const percentage = Math.floor(emitter.coverageRatio * 100); +function getCoverageRatio(overview: EmitterOverview): number { + return overview.totalScenarios > 0 ? overview.coveredScenarios / overview.totalScenarios : 0; +} + +const OverviewCard: FunctionComponent<{ overview: OverviewGroup }> = ({ overview }) => { + const coverageRatio = getCoverageRatio(overview); + const accentColor = getOverviewColor(coverageRatio); + const percentage = Math.floor(coverageRatio * 100); return ( - - - {emitter.displayName} - - - {percentage}% - - +
+ + + {overview.displayName} + + + {percentage}% + + {overview.emitters.length > 0 && ( +
+ {overview.emitters.map((emitter) => { + const ratio = getCoverageRatio(emitter); + return ( +
+
+ {emitter.displayName} +
+
+ + {Math.floor(ratio * 100)}% + +
+
+ ); + })} +
+ )} +
+
); }; diff --git a/packages/spec-dashboard/src/components/dashboard-az-storage.tsx b/packages/spec-dashboard/src/components/dashboard-az-storage.tsx index cdab48d6357..1fd5187c311 100644 --- a/packages/spec-dashboard/src/components/dashboard-az-storage.tsx +++ b/packages/spec-dashboard/src/components/dashboard-az-storage.tsx @@ -28,6 +28,7 @@ export const DashboardFromAzureStorage = (props: DashboardFromAzureStorageProps) scenarioTierConfig={props.options.tiers} showOverview={props.options.showOverview} emitterDisplayNames={props.options.emitterDisplayNames} + groupEmitters={props.options.groupEmitters} >
) : ( "Loading" diff --git a/packages/spec-dashboard/src/components/dashboard.tsx b/packages/spec-dashboard/src/components/dashboard.tsx index 4c61858f546..ae39602adb1 100644 --- a/packages/spec-dashboard/src/components/dashboard.tsx +++ b/packages/spec-dashboard/src/components/dashboard.tsx @@ -1,7 +1,7 @@ import { Card, CardHeader, SearchBox, Text } from "@fluentui/react-components"; import type { FunctionComponent } from "react"; import { useDeferredValue, useMemo, useState } from "react"; -import type { CoverageSummary } from "../apis.js"; +import type { CoverageSummary, EmitterGroup } from "../apis.js"; import { useTierFiltering } from "../hooks/use-tier-filtering.js"; import type { TierConfig } from "../utils/tier-filtering-utils.js"; import { CoverageOverview } from "./coverage-overview.js"; @@ -17,6 +17,8 @@ export interface DashboardProps { showOverview?: boolean; /** Optional friendly display names for emitters. Key is the emitter package name. */ emitterDisplayNames?: Record; + /** Combine emitter coverage in named overview cards. */ + groupEmitters?: readonly EmitterGroup[]; } export const Dashboard: FunctionComponent = ({ @@ -24,6 +26,7 @@ export const Dashboard: FunctionComponent = ({ scenarioTierConfig, showOverview, emitterDisplayNames, + groupEmitters, }) => { const [selectedTier, setSelectedTier] = useState(undefined); const [nameFilter, setNameFilter] = useState(""); @@ -85,6 +88,7 @@ export const Dashboard: FunctionComponent = ({ )}
{specsCardTable}
diff --git a/packages/spec-dashboard/src/index.ts b/packages/spec-dashboard/src/index.ts index ef29714f6e1..0a8c3e0540a 100644 --- a/packages/spec-dashboard/src/index.ts +++ b/packages/spec-dashboard/src/index.ts @@ -1,3 +1,3 @@ -export type { CoverageFromAzureStorageOptions, TableDefinition } from "./apis.js"; +export type { CoverageFromAzureStorageOptions, EmitterGroup, TableDefinition } from "./apis.js"; export { DashboardFromAzureStorage } from "./components/dashboard-az-storage.js"; export { Dashboard } from "./components/dashboard.js"; diff --git a/packages/spec-dashboard/src/utils/coverage-utils.ts b/packages/spec-dashboard/src/utils/coverage-utils.ts index 7d436903bc1..2476d69b259 100644 --- a/packages/spec-dashboard/src/utils/coverage-utils.ts +++ b/packages/spec-dashboard/src/utils/coverage-utils.ts @@ -1,6 +1,10 @@ -import type { ScenarioData } from "@typespec/spec-coverage-sdk"; +import type { ScenarioData, ScenarioStatus } from "@typespec/spec-coverage-sdk"; import type { GeneratorCoverageSuiteReport } from "../apis.js"; +export function isScenarioCompleted(status: ScenarioStatus | undefined): boolean { + return status === "pass" || status === "not-applicable" || status === "not-supported"; +} + /** * Calculates the ratio of completed (pass, not-applicable, not-supported) scenarios. * @param scenarios - All scenarios to consider @@ -17,7 +21,7 @@ export function getCompletedRatio( let coveredCount = 0; for (const scenario of filtered) { const status = report.results[scenario.name]; - if (status === "pass" || status === "not-applicable" || status === "not-supported") { + if (isScenarioCompleted(status)) { coveredCount++; } } From dbf99e24fd4da2a19a8069064cbe60763cd93c49 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 14 Sep 2026 11:40:33 -0400 Subject: [PATCH 2/2] chore: recognize tabular-nums in spellcheck --- cspell.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/cspell.yaml b/cspell.yaml index 9c26d9f1de2..1aceb6ebc73 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -283,6 +283,7 @@ words: - strs - stubbed - swaggerui + - tabular-nums - TCGC - TEAMPROJECTID - terlson