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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ benchmark-snapshot.json
# Generated by boost-core. Edit .ai/ instead.
.agents/skills/
.boost/
.claude/agents/boost/
.claude/commands/
.claude/skills/
.config/boost/
Expand Down
41 changes: 41 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ Write a test when the PHPStan error indicates a fault that would surface at runt
- Accessing a property or method that does not exist
- Any type error that would manifest as a runtime exception

### Annotate Rather Than Suppress

Some errors are PHPStan reading a signature that says less than the code does — a return type a parameter decides, a bool helper that proves a type. The `backend-quality` skill carries the two annotations that state the missing fact, and the rules for when each one lies.

### When to Skip the Test

Skip the test when the error is purely static and cannot cause a runtime failure:
Expand All @@ -85,6 +89,29 @@ A missing signature is a blocker to resolve (unlock the agent, re-authenticate 1

---

## Task Scope and Edits

For session, branch, and PR scope, see the `single-issue-scope` guideline when the project enables it.

### The Task Sets the Scope

- Do not fix a pre-existing bug, a performance problem, or unrelated behaviour you find on the way, unless the requested behaviour cannot work without it. The same holds for refactors, cleanup, and documentation nobody asked for. A defect your own change introduces is not pre-existing: fix it.
- A sibling rule that requires an update on a line you already change still applies. The rule removes extras, not obligations.
- Report the rest as a follow-up in your summary. Propose an issue when the project tracks work that way, and let the user decide whether to file it. Report it; do not fix it.
- Implement every behaviour the task does ask for, completely. This rule cuts extras, never the requested scope.

### One Reading of an Ambiguous Ask

Implement the reading that the wording and the surrounding code support most directly. State that assumption in your summary. Do not build for both readings.

Materially different work is the test. When two readings would produce the same change, pick one and carry on. When they would not, or when a wrong guess is unsafe or makes the work useless, ask before building — through the `clarify` skill where the whole ask is fuzzy, otherwise with a direct question.

### Edit in Place

Change only the lines that must change. Rewrite a whole file only when the file is short, or when most of it changes. A rewrite churns lines the task never touched and can drop content by accident.

---

## Verification Before Completion

Before claiming any work is complete or successful, run the verification command fresh and confirm the output. Evidence before claims, always.
Expand Down Expand Up @@ -115,6 +142,12 @@ Use the project's own commands — check its `composer.json` / `package.json` sc

Where the project has dedicated quality-check skills synced, delegate to them — `backend-quality` for backend files, `frontend-quality` for frontend files, both when a change spans both. Otherwise, run the project's own equivalent commands directly.

### A Commit Is a Claim Too

Commit a change once its own checks pass against the tree as it stands, not while the approach is still being tried. A commit reads as a decision. The next defect then gets patched on top of the approach instead of the approach being dropped, and each extra commit raises the cost of the revert that was the right answer.

Deferring is not "never commit". Uncommitted work is unprotected, and a commit is still the safe way to set work aside or to hand it over. A measurement loop inverts the rule on purpose — it commits before it measures, so a rejected experiment reverts in one step. Where a skill states that it commits first, that skill wins for its own flow.

### Never Use Without Evidence

- "should work now"
Expand All @@ -124,6 +157,14 @@ Where the project has dedicated quality-check skills synced, delegate to them

These phrases indicate missing verification. Run the command first, then report what actually happened.

### Say What You Did Not Verify

State the limits of your own check. A reader cannot tell a gap you did not mention from a check you ran, so an unmentioned gap counts as a claim you did not make good on.

When you report a result, name what you ran and what you did not. "The unit suite passes; I did not run the browser tests" is a complete report. "Tests pass" is not, when you ran one suite of three. The same holds for a claim you carried over from an earlier step: if you did not re-run it against the tree as it stands now, say so.

This is the outward half of the `NEEDS-CONFIRMATION` rule above. That rule stops you asserting an untraced cause. This one stops a traced, true statement from implying more than it covers.

---

# Laravel Package Guidelines
Expand Down
41 changes: 41 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ Write a test when the PHPStan error indicates a fault that would surface at runt
- Accessing a property or method that does not exist
- Any type error that would manifest as a runtime exception

### Annotate Rather Than Suppress

Some errors are PHPStan reading a signature that says less than the code does — a return type a parameter decides, a bool helper that proves a type. The `backend-quality` skill carries the two annotations that state the missing fact, and the rules for when each one lies.

### When to Skip the Test

Skip the test when the error is purely static and cannot cause a runtime failure:
Expand All @@ -85,6 +89,29 @@ A missing signature is a blocker to resolve (unlock the agent, re-authenticate 1

---

## Task Scope and Edits

For session, branch, and PR scope, see the `single-issue-scope` guideline when the project enables it.

### The Task Sets the Scope

- Do not fix a pre-existing bug, a performance problem, or unrelated behaviour you find on the way, unless the requested behaviour cannot work without it. The same holds for refactors, cleanup, and documentation nobody asked for. A defect your own change introduces is not pre-existing: fix it.
- A sibling rule that requires an update on a line you already change still applies. The rule removes extras, not obligations.
- Report the rest as a follow-up in your summary. Propose an issue when the project tracks work that way, and let the user decide whether to file it. Report it; do not fix it.
- Implement every behaviour the task does ask for, completely. This rule cuts extras, never the requested scope.

### One Reading of an Ambiguous Ask

Implement the reading that the wording and the surrounding code support most directly. State that assumption in your summary. Do not build for both readings.

Materially different work is the test. When two readings would produce the same change, pick one and carry on. When they would not, or when a wrong guess is unsafe or makes the work useless, ask before building — through the `clarify` skill where the whole ask is fuzzy, otherwise with a direct question.

### Edit in Place

Change only the lines that must change. Rewrite a whole file only when the file is short, or when most of it changes. A rewrite churns lines the task never touched and can drop content by accident.

---

## Verification Before Completion

Before claiming any work is complete or successful, run the verification command fresh and confirm the output. Evidence before claims, always.
Expand Down Expand Up @@ -115,6 +142,12 @@ Use the project's own commands — check its `composer.json` / `package.json` sc

Where the project has dedicated quality-check skills synced, delegate to them — `backend-quality` for backend files, `frontend-quality` for frontend files, both when a change spans both. Otherwise, run the project's own equivalent commands directly.

### A Commit Is a Claim Too

Commit a change once its own checks pass against the tree as it stands, not while the approach is still being tried. A commit reads as a decision. The next defect then gets patched on top of the approach instead of the approach being dropped, and each extra commit raises the cost of the revert that was the right answer.

Deferring is not "never commit". Uncommitted work is unprotected, and a commit is still the safe way to set work aside or to hand it over. A measurement loop inverts the rule on purpose — it commits before it measures, so a rejected experiment reverts in one step. Where a skill states that it commits first, that skill wins for its own flow.

### Never Use Without Evidence

- "should work now"
Expand All @@ -124,6 +157,14 @@ Where the project has dedicated quality-check skills synced, delegate to them

These phrases indicate missing verification. Run the command first, then report what actually happened.

### Say What You Did Not Verify

State the limits of your own check. A reader cannot tell a gap you did not mention from a check you ran, so an unmentioned gap counts as a claim you did not make good on.

When you report a result, name what you ran and what you did not. "The unit suite passes; I did not run the browser tests" is a complete report. "Tests pass" is not, when you ran one suite of three. The same holds for a claim you carried over from an earlier step: if you did not re-run it against the tree as it stands now, say so.

This is the outward half of the `NEEDS-CONFIRMATION` rule above. That rule stops you asserting an untraced cause. This one stops a traced, true statement from implying more than it covers.

---

# Laravel Package Guidelines
Expand Down
2 changes: 1 addition & 1 deletion src/Commands/DoctorCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public function handle(RuleRegistrar $registrar): int
: ['WARN', $list::class . ' does not flag a canonical disposable domain'];
}],
['role-account list', fn (): array => ['OK', resolve(RoleAccountList::class)::class]],
['dns resolver', fn (): array => ['OK', resolve(DnsResolver::class)::class]],
['dns resolver', fn (): array => ['OK', resolve(DnsResolver::class)::class]],
['batch query cap', function (): array {
$limit = BatchDatabaseChecker::$maxValuesPerGroup;

Expand Down
2 changes: 1 addition & 1 deletion tests/BatchDatabaseValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ function setupTestDatabase(): void

$validator = Validator::make(
['email' => 'unknown@example.com'],
['email' => ['required', Rule::exists('users', 'email')]],
['email' => ['required', Rule::exists('users', 'email')]],
['email.exists' => 'Custom exists message'],
);
$validator->setPresenceVerifier($verifier);
Expand Down
12 changes: 6 additions & 6 deletions tests/DocCountsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
/** @return list<class-string<ValidationRule>> */
function liveRuleClasses(): array
{
$root = dirname(__DIR__) . '/src/Rules';
$root = __DIR__ . '/../src/Rules';
$classes = [];

foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root)) as $file) {
Expand All @@ -39,7 +39,7 @@ function liveRuleClasses(): array

it('states the real rule count in the CHANGELOG', function (): void {
$actual = count(liveRuleClasses());
$prose = (string) file_get_contents(dirname(__DIR__) . '/CHANGELOG.md');
$prose = (string) file_get_contents(__DIR__ . '/../CHANGELOG.md');

preg_match_all('/(\d+) rules across/', $prose, $m);

Expand All @@ -56,13 +56,13 @@ function liveRuleClasses(): array
it('documents every rule family with a section in the rule reference', function (): void {
// Directory name → the reference page's heading, where they differ.
$headings = ['AntiSpam' => 'Anti-spam', 'Vendor' => 'Vendor identifiers'];
$reference = (string) file_get_contents(dirname(__DIR__) . '/docs/tools/rule-library.md');
$reference = (string) file_get_contents(__DIR__ . '/../docs/tools/rule-library.md');

$entries = scandir(dirname(__DIR__) . '/src/Rules');
$entries = scandir(__DIR__ . '/../src/Rules');
$families = array_values(array_filter(
$entries === false ? [] : $entries,
static fn (string $entry): bool => ! str_starts_with($entry, '.')
&& is_dir(dirname(__DIR__) . '/src/Rules/' . $entry),
&& is_dir(__DIR__ . '/../src/Rules/' . $entry),
));

foreach ($families as $family) {
Expand All @@ -75,7 +75,7 @@ function liveRuleClasses(): array
});

it('names every ClientCheckable implementer in the CHANGELOG', function (): void {
$prose = (string) file_get_contents(dirname(__DIR__) . '/CHANGELOG.md');
$prose = (string) file_get_contents(__DIR__ . '/../CHANGELOG.md');

$implementers = array_values(array_filter(
liveRuleClasses(),
Expand Down
18 changes: 9 additions & 9 deletions tests/ExcludeCoercionParityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ function assertExcludeParity(array $native, array $fluent, array $data): void
assertExcludeParity(
['items.*.detail' => ['exclude_unless:items.*.state,null', 'required', 'string']],
['items.*.detail' => [['exclude_unless', 'items.*.state', 'null'], 'required', 'string']],
['items' => [['state' => null]]], // state IS null → not excluded → required fires
['items' => [['state' => null]]], // state IS null → not excluded → required fires
);
});

it('exclude_if with null dependent matches native', function (): void {
assertExcludeParity(
['items.*.detail' => ['exclude_if:items.*.state,null', 'required', 'string']],
['items.*.detail' => [['exclude_if', 'items.*.state', 'null'], 'required', 'string']],
['items' => [['state' => null]]], // state IS null → excluded → no error
['items' => [['state' => null]]], // state IS null → excluded → no error
);
});

Expand Down Expand Up @@ -78,7 +78,7 @@ function assertExcludeParity(array $native, array $fluent, array $data): void
assertExcludeParity(
['items.*.x' => ['exclude_unless:items.*.n,1', 'required', 'string']],
['items.*.x' => [['exclude_unless', 'items.*.n', '1'], 'required', 'string']],
['items' => [['n' => 1]]], // int 1 ↔ '1' loose match → not excluded → required fires
['items' => [['n' => 1]]], // int 1 ↔ '1' loose match → not excluded → required fires
);
});

Expand Down Expand Up @@ -145,15 +145,15 @@ function assertExcludeParity(array $native, array $fluent, array $data): void
assertExcludeParity(
['items.*.name' => ['exclude_if:items.*.flag,null', 'required', 'string']],
['items.*.name' => [['exclude_if', 'items.*.flag', 'null'], 'required', 'string']],
['items' => [['other' => 'x']]], // flag absent
['items' => [['other' => 'x']]], // flag absent
);
});

it('exclude_if with an EXPLICIT null dependent excludes (matches native)', function (): void {
assertExcludeParity(
['items.*.name' => ['exclude_if:items.*.flag,null', 'required', 'string']],
['items.*.name' => [['exclude_if', 'items.*.flag', 'null'], 'required', 'string']],
['items' => [['flag' => null]]], // flag present and null → excluded → no error
['items' => [['flag' => null]]], // flag present and null → excluded → no error
);
});

Expand Down Expand Up @@ -195,15 +195,15 @@ function assertExcludeParity(array $native, array $fluent, array $data): void
assertExcludeParity(
['items.*.x' => ['exclude_unless:items.*.type,a', 'exclude_if:items.*.other,z', 'required', 'string']],
['items.*.x' => [['exclude_unless', 'items.*.type', 'a'], ['exclude_if', 'items.*.other', 'z'], 'required', 'string']],
['items' => [['type' => 'a', 'other' => 'z']]],
['items' => [['type' => 'a', 'other' => 'z']]],
);
});

it('evaluates ALL exclude conditions on a field (none fires → kept)', function (): void {
assertExcludeParity(
['items.*.x' => ['exclude_unless:items.*.type,a', 'exclude_if:items.*.other,z', 'required', 'string']],
['items.*.x' => [['exclude_unless', 'items.*.type', 'a'], ['exclude_if', 'items.*.other', 'z'], 'required', 'string']],
['items' => [['type' => 'a', 'other' => 'keep']]], // type=a (unless ok), other≠z → kept → required fires
['items' => [['type' => 'a', 'other' => 'keep']]], // type=a (unless ok), other≠z → kept → required fires
);
});

Expand All @@ -216,15 +216,15 @@ function assertExcludeParity(array $native, array $fluent, array $data): void
assertExcludeParity(
['items.*.x' => ['exclude_unless:items.*.type,a', 'required_if:items.*.other,y', 'string']],
['items.*.x' => [['exclude_unless', 'items.*.type', 'a'], ['required_if', 'items.*.other', 'y'], 'string']],
['items' => [['type' => 'a', 'other' => 'y']]], // x missing
['items' => [['type' => 'a', 'other' => 'y']]], // x missing
);
});

it('keeps tuple-form required_unless when a field survives its exclude condition', function (): void {
assertExcludeParity(
['items.*.x' => ['exclude_unless:items.*.type,a', 'required_unless:items.*.other,skip', 'string']],
['items.*.x' => [['exclude_unless', 'items.*.type', 'a'], ['required_unless', 'items.*.other', 'skip'], 'string']],
['items' => [['type' => 'a', 'other' => 'go']]], // other≠skip → x required, missing → error
['items' => [['type' => 'a', 'other' => 'go']]], // other≠skip → x required, missing → error
);
});

Expand Down
6 changes: 3 additions & 3 deletions tests/FluentValidatorConditionalParityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ function assertFvExcludeParity(array $rules, array $fluentRules, array $data): v
assertFvExcludeParity(
['items.*.detail' => ['exclude_unless:items.*.state,null', 'required', 'string']],
['items.*.detail' => [['exclude_unless', 'items.*.state', 'null'], 'required', 'string']],
['items' => [['state' => null]]], // state IS null → not excluded → required fires
['items' => [['state' => null]]], // state IS null → not excluded → required fires
);
});

Expand All @@ -154,7 +154,7 @@ function assertFvExcludeParity(array $rules, array $fluentRules, array $data): v
assertFvExcludeParity(
['items.*.extra' => ['exclude_unless:items.*.type,a', 'required', 'string']],
['items.*.extra' => [['exclude_unless', 'items.*.type', 'a'], 'required', 'string']],
['items' => ['foo' => ['type' => 'a']]], // type matches → not excluded → required fires
['items' => ['foo' => ['type' => 'a']]], // type matches → not excluded → required fires
);
});

Expand All @@ -165,7 +165,7 @@ function assertFvExcludeParity(array $rules, array $fluentRules, array $data): v
assertFvExcludeParity(
['items.*.rows.*.detail' => ['exclude_unless:items.*.type,keep', 'required', 'string']],
['items.*.rows.*.detail' => [['exclude_unless', 'items.*.type', 'keep'], 'required', 'string']],
['items' => ['foo' => ['type' => 'drop', 'rows' => [['x' => 1]]]]],
['items' => ['foo' => ['type' => 'drop', 'rows' => [['x' => 1]]]]],
);
});

Expand Down
2 changes: 1 addition & 1 deletion tests/Helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ function compiledArray(mixed $compiled): array
*/
function ruleClassesUnder(?string $implementing = null): array
{
$base = dirname(__DIR__) . '/src/Rules';
$base = __DIR__ . '/../src/Rules';
$classes = [];

foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base)) as $file) {
Expand Down
Loading