whisper-money/app/Console/Commands
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
..
Concerns fix(encryption): never email or delete users who are still being billed (#656) 2026-07-07 16:04:27 +00:00
AgentDatabaseCommand.php feat(console): add agent:db command for querying local and prod DB (#522) 2026-06-12 18:35:14 +02:00
ApplyRealEstateRevaluationCommand.php fix(real-estate): compound annual revaluation monthly (#337) 2026-04-27 07:35:51 +01:00
BackfillAccountIbans.php refactor: centralize duplicated provider & locale keys into enums (#543) 2026-06-16 13:43:14 +00:00
BackfillSpaces.php feat(spaces): phase 0 — multi-tenant Space foundation (no behaviour change) (#650) 2026-07-09 14:26:07 +02:00
BackfillTransactionDescriptions.php fix(banking): strip the ISO 20022 remittance tag from transaction descriptions (#744) 2026-08-09 18:11:05 +02:00
BackfillUserCurrencyCode.php Add Budgeting Feature to Track and Manage Spending (#36) 2026-01-21 15:25:50 +01:00
CancelFreeEnableBankingConnectionsCommand.php refactor: centralize duplicated provider & locale keys into enums (#543) 2026-06-16 13:43:14 +00:00
CategorizeBackfillCommand.php AI auto-categorization: open to pro + consent, nudge free users (#561) 2026-06-19 14:08:40 +00:00
CheckBankLogosCommand.php Add weekly bank logo audit command (#211) 2026-03-06 11:05:13 +00:00
CrapCommand.php ci: add duplication and complexity quality checks (#765) 2026-08-11 13:29:37 +02:00
DeleteAiConsentCommand.php feat(ai): add command to delete a user's AI consent by email (#729) 2026-07-23 08:35:28 +00:00
DeleteEncryptedDataAccountsCommand.php fix(encryption): never email or delete users who are still being billed (#656) 2026-07-07 16:04:27 +00:00
DeleteManualAccountDataCommand.php feat(commands): delete transactions and balances of a user's non-connected accounts (#728) 2026-07-23 08:26:11 +00:00
DeleteUserCommand.php refactor: centralize duplicated provider & locale keys into enums (#543) 2026-06-16 13:43:14 +00:00
DisconnectBankingConnectionsCommand.php feat(banking): add command to disconnect connections by id (#497) 2026-06-06 11:16:01 +02:00
E2eBankingFixtureCommand.php test(open-banking): e2e coverage for Enable Banking connection flows (#509) 2026-06-09 11:58:50 +02:00
EnsureLaunchCouponsCommand.php refactor: remove HIDE_AUTH_BUTTONS launch gate and waitlist apparatus (#717) 2026-07-22 08:51:48 +02:00
FeatureDisableCommand.php Add Budgeting Feature to Track and Manage Spending (#36) 2026-01-21 15:25:50 +01:00
FeatureEnableCommand.php feat(features): support percentage rollouts in feature:enable (#592) 2026-06-25 10:46:30 +00:00
GenerateBudgetPeriods.php fix: Budget period not found on last day of period (#91) 2026-02-01 11:33:10 +01:00
GenerateMonthlyLoanBalances.php feat(accounts): add loan amortization projections for loan accounts (#246) 2026-03-26 15:06:09 +01:00
GenerateStripePromotionCodesCommand.php chore(deps): update composer dependencies to latest (#764) 2026-08-11 11:15:27 +00:00
McpUsageStatsCommand.php feat(mcp): record MCP tool usage and report it with stats:mcp-usage (#760) 2026-08-11 10:27:32 +02:00
NotifyEncryptedDataRemovalCommand.php feat(encryption): report count of users still holding encrypted data (#687) 2026-07-17 09:29:33 +00:00
ResendSyncCommand.php feat: Sync new users to Resend contacts (#85) 2026-01-28 21:25:58 +01:00
ResetDemoAccountCommand.php fix(demo): stop demo:reset from colliding on the fake Stripe subscription id (#756) 2026-08-10 12:48:13 +00:00
ResetUserCategories.php Refactor: Optimize category management in CreateDefaultCategories and ResetUserCategories 2025-12-01 15:37:53 +01:00
ReviewIntegrationRequestsCommand.php feat(integration-requests): add done status and fix review command crash on orphaned author (#601) 2026-06-27 14:42:09 +00:00
SendAiCohortReportCommand.php feat(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752) 2026-08-10 10:13:40 +02:00
SendAiConsentFollowUpEmailsCommand.php feat(email): follow up after post-onboarding AI consent (#596) 2026-06-26 17:56:06 +00:00
SendDailyStatsReportCommand.php feat(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752) 2026-08-10 10:13:40 +02:00
SendExperimentFunnelReportCommand.php feat(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752) 2026-08-10 10:13:40 +02:00
SendPaywallFollowUpEmailsCommand.php feat(drip): email users stuck on the paywall a day after onboarding (#562) 2026-06-19 14:11:12 +00:00
SendStuckCohortReportCommand.php feat(stats): add --no-discord to the remaining report commands (#607) 2026-06-29 15:32:31 +02:00
SendSubscriptionCancelledEmailCommand.php Add subscription cancellation email with manual trigger command (#47) 2025-12-30 20:32:44 +01:00
SendSubscriptionFunnelReportCommand.php feat(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752) 2026-08-10 10:13:40 +02:00
SendTestEmails.php chore(commands): unify command signatures 2025-12-30 07:22:18 +01:00
SendUpdateEmailCommand.php fix: delay emails to avoid reaching daily resend limit 2026-01-09 11:11:38 +01:00
SetBankLogoCommand.php chore(deps): update composer dependencies to latest (#764) 2026-08-11 11:15:27 +00:00
SetupMainUser.php fix(static-analysis): clear phpstan-baseline by fixing all suppressed errors (#183) 2026-03-02 12:22:30 +00:00
StripeSubscriptionStatsCommand.php refactor: extract duplicated money formatter into App\Support\Money (#680) 2026-07-15 09:47:53 +02:00
SuggestRulesCommand.php feat(ai): suggest automation rules during onboarding (#523) 2026-06-13 22:51:15 +02:00
SyncBankingConnections.php refactor: centralize duplicated provider & locale keys into enums (#543) 2026-06-16 13:43:14 +00:00
SyncStripePricesCommand.php refactor: extract duplicated money formatter into App\Support\Money (#680) 2026-07-15 09:47:53 +02:00
TestScheduledDripEmailCommand.php Test mail drip command 2025-12-30 07:22:18 +01:00
VerifyRefundFlowCommand.php feat(subscriptions): trial/pricing A/B/C experiment (#600) 2026-06-27 18:00:15 +02:00