Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .ai/guidelines/core.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
## Kirschbaum Monitor: control points

This application uses `kirschbaum-development/monitor`. A **control point** is an operation whose failure matters: charging a card, filing with an external system, posting a ledger entry, sending an email a person is waiting for, calling any third-party API. Control points are declared, not improvised.

### When an operation is critical

Wrap it in a control point when a failure would lose money, lose data, break a promise to a user, or leave an external system in a different state from ours. When in doubt, it is critical.

### How to declare one

Create a class under `App\ControlPoints\{Domain}` with `php artisan make:control-point {Domain}/{Name}`. The domain is the business area (Payments, Filings, Notifications); it becomes the `domain` field on every record.

```php
#[Point('payment.charge', profile: 'external')]
final class ChargeCard extends ControlPoint
{
public function __construct(private readonly Invoice $invoice) {}

protected function control(Control $control): void
{
$control
->recover(CardDeclined::class, fn (CardDeclined $e) => ChargeResult::declined($e->code))
->ensure(fn (ChargeResult $r): bool => $r->settled, 'charge must be settled')
->escalate(PagePayments::class);
}

public function context(): array { return ['invoice' => $this->invoice->id]; }

public function handle(StripeClient $stripe): ChargeResult { return $stripe->charge($this->invoice); }
}

ChargeCard::run($invoice); // value, or throws what escaped
ChargeCard::attempt($invoice); // Outcome: ->status, ->value, ->exception
```

Use `attempt()` when the caller branches on the outcome and `run()` when it wants the value. Points nest: a child's escalation reaches the parent's `recover()`, and retries never compose across the stack.

An operation that moves money or files with an external system also declares `->once('invoice:'.$id)` with an idempotency key, so a retried job or a double submit is refused with a `Duplicate` risk instead of running twice. To run a point on the queue, `ChargeCard::dispatch($invoice)`: the queue keeps its retries, the point keeps its policies, and a run refused by an open breaker releases the job for the breaker's retry-after. For an outbound call that does not deserve a point, `Http::breaker('stripe')->post(...)` puts the same circuit on the request.

Rules that `php artisan monitor:points --check` enforces:

- Names are dotted lowercase: `domain.operation`, unique across the app.
- Every point declares `escalate()` (an `Escalation` class or a closure) unless it recovers from `Throwable` deliberately.
- `control()` must not read constructor arguments; the inventory calls it without them.
- Profiles set sensible defaults: `external` for outbound HTTP, `database` for transactional writes, `messaging` for queues and email, `internal` otherwise. Declare on the point anything that differs.

### Risks and corrections

`recover(SomeException::class, fn ($e, Outcome $partial) => $value)` declares an expected failure and what to return instead; a class implementing `Kirschbaum\Monitor\Contracts\Correction` can stand in for the closure. The handler's return value **is** the result, `null` included. To escalate from a handler, throw. Do not use `try/catch` around the operation for expected failures; declare them. `escalateLimits()` also escalates a slow success; `throttleEscalation(600)` pages at most once per window during an outage.

### Limits

`within(seconds)` records a duration breach and never fails a completed run. `attempts(n)` caps retries. `ensure(fn ($result): bool, 'reason')` fails the run with `EnsureFailed` when the result is wrong even though the call "succeeded".

### Testing a point

```php
Monitor::fake(); // records every outcome, still runs the code
Monitor::fake()->failing('payment.charge', new CardDeclined('do_not_honor'));
Monitor::fake()->returning('payment.charge', ChargeResult::declined());

Monitor::assertSucceeded('payment.charge');
Monitor::assertRecovered('payment.charge', from: CardDeclined::class);
Monitor::assertEscalated('payment.charge', with: ConnectionException::class);
Monitor::assertRetried('payment.charge', times: 2);
Monitor::assertNotEscalated('payment.charge');
Monitor::assertNothingEscalated();
```

### Reading what happened

`php artisan monitor:points` lists every control point and its contract. `php artisan monitor:explain payment.charge` describes one. `php artisan monitor:outcomes --since=1h --status=escalated` lists recent outcomes when the store is on. Records in the log carry `point`, `domain`, `status`, `run_id` and `trace_id` as fields.
18 changes: 18 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
* text=auto eol=lf

# Keep development-only material out of the distributed package.
/.github export-ignore
/.githooks export-ignore
/scripts export-ignore
/tests export-ignore
/workbench export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/phpstan.neon.dist export-ignore
/phpunit.xml.dist export-ignore
/pint.json export-ignore
/testbench.yaml export-ignore

# Diff/linguist hints
*.php diff=php
/tests/** linguist-vendored
64 changes: 64 additions & 0 deletions .githooks/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/bin/bash
#
# Enforce single-line commit messages with no attribution trailers.
#
# Repository reporting and per-developer exposure coverage are derived from git
# history. Co-author trailers split authorship across two identities and skew
# those reports; multi-line bodies are noise the parser has to strip. The "why"
# belongs in the PR description, the CHANGELOG, or a comment at the code site,
# where it stays readable.
#
# Installed by `composer setup-hooks`, which points core.hooksPath at .githooks.

MSG_FILE="$1"
SOURCE="$2"

# Merges, squashes and reverts generate bodies git wrote itself.
case "$SOURCE" in
merge|squash) exit 0 ;;
esac

# Strip comments and trailing blank lines; that is what git will actually store.
BODY="$(grep -v '^#' "$MSG_FILE" | sed -e :a -e '/^\s*$/{$d;N;ba' -e '}')"

if [[ -z "${BODY//[[:space:]]/}" ]]; then
# An empty message aborts the commit anyway; let git say so.
exit 0
fi

FIRST_LINE="$(printf '%s\n' "$BODY" | head -n 1)"

case "$FIRST_LINE" in
Revert\ \"*) exit 0 ;;
esac

fail() {
echo ""
echo "🚫 Commit rejected: $1"
echo ""
echo " Commit messages must be a single line, with no body and no trailers."
echo " Put the reasoning in the PR description, the CHANGELOG, or a code comment."
echo ""
echo " Got:"
printf '%s\n' "$BODY" | sed 's/^/ | /'
echo ""
exit 1
}

if printf '%s\n' "$BODY" | grep -qiE '^[[:space:]]*(co-authored-by|claude-session|signed-off-by[[:space:]]*:[[:space:]]*claude)'; then
fail "attribution trailers break authorship reporting."
fi

if printf '%s\n' "$BODY" | grep -qiE 'claude\.ai/code/session'; then
fail "session links do not belong in git history."
fi

if [[ "$(printf '%s\n' "$BODY" | wc -l | tr -d ' ')" -gt 1 ]]; then
fail "the message has more than one line."
fi

if [[ ${#FIRST_LINE} -gt 72 ]]; then
fail "the subject is ${#FIRST_LINE} characters; keep it to 72."
fi

exit 0
8 changes: 7 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,10 @@ updates:
schedule:
interval: "weekly"
labels:
- "dependencies"
- "dependencies"
- package-ecosystem: "composer"
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"
2 changes: 1 addition & 1 deletion .github/workflows/automerge-dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:

- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2.5.0
uses: dependabot/fetch-metadata@v3.1.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/changelog-update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: main

Expand Down
46 changes: 36 additions & 10 deletions .github/workflows/php-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,24 @@ jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: true
# One unusable matrix leg should not cancel the other nine.
fail-fast: false
matrix:
os: [ubuntu-latest]
php: [8.3, 8.4]
laravel: [11.*, 12.*]
php: ['8.3', '8.4', '8.5']
laravel: [12.*, 13.*]
stability: [prefer-lowest, prefer-stable]
include:
- laravel: 11.*
testbench: ^9.9
carbon: ^2.63
- laravel: 12.*
testbench: 10.*
carbon: ^2.63|^3.0
- laravel: 13.*
testbench: 11.*

name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }}

steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Setup PHP
uses: shivammathur/setup-php@v2
Expand All @@ -42,13 +41,40 @@ jobs:
echo "::add-matcher::${{ runner.tool_cache }}/php.json"
echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"

# Carbon is not pinned here: the package only reaches it through
# Laravel's helpers, so whatever the framework resolves is the version
# worth testing against.
- name: Install dependencies
run: |
composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update
composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update
composer update --${{ matrix.stability }} --prefer-dist --no-interaction

- name: List Installed Dependencies
run: composer show -D

- name: Execute tests
run: vendor/bin/pest --ci --bail --compact --memory --coverage
run: vendor/bin/pest --ci --memory --coverage --min=100

performance:
name: performance guards
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- uses: actions/checkout@v7

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, intl, fileinfo
# Explicitly no coverage: instrumentation dominates the clock and
# flattens the difference between a fast and a slow implementation,
# so these assertions skip themselves when a driver is active.
coverage: none

- name: Install composer dependencies
uses: ramsey/composer-install@v4

- name: Run performance guards
run: vendor/bin/pest --testsuite=Performance --ci
60 changes: 60 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Security

# SAST and dependency advisories, on a schedule as well as on push so an
# advisory published while nobody is committing is still caught.
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '30 5 * * 1'
workflow_dispatch:

permissions:
contents: read

jobs:
semgrep:
name: Semgrep SAST
runs-on: ubuntu-latest
timeout-minutes: 10
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false

# Test fixtures use a Stripe-shaped placeholder to prove redaction works.
# That rule is excluded rather than the files, so every other secret
# rule still runs over them.
- name: Semgrep scan
run: >
semgrep scan
--config p/php
--config p/secrets
--exclude-rule generic.secrets.security.detected-stripe-api-key.detected-stripe-api-key
--error
--text

audit:
name: Dependency advisories
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
tools: composer:v2
coverage: none

- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress

- name: Composer audit
run: composer audit
23 changes: 17 additions & 6 deletions .github/workflows/static-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,38 @@ on:
push:
paths:
- '**.php'
- 'composer.lock'
- 'composer.json'
- 'phpstan.neon.dist'
- '.github/workflows/phpstan.yml'
- 'rector.php'
- '.github/workflows/static-analysis.yml'
pull_request:
paths:
- '**.php'
- 'composer.json'
- 'phpstan.neon.dist'
- 'rector.php'
- '.github/workflows/static-analysis.yml'

jobs:
phpstan:
name: phpstan
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
php-version: '8.5'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo, swoole, openssl
coverage: none

- name: Install composer dependencies
uses: ramsey/composer-install@v3
uses: ramsey/composer-install@v4

- name: Run Rector (dry run)
run: ./vendor/bin/rector process --dry-run --no-progress-bar

- name: Run PHPStan
run: ./vendor/bin/phpstan --error-format=github
run: ./vendor/bin/phpstan analyse --error-format=github --no-progress --memory-limit=1G
8 changes: 3 additions & 5 deletions .github/workflows/style-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: Code Style

on:
workflow_dispatch:
pull_request:
push:
branches-ignore:
- 'dependabot/npm_and_yarn/*'
Expand All @@ -14,13 +15,10 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.3
php-version: '8.5'

- name: Checkout
uses: actions/checkout@v6

- name: Copy .env
run: php -r "file_exists('.env') || copy('.env.example', '.env');"
uses: actions/checkout@v7

- name: Install Dependencies
run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
Expand Down
Loading