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/file-context-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-doctor": patch
---

Diagnostics in test, spec, fixture, and Storybook files are now labeled with their file context. The terminal report and the per-rule text dumps tag those sites as `(test file)` / `(story file)` so a finding in a spec doesn't read as a production problem, and each diagnostic in the JSON report carries an optional `fileContext` field (`"test"` / `"story"`; omitted for production files). The classification reuses the same path heuristics that already drive test-noise auto-suppression, so the label and the suppression can never disagree.
28 changes: 20 additions & 8 deletions packages/core/src/build-diagnostic-pipeline.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import reactDoctorPlugin from "oxlint-plugin-react-doctor";
import type { Diagnostic, ReactDoctorConfig, RuleSeverityOverride } from "./types/index.js";
import type {
Diagnostic,
DiagnosticFileContext,
ReactDoctorConfig,
RuleSeverityOverride,
} from "./types/index.js";
import {
compileIgnoreOverrides,
isDiagnosticIgnoredByOverrides,
Expand All @@ -9,7 +14,7 @@ import { buildRuleSeverityControls } from "./build-rule-severity-controls.js";
import { evaluateSuppression } from "./evaluate-suppression.js";
import { getDiagnosticRuleIdentity } from "./get-diagnostic-rule-identity.js";
import { compileIgnoredFilePatterns, isFileIgnoredByPatterns } from "./is-ignored-file.js";
import { isTestFilePath } from "./is-test-file.js";
import { classifyFileContext } from "./classify-file-context.js";
import { resolveRuleSeverityOverride } from "./resolve-rule-severity-override.js";
import { isSameRuleKey } from "./rule-key-aliases.js";
import { APP_ONLY_RULE_KEYS } from "./constants.js";
Expand Down Expand Up @@ -60,6 +65,8 @@ const collectStringSet = (values: unknown): ReadonlySet<string> => {
* 5. `rn-no-raw-text` suppression via configured `textComponents` and
* `rawTextWrapperComponents` (config-driven JSX enclosure checks)
* 6. inline suppressions (`// react-doctor-disable-next-line ...`)
* 7. file-context stamping (`fileContext: "test" | "story"` on
* survivors in non-production files, so renderers can label them)
*
* Returns `null` when the diagnostic is dropped, the (possibly
* severity-restamped) diagnostic otherwise.
Expand Down Expand Up @@ -87,7 +94,7 @@ export const buildDiagnosticPipeline = (
const hasTextComponents = textComponentNames.size > 0;
const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
const fileLinesCache = new Map<string, string[] | null>();
const testFileCache = new Map<string, boolean>();
const fileContextCache = new Map<string, DiagnosticFileContext>();
const libraryFileCache = new Map<string, boolean>();

// App-only rules (`static-components`, `no-render-prop-children`) describe
Expand All @@ -113,11 +120,11 @@ export const buildDiagnosticPipeline = (
return lines;
};

const isTest = (filePath: string): boolean => {
let cached = testFileCache.get(filePath);
const getFileContext = (filePath: string): DiagnosticFileContext => {
let cached = fileContextCache.get(filePath);
if (cached === undefined) {
cached = isTestFilePath(filePath);
testFileCache.set(filePath, cached);
cached = classifyFileContext(filePath);
fileContextCache.set(filePath, cached);
}
return cached;
};
Expand All @@ -127,7 +134,7 @@ export const buildDiagnosticPipeline = (
const rule = reactDoctorPlugin.rules[diagnostic.rule];
if (!rule?.tags?.includes("test-noise")) return false;
if (rule.tags.includes("migration-hint")) return false;
return isTest(diagnostic.filePath);
return getFileContext(diagnostic.filePath) !== "production";
};

const isRuleIgnored = (ruleIdentifier: string): boolean => {
Expand Down Expand Up @@ -235,6 +242,11 @@ export const buildDiagnosticPipeline = (
}
}

const fileContext = getFileContext(current.filePath);
if (fileContext !== "production") {
current = { ...current, fileContext };
}

return current;
},
};
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/calculate-score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@ const ScoreApiResponseSchema = Schema.Struct({
const parseScoreResult = (value: unknown): ScoreResult | null =>
Option.getOrNull(Schema.decodeUnknownOption(ScoreApiResponseSchema)(value));

const stripFilePaths = (diagnostics: Diagnostic[]): Omit<Diagnostic, "filePath">[] =>
diagnostics.map(({ filePath: _filePath, ...rest }) => rest);
// `filePath` never leaves the machine, and `fileContext` is derived
// from it — neither rides the Score API payload, which keeps the
// request shape identical to what the server has always received.
const stripLocalFileFields = (
diagnostics: Diagnostic[],
): Omit<Diagnostic, "filePath" | "fileContext">[] =>
diagnostics.map(({ filePath: _filePath, fileContext: _fileContext, ...rest }) => rest);

const isAbortError = (error: unknown): boolean =>
error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
Expand Down Expand Up @@ -66,7 +71,7 @@ export const calculateScore = async (

try {
const requestBody = JSON.stringify({
diagnostics: stripFilePaths(diagnostics),
diagnostics: stripLocalFileFields(diagnostics),
...(options.metadata?.repo ? { repo: options.metadata.repo } : {}),
...(options.metadata?.sha ? { sha: options.metadata.sha } : {}),
...(options.metadata?.framework ? { framework: options.metadata.framework } : {}),
Expand Down
83 changes: 83 additions & 0 deletions packages/core/src/classify-file-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { DiagnosticFileContext } from "./types/index.js";

// HACK: most rules in react-doctor's curated set encode "you wouldn't
// want this in production code" — but tests intentionally exercise
// bad patterns to lock in regression coverage (an array-index key in a
// test fixture, a giant fixture component, a `forwardRef` to verify
// ref forwarding). Surface-level path matching is enough to catch the
// near-universal test conventions: `.test.*` / `.spec.*` suffix, or
// living under a `__tests__` / `tests` / `test` directory.
//
// We use combined regexes against the forward-slash relative path so
// the match is allocation-free per diagnostic. The story and test
// suffix patterns share one extension grammar so they can never drift
// apart — `isTestFilePath` is derived from this classifier, making
// "the label and the suppression always agree" structural.
const SCRIPT_EXTENSION_FRAGMENT = "[cm]?[jt]sx?";
const STORY_FILE_SUFFIX_PATTERN = new RegExp(
`\\.(?:stories|story)\\.(?:${SCRIPT_EXTENSION_FRAGMENT})$`,
);
const TEST_FILE_SUFFIX_PATTERN = new RegExp(
`\\.(?:test|spec|fixture|fixtures)\\.(?:${SCRIPT_EXTENSION_FRAGMENT})$`,
);
const TEST_FILE_DIRECTORY_PATTERN =
/(?:^|\/)(?:__tests__|__test__|tests|test|__mocks__|cypress|e2e|playwright)\//;

// "Source root" markers — once a path contains `/src/`, `/app/`,
// `/lib/`, `/pages/`, etc., everything BELOW that is production code
// regardless of how the project is laid out above. Critical for test
// fixture projects (`tests/fixtures/<proj>/src/...`) so the FIXTURE
// source files don't get auto-suppressed just because the outer wrap
// happens to have `/tests/` or `/fixtures/` in the path.
//
// We only strip when a `/fixtures/` (or `/__fixtures__/`) segment is
// present, because that's the unambiguous signal of "fixture project —
// the inner source root is the real production code under lint".
// For regular layouts like `tests/app/setup.ts` or `e2e/components/
// helpers.ts`, the `app` / `components` segment is just a sub-folder
// of the test directory, NOT a real source root, so stripping would
// incorrectly drop the `tests/` / `e2e/` prefix and the file would
// no longer be detected as a test.
const FIXTURE_PROJECT_PATTERN = /\/(?:fixtures|__fixtures__)\//;
const SOURCE_ROOT_PATTERN =
/\/(?:src|app|lib|components|pages|features|modules|packages|apps|frontend|client)\//g;

const stripAboveSourceRoot = (relativePath: string): string => {
const fixtureMatch = FIXTURE_PROJECT_PATTERN.exec(relativePath);
if (fixtureMatch === null) return relativePath;
let lastIdx = -1;
for (const match of relativePath.matchAll(SOURCE_ROOT_PATTERN)) {
if (match.index !== undefined && match.index > lastIdx) lastIdx = match.index;
}
if (lastIdx >= 0) return relativePath.slice(lastIdx);
// No inner source-root marker — strip up through the fixture segment
// so the outer `tests/` / `e2e/` prefix can't re-trigger the test
// heuristic on fixture production files like
// `tests/fixtures/my-app/Component.tsx`. The fixture itself is the
// unit under test; its contents are production-shaped code.
return relativePath.slice(fixtureMatch.index + fixtureMatch[0].length - 1);
};

/**
* Classifies where a file sits relative to shipped code. A finding in a
* `.stories.tsx` or `.spec.ts` file never runs in front of users, so
* renderers label those sites instead of framing them as production
* impact (`rn-no-raw-text` in a spec doesn't say users crash).
*
* `"story"` is the `.stories.*` / `.story.*` suffix; `"test"` is the
* test/spec/fixture suffixes and test directories; `"production"` is
* the default.
*/
export const classifyFileContext = (relativePath: string): DiagnosticFileContext => {
if (relativePath.length === 0) return "production";
const forwardSlashed = relativePath.replaceAll("\\", "/");
// The SUFFIX checks (.stories/.test/.spec etc.) are on the FULL path —
// unambiguous regardless of context.
if (STORY_FILE_SUFFIX_PATTERN.test(forwardSlashed)) return "story";
if (TEST_FILE_SUFFIX_PATTERN.test(forwardSlashed)) return "test";
// The DIRECTORY check (`tests/`, `__tests__/`, `cypress/`, etc.)
// scopes to the source-root-below path so that fixture-project
// source files don't get falsely auto-suppressed.
const scoped = stripAboveSourceRoot(forwardSlashed);
return TEST_FILE_DIRECTORY_PATTERN.test(scoped) ? "test" : "production";
};
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export * from "./check-react-native-project.js";
export * from "./check-react-server-components-advisory.js";
export * from "./check-reduced-motion.js";
export * from "./check-supply-chain.js";
export * from "./classify-file-context.js";
export * from "./collect-ignore-patterns.js";
export * from "./compute-diagnostic-delta.js";
export * from "./constants.js";
Expand Down
68 changes: 7 additions & 61 deletions packages/core/src/is-test-file.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,8 @@
// HACK: most rules in react-doctor's curated set encode "you wouldn't
// want this in production code" — but tests intentionally exercise
// bad patterns to lock in regression coverage (an array-index key in a
// test fixture, a giant fixture component, a `forwardRef` to verify
// ref forwarding). Surface-level path matching is enough to catch the
// near-universal test conventions: `.test.*` / `.spec.*` suffix, or
// living under a `__tests__` / `tests` / `test` directory.
//
// We use a single combined regex against the forward-slash relative
// path so the match is allocation-free per diagnostic.
const TEST_FILE_DIRECTORY_PATTERN =
/(?:^|\/)(?:__tests__|__test__|tests|test|__mocks__|cypress|e2e|playwright)\//;
const TEST_FILE_SUFFIX_PATTERN =
/\.(?:test|spec|stories|story|fixture|fixtures)\.(?:[cm]?[jt]sx?)$/;
import { classifyFileContext } from "./classify-file-context.js";

// "Source root" markers — once a path contains `/src/`, `/app/`,
// `/lib/`, `/pages/`, etc., everything BELOW that is production code
// regardless of how the project is laid out above. Critical for test
// fixture projects (`tests/fixtures/<proj>/src/...`) so the FIXTURE
// source files don't get auto-suppressed just because the outer wrap
// happens to have `/tests/` or `/fixtures/` in the path.
//
// We only strip when a `/fixtures/` (or `/__fixtures__/`) segment is
// present, because that's the unambiguous signal of "fixture project —
// the inner source root is the real production code under lint".
// For regular layouts like `tests/app/setup.ts` or `e2e/components/
// helpers.ts`, the `app` / `components` segment is just a sub-folder
// of the test directory, NOT a real source root, so stripping would
// incorrectly drop the `tests/` / `e2e/` prefix and the file would
// no longer be detected as a test.
const FIXTURE_PROJECT_PATTERN = /\/(?:fixtures|__fixtures__)\//;
const SOURCE_ROOT_PATTERN =
/\/(?:src|app|lib|components|pages|features|modules|packages|apps|frontend|client)\//g;

const stripAboveSourceRoot = (relativePath: string): string => {
const fixtureMatch = FIXTURE_PROJECT_PATTERN.exec(relativePath);
if (fixtureMatch === null) return relativePath;
let lastIdx = -1;
for (const match of relativePath.matchAll(SOURCE_ROOT_PATTERN)) {
if (match.index !== undefined && match.index > lastIdx) lastIdx = match.index;
}
if (lastIdx >= 0) return relativePath.slice(lastIdx);
// No inner source-root marker — strip up through the fixture segment
// so the outer `tests/` / `e2e/` prefix can't re-trigger the test
// heuristic on fixture production files like
// `tests/fixtures/my-app/Component.tsx`. The fixture itself is the
// unit under test; its contents are production-shaped code.
return relativePath.slice(fixtureMatch.index + fixtureMatch[0].length - 1);
};

export const isTestFilePath = (relativePath: string): boolean => {
if (relativePath.length === 0) return false;
const forwardSlashed = relativePath.replaceAll("\\", "/");
// The SUFFIX check (.test/.spec/.stories etc.) is on the FULL path —
// unambiguous regardless of context.
if (TEST_FILE_SUFFIX_PATTERN.test(forwardSlashed)) return true;
// The DIRECTORY check (`tests/`, `__tests__/`, `cypress/`, etc.)
// scopes to the source-root-below path so that fixture-project
// source files don't get falsely auto-suppressed.
const scoped = stripAboveSourceRoot(forwardSlashed);
return TEST_FILE_DIRECTORY_PATTERN.test(scoped);
};
// Derived from `classifyFileContext` so the test/story path heuristics
// live in exactly one place: any non-production context (a story file
// is still non-shipping code) counts as a test path for test-noise
// auto-suppression.
export const isTestFilePath = (relativePath: string): boolean =>
classifyFileContext(relativePath) !== "production";
1 change: 1 addition & 0 deletions packages/core/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class Diagnostic extends Schema.Class<Diagnostic>("Diagnostic")({
endLine: Schema.optional(Schema.Number),
endColumn: Schema.optional(Schema.Number),
category: Schema.String,
fileContext: Schema.optional(Schema.Literals(["test", "story"])),
suppressionHint: Schema.optional(Schema.String),
relatedLocations: Schema.optional(Schema.Array(DiagnosticRelatedLocation)),
}) {}
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/types/diagnostic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export interface DiagnosticRelatedLocation {
message: string;
}

export type DiagnosticFileContext = "test" | "story" | "production";

export interface Diagnostic {
filePath: string;
plugin: string;
Expand Down Expand Up @@ -77,6 +79,12 @@ export interface Diagnostic {
/** 1-indexed end column of the primary span, when derivable. */
endColumn?: number;
category: string;
/**
* Set when the file never ships to users (`"test"` / `"story"`), so
* renderers can label the site instead of implying production impact.
* Omitted for production files (the default).
*/
fileContext?: Exclude<DiagnosticFileContext, "production">;
suppressionHint?: string;
/** Secondary source locations (oxlint's non-primary labels). */
relatedLocations?: DiagnosticRelatedLocation[];
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 @@ -21,6 +21,7 @@ export type {
export type {
CleanedDiagnostic,
Diagnostic,
DiagnosticFileContext,
DiagnosticRelatedLocation,
OxlintOutput,
} from "./diagnostic.js";
Expand Down
49 changes: 49 additions & 0 deletions packages/core/tests/classify-file-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vite-plus/test";
import { classifyFileContext } from "@react-doctor/core";

describe("classifyFileContext", () => {
it("classifies `.stories.*` / `.story.*` suffixes as story", () => {
expect(classifyFileContext("src/components/Button.stories.tsx")).toBe("story");
expect(classifyFileContext("src/components/Button.story.tsx")).toBe("story");
expect(classifyFileContext("src/components/Button.stories.jsx")).toBe("story");
expect(classifyFileContext("src/components/Button.story.mts")).toBe("story");
});

it("classifies story files inside test directories as story, not test", () => {
expect(classifyFileContext("__tests__/Button.stories.tsx")).toBe("story");
expect(classifyFileContext("tests/Button.story.tsx")).toBe("story");
});

it("classifies `.test.*` / `.spec.*` suffixes as test", () => {
expect(classifyFileContext("src/utils/foo.test.ts")).toBe("test");
expect(classifyFileContext("src/utils/foo.spec.tsx")).toBe("test");
expect(classifyFileContext("src/utils/foo.test.mjs")).toBe("test");
});

it("classifies fixture suffixes and test directories as test", () => {
expect(classifyFileContext("src/components/Button.fixture.tsx")).toBe("test");
expect(classifyFileContext("src/utils/__tests__/foo.ts")).toBe("test");
expect(classifyFileContext("cypress/e2e/login.cy.ts")).toBe("test");
expect(classifyFileContext("e2e/checkout.ts")).toBe("test");
});

it("normalizes Windows-style backslashes", () => {
expect(classifyFileContext("src\\components\\Button.stories.tsx")).toBe("story");
expect(classifyFileContext("src\\utils\\__tests__\\foo.ts")).toBe("test");
});

it("classifies production source files as production", () => {
expect(classifyFileContext("src/components/Button.tsx")).toBe("production");
expect(classifyFileContext("packages/ui/src/index.ts")).toBe("production");
expect(classifyFileContext("app/page.tsx")).toBe("production");
});

it("classifies fixture-project source files under `fixtures/` as production", () => {
expect(classifyFileContext("tests/fixtures/sample/src/Button.tsx")).toBe("production");
expect(classifyFileContext("tests/__fixtures__/repo/Component.tsx")).toBe("production");
});

it("classifies empty input as production", () => {
expect(classifyFileContext("")).toBe("production");
});
});
Loading
Loading