Skip to content
Open
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
93 changes: 93 additions & 0 deletions .agents/skills/reproer/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
name: reproer
description: Turn React Doctor `rule.evidence` telemetry events or identifier-redacted token patterns into synthetic, parseable React/TypeScript repro hypotheses and fuzz cases. Use when investigating possible false positives or false negatives from an `evidence.pattern`, reconstructing a minimal rule-triggering shape, producing adversarial variants, or promoting a verified hypothesis into the React Doctor fuzz corpus.
---

# Reproer

Generate synthetic programs that could produce the supplied token pattern. Treat every result as a hypothesis, never as recovered source.

## Protect the privacy boundary

- Never claim to reconstruct the original code. The mapping from source to evidence is many-to-one.
- Never search GitHub, the internet, or private repositories to identify a matching source unless the user explicitly requests that separate investigation.
- Invent neutral identifiers and literal values. Do not guess names, domains, secrets, UI copy, package names, or repository identity.
- Preserve an `identifier_N` equality relationship only within one candidate. Do not correlate placeholders across events.
- Keep the telemetry event out of committed fixtures. Commit only synthetic code written from the structural hypothesis.
- Describe reconstruction confidence as structural fidelity, not source fidelity.

## Require the useful inputs

Collect these fields when available:

```text
rule
evidence.pattern
evidence.fileContext
evidence.tokenCount
evidence.truncated
category
severity
```

Require `rule` and `evidence.pattern`. Ask for either field if missing because the rule supplies semantics that the anonymized identifiers no longer carry.

## Build repro hypotheses

1. Locate and read the target rule, its metadata, focused tests, and any existing fuzz fixtures. Use `rg` with the short rule ID.
2. Read `.agents/skills/fuzz/SKILL.md` and `packages/fuzz/README.md` before running the harness or modifying its corpus.
3. Decode the pattern conservatively:
- Map each `identifier_N` to one neutral role name and reuse it within the candidate.
- Replace `string_literal`, `number_literal`, `boolean_literal`, `null_literal`, `bigint_literal`, `regular_expression_literal`, and `template_literal` with harmless canonical values.
- Preserve keywords, operators, delimiters, member access, calls, and identifier-equality relationships.
- Treat `syntax_N` as unknown until checking the matching TypeScript `SyntaxKind`.
- Add the smallest component, hook, import, or function wrapper needed to parse and exercise the rule.
- When `evidence.truncated` is true, close the program into a valid minimal shape without pretending the invented suffix matches the source.
4. Produce a small hypothesis matrix:
- **Nearest shape:** retain the observed token order with only parseability scaffolding.
- **Valid-context shape:** place the syntax in the most ordinary legitimate React or TypeScript context.
- **False-positive probe:** create a valid program where the suspicious shape is intentional or safe.
- **False-negative probes:** change one dimension at a time, such as aliasing, wrappers, optional chaining, parentheses, control flow, TypeScript syntax, JSX placement, or cross-statement data flow.
5. Generate three to eight candidates. Avoid a combinatorial matrix until one candidate reaches the rule's reporting path.

## Verify instead of guessing

Place initial candidates in a temporary directory or an existing focused test harness. Do not add corpus files before checking the verdict.

For every candidate, record:

- whether it parses;
- whether the target rule fires;
- the diagnostic span and message;
- the intended semantic verdict: valid, invalid, or uncertain;
- the mutation dimension that distinguishes it from the nearest shape.

Do not call a case a false positive or false negative from telemetry alone. Confirm the rule contract and the program's semantics first.

Run the narrowest focused rule test. After it passes, run the targeted fuzzer:

```sh
FUZZ_RULE=<short-rule-id> FUZZ_STRICT=1 FUZZ_ITERATIONS=500 nr fuzz
```

Confirm that the target rule fires at least once. A silent run validates only early exits.

## Promote verified cases

- For a confirmed false positive, add a minimal focused valid case and `packages/fuzz/corpus/regressions/<rule-id>--<weakness>.tsx` with `// verdict: pass`.
- For a confirmed true positive or liveness seed, add a minimal focused invalid case and use `packages/fuzz/corpus/targets/` when the harness needs a reporting-path seed.
- For a confirmed false negative, add the missing invalid case to the focused rule tests, fix the detector when requested, and add a generator snippet only when existing pools cannot produce the weakness.
- Use stable weakness names from the fuzz skill. Never label an unverified hypothesis as a regression.
- Run the focused test, `nr -C packages/fuzz test`, and replay the target rule after changing the corpus.

## Report the result

Return:

1. the normalized evidence skeleton;
2. the synthetic candidates and their structural-fidelity notes;
3. the observed rule verdict for each candidate;
4. confirmed FP, FN, or liveness findings, clearly separated from hypotheses;
5. tests, fuzz commands, seeds, and files changed.

State explicitly that the candidates do not recover identifiers, literals, comments, paths, or repository identity.
5 changes: 5 additions & 0 deletions .changeset/bright-rules-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-doctor": patch
---

Collect minimized diagnostic source patterns with score telemetry to help fix false positives and false negatives, with score-level indexing, a one-time interactive disclosure, and a `--no-telemetry` opt-out.
1 change: 1 addition & 0 deletions packages/core/src/request-score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const buildScoreRequestBody = (
return gzipSync(
JSON.stringify({
diagnostics: sanitizeScoreDiagnostics(diagnostics),
...(options.ruleEvidence !== undefined ? { ruleEvidence: options.ruleEvidence } : {}),
...buildScoreRequestMetadata(options.metadata),
}),
);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/run-inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,7 @@ export const runInspect = <HooksR = never>(
diagnostics: scoreDiagnostics,
isCi: input.isCi,
metadata: scoreMetadata,
ruleEvidence: input.collectScoreEvidence?.(scoreDiagnostics),
});
const lintPartialFailures = yield* Ref.get(partialFailuresRef);
const didSecurityScanFail = yield* Ref.get(securityScanFailedRef);
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/services/score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
import * as HttpClient from "effect/unstable/http/HttpClient";
import type { Diagnostic, ScoreRequestMetadata, ScoreResult } from "../types/index.js";
import type {
Diagnostic,
ScoreRequestMetadata,
ScoreResult,
ScoreRuleEvidence,
} from "../types/index.js";
import { requestScore } from "../request-score.js";

interface ComputeInput {
readonly diagnostics: ReadonlyArray<Diagnostic>;
readonly isCi?: boolean;
readonly metadata?: ScoreRequestMetadata;
readonly ruleEvidence?: ReadonlyArray<ScoreRuleEvidence>;
}

export class Score extends Context.Service<
Expand All @@ -27,6 +33,7 @@ export class Score extends Context.Service<
requestScore(httpClient, input.diagnostics, {
isCi: input.isCi,
metadata: input.metadata,
ruleEvidence: input.ruleEvidence,
}),
),
});
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 @@ -66,6 +66,7 @@ export type { PromptMultiselectChoiceState, PromptMultiselectContext } from "./p
// See that file for the duplication rationale.
export type {
CalculateScoreOptions,
ScoreRuleEvidence,
ScoreRequestMetadata,
ScoreResult,
RulePriority,
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/types/run-inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { DiagnosticSurface, ReactDoctorConfig } from "./config.js";
import type { Diagnostic, SourceFileEntry, SuppressedRuleCount } from "./diagnostic.js";
import type { ChangedFileLineRanges } from "./inspect.js";
import type { ProjectInfo } from "./project-info.js";
import type { ScoreRequestMetadata, ScoreResult } from "./score.js";
import type { ScoreRequestMetadata, ScoreResult, ScoreRuleEvidence } from "./score.js";

export interface InspectInput {
readonly directory: string;
Expand Down Expand Up @@ -35,6 +35,9 @@ export interface InspectInput {
readonly doctorVersion?: string;
/** Random per-run id. */
readonly runId?: string;
readonly collectScoreEvidence?: (
diagnostics: ReadonlyArray<Diagnostic>,
) => ReadonlyArray<ScoreRuleEvidence>;
/** Enables best-effort authenticated local GitHub permission lookup for score metadata. */
readonly resolveLocalGithubViewerPermission?: boolean;
/**
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/types/score.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
import type { ProjectInfo } from "./project-info.js";
import type { Diagnostic, DiagnosticFileContext } from "./diagnostic.js";

export type RuleTier = "P0" | "P1" | "P2" | "P3";

export interface CalculateScoreOptions {
isCi?: boolean;
metadata?: ScoreRequestMetadata;
ruleEvidence?: ReadonlyArray<ScoreRuleEvidence>;
}

export interface ScoreRuleEvidence {
readonly schemaVersion: 1;
readonly category: string;
readonly fileContext: DiagnosticFileContext;
readonly pattern: string;
readonly plugin: string;
readonly rule: string;
readonly severity: Diagnostic["severity"];
readonly tokenCount: number;
readonly truncated: boolean;
}

export interface ScoreRequestMetadata {
Expand Down
20 changes: 20 additions & 0 deletions packages/core/tests/calculate-score.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ describe("calculateScore", () => {
githubActorAssociation: "CONTRIBUTOR",
githubViewerPermission: "write",
},
ruleEvidence: [
{
schemaVersion: 1,
category: "State & Effects",
fileContext: "production",
pattern: "identifier_1 ( )",
plugin: "react-doctor",
rule: "react-doctor/example-rule",
severity: "error",
tokenCount: 4,
truncated: false,
},
],
});

expect(result).toEqual(apiScoreResponse);
Expand Down Expand Up @@ -109,6 +122,13 @@ describe("calculateScore", () => {
githubEventName: "pull_request",
githubActorAssociation: "CONTRIBUTOR",
githubViewerPermission: "write",
ruleEvidence: [
{
schemaVersion: 1,
pattern: "identifier_1 ( )",
rule: "react-doctor/example-rule",
},
],
});
});

Expand Down
38 changes: 38 additions & 0 deletions packages/core/tests/run-inspect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
Diagnostic,
ProjectInfo,
ReactDoctorConfig,
ScoreRuleEvidence,
SourceFileEntry,
} from "@react-doctor/core";
import {
Expand Down Expand Up @@ -502,6 +503,43 @@ describe("runInspect — happy path", () => {
expect(output.didDeadCodeFail).toBe(false);
});

it("attaches collected rule evidence to the score request", async () => {
const ruleEvidence: ScoreRuleEvidence = {
schemaVersion: 1,
category: "Correctness",
fileContext: "production",
pattern: "identifier_1 ( )",
plugin: "react-doctor",
rule: "react-doctor/no-derived-state",
severity: "error",
tokenCount: 4,
truncated: false,
};
let receivedRuleEvidence: ReadonlyArray<ScoreRuleEvidence> | undefined;
const scoreLayer = Layer.succeed(
Score,
Score.of({
compute: (input) =>
Effect.sync(() => {
receivedRuleEvidence = input.ruleEvidence;
return { score: 85, label: "Good" };
}),
}),
);

await Effect.runPromise(
runInspect({
...baseInput,
collectScoreEvidence: (diagnostics) => {
expect(diagnostics).toEqual([lintDiagnostic]);
return [ruleEvidence];
},
}).pipe(Effect.provide(layersOf({ diagnostics: [lintDiagnostic], scoreLayer }))),
);

expect(receivedRuleEvidence).toEqual([ruleEvidence]);
});

it("adds local authenticated GitHub viewer permission to score metadata", async () => {
const output = await Effect.runPromise(
runInspect({ ...baseInput, resolveLocalGithubViewerPermission: true }).pipe(
Expand Down
9 changes: 5 additions & 4 deletions packages/react-doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,17 +84,18 @@ URLs, source paths, and React profiling details. Treat it as sensitive applicati

## Telemetry

The CLI reports crashes, basic run traces, and anonymous usage counters to [Sentry](https://sentry.io/) to help us fix bugs and prioritize work.
Telemetry is on by default. The CLI sends crash reports to [Sentry](https://sentry.io/) and usage telemetry to [Axiom](https://axiom.co/). We use this data to operate React Doctor and improve its diagnostic rules.

We collect:

- Environment: CLI version, platform, Node version
- Invocation: which command, package manager, and run context (whether it's local vs. CI vs. coding agent)
- Project shape: framework, React version, TypeScript, project size (NO file contents)
- Rules fired: rule names and counts only (e.g. `react-doctor/no-array-index-as-key`) (NO code or specific findings)
- Project shape: framework, React version, TypeScript, and project size
- Rules fired: rule names and counts (e.g. `react-doctor/no-array-index-as-key`)
- Rule evidence: up to 24 minimized, identifier-redacted token patterns from source spans that trigger diagnostics, with no more than 3 patterns per rule and 160 tokens per pattern. Patterns contain no identifier names, literal contents, comments, or file paths. Score submissions also contain diagnostic file paths after path and secret scrubbing, line and column numbers, diagnostic messages, and help text. Submissions may include repository and commit details. The score service logs the request IP address and user agent. We use the patterns to fix false positives, which are incorrect diagnostics, and false negatives, which are issues that rules miss. React Doctor does not collect complete source files
- De-minified React Doctor CLI stack traces

To opt out, run: `npx react-doctor@latest --no-telemetry`
To disable telemetry and skip the score API and share URL for a run: `npx react-doctor@latest --no-telemetry`

## Contributing

Expand Down
12 changes: 12 additions & 0 deletions packages/react-doctor/src/cli/commands/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { resolveCliInspectOptions } from "../utils/resolve-cli-inspect-options.j
import { resolveTuiEnvironment } from "../utils/resolve-tui-environment.js";
import { warnDeprecatedDiff } from "../utils/resolve-scope.js";
import { shouldUseTui } from "../utils/should-use-tui.js";
import { showTelemetryDisclosureIfNeeded } from "../utils/telemetry-disclosure.js";
import { validateModeFlags } from "../utils/validate-mode-flags.js";
import { warnDeprecatedFailOn } from "../utils/warn-deprecated-fail-on.js";

Expand All @@ -27,6 +28,16 @@ export const runScanCommand = async (input: RunScanCommandInput): Promise<void>

if (!shouldUseTui(tuiEnvironment)) {
await inspectAction(input.directory, input.flags, input.invocationCommand);
showTelemetryDisclosureIfNeeded({
isInteractive:
!tuiEnvironment.isNonInteractiveEnvironment &&
tuiEnvironment.stdinIsTty &&
tuiEnvironment.outputIsTty &&
input.flags.json !== true &&
input.flags.jsonCompact !== true &&
input.flags.jsonOut === undefined &&
input.flags.score !== true,
});
return;
}

Expand All @@ -47,4 +58,5 @@ export const runScanCommand = async (input: RunScanCommandInput): Promise<void>
flags: input.flags,
});
if (shouldFail) process.exitCode = 1;
showTelemetryDisclosureIfNeeded({ isInteractive: true });
};
6 changes: 3 additions & 3 deletions packages/react-doctor/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ const program = new Command()
"scan source files listed in a newline-delimited changed-files file",
).hideHelp(),
)
.option("--no-score", "skip the score API, the share URL, and crash reporting")
.option("--no-score", "skip the score API, the share URL, and all telemetry")
.addOption(
new Option(
"--category <category>",
Expand All @@ -246,7 +246,7 @@ const program = new Command()
)
.option(
"--no-telemetry",
"alias for --no-score (skip the score API, share URL, and crash reporting)",
"disable all telemetry, including identifier-redacted code patterns (also skips the score API and share URL)",
)
.option(
"--staged",
Expand Down Expand Up @@ -521,7 +521,7 @@ program
.addOption(new Option("--no-dead-code").hideHelp())
.option("--no-supply-chain", "skip the dependency supply-chain scan")
.option("--score", "only print the numeric score (for scripts and CI)")
.option("--no-score", "skip the score API, the share URL, and crash reporting")
.option("--no-score", "skip the score API, the share URL, and all telemetry")
.option("--no-cache", "disable all scan caches for this run")
.option("--max-duration <seconds>", MAX_DURATION_OPTION_DESCRIPTION)
.option("-p, --project <names>", "scan specific workspace projects (comma-separated, or *)")
Expand Down
Loading
Loading