Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rule-ignore-telemetry.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 30 additions & 4 deletions packages/core/src/build-diagnostic-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
DiagnosticFileContext,
ReactDoctorConfig,
RuleSeverityOverride,
SuppressedRuleCount,
} from "./types/index.js";
import {
compileIgnoreOverrides,
Expand Down Expand Up @@ -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<string> => {
Expand Down Expand Up @@ -103,6 +114,18 @@ export const buildDiagnosticPipeline = (
const fileLinesCache = new Map<string, string[] | null>();
const fileContextCache = new Map<string, DiagnosticFileContext>();
const libraryFileCache = new Map<string, boolean>();
const suppressions = new Map<string, SuppressedRuleCount>();

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
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
}
Expand All @@ -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 };
}
Expand All @@ -268,5 +293,6 @@ export const buildDiagnosticPipeline = (

return current;
},
summarizeSuppressions: () => [...suppressions.values()],
};
};
13 changes: 13 additions & 0 deletions packages/core/src/rule-key-aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>` — 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);
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/run-inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<SuppressedRuleCount>;
}

/**
Expand Down Expand Up @@ -912,6 +923,7 @@ export const runInspect = <HooksR = never>(
supplyChainOverlapTimedOut: supplyChainResult.timedOut,
lintCacheHitFileCount,
lintCacheTotalFileCount,
suppressedRuleCounts: transform.summarizeSuppressions(),
};
}).pipe(
Effect.withSpan("runInspect", {
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/types/diagnostic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<plugin>/<rule>` 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
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type {
DiagnosticRelatedLocation,
OxlintOutput,
SourceFileEntry,
SuppressedRuleCount,
} from "./diagnostic.js";
export type { HandleErrorOptions } from "./handle-error.js";
export type {
Expand Down
64 changes: 63 additions & 1 deletion packages/core/tests/merge-and-filter-diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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([]);
});
});
32 changes: 31 additions & 1 deletion packages/react-doctor/src/cli/utils/build-run-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<SuppressedRuleCount>;
/** Present only when the scan threw. */
readonly error?: unknown;
}
Expand Down Expand Up @@ -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,
Expand All @@ -216,6 +245,7 @@ const buildOutcomeAttributes = (input: RunEventInput): RunEventAttributes => {
fixGroups: findingsPerFixGroup.size,
fixGroupedFindings,
...categoryRollup,
...suppressionRollup,
}),
...withNamespace("score", {
value: result.score ? result.score.score : null,
Expand Down
10 changes: 9 additions & 1 deletion packages/react-doctor/src/cli/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading