mirror of
https://github.com/millionco/react-doctor.git
synced 2026-09-14 20:00:24 +08:00
0b0b5ac523
* feat(rules): add 10 motion, accessibility, and design lint rules
Derived from a cross-resource design reference (every principle from the
design knowledge base), deduped against the full existing rule inventory.
Motion: extend no-transition-all to the Tailwind `transition-all` class
(was inline-only) and add no-tailwind-layout-transition for arbitrary
`transition-[<layout-prop>]`.
Accessibility: no-autoplay-without-muted, no-uninformative-aria-label,
no-target-blank-without-rel, and no-low-contrast-inline-style (computes the
real WCAG 2.1 ratio from co-located inline color + backgroundColor).
Design/Tailwind hygiene: no-redundant-display-class, prefer-truncate-shorthand,
no-full-viewport-width, and no-svg-currentcolor-with-fill-class.
All register via codegen (388 rules); 77 co-located tests pass; typecheck,
lint, and format clean.
* fix(rules): correct two false positives found in rule-validate
- no-low-contrast-inline-style: when the inline style has no `fontSize`,
the text may be sized large via a class (`text-5xl`), so fall back to the
lenient 3:1 large-text threshold instead of 4.5:1 to avoid flagging large
text that only needs 3:1. Stricter 4.5:1 applies only when the size is a
visible normal size.
- no-redundant-display-class: `<li>` defaults to `display: list-item`, not
`block`, so `block` on an `<li>` is meaningful — removed `li` from the
block-default set.
Adds regression tests for both.
* fix(rules): resolve Cursor Bugbot findings
- no-low-contrast-inline-style: use a solid `background` shorthand color as
the background (was skipped entirely, missing low-contrast pairs); skip only
gradients/images/ambiguous (both backgroundColor + background). Also treat a
string `fontWeight: "700"` as bold for the large-text threshold.
- no-transition-all: match `transition-all` as a whole Tailwind token (segment
after variant prefixes) so compound classes like `transition-all-custom` are
not falsely flagged.
- no-svg-currentcolor-with-fill-class: exclude stroke-WIDTH utilities
(`stroke-2`, `stroke-[1.5]`) — they set thickness, not color — so they no
longer false-conflict with `stroke="currentColor"`.
Adds regression tests for each.
* feat(rules): add 3 Tailwind-canonicalization rules from ui.sh guidance
- no-deprecated-tailwind-class: flag Tailwind v4 renames (bg-gradient-* →
bg-linear-*, flex-shrink-* → shrink-*, flex-grow-* → grow-*,
overflow-ellipsis → text-ellipsis). Gated on a new `tailwind:4` capability.
- no-arbitrary-px-font-size: `text-[13px]` → rem so text scales with the
user's root font size (px stays fine for border-*/outline-*).
- prefer-dvh-over-vh: `h-screen`/`min-h-screen`/`h-[100vh]` → dvh (tailwind:3.4).
Adds `tailwind:4` capability to @react-doctor/core and extracts a shared
`get-class-name-tokens` util (variant-aware token splitting), reused by
no-transition-all and no-svg-currentcolor-with-fill-class.
* fix(rules): adversarial-review false positives in the new design rules
- no-tailwind-layout-transition: match transition-[…] property names exactly
(comma-split + set) so SVG `stroke-width` / `border-width` — which merely
contain "width" — are no longer flagged as HTML layout thrash.
- no-full-viewport-width: drop `max-w-*` / `maxWidth` (a defensive cap, not the
overflow footgun); keep `w-screen`/`w-[100vw]`/`width`/`minWidth`.
- prefer-dvh-over-vh: drop `max-h-*` / `maxHeight` (a valid height cap, e.g. a
scrollable modal); keep `h-screen`/`min-h-screen`/`height`/`minHeight`.
- no-deprecated-tailwind-class: only rename `bg-gradient-to-*` → `bg-linear-to-*`
(v4 radial/conic are `bg-radial`/`bg-conic`, not `bg-linear-radial`).
Adds regression tests for each false positive.
* fix(rules): resolve thermo-review false positives
- no-svg-currentcolor-with-fill-class: only treat UNPREFIXED `fill-*`/`stroke-*`
color classes as conflicts; `hover:fill-blue-600` / `dark:fill-white` (state-
gated, no static conflict with the base `fill="currentColor"`) are no longer
flagged — a very common icon pattern.
- no-low-contrast-inline-style: reject 4-arg `rgb(0,0,0,0.5)` / slash-form alpha
(was judged opaque), and bail when the style object contains a `{...spread}`
that could override the colors. Tag `test-noise` to match sibling no-tiny-text.
- no-full-viewport-width: drop the redundant `category: "Architecture"` override
(it's the design-bucket default).
- Extract `has-jsx-spread-attribute` util; use it in the two a11y rules.
Adds regression tests for the variant-prefixed svg classes, rgb-alpha, opaque
rgb, and the style-spread bail.
* refactor(rules): deslop the design/motion/a11y rule additions
Consolidate duplication surfaced by an adversarial deslop pass; no behavior
change (the same diagnostics fire for the same inputs):
- no-autoplay-without-muted: fold isStaticallyTrue/isStaticallyFalse into one
tri-state resolveStaticBoolean, mirroring the existing parseJsxValue idiom
- no-svg-currentcolor-with-fill-class: collapse the duplicated fill/stroke
branches into a single loop over the two paint attributes
- no-low-contrast-inline-style: hoist the repeated `properties` fallback
- no-uncontrolled-input: drop the local hasJsxSpreadAttribute copy in favor of
the branch's new shared utils/has-jsx-spread-attribute, completing the extraction
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(rules): no-deprecated-tailwind-class stops flagging overflow-clip
`overflow-clip` is a current Tailwind utility for `overflow: clip` (v3.1+,
still present in v4) — it was never renamed to `text-clip`, which sets the
unrelated `text-overflow: clip`. The stray mapping produced an incorrect
"renamed in v4" suggestion (Cursor Bugbot). Only `overflow-ellipsis ->
text-ellipsis` is a real rename; keep that. Adds a regression test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(capabilities): require a confirmed Tailwind major for the tailwind:4 gate
`tailwind:4` gates no-deprecated-tailwind-class, which tells users a class was
renamed/removed in v4. The optimistic-on-null policy (assume latest for an
unparseable spec like `workspace:*`) is right for the suggestion rule behind
`tailwind:3.4`, but for a deprecation rule it surfaces confidently-wrong
"renamed in v4" warnings on a v3 project. Require a CONFIRMED parsed major >= 4
for tailwind:4 — favor a false negative over a false positive (Cursor Bugbot).
`tailwind:3.4` stays optimistic. Adds an unparseable-version regression test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(rules): resolve Bugbot/Devin review findings on design/a11y rules
- no-full-viewport-width: match variant-prefixed `md:w-screen` / `lg:min-w-screen`, mirroring the sibling `prefer-dvh-over-vh` regex (overflow happens at every breakpoint).
- no-target-blank-without-rel: resolve `rel={'noopener'}` expression-container literals instead of treating them as dynamic, so reverse-tabnabbing isn't silently missed; share one literal resolver with the `target` check.
- Extract the bare `16` root-font-size divisor into `ROOT_FONT_SIZE_PX` per the magic-number convention.
* fix(rules): no-uninformative-aria-label resolves braced string literals
Extracts no-target-blank-without-rel's inline getStringLiteralAttributeValue
into a shared util and uses it in no-uninformative-aria-label, so a static
braced literal like aria-label={'icon'} is judged instead of silently skipped
(getJsxPropStringValue only handles plain string literals). Adds regression
tests for the braced uninformative and braced descriptive cases.
Addresses Bugbot review on #850.
* test(rules): add liveness fixtures after rebase
* fix(rules): address automated review findings
* fix(rules): handle leading-dot stroke widths
* fix(rules): skip dynamic background overrides
* fix(rules): address target blank and transition reviews
* fix(rules): skip svg layout transitions
* fix(rules): preserve font line height suggestion
* fix(rules): match complete transition utilities
* fix(rules): share layout transition properties
* fix(rules): bound deprecated utility prefixes
* fix(rules): clear dynamically overridden colors
* fix(rules): tighten style and svg precision
* fix(rules): preserve competing display utilities
* fix(rules): preserve animation property scopes
* feat(rules): expand deterministic design checks
* feat(rules): add structural design diagnostics
* feat(rules): complete source-auditable design checks
* fix(rules): skip unresolved input types
* fix(rules): tighten layout and boundary detection
* fix(rules): close motion and padding gaps
* fix(rules): tighten surface and line-height proofs
* fix(rules): evaluate opaque hsl contrast
* fix(rules): honor effective static values
* fix(rules): honor final inline style declarations
* fix(rules): respect variants and share surface checks
* fix(rules): traverse fragments and preserve variant scope
* feat(rules): deepen UI quality diagnostics
* feat(rules): add deterministic animation diagnostics
* fix(rules): retain in-house accessibility checks
* feat(rules): expand deterministic UI diagnostics
* feat(rules): detect generated interface patterns
* fix(rules): respect dynamic style overrides
* feat(rules): expand deterministic design diagnostics
* fix(rules): close design precision gaps
* feat(rules): expand deterministic design and accessibility checks
* feat(rules): detect unsafe Motion API usage
* test(fuzz): remove duplicate module snippets
* feat(cli): add focused design command
* fix(design): include the complete rule bucket
* fix(core): align opt-in rule gates
* feat(rules): expand deterministic motion and interaction checks
* feat(rules): harden deterministic design diagnostics
* fix(rules): harden design expansion diagnostics
* fix(rules): apply large-corpus precision review
* fix(rules): suppress large-corpus accessibility noise
* fix(core): detect compiler in rstack configs
* fix(a11y): avoid responsive navigation false positives
* fix(rules): reduce design corpus false positives
* fix(rules): restore will-change build after rebase
* fix(rules): harden all-rules corpus precision
---------
Co-authored-by: Rayhan Noufal Arayilakath <me@rayhanadev.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
111 lines
4.0 KiB
TypeScript
111 lines
4.0 KiB
TypeScript
import os from "node:os";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
|
|
import { buildRunContext } from "../src/cli/utils/build-run-context.js";
|
|
|
|
const CI_ENV_VARS = ["GITHUB_EVENT_NAME", "REACT_DOCTOR_GITHUB_ACTION"] as const;
|
|
|
|
describe("buildRunContext", () => {
|
|
let savedUserAgent: string | undefined;
|
|
let savedLintBatchOrdering: string | undefined;
|
|
let savedArgv: string[];
|
|
let savedEnv: Record<string, string | undefined>;
|
|
|
|
beforeEach(() => {
|
|
savedUserAgent = process.env.npm_config_user_agent;
|
|
savedLintBatchOrdering = process.env.REACT_DOCTOR_LINT_BATCH_ORDERING;
|
|
delete process.env.REACT_DOCTOR_LINT_BATCH_ORDERING;
|
|
savedArgv = process.argv;
|
|
savedEnv = {};
|
|
for (const name of CI_ENV_VARS) {
|
|
savedEnv[name] = process.env[name];
|
|
delete process.env[name];
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (savedUserAgent === undefined) {
|
|
delete process.env.npm_config_user_agent;
|
|
} else {
|
|
process.env.npm_config_user_agent = savedUserAgent;
|
|
}
|
|
if (savedLintBatchOrdering === undefined) {
|
|
delete process.env.REACT_DOCTOR_LINT_BATCH_ORDERING;
|
|
} else {
|
|
process.env.REACT_DOCTOR_LINT_BATCH_ORDERING = savedLintBatchOrdering;
|
|
}
|
|
process.argv = savedArgv;
|
|
for (const name of CI_ENV_VARS) {
|
|
const previous = savedEnv[name];
|
|
if (previous === undefined) delete process.env[name];
|
|
else process.env[name] = previous;
|
|
}
|
|
});
|
|
|
|
it("derives invokedVia from the leading npm_config_user_agent token", () => {
|
|
process.env.npm_config_user_agent = "pnpm/9.1.0 npm/? node/v22.0.0 darwin arm64";
|
|
expect(buildRunContext().invokedVia).toBe("pnpm");
|
|
|
|
process.env.npm_config_user_agent = "npm/10.2.3 node/v20.11.0 darwin arm64 workspaces/false";
|
|
expect(buildRunContext().invokedVia).toBe("npm");
|
|
});
|
|
|
|
it("falls back to 'unknown' when no package-manager user agent is present", () => {
|
|
delete process.env.npm_config_user_agent;
|
|
expect(buildRunContext().invokedVia).toBe("unknown");
|
|
});
|
|
|
|
it("records a terminalKind label for where the run is hosted", () => {
|
|
expect(typeof buildRunContext().terminalKind).toBe("string");
|
|
});
|
|
|
|
it("reports the running Node major version", () => {
|
|
const expectedMajor = Number.parseInt(process.versions.node.split(".", 1)[0] ?? "", 10);
|
|
expect(buildRunContext().nodeMajor).toBe(expectedMajor);
|
|
});
|
|
|
|
it("records the focused design command separately from a general inspection", () => {
|
|
process.argv = ["node", "react-doctor", "design", ".", "--verbose"];
|
|
expect(buildRunContext().command).toBe("design");
|
|
});
|
|
|
|
it("scrubs the OS username out of cwd (home directory replaced with ~)", () => {
|
|
const { cwd } = buildRunContext();
|
|
expect(cwd).not.toContain(os.homedir());
|
|
});
|
|
|
|
it("scrubs home-directory paths out of argv", () => {
|
|
process.argv = ["node", "react-doctor", `${os.homedir()}/secret-project`, "--json"];
|
|
const { argv } = buildRunContext();
|
|
expect(argv).not.toContain(os.homedir());
|
|
expect(argv).toContain("~/secret-project");
|
|
expect(argv).toContain("--json");
|
|
});
|
|
|
|
it("memoizes a stable runId across calls", () => {
|
|
const { runId } = buildRunContext();
|
|
expect(runId).toMatch(/[0-9a-f-]{36}/i);
|
|
expect(buildRunContext().runId).toBe(runId);
|
|
});
|
|
|
|
it("reports the GitHub event name and official-action marker when present", () => {
|
|
process.env.GITHUB_EVENT_NAME = "pull_request";
|
|
process.env.REACT_DOCTOR_GITHUB_ACTION = "v1";
|
|
const context = buildRunContext();
|
|
expect(context.eventName).toBe("pull_request");
|
|
expect(context.viaAction).toBe(true);
|
|
});
|
|
|
|
it("leaves eventName null and viaAction false outside the GitHub Action", () => {
|
|
const context = buildRunContext();
|
|
expect(context.eventName).toBeNull();
|
|
expect(context.viaAction).toBe(false);
|
|
});
|
|
|
|
it("defaults lintBatchOrdering to 'cost' and honors the arrival rollback", () => {
|
|
expect(buildRunContext().lintBatchOrdering).toBe("cost");
|
|
|
|
process.env.REACT_DOCTOR_LINT_BATCH_ORDERING = "arrival";
|
|
expect(buildRunContext().lintBatchOrdering).toBe("arrival");
|
|
});
|
|
});
|