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
609 changes: 609 additions & 0 deletions .claude/hooks/php-ordering-conformance.py

Large diffs are not rendered by default.

203 changes: 203 additions & 0 deletions .claude/hooks/php-prose-punctuation-conformance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Prose punctuation conformance hook for tiny-blocks PHP libraries.

Self-contained PostToolUse hook on Edit|Write|MultiEdit. Verifies prose punctuation:
no em-dash, en-dash, or ` -- ` as a clause separator in Markdown prose or
PHP comments, plus no `;` separator in Markdown. The checks read raw text only, so
this script carries no PHP lexer. Markdown files route to the prose check, PHP
sources to the comment check.

Control flow uses guard clauses only and nesting never exceeds two levels. Reports
violations to stderr and exits 2 to prompt Claude with feedback; exits 0 silently if
no violations or the file is out of scope.
"""

import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Final

# --- Configuration ----------------------------------------------------------

# In-scope files: PHP sources under src/ or tests/, plus any Markdown file.
SCOPE_PATTERN: Final = re.compile(r"(^|/)(src|tests)/.+\.php$")
MARKDOWN_PATTERN: Final = re.compile(r"\.md$")

# Prohibited prose punctuation as clause separators. Em-dash U+2014, en-dash U+2013,
# spaced double hyphen, and (Markdown only) the semicolon.
PROSE_PUNCTUATION: Final = re.compile(r"[\u2014\u2013]| -- |;")
PROSE_DASHES: Final = re.compile(r"[\u2014\u2013]| -- ")
FENCE: Final = re.compile(r"^\s*```")
PHP_COMMENT: Final = re.compile(r"/\*.*?\*/|//[^\n]*|#(?!\[)[^\n]*", re.DOTALL)
INLINE_CODE: Final = re.compile(r"`[^`]*`")

# String and heredoc bodies, blanked before the PHP comment scan so a `//` or ` -- `
# inside a string literal is never mistaken for a comment.
STRING_LITERAL: Final = re.compile(
r"""
<<<[ \t]*(?P<quote>['"]?)(?P<label>\w+)(?P=quote)[^\n]*\n
.*?\n[ \t]*(?P=label)\b
| '(?:\\.|[^'\\])*'
| "(?:\\.|[^"\\])*"
""",
re.DOTALL | re.VERBOSE,
)

# HTML entities (&amp;, &#39;) and URLs carry legitimate semicolons, so they are
# stripped from a Markdown line before the prose-punctuation scan.
HTML_ENTITY: Final = re.compile(r"&(?:\w+|#\d+);")
URL: Final = re.compile(r"https?://\S+")
# A line indented by four or more spaces is a Markdown indented code block.
INDENTED_CODE: Final = re.compile(r"^ {4,}\S")

MAX_ERRORS_REPORTED = 30


# --- Types --------------------------------------------------------------------


@dataclass(frozen=True)
class Violation:
"""One style violation at a source position."""

line: int
path: str
message: str

def __str__(self) -> str:
return f"{self.path}:{self.line}: {self.message}"


@dataclass(frozen=True)
class FileUnit:
"""One file under analysis: its path and raw text. No lexing needed here."""

path: str
text: str


# --- Checks -------------------------------------------------------------------


def blanked(literal: re.Match[str]) -> str:
"""The matched literal as spaces, newlines preserved."""
return re.sub(r"[^\n]", " ", literal.group(0))


def is_markdown(path: str) -> bool:
"""Whether the path is a Markdown file, routed to the Markdown prose check."""
return bool(MARKDOWN_PATTERN.search(path))


def markdown_violations(unit: FileUnit) -> tuple[Violation, ...]:
"""No `;`, em-dash, en-dash, or ` -- ` as a clause
separator in Markdown prose. Fenced code and table rows are exempt."""
violations = []
in_fence = False
for number, line in enumerate(unit.text.split("\n"), start=1):
if FENCE.match(line):
in_fence = not in_fence
continue

if in_fence or "|" in line or INDENTED_CODE.match(line):
continue

prose = URL.sub("", HTML_ENTITY.sub("", INLINE_CODE.sub("", line)))

if PROSE_PUNCTUATION.search(prose):
violations.append(Violation(
line=number,
path=unit.path,
message=(
"prohibited prose punctuation (`;`, em-dash, en-dash, or ` -- `), "
"split the sentence or use a comma, colon, or parentheses"
),
))
return tuple(violations)


def comment_violations(unit: FileUnit) -> tuple[Violation, ...]:
"""In PHPDoc and comments: no em-dash, en-dash,
or ` -- ` as a separator. The `;` is not checked in PHP comments (it terminates
statements in commented code)."""
violations = []
source = STRING_LITERAL.sub(blanked, unit.text)
for match in PHP_COMMENT.finditer(source):
if PROSE_DASHES.search(INLINE_CODE.sub("", match.group(0))):
violations.append(Violation(
line=source.count("\n", 0, match.start()) + 1,
path=unit.path,
message=(
"prohibited prose punctuation (em-dash, en-dash, or ` -- `) in a "
"comment"
),
))
return tuple(violations)


def punctuation_violations(unit: FileUnit) -> tuple[Violation, ...]:
"""Punctuation violations for one file: Markdown prose for `.md`, comments otherwise."""
if is_markdown(unit.path):
return markdown_violations(unit)
return comment_violations(unit)


# --- Shell --------------------------------------------------------------------


def requested_paths() -> list[Path]:
"""The paths to verify, from argv or from the hook's stdin payload."""
if len(sys.argv) > 1:
return [Path(argument) for argument in sys.argv[1:]]
try:
payload = json.load(sys.stdin)
except ValueError:
return []

file_path = (payload.get("tool_input") or {}).get("file_path")

if isinstance(file_path, str):
return [Path(file_path)]
return []


def in_scope(path: Path) -> bool:
"""Whether the path is a PHP source or any Markdown this hook covers."""
posix = path.as_posix()
matched = SCOPE_PATTERN.search(posix) or MARKDOWN_PATTERN.search(posix)
return bool(matched) and path.is_file()


def file_violations(path: Path) -> tuple[Violation, ...]:
"""The punctuation violations for one file."""
unit = FileUnit(
path=path.as_posix(),
text=path.read_text(errors="replace", encoding="utf-8"),
)
return punctuation_violations(unit)


def main() -> int:
violations = [
violation
for path in requested_paths()
if in_scope(path)
for violation in file_violations(path)
]

if not violations:
return 0

for violation in violations[:MAX_ERRORS_REPORTED]:
print(violation, file=sys.stderr)
overflow = len(violations) - MAX_ERRORS_REPORTED

if overflow > 0:
print(f"... and {overflow} more violations", file=sys.stderr)
return 2


if __name__ == "__main__":
sys.exit(main())
48 changes: 11 additions & 37 deletions .claude/rules/php-library-code-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ paths:

# Code style

Semantic rules for all PHP files in libraries. Formatting rules covered by `PSR-12` are enforced
by `phpcs.xml`. Four formatting rules outside `PSR-12` (single-line signatures within 120
characters, no vertical alignment in parameter lists, vertical alignment of `=>` in multi-line
match arms and array literals, no trailing comma in multi-line lists) are documented at the end
of this file under "Formatting overrides". Complexity rules live in `php-library-modeling.md`.
Semantic rules for all PHP files in libraries. Formatting and structural rules are enforced by
`phpcs.xml` (the `PSR12` ruleset plus curated Squiz, Generic, PSR2, and `SlevomatCodingStandard`
sniffs). Three formatting rules the ruleset does not cover (single-line signatures within 120
characters, vertical alignment of `=>` in multi-line match arms and array literals, no trailing
comma in multi-line lists) are documented at the end of this file under "Formatting overrides".
Single spacing between a type hint and its parameter is mechanically enforced by the
`Squiz.Functions.FunctionDeclarationArgumentSpacing` sniff. Complexity rules live in `php-library-modeling.md`.
Folder structure, public API boundary, and the semantics of `Internal/` live in
`php-library-architecture.md`.

Expand Down Expand Up @@ -829,8 +831,7 @@ public function classify(int $score): Grade

## Formatting overrides

Four formatting rules are not covered by the canonical `phpcs.xml` (which references `PSR-12`
only). Apply them manually.
Three formatting rules are not covered by the canonical `phpcs.xml`. Apply them manually.

### Single-line signatures within 120 characters

Expand Down Expand Up @@ -859,36 +860,9 @@ private function __construct(public ExternalReference $id, public Money $amount,
```

When the one-line form would exceed 120 characters, break to one parameter per line and apply the
no-vertical-alignment and no-trailing-comma rules below.

### No vertical alignment in parameter lists

Use a single space between the type and the variable name in parameter lists (constructors,
function signatures, closures). Never pad with extra spaces to align columns. This rule applies
only to parameter lists, not to other contexts that use `=>` alignment (see "Vertical alignment
of `=>`" below).

**Prohibited.** Vertical alignment of types:

```php
public function __construct(
public OrderId $id,
public Money $total,
public Customer $customer,
public Precision $precision
) {}
```

**Correct.** Single space between type and variable:

```php
public function __construct(
public OrderId $id,
public Money $total,
public Customer $customer,
public Precision $precision
) {}
```
no-trailing-comma rule below. Single spacing between a type hint and its parameter (never padded to
align columns) is enforced mechanically by the `Squiz.Functions.FunctionDeclarationArgumentSpacing`
sniff, so it is not a manual override.

### Vertical alignment of `=>` in match arms and array literals

Expand Down
27 changes: 18 additions & 9 deletions .claude/rules/php-library-tooling.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,26 @@ revise before outputting.
2. `composer.json` exposes exactly five scripts: `configure`, `configure-and-update`, `review`,
`test-file`, `tests`. No other public scripts.
3. `composer.json` fixed fields use the canonical values from the skill asset (`license`, `type`,
`minimum-stability`, `prefer-stable`, `authors`, `config`, `require.php`). The five universal
`minimum-stability`, `prefer-stable`, `authors`, `config`, `require.php`). The six universal
dev dependencies (`ergebnis/composer-normalize`, `infection/infection`, `phpstan/phpstan`,
`phpunit/phpunit`, `squizlabs/php_codesniffer`) are present. `require-dev` may add libraries
the tests need on top of those five. The asset's caret ranges are the canonical floor, and
the repo `composer.json` matches the asset. To bump, update the asset first, then the repo.
`phpunit/phpunit`, `slevomat/coding-standard`, `squizlabs/php_codesniffer`) are present, and
`config.allow-plugins` enables `dealerdirect/phpcodesniffer-composer-installer` so the Slevomat
sniffs register with phpcs. `require-dev` may add libraries the tests need on top of those six.
The asset's caret ranges are the canonical floor, and the repo `composer.json` matches the
asset. To bump, update the asset first, then the repo.
4. `composer.json` `description` is a single short sentence. Multi-sentence prose belongs in the
README Overview, not in Composer metadata.
5. `composer.json` includes a `keywords` array that contains `"tiny-blocks"`. Its position in
the array is not constrained. The remaining entries are topic tokens derived from the
library's purpose (`psr-7`, `http-client`, `event-sourcing`, etc.).
6. `phpcs.xml` references only the `PSR12` ruleset. No additional sniffs. Formatting rules outside
PSR-12 live in `php-library-code-style.md` under "Formatting overrides".
6. `phpcs.xml` references the `PSR12` ruleset plus curated Squiz, Generic, PSR2, and
`SlevomatCodingStandard` sniffs that mechanically enforce the library's structural and
type-hint conventions (class member order, alphabetically sorted and used-only imports, strict
types, parameter, property, and return type hints, and complexity ceilings). The
`SlevomatCodingStandard.Classes.ClassStructure` groups collapse all methods into one group, so
the `php-ordering-conformance` hook owns the name-length ordering within them. The three
formatting rules the ruleset does not cover live in `php-library-code-style.md` under
"Formatting overrides".
7. `phpunit.xml` sets all five `failOn*` flags to `true` (`failOnDeprecation`, `failOnNotice`,
`failOnPhpunitDeprecation`, `failOnRisky`, `failOnWarning`).
8. `phpunit.xml` sets `executionOrder="random"` and `beStrictAboutOutputDuringTests="true"`.
Expand Down Expand Up @@ -84,9 +92,10 @@ revise before outputting.
The committed config files split into two naming conventions on purpose. The split is documented
here so it reads as intentional, not accidental.

- **Committed live, no `.dist`:** `phpcs.xml` and `phpunit.xml`. The ruleset (`PSR12` only) and
the test configuration are stable across the whole ecosystem and identical in every library.
There is no per-clone local-override story, so the live file is committed directly.
- **Committed live, no `.dist`:** `phpcs.xml` and `phpunit.xml`. The ruleset (`PSR12` plus the
curated Squiz, Generic, PSR2, and `SlevomatCodingStandard` sniffs) and the test configuration
are stable across the whole ecosystem and identical in every library. There is no per-clone
local-override story, so the live file is committed directly.
- **Committed as `.dist`:** `phpstan.neon.dist` and `infection.json.dist`. These are the two
tools a contributor may legitimately want to tune locally (a temporary `ignoreErrors` entry, a
narrower mutator set while iterating). The `.dist` baseline is committed. A contributor drops a
Expand Down
17 changes: 17 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -228,5 +228,22 @@
"Bash(wget * | bash)",
"Bash(wget * | sudo:*)"
]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PROJECT_DIR}/.claude/hooks/php-ordering-conformance.py"
},
{
"type": "command",
"command": "python3 ${CLAUDE_PROJECT_DIR}/.claude/hooks/php-prose-punctuation-conformance.py"
}
]
}
]
}
}
5 changes: 3 additions & 2 deletions .claude/skills/tiny-blocks-create/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ description: Scaffold a new PHP library for the tiny-blocks ecosystem, or restor
This skill is the single source of truth for the boilerplate every tiny-blocks PHP library
shares: the config files, the CI workflow, and the repository templates. The canonical bodies
live in `assets/` as drop-in files. Copy them and substitute the placeholders rather than
regenerating them from memory. The assets already encode the ecosystem's decisions (PSR-12 only,
`level: max`, MSI 100, Docker-wrapped Makefile, the `.dist` naming split, the export-ignore set).
regenerating them from memory. The assets already encode the ecosystem's decisions (the `PSR12`
plus `SlevomatCodingStandard` ruleset, `level: max`, MSI 100, Docker-wrapped Makefile, the
`.dist` naming split, the export-ignore set).

The semantic conventions (how to name classes, how to structure `src/`, how to write tests) are
**not** in this skill. They live in `.claude/rules/`. This skill produces the skeleton. The rules
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

*.php text diff=php

# Keep Claude tooling scripts out of GitHub's language statistics
/.claude/**/*.py linguist-vendored

# Dev-only, excluded from the Packagist tarball
/.github export-ignore
/tests export-ignore
Expand Down
2 changes: 2 additions & 0 deletions .claude/skills/tiny-blocks-create/assets/config/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ composer.lock
# Tooling cache
.phpunit.cache/
.phpunit.result.cache
__pycache__/
*.pyc

# Coverage and reports
build/
Expand Down
2 changes: 2 additions & 0 deletions .claude/skills/tiny-blocks-create/assets/config/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"infection/infection": "^0.33",
"phpstan/phpstan": "^2.2",
"phpunit/phpunit": "^13.1",
"slevomat/coding-standard": "^8.30",
"squizlabs/php_codesniffer": "^4.0"
},
"minimum-stability": "stable",
Expand All @@ -43,6 +44,7 @@
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true,
"ergebnis/composer-normalize": true,
"infection/extension-installer": true
},
Expand Down
Loading