whisper-money/app
Víctor Falcón 026e61cd6e
ci: add duplication and complexity quality checks (#765)
Adds two code-quality signals to CI, with deliberately different
strengths.

## Duplication (blocks merges)

`bun run dry` runs jscpd as a step in the `linter` job, a required
check, so
copy-paste that pushes duplication above the threshold in `.jscpd.json`
blocks
the merge. Baseline today is 5.36% (PHP 6.01%, TSX 5.82%) and the
threshold sits
at 5.4. jscpd is pinned as a devDependency rather than run through
`bunx`: a
version bump changes the reported number and would fail unrelated PRs.

## Complexity (goes red, does not block)

`php artisan crap` reports cyclomatic complexity per method. A separate
`crap`
job reports the methods a PR touched that exceed complexity 10. It is
**not** a
required check: it goes red so the number is visible, but never blocks a
merge.
Folding it into the `linter` job would turn it into a gate, which is not
the
intent — please keep it out of branch protection.

### Why complexity decides the verdict and not CRAP

CRAP is `c² × (1 − coverage)³ + c`, so it is complexity penalised by
missing
tests. Measured here, it is the wrong signal for readability:

| | Methods over 30 | crapLoad | totalCrap |
|---|---:|---:|---:|
| Whole suite with real coverage | 15 (0.99%) | 100 | 5,031 |

Because `app/` is well covered, CRAP mostly ranks what is untested. The
two
rankings barely overlap:

| Rank | By CRAP | By complexity |
|---|---|---|
| 1 | `VerifyRefundFlowCommand::handle` (c=14, 0%) |
`ExperimentFunnelCollector::collect` (c=35, 99%) |
| 2 | `StripeCustomerResolver::label` (c=10, 0%) |
`DashboardAnalyticsController::accountBalanceEvolution` (c=30, 91%) |
| 3 | `WiseTransactionSyncService::parseActivity` (c=8, 0%) |
`UpdateTransaction::write` (c=20, 75%) |

The most complex method in the codebase — 35 branches — is 99% covered,
so it
scores CRAP 35 and lands 11th of 15, below an untested enum `label()` of
complexity 6. An agent guided by CRAP would write tests for a console
command
and leave the 35-branch method alone. So complexity triggers the check,
and CRAP
plus per-method coverage travel in the output as context for *how* to
fix it.

### Threshold

10, McCabe's number, just above this codebase's p95 of 8 (median 1, p99
14,
max 35). It fires only on methods a diff touches, so the 35 existing
offenders
only matter when someone edits them.

## The command

```bash
php artisan crap                                      # whole project, ranked
php artisan crap --base=origin/main --no-coverage     # what CI checks, <1s
php artisan crap --base=origin/main --json            # for agents
```

- `--no-coverage` skips the coverage report and gives the same verdict,
since
complexity alone decides it. Without it, a crap4j report is required and
the
command refuses to guess when it is missing, printing the exact command
to
  generate one.
- Exemptions live in `.crap-ignore.json` keyed by method, with a reason
that is
read in review. Entries that are no longer needed get reported for
deletion.
- Untracked files count whole — a new feature is mostly new files and
would
  otherwise sail through unmeasured.
- Exit codes: 0 clean, 1 over threshold, 2 unusable input.

The counter is php-code-coverage's own
`CyclomaticComplexityCalculatingVisitor`,
so the numbers match the CRAP it reports: **1502 of 1503 methods
agree**. Its
wrapping visitor is not reused because that one asserts a method's
parent is a
class or a trait, which fails on enums.

## Notes for review

- `pcov.directory` is set explicitly in the tests job. Left to
autodetect, pcov
picks `src`, which does not exist in a Laravel app, and every method
silently
reports 0% coverage — which is how the first measurements of this metric
came
  out wrong, with plausible-looking numbers.
- The `crap` job skips rather than fails when `tests` fails: without the
coverage
  artifact, a red `crap` job would say nothing about complexity.
- The two checks cover each other's blind spot. Splitting a complex
method into
near-identical pieces to lower complexity raises duplication, and that
check
  does block.
- **Not verified:** that paratest merges coverage across its 4 processes
in CI.
Locally only the serial run works — in parallel each worker starts its
own
MySQL testcontainer and they time out. If it misbehaves, the symptom is
empty
  crap/coverage context columns, not a wrong verdict.
- Scope is PHP only. `resources/js` (392 files, 72k lines vs 435/37k in
`app/`)
has no complexity pipeline; the JSON says so explicitly rather than
letting
  "whole project" be assumed.

## Testing

10 Pest tests covering the threshold verdict, the CCN counting rules
(including
match arms and closures nested in a method), enum methods, exemptions,
stale
exemptions, the missing-report refusal, the crap4j join, and the
reported scope.
`pint --test` and the full `phpstan` run are clean.
2026-08-11 13:29:37 +02:00
..
Actions fix(auth): make build deterministic when REGISTRATION_ENABLED=false (#720) 2026-07-22 07:04:20 +00:00
Ai/Agents feat(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752) 2026-08-10 10:13:40 +02:00
Console/Commands ci: add duplication and complexity quality checks (#765) 2026-08-11 13:29:37 +02:00
Contracts feat(connections): manage which accounts a bank connection syncs (#558) 2026-06-18 16:22:49 +02:00
Enums feat(transactions): add a monthly trend view to the analysis drawer (#736) 2026-07-26 17:03:52 +02:00
Events Add Budgeting Feature to Track and Manage Spending (#36) 2026-01-21 15:25:50 +01:00
Exceptions/Banking fix(banking): stop unclassified bank responses from silently killing a connection (#742) 2026-08-09 17:26:34 +02:00
Features feat(mcp): read-only MCP server for Pro accounts (#689) 2026-07-17 16:54:15 +02:00
Http fix(demo): stop demo:reset from colliding on the fake Stripe subscription id (#756) 2026-08-10 12:48:13 +00:00
Jobs fix(banking): stop Wise outages from silently parking a bank connection (#757) 2026-08-10 16:40:09 +02:00
Listeners feat: reuse the upgrade modal at more upsell points and attribute revenue (#699) 2026-07-18 12:53:20 +00:00
Mail feat(budgets): add per-budget email notifications (#731) 2026-07-24 12:52:03 +02:00
Mcp feat(mcp): record MCP tool usage and report it with stats:mcp-usage (#760) 2026-08-11 10:27:32 +02:00
Models feat(mcp): record MCP tool usage and report it with stats:mcp-usage (#760) 2026-08-11 10:27:32 +02:00
Notifications refactor: remove HIDE_AUTH_BUTTONS launch gate and waitlist apparatus (#717) 2026-07-22 08:51:48 +02:00
Policies refactor(policies): extract HandlesUserOwnership trait (#478) 2026-06-03 17:43:30 +02:00
Providers fix(auth): make build deterministic when REGISTRATION_ENABLED=false (#720) 2026-07-22 07:04:20 +00:00
Rules feat: parent/child category tree (#474) 2026-06-03 19:30:12 +02:00
Services fix(binance): value past days from Binance's own snapshot total (#763) 2026-08-11 11:04:06 +00:00
Support ci: add duplication and complexity quality checks (#765) 2026-08-11 13:29:37 +02:00