From eb41a4dca6b20ccd8c56c23ba92f8c5905d5fdc1 Mon Sep 17 00:00:00 2001 From: Rayhan Noufal Arayilakath Date: Wed, 1 Jul 2026 21:56:21 -0700 Subject: [PATCH] feat(telemetry): track which rules users disable and suppress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule rejection — turning a rule off in config or silencing a finding inline — is the strongest false-positive signal we have, and until now no rule identity ever rode telemetry for it: the wide event carried only counts (scan.rulesDisabled, scan.ignoredTagCount) and the pipeline silently nulled every suppressed diagnostic. Two complementary counters, both keyed by canonicalized rule + source: - rule.disabled: once per scan per config off-switch (rules: "off" / ignore.rules). Load-bearing on its own because "off" rules are stripped from the generated oxlint config upstream and never fire, so per-diagnostic counting can never see them. - rule.suppressed: findings the diagnostic pipeline dropped per user intent (config off-switch, per-path ignore.overrides entry, inline react-doctor-disable comment), tallied inside buildDiagnosticPipeline and rolled up on the wide event as diag.suppressed*. Suppression tallies thread InspectOutput -> CachedScanPayload (schema v2 -> 3) so cache hits replay them; the public InspectResult and JSON report are untouched. Engine-owned drops (test-file auto-suppression, the library gate, ignore.files, the warnings hide) are deliberately not counted — they say nothing about the user rejecting a specific rule. Co-Authored-By: Claude Fable 5 --- .changeset/rule-ignore-telemetry.md | 5 ++ .../core/src/build-diagnostic-pipeline.ts | 34 ++++++++-- packages/core/src/rule-key-aliases.ts | 13 ++++ packages/core/src/run-inspect.ts | 12 ++++ packages/core/src/types/diagnostic.ts | 14 ++++ packages/core/src/types/index.ts | 1 + .../merge-and-filter-diagnostics.test.ts | 64 ++++++++++++++++++- .../src/cli/utils/build-run-event.ts | 32 +++++++++- .../react-doctor/src/cli/utils/constants.ts | 10 ++- .../src/cli/utils/record-scan-metrics.ts | 52 ++++++++++++++- .../src/cli/utils/scan-result-cache.ts | 7 ++ packages/react-doctor/src/inspect.ts | 4 ++ .../tests/build-run-event.test.ts | 21 ++++++ .../tests/record-scan-metrics.test.ts | 37 ++++++++++- .../tests/scan-result-cache.test.ts | 2 + 15 files changed, 298 insertions(+), 10 deletions(-) create mode 100644 .changeset/rule-ignore-telemetry.md diff --git a/.changeset/rule-ignore-telemetry.md b/.changeset/rule-ignore-telemetry.md new file mode 100644 index 0000000000..88bdd27d5c --- /dev/null +++ b/.changeset/rule-ignore-telemetry.md @@ -0,0 +1,5 @@ +--- +"react-doctor": patch +--- + +Add anonymized telemetry for which rules users silence. A `rule.disabled` counter records config off-switches (`rules: "off"` and `ignore.rules`, keyed by canonicalized rule + source) once per scan, and a `rule.suppressed` counter records findings the diagnostic pipeline dropped per user intent — config off-switch, per-path `ignore.overrides` entry, or inline `react-doctor-disable*` comment — with per-source rollups (`diag.suppressed*`) on the per-scan wide event. No rule identity ever rode telemetry for silenced rules before, so rule-rejection (the strongest false-positive signal) was unmeasurable. diff --git a/packages/core/src/build-diagnostic-pipeline.ts b/packages/core/src/build-diagnostic-pipeline.ts index cad61b9ecf..dfd4a063d9 100644 --- a/packages/core/src/build-diagnostic-pipeline.ts +++ b/packages/core/src/build-diagnostic-pipeline.ts @@ -4,6 +4,7 @@ import type { DiagnosticFileContext, ReactDoctorConfig, RuleSeverityOverride, + SuppressedRuleCount, } from "./types/index.js"; import { compileIgnoreOverrides, @@ -43,6 +44,16 @@ interface BuildDiagnosticPipelineInput { export interface DiagnosticPipeline { readonly apply: (diagnostic: Diagnostic) => Diagnostic | null; + /** + * Per-rule tallies of the diagnostics `apply` dropped because the user + * explicitly silenced the rule — the config off switches (severity `"off"`, + * `ignore.rules`), per-path `ignore.overrides`, and inline disable + * comments. Engine-owned drops (test-file auto-suppression, the library + * gate, the global warnings hide, `ignore.files` patterns, the + * `textComponents` / `runtimeGlobals` feature knobs) are deliberately not + * counted: they say nothing about the user rejecting a specific rule. + */ + readonly summarizeSuppressions: () => SuppressedRuleCount[]; } const collectStringSet = (values: unknown): ReadonlySet => { @@ -103,6 +114,18 @@ export const buildDiagnosticPipeline = ( const fileLinesCache = new Map(); const fileContextCache = new Map(); const libraryFileCache = new Map(); + const suppressions = new Map(); + + const suppress = (diagnostic: Diagnostic, source: SuppressedRuleCount["source"]): null => { + const { ruleKey } = getDiagnosticRuleIdentity(diagnostic); + const suppressionKey = `${ruleKey}\u0000${source}`; + const existing = suppressions.get(suppressionKey); + suppressions.set( + suppressionKey, + existing ? { ...existing, count: existing.count + 1 } : { rule: ruleKey, source, count: 1 }, + ); + return null; + }; // App-only rules (`static-components`, `no-render-prop-children`) describe // patterns that are noise in published libraries — silence them on files @@ -213,7 +236,7 @@ export const buildDiagnosticPipeline = ( { ruleKey, category }, severityControls, ); - if (explicitSeverityOverride === "off") return null; + if (explicitSeverityOverride === "off") return suppress(current, "config"); if (explicitSeverityOverride !== undefined) { current = restampSeverity(current, explicitSeverityOverride); } @@ -239,11 +262,13 @@ export const buildDiagnosticPipeline = ( if (userConfig) { const ruleIdentifier = `${current.plugin}/${current.rule}`; - if (isRuleIgnored(ruleIdentifier)) return null; + if (isRuleIgnored(ruleIdentifier)) return suppress(current, "config"); if (isFileIgnoredByPatterns(current.filePath, rootDirectory, ignoredFilePatterns)) { return null; } - if (isDiagnosticIgnoredByOverrides(current, rootDirectory, compiledOverrides)) return null; + if (isDiagnosticIgnoredByOverrides(current, rootDirectory, compiledOverrides)) { + return suppress(current, "override"); + } if (isRnRawTextSuppressedByConfig(current)) return null; if (isJsxNoUndefSuppressedByConfig(current)) return null; } @@ -254,7 +279,7 @@ export const buildDiagnosticPipeline = ( const ruleIdentifier = `${current.plugin}/${current.rule}`; const diagnosticLineIndex = current.line - 1; const evaluation = evaluateSuppression(lines, diagnosticLineIndex, ruleIdentifier); - if (evaluation.isSuppressed) return null; + if (evaluation.isSuppressed) return suppress(current, "inline"); if (evaluation.nearMissHint) { current = { ...current, suppressionHint: evaluation.nearMissHint }; } @@ -268,5 +293,6 @@ export const buildDiagnosticPipeline = ( return current; }, + summarizeSuppressions: () => [...suppressions.values()], }; }; diff --git a/packages/core/src/rule-key-aliases.ts b/packages/core/src/rule-key-aliases.ts index 537509eadc..abae923dc3 100644 --- a/packages/core/src/rule-key-aliases.ts +++ b/packages/core/src/rule-key-aliases.ts @@ -168,6 +168,19 @@ const isReactDoctorShortIdOf = (bareRuleKey: string, qualifiedRuleKey: string): !bareRuleKey.includes("/") && qualifiedRuleKey === `${REACT_DOCTOR_RULE_KEY_PREFIX}${bareRuleKey}`; +/** + * Canonicalizes a rule key as users write it in config: a legacy alias + * (`react/jsx-key`) maps to its native key, and a bare short id (`no-eval`) + * qualifies as `react-doctor/` — mirroring `isSameRuleKey`'s matching — + * so telemetry groups every spelling of one rule under one key. + */ +export const canonicalizeUserRuleKey = (ruleKey: string): string => { + const nativeRuleKey = canonicalizeRuleKey(ruleKey); + return nativeRuleKey.includes("/") + ? nativeRuleKey + : `${REACT_DOCTOR_RULE_KEY_PREFIX}${nativeRuleKey}`; +}; + export const isSameRuleKey = (candidateRuleKey: string, targetRuleKey: string): boolean => { const canonicalCandidate = canonicalizeRuleKey(candidateRuleKey); const canonicalTarget = canonicalizeRuleKey(targetRuleKey); diff --git a/packages/core/src/run-inspect.ts b/packages/core/src/run-inspect.ts index adbea57f65..9826ec8a18 100644 --- a/packages/core/src/run-inspect.ts +++ b/packages/core/src/run-inspect.ts @@ -11,6 +11,7 @@ import type { ProjectInfo, ReactDoctorConfig, ScoreResult, + SuppressedRuleCount, } from "./types/index.js"; import { assignFixGroups } from "./utils/assign-fix-groups.js"; import { sortDiagnosticsStable } from "./utils/sort-diagnostics-stable.js"; @@ -220,6 +221,16 @@ export interface InspectOutput { */ readonly lintCacheHitFileCount: number | null; readonly lintCacheTotalFileCount: number | null; + /** + * Per-rule tallies of diagnostics the pipeline dropped because the user + * explicitly silenced the rule (config off switches, per-path overrides, + * inline disable comments) — see `DiagnosticPipeline.summarizeSuppressions`. + * Telemetry-only; NOT part of the public `inspect()` `InspectResult`. Note + * that a `rules: "off"` lint rule is removed from the generated oxlint + * config upstream and never fires, so its findings can't be counted here — + * the CLI's scan-level `rule.disabled` counter covers that case. + */ + readonly suppressedRuleCounts: ReadonlyArray; } /** @@ -912,6 +923,7 @@ export const runInspect = ( supplyChainOverlapTimedOut: supplyChainResult.timedOut, lintCacheHitFileCount, lintCacheTotalFileCount, + suppressedRuleCounts: transform.summarizeSuppressions(), }; }).pipe( Effect.withSpan("runInspect", { diff --git a/packages/core/src/types/diagnostic.ts b/packages/core/src/types/diagnostic.ts index d4f7e41c61..3108ca0aa8 100644 --- a/packages/core/src/types/diagnostic.ts +++ b/packages/core/src/types/diagnostic.ts @@ -104,6 +104,20 @@ export interface CleanedDiagnostic { help: string; } +/** + * Per-rule tally of diagnostics the user explicitly silenced, aggregated by + * how: a config-level off switch (`rules: "off"` / `ignore.rules`), a + * per-path `ignore.overrides` entry, or an inline `react-doctor-disable*` + * comment. Telemetry-only — the rule-quality signal for which rules users + * reject — never rendered, scored, or part of the JSON report. + */ +export interface SuppressedRuleCount { + /** Canonical `/` key (see `getDiagnosticRuleIdentity`). */ + readonly rule: string; + readonly source: "config" | "override" | "inline"; + readonly count: number; +} + /** * A discovered source file paired with its on-disk byte size. The size is * the single `fs.statSync` the minified-file gate already pays during diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 5e17c9b8ca..b110b1c3d5 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -25,6 +25,7 @@ export type { DiagnosticRelatedLocation, OxlintOutput, SourceFileEntry, + SuppressedRuleCount, } from "./diagnostic.js"; export type { HandleErrorOptions } from "./handle-error.js"; export type { diff --git a/packages/core/tests/merge-and-filter-diagnostics.test.ts b/packages/core/tests/merge-and-filter-diagnostics.test.ts index d9adeb09b2..58b77b257e 100644 --- a/packages/core/tests/merge-and-filter-diagnostics.test.ts +++ b/packages/core/tests/merge-and-filter-diagnostics.test.ts @@ -3,8 +3,9 @@ import os from "node:os"; import * as path from "node:path"; import { afterAll, describe, expect, it } from "vite-plus/test"; -import type { Diagnostic } from "@react-doctor/core"; +import type { Diagnostic, ReactDoctorConfig } from "@react-doctor/core"; import { + buildDiagnosticPipeline, clearAutoSuppressionCaches, createNodeReadFileLinesSync, mergeAndFilterDiagnostics, @@ -163,3 +164,64 @@ describe("mergeAndFilterDiagnostics — test-noise tag auto-suppression for asyn expect(filtered).toHaveLength(1); }); }); + +describe("buildDiagnosticPipeline — summarizeSuppressions", () => { + const readNoop = () => null; + const buildPipeline = ( + userConfig: ReactDoctorConfig | null, + rootDirectory: string = path.join(tempRoot, "suppression-summary"), + readFileLinesSync: (filePath: string) => string[] | null = readNoop, + ) => + buildDiagnosticPipeline({ + rootDirectory, + userConfig, + readFileLinesSync, + respectInlineDisables: true, + showWarnings: true, + }); + + it("tallies rules dropped via severity `off` and `ignore.rules` as `config`", () => { + const pipeline = buildPipeline({ + rules: { "react-doctor/no-derived-state-effect": "off" }, + ignore: { rules: ["react-doctor/test-rule"] }, + }); + expect(pipeline.apply(baseDiagnostic())).toBeNull(); + expect(pipeline.apply(baseDiagnostic({ filePath: "src/other.tsx" }))).toBeNull(); + expect(pipeline.apply(buildDiagnostic({ line: 3 }))).toBeNull(); + expect(pipeline.summarizeSuppressions()).toEqual([ + { rule: "react-doctor/no-derived-state-effect", source: "config", count: 2 }, + { rule: "react-doctor/test-rule", source: "config", count: 1 }, + ]); + }); + + it("tallies per-path `ignore.overrides` drops as `override` and leaves survivors uncounted", () => { + const pipeline = buildPipeline({ + ignore: { + overrides: [{ files: ["src/legacy/**"], rules: ["react-doctor/no-derived-state-effect"] }], + }, + }); + expect(pipeline.apply(baseDiagnostic({ filePath: "src/legacy/app.tsx" }))).toBeNull(); + expect(pipeline.apply(baseDiagnostic())).not.toBeNull(); + expect(pipeline.summarizeSuppressions()).toEqual([ + { rule: "react-doctor/no-derived-state-effect", source: "override", count: 1 }, + ]); + }); + + it("tallies inline disable comments as `inline`", () => { + const projectDir = setupCase( + "suppression-summary-inline", + `// react-doctor-disable-next-line react-doctor/no-derived-state-effect\nconst x = 1;\n`, + ); + const pipeline = buildPipeline(null, projectDir, createNodeReadFileLinesSync(projectDir)); + expect(pipeline.apply(baseDiagnostic())).toBeNull(); + expect(pipeline.summarizeSuppressions()).toEqual([ + { rule: "react-doctor/no-derived-state-effect", source: "inline", count: 1 }, + ]); + }); + + it("does not count file-level `ignore.files` drops — they reject a path, not a rule", () => { + const pipeline = buildPipeline({ ignore: { files: ["src/skip.tsx"] } }); + expect(pipeline.apply(baseDiagnostic({ filePath: "src/skip.tsx" }))).toBeNull(); + expect(pipeline.summarizeSuppressions()).toEqual([]); + }); +}); diff --git a/packages/react-doctor/src/cli/utils/build-run-event.ts b/packages/react-doctor/src/cli/utils/build-run-event.ts index 96049ad54f..8f6523279d 100644 --- a/packages/react-doctor/src/cli/utils/build-run-event.ts +++ b/packages/react-doctor/src/cli/utils/build-run-event.ts @@ -4,7 +4,12 @@ import { resolveGithubActionsScoreMetadata, summarizeDiagnostics, } from "@react-doctor/core"; -import type { BlockingLevel, InspectResult, ReactDoctorConfig } from "@react-doctor/core"; +import type { + BlockingLevel, + InspectResult, + ReactDoctorConfig, + SuppressedRuleCount, +} from "@react-doctor/core"; import { buildRuleBlastRadii } from "./diagnostic-grouping.js"; import { ACTION_INPUT_ENVIRONMENT_VARIABLES, detectRunnerOs } from "./is-ci-environment.js"; import { summarizeRuleFirings } from "./record-scan-metrics.js"; @@ -75,6 +80,14 @@ export interface RunEventInput { // A degraded baseline run (no delta computed) skips the CI gate, so the // `wouldBlock` prediction must match — never block on its plain-diff findings. readonly gateExempt?: boolean; + /** + * Per-rule tallies of findings the user explicitly silenced (config off + * switch / per-path override / inline disable comment), from the scan + * payload — so a cache hit replays them. Rolled up to the `diag.suppressed*` + * dims; per-rule identity rides the `rule.suppressed` counter instead + * (100+ rules would blow up the attribute set). Omitted on the failure path. + */ + readonly suppressedRuleCounts?: ReadonlyArray; /** Present only when the scan threw. */ readonly error?: unknown; } @@ -195,6 +208,22 @@ const buildOutcomeAttributes = (input: RunEventInput): RunEventAttributes => { categoryRollup[`category.${toCategoryKey(category)}`] = count; } + // Findings the user explicitly silenced, by mechanism — the per-scan + // complement of the `rule.suppressed` counter (which carries rule identity). + // Absent (not zero) when the caller couldn't supply the tallies. + const suppressionRollup: RunEventAttributes = {}; + if (input.suppressedRuleCounts) { + const countBySource = { config: 0, override: 0, inline: 0 }; + for (const suppression of input.suppressedRuleCounts) { + countBySource[suppression.source] += suppression.count; + } + suppressionRollup.suppressed = + countBySource.config + countBySource.override + countBySource.inline; + suppressionRollup.suppressedConfig = countBySource.config; + suppressionRollup.suppressedOverride = countBySource.override; + suppressionRollup.suppressedInline = countBySource.inline; + } + const attributes: RunEventAttributes = { ...withNamespace("outcome", { status: outcome, @@ -216,6 +245,7 @@ const buildOutcomeAttributes = (input: RunEventInput): RunEventAttributes => { fixGroups: findingsPerFixGroup.size, fixGroupedFindings, ...categoryRollup, + ...suppressionRollup, }), ...withNamespace("score", { value: result.score ? result.score.score : null, diff --git a/packages/react-doctor/src/cli/utils/constants.ts b/packages/react-doctor/src/cli/utils/constants.ts index 6f19d7e570..7807694be5 100644 --- a/packages/react-doctor/src/cli/utils/constants.ts +++ b/packages/react-doctor/src/cli/utils/constants.ts @@ -26,7 +26,8 @@ export const BASELINE_FILES_TEMP_DIR_PREFIX = "react-doctor-baseline-"; // `readPersistedCache` instead of deserializing into an invalid payload. // Bumped to 2: `CachedScanPayload` gained the required `supplyChainOverlapTimedOut` // (supply-chain overlap) and `deadCodeOverlapped` (dead-code overlap) fields. -export const SCAN_RESULT_CACHE_SCHEMA_VERSION = 2; +// Bumped to 3: gained the required `suppressedRuleCounts` field (suppression telemetry). +export const SCAN_RESULT_CACHE_SCHEMA_VERSION = 3; export const SCAN_RESULT_CACHE_MAX_ENTRY_COUNT = 20; export const CACHE_FILENAME_HASH_LENGTH_CHARS = 16; @@ -168,6 +169,13 @@ export const METRIC = { scanCheckSkipped: "scan.check_skipped", baselineDegraded: "baseline.degraded", ruleFired: "rule.fired", + // Rule-rejection telemetry, both keyed by `rule` + `source` attributes: + // `rule.disabled` counts one per scan per config-off rule (`rules: "off"` / + // `ignore.rules` — the former never fires, so this is its only signal); + // `rule.suppressed` counts findings the pipeline dropped per user silencing + // (config / per-path override / inline disable comment). + ruleDisabled: "rule.disabled", + ruleSuppressed: "rule.suppressed", lintFailed: "lint.failed", deadCodeFailed: "deadcode.failed", scoreUnavailable: "score.unavailable", diff --git a/packages/react-doctor/src/cli/utils/record-scan-metrics.ts b/packages/react-doctor/src/cli/utils/record-scan-metrics.ts index edd08a37aa..851e44cb65 100644 --- a/packages/react-doctor/src/cli/utils/record-scan-metrics.ts +++ b/packages/react-doctor/src/cli/utils/record-scan-metrics.ts @@ -1,5 +1,10 @@ -import { getDiagnosticRuleIdentity } from "@react-doctor/core"; -import type { Diagnostic, InspectResult } from "@react-doctor/core"; +import { canonicalizeUserRuleKey, getDiagnosticRuleIdentity } from "@react-doctor/core"; +import type { + Diagnostic, + InspectResult, + ReactDoctorConfig, + SuppressedRuleCount, +} from "@react-doctor/core"; import { METRIC } from "./constants.js"; import { recordCount, recordDistribution } from "./record-metric.js"; @@ -43,6 +48,36 @@ export const summarizeRuleFirings = (diagnostics: ReadonlyArray): Ru return [...firings.values()]; }; +export interface DisabledRule { + readonly rule: string; + readonly source: "rules" | "ignore"; +} + +/** + * Enumerates the rules a config turns off entirely — `rules: { x: "off" }` + * entries and the `ignore.rules` list — with keys canonicalized so every + * spelling of one rule (legacy alias, bare short id) groups under one + * `rule` attribute in Sentry. Deliberately scan-independent: an `"off"` + * lint rule is removed from the generated oxlint config and never fires, + * so the per-diagnostic `rule.suppressed` counter can't see it. + */ +export const summarizeDisabledRules = (userConfig: ReactDoctorConfig | null): DisabledRule[] => { + const disabledRules = new Map(); + const record = (configuredRuleKey: string, source: DisabledRule["source"]): void => { + const rule = canonicalizeUserRuleKey(configuredRuleKey); + disabledRules.set(`${rule}\u0000${source}`, { rule, source }); + }; + for (const [ruleKey, severity] of Object.entries(userConfig?.rules ?? {})) { + if (severity === "off") record(ruleKey, "rules"); + } + if (Array.isArray(userConfig?.ignore?.rules)) { + for (const ruleKey of userConfig.ignore.rules) { + if (typeof ruleKey === "string") record(ruleKey, "ignore"); + } + } + return [...disabledRules.values()]; +}; + export interface ScanMetricsInput { readonly result: InspectResult; /** `"diff"` (changed/staged files) or `"full"` (whole project). */ @@ -65,6 +100,10 @@ export interface ScanMetricsInput { readonly didDeadCodeFail: boolean; /** A baseline run that couldn't compute a delta and fell back to a plain diff. */ readonly baselineDegraded: boolean; + /** Feeds the scan-level `rule.disabled` counter (see `summarizeDisabledRules`). */ + readonly userConfig: ReactDoctorConfig | null; + /** `CachedScanPayload["suppressedRuleCounts"]` — present on fresh and cache-hit paths. */ + readonly suppressedRuleCounts: ReadonlyArray; } /** @@ -117,6 +156,15 @@ export const recordScanMetrics = (input: ScanMetricsInput): void => { severity: firing.severity, }); } + for (const disabled of summarizeDisabledRules(input.userConfig)) { + recordCount(METRIC.ruleDisabled, 1, { rule: disabled.rule, source: disabled.source }); + } + for (const suppression of input.suppressedRuleCounts) { + recordCount(METRIC.ruleSuppressed, suppression.count, { + rule: suppression.rule, + source: suppression.source, + }); + } // "Clean" means the scan actually completed and found nothing — not that a // failed/incomplete run (lint or dead-code failed, a check was skipped) // happened to produce zero diagnostics. `skippedChecks` already includes diff --git a/packages/react-doctor/src/cli/utils/scan-result-cache.ts b/packages/react-doctor/src/cli/utils/scan-result-cache.ts index f631bb4736..caa0de2a68 100644 --- a/packages/react-doctor/src/cli/utils/scan-result-cache.ts +++ b/packages/react-doctor/src/cli/utils/scan-result-cache.ts @@ -10,6 +10,7 @@ import type { InspectResult, ReactDoctorConfig, ScoreResult, + SuppressedRuleCount, } from "@react-doctor/core"; import { CACHE_FILENAME_HASH_LENGTH_CHARS, @@ -47,6 +48,12 @@ export interface CachedScanPayload { */ readonly scanConcurrency?: number; readonly supplyChainOverlapTimedOut: boolean; + /** + * `InspectOutput["suppressedRuleCounts"]` — deterministic for a given + * commit + config (part of the cache key), so a cache hit replays the same + * suppression telemetry the fresh scan emitted. + */ + readonly suppressedRuleCounts: ReadonlyArray; } interface PersistedScanResultCacheEntry { diff --git a/packages/react-doctor/src/inspect.ts b/packages/react-doctor/src/inspect.ts index c4e8e1a9ff..ef9ccac163 100644 --- a/packages/react-doctor/src/inspect.ts +++ b/packages/react-doctor/src/inspect.ts @@ -709,6 +709,7 @@ const runInspectWithRuntime = async ( ? "native-binding-missing" : output.lintFailureReasonKind, supplyChainOverlapTimedOut: output.supplyChainOverlapTimedOut, + suppressedRuleCounts: output.suppressedRuleCounts, }; if (cacheKey !== null && scanResultCache !== null && shouldStoreScanPayload(payload)) { scanResultCache.store(cacheKey, payload); @@ -844,6 +845,8 @@ const renderAndRecordScan = async (input: RenderAndRecordScanInput): Promise { expect(withoutDrops["lint.droppedFileCount"]).toBeUndefined(); }); + it("rolls suppressed findings up by source and drops the dims when tallies are absent", () => { + const attributes = buildRunEventAttributes( + baseInput({ + result: buildResult(), + suppressedRuleCounts: [ + { rule: "react-doctor/no-danger", source: "config", count: 3 }, + { rule: "react-doctor/jsx-key", source: "inline", count: 2 }, + { rule: "react-doctor/alt-text", source: "inline", count: 1 }, + ], + }), + ); + expect(attributes["diag.suppressed"]).toBe(6); + expect(attributes["diag.suppressedConfig"]).toBe(3); + expect(attributes["diag.suppressedOverride"]).toBe(0); + expect(attributes["diag.suppressedInline"]).toBe(3); + + // Absent tallies (e.g. the failure path) read as "unknown", not zero. + const withoutTallies = buildRunEventAttributes(baseInput({ result: buildResult() })); + expect(withoutTallies["diag.suppressed"]).toBeUndefined(); + }); + it("captures config shape and drops null/undefined-valued attributes", () => { const attributes = buildRunEventAttributes( baseInput({ diff --git a/packages/react-doctor/tests/record-scan-metrics.test.ts b/packages/react-doctor/tests/record-scan-metrics.test.ts index 4b4c752c7b..d3de2fb7f8 100644 --- a/packages/react-doctor/tests/record-scan-metrics.test.ts +++ b/packages/react-doctor/tests/record-scan-metrics.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; import type { Diagnostic } from "@react-doctor/core"; -import { summarizeRuleFirings } from "../src/cli/utils/record-scan-metrics.js"; +import { + summarizeDisabledRules, + summarizeRuleFirings, +} from "../src/cli/utils/record-scan-metrics.js"; const buildDiagnostic = (overrides: Partial): Diagnostic => ({ filePath: "src/App.tsx", @@ -60,3 +63,35 @@ describe("summarizeRuleFirings", () => { expect(summarizeRuleFirings([])).toEqual([]); }); }); + +describe("summarizeDisabledRules", () => { + it("lists `rules: off` entries with canonicalized keys and skips warn/error overrides", () => { + const disabledRules = summarizeDisabledRules({ + rules: { + "react/jsx-key": "off", + "no-eval": "off", + "react-doctor/no-danger": "warn", + }, + }); + expect(disabledRules).toEqual([ + { rule: "react-doctor/jsx-key", source: "rules" }, + { rule: "react-doctor/no-eval", source: "rules" }, + ]); + }); + + it("lists `ignore.rules` entries, deduping alias spellings of one rule per source", () => { + const disabledRules = summarizeDisabledRules({ + rules: { "react-doctor/jsx-key": "off" }, + ignore: { rules: ["react/jsx-key", "react-doctor/jsx-key"] }, + }); + expect(disabledRules).toEqual([ + { rule: "react-doctor/jsx-key", source: "rules" }, + { rule: "react-doctor/jsx-key", source: "ignore" }, + ]); + }); + + it("returns an empty list for a null or rule-free config", () => { + expect(summarizeDisabledRules(null)).toEqual([]); + expect(summarizeDisabledRules({})).toEqual([]); + }); +}); diff --git a/packages/react-doctor/tests/scan-result-cache.test.ts b/packages/react-doctor/tests/scan-result-cache.test.ts index 6f7c0b7f43..71347b99ce 100644 --- a/packages/react-doctor/tests/scan-result-cache.test.ts +++ b/packages/react-doctor/tests/scan-result-cache.test.ts @@ -269,6 +269,8 @@ describe("scan result cache", () => { scanElapsedMilliseconds: firstResult.scanElapsedMilliseconds ?? 0, baselineDelta: undefined, lintFailureReasonKind: null, + supplyChainOverlapTimedOut: false, + suppressedRuleCounts: [], }); const verboseResult = await inspect(projectDirectory, {