Skip to content
Open
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
607 changes: 607 additions & 0 deletions .claude/hooks/php-ordering-conformance.py

Large diffs are not rendered by default.

176 changes: 176 additions & 0 deletions .claude/hooks/php-prose-punctuation-conformance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#!/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"`[^`]*`")

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 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:
continue

if PROSE_PUNCTUATION.search(INLINE_CODE.sub("", line)):
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 = []
for match in PHP_COMMENT.finditer(unit.text):
if PROSE_DASHES.search(INLINE_CODE.sub("", match.group(0))):
violations.append(Violation(
line=unit.text.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())
147 changes: 147 additions & 0 deletions .claude/rules/php-library-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
---
description: Folder structure, public API boundary, and Internal/ semantics for PHP libraries.
paths:
- "src/**/*.php"
---

# Architecture

Covers the physical layout of the library. Folder structure, the boundary between public API and
implementation detail, and where each type of class lives. Semantic rules (value objects,
exceptions, enums, complexity, nomenclature) live in `php-library-modeling.md`. Code style lives
in `php-library-code-style.md`.

## Pre-output checklist

Verify every item before producing or relocating any file. If any item fails, revise before
outputting.

1. None of the following folder names exist in `src/`: `Models/`, `Entities/`, `ValueObjects/`,
`Enums/`, `Domain/`. They carry no semantic content and conflate technical role with domain
meaning.
2. The `src/` root contains only interfaces, extension points, public enums, thin orchestration
classes, and primary implementations or façades. Substantial logic (algorithms, state machines,
I/O) lives in `src/Internal/`, never at the root.
3. `src/Internal/` is implementation detail and not part of the public API. Breaking changes
inside `src/Internal/` are not semver-breaking.
4. Consumers must not reference, extend, or depend on any type inside `src/Internal/`. The
namespace itself is the boundary.
5. Public exception classes live in `src/Exceptions/`.
6. Internal exception classes live in `src/Internal/Exceptions/`.
7. Public enums live at the `src/` root or inside a public `<ConceptGroup>/` folder. Enums used
only by internals live in `src/Internal/`.
8. Public interfaces live at the `src/` root or inside a public `<ConceptGroup>/` folder.
9. A `<ConceptGroup>/` folder at the `src/` root groups related public types under a shared
concept. Each group has its own namespace and is part of the public API.
10. `<ConceptGroup>/` is optional. Use it only when the library exposes several coherent groups of
types (for example, aggregates and events) rather than a flat set of types around a single
concept.
11. Test fixtures representing domain concepts live in `tests/Models/`. Test doubles for system
boundaries live at the root of `tests/Unit/` or `tests/Integration/`. No dedicated `Mocks/`
or `Doubles/` subdirectory exists. Vendor compatibility (driver) tests, verifying the
library against specific external libraries/frameworks, are optional and have no `src/`
counterpart. They exist only as tests, under `tests/Integration/Drivers/<Vendor>/`,
grouped by vendor. Never a top-level `Drivers/` under `tests/`.
12. The `tests/Integration/` folder exists only when the library interacts with external
infrastructure (filesystem, database, network). Otherwise, the folder is absent.

## Folder structure

Canonical layout for a PHP library in the tiny-blocks ecosystem.

```
src/
├── <PublicInterface>.php # public contract at root
├── <Implementation>.php # main implementation or extension point at root
├── <PublicEnum>.php # public enum at root
├── <ConceptGroup>/ # public folder grouping related public types under a shared concept
│ ├── <PublicType>.php
│ └── ...
├── Internal/ # implementation details, not part of the public API
│ ├── <Collaborator>.php
│ └── Exceptions/ # internal exception classes
└── Exceptions/ # public exception classes

tests/
├── Models/ # domain fixtures reused across tests
├── Unit/ # unit tests targeting the public API
│ ├── <SomeMock>.php # test doubles at root of Unit/
│ └── <SomeSpy>.php
└── Integration/ # only present when the library interacts with infrastructure
├── Drivers/ # only present when the library exposes vendor-specific drivers
│ └── <Vendor>/ # tests against one specific third-party implementation
└── <SomeMock>.php # test doubles at root of Integration/ when needed
```

Never use `Models/`, `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. They
carry no semantic content and describe technical role instead of domain meaning.

## Public API boundary

The `src/` root is the contract. Everything at the root, plus everything inside public
`<ConceptGroup>/` folders and the public `Exceptions/` folder, is what consumers depend on. Changes
to these types follow semver rules.

`src/Internal/` is implementation detail. The namespace itself signals the boundary. Consumers
must not depend on any type inside `src/Internal/`. Breaking changes inside `src/Internal/` are
not semver-breaking for the library.

### What lives at the public boundary

- Interfaces that define contracts for consumers.
- Extension points designed to be subclassed or composed by consumers.
- Public enums and value objects consumers manipulate directly.
- Thin orchestration classes that wire collaborators together without containing substantial logic.
- Public exception classes consumers may catch.

### What lives in `src/Internal/`

- Algorithms, state machines, and complex transformations.
- Adapters for I/O (filesystem, network, database).
- Collaborators that exist purely to break a public class into testable units.
- Implementation details that may change between minor or patch releases.
- Internal exception classes raised by collaborators.

## Reference examples

### Small library with flat root

```
src/
├── Timezone.php # public value object
├── Timezones.php # public collection
├── Clock.php # public interface
└── Internal/
├── SystemClock.php # default Clock implementation
└── Exceptions/
└── InvalidTimezone.php
```

Everything lives at the root or inside `Internal/`. No `<ConceptGroup>/` folders. Suitable when
the library exposes a small, cohesive set of types around a single concept.

### Library with public concept groups

```
src/
├── ValueObject.php # public extension point at root
├── Aggregate/ # public namespace grouping aggregate types
│ ├── AggregateRoot.php
│ ├── EventualAggregateRoot.php
│ └── ModelVersion.php
├── Event/ # public namespace grouping event types
│ ├── EventRecord.php
│ ├── EventRecords.php
│ └── SequenceNumber.php
├── Internal/
│ ├── DefaultModelVersionResolver.php
│ └── Exceptions/
│ └── InvalidSequenceNumber.php
└── Exceptions/
└── EventRecordingFailure.php
```

`Aggregate/` and `Event/` are public folders at the root, each grouping a coherent set of public
types under one shared concept. Consumers import directly, for example
`TinyBlocks\<LibName>\Aggregate\AggregateRoot`. Suitable when the library exposes several distinct
concept areas, each with its own set of related types.
Loading