Skip to content

feat(react-doctor): multi-line JSX & stacked suppressions, per-file rule overrides, near-miss hints#165

Merged
aidenybai merged 5 commits into
mainfrom
cursor/suppression-and-overrides-3ded
May 8, 2026
Merged

feat(react-doctor): multi-line JSX & stacked suppressions, per-file rule overrides, near-miss hints#165
aidenybai merged 5 commits into
mainfrom
cursor/suppression-and-overrides-3ded

Conversation

@aidenybai

@aidenybai aidenybai commented May 8, 2026

Copy link
Copy Markdown
Member

Closes #158, #159, #160, #161.

What this PR does

Refactors inline-suppression matching into small composable utilities and extends its semantics to address the four open issues filed against react-doctor 0.0.47. Architecture-wise, the previous monolithic filter-diagnostics.ts is now an orchestrator over single-purpose utilities — one per file, in line with the project's "one utility per file" convention.

#158 — multi-line JSX disable-next-line

A react-doctor-disable-next-line immediately above a multi-line JSX opening tag now suppresses diagnostics on attribute lines inside that opener, matching the ESLint convention users expect:

{/* react-doctor-disable-next-line react-doctor/no-array-index-as-key */}
<li
  key={`item-${index}`}   // diagnostic line — now correctly suppressed
  role="button"
>
  {item.label}
</li>

Coverage extends through the closing > of the opening tag, not into children — so the suppression doesn't silently mask unrelated rules in element bodies. The detection is brace- and string-aware (> inside {...} expressions or string attributes is correctly ignored), skips => and >= operators, and is bounded by JSX_OPENER_SCAN_MAX_LINES to keep worst-case work bounded.

#159 — stacked single-rule comments

Walking the chain of consecutive disable-next-line comments above the diagnostic now applies all of them, not just the immediately-adjacent one:

// react-doctor-disable-next-line react-doctor/rerender-state-only-in-handlers
// react-doctor-disable-next-line react-doctor/no-derived-useState
const [localSearch, setLocalSearch] = useState(searchQuery); // both rules suppressed

The README's "Inline suppressions" section is also reworked to surface the comma-separated multi-rule form (already supported but easy to miss in docs):

// react-doctor-disable-next-line react-doctor/no-derived-state-effect, react-doctor/no-fetch-in-effect

#160 — per-file rule overrides

New ignore.overrides config field scopes specific rules to specific globs without forcing whole-file ignores:

{
  "ignore": {
    "overrides": [
      {
        "files": ["components/diff/**"],
        "rules": ["react-doctor/no-array-index-as-key"]
      },
      {
        "files": ["components/search/HighlightedSnippet.tsx"],
        "rules": ["react/no-danger"]
      }
    ]
  }
}

A diagnostic is dropped when its file matches an entry's globs and its plugin/rule id is listed. Omit rules (or pass []) to suppress every rule for the matched files.

#161 — near-miss hints + --explain <file:line>

When a diagnostic survives the suppression filter but a nearby disable-next-line looks intentional, an explanatory hint is attached to the diagnostic. Two near-miss shapes get a hint:

  • wrong-rule: an in-chain comment lists rules but not this one. Suggests the documented comma form.
  • gap-code: the rule matches but a code line broke the chain. Suggests moving / extracting.

The hint is exposed via:

  • --verbose output: appended under each affected file:line.
  • --json output: as the new optional Diagnostic.suppressionHint field.
  • A new --explain <file:line> flag (with --why as a hidden alias) for single-site investigation, mirroring the rustc --explain <error-code> shape:
$ npx react-doctor --explain components/projects/Snapshot.tsx:254
react-doctor/no-array-index-as-key — Use a stable id, not the array index, for `key`
  Use the item's stable id (e.g. `item.id`) as the key, or extract a wrapper component.

  Suppression diagnosis: A react-doctor-disable-next-line for react-doctor/no-array-index-as-key sits at line 252, but 1 line of code separate it from the diagnostic on line 254. Move the comment immediately above line 254, or extract the surrounding code into a helper so the suppression is adjacent.

--explain is mode-exclusive with --json, --score, --annotations, and --staged (consistent with existing CLI mutual-exclusion checks). Passing both --explain and --why is rejected with a clear message.

File layout

New utilities (one per file):

  • find-jsx-opener-span.ts — heuristic, brace/string-aware finder for the line that closes a JSX opener.
  • find-enclosing-jsx-opener.ts — uses the span finder to locate any multi-line opener that contains a diagnostic.
  • find-stacked-disable-comments.ts — single-pass walk that returns all disable-next-line comments above an anchor, tagged with isInChain.
  • is-rule-listed-in-comment.ts — atomic helper: does this comment's rule list cover this rule?
  • is-rule-suppressed-at.ts — orchestrates same-line + chain-above + opener-above into the boolean answer.
  • classify-suppression-near-miss.ts — builds the diagnostic hint string when a near-miss is detected.
  • apply-ignore-overrides.ts — compiles ignore.overrides once and matches each diagnostic against it.
  • to-relative-path.ts — extracted from is-ignored-file.ts so the override matcher and ignore-file matcher share the same path normalization.
  • parse-file-line-argument.ts — last-colon-wins parsing so Windows drive letters round-trip.

Type changes:

  • Diagnostic.suppressionHint?: string (additive, backward-compatible).
  • ReactDoctorIgnoreOverride { files: string[]; rules?: string[] } and ReactDoctorIgnoreConfig.overrides?: ReactDoctorIgnoreOverride[].

Constants:

  • JSX_OPENER_SCAN_MAX_LINES = 32 and SUPPRESSION_NEAR_MISS_MAX_LINES = 10 in constants.ts.

Test coverage

30 new tests across 4 files (510 total, all passing):

Backward compatibility

  • All four existing inline-suppression behaviors continue to work (verified via the original inline-suppressions.test.ts + respect-lint-ignores.test.ts suites). Boundary tests (suppression doesn't leak to N+2, doesn't apply to other rules, etc.) all still pass.
  • ignore.rules and ignore.files semantics unchanged.
  • Diagnostic.suppressionHint and ignore.overrides are both additive optional fields — existing configs continue to work without changes.

Risk notes

  • The JSX opener-span finder is a heuristic, not a parser. Two known limitations:
    • TypeScript generic syntax (<List<Item>) used as a JSX component is rare enough I haven't worked around it; the JSX-block-comment workaround {/* … */} immediately above the offending attribute still works in that case.
    • Coverage extends through attribute lines but not into children. This is intentional — extending into children would silently mask unrelated diagnostics on different rules in nested elements.
  • The suppression engine is conservative: when in doubt about whether a suppression applies, it falls back to "doesn't apply" (so users see the diagnostic + the near-miss hint, rather than silently swallowing it).
Open in Web Open in Cursor 

cursoragent and others added 2 commits May 8, 2026 04:27
…verrides, near-miss hints

Refactors inline-suppression matching into small composable utilities and extends
its semantics to cover three reported gaps:

- #158 multi-line JSX: `react-doctor-disable-next-line` immediately above a
  multi-line JSX opening tag now suppresses diagnostics on attribute lines
  inside that opener (matching the ESLint convention).
- #159 stacked comments: walking the chain of consecutive disable-next-line
  comments above the diagnostic now applies all of them, not just the
  immediately-adjacent one.
- #160 per-file rule overrides: `ignore.overrides: [{ files, rules }]`
  scopes specific rules to specific globs without forcing whole-file ignores.
- #161 near-miss hints + `why` subcommand: when a disable comment exists
  nearby but didn't apply, an explanatory hint is attached to the diagnostic
  (verbose / JSON / `react-doctor why <file:line>`).

Each utility lives in its own file: find-jsx-opener-span, find-enclosing-jsx-opener,
find-stacked-disable-comments, is-rule-suppressed-at, classify-suppression-near-miss,
apply-ignore-overrides, to-relative-path, is-rule-listed-in-comment, parse-file-line-argument.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…near-miss

Adds:

- find-jsx-opener-span tests: single-line, multi-line, brace/string
  awareness, =>/>= operator skipping, lookahead cap, closing tag rejection.
- inline-suppressions regressions: 5 multi-line JSX cases (#158), 3 stacked
  comment cases (#159), and the JSX-comment-inside-opener case (#144 form
  preserved).
- classify-suppression-near-miss tests: wrong-rule, gap-code, JSX-anchor
  near-miss, and active-suppression silence.
- filter-diagnostics overrides tests: scoped per-rule, no-rule-list, multi-entry.
- parse-file-line-argument tests: standard parse, Windows drive letters,
  validation errors.

Also: README docs for #158 (multi-line JSX), #159 (comma form prominence),
#160 (ignore.overrides), and #161 (react-doctor why subcommand and
suppressionHint diagnostic field). And restored the previous logger-silent
state in runWhy via the existing scan() silent option instead of toggling
the module-level flag manually.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@vercel

vercel Bot commented May 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-doctor-website Ready Ready Preview, Comment May 8, 2026 5:53am

@aidenybai aidenybai marked this pull request as ready for review May 8, 2026 05:06
Removes function-level / interface-level explanation comments from the new
suppression utilities. Function names, type signatures, and the dedicated
test files document the behavior; narrative prose violates 'never comment
unless absolutely necessary'. HACK comments on heuristic caps in constants.ts
are kept and trimmed to two lines each (matching surrounding style).

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
ESLint's idiom for diagnostic introspection is a flag with a required path
argument (`--print-config <file>`, `--inspect-config`, `--debug`); it has
no subcommand for this kind of question. The original issue (#161) also
wrote it as `--why <file:line>`. Subcommand-form `react-doctor why` was
divergent on both axes — package-manager idiom (pnpm/npm/yarn `why`) bled
into a lint tool where it doesn't belong, and `react-doctor diagnose`
would have collided with the existing `diagnose()` API export anyway.

Move `--why <file:line>` to a top-level flag, mode-exclusive with
`--json`, `--score`, `--annotations`, and `--staged` (consistent with the
existing mutual-exclusion checks for output modes). The targeted report
output is unchanged.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…s hidden alias

`rustc --explain <error-code>` is the closest CLI precedent for this
operation: a flag with a target argument that prints explanatory info
about why the tool reported what it did. `--explain` reads more clearly
than `--why` in --help output ("explain this site" vs. "why this site")
and matches the verb-shape ESLint already uses for diagnostic flags.

`--why` is preserved as a hidden alias (via `Option.hideHelp()`) so the
issue's vocabulary still works for anyone who already typed it. Passing
both flags at once is rejected with a clear message.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@aidenybai aidenybai merged commit 78db3b2 into main May 8, 2026
5 checks passed
cursor Bot pushed a commit that referenced this pull request May 8, 2026
…idation, --explain monorepo, JSX generics, line-comment skip, single-pass evaluator (#166)

Follow-up to #165. Addresses everything flagged during the post-merge audit.

1. Audit-mode asymmetry. `respectInlineDisables: false` was documented as
   neutralizing every inline suppression, but `mergeAndFilterDiagnostics` ran
   `filterInlineSuppressions` unconditionally, so `// react-doctor-disable*`
   comments still hid diagnostics in audit mode. Thread the option through
   `combineDiagnostics → mergeAndFilterDiagnostics` and skip the inline
   filter when false. Config-level `ignore.rules`, `ignore.files`, and
   `ignore.overrides` stay honored, matching the existing `.oxlintignore`
   carve-out.

2. `ignore.overrides` config-shape footgun. Malformed entries (e.g.
   `rules: "react/no-danger"` instead of an array) silently became
   "ignore everything for these files". Validate each entry, emit stderr
   warnings (mirroring `validate-config-types`), preserve `files` while
   dropping malformed `rules`.

3. `--explain <file:line>` monorepo + ergonomics. Previously crashed in
   monorepos and silently ignored `--project` / `--no-lint` /
   `--no-dead-code`. New `findOwningProjectDirectory` walks workspace
   packages and picks the longest-prefix match for the requested file;
   `--project` is honored when set; resolved `ScanOptions` propagate.
   Output is severity-aware (warnings yellow, errors red) and shows the
   diagnostic's `category`.

4. TypeScript generic JSX components. `<List<Item>` followed by a
   multi-line attribute list is now correctly recognized via an
   `innerAngleDepth` counter — each `<` followed by an alpha char at
   brace depth 0 increments, each matching `>` decrements, the OUTER `>`
   closes the opener. Nested generic constraints
   (`<Form<Schema<Item>>>`) work too.

5. Line-comment skipping in the JSX opener scanner.
   `isOpenerMatchInsideLineComment` walks the line up to the regex match
   position (string-aware) and rejects matches that come after `//`,
   eliminating a false-positive class where a `disable-next-line` above
   a `// docs example: <Foo>` line could extend coverage to a real
   diagnostic below. Block-comment edge case remains a documented
   limitation.

6. Single-pass suppression evaluator. `filterInlineSuppressions` used to
   call both `isRuleSuppressedAt` and `classifySuppressionNearMiss` for
   every surviving diagnostic, each independently looking up
   `findEnclosingMultilineJsxOpenerStart` and
   `findStackedDisableCommentsAbove`. New `evaluate-suppression.ts`
   consolidates both into one pass; the original utility files remain
   as thin one-line wrappers so external test imports keep working.

7. README polish. `--explain` section calls out monorepo
   auto-resolution, the `--project` override, and steers users to
   `--verbose` when they want hints across many sites.
   `respectInlineDisables` documentation now lists
   `// react-doctor-disable*` alongside the eslint / oxlint forms in
   both prose and the config table.

Cleanup. `findOpenerTagOnLine` now uses `String.prototype.matchAll`
instead of `exec()` against a stateful module-scope `g`-flag regex.
`findChainSuppressor` was using `.find()` to retrieve an entry but
the caller only checked existence — renamed to `hasChainSuppressor`
and switched to `.some()`.

Tests: 17 new (527 total)
- find-jsx-opener-span: 7 added (line-comment containing fake `<Tag`,
  single-generic component, nested generic constraints, generic +
  self-close, generic + immediate close, string-attribute angle,
  code-after-//-on-same-line)
- inline-suppressions regressions: 2 added (line-comment fake-opener
  doesn't cause a false-positive suppression; generic-typed JSX
  component attribute-line suppression works end-to-end)
- merge-and-filter-diagnostics: 3 (default respects, audit bypasses,
  audit still honors config-level ignores)
- filter-diagnostics: 1 (stderr warning when overrides[i].rules is a
  string)
- find-owning-project: 4 (single-project, monorepo dispatch, orphan
  fallback, relative-path resolution)

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
aidenybai added a commit that referenced this pull request May 10, 2026
…ext (#159) (#196)

Two small but related fixes for inline-suppression robustness:

1. The disable-line / disable-next-line regexes used a narrow rule-list
   character class ([\\w/\\-.,\\s]) that excluded common comment
   punctuation (`;` `:` `(` `'` `?` `!` …). Whenever a user
   appended an explanatory tail like

       // react-doctor-disable-next-line react-doctor/foo -- searchQuery initial; user can type

   the regex stopped matching entirely and the suppression was silently
   ignored. Broaden the capture to `[^\\r\\n]*?` (anything on the
   same line) and split the rule section vs. description in a follow-up
   step, so any prose after the rule ids is fine.

2. `isRuleListedInComment` now strips text after ` -- ` before
   tokenizing the rule list, mirroring ESLint's convention that anything
   after ` -- ` on a disable comment is a description rather than a
   rule id. Without this, descriptive words could be tokenized
   alongside the rule list, and any trailing punctuation kept polluting
   equality checks.

Stacked single-rule comments (#159) already worked end-to-end after #165
landed multi-line / chain handling — the failing case in the report was
purely the regex rejecting comments with descriptive tails. Adds two
regression tests using the issue's exact reproduction (`useState(searchQuery)`
with stacked descriptive disables, plus the equivalent comma-separated
form), and documents both forms (and the chain-breaks-on-code rule) in
the README so the comma-separated syntax stops being undocumented.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
aidenybai added a commit that referenced this pull request May 10, 2026
)

* docs(config): clarify ignore.overrides covers per-file rule ignore (#160)

The `ignore.overrides` config field already supports per-file rule
ignores with the same shape requested in #160 (`{ files, rules }[]`)
landing in #165 alongside multi-line JSX suppressions. This rewrites
the configuration section so the three nested keys' relative scope is
unambiguous and the example covers two of the scenarios from the
issue (a glob with multiple rules, a specific file with a
single-rule override). No code change needed — the feature is fully
implemented and unit-tested in tests/filter-diagnostics.test.ts.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* chore(docs): apply formatter to README example

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

// react-doctor-disable-next-line doesn't suppress when the rule reports an attribute on a later line of a multi-line JSX element

2 participants