# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

A **CodeIgniter 4** PHP application that hosts a suite of back-office "Solutions & Automations" tools for [Businessmap / Kanbanize](https://businessmap.io) accounts — imports, exports, reports, account admin, third-party integrations (Jira, Azure DevOps, Hubspot, SmartSheet, Planner, Slack, Zendesk), OAuth2 server, a custom scripting runtime (`CodeRunner`), and a cron-like scheduler. Each tool is an isolated controller + view + model under [app/Controllers/Tools](app/Controllers/Tools/).

The front controller lives in [public/index.php](public/index.php); web server must point at [public/](public/), not the project root.

## Commands

```bash
# Pre-commit gate: lint changed files + full suite + coverage floors  (REQUIRED before committing)
bash scripts/verify-gate.sh
bash scripts/verify-gate.sh --fast        # suite only, no coverage — for iterating, not for committing

# Run the full test suite (PHPUnit via Composer)
composer test
# or: vendor/bin/phpunit --no-coverage    # ~2x faster without coverage

# Run a single test file / method
vendor/bin/phpunit tests/unit/CodeRunner/ParserTest.php
vendor/bin/phpunit --filter testBaseUrlHasBeenSet

# Keep the distributable Code Runner skill in step with the DSL  (run after changing the DSL)
php scripts/check-skill-sync.php          # registry + lexer keywords vs the skill's docs and linter
bash scripts/build-skill.sh               # repackage build/businessmap-code-runner.zip

# Strip a Code Runner script's comments for pasting into a Business Rule (64 KB cap on the
# rule's script parameter). Writes build/coderunner/<name>.min.coderunner; keeps /*! … */
# comments so the SETUP block survives; refuses to write unless the stripped script lexes to
# the identical token stream. The commented file under `codeRunner scripts/` stays the only
# one anyone edits.
php scripts/coderunner-strip.php                            # every script in codeRunner scripts/
php scripts/coderunner-strip.php --stdout path/to.coderunner

# Coverage floors + seam budgets for the tested scope (both run by the gate)
php scripts/coverage-floor.php            # pass/fail table, exit 1 on a regression
php scripts/coverage-floor.php --report   # same table, always exit 0
php scripts/seam-budget.php               # no new exit/die, sleep, or raw cURL in scope

# CLI entry point (CodeIgniter "spark")
php spark list                            # all available commands
php spark scheduler:run                   # run due jobs from the scheduler queue (see app/Commands/Scheduler.php)
php spark migrate                         # apply DB migrations in app/Database/Migrations

# Toggle CodeIgniter framework between stable / dev
php builds release
php builds development
```

PHP **8.1+** is required. `intl` and `mbstring` extensions must be enabled.

Local dev uses [dev.env](dev.env) as a template — copy it to `.env` and set `CI_ENVIRONMENT=development`, DB credentials, and `TESTING_SUBDOMAIN` / `TESTING_APIKEY` / `SSO_USER_EMAIL` / `ENC_KEY` / `CREDS_APIKEY`. In development the code reads DB credentials from `.env`; in production it pulls them from **AWS Secrets Manager** (region `eu-west-1`) via [app/Libraries/SecretsManager.php](app/Libraries/SecretsManager.php).

## Architecture

### Routing is registry-driven

[app/Config/Routes.php](app/Config/Routes.php) does **not** list every tool route individually. Instead it iterates over three registries in [app/Config/ControllersMap.php](app/Config/ControllersMap.php):

- `getTools()` — public tools under `/<uri>` → `Tools\<Controller>::index` (GET) and `::actionView` (POST). Each also auto-gets `/viewLogs`, `/viewDebugLog`, `/clearDebugLog`.
- `getInternal()` — back-office tools mounted under `/internal/<uri>`.
- `getCSRFRoutes()` — POST endpoints that require CSRF protection; [app/Config/Filters.php](app/Config/Filters.php) dynamically builds the `csrf` filter's `before` list from this registry plus every non-excluded tool URI.

**When adding a new tool**, register it in `ControllersMap` (not by hand-writing routes), unless it's an oddball route (look at the explicit blocks in `Routes.php`). URIs hardcoded in the exclusion list inside `Routes.php` / `Filters.php` (`ado2Bmap`, `premiumHealthIndexes`, `bmap2SmartSheet`, `codeRunner`, `internal`, `subscriptions`) get special handling and must declare their routes explicitly.

### BaseTool pattern

Every public tool controller extends [App\Controllers\Components\BaseTool](app/Controllers/Components/BaseTool.php). It provides:

- **Auto-paired model**: on construct, if `app/Models/Tools/<ClassName>Model.php` exists it's loaded; otherwise a generic `BaseModel` is instantiated with table names `<lcClassName>_jobs` / `<lcClassName>_logs` (migrations follow this convention).
- **Job/session lifecycle**: `checkLogins()` authenticates the user against the Businessmap API using `knbSubdomain`+`knbApikey` POSTed from the login view, creates a **job** row, stores `job_id` in session, and from then on logs API calls and user activity to `<tool>_logs`. Credentials in the jobs table are **encrypted** via `CredentialManager` (AES); subdomains are additionally stored as a salted SHA-256 hash for lookup.
- **reCAPTCHA v3** on first login (non-embed), score threshold 0.3, with a session-level failure counter.
- **Account domain auto-detection** via `PremiumConnect::getConfigInfo()` — accounts on the `new` domain flag use `.businessmap.io`, others stay on `.kanbanize.com`.
- **Abstract `init()`** — subclasses set `$title` / `$info` here.
- **Privilege gating** — `checkLogins($privilege, $embed, $packageRequired)` supports global privilege names, "owner", and support-package checks.

Subclasses typically override `init()`, `actionView()` (the POST handler), and add business-specific methods that appear in `getCSRFRoutes()`.

### Connectors

All third-party integrations live in [app/Controllers/Components](app/Controllers/Components/) as `*Connect` classes extending `HTTPConnect` (a cURL wrapper). The main one is [BusinessmapConnect](app/Controllers/Components/BusinessmapConnect.php), which speaks to four Businessmap APIs at once: `v1` (legacy kanbanize), `v2`, `v3` (apiv3), and the `reportingApi/v1` endpoint. It sets a `KANBANIZE-INTEGRATION: sa_tools_<toolname>` header for traceability and writes every call to the tool's `_logs` table when a `dbLog` + `jobId` are passed in (BaseTool does this automatically via `initConnector()`).

Other connectors follow the same pattern: `JiraConnect`, `DevOpsConnect` (Azure DevOps), `HubspotConnect`, `SmartSheetConnect`, `PlanviewConnect`, `ChatGPTConnect`, `AzureAIConnect`, `BraintreeConnect`, `FreshbooksConnect`, `OpenExchangeConnect`, `ShortcutConnect`, `YoutrackConnect`, `PremiumConnect`, `RDStationConnect`, `AFRConnect`.

### Controller namespaces

- `App\Controllers\Tools\*` — user-facing tools (login with subdomain+apikey).
- `App\Controllers\Internal\*` — staff-only tools behind SSO via [InternalSSO](app/Libraries/InternalSSO.php); extend `BaseInternal`.
- `App\Controllers\External\*` — endpoints called by external Businessmap business rules / webhooks (no interactive login).
- `App\Controllers\Services\*` — platform services: `DatabaseController` (credential CRUD), `SchedulerController` (cron/one-off job scheduling, see `app/Commands/Scheduler.php`), `CurrencyConverterController`, `SubscriptionsController`, `SSOController`.
- `App\Controllers\Components\*` — shared connectors + `BaseTool` / `BaseInternal` / `Utils` (not routable).

### Dual database groups

[app/Config/Database.php](app/Config/Database.php) defines two connection groups populated from Secrets Manager: `default` (tools DB, secret `tools_db`) and `accounts` (secret `accounts_db`). Pass the group name to `BaseModel`'s constructor to connect to `accounts`. The PHPUnit group is `tests` (SQLite3 in-memory) and is auto-selected when `ENVIRONMENT === 'testing'`.

### CodeRunner (sandboxed DSL)

[app/Libraries/Businessmap/CodeRunner](app/Libraries/Businessmap/CodeRunner/) is a **Composer path repository** (declared in the root `composer.json`) that ships a small lexer/parser/interpreter (`Businessmap\CodeRunner\` namespace) executed by `Tools\CodeRunner`. It lets customers embed custom logic in Businessmap business rules without running arbitrary PHP. If you touch the grammar, remember to `composer update businessmap/coderunner`.

The DSL is also documented for the SA team by a distributable Agent Skill in
[skills/businessmap-code-runner](skills/businessmap-code-runner/) — reference docs plus a linter
(`crlint.py`) that encodes the language's traps, and an OpenAPI query tool (`bmapi.py`). Nothing
links it to the source automatically, so **adding a function to the interpreter or a keyword to the
lexer means updating the skill in the same commit**; `php scripts/check-skill-sync.php` fails when
they diverge.

### Scheduler

The `Services\SchedulerController` registers jobs (`one_off`, `repeat`, `cron`) into the scheduler DB; a cron on the host runs `php spark scheduler:run` which delegates to [App\Libraries\SchedulerService](app/Libraries/SchedulerService.php) to fire due HTTP callbacks. `dragonmantank/cron-expression` parses cron strings.

### Credentials & encryption

- **Per-tool user credentials** (subdomain/apikey) — encrypted at rest in `*_jobs` tables via `CredentialManager` using a key from Secrets Manager (`ENC_KEY` locally) plus salt for hashed lookup.
- **Shared app credentials** (reCAPTCHA keys, log passwords, rock_* SSO keys, OAuth client secrets, etc.) — stored in a central `credentials` table, accessed via `CredentialService::getCredentialValue($name)`. CRUD is exposed at `/credential` via `Services\DatabaseController` (gated by the `CREDS_APIKEY`).

Never hardcode secrets; always read them through `CredentialService` or `SecretsManager`.

### Views and assets

Tool views live alongside the controllers in [app/Views/Tools](app/Views/Tools/) (one view per tool, plus shared `BmapLogin.php`). Public static assets are in [public/resources](public/resources/). Writable paths (`writable/logs`, `writable/cache`, `writable/session`, `writable/uploads`) must be writable by the web-server user — `writable/` is `nfsnobody`-owned on this box.

## Testing

### Tested scope

**ReportingProxy, CodeRunner, and everything they execute** are covered at ~96% of statements and
must stay that way. The authoritative file list, with a per-file coverage floor for each, is
[tests/coverage-floors.json](tests/coverage-floors.json):

`Tools/ReportingProxy`, `Tools/CodeRunner`, `Components/BaseTool`, `Components/HTTPConnect`,
`Models/Tools/BaseModel`, `ReportingProxyModel`, `ReportingProxyStatsTrait`, `CodeRunnerModel`, and
the CodeRunner DSL (`Lexer`, `Parser`, `Interpreter`, `Functions`, `Token`).

Everything else in `app/` is deliberately unmeasured — the older tools have no fixtures, so don't
assume one exists for the tool you're editing. Adding a file to `coverage-floors.json` is a
commitment to test it from then on; do that when the scope genuinely expands, not casually.

### The gate — required before every commit that touches `app/` or `tests/`

```bash
bash scripts/verify-gate.sh
```

It runs `php -l` on changed files, the full suite, the coverage floors, the seam budgets, and two
hygiene checks (`writable/db_backups` clean; `tests/bootstrap.php` still installs both outbound
guards). Green writes `build/.verify-ok`, a fingerprint of the in-scope sources plus every `.php`
under `tests/`.
A PreToolUse hook ([scripts/hooks/commit-gate.php](scripts/hooks/commit-gate.php), registered in the
tracked [.claude/settings.json](.claude/settings.json)) blocks `git commit` when that marker is
missing or stale. Any edit invalidates it, including an edit to a test. Personal Claude Code
settings belong in `.claude/settings.local.json`, which is gitignored.

**A coverage floor may only be lowered when the new code genuinely cannot be reached from a test**
— a `fastcgi_finish_request()` branch, a TLS-only cURL option, a `catch` arm that exists only so a
metrics failure cannot break a delivery. Lower it in the same commit and say why in the message.
Lowering a floor to turn a red gate green falsifies the gate.

Skills and agents for this, in `.claude/`:

| | Purpose |
|---|---|
| `backend-verify` skill | Run and interpret the gate; what may be waived |
| `test-write` skill | Base test cases, the seams, the doubles, the DSL, and the catalogue of traps (+ `REFERENCE.md` for the support API) |
| `test-run` skill | Execution recipes, the MySQL test-DB setup, reading red/risky results |
| `backend-commit` skill | Repo-specific commit addendum to the user's global `git-commit` skill |
| `backend-code-reviewer` agent | Reviews the PHP diff; BLOCKER/HIGH block the commit |
| `test-coverage-auditor` agent | Names the uncovered branches and missing behavioural cases for a change |

### Test environment

- The suite needs a **real MySQL database** — the `sqlite3` extension is not installed on this box,
  so the in-memory option commented into `phpunit.xml.dist` is unusable. Configure
  `database.tests.*` in `.env`; the schema is built from the App migrations on first run.
- `app/Config/Database.php` aliases the `default` and `accounts` groups to `tests` whenever
  `ENVIRONMENT === 'testing'`. This is load-bearing: `BaseModel` asks for the literal group
  `'default'`, which outside testing is the live tools database. If rows or migrations ever appear
  in `tools` during a test run, that aliasing has been broken — stop and fix it first.
- `phpunit.xml.dist` sets `beStrictAboutOutputDuringTests=true`, `failOnRisky=true` and
  `failOnWarning=true`, so a test that echoes anything or leaves a DB handle open is a failure.
- Coverage includes `./app` only; `./app/Views` and `./app/Config/Routes.php` are excluded.
  Reports land in `build/logs/` (gitignored).

### The suite must never reach the network

[tests/bootstrap.php](tests/bootstrap.php) installs two process-wide guards before any test loads:
`HTTPConnect::setDefaultTransport(new DeniedTransport())` and
`OutboundNotifications::intercept(...)`. They are process-wide rather than per-test because
`setUp()` runs too late to catch a fatal raised while a test file is still loading, and this app's
fatal handler talks to production — `CustomExceptionHandler::sendException()` creates a card on the
**live Rock SA board**, and `Utils::createErrorLogCard()` / `sendEmail()` do the same from
CodeRunner's error path. Never remove either guard; the gate checks they are still there.

The few tests that need real cURL use a PHP built-in web server on a loopback port
([tests/_support/Http/EchoServer.php](tests/_support/Http/EchoServer.php)).

### Test seams in production code

Four things that would otherwise be untestable are routed through overridable methods. Reuse them,
and when you write code of the same shape add the seam in the same commit rather than leaving the
branch uncovered:

| Instead of | Use | Under test |
|---|---|---|
| `exit` / `die` | `\App\Libraries\ProcessHalt::halt()` | Throws `HaltException` (extends `\Error`, so the app's `catch (\Exception)` stays transparent) |
| `sleep()` | `protected function pause(int $seconds)` | `InstantHTTPConnect` / `InstantReportingProxy` record instead of waiting |
| raw cURL outside `HTTPConnect` | `\App\Libraries\OutboundNotifications::dispatch()` | Interceptor records the call and scripts its return value |
| a hardcoded third-party URL | a `protected function …Url(): string` | Overridden to point at the local echo server |

`HTTPConnect` also accepts an injectable `Transport` (`setTransport()` per instance,
`setDefaultTransport()` process-wide) — that is how every HTTP interaction in the suite is stubbed.

[scripts/seam-budget.php](scripts/seam-budget.php) enforces the left-hand column: it counts
`exit`/`die`, `sleep`/`usleep` and `curl_init`/`curl_exec` in the in-scope files from the PHP token
stream (so comments, string literals and same-named methods don't register) and fails the gate on
any call site beyond the budget in `tests/coverage-floors.json`. The budgeted sites are the seams
themselves, plus two accepted exceptions: `BaseModel`'s 20ms breather between purge batches, and the
DSL's own `sleep()` builtin, which is a documented feature of the scripting language.

### Some tests pin bugs on purpose

A number of tests assert behaviour they document as **wrong**, so that fixing it shows up as a red
test rather than a silent change. They carry a docblock saying so and what to assert once fixed.
Don't "correct" one to make a change go green — changing one is a deliberate decision, and it needs
to be stated out loud.
