Skip to content
Draft
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
17 changes: 17 additions & 0 deletions .changeset/ambiguous-paths-inside-source-roots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"oxlint-plugin-react-doctor": patch
---

fix: treat ambiguous paths like /tools/ and /migrations/ as production inside source roots

Rules tagged `test-noise` were silently skipping files in paths containing `/tools/`, `/migrations/`, `/scripts/`, `/cli/`, `/bin/`, etc., even when nested inside application source roots like `src/components/tools/`. These directories are ambiguous: at the repo root they're typically build tooling, but inside source roots they're often feature areas.

The heuristic now distinguishes between:
- **Unambiguous non-production** (test/fixture/story/benchmark/demo/examples): always skipped regardless of depth
- **Ambiguous build tooling** (/tools/, /migrations/, /scripts/, etc.): only skipped at repo root, not inside source roots

This lets test-noise rules correctly fire on production feature areas like `src/components/tools/` while still skipping top-level build tooling like `<repo>/tools/`.

Component library demos (`/demo/`, `/examples/`) remain unambiguous and are skipped everywhere, as they're genuinely non-production even when nested in component source (e.g., `components/Button/demos/`).

Fixes #1724
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Directory names that mark a file as part of a test / fixture /
// Storybook / Cypress / docs-site (`.dumi`) / example surface, regardless
// of the file's own suffix.
const NON_PRODUCTION_PATH_SEGMENTS: ReadonlyArray<string> = [
// Unambiguous non-production directory names: test/fixture/story/benchmark/
// demo/example surfaces that are never shipped code, regardless of nesting depth.
// Includes `/demo/` and `/examples/` because even nested in component source
// (e.g. `components/Button/demos/`), they're demonstration code, not production.
const UNAMBIGUOUS_NON_PRODUCTION_PATH_SEGMENTS: ReadonlyArray<string> = [
"/test/",
"/tests/",
"/testing/",
Expand Down Expand Up @@ -41,11 +42,15 @@ const NON_PRODUCTION_PATH_SEGMENTS: ReadonlyArray<string> = [
"/__benchmarks__/",
"/perf/",
"/perf-tests/",
// CLI / one-shot / build-time tooling — never shipped in the
// user-facing bundle, no render-perf or React-rule concerns. Captures
// top-level `scripts/`, `cli/`, `bin/`, `tooling/`, `tools/`,
// `codemods/`, `migrations/`, `generators/`, `runbooks/`, etc. as well
// as `src/scripts/...` shaped layouts.
];

// Ambiguous directory names: build tooling/infrastructure that marks
// non-production code at the repo root but can be feature areas when
// nested inside source roots. E.g. `<repo>/tools/` is build tooling, but
// `src/components/tools/` is typically an admin-tools UI section.
// Excludes `/demo/`, `/examples/` which remain unambiguous even in component
// libraries (where `components/Button/demos/` is genuinely non-production).
const AMBIGUOUS_NON_PRODUCTION_PATH_SEGMENTS: ReadonlyArray<string> = [
"/scripts/",
"/cli/",
"/bin/",
Expand Down Expand Up @@ -200,7 +205,7 @@ const SOURCE_ROOT_SEGMENTS: ReadonlyArray<string> = [
// like `.dumi/pages/.../components/...` — so they're checked against the
// FULL path, before the source-root scoping below cuts them off.
const DOT_PREFIXED_NON_PRODUCTION_PATH_SEGMENTS: ReadonlyArray<string> =
NON_PRODUCTION_PATH_SEGMENTS.filter((segment) => segment.startsWith("/."));
UNAMBIGUOUS_NON_PRODUCTION_PATH_SEGMENTS.filter((segment) => segment.startsWith("/."));

const sliceBelowSourceRoot = (filename: string): string => {
let cutAt = -1;
Expand Down Expand Up @@ -260,15 +265,28 @@ const computeIsTestlikeFilename = (
// INSIDE the fixture project. Critical for any test runner that
// builds a fake project under a test directory to assert rule
// behaviour.
// Dot-directories (`/.storybook/`, `/.dumi/`) are tooling/docs surfaces
// that can never BE a source root, yet often CONTAIN one (`.dumi/pages/
// index/components/Group.tsx`) — so they're matched against the full
// path, before the source-root cut hides them.
const scopedFilename = sliceBelowSourceRoot(filename);
for (const segment of NON_PRODUCTION_PATH_SEGMENTS) {
const isWithinSourceRoot = scopedFilename !== filename;

// Check UNAMBIGUOUS segments on the scoped path (below source roots).
// These are test/fixture/story/benchmark directories that are never
// shipped code at any depth.
for (const segment of UNAMBIGUOUS_NON_PRODUCTION_PATH_SEGMENTS) {
if (ignoredPathSegments.has(segment)) continue;
const haystack = segment.startsWith("/.") ? filename : scopedFilename;
if (haystack.includes(segment)) return true;
}

// Check AMBIGUOUS segments (tooling/demo/examples/migrations) only when
// the file is NOT within a source root. Once inside `/src/`, `/app/`,
// `/components/`, etc., a directory named `tools` or `demo` is far more
// likely to be a product feature area than build tooling.
if (!isWithinSourceRoot) {
for (const segment of AMBIGUOUS_NON_PRODUCTION_PATH_SEGMENTS) {
if (ignoredPathSegments.has(segment)) continue;
if (filename.includes(segment)) return true;
}
}

return false;
};
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ describe("isTestlikeFilename", () => {

it("recognizes test directories and suffixes below a source root", () => {
expect(isTestlikeFilename("/repo/components/space/__tests__/index.test.tsx")).toBe(true);
expect(isTestlikeFilename("/repo/components/config-provider/demo/direction.tsx")).toBe(true);
});

it("does not suppress source files solely because their basename marks a demo", () => {
Expand All @@ -38,6 +37,96 @@ describe("isTestlikeFilename", () => {
it("keeps fixture-project source roots as production despite outer test wrappers", () => {
expect(isTestlikeFilename("monorepo/tests/fixtures/proj/src/app/page.tsx")).toBe(false);
});

describe("ambiguous path segments (tools/demo/examples/migrations)", () => {
it("treats /tools/ at repo root as non-production", () => {
expect(isTestlikeFilename("/repo/tools/build-script.ts")).toBe(true);
expect(isTestlikeFilename("/repo/tools/cli/command.ts")).toBe(true);
});

it("treats /tools/ inside source roots as production", () => {
expect(isTestlikeFilename("/repo/src/components/tools/widget.tsx")).toBe(false);
expect(isTestlikeFilename("/repo/app/admin/tools/dashboard.tsx")).toBe(false);
expect(isTestlikeFilename("/repo/src/pages/tools/settings.tsx")).toBe(false);
});

it("treats /demo/ as non-production everywhere (component library demos)", () => {
expect(isTestlikeFilename("/repo/demo/app.tsx")).toBe(true);
expect(isTestlikeFilename("/repo/demos/interactive.tsx")).toBe(true);
expect(isTestlikeFilename("/repo/src/components/demo/showcase.tsx")).toBe(true);
expect(isTestlikeFilename("/repo/app/pages/demo/interactive.tsx")).toBe(true);
});

it("treats /examples/ as non-production everywhere (documentation examples)", () => {
expect(isTestlikeFilename("/repo/examples/basic.tsx")).toBe(true);
expect(isTestlikeFilename("/repo/example/usage.tsx")).toBe(true);
expect(isTestlikeFilename("/repo/src/pages/examples/showcase.tsx")).toBe(true);
expect(isTestlikeFilename("/repo/app/components/example/card.tsx")).toBe(true);
});

it("treats /migrations/ at repo root as non-production", () => {
expect(isTestlikeFilename("/repo/migrations/001_init.ts")).toBe(true);
expect(isTestlikeFilename("/repo/migration/setup.ts")).toBe(true);
});

it("treats /migrations/ inside source roots as production", () => {
expect(isTestlikeFilename("/repo/src/admin/migrations/history.tsx")).toBe(false);
expect(isTestlikeFilename("/repo/app/features/migration/status.tsx")).toBe(false);
});

it("treats /scripts/ at repo root as non-production", () => {
expect(isTestlikeFilename("/repo/scripts/deploy.ts")).toBe(true);
});

it("treats /scripts/ inside source roots as production", () => {
expect(isTestlikeFilename("/repo/src/api/scripts/runner.ts")).toBe(false);
});

it("treats other ambiguous segments correctly", () => {
expect(isTestlikeFilename("/repo/cli/index.ts")).toBe(true);
expect(isTestlikeFilename("/repo/src/components/cli/terminal.tsx")).toBe(false);

expect(isTestlikeFilename("/repo/bin/start.ts")).toBe(true);
expect(isTestlikeFilename("/repo/src/features/bin/viewer.tsx")).toBe(false);

expect(isTestlikeFilename("/repo/generators/scaffold.ts")).toBe(true);
expect(isTestlikeFilename("/repo/src/tools/generator/wizard.tsx")).toBe(false);

expect(isTestlikeFilename("/repo/codemods/transform.ts")).toBe(true);
expect(isTestlikeFilename("/repo/src/editor/codemod/apply.tsx")).toBe(false);

expect(isTestlikeFilename("/repo/devtools/panel.tsx")).toBe(true);
expect(isTestlikeFilename("/repo/src/components/devtools/inspector.tsx")).toBe(false);
});
});

describe("unambiguous segments remain consistently non-production", () => {
it("treats /test/ directories as non-production everywhere", () => {
expect(isTestlikeFilename("/repo/test/unit.ts")).toBe(true);
expect(isTestlikeFilename("/repo/src/test/helper.ts")).toBe(true);
expect(isTestlikeFilename("/repo/app/test/setup.ts")).toBe(true);
});

it("treats /fixtures/ as non-production everywhere", () => {
expect(isTestlikeFilename("/repo/fixtures/data.json")).toBe(true);
expect(isTestlikeFilename("/repo/src/fixtures/mock.ts")).toBe(true);
});

it("treats /stories/ as non-production everywhere", () => {
expect(isTestlikeFilename("/repo/stories/Button.stories.tsx")).toBe(true);
expect(isTestlikeFilename("/repo/src/stories/Card.stories.tsx")).toBe(true);
});

it("treats /playground/ as non-production everywhere", () => {
expect(isTestlikeFilename("/repo/playground/test.tsx")).toBe(true);
expect(isTestlikeFilename("/repo/src/playground/sandbox.tsx")).toBe(true);
});

it("treats /benchmarks/ as non-production everywhere", () => {
expect(isTestlikeFilename("/repo/benchmarks/perf.ts")).toBe(true);
expect(isTestlikeFilename("/repo/src/benchmarks/render.ts")).toBe(true);
});
});
});

describe("isTestlikeFilenameIgnoringPathSegments", () => {
Expand Down
Loading