Skip to content

feat(complexity): add react-doctor complexity command (CFG-based cyclomatic + cognitive metrics, --diff, --json) - #1089

Open
aidenybai wants to merge 8 commits into
mainfrom
devin/1783559134-complexity-command
Open

feat(complexity): add react-doctor complexity command (CFG-based cyclomatic + cognitive metrics, --diff, --json)#1089
aidenybai wants to merge 8 commits into
mainfrom
devin/1783559134-complexity-command

Conversation

@aidenybai

@aidenybai aidenybai commented Jul 9, 2026

Copy link
Copy Markdown
Member

Summary

Adds a new standalone react-doctor complexity subcommand that measures function complexity from an actual control-flow graph, ranked worst-first, with a base-ref diff mode and JSON output. It is intentionally a separate command — it does not touch the default scan, the 0–100 score, or the JsonReport schema, so no existing contract changes.

The metrics are grounded in the standard literature:

  • Cyclomatic complexity (McCabe, 1976) computed directly from the CFG as E − N + 2P (edges, nodes, weakly-connected components) over the blocks reachable from the function entry — not by counting keywords.
  • Cognitive complexity (Campbell / SonarSource, 2018) — nesting-aware understandability.
  • Plus graph stats (nodes/edges/components), an independent decisionPoints cross-check, and maxNestingDepth. Functions are React-aware classified (component / hook / method / arrow / function / module).

Terminal output redesign + headline 0–1 score (latest)

The command output was reworked to reuse React Doctor's own visual language instead of ad-hoc formatting, and now leads with a single 0–1 complexity score (higher = more complex/riskier; red when high, green when low).

  • Headline score + fill bar, colorized via the existing colorizeByScore (fed the inverted health value 100·(1 − score) so simple = green, complex = red), rendered with a new focused build-meter-bar util (/), a buildSectionDivider, and the branded 2-space layout.
  • Full mode score — per-function 0.6·min(cyc/50,1) + 0.4·min(cog/50,1), aggregated to a codebase score 0.5·mean + 0.5·max over real functions (the <module> aggregate is excluded from the score and from the rendered tables, but stays in JSON).
  • Diff mode score — the existing unbounded changeComplexityScore is mapped into [0,1] via a saturating 1 − e^(−score/K) (K=80): normalizedChangeComplexityScore.
  • Compact table — trimmed to the columns that matter (full: cyc/cog/nest/function/location; diff: Δcyc/Δcog/essential/bloat/function/location), short headers, location never truncated on the right (left-elided so file:line stays visible). A bloat callout fires only when a changed function wrote many lines for ~0 structural change (pure churn).
  React Doctor · Complexity vs HEAD~1

  0.39 / 1.00  ████████░░░░░░░░░░░░  moderate change
  net cyclomatic +3 · 4 changed · 0 added · 0 removed · entropy 0.73
  ──────────────────────────────────────────────
  Δcyc  Δcog  essential  bloat  function    location
    +2    +5          9  0.67×  addLoop     src/mod.ts:18
    +1    +1        4.5  1.11×  addBranch   src/mod.ts:11
     0     0          0    10×  reformatMe  src/mod.ts:1
  ⚠ reformatMe: wrote 10 lines for ~0 structural change (pure churn)

JSON is additive (no schemaVersion bump): ComplexitySummary.complexityScore and ComplexityDiffSummary.normalizedChangeComplexityScore. Terminal↔JSON parity holds. New score constants live in cli/utils/constants.ts (SCREAMING_SNAKE_CASE). Telemetry gains a scoreBand attribute (simple|moderate|complex|very complex) on the existing complexity.command_invoked metric.

Change complexity (diff mode)

Diff mode also reports a change-complexity decomposition (Hassan-style change entropy + AST essential-change + bloat ratio + structural-risk Δ): essential change, raw line churn, bloat ratio (lines touched ÷ essential change — the "100 lines for a tiny change" smell), nesting delta, and entropy.

Reuse (no new CFG)

The engine reuses the plugin's existing per-function CFG (analyzeControlFlow) and AST parsing (parseSourceFile); --diff reuses the same base-ref file materialization the baseline comparison uses; file enumeration reuses listSourceFiles from core. New public exports from oxlint-plugin-react-doctor: analyzeComplexity, parseSourceFile, and the FileComplexity / FunctionComplexity types.

Shape

react-doctor complexity [directory]
  --sort <cyclomatic|cognitive>   # rank metric (default cyclomatic)
  --top <n>                       # cap terminal rows (default 20; JSON uncapped)
  --min <n>                       # hide functions below a cyclomatic threshold
  --diff <ref>                    # compare HEAD vs a base git ref, per-function Δ
  --json                          # structured machine output

--diff <ref> materializes the analyzed files at <ref> (relative refs like HEAD~1/HEAD^ are rev-parsed to a SHA first), re-runs the same analysis on base + head, joins functions by qualified key (same-named class methods stay distinct), and reports per-function Δ plus added/removed. If base materialization fails it degrades to a head-only report with a note; a valid base ref with no matching files reports all functions as added.

Notes for reviewers

  • Telemetry: complexity.command_invoked with mode: full|diff and scoreBand, following the existing anonymized record-metric path.
  • Docs: --help epilog, README.md, and website/public/llms.txt updated. Changeset added (patch: react-doctor, oxlint-plugin-react-doctor).

Testing

  • pnpm typecheck, pnpm lint (only pre-existing fixture warnings), and the complexity unit/e2e tests pass.
  • Smoke-tested full + diff rendering and --json parity against fixtures; score lands in [0,1] with correct bands and no column wrap at ~80 cols.

Link to Devin session: https://app.devin.ai/sessions/a2505c42baa240ef8c83852305ec7ba4
Requested by: @aidenybai

…clomatic + cognitive metrics, --diff, --json)

Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
@aidenybai aidenybai self-assigned this Jul 9, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor
Original prompt from Aiden Bai

in react doctor, create a new method called complexity. basically create a CFG of the thing, then calculate the grpah edges etc. go researchthe acemic research paepr sto calculate comp;leity , maybe be able to diff compelxity between two code JS/react, etc

@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@pkg-pr-new

pkg-pr-new Bot commented Jul 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/eslint-plugin-react-doctor@1089
npm i https://pkg.pr.new/oxlint-plugin-react-doctor@1089
npm i https://pkg.pr.new/react-doctor@1089

commit: fc47771

Comment thread packages/react-doctor/src/cli/utils/strip-unknown-cli-flags.ts
Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
Comment thread packages/react-doctor/src/cli/utils/complexity-report.ts Outdated
Comment thread packages/react-doctor/src/cli/utils/complexity-report.ts
Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
Comment thread packages/react-doctor/src/cli/utils/complexity-report.ts Outdated
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Manual end-to-end test results — react-doctor complexity

Shell/CLI testing against fixtures with literature-grounded known values. All supported paths pass; one limitation found (relative refs in --diff).

T1 — Full-mode metrics + line numbers (JSON): PASS

sumOfPrimes: line=24 cyc=3 cog=7 nesting=3   # canonical SonarSource cognitive value
getWords:    line=11 cyc=4 cog=1 nesting=1
singleIf:    line=5  cyc=2
linear:      line=1  cyc=1 cog=0

Cognitive is nesting-aware (not keyword count); line numbers are real source lines (confirms the byte-offset→line fix).

T2 — Flags take effect: PASS

  • --sort=cognitivefix #1 becomes sumOfPrimes (7); default (cyclomatic) → getWords (4)
  • --min=3 → only getWords/sumOfPrimes rendered
  • --top=1 → 1 row + 4 more…

T3 — --diff <SHA|branch> deltas: PASS

grow: cycΔ=+2 cogΔ=+3 status=changed
summary: net=+2 regressed=1 improved=0 added=0 removed=0

Correct join (single changed row) and correct deltas.

T4 — --diff HEAD~1 (relative ref): DEGRADES to head-only

Could not compute diff against HEAD~1; showing head-only complexity.

~/^ refs are rejected by the shared isSafeGitRevision guard (core/src/services/git.ts), so --diff silently falls back to head-only. Branch names and SHAs work. inspect --diff HEAD~1 instead emits an explicit "not a valid git ref name" error. Flagging as a product decision — HEAD~1 is the natural way to diff against the previous commit.

Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
Comment thread packages/react-doctor/src/cli/utils/complexity-report.ts
Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
Comment thread packages/react-doctor/src/cli/utils/complexity-report.ts Outdated
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Bugbot findings addressed (commit 815b5d2)

Thanks Bugbot — all 5 were valid and are fixed, each with a regression test:

  • Complexity help flags stripped--help/-h added to COMPLEXITY_FLAG_SPEC; complexity --help/-h now prints subcommand help and exits without scanning.
  • Diff mode ignores sortcompareFunctionDeltas now ranks by the selected metric's absolute delta (--sort cognitive reorders diff rows); applied to both terminal and JSON.
  • Diff counts skip cognitivesummarizeComparison now counts a changed function as regressed/improved when the cyclomatic delta is zero but the cognitive delta isn't, so footer totals match visible rows.
  • Diff JSON ignores min filter → the --min predicate is now applied to the diff functions list in the report itself, so JSON and terminal agree (and the summary is computed over the same visible set).
  • Removed diff paths are temp → removed-entry filePath is now path.resolve(directory, relativePath) (the real project path), not the materialized temp-tree path.

Tests: added regressions in complexity-report.test.ts (sort metric, cognitive-only counting, --min JSON/terminal parity, removed-entry path), e2e/complexity-command.test.ts (--help/-h), and strip-unknown-cli-flags.test.ts (help-flag stripping). CI green.

Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
Comment thread packages/oxlint-plugin-react-doctor/src/plugin/semantic/complexity-helpers.ts Outdated
Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit aad0956. Configure here.

resolvedDirectory,
headAnalysis.functions,
baseAnalysis.functions,
sortMetric,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted files omit removed functions

Medium Severity

--diff only enumerates current-tree source paths and materializes those paths at the base ref, so functions in files removed on HEAD never appear as removed deltas and removedCount can stay zero.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit aad0956. Configure here.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Test results — change complexity --diff + 2 Bugbot fixes (commit aad0956e)

Ran the built CLI against 3 purpose-built git fixtures, asserting exact JSON/terminal values. Shell-only (CLI, no UI) → terminal output is the evidence. All 7 checks passed; no spec deviations.

  • ✅ T1 — formatting-only change ⇒ essentialChange 0, bloatRatio 7.00 (the "many lines, zero semantic change" signal)
  • ✅ T2 — structural change ⇒ essential scales: addLoop 9 > addBranch 4.5 > reformatMe 0
  • ✅ T3 — summary entropy non-degenerate: 1.46 (0.73 normalized), score 37.73
  • ✅ T4 — JSON ↔ terminal parity
  • ✅ T5 — full (non-diff) mode unchanged (no essentialChange/bloatRatio/rawLinesChanged keys)
  • ✅ T6 — Bugbot "diff keys collide for methods": same-named methods now get distinct keys + correct non-swapped deltas
  • ✅ T7 — Bugbot "diff aborts all-new paths": valid-but-empty base ⇒ all-added; unresolvable ref ⇒ head-only
T1–T4 — complexity /tmp/cx-change --diff HEAD~1 (reformatMe reflowed only; addBranch +if; addLoop +nested for/if)
location       function               Δ cyclomatic  Δ cognitive  essential  raw lines  bloat  nesting   status
src/mod.ts:15  addLoop [function]               +2           +3          9          8   0.89        2  changed
src/mod.ts:8   addBranch [function]             +1           +1        4.5          5   1.11        1  changed
src/mod.ts:1   <module> [module]                 0            0       13.5         20   1.48        0  changed
src/mod.ts:1   reformatMe [function]             0            0          0          7   7.00        0  changed

net cyclomatic +3, regressed 2, improved 0, added 0, removed 0
change complexity total essential 27, entropy 1.46 (0.73 normalized), structural risk 10.00, score 37.73

reformatMe reflowed 1→7 lines but its AST is unchanged → essentialChange 0, bloat 7.00. A line-based metric would show essential ≈ 7 / bloat ≈ 1.

T6 — method-key collision fix (/tmp/cx-methods)
key: src/ui.ts|method|class:Second|render   cycΔ 2 cogΔ 3 ess 9
key: src/ui.ts|method|class:First|render    cycΔ 1 cogΔ 1 ess 4.5

Distinct qualified keys, correct per-class deltas (pre-fix: one merged …|method|render bucket paired by line order).

T7 — empty base vs invalid ref (/tmp/cx-newfile)
# --diff HEAD~1  (valid ref, file absent at base)
computed: true  addedCount: 2   handle status: added  bloat: null  raw: null

# --diff does-not-exist-ref  (unresolvable ref)
computed: false   note: Could not compute diff against does-not-exist-ref; showing head-only complexity.

Observation (not a bug): the <module> row aggregates its child functions, so its essentialChange/totalEssentialChange/entropy overlap the per-function values.

Session: https://app.devin.ai/sessions/a2505c42baa240ef8c83852305ec7ba4

Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Test results — output redesign + 0–1 score

Built the CLI locally and ran react-doctor complexity in full + diff modes against git fixtures, verifying the headline score, color orientation, compact table, and JSON parity. All assertions passed; CI green (19/19).

complexity output — green / amber / red

  • Full (low = green): 0.07 / 1.00 · simple, green score+bar; compact cyc cog nest function location; no <module> row; locations not truncated.
  • Diff (moderate = amber): title … vs HEAD~1 (typed ref, not a SHA); 0.39 / 1.00 · moderate change; Δcyc Δcog essential bloat with ×-suffixed bloat; ⚠ reformatMe … (pure churn) callout fires.
  • Orientation (high = red): synthetic cyc 181/cog 420 → 1.00 / 1.00 · very complex, red. Confirms green→amber→red as complexity rises.
  • JSON parity: summary.complexityScore (0.0713→0.07) and normalizedChangeComplexityScore (0.3914→0.39) match the rendered numbers, both in [0,1].
  • Regression: same-named render methods stay distinct with correct deltas, no wrap.
caveatsThe red case is a synthetic fixture just to hit the ≥0.75 band (not part of the PR). Colors captured by rendering real ANSI output to HTML (piping strips ANSI); escape codes 32/33/31 also verified directly.

Devin session: https://app.devin.ai/sessions/a2505c42baa240ef8c83852305ec7ba4

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.

1 participant