diff --git a/.claude/hooks/php-ordering-conformance.py b/.claude/hooks/php-ordering-conformance.py
deleted file mode 100644
index 21a3ec0..0000000
--- a/.claude/hooks/php-ordering-conformance.py
+++ /dev/null
@@ -1,607 +0,0 @@
-#!/usr/bin/env python3
-"""PHP ordering conformance hook for tiny-blocks PHP libraries.
-
-Self-contained PostToolUse hook on Edit|Write|MultiEdit. Verifies the deterministic
-ordering conventions for PHP declarations:
-
-- Parameter ordering: declaration parameters (constructors, factories, methods,
- property promotion) in three tiers, required parameters first, then defaulted
- parameters, then a variadic, each tier by identifier length ascending,
- alphabetical tie-breaker, semantic pairs preserved. A PHPUnit test method fed by
- a data provider is exempt, its parameters are the columns of its data set.
-- Member ordering: constants, enum cases, constructor, static methods, instance
- methods, in that group order, each group length-ascending with alphabetical
- tie-breaker. PHPUnit test classes instead order methods as lifecycle hooks (in
- execution order), then other methods, then data providers.
-
-The analysis is pure (FileUnit in, Violation out) and runs in three passes over
-well-formed PHP: a lexical pass blanks every comment, string, and heredoc/nowdoc
-body (LITERALS), a structural pass maps every bracket to its pair (bracket_spans);
-extraction assigns tokens of interest to their containers by flat walks. Control
-flow uses guard clauses only and nesting never exceeds two levels. Reports
-violations to stderr and exits 2 to prompt Claude, exits 0 silently if no violations
-or the file is out of scope.
-"""
-
-import json
-import re
-import sys
-from dataclasses import dataclass
-from enum import Enum
-from functools import cached_property
-from pathlib import Path
-from typing import Final
-
-# --- Configuration ----------------------------------------------------------
-
-# In-scope files: PHP sources under src/ or tests/.
-SCOPE_PATTERN: Final = re.compile(r"(^|/)(src|tests)/.+\.php$")
-
-# Semantic pairs (exhaustive). Natural order wins between
-# the two members when both appear in the same parameter list.
-SEMANTIC_PAIRS: Final = (
- ("start", "end"),
- ("from", "to"),
- ("startAt", "endAt"),
- ("createdAt", "updatedAt"),
- ("before", "after"),
- ("min", "max"),
-)
-
-# Each member maps to (first, second, position). Both members keep their natural
-# order only when both are present, sorting as a unit at the lead member's key.
-PAIR_MEMBER: Final = {
- member: (first, second, position)
- for first, second in SEMANTIC_PAIRS
- for position, member in enumerate((first, second))
-}
-
-MODIFIERS: Final = ("abstract", "final", "private", "protected", "public", "static")
-
-# The lexical grammar: every PHP construct that must not be scanned as code.
-# Alternatives are ordered, the heredoc label closes via backreference.
-LITERALS: Final = re.compile(
- r"""
- /\*.*?\*/ # block comment
- | //[^\n]* # line comment
- | \#(?!\[)[^\n]* # hash comment, never a #[ attribute
- | <<<[ \t]*(?P['"]?)(?P\w+)(?P=quote)[^\n]*\n
- .*?\n[ \t]*(?P=label)\b # heredoc and nowdoc body
- | '(?:\\.|[^'\\])*' # single-quoted string
- | "(?:\\.|[^"\\])*" # double-quoted string
- """,
- re.DOTALL | re.MULTILINE | re.VERBOSE,
-)
-
-TYPE_DECLARATION: Final = re.compile(
- r"^[ \t]*(?:(?:abstract|final|readonly)\s+)*(class|interface|trait|enum)\s+(\w+)",
- re.MULTILINE,
-)
-FUNCTION_DECLARATION: Final = re.compile(r"\bfunction\s+&?(\w+)\s*\(")
-METHOD_LINE: Final = re.compile(
- rf"^\s*((?:(?:{'|'.join(MODIFIERS)})\s+)+)function\s+&?(\w+)\s*\("
-)
-CONST_LINE: Final = re.compile(
- r"^\s*(?:(?:final|private|protected|public)\s+)*const\s+(?:[?\w|&()\s]+\s)?(\w+)\s*="
-)
-CASE_LINE: Final = re.compile(r"^\s*case\s+(\w+)\s*[=;]")
-
-PARAMETER: Final = re.compile(r"\$(\w+)")
-VARIADIC: Final = re.compile(r"\.\.\.\s*\$")
-# A default assignment is a lone `=`, never `=>` in an array or arrow function,
-# never a comparison (`==`, `!=`, `<=`, `>=`).
-DEFAULT_ASSIGNMENT: Final = re.compile(r"(?])=(?![=>])")
-
-# PHPUnit lifecycle hooks in fixed execution order, with detection patterns for a
-# test class and for the data providers its methods reference.
-LIFECYCLE_ORDER: Final = ("setUpBeforeClass", "setUp", "tearDown", "tearDownAfterClass")
-LIFECYCLE_HOOKS: Final = frozenset(LIFECYCLE_ORDER)
-EXTENDS_TESTCASE: Final = re.compile(r"\bextends\s+\\?(?:\w+\\)*TestCase\b")
-DATA_PROVIDER_REFERENCE: Final = re.compile(
- r"#\[\s*(?:\\?\w+\\)*DataProvider\s*\(\s*['\"](\w+)['\"]"
- r"|@dataProvider\s+(\w+)"
-)
-
-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 Source:
- """PHP source with literals blanked out at their original positions."""
-
- clean: str
-
- @staticmethod
- def blanked(literal: re.Match[str]) -> str:
- """The matched literal as spaces, newlines preserved."""
- return re.sub(r"[^\n]", " ", literal.group(0))
-
- @classmethod
- def from_php(cls, text: str) -> "Source":
- """A source with every comment, string, and heredoc blanked out."""
- return cls(clean=LITERALS.sub(cls.blanked, text))
-
- def line_of(self, index: int) -> int:
- """The 1-based line number of a character index."""
- return self.clean.count("\n", 0, index) + 1
-
-
-@dataclass(frozen=True)
-class FileUnit:
- """One file under analysis: its raw text and the blanked Source on demand."""
-
- path: str
- text: str
-
- @cached_property
- def source(self) -> Source:
- """The PHP source with literals blanked, computed once per file."""
- return Source.from_php(self.text)
-
-
-@dataclass(frozen=True)
-class Ordering:
- """Sole owner of the parameter-ordering logic for one variant."""
-
- pair_member: dict[str, tuple[str, str, int]]
-
- def sorted(self, names: list[str]) -> list[str]:
- """The names in the required order."""
- present = set(names)
- return sorted(names, key=lambda name: self.key(name, present))
-
- def key(self, name: str, present: set[str]) -> tuple[int, str, int]:
- """Length, then alphabet, with both members of a present pair at the lead key."""
- entry = self.pair_member.get(name)
- if entry is not None:
- first, second, position = entry
- partner = second if position == 0 else first
- if partner in present:
- return (len(first), first, position)
- return (len(name), name, 0)
-
-
-PARAMETER_ORDERING: Final = Ordering(pair_member=PAIR_MEMBER)
-MEMBER_ORDERING: Final = Ordering(pair_member={})
-
-
-class MemberKind(Enum):
- """Closed set of class-member groups in required declaration order.
-
- Ranks 0 to 4 are the production families. A PHPUnit test class keeps the
- const, case, constructor ranks then draws its methods from the test families
- (5 to 7): lifecycle hooks, other methods, data providers. A class draws its
- methods from one family set or the other, never both, so ranks stay monotonic.
- """
-
- CONSTANT = (0, "const")
- CASE = (1, "case")
- CONSTRUCTOR = (2, "constructor")
- STATIC_METHOD = (3, "static method")
- INSTANCE_METHOD = (4, "instance method")
- LIFECYCLE = (5, "lifecycle hook")
- METHOD = (6, "method")
- DATA_PROVIDER = (7, "data provider")
-
- @property
- def rank(self) -> int:
- return self.value[0]
-
- @property
- def label(self) -> str:
- return self.value[1]
-
- def precedes(self, other: "MemberKind") -> bool:
- """Whether this group must appear before the other group."""
- return self.rank < other.rank
-
-
-@dataclass(frozen=True)
-class Member:
- """One classified class member at its declaration line."""
-
- kind: MemberKind
- line: int
- name: str
-
-
-@dataclass(frozen=True)
-class TypeMembers:
- """The members of one class-like declaration in source order."""
-
- name: str
- is_test: bool
- members: list[Member]
-
-
-# Parameter ordering tiers: required first, then defaulted, then a variadic.
-REQUIRED_TIER: Final = 0
-DEFAULT_TIER: Final = 1
-VARIADIC_TIER: Final = 2
-
-
-@dataclass(frozen=True)
-class Parameter:
- """One declared parameter: its identifier and its ordering tier."""
-
- name: str
- tier: int
-
-
-@dataclass(frozen=True)
-class ParameterList:
- """One declaration's parameters in source order."""
-
- line: int
- owner: str
- params: list[Parameter]
-
- @property
- def names(self) -> list[str]:
- return [parameter.name for parameter in self.params]
-
- def in_tier(self, tier: int) -> list[str]:
- return [parameter.name for parameter in self.params if parameter.tier == tier]
-
- def required(self) -> list[str]:
- ordered = PARAMETER_ORDERING.sorted(self.in_tier(REQUIRED_TIER))
- ordered += PARAMETER_ORDERING.sorted(self.in_tier(DEFAULT_TIER))
- return ordered + self.in_tier(VARIADIC_TIER)
-
- def out_of_order(self) -> bool:
- return len(self.params) >= 2 and self.names != self.required()
-
-
-# --- Structure ----------------------------------------------------------------
-
-
-def bracket_spans(text: str) -> dict[int, int]:
- """Every opening bracket position mapped to its closing position."""
- spans: dict[int, int] = {}
- stack: list[int] = []
-
- for position, character in enumerate(text):
- if character in "([{":
- stack.append(position)
-
- if character in ")]}" and stack:
- spans[stack.pop()] = position
-
- return spans
-
-
-def top_level_pieces(text: str) -> list[str]:
- """The comma-separated pieces of a list, ignoring nested separators."""
- pieces, depth, current = [], 0, []
- for character in text:
- if character in "([{":
- depth += 1
-
- if character in ")]}":
- depth -= 1
-
- if character == "," and depth == 0:
- pieces.append("".join(current))
- current = []
- continue
- current.append(character)
-
- tail = "".join(current).strip()
-
- if tail:
- pieces.append(tail)
-
- return pieces
-
-
-# --- Extraction ---------------------------------------------------------------
-
-
-def declared_signatures(source: Source) -> list[ParameterList]:
- """Every function or method declaration with its parameters."""
- signatures = []
- spans = bracket_spans(source.clean)
-
- for match in FUNCTION_DECLARATION.finditer(source.clean):
- open_paren = source.clean.index("(", match.end() - 1)
- inner = source.clean[open_paren + 1: spans.get(open_paren, len(source.clean))]
- params = [
- parameter_of(piece)
- for piece in top_level_pieces(inner)
- if PARAMETER.search(piece)
- ]
-
- signatures.append(ParameterList(
- line=source.line_of(match.start()),
- owner=match.group(1),
- params=params,
- ))
- return signatures
-
-
-def parameter_of(piece: str) -> Parameter:
- """One parameter's identifier and ordering tier from its declaration piece."""
- name = PARAMETER.findall(piece)[-1]
-
- if VARIADIC.search(piece):
- return Parameter(name=name, tier=VARIADIC_TIER)
-
- if DEFAULT_ASSIGNMENT.search(piece):
- return Parameter(name=name, tier=DEFAULT_TIER)
- return Parameter(name=name, tier=REQUIRED_TIER)
-
-
-def class_members(unit: FileUnit) -> list[TypeMembers]:
- """Every class-like declaration with its members in source order."""
- declarations = []
- clean = unit.source.clean
- spans = bracket_spans(clean)
-
- for type_match in TYPE_DECLARATION.finditer(clean):
- body_open = clean.find("{", type_match.end())
-
- if body_open == -1:
- continue
-
- body_close = spans.get(body_open, len(clean))
- is_test = bool(EXTENDS_TESTCASE.search(clean[type_match.end(): body_open]))
- providers = test_providers(unit.text[body_open + 1: body_close]) if is_test else set()
- declarations.append(TypeMembers(
- name=type_match.group(2),
- is_test=is_test,
- members=declared_members(
- at_line=unit.source.line_of(body_open),
- body=clean[body_open + 1: body_close],
- is_test=is_test,
- providers=providers,
- ),
- ))
- return declarations
-
-
-def test_providers(raw_body: str) -> set[str]:
- """Every method name a data-provider attribute or annotation references."""
- names = set()
- for match in DATA_PROVIDER_REFERENCE.finditer(raw_body):
- names.add(match.group(1) or match.group(2))
- return names
-
-
-def provider_consumer_lines(unit: FileUnit) -> set[int]:
- """Declaration lines of the test methods a data provider feeds.
-
- The consumer side of the data-provider relation: the method a
- `#[DataProvider('name')]` attribute or `@dataProvider name` tag immediately
- precedes. Its parameters are the columns of the data set, ordered by the data
- rather than by name length, so the parameter check skips these lines. Detection
- runs on the raw text, since the docblock form is blanked in the Source, then the
- next declaration in the position-aligned Source fixes the line.
- """
- clean = unit.source.clean
- lines = set()
- for reference in DATA_PROVIDER_REFERENCE.finditer(unit.text):
- method = FUNCTION_DECLARATION.search(clean, reference.end())
- if not method:
- continue
- lines.add(unit.source.line_of(method.start()))
- return lines
-
-
-def declared_members(at_line: int, body: str, is_test: bool, providers: set[str]) -> list[Member]:
- """The members declared at the top level of one type body."""
- members, depth = [], 0
- for offset, line in enumerate(body.split("\n")):
- member = classified(at=at_line + offset, line=line, is_test=is_test, providers=providers)
- if depth == 0 and member:
- members.append(member)
- depth += line.count("{") - line.count("}")
- return members
-
-
-def classified(at: int, line: str, is_test: bool, providers: set[str]) -> Member | None:
- """The member a line declares, when it declares one."""
- method = METHOD_LINE.match(line)
- if method:
- name = method.group(2)
- if name == "__construct":
- return Member(kind=MemberKind.CONSTRUCTOR, line=at, name=name)
-
- if is_test:
- return Member(kind=test_method_kind(name=name, providers=providers), line=at, name=name)
-
- if "static" in method.group(1).split():
- return Member(kind=MemberKind.STATIC_METHOD, line=at, name=name)
- return Member(kind=MemberKind.INSTANCE_METHOD, line=at, name=name)
-
- constant = CONST_LINE.match(line)
-
- if constant:
- return Member(kind=MemberKind.CONSTANT, line=at, name=constant.group(1))
- case = CASE_LINE.match(line)
-
- if case:
- return Member(kind=MemberKind.CASE, line=at, name=case.group(1))
- return None
-
-
-def test_method_kind(name: str, providers: set[str]) -> MemberKind:
- """The method family a name takes inside a PHPUnit test class."""
- if name in LIFECYCLE_HOOKS:
- return MemberKind.LIFECYCLE
-
- if name in providers:
- return MemberKind.DATA_PROVIDER
- return MemberKind.METHOD
-
-
-# --- Checks -------------------------------------------------------------------
-
-
-def parameter_violations(unit: FileUnit) -> tuple[Violation, ...]:
- """Parameter ordering on every declaration a data provider does not feed."""
- exempt = provider_consumer_lines(unit)
- return tuple(
- Violation(
- line=signature.line,
- path=unit.path,
- message=(
- f"parameter order in `{signature.owner}()` is "
- f"({', '.join(signature.names)}), "
- f"required ({', '.join(signature.required())})"
- ),
- )
-
- for signature in declared_signatures(unit.source)
- if signature.line not in exempt and signature.out_of_order()
- )
-
-
-def member_violations(unit: FileUnit) -> tuple[Violation, ...]:
- """Member ordering on group sequence and order within each group."""
- violations: list[Violation] = []
- for declared in class_members(unit):
- violations.extend(group_sequence_violations(path=unit.path, declared=declared))
- violations.extend(within_group_violations(path=unit.path, declared=declared))
- return tuple(violations)
-
-
-def group_sequence_violations(path: str, declared: TypeMembers) -> tuple[Violation, ...]:
- """Members declared after a group that must come later."""
- violations = []
- order = group_order_text(declared.is_test)
- latest = MemberKind.CONSTANT
- for member in declared.members:
- if member.kind.precedes(latest):
- violations.append(Violation(
- line=member.line,
- path=path,
- message=(
- f"`{member.name}` ({member.kind.label}) declared after a later "
- f"group in `{declared.name}`, required group order {order}"
- ),
- ))
-
- if latest.precedes(member.kind):
- latest = member.kind
- return tuple(violations)
-
-
-def group_order_text(is_test: bool) -> str:
- """The required group-order clause for the production or the test layout."""
- if is_test:
- return "const, case, constructor, lifecycle hooks, methods, data providers"
- return "const, case, constructor, static methods, instance methods"
-
-
-def within_group_violations(path: str, declared: TypeMembers) -> tuple[Violation, ...]:
- """Groups whose members break their required intra-group order."""
- violations = []
- for kind in MemberKind:
- if kind is MemberKind.CONSTRUCTOR:
- continue
- grouped = [member for member in declared.members if member.kind is kind]
- names = [member.name for member in grouped]
-
- if not names:
- continue
- required = required_within(kind=kind, names=names)
-
- if names == required:
- continue
-
- violations.append(Violation(
- line=grouped[0].line,
- path=path,
- message=(
- f"{kind.label} order in `{declared.name}` is ({', '.join(names)}), "
- f"required ({', '.join(required)})"
- ),
- ))
- return tuple(violations)
-
-
-def required_within(kind: MemberKind, names: list[str]) -> list[str]:
- """One group's required order, lifecycle hooks by execution order, else length then alphabet."""
- if kind is MemberKind.LIFECYCLE:
- return [hook for hook in LIFECYCLE_ORDER if hook in names]
- return MEMBER_ORDERING.sorted(names)
-
-
-def ordering_violations(unit: FileUnit) -> tuple[Violation, ...]:
- """Every ordering violation in one PHP file: members, parameters."""
- return (
- *member_violations(unit),
- *parameter_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 under src/ or tests/."""
- return bool(SCOPE_PATTERN.search(path.as_posix())) and path.is_file()
-
-
-def file_violations(path: Path) -> tuple[Violation, ...]:
- """The ordering violations for one file."""
- unit = FileUnit(
- path=path.as_posix(),
- text=path.read_text(errors="replace", encoding="utf-8"),
- )
- return ordering_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())
diff --git a/.claude/hooks/php-prose-punctuation-conformance.py b/.claude/hooks/php-prose-punctuation-conformance.py
deleted file mode 100644
index 18cd3ef..0000000
--- a/.claude/hooks/php-prose-punctuation-conformance.py
+++ /dev/null
@@ -1,176 +0,0 @@
-#!/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())
diff --git a/.claude/rules/php-library-architecture.md b/.claude/rules/php-library-architecture.md
deleted file mode 100644
index 4be7fc3..0000000
--- a/.claude/rules/php-library-architecture.md
+++ /dev/null
@@ -1,147 +0,0 @@
----
-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 `/` folder. Enums used
- only by internals live in `src/Internal/`.
-8. Public interfaces live at the `src/` root or inside a public `/` folder.
-9. A `/` 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. `/` 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//`,
- 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/
-├── .php # public contract at root
-├── .php # main implementation or extension point at root
-├── .php # public enum at root
-├── / # public folder grouping related public types under a shared concept
-│ ├── .php
-│ └── ...
-├── Internal/ # implementation details, not part of the public API
-│ ├── .php
-│ └── Exceptions/ # internal exception classes
-└── Exceptions/ # public exception classes
-
-tests/
-├── Models/ # domain fixtures reused across tests
-├── Unit/ # unit tests targeting the public API
-│ ├── .php # test doubles at root of Unit/
-│ └── .php
-└── Integration/ # only present when the library interacts with infrastructure
- ├── Drivers/ # only present when the library exposes vendor-specific drivers
- │ └── / # tests against one specific third-party implementation
- └── .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
-`/` 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 `/` 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\\Aggregate\AggregateRoot`. Suitable when the library exposes several distinct
-concept areas, each with its own set of related types.
diff --git a/.claude/rules/php-library-code-style.md b/.claude/rules/php-library-code-style.md
deleted file mode 100644
index c44dbc1..0000000
--- a/.claude/rules/php-library-code-style.md
+++ /dev/null
@@ -1,960 +0,0 @@
----
-description: Semantic code rules for all PHP files in libraries.
-paths:
- - "src/**/*.php"
- - "tests/**/*.php"
----
-
-# 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`.
-Folder structure, public API boundary, and the semantics of `Internal/` live in
-`php-library-architecture.md`.
-
-## Pre-output checklist
-
-Verify every item before producing any PHP code. If any item fails, revise before outputting.
-
-1. `declare(strict_types=1)` is present.
-2. All parameters, return types, and properties have explicit types.
-3. Constructor property promotion is used.
-4. Named arguments are used at call sites for own code, tests, and third-party library methods
- (for example, tiny-blocks). Never use named arguments on:
- - Native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`,
- `iterator_to_array`, `sprintf`, `implode`, and similar).
- - Native PHP enum methods (`from`, `tryFrom`, `cases`).
- - PHPUnit assertions and expectations (`assertEquals`, `assertSame`, `assertTrue`,
- `expectException`, and similar).
- - Interfaces from PHP-FIG PSR standards (PSR-7 `withHeader`, PSR-18 `sendRequest`, etc.).
- The PSR contract does not include parameter names. Implementations may rename parameters.
- - Calls that include variadic spread (`...$args`). PHP rejects positional argument unpacking
- after named arguments. When the caller passes through a `...$variadic`, all arguments are
- positional. New own-code APIs should prefer a typed collection parameter over a variadic
- so named-argument call sites remain possible.
- - Native PHP class static and instance methods (`DateTimeImmutable::createFromFormat`,
- `DateTimeImmutable::createFromInterface`, `->setTimezone`, `->format`, and similar). Their
- parameter names are an internal implementation detail, not a stable contract, exactly as
- with native functions.
-
- Native PHP **class constructors** (`parent::__construct` calls to `\Exception`,
- `\RuntimeException`, `\InvalidArgumentException`, `\LogicException`, and similar) are not
- in the list above. They accept named arguments, and rule 8 requires using them whenever
- the positional call would pass an argument whose value equals the parameter's default.
- Example: `parent::__construct(message: sprintf(...), previous: $previous)` instead of
- `parent::__construct(sprintf(...), 0, $previous)`. The exclusion above covers native
- functions, enum methods, and native class static and instance methods, but not native class
- constructors (instantiation): those accept named arguments per rule 8.
-5. Classes follow the rules in "Inheritance and constructors". `final readonly` is the default,
- with documented exceptions for extension points and for parents that are not `readonly`.
-6. Members are ordered constants first, then constructor, then static methods, then instance
- methods. Within each group, order by **member name length ascending** (count the name only,
- without parentheses, arguments, or return type). Constants, enum cases, and methods share
- the same name-length-ascending rule, applied within their respective groups. This mirrors
- the rule that governs constructor parameters and named arguments (rule 7). When two names
- have equal length, order them alphabetically. This ordering may be overridden only when the
- alternative carries explicit documentation value: grouping by domain class with section
- markers (HTTP status codes by 1xx/2xx/3xx/etc), mirroring the order of an implemented
- interface, or similar evident structure. The override must be obvious at first reading.
-
- **At call sites** (chained method calls in production code, tests, or documentation
- examples), consecutive method invocations on the same receiver are ordered by **method name
- length ascending**, the same rule that governs member declarations. Boolean toggles such as
- `->secure()` and `->httpOnly()` come before parameterized `with*` builders because their
- names are shorter, not because the expression is narrower. When two method names have equal
- length, order them alphabetically.
-
- **Terminal methods that change the receiver type** stay at the end of the chain regardless
- of name length. A `build()` that returns the built value, a `commit()` that finalizes a unit
- of work, a `send()` that flushes a request, are terminal: the chain ends with them. The
- ordering rule applies only to consecutive calls on the same receiver type. Calls that
- transition to a different type are not reorderable. The same applies in reverse to the
- factory or accessor that starts the chain (`Cookie::create(...)`, `$repository`) stays
- at its position.
-
- **PHPUnit test classes** follow a dedicated sub-grouping inside the instance-methods group
- that overrides the name-length-ascending rule:
-
- 1. **Lifecycle hooks** first, in PHPUnit execution order:
- `setUpBeforeClass` → `setUp` → `tearDown` → `tearDownAfterClass`. Only those actually
- defined appear. Never introduce an empty hook to satisfy the rule.
- 2. **Test methods** (prefix `test`) next, ordered by name length ascending (alphabetical
- tiebreak).
- 3. **Data providers** last, ordered by name length ascending (alphabetical tiebreak).
-
- A method is a data provider if and only if its name appears as the string argument of a
- `#[DataProvider('')]` attribute or a `@dataProvider ` docblock annotation on a
- test method in the same class. The naming convention (`*DataProvider`) is informational
- only. The reference is the authoritative signal. A method named `*DataProvider` that no
- test references is dead code under rule 17, not a data provider.
-7. Constructor parameters are ordered by parameter name length ascending (count the name only,
- without `$` or type), except when parameters have an implicit semantic order (for example,
- `$start/$end`, `$from/$to`, `$startAt/$endAt`), which takes precedence. Parameters with default
- values go last, regardless of name length. The same rule applies to named arguments at call
- sites. Example order: `$id` (2), `$value` (5), `$status` (6), `$precision` (9).
-8. Never pass an argument whose value equals the parameter's default. Omit the argument entirely.
- Example with `toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE)`. The call
- `$collection->toArray(keyPreservation: KeyPreservation::PRESERVE)` becomes
- `$collection->toArray()`. Only pass the argument when the value differs from the default.
-9. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch
- instead. See "Polymorphism and tell-don't-ask".
-10. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of
- `$acc`.
-11. No generic identifiers exist. Use domain-specific names instead. Examples are `$data` to
- `$payload`, `$value` to `$totalAmount`, `$item` to `$element`, `$info` to `$currencyDetails`,
- `$result` to `$conversionOutcome`. **Exception:** a factory or constructor parameter that
- wraps a single opaque scalar the value object exists to represent may keep `$value` when no
- more specific meaning applies (for example, `Seconds::from(int $value)`). Where a more
- specific meaning exists, prefer it (`$iso`, `$identifier`, `$isoDay`).
-12. No raw arrays exist where a typed collection or value object is available. When data is
- `Collectible`, use the `tiny-blocks/collection` fluent API (`Collection`, `Collectible`). Use
- `createLazyFrom` when elements are consumed once. Raw arrays are acceptable only for primitive
- configuration data, variadic pass-through, and interop at system boundaries. See "Collection
- usage" for the full rule and example.
-13. No private methods exist except for private constructors in factory patterns, methods inside
- `src/Internal/` (implementation detail by definition, where the namespace is the abstraction
- boundary), and `setUp` or `tearDown` overrides in PHPUnit test classes. Outside these cases,
- inline trivial logic at the call site or extract it to a collaborator or value object.
-14. No logic is duplicated across two or more places (DRY). See "Duplication" for the resolution
- under the inheritance and private-method constraints.
-15. No abstraction exists without real duplication or isolation need (KISS).
-16. No inline comments exist in `src/` or `tests/`, except `# TODO: ` when implementation
- is unknown, uncertain, or intentionally deferred. Code is the documentation. Block comments
- (`/* */`) never appear outside docblocks (`/** */`). The `#` style for inline PHP comments
- applies only to code examples inside Markdown files (see `php-library-documentation.md`).
-17. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports.
-18. Never create public methods, constants, or classes in `src/` solely to serve tests. If
- production code does not need it, it does not exist.
-19. Format strings with placeholders (`%s`, `%d`, `%f`, etc.) are assigned to a `$template`
- variable before being passed to `sprintf`. The variable assignment and the `sprintf` call live
- on separate statements. See "Format strings" for examples.
-20. All class references use `use` imports at the top of the file. Fully qualified names inline are
- prohibited.
-21. Return types and `new` calls use the explicit class name. `self` is prohibited as a type,
- as a return type, in `new self()` instantiation, and in static method calls
- (`self::from(...)` → `ClassName::from(...)`). Constant access via `self::CONST_NAME` is the
- only permitted `self::` form. `static` is permitted only inside extension-point classes
- (declared `class` without `final readonly`) and inside traits, where late static binding lets
- subclasses or consuming classes instantiate the correct concrete type. In every other
- context, use the class name.
-22. Always use the most current and clean syntax available in the target PHP version. Prefer
- `match` over `switch`, first-class callables over `Closure::fromCallable()`, readonly promotion
- over manual assignment, enum methods over external switch or if chains, named arguments over
- positional ambiguity (except where excluded by rule 4), `Collection::map` over foreach
- accumulation, concise standard regex character classes (`\w`, `\d`, `\s`, and their negations)
- over their explicit equivalents (`[A-Za-z0-9_]`, `[0-9]`), and **unparenthesized constructor
- chaining** (PHP 8.4+): `new Foo()->bar()` instead of `(new Foo())->bar()`. The parentheses
- around the `new` expression are no longer required and add visual noise.
-23. All identifiers, comments, and documentation use American English. See "American English" for
- the spelling list.
-24. No method has more than three `return` statements. This bounds branching complexity and
- coexists with rule 9 (no `else`): early-return guard clauses are fine, but a method that needs
- more than three exit points is doing too much. Invariant violations `throw` a dedicated
- exception rather than returning, so guards rarely add return points. When branching still
- produces more than three returns, replace it with a `match` or map dispatch that resolves to a
- single return, or extract a collaborator. When the branches turn on the runtime type of
- polymorphic collaborator, the behavior belongs on that type instead. See "Return statements"
- and "Polymorphism and tell-don't-ask".
-25. The string concatenation operator (`.`) is never used, in any position. A string that
- would be assembled by concatenation, whether it embeds a value or joins two or more
- strings, is built with `sprintf` and a `$template` variable (rule 19) instead. This
- covers value prefixes, value suffixes, inline fragments, and plain joins. See "Format
- strings".
-
- **Exception:** a `const` string literal that contains no `sprintf` placeholder may
- use `.` to split a message across lines when a single-line literal would exceed the
- 120-character limit. In that case `sprintf` offers no benefit, since the `$template`
- line would itself exceed the limit, and heredoc and nowdoc are not permitted in
- constant expressions, so concatenation is the only way to honor the line length.
- This exception is limited to placeholder-free constant literals. Runtime string
- assembly, and any constant that interpolates a value, still uses `sprintf` with a
- `$template`.
-26. Behavior that varies by the concrete type of type the library owns is a polymorphic method
- on that type, never an `instanceof`, `get_class`, or enum-case branch. A value or behavior an
- enum case owns (a token, a flag about the case's nature, a derived value) lives on the enum as
- a predicate or vocabulary method, called at the site instead of comparing the case. Behavior
- that depends on a collaborator's state lives on that collaborator. See "Polymorphism and
- tell-don't-ask".
-
-## Naming
-
-- Internal code (variables, methods, classes) uses `camelCase`.
-- Constants and enum-backed values when representing codes use `SCREAMING_SNAKE_CASE`.
-- Names describe what in domain terms, not how technically. `$monthlyRevenue` instead of
- `$calculatedValue`. Generic technical verbs are avoided. See `php-library-modeling.md` for the
- full banlist of generic and anemic names.
-- Booleans use predicate form. Examples are `isActive`, `hasPermission`, `wasProcessed`.
-- Collections are always plural. Examples are `$orders`, `$lines`.
-- A boolean method reads as a predicate, using an `is`/`has`/`can`/`was`/`should` prefix or a
- third-person verb that reads as a yes/no question, such as `contains`, `matches`, `supports`,
- `equals`, or `omits`.
-
-## Class self-references
-
-Type declarations, return types, and `new` calls inside a class use the explicit class name.
-The class name is unambiguous, survives refactors that move the method to a different class,
-and reads identically inside the class body and at the call site.
-
-- `self` is prohibited everywhere as a type, as a return type, in `new self()` instantiation,
- and in static method calls (`self::from(...)`). Constant access via `self::CONST_NAME` is
- **permitted** and is the only allowed `self::` form. The prohibition covers the forms that
- carry refactoring ambiguity when a method moves to a different class (type, instantiation, and
- static-call forms): a `self::from()` call rebinds to the wrong class if the method moves,
- exactly like `new self()`. Constant access does not have that ambiguity because the constant is
- declared in the same class body.
-- `static` is permitted only inside extension-point classes (declared `class` without
- `final readonly`) and inside traits, where late static binding is required for subclasses or
- consuming classes to instantiate the correct concrete type.
-- In every other context (the default `final readonly class`, factory methods, return types),
- use the class name.
-
-**Prohibited.** `self` as return type and `new self()` inside a final class:
-
-```php
-final readonly class UserAgent
-{
- public static function from(string $product): self
- {
- return new self(product: $product);
- }
-}
-```
-
-**Correct.** Explicit class name in a final class:
-
-```php
-final readonly class UserAgent
-{
- public static function from(string $product): UserAgent
- {
- return new UserAgent(product: $product);
- }
-}
-```
-
-**Correct.** `static` permitted in an extension-point class:
-
-```php
-class Collection
-{
- public static function createFrom(iterable $elements): static
- {
- return new static(elements: $elements);
- }
-}
-```
-
-## Inheritance and constructors
-
-- All classes are `final readonly` by default.
-- Use `class` (without `final` or `readonly`) only when the class is designed as an extension point
- for consumers, for example `Collection` or `ValueObject`.
-- Use `final class` without `readonly` only when the parent class is not readonly, for example
- when extending a third-party abstract class.
-- Use `final class` without `readonly` is also permitted for `src/Internal/` collaborators that
- carry intrinsically mutable state (resource handles, counters, cursors) where the mutation is
- central to the class's responsibility (`Stream` closing a resource, `Cursor` advancing a
- position). The class must remain confined to `src/Internal/`.
-- Use `final class` without `readonly` for classes that consist exclusively of `static` methods
- (no instance properties, no instance methods, only static factories or utilities). Pair it
- with `private function __construct() {}` to prevent instantiation. `readonly` is meaningless
- without instance state, and the private constructor signals that the class is a static
- surface, not a value type.
-- Inheritance between concrete classes is prohibited. Every concrete class is `final`.
-- Polymorphism uses interfaces plus composition, never extension of concrete types.
-- The only allowed `extends` is against framework or SPL base classes that the language requires.
- Examples are `RuntimeException`, `LogicException`, `PHPUnit\Framework\TestCase`.
-- Constructors of `final` classes are `private` when paired with named factory methods, `public`
- otherwise. `protected` constructors are prohibited because no subclasses exist to call them.
-
-## Comparisons
-
-1. Null checks use `is_null($variable)`, never `$variable === null`.
-2. Empty string checks on typed `string` parameters use `$variable === ''`. Avoid `empty()` on
- typed strings because `empty('0')` returns `true`.
-3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`) use
- `empty($variable)`.
-
-## American English
-
-All identifiers, enum values, comments, and error codes use American English spelling. Examples
-are `canceled` (not `cancelled`), `organization` (not `organisation`), `initialize` (not
-`initialise`), `behavior` (not `behaviour`), `modeling` (not `modelling`), `labeled` (not
-`labelled`), `fulfill` (not `fulfil`), `color` (not `colour`).
-
-## PHPDoc
-
-### When required
-
-Everything exposed on the public API for consumption carries PHPDoc per these rules.
-
-- Every method of an interface, regardless of location. Interfaces are contracts, so they carry
- PHPDoc per these rules even when declared inside `src/Internal/`.
-- Every public method of a concrete class outside `src/Internal/`. Public classes are at the
- public API boundary by definition. Consumers call every public method directly, and the
- PHPDoc is the contract for each call. Trivial getters and `with*` methods are not exempt.
- The only exception is a public method whose contract is already documented on an implemented
- interface (the interface carries the docblock).
-- Every abstract method on a public class or extension point outside `src/Internal/`. Abstract
- methods are part of the public contract consumers implement or override, so each carries PHPDoc
- exactly as an interface method does.
-- A class-level summary docblock on every interface (including interfaces inside `src/Internal/`)
- and on every public class or enum outside `src/Internal/`. The summary is a single line placed
- directly above the declaration stating what the type is or does, following the same summary-line
- rule as method docblocks. It is the class-level counterpart of the per-method PHPDoc.
-
-### When prohibited
-
-- Constructors. The constructor signature with property promotion is self-documenting. Parameter
- types are already explicit in the signature.
-- Private and protected methods.
-- Public methods of concrete classes whose contract is already documented on an implemented
- interface. The interface carries the docblock.
-- Concrete classes and collaborators inside `src/Internal/`. Internal implementation types are
- detail, not contract, and carry no PHPDoc, class-level summary included. **Interfaces are the
- exception**: an interface declared inside `src/Internal/` is still a contract and follows the
- interface PHPDoc rules under "When required", including the class-level summary. See
- `php-library-architecture.md` for the architectural meaning of `Internal/`.
-- Anywhere inside `tests/`. Test methods name the scenario via the `testXxxWhenYyyThenZzz`
- naming convention, and the `@Given`/`@When`/`@Then`/`@And` annotation blocks defined in
- `php-library-testing.md` describe the steps. PHPDoc documentation (summary plus
- `@param`/`@return` descriptions) is prohibited on test methods, data providers, fixtures,
- setUp/tearDown overrides, and anonymous classes inside tests. The BDD annotations are not
- PHPDoc documentation in the sense of this section and remain required per the testing rule.
-- Single-line PHPDocs with only a tag (`/** @param ... */`, `/** @return ... */`,
- `/** @throws ... */`). PHPDoc always opens with a summary line. Bare-tag docblocks are
- prohibited regardless of how few tags they carry.
-
-The prohibitions above apply to **every form of PHPDoc** in the prohibited scope:
-method-level docblocks, property-level docblocks, inline `@var` annotations on local variables,
-and PHPDoc blocks placed above anonymous functions or closures inside method bodies. Inside
-`tests/`, zero PHPDoc is the rule, save for the generics carve-out below. Inside `src/Internal/`,
-zero PHPDoc applies to concrete classes and collaborators, but interfaces carry PHPDoc per "When
-required", and the generics carve-out below still applies to those concrete classes. PHPStan
-errors that result from the missing annotations on the non-interface code route through
-`ignoreErrors` (see below).
-
-**Generics carve-out.** The prohibitions above are waived for PHPDoc that exists *purely to
-express generics* the native type system cannot: `@template`, `@extends`, `@implements`, and the
-`@param`/`@return`/`@var` tags whose sole purpose is to carry a type parameter (for example
-`Collection`, `iterable`, `Closure(TValue): bool`, `static`). These tags
-are permitted wherever they are necessary for generic typing, including on **constructors**, on
-**concrete classes and collaborators inside `src/Internal/`**, and as a **bare-tag block with no
-summary line** (a summary would be the prohibited descriptive form). The waiver is strict: it
-covers only the type-parameter information. Descriptive or redundant PHPDoc (summaries, prose
-`@param`/`@return` descriptions, anything restating what the signature already says) stays
-prohibited everywhere. When the only missing annotation is non-generic (a plain iterable value
-type, a mixed-origin argument), the typed-array case below still applies and routes through
-`ignoreErrors`, not PHPDoc.
-
-The PHPDoc prohibitions above take priority over the typed-array case. When PHPStan at
-`level: max` flags a missing iterable value type (`missingType.iterableValue`,
-`argument.type`, `return.type`):
-
-- On a **constructor parameter** → suppress via `ignoreErrors` in `phpstan.neon.dist`. Do not
- add PHPDoc.
-- On a concrete class or collaborator inside **`src/Internal/`** → suppress via `ignoreErrors`.
- Do not add PHPDoc. An interface inside `src/Internal/` is the exception: it carries PHPDoc per
- "When required", so the typed-array information goes in the docblock, not `ignoreErrors`.
-- On anything inside **`tests/`** → suppress via `ignoreErrors`. Do not add PHPDoc.
-- On a **public method of a public (non-Internal) class** → add full PHPDoc with summary,
- `@param` descriptions, and the typed-array information. The bare-tag form remains
- prohibited. This is the normal case where PHPDoc is permitted by "When required" above.
-
-The summary requirement and the bare-tag prohibition are never waived. Use `ignoreErrors` only
-when the context (constructor, `src/Internal/`, `tests/`) makes PHPDoc impossible. Every public
-method of a public concrete class carries PHPDoc per "When required", whether the method
-has typed-array parameters.
-
-### Style
-
-- Summary on the first line, in domain terms. **Mandatory.** PHPDoc without a summary line is
- prohibited, even when it carries a single `@param` or `@return`.
-- Optional detailed body in `` paragraphs below the summary.
-- Tags use the form `@param Type $name Description.`, `@return Type Description.`,
- `@throws ExceptionClass If .`.
-- Document `@throws` for every exception the method may raise.
-- HTML tags allowed inside descriptions are `` for paragraphs, `
` for lists,
- `` for inline code, `` and `` for emphasis.
-
-### Summary patterns
-
-The summary line is not a creative intent statement. It is a template selected by the method's
-name prefix. Apply the matching template. Only methods with no matching prefix require a
-free-form one-line summary in domain terms.
-
-| Method shape | Template |
-|-------------------------------------------------------------------------|--------------------------------------------------------------------------------|
-| Static factory (`create`, `from`, `fromX`, `with*` when static) | `Creates a {ClassName} from {input}.` or `Builds a {ClassName} with {fields}.` |
-| `with*` instance method | `Returns a copy of the {ClassName} with the {field} replaced.` |
-| Getter (no prefix, returns a property: `code()`, `body()`, `headers()`) | `Returns the {field}.` |
-| Predicate (`is*`, `has*`, `can*`, `was*`, `should*`) | `Tells whether {condition}.` |
-| Converter (`toArray`, `toString`, `asX`) | `Returns the {ClassName} as {target shape}.` |
-| `apply*`, `merge*`, `add*`, and other side-effect-free operations | One-line summary in domain terms describing the operation. |
-
-The patterns are mandatory when applicable. They make summary lines mechanical: substitute
-`{ClassName}` and `{field}` and the summary is complete. No per-method intent decision is
-required. Volume is never a reason to skip the summary. Many methods just mean applying the
-template many times.
-
-### Cross-references
-
-- `{@see ClassName}` for links to other types in the codebase.
-- `@see Author, Title (Publisher, Year), Chapter X.` for bibliographical references.
-
-### Examples
-
-**Prohibited.** Single-line bare-tag PHPDoc, no summary:
-
-```php
-/** @param array|null $body */
-public static function with(Code $code, ?array $body = null): Response
-```
-
-**Prohibited.** PHPDoc on a constructor:
-
-```php
-/** @param array $entries */
-public function __construct(public array $entries)
-{
-}
-```
-
-**Prohibited.** PHPDoc on anything inside `src/Internal/`:
-
-```php
-namespace TinyBlocks\Http\Internal\Client;
-
-final readonly class Url
-{
- /** @param array|null $query */
- public static function compose(string $path, ?array $query, string $baseUrl): string
- {
- }
-}
-```
-
-**Correct.** Generic array type with summary and `@param` description:
-
-```php
-/**
- * Builds a synthesized response from a status code and an optional body.
- *
- * @param array|null $body The response body as an associative array.
- * @return Response The synthesized response instance.
- */
-public static function with(Code $code, ?array $body = null): Response
-```
-
-**Correct.** Interface with rich description, paragraphs, cross-references, and bibliography:
-
-```php
-/**
- * Money tied to a specific currency.
- *
- * Operations between different currencies raise CurrencyMismatch. Arithmetic
- * preserves the currency.
- *
- * Sibling of {@see Quantity}, not a parent. Money carries currency semantics.
- *
- * @see Eric Evans, Domain-Driven Design (Addison-Wesley, 2003), Chapter 5.
- */
-interface Money
-{
- /**
- * Adds the given amount.
- *
- * @param Money $other The amount to add.
- * @return Money A new instance with the summed amount.
- * @throws CurrencyMismatch If $other has a different currency.
- */
- public function add(Money $other): Money;
-}
-```
-
-**Correct.** Concrete class with a short summary and direct tags:
-
-```php
-/**
- * IANA timezone identifier (e.g. America/Sao_Paulo).
- */
-final readonly class Timezone
-{
- /**
- * Creates a Timezone from a valid IANA identifier.
- *
- * @param string $identifier The IANA timezone identifier.
- * @return Timezone The created instance.
- * @throws InvalidTimezone If the identifier is not a valid IANA timezone.
- */
- public static function from(string $identifier): Timezone
- {
- # ...
- }
-}
-```
-
-## Dependencies
-
-When the library needs an external dependency, prefer packages from the `tiny-blocks` ecosystem
-(https://github.com/tiny-blocks) whenever a suitable option exists. Reach for outside packages
-only when the ecosystem has no equivalent that fits the use case.
-
-## Collection usage
-
-When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array
-functions such as `array_map`, `array_filter`, `iterator_to_array`, or `foreach` plus accumulation.
-The same applies to `filter()`, `reduce()`, `each()`, and every other `Collectible` operation.
-Chain them fluently. Never materialize with `iterator_to_array` to then pass into a raw `array_*`
-function.
-
-**Prohibited.** `array_map` plus `iterator_to_array` on a `Collectible`:
-
-```php
-$names = array_map(
- static fn(Element $element): string => $element->name(),
- iterator_to_array($collection)
-);
-```
-
-**Correct.** Fluent chain with `map()` plus `toArray()`:
-
-```php
-$names = $collection
- ->map(transformations: static fn(Element $element): string => $element->name())
- ->toArray(keyPreservation: KeyPreservation::DISCARD);
-```
-
-## Format strings
-
-When building a message with placeholders, assign the format string to a `$template` variable
-first. Pass it to `sprintf` on a separate statement. The format and the data are visually
-separated, and the template line stays scannable.
-
-**Prohibited.** Format string inline with the call:
-
-```php
-if ($value < 0 || $value > 16) {
- throw new PrecisionOutOfRange(
- message: sprintf('Precision must be between 0 and 16, got %d.', $value)
- );
-}
-```
-
-**Correct.** Format string in a `$template` variable:
-
-```php
-if ($value < 0 || $value > 16) {
- $template = 'Precision must be between 0 and 16, got %d.';
-
- throw new PrecisionOutOfRange(message: sprintf($template, $value));
-}
-```
-
-The `.` operator is never used to assemble a string. Value prefixes, value suffixes, inline
-fragments, and plain joins all go through `sprintf` with a `$template`. This holds even when
-no value is interpolated, for example when joining a directory and a file name.
-
-The sole exception is a placeholder-free `const` string literal that would exceed 120
-characters on a single line: it may use `.` to split across lines, since `sprintf` would
-not shorten the line and heredoc is unavailable in constant expressions.
-
-**Prohibited.** Concatenation to inject a value:
-
-```php
-$candidate = is_int($value) ? '@' . $value : $value;
-```
-
-**Correct.** `$template` plus `sprintf`:
-
-```php
-$template = '@%d';
-$candidate = is_int($value) ? sprintf($template, $value) : $value;
-```
-
-**Prohibited.** Concatenation to join strings:
-
-```php
-$location = $directory . '/' . $file;
-```
-
-**Correct.** A single `$template` for the join:
-
-```php
-$template = '%s/%s';
-$location = sprintf($template, $directory, $file);
-```
-
-## Constructor chaining
-
-PHP 8.4 allows chained method calls directly on a `new` expression without wrapping it in
-parentheses. The parentheses are no longer required and only add visual noise. Apply this
-everywhere a `new` is followed by a method call.
-
-**Prohibited.** Parentheses around the `new` expression:
-
-```php
-$body = (new ServerRequest(uri: 'https://api.example.com', method: 'GET'))
- ->withHeader('Accept', 'application/json')
- ->getBody();
-```
-
-**Correct.** No parentheses:
-
-```php
-$body = new ServerRequest(uri: 'https://api.example.com', method: 'GET')
- ->withHeader('Accept', 'application/json')
- ->getBody();
-```
-
-## Duplication
-
-When two or more places share logic, extract it into a collaborator (a value object, or a class
-in `src/Internal/`), or move it onto a collaborator both call sites already depend on. The type
-that owns the data owns the derived behavior.
-
-A shared base class is not available: inheritance between concrete classes is prohibited (see
-"Inheritance and constructors"). A shared private helper is not available either: private methods
-on public classes are prohibited (rule 13). Composition is therefore the only mechanism, and
-leaving the duplication in place is never the resolution.
-
-**Prohibited.** The same derivation copied byte for byte into two types:
-
-```php
-final readonly class Exam
-{
- public function __construct(public int $score) {}
-
- public function grade(): Grade
- {
- return match (true) {
- $this->score >= 90 => Grade::A,
- $this->score >= 80 => Grade::B,
- $this->score >= 70 => Grade::C,
- default => Grade::F
- };
- }
-}
-
-final readonly class Assignment
-{
- public function __construct(public int $score) {}
-
- public function grade(): Grade
- {
- return match (true) {
- $this->score >= 90 => Grade::A,
- $this->score >= 80 => Grade::B,
- $this->score >= 70 => Grade::C,
- default => Grade::F
- };
- }
-}
-```
-
-**Correct.** The derivation lives once on the collaborator both types hold, and each delegates:
-
-```php
-final readonly class Score
-{
- public function __construct(public int $value) {}
-
- public function toGrade(): Grade
- {
- return match (true) {
- $this->value >= 90 => Grade::A,
- $this->value >= 80 => Grade::B,
- $this->value >= 70 => Grade::C,
- default => Grade::F
- };
- }
-}
-
-final readonly class Exam
-{
- public function __construct(public Score $score) {}
-
- public function grade(): Grade
- {
- return $this->score->toGrade();
- }
-}
-
-final readonly class Assignment
-{
- public function __construct(public Score $score) {}
-
- public function grade(): Grade
- {
- return $this->score->toGrade();
- }
-}
-```
-
-## Polymorphism and tell-don't-ask
-
-This refines rules 9 and 24. A `match` on an enum, on a scalar, or on a value condition stays
-correct. What is prohibited is branching on the runtime type of polymorphic collaborator the
-library defines: when behavior differs across the concrete implementations of an interface the
-library owns, that behavior is a method on the interface, resolved by the object itself, never an
-`instanceof` or `get_class` chain at the call site.
-
-The opening sentence holds only for control flow. When a branch on an enum case yields a value or
-behavior that belongs to the case itself, a token, a flag about the case's nature, or a derived
-value, that value or behavior is a method on the enum: a predicate `isXxx()`, or a vocabulary
-method that returns the value, called at the site instead of comparing the case. Comparing a case
-(`$direction === Order::ASCENDING`, `match ($direction)`) stays correct for control flow whose
-outcome is not a property of the case. This is the enum form of tell-don't-ask, and the companion
-of the modeling rule that enums carry methods only when those methods hold vocabulary meaning (see
-`php-library-modeling.md`, "Enums"): a case that drives a derived value is exactly that vocabulary.
-
-A consumer is outside this rule. A consumer matching on a sealed type the library exposes (for
-example, translating a parsed tree into its own store) cannot add methods to the library's types,
-so its `instanceof` is legitimate. The rule binds the library's own code.
-
-A type the library owns may `instanceof` its own internal types at construction or registration
-time, to invoke behavior that exists only on the concrete type and that cannot be lifted onto a
-public extension interface without breaking external implementers. The minimal public interface
-outweighs the local, build-time type check.
-
-Tell-don't-ask. Behavior that depends on a collaborator's state belongs to the collaborator. Do
-not read a collaborator's fields to recompute a result the collaborator should produce. Ask it for
-the result, not for its parts. A getter exposes a value the caller needs as data, it is not a
-license to reimplement the collaborator's logic at the call site. Tell-don't-ask binds the types
-the library owns. Reading a value off a type the library does not own (a dependency's value object,
-a PSR type) and computing with it is interop, not a violation: the library cannot add a method to a
-type it does not control. The rule still binds the library's own types.
-
-**Prohibited.** Dispatching on the concrete type of interface the library owns:
-
-```php
-return match (true) {
- $discount instanceof Percentage => $amount->multiplyBy(factor: $discount->rate()),
- $discount instanceof Fixed => $amount->subtract(other: $discount->amount())
-};
-```
-
-**Correct.** The behavior is a method on the interface, resolved by the object:
-
-```php
-return $discount->applyTo(amount: $amount);
-```
-
-**Prohibited.** Comparing an enum case to produce a value the case owns:
-
-```php
-$token = match ($direction) {
- Order::ASCENDING => '',
- Order::DESCENDING => '-'
-};
-```
-
-**Correct.** A vocabulary method on the enum returns the value, called at the site:
-
-```php
-enum Order: string
-{
- case ASCENDING = 'asc';
- case DESCENDING = 'desc';
-
- public function token(): string
- {
- return match ($this) {
- self::ASCENDING => '',
- self::DESCENDING => '-'
- };
- }
-}
-
-$token = $direction->token();
-```
-
-**Prohibited.** Reading a collaborator's parts to recompute what it already owns:
-
-```php
-$doubled = Money::of(amount: $price->amount() * 2, currency: $price->currency());
-```
-
-**Correct.** Telling the collaborator to produce the result:
-
-```php
-$doubled = $price->multiplyBy(factor: 2);
-```
-
-## Return statements
-
-A method has at most three `return` statements. The cap keeps methods small and their control
-flow scannable, and it complements rule 9: early returns are the preferred alternative to `else`,
-but they stop being a simplification once a method accumulates more than three exit points.
-Invariant violations are signaled with a `throw`, not a `return`, so guard clauses usually do not
-add to the count.
-
-**Prohibited.** Four return points:
-
-```php
-public function classify(int $score): Grade
-{
- if ($score >= 90) {
- return Grade::A;
- }
-
- if ($score >= 80) {
- return Grade::B;
- }
-
- if ($score >= 70) {
- return Grade::C;
- }
-
- return Grade::F;
-}
-```
-
-**Correct.** Single return through `match`:
-
-```php
-public function classify(int $score): Grade
-{
- return match (true) {
- $score >= 90 => Grade::A,
- $score >= 80 => Grade::B,
- $score >= 70 => Grade::C,
- default => Grade::F
- };
-}
-```
-
-## Formatting overrides
-
-Four formatting rules are not covered by the canonical `phpcs.xml` (which references `PSR-12`
-only). Apply them manually.
-
-### Single-line signatures within 120 characters
-
-A function or constructor signature stays on one line when the whole signature fits within the
-120-character limit. Do not break the parameter list onto multiple lines unless the single-line
-form would exceed 120 characters. The opening brace still goes on its own line (PSR-12). Break to
-one parameter per line only when the signature genuinely overflows.
-
-**Prohibited.** Multiline signature that fits on one line:
-
-```php
-private function __construct(
- public ExternalReference $id,
- public Money $amount,
- public OrderContext $context
-) {
-}
-```
-
-**Correct.** Single line within 120 characters:
-
-```php
-private function __construct(public ExternalReference $id, public Money $amount, public OrderContext $context)
-{
-}
-```
-
-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
-) {}
-```
-
-### Vertical alignment of `=>` in match arms and array literals
-
-Multi-line `match` expressions and multi-line array literals with `=>` align the `=>` column
-across all arms or entries by padding shorter left-hand sides with spaces. Single-line cases
-(one-arm match, single-line array) keep the standard PSR-12 single-space form.
-
-**Prohibited.** Unaligned `=>` in match:
-
-```php
-return match ($this) {
- self::MAX_AGE => sprintf($template, $this->value, $value),
- default => $this->value
-};
-```
-
-**Correct.** Aligned `=>` in match:
-
-```php
-return match ($this) {
- self::MAX_AGE => sprintf($template, $this->value, $value),
- default => $this->value
-};
-```
-
-**Prohibited.** Unaligned `=>` in array literal:
-
-```php
-return [
- 'name' => 'Gustavo',
- 'role' => 'developer',
- 'company' => 'Anthropic'
-];
-```
-
-**Correct.** Aligned `=>` in array literal:
-
-```php
-return [
- 'name' => 'Gustavo',
- 'role' => 'developer',
- 'company' => 'Anthropic'
-];
-```
-
-### No trailing comma in multi-line lists
-
-Never place a trailing comma after the last element of any multi-line list. Applies to parameter
-lists, argument lists, array literals, match arms, and every other comma-separated multi-line
-structure. PHP accepts trailing commas in these positions, but this ecosystem prohibits them for
-visual consistency.
-
-**Prohibited.** Trailing comma after the last argument:
-
-```php
-new Precision(
- value: 2,
- rounding: RoundingMode::HALF_UP,
-);
-```
-
-**Correct.** No trailing comma:
-
-```php
-new Precision(
- value: 2,
- rounding: RoundingMode::HALF_UP
-);
-```
diff --git a/.claude/rules/php-library-documentation.md b/.claude/rules/php-library-documentation.md
deleted file mode 100644
index a29de61..0000000
--- a/.claude/rules/php-library-documentation.md
+++ /dev/null
@@ -1,203 +0,0 @@
----
-description: Conventions for README and public-facing Markdown docs in PHP libraries.
-paths:
- - "README.md"
- - "docs/**/*.md"
----
-
-# Documentation
-
-Conventions for `README.md` and the public-facing Markdown a library ships. PHPDoc rules for
-`.php` files live in `php-library-code-style.md`. American English applies everywhere (see the
-American English section in `php-library-code-style.md`).
-
-The **canonical bodies** of the non-README repository files (`SECURITY.md`, the issue templates,
-the pull request template) are not duplicated here. They live as drop-in assets in the
-`tiny-blocks-create` skill, the single source of truth for those files. This rule governs how
-the README and any `docs/` Markdown are written. "Required repository files" below lists which
-files must exist and points to the skill for their content.
-
-`CONTRIBUTING.md` is centralized at
-`https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md`. Each library's README and
-pull request template link to that location. No local `CONTRIBUTING.md` is created per library.
-
-## Pre-output checklist
-
-Verify every item before producing any Markdown documentation. If any item fails, revise before
-outputting.
-
-1. README title is `# ` with spaces between words (`# Building Blocks`, not
- `# BuildingBlocks`).
-2. License badge is the only badge. No build, coverage, Packagist, or version badges.
-3. Header is followed by an anchor-linked table of contents.
-4. Table of contents uses `*` for top-level (H2) entries, `+` indented by 4 spaces for
- second-level (H3) entries, and `-` indented by 8 spaces for third-level (H4) entries. Every
- heading from the document appears in the TOC, except FAQ entries: the FAQ is represented by a
- single `* [FAQ](#faq)` line regardless of how many questions it contains.
-5. Sections appear in the canonical order: Overview, Installation, How to use, FAQ (optional),
- License, Contributing.
-6. FAQ exists only when there are genuine points of confusion or unusual design decisions. Skip
- it entirely when not needed.
-7. **Self-contained code examples** are blocks that include any of: a `use` statement, a
- `class`/`enum`/`interface`/`trait`/`function` declaration, or more than 3 lines of executable
- code. Self-contained blocks open with `?` with zero-padded numbering
- (`### 01.`, `### 02.`).
-12. FAQ bibliographic citations use the format
- `> Author, *Title* (Publisher, Year), Chapter X, "Section Name".`
-13. License and Contributing sections each follow the canonical one-line template.
-14. The repository contains the required non-README files listed in "Required repository files",
- each matching its canonical asset in the `tiny-blocks-create` skill.
-
-## README
-
-### Structure
-
-The README follows a fixed section order:
-
-1. **Overview**. One or more paragraphs explaining the problem the library solves and its design
- philosophy. Cross-references to related `tiny-blocks` libraries belong here.
-2. **Installation**. Composer command in a code block, with no surrounding prose unless strictly
- necessary.
-3. **How to use**. Runnable examples covering the primary use cases. Each subsection demonstrates
- one capability with a heading and a self-contained code block.
-4. **FAQ** (optional). Numbered questions that address real points of confusion or unusual design
- decisions.
-5. **License**. One-line link to the `LICENSE` file.
-6. **Contributing**. One-line link to the centralized `CONTRIBUTING.md` in
- `tiny-blocks/tiny-blocks`.
-
-### Header and license badge
-
-The first line is `# ` followed by a blank line and the license badge:
-
-```markdown
-# Outbox
-
-[](https://github.com/tiny-blocks//blob/main/LICENSE)
-```
-
-Replace `` with the library's repository name. The badge is the only badge in the
-document.
-
-### Table of contents
-
-The table of contents is anchor-linked. Top-level (H2) entries use `*`. Second-level (H3) entries
-use `+` indented by 4 spaces. Third-level (H4) entries use `-` indented by 8 spaces. Every heading
-from the document appears, with one exception: the FAQ is represented by a single
-`* [FAQ](#faq)` line. Its questions never appear as TOC sub-entries, regardless of how many exist.
-
-```markdown
-* [Overview](#overview)
-* [Installation](#installation)
-* [How to use](#how-to-use)
- + [Subtopic A](#subtopic-a)
- + [Subtopic B](#subtopic-b)
-* [FAQ](#faq)
-* [License](#license)
-* [Contributing](#contributing)
-```
-
-Use the third level whenever the document has H4 headings. The TOC mirrors the document structure
-exactly.
-
-### Code examples
-
-Code examples fall into two categories.
-
-**Self-contained examples** include at least one of these: a `use` statement, a
-`class`/`enum`/`interface`/`trait`/`function` declaration, or more than 3 lines of executable code.
-They open with `value;
-```
-
-The criteria are mechanical: a block meeting any self-contained condition gets the prologue. A
-block meeting every fragment condition may omit it. There is no middle ground.
-
-The `#` convention for inline comments applies only to code examples inside Markdown files. PHP
-files under `src/` and `tests/` have no inline comments at all, except `# TODO: ` (see
-rule 16 in `php-library-code-style.md`).
-
-### FAQ
-
-FAQ entries are numbered with zero-padded prefixes and end with a question mark:
-
-```markdown
-### 01. Why is DomainEvent close to a marker interface?
-
-A domain event is a fact about something that happened in the domain. The contract carries only
-`revision()` so the library can route schema migrations through upcasters.
-
-> Vaughn Vernon, *Implementing Domain-Driven Design* (Addison-Wesley, 2013), Chapter 8,
-> "Domain Events".
-```
-
-Bibliographic citations follow `> Author, *Title* (Publisher, Year), Chapter X, "Section Name".`
-The chapter and section fragments are optional when the title is precise enough. Multiple
-citations stack as separate blockquote lines.
-
-### License and Contributing
-
-```markdown
-## License
-
- is licensed under [MIT](LICENSE).
-```
-
-```markdown
-## Contributing
-
-Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to
-contribute to the project.
-```
-
-## Structured data
-
-Tables are preferred to prose for any structured information: constructor parameter lists,
-builder method catalogs, default value tables, complexity tables, and configuration matrices.
-Column layout is chosen per case. No fixed column set is mandated.
-
-## Required repository files
-
-In addition to the README, every library repository contains the files below. Their canonical
-bodies are the drop-in assets in the `tiny-blocks-create` skill. This rule only asserts they
-must exist and match those assets.
-
-- `SECURITY.md`: security policy (supported versions, private reporting via GitHub Security
- Advisories). `` is substituted.
-- `.github/ISSUE_TEMPLATE/bug_report.md`: bug report template (`labels: bug`).
-- `.github/ISSUE_TEMPLATE/feature_request.md`: feature request template (`labels: enhancement`).
-- `.github/PULL_REQUEST_TEMPLATE.md`: pull request template linking the centralized contributing
- guidelines, with the standard checklist (`composer review` passes, `composer tests` passes).
diff --git a/.claude/rules/php-library-github-workflows.md b/.claude/rules/php-library-github-workflows.md
deleted file mode 100644
index c30d364..0000000
--- a/.claude/rules/php-library-github-workflows.md
+++ /dev/null
@@ -1,104 +0,0 @@
----
-description: Structure, ordering, and pinning conventions for GitHub Actions workflows in PHP libraries.
-paths:
- - ".github/workflows/**/*.yml"
- - ".github/workflows/**/*.yaml"
----
-
-# Workflows
-
-Conventions for GitHub Actions workflows in PHP libraries. CD does not apply: libraries publish to
-Packagist via tags and never deploy.
-
-The **canonical `ci.yml` body** is not duplicated here. It lives as a drop-in asset in the
-`tiny-blocks-create` skill (`assets/github/workflows/ci.yml`), the single source of truth. This
-rule defines the conventions that asset satisfies and that any edit to a workflow must preserve.
-
-`ci.yml` is mandatory. Additional workflow files (security scanning, automated triage, scheduled
-tasks, dependency updates) may exist and follow the general rules below. Their trigger, job
-structure, and steps are chosen by their purpose. The Composer scripts invoked by `ci.yml`
-(`composer review`, `composer tests`) are defined in `php-library-tooling.md`.
-
-## Pre-output checklist
-
-Verify every item before producing or editing any workflow YAML. If any item fails, revise before
-outputting.
-
-### Rules for every workflow
-
-Apply to `ci.yml` and to every additional workflow in `.github/workflows/`.
-
-1. Keys at the workflow root follow the canonical order `name`, `on`, `concurrency`,
- `permissions`, `jobs`. Absent keys are omitted. The relative order of the rest is preserved.
-2. Properties inside a job follow the canonical order `name`, `needs`, `runs-on`,
- `timeout-minutes`, `outputs`, `env`, `steps`. Same omission rule.
-3. Inside any block (`env`, `outputs`, `with`, `permissions`), entries are ordered by key length
- ascending.
-4. The workflow `name`, every job `name`, and every step `name` are mandatory and use sentence
- case (`Resolve PHP version`, not `RESOLVE_PHP_VERSION`). Step names start with a verb. Job keys
- describe the job's purpose. Generic keys (`run`, `job`, `do`) are discouraged in favor of
- descriptive identifiers (`auto-assign`, `analyze`, `notify`).
-5. `concurrency` is set at the workflow root with `cancel-in-progress: true` and a `group`
- expression scoped by the workflow's trigger, prefixed by the workflow's short purpose name
- (`ci`, `codeql`, `auto-assign`):
- - `pull_request`: `-${{ github.event.pull_request.number }}`.
- - `issues`, or `issues` combined with `pull_request`:
- `-${{ github.event.issue.number || github.event.pull_request.number }}`.
- - `push`, `schedule`, or both: `-${{ github.ref }}`.
-6. `permissions` is declared at the workflow root with the minimum scope every job needs.
- Job-level `permissions` are allowed only when a specific job needs a narrower scope than the
- root, never broader.
-7. Every job sets `timeout-minutes`. Defaults: 5 for trivial steps (single API call, lightweight
- script), 15 for jobs with PHP setup or test runs, 30 for analysis-heavy jobs (CodeQL, security
- scanning). Adjust based on observed runtime when prior runs exist.
-8. Every action is pinned to a fixed, immutable ref: a version tag at any granularity (major, minor, or patch) or a
- commit SHA. Moving refs (branch names such as @main/@master, or @v with no version) are prohibited. Do not normalize
- an explicit minor or patch pin down to its major, preserve the granularity the maintainer chose.
-9. Inline shell logic longer than 3 lines is extracted to a script in `scripts/ci/`.
-10. All text (workflow name, job names, step names, comments) uses American English with correct
- spelling and punctuation. Sentences and descriptions end with a period.
-
-### Rules specific to ci.yml
-
-Apply only to `.github/workflows/ci.yml`. Additional workflows are not bound by them.
-
-1. File path is `.github/workflows/ci.yml`. The workflow `name` field is exactly `CI`. Per rule 5
- for every workflow, with purpose `ci` and a `pull_request` trigger, `concurrency.group` is
- `ci-${{ github.event.pull_request.number }}`.
-2. Trigger is `pull_request` only. No `push`, no branch filter, no `workflow_dispatch`.
-3. Jobs run in the fixed sequence `resolve-php-version`, `build`, `auto-review`, `tests`. Each
- downstream job lists its upstream jobs in `needs`.
-4. PHP version is never hardcoded. The `resolve-php-version` job reads `.require.php` from
- `composer.json` at runtime and exposes the minor version (for example, `8.5`) as the job
- output `php-version`. Downstream jobs reference
- `${{ needs.resolve-php-version.outputs.php-version }}` when setting up PHP.
-5. The `auto-review` job runs `composer review`. The `tests` job runs `composer tests`. No other
- command is invoked in either job.
-6. The `build` job uploads `vendor/` and `composer.lock` as a single artifact named
- `vendor-artifact`. The `auto-review` and `tests` jobs download that artifact instead of
- running `composer install` again.
-7. The `tests` job is the only job that may extend with extra setup the library needs (service
- containers, fixture preparation, environment variables used during testing). The other three
- jobs are identical across every library in the ecosystem.
-8. `timeout-minutes` is 5 for `resolve-php-version` and 15 for `build`, `auto-review`, and
- `tests`. `permissions` is `contents: read`.
-
-## ci.yml job sequence
-
-`ci.yml` gates every pull request with four jobs in this exact order. The first three are
-identical across every library. Only `tests` may extend.
-
-- **Resolve PHP version.** Reads `.require.php` from `composer.json` and exposes the minor version
- as the output `php-version`. A single step uses `jq` and a short regex to extract the value.
-- **Build.** Sets up PHP using the resolved version, validates `composer.json`, installs with
- `--no-progress --optimize-autoloader --prefer-dist --no-interaction`, and uploads `vendor/` and
- `composer.lock` as `vendor-artifact`.
-- **Auto review.** Needs `resolve-php-version` and `build`. Downloads `vendor-artifact`, sets up
- PHP, runs `composer review` (phpcs + phpstan).
-- **Tests.** Needs `resolve-php-version` and `auto-review`. Downloads `vendor-artifact`, sets up
- PHP, runs `composer tests` (phpunit + infection). Library-specific test setup lives in this job
- only.
-
-To extend the `tests` job (external services, env vars, fixtures), the additions go inside the
-`tests` job exclusively. The skill asset includes an extended example with a MySQL service
-container.
diff --git a/.claude/rules/php-library-modeling.md b/.claude/rules/php-library-modeling.md
deleted file mode 100644
index a587b54..0000000
--- a/.claude/rules/php-library-modeling.md
+++ /dev/null
@@ -1,314 +0,0 @@
----
-description: Semantic modeling rules for PHP libraries (nomenclature, value objects, exceptions, enums, extension points, complexity).
-paths:
- - "src/**/*.php"
----
-
-# Modeling
-
-Library modeling rules. How to model the concepts the library exposes. Folder structure and
-public API boundary live in `php-library-architecture.md`. Code style lives in
-`php-library-code-style.md`. Tooling lives in `php-library-tooling.md`.
-
-## Pre-output checklist
-
-Verify every item before producing any PHP code that defines a model, an exception, or an
-algorithm. If any item fails, revise before outputting.
-
-1. Each model has a single, clear responsibility. Apply DDD, SOLID, DRY, and KISS where they
- sharpen the design, not as dogma.
-2. Concept names. Every class, property, method, and exception name reflects the concept the
- library represents, not a technical role.
-3. No always-banned names. Never use `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity` as
- class suffix, prefix, or method name. Never use `Exception` as a class suffix. Exception:
- names that correspond to externally standardized identifiers (HTTP status text from RFC
- documents, PSR interface names being mirrored, etc.) are permitted. The standard reference
- is the meaning carrier.
-4. No anemic verbs as the primary operation name (`ensure`, `validate`, `check`, `verify`,
- `assert`, `mark`, `enforce`, `sanitize`, `normalize`, `compute`, `transform`, `parse`) unless
- the verb is the library's reason to exist.
-5. Architectural role names (`Manager`, `Handler`, `Processor`, `Service`, and their verb forms
- `process`, `handle`, `execute`) are allowed only when the class IS that role for consumers
- integrating with the library.
-6. Value objects are immutable. No setters. Operations return new instances.
-7. Value objects compare by value, never by reference. No identity field.
-8. Value objects validate invariants in the constructor and throw a dedicated exception on
- invalid input.
-9. Value objects with multiple creation paths use static factory methods (`from`, `of`, `zero`)
- with a private constructor.
-10. Every failure throws a dedicated exception class named after the invariant it guards. Never
- `throw new DomainException(...)`, `throw new InvalidArgumentException(...)`, or any other
- generic native exception directly.
-11. Dedicated exception classes extend the appropriate native PHP exception (`DomainException`,
- `InvalidArgumentException`, `OverflowException`, etc.).
-12. Exceptions are pure. No transport-specific fields (HTTP status in `code`, formatted message
- for end-user display). They signal invariant violations only, never control flow.
-13. Enums are PHP backed enums. They include methods only when those methods carry vocabulary
- meaning. A value or behavior a case owns lives on the enum as that method, called instead of a
- `match` on the case at the site. See "Polymorphism and tell-don't-ask" in
- `php-library-code-style.md`.
-14. Extension points use `class` instead of `final readonly class`. They expose a private
- constructor with static factory methods as the only creation path. Internal state is
- injected via the constructor.
-15. Algorithms run in O(N) or O(N log N) unless the problem inherently requires worse. O(N²)
- or worse needs explicit justification.
-16. Prefer lazy or streaming evaluation over materializing intermediate results. Memory usage
- is bounded and proportional to the output, not to the sum of intermediate stages.
-17. A configuration-like value object whose fields are mostly optional exposes a no-argument
- baseline factory (`default()`) plus fluent immutable `with*` copies, not a single factory
- whose signature lists every field. See "Value objects".
-
-## Modeling principles
-
-Apply the following principles where they sharpen the design. Treat them as guides, not as dogma.
-
-- Single responsibility. Each model represents one concept, has one reason to change, and
- exposes operations that belong to that concept.
-- DDD ubiquitous language. Names, types, and operations match the vocabulary the library's
- domain uses. Code and conversation share the same terms.
-- SOLID. Interfaces define narrow contracts. Composition is preferred to inheritance.
- Substitutability holds at every interface boundary.
-- DRY. No duplicated logic across two or more places. See "Duplication" in
- `php-library-code-style.md` for how to resolve it without inheritance or private helpers.
-- KISS. No abstraction without real duplication or isolation need.
-
-## Nomenclature
-
-- Every class, property, method, and exception name reflects the concept the library represents.
- A math library uses `Precision` and `RoundingMode`. A money library uses `Currency` and
- `Amount`. A collection library uses `Collectible` and `Order`.
-- Name classes after what they represent, not after what they do technically. Use `Money`,
- `Color`, `Pipeline`, not `MoneyCalculator`, `ColorHelper`, `PipelineProcessor`.
-- Name methods after the operation in the library's vocabulary. Use `add()`, `convertTo()`,
- `splitAt()`, not `compute()`, `process()`, `handle()`.
-
-### Always banned
-
-These names carry zero semantic content. Never use them anywhere as class suffix, prefix, or
-method name.
-
-- `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`.
-- `Exception` as a class suffix (e.g., `FooException`). Use the invariant name when extending a
- native exception (e.g., `PrecisionOutOfRange`, not `InvalidPrecisionException`).
-
-### Externally standardized names (exception to the banlist)
-
-Names that correspond to externally standardized identifiers are exempt from the banlist. The
-standard reference is the meaning carrier. Renaming weakens it. Examples:
-
-- HTTP status text from RFC documents (`unprocessableEntity` from RFC 4918, `noContent`).
-- PSR interface names being mirrored as test doubles (`ClientException` mirroring
- `Psr\Http\Client\ClientExceptionInterface`).
-- Unicode category names, locale identifiers, MIME type tokens, and similar registered names.
-
-This exception applies only when the external standard is the actual source of the name. It
-does not authorize using `Data` or `Entity` as generic suffixes when no external reference is
-involved.
-
-### Anemic verbs
-
-These verbs hide what is actually happening behind a generic action. Banned unless the verb IS
-the operation that constitutes the library's reason to exist (e.g., a JSON parser may have
-`parse()`, a hashing library may have `compute()`).
-
-- `ensure`, `validate`, `check`, `verify`, `assert`, `mark`, `enforce`, `sanitize`, `normalize`,
- `compute`, `transform`, `parse`.
-
-When in doubt, prefer the domain operation name. `Password::hash()` beats `Password::compute()`.
-`Email::parse()` is fine in a parser library but suspicious elsewhere. Use `Email::from()`
-instead.
-
-### Architectural roles
-
-These names describe a role the library offers as a building block. Acceptable when the class IS
-that role (e.g., `EventHandler` in an events library, `CacheManager` in a cache library,
-`Upcaster` in an event-sourcing library). Not acceptable on domain objects inside the library
-(value objects, enums, contract interfaces).
-
-- `Manager`, `Handler`, `Processor`, `Service`.
-- Verb forms: `process`, `handle`, `execute`.
-
-The test. If the consumer instantiates or extends this class to integrate with the library, the
-role name is legitimate. If the class models a concept the consumer manipulates (a money amount,
-a country code, a color), the role name is wrong.
-
-**Scope.** The architectural-role banlist and the anemic-verb banlist apply to the **public
-surface**: types at the `src/` root, types in public `/` folders, and public
-exception and contract names. Inside `src/Internal/` (implementation detail by definition, where
-the namespace is the boundary), a collaborator may carry a mechanical role or operation name that
-describes its job (`Decoder`, `Encoder`, `Parser`, `Resolver`), since consumers never see or
-manipulate it. The always-banned names (`Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`)
-remain banned everywhere, `Internal/` included.
-
-## Value objects
-
-- Are immutable. No setters. No mutation after construction. Operations return new instances.
-- Compare by value, not by reference.
-- Validate invariants in the constructor and throw a dedicated exception on invalid input.
-- Have no identity field.
-- Use static factory methods (`from`, `of`, `zero`) with a private constructor when multiple
- creation paths exist. The factory name communicates the semantic intent.
-
-**Prohibited.** Public constructor with multiple creation paths. Semantics are unclear at the
-call site:
-
-```php
-final readonly class Money
-{
- public function __construct(public int $amount, public Currency $currency) {}
-}
-
-new Money(amount: 1000, currency: Currency::BRL);
-new Money(amount: 0, currency: Currency::USD);
-```
-
-**Correct.** Private constructor with named factory methods. Each factory name communicates
-intent:
-
-```php
-final readonly class Money
-{
- private function __construct(public int $amount, public Currency $currency) {}
-
- public static function of(int $amount, Currency $currency): Money
- {
- return new Money(amount: $amount, currency: $currency);
- }
-
- public static function zero(Currency $currency): Money
- {
- return new Money(amount: 0, currency: $currency);
- }
-}
-
-Money::of(amount: 1000, currency: Currency::BRL);
-Money::zero(currency: Currency::USD);
-```
-
-When a value object is configuration-like and most of its fields are optional with defaults, prefer
-a baseline factory that takes no required arguments (`default()`, or `from()` with every parameter
-defaulted) together with fluent immutable `with*` copies, over a single factory whose signature
-carries every field. Each `with*` returns a new instance. Prefer the `with*` methods on the value
-object itself over a separate mutable builder class: the value object is already immutable, so it
-is its own builder. The smell is a factory signature that lists every field while most are
-optional.
-
-**Prohibited.** A single factory whose signature carries every field, most of them optional:
-
-```php
-MoneyFormat::from(scale: 4, symbol: '€', grouping: ',');
-```
-
-**Correct.** A baseline `default()` plus fluent `with*` copies that override only what differs:
-
-```php
-MoneyFormat::default()->withScale(scale: 4)->withGrouping(grouping: ',');
-```
-
-## Exceptions
-
-- Every failure throws a dedicated exception class named after the invariant it guards. Never
- `throw new DomainException(...)`, `throw new InvalidArgumentException(...)`,
- `throw new RuntimeException(...)`, or any other generic native exception directly. If the
- invariant is worth throwing for, it is worth a named class.
-- Dedicated exception classes extend the appropriate native PHP exception (`DomainException`,
- `InvalidArgumentException`, `OverflowException`, etc.). The native class is the parent, never
- the thing that is thrown. Consumers that catch the broad standard types continue to work.
- Consumers that need precise handling can catch the specific classes.
-- Exceptions are pure. No transport-specific fields (`code` populated with HTTP status,
- formatted `message` meant for end-user display). Formatting to any transport happens at the
- consumer's boundary, not inside the library.
-- Exceptions signal invariant violations only, not control flow.
-- Name the class after the invariant violated, never after the technical type. Use
- `PrecisionOutOfRange`, not `InvalidPrecisionException`. Use `CurrencyMismatch`, not
- `BadCurrencyException`. Use `ContainerWaitTimeout`, not `TimeoutException`.
-- A descriptive `message` argument is allowed and encouraged when it carries debugging context
- (the violating value, the boundary crossed, the state the library was in). The class name
- identifies the invariant. The message describes the specific violation for stack traces and
- test assertions. Keep messages short, factual, and in American English.
-
-**Prohibited.** Throwing a native exception directly:
-
-```php
-if ($value < 0) {
- throw new InvalidArgumentException('Precision cannot be negative.');
-}
-```
-
-**Correct.** Dedicated class, no message (class name is sufficient):
-
-```php
-final class PrecisionOutOfRange extends InvalidArgumentException
-{
-}
-
-if ($value < 0) {
- throw new PrecisionOutOfRange();
-}
-```
-
-**Correct.** Dedicated class with debugging context in the message:
-
-```php
-if ($value < 0 || $value > 16) {
- $template = 'Precision must be between 0 and 16, got %d.';
-
- throw new PrecisionOutOfRange(message: sprintf($template, $value));
-}
-```
-
-## Enums
-
-- Are PHP backed enums.
-- Include methods only when those methods carry vocabulary meaning. Examples are
- `OrderStatus::isFinal()` and `RoundingMode::apply()`.
-- A value or behavior a case owns (a token, a flag, a derived value) is one of those vocabulary
- methods, a predicate `isXxx()` or a method returning the value, called at the site instead of a
- `match` comparing the case. This is the enum form of tell-don't-ask. See "Polymorphism and
- tell-don't-ask" in `php-library-code-style.md`.
-
-## Extension points
-
-- A class designed to be extended by consumers (e.g., `Collection`, `ValueObject`) uses `class`
- instead of `final readonly class`. All other classes use `final readonly class`. See
- "Inheritance and constructors" in `php-library-code-style.md`.
-- Extension point classes use a private constructor with static factory methods (`createFrom`,
- `createFromEmpty`) as the only creation path.
-- Internal state is injected via the constructor and stored in a `private readonly` property.
-
-## Time and space complexity
-
-- Algorithms run in O(N) or O(N log N) unless the problem inherently requires worse. O(N²) or
- worse needs explicit justification at the point of definition.
-- Prefer lazy or streaming evaluation over materializing intermediate results. In pipeline-style
- libraries, fuse stages so a single pass suffices over the input.
-- Memory usage is bounded and proportional to the output, not to the sum of intermediate stages.
-- Never re-iterate the same source. When a sequence is consumed once, use lazy creation
- primitives (`createLazyFrom`) instead of materializing.
-
-**Prohibited.** Eager pipeline that materializes between stages:
-
-```php
-$paidTotals = array_map(
- static fn(Order $order): float => $order->total(),
- array_filter(
- $orders->toArray(),
- static fn(Order $order): bool => $order->isPaid()
- )
-);
-```
-
-Each stage allocates a full intermediate array. Memory grows with the input size, even when only
-the final scalar matters.
-
-**Correct.** Fused pipeline that runs in a single pass:
-
-```php
-$paidTotals = $orders
- ->filter(predicates: static fn(Order $order): bool => $order->isPaid())
- ->map(transformations: static fn(Order $order): float => $order->total())
- ->toArray(keyPreservation: KeyPreservation::DISCARD);
-```
-
-Operations stack on the same iterator. No intermediate array is built. Memory stays bounded by
-the final output.
diff --git a/.claude/rules/php-library-testing.md b/.claude/rules/php-library-testing.md
deleted file mode 100644
index 30a329f..0000000
--- a/.claude/rules/php-library-testing.md
+++ /dev/null
@@ -1,372 +0,0 @@
----
-description: BDD Given/When/Then structure, PHPUnit conventions, fixture rules, and coverage discipline.
-paths:
- - "tests/**/*.php"
----
-
-# Testing
-
-PHPUnit conventions for tests in PHP libraries. Covers BDD structure, fixture rules, and coverage
-discipline. Code style applies to test files as well. See `php-library-code-style.md`. Folder
-structure for `tests/` lives in `php-library-architecture.md`. Canonical thresholds (MSI 100,
-covered MSI 100) live in `php-library-tooling.md`.
-
-## Pre-output checklist
-
-Verify every item before producing any test code. If any item fails, revise before outputting.
-
-1. Each test contains exactly one `@When` block. Two actions require two tests.
-2. Use `@And` for complementary preconditions or actions within the same scenario, avoiding
- consecutive `@Given` or `@When` tags.
-3. Each `@Given` or `@And` block contains exactly one annotation line followed by one expression
- or assignment. Never place multiple variable declarations or object constructions under a
- single annotation. **Exception for data-provider tests.** When the test method binds its
- inputs through a `#[DataProvider]` attribute (or the equivalent `@dataProvider` annotation),
- the `@Given` block may declare the input shape in prose form, without an expression below
- it. The values are bound by PHPUnit before the test body runs, so the prose annotation
- replaces the assignment that would otherwise sit under the `@Given`.
-
- `@When` blocks follow the same one-expression rule by default: the block represents the
- single action under test. **Exception for repeated-invocation tests** (idempotence, caching,
- memoization). When the purpose of the test is asserting that the same operation produces the
- same outcome across N invocations, the `@When` block may contain N consecutive identical
- invocations, each captured in a numbered variable (`$first`, `$second`, ...), and the
- annotation reads `@When invoked twice` (or thrice, etc.) to make the composite-action
- semantic explicit. Two unrelated actions still require two tests.
-4. No intermediate variables used only once. Chain method calls when the intermediate state is
- not referenced elsewhere (e.g., `Money::of(...)->add(...)` instead of
- `$money = Money::of(...)` followed by `$money->add(...)`).
-5. No private or helper methods in test classes. The only non-test methods allowed are PHPUnit
- lifecycle hooks (`setUp`, `setUpBeforeClass`, `tearDown`, `tearDownAfterClass`) and data
- providers. Setup logic complex enough to extract belongs in a dedicated fixture class.
-6. Test only the public API. Never assert on private state or `Internal/` classes directly.
- One narrow, last-resort exception covers irreducible internal elements. See "White-box
- coverage of irreducible internals".
-7. Test the behavior that **raises** an exception, never the exception itself. Exception classes
- represent invariant violations and are value objects, not the subject of behavior tests. A
- test constructs the conditions, invokes the public method that is supposed to fail, and
- asserts the expected exception class is raised (plus its accessor values when they carry
- information relevant to the failure). Constructing an exception directly
- (`new HttpRequestInvalid(...)`) and asserting on its accessors is **prohibited**: the
- exception's structure is exercised through the call path that produces it. If a method does
- not exist whose call path produces the exception, the exception is dead code and should be
- removed.
-8. Never mock internal collaborators. Use real objects. Test doubles are used only at system
- boundaries (filesystem, clock, network) when the library interacts with external resources.
-9. Name tests after behavior using the `testXxxWhenYyyThenZzz` shape, never after the method
- under test. `Xxx` names the subject or operation, `Yyy` the condition, `Zzz` the expected
- outcome (for example, `testAddMoneyWhenSameCurrencyThenAmountsAreSummed`). The `When`/`Then`
- structure is mandatory. The `@Given`/`@When`/`@Then`/`@And` annotation blocks describe the
- steps within. A condition-free operation may collapse to `testXxxThenZzz` when there is no
- meaningful precondition to name.
-10. Use domain-specific names in variables and properties. Never `$spy`, `$mock`, `$stub`,
- `$fake`, `$dummy` as variable or property names. Use the domain concept the object
- represents (`$collection`, `$amount`, `$currency`, `$sortedElements`). Class names like
- `ClientMock` or `GatewaySpy` are acceptable. The variable holding the instance is what matters.
-11. Annotations use domain language. Write `/** @Given a collection of amounts */`, not
- `/** @Given a mocked collection in test state */`.
-12. Never use the `/** @test */` annotation. Test methods are discovered by the `test` prefix in
- the method name.
-13. Named arguments are never used on PHPUnit assertions and expectations. Arguments are passed
- positionally. The canonical rule and its full exclusion list live in
- `php-library-code-style.md` rule 4.
-14. Never include conditional logic inside tests. Each `@Then` block expresses one logical
- concept. The only allowed `try`/`catch` is when the assertion target is a property of the
- caught exception that cannot be expressed via `expectException*` methods (notably
- `getPrevious()` for chain inspection). The catch block contains only assertions against the
- caught exception, no branching.
-15. Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from
- coverage. Never suppress mutants via `infection.json.dist` or any other mechanism. See
- "Coverage and mutation discipline".
-16. Member ordering in test classes follows `php-library-code-style.md` rule 6 (PHPUnit
- test-class sub-grouping).
-
-## Generics in test PHPDoc
-
-The "zero PHPDoc anywhere inside `tests/`" rule (defined in `php-library-code-style.md`) has one
-narrow exception: PHPDoc that exists *purely to express generics* the native type system cannot.
-A test fixture that extends a generic public type carries the type argument with `@extends` (for
-example `@extends Collection` on an `Invoices` fixture), and a generics-only `@var` may
-pin a type parameter at an inference point where an imprecise result feeds a typed sink (for
-example `/** @var Collection $shipments */` before passing a mapped collection to
-`Shipments::createFrom(...)`). These tags carry only the type-parameter information, never a
-summary or prose description. Every other form of PHPDoc (summaries, `@param`/`@return`
-descriptions on test methods, fixtures, data providers, or anonymous classes) stays prohibited.
-This is the same carve-out stated in `php-library-code-style.md` under "When prohibited",
-restated here because it most often surfaces on collection fixtures and inference points in
-`tests/`.
-
-## Structure: Given/When/Then (BDD)
-
-Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments
-without exception.
-
-### Happy path example
-
-```php
-public function testAddMoneyWhenSameCurrencyThenAmountsAreSummed(): void
-{
- /** @Given two money instances in the same currency */
- $ten = Money::of(amount: 1000, currency: Currency::BRL);
-
- /** @And another money instance with the same currency */
- $five = Money::of(amount: 500, currency: Currency::BRL);
-
- /** @When adding them together */
- $total = $ten->add(other: $five);
-
- /** @Then the result contains the sum of both amounts */
- self::assertEquals(1500, $total->amount());
-}
-```
-
-### Exception example
-
-When testing that an exception is thrown, place `@Then` (`expectException`) before `@When`.
-PHPUnit requires this ordering.
-
-```php
-public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void
-{
- /** @Given two money instances in different currencies */
- $brl = Money::of(amount: 1000, currency: Currency::BRL);
-
- /** @And another money instance with a different currency */
- $usd = Money::of(amount: 500, currency: Currency::USD);
-
- /** @Then an exception indicating currency mismatch should be thrown */
- $this->expectException(CurrencyMismatch::class);
-
- /** @When trying to add money with different currencies */
- $brl->add(other: $usd);
-}
-```
-
-## Testing exceptions
-
-Exception classes are value objects describing an invariant violation. They are not the subject
-of behavior tests. A test verifies that a public method, under specific conditions, raises a
-specific exception. Constructing the exception directly and asserting on its accessors is
-prohibited. The exception's structure is exercised through the call path that produces it.
-
-**Prohibited.** Testing the exception as a value object:
-
-```php
-public function testFromWhenAllFieldsGivenThenExposesEveryAccessor(): void
-{
- /** @Given a URL */
- $url = 'https://api.example.com';
-
- /** @And an HTTP method */
- $method = Method::GET;
-
- /** @And a reason */
- $reason = 'Connection refused.';
-
- /** @When the exception is constructed */
- $exception = HttpNetworkFailed::from(url: $url, method: $method, reason: $reason);
-
- /** @Then it exposes the URL */
- self::assertSame($url, $exception->url());
-}
-```
-
-The test constructs the exception in isolation and asserts on its accessors. No production code
-is exercised. The same coverage is achieved (and made meaningful) by the test below, which
-drives the path that raises the exception.
-
-**Correct.** Testing the behavior that raises the exception:
-
-```php
-public function testSendRequestWhenTransportCannotReachServerThenThrowsHttpNetworkFailed(): void
-{
- /** @Given an HTTP client backed by a transport that always raises a network error */
- $http = Http::usingTransport(transport: new ThrowingClient());
-
- /** @And a target request to that transport */
- $request = Request::create(url: 'https://api.example.com', method: Method::GET);
-
- /** @Then a network failure exception describing the unreachable target is raised */
- $this->expectException(HttpNetworkFailed::class);
-
- /** @When the request is sent */
- $http->send(request: $request);
-}
-```
-
-When the accessor values on the raised exception are part of the assertion, `expectException`
-alone is not enough (it asserts only the class). Use a `try`/`catch` block as permitted by
-rule 14. The catch block contains only assertions against the caught exception, no branching.
-
-```php
-public function testSendRequestWhenTargetUnreachableThenExceptionCarriesUrlAndMethod(): void
-{
- /** @Given an HTTP client backed by a transport that always raises a network error */
- $http = Http::usingTransport(transport: new ThrowingClient());
-
- /** @And a target request to that transport */
- $request = Request::create(url: 'https://api.example.com', method: Method::GET);
-
- try {
- /** @When the request is sent */
- $http->send(request: $request);
- } catch (HttpNetworkFailed $failure) {
- /** @Then the exception exposes the target URL and method */
- self::assertSame('https://api.example.com', $failure->url());
- self::assertSame(Method::GET, $failure->method());
- }
-}
-```
-
-If a method does not exist whose call path produces the exception, the exception itself is dead
-code. Remove it instead of writing a behavior test against a constructor.
-
-**The `try`/`catch` form is reserved for assertions that PHPUnit's `expectException*` family
-does not cover.** Message, code, and class are covered by PHPUnit (`expectException`,
-`expectExceptionMessage`, `expectExceptionMessageMatches`, `expectExceptionCode`): use those
-methods, not `try`/`catch`. The only case that warrants `try`/`catch` is inspecting accessors
-that PHPUnit cannot reach, notably `getPrevious()` for chain inspection, or domain-specific
-accessors on a `HttpNetworkFailed` (`url()`, `method()`, `reason()`).
-
-**Prohibited.** `try`/`catch` to assert message:
-
-```php
-try {
- $http->send(request: $request);
- self::fail('NoMoreResponses was expected.');
-} catch (NoMoreResponses $exception) {
- self::assertStringContainsString('queue exhausted', $exception->getMessage());
-}
-```
-
-**Correct.** PHPUnit's `expectExceptionMessage`:
-
-```php
-$this->expectException(NoMoreResponses::class);
-$this->expectExceptionMessage('queue exhausted');
-
-$http->send(request: $request);
-```
-
-## Test setup and fixtures
-
-Checklist items 3, 4, 5, 10, and 11 govern setup blocks: one declaration per annotation, no
-single-use intermediate variables, no private or helper methods, domain-named variables, and
-domain-language annotations. The examples below illustrate the rules most often violated in
-practice. Double naming (the `$spy`/`$mock` banlist and the class-name suffix nuance) is detailed
-in "Test doubles" below.
-
-**Prohibited.** Multiple declarations under a single annotation:
-
-```php
-/** @And two money instances in different currencies */
-$usd = Money::of(amount: 500, currency: Currency::USD);
-$eur = Money::of(amount: 300, currency: Currency::EUR);
-```
-
-**Correct.** One annotation per declaration:
-
-```php
-/** @And a money instance in USD */
-$usd = Money::of(amount: 500, currency: Currency::USD);
-
-/** @And a money instance in EUR */
-$eur = Money::of(amount: 300, currency: Currency::EUR);
-```
-
-**Also prohibited.** Setup multi-statement grouped under a single annotation because "the
-statements build one coherent concept":
-
-```php
-/** @Given transport seeded with two responses */
-$first = Response::with(code: Code::OK);
-$second = Response::with(code: Code::CREATED);
-$transport = InMemoryTransport::with(responses: [$first, $second]);
-```
-
-Three statements, one annotation. The fact that the three lines together build a single
-setup concept is **not** a license to share one annotation. Each declaration takes its own
-`@And` block. The same applies under `@When` when the test prepares the input alongside the
-action: the input preparation goes back to `@And` under `@Given`, and `@When` contains only
-the action under test.
-
-**Correct.** Each statement keeps its own annotation:
-
-```php
-/** @Given a first queued response */
-$first = Response::with(code: Code::OK);
-
-/** @And a second queued response */
-$second = Response::with(code: Code::CREATED);
-
-/** @And transport with both responses */
-$transport = InMemoryTransport::with(responses: [$first, $second]);
-```
-
-## Test doubles
-
-Conventions for naming and locating test doubles (mocks, spies, stubs, fakes, dummies).
-
-### Naming
-
-- Variables and properties never carry the technical role in their name. Never `$spy`, `$mock`,
- `$stub`, `$fake`, `$dummy`. Use the domain concept the object represents (`$gateway`,
- `$clock`, `$repository`, `$client`).
-- Class names may carry the technical role as suffix when the class IS a test double
- (`ClientMock`, `GatewaySpy`, `ClockFake`). The suffix signals that the file is a collaborator
- built for tests, not a production type.
-
-### Location
-
-- Test doubles live at the root of `tests/Unit/`. When integration tests exist, doubles used
- there live at the root of `tests/Integration/`.
-- No dedicated `Mocks/` or `Doubles/` subdirectory exists.
-- Domain fixtures that represent real domain concepts live in `tests/Models/`. See
- `php-library-architecture.md` for the canonical `tests/` folder layout.
-
-## Coverage and mutation discipline
-
-- Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from coverage.
-- Never suppress mutants via `infection.json.dist` or any other mechanism.
-- If a line or mutation cannot be covered or killed, the design is wrong. Refactor the
- production code to make it testable. Never work around the tool.
-- The sole exception is an irreducible internal element (a non-functional memoization
- cache, or the private constructor of a static-only surface) that cannot be reached
- publicly without harming the design. It is covered or killed through a reflection-based
- white-box test, never through suppression. See "White-box coverage of irreducible
- internals".
-
-Canonical thresholds (MSI 100, covered MSI 100) live in `php-library-tooling.md`. They are
-enforced by `infection.json.dist`. Achieving MSI 100 implies effective full coverage of `src/`
-because every mutation must be killed by an assertion. This file covers only the behavioral
-rules that complement those thresholds.
-
-## White-box coverage of irreducible internals
-
-Rules 6 and 15 are near-absolute: tests exercise the public API, refactoring is the response
-when a line or mutation resists coverage, and code is never hidden from coverage or mutation.
-They yield in one narrow case: an *irreducible* internal element that cannot be reached
-through the public API without either removing a legitimate non-functional optimization or
-defeating a deliberate design. Two such elements recur:
-
-- **Memoization caches.** A purely non-functional cache (a resolved-mapping cache, a
- shared-instance cache, a reflection-descriptor cache) whose removal leaves behavior
- identical. The mutant that drops the cache is an equivalent mutant: no public observation
- distinguishes the cached path from the recomputed one, so no public-API test can kill it.
-- **Intentionally-uncallable members.** The private constructor of a static-only surface (a
- class that exists solely to expose static factories and must never be instantiated). It is
- never executed through any public path, so its line stays uncovered by construction.
-
-For these, and only these, a white-box test is permitted as a last resort: reflecting into
-`Internal/` private state to assert that memoization holds, or reflection-invoking an
-uncallable constructor so its line is covered. Such a test still follows the BDD structure
-and `testXxxWhenYyyThenZzz` naming, and the repeated-invocation `@When` exception (checklist
-item 3) already covers the memoization case.
-
-This exception covers code. It never hides it. `@codeCoverageIgnore`, coverage-excluding
-configuration, and mutant suppression remain prohibited without exception. The irreducible
-element is killed or covered honestly through reflection, not excluded from the metric. The
-burden is on demonstrating irreducibility: if the line or mutation can be reached through the
-public API, or if a proportionate refactor would expose it without harming the design, this
-exception does not apply and the public-API test is required. White-box access is never a
-convenience and never the first resort.
diff --git a/.claude/rules/php-library-tooling.md b/.claude/rules/php-library-tooling.md
deleted file mode 100644
index 8cf50d2..0000000
--- a/.claude/rules/php-library-tooling.md
+++ /dev/null
@@ -1,138 +0,0 @@
----
-description: Invariants for the canonical config files of PHP libraries in the tiny-blocks ecosystem.
-paths:
- - "composer.json"
- - "phpcs.xml"
- - "phpstan.neon"
- - "phpstan.neon.dist"
- - "phpunit.xml"
- - "infection.json"
- - "infection.json.dist"
- - ".editorconfig"
- - ".gitattributes"
- - ".gitignore"
- - "Makefile"
----
-
-# Tooling
-
-Invariants that every config file in a tiny-blocks library must satisfy. The **canonical file
-bodies** (full `composer.json`, `Makefile`, `phpunit.xml`, etc.) are not duplicated here. They
-live as drop-in assets in the `tiny-blocks-create` skill, which is the single source of truth
-for scaffolding a new library or restoring a file to its canonical shape. This rule defines the
-invariants those files are checked against when editing an existing library.
-
-Folder structure lives in `php-library-architecture.md`. Code style lives in
-`php-library-code-style.md`.
-
-## Pre-output checklist
-
-Verify every item before creating, editing, or relocating any config file. If any item fails,
-revise before outputting.
-
-1. The repository root contains all of: `composer.json`, `phpcs.xml`, `phpstan.neon.dist`,
- `phpunit.xml`, `infection.json.dist`, `.editorconfig`, `.gitattributes`, `.gitignore`,
- `Makefile`. (See "Config file naming" for which carry a `.dist` suffix and why.)
-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
- 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.
-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".
-7. `phpunit.xml` sets all five `failOn*` flags to `true` (`failOnDeprecation`, `failOnNotice`,
- `failOnPhpunitDeprecation`, `failOnRisky`, `failOnWarning`).
-8. `phpunit.xml` sets `executionOrder="random"` and `beStrictAboutOutputDuringTests="true"`.
- Non-namespace root attributes are sorted alphabetically. The `xmlns:xsi` and
- `xsi:noNamespaceSchemaLocation` declarations lead the attribute list and are not part of
- the alphabetical run.
-9. `infection.json.dist` sets `minMsi: 100` and `minCoveredMsi: 100`. Lowering either is
- prohibited.
-10. `.editorconfig` sets `max_line_length = 120`, `indent_size = 4`, `indent_style = space`,
- `end_of_line = lf` as the global default under `[*]`. YAML uses `indent_size = 2` and
- Makefile uses `indent_style = tab` as per-extension overrides.
-11. `.gitattributes` sets `* text=auto eol=lf` and lists every committed dev-only file under
- `export-ignore`. The Packagist tarball contains only `src/`, `composer.json`, `README.md`,
- `LICENSE`, and `SECURITY.md`. `.claude/` is listed under `export-ignore` (versioned on
- GitHub for contributor parity, excluded from the published package), and `CLAUDE.md` (where
- committed) is `export-ignore`d alongside it for the same reason. `.gitattributes` lists
- only files that are actually committed: it never names a file the repository does not
- contain (no `CONTRIBUTING.md`, which is centralized, and no phantom `.dist`/non-`.dist`
- twin of a file that is committed under only one of those names).
-12. `.gitignore` ignores the dependency and artifact paths, the local config overrides
- (`/phpstan.neon`, `/infection.json`), and nothing tool caches the project does not produce.
- The `.claude/` directory itself is **not** ignored (it is versioned on GitHub). Only
- `/.claude/settings.local.json`, the per-clone settings override, is ignored.
-13. `Makefile` wraps every PHP and Composer command in Docker using the canonical image
- `gustavofreze/php:8.5-alpine`. No PHP command runs on the host directly. Targets that share
- a name with a Composer script delegate to it. Additional non-Composer convenience targets
- (`help`, `clean`, `show-*`) are permitted.
-14. All test artifact paths use `reports/` (plural), consistent across `composer tests`,
- `infection.json.dist`, `phpunit.xml`, and `Makefile`. `reports/` is listed under
- `export-ignore` in `.gitattributes`.
-
-## Config file naming
-
-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 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
- gitignored `phpstan.neon` or `infection.json` to override it, and the tool auto-resolves the
- override over the `.dist` fallback. Those override names appear in `.gitignore`.
-
-Do not introduce a `.dist` twin for `phpcs.xml`/`phpunit.xml`, and do not commit a live
-`phpstan.neon`/`infection.json` in place of the `.dist` baseline.
-
-## phpstan ignoreErrors
-
-`phpstan.neon.dist` runs at `level: max` on `src` and `tests`. `ignoreErrors` is permitted to
-suppress legitimate false positives produced by `level: max` (third-party signatures carrying
-`mixed`, PHP-FIG interfaces returning untyped arrays, trait unused-method warnings on shared
-behavior, and the typed-array cases routed here by `php-library-code-style.md` instead of adding
-PHPDoc). Each entry follows these rules:
-
-- A short comment above the entry justifies its existence.
-- Prefer scoping via `identifier:` plus `path:` over raw `#...#` message patterns.
-- `reportUnmatchedIgnoredErrors: true` is mandatory. Obsolete entries fail the build, forcing
- cleanup.
-
-```neon
-ignoreErrors:
- # Trait method intentionally unused by the consuming aggregate. Reflection wires it.
- - identifier: trait.unused
- path: src/Internal/EventualAggregateRootBehavior.php
-```
-
-## Infection mutator config
-
-`infection.json.dist` is configured with `"mutators": {"@default": true}`. That is the only
-permitted form. No `ignore` lists, no `ignoreSourceCodeByRegex`, and no per-mutator overrides
-are allowed. Every mutant the default profile produces must be killed by a test. When a mutant
-escapes, the production code is refactored to make it testable rather than the configuration
-relaxed. This aligns with `php-library-testing.md` rule 15 (no mutant suppression by any
-mechanism) and with the MSI 100 thresholds in checklist item 9.
-
-## Composer scripts
-
-The five scripts and their purpose. Bodies live in the skill asset.
-
-- `composer configure` installs with `--optimize-autoloader` then normalizes. Run after cloning
- or pulling.
-- `composer configure-and-update` updates dependencies then normalizes. Run when intentionally
- bumping dependencies.
-- `composer review` runs `phpcs` then `phpstan`. Used by CI (`auto-review` job) and locally.
-- `composer tests` runs `phpunit` then `infection`. Used by CI (`tests` job).
-- `composer test-file ` runs a filtered subset without coverage. Local only.
diff --git a/.claude/settings.json b/.claude/settings.json
deleted file mode 100644
index f01749f..0000000
--- a/.claude/settings.json
+++ /dev/null
@@ -1,249 +0,0 @@
-{
- "$schema": "https://json.schemastore.org/claude-code-settings.json",
- "permissions": {
- "defaultMode": "default",
- "allow": [
- "Read",
- "Glob",
- "Grep",
-
- "Edit(./**)",
- "Write(./**)",
-
- "Bash(make:*)",
- "Bash(docker:*)",
-
- "Bash(rtk gain:*)",
- "Bash(rtk discover:*)",
- "Bash(rtk --version)",
-
- "Bash(rtk git status:*)",
- "Bash(rtk git diff:*)",
- "Bash(rtk git log:*)",
- "Bash(rtk git show:*)",
- "Bash(rtk ls:*)",
- "Bash(rtk cat:*)",
- "Bash(rtk grep:*)",
- "Bash(rtk rg:*)",
- "Bash(rtk head:*)",
- "Bash(rtk tail:*)",
-
- "Bash(rtk docker:*)",
-
- "Bash(rg:*)",
- "Bash(grep:*)",
- "Bash(jq:*)",
- "Bash(cat:*)",
- "Bash(ls:*)",
- "Bash(head:*)",
- "Bash(tail:*)",
- "Bash(wc:*)",
- "Bash(sort:*)",
- "Bash(uniq:*)",
- "Bash(diff:*)",
- "Bash(echo:*)",
- "Bash(mkdir:*)",
- "Bash(rmdir:*)",
- "Bash(rm:*)",
-
- "Bash(composer install:*)",
- "Bash(composer validate:*)",
- "Bash(composer outdated:*)",
- "Bash(composer show:*)",
-
- "Bash(git status:*)",
- "Bash(git diff:*)",
- "Bash(git log:*)",
- "Bash(git show:*)",
- "Bash(git blame:*)",
- "Bash(git ls-files:*)",
- "Bash(git grep:*)",
- "Bash(git merge-base:*)",
- "Bash(git rev-parse:*)",
- "Bash(git describe:*)",
- "Bash(git shortlog:*)",
- "Bash(git reflog show:*)",
- "Bash(git remote -v)",
- "Bash(git remote get-url:*)",
- "Bash(git stash list)",
- "Bash(git stash show:*)",
- "Bash(git worktree list)",
- "Bash(git config --get:*)",
- "Bash(git config --get-all:*)",
- "Bash(git config --list)",
- "Bash(git config --list:*)",
-
- "Bash(git branch)",
- "Bash(git branch -a)",
- "Bash(git branch -r)",
- "Bash(git branch -v)",
- "Bash(git branch -vv)",
- "Bash(git branch --show-current)",
- "Bash(git branch --list:*)",
- "Bash(git branch --contains:*)",
- "Bash(git branch --merged:*)",
- "Bash(git branch --no-merged:*)",
-
- "Bash(git tag)",
- "Bash(git tag -l)",
- "Bash(git tag -l:*)",
- "Bash(git tag -n)",
- "Bash(git tag -n:*)",
- "Bash(git tag --list)",
- "Bash(git tag --list:*)",
- "Bash(git tag --contains:*)",
- "Bash(git tag --points-at:*)",
-
- "Bash(git rm:*)"
- ],
- "ask": [
- "Edit(./.claude/**)",
- "Write(./.claude/**)",
-
- "Bash(git add:*)",
- "Bash(git commit:*)",
- "Bash(git mv:*)",
-
- "Bash(composer require:*)",
- "Bash(composer update:*)",
- "Bash(composer remove:*)",
- "Bash(composer normalize:*)",
-
- "Bash(curl:*)",
- "Bash(wget:*)"
- ],
- "deny": [
- "Read(./.env)",
- "Read(./.env.*)",
- "Read(./**/.env)",
- "Read(./**/.env.*)",
- "Read(./secrets/**)",
- "Read(./**/credentials*)",
- "Read(./**/*.pem)",
- "Read(./**/*.key)",
- "Read(./**/id_rsa)",
- "Read(./**/id_ed25519)",
- "Read(~/.ssh/**)",
-
- "Edit(./.env)",
- "Edit(./.env.*)",
- "Edit(./**/.env)",
- "Edit(./**/.env.*)",
- "Edit(./secrets/**)",
- "Edit(./.git/**)",
- "Edit(~/.bashrc)",
- "Edit(~/.zshrc)",
- "Edit(~/.profile)",
- "Edit(~/.ssh/**)",
-
- "Write(./.env)",
- "Write(./.env.*)",
- "Write(./**/.env)",
- "Write(./**/.env.*)",
- "Write(./secrets/**)",
- "Write(./.git/**)",
- "Write(~/.bashrc)",
- "Write(~/.zshrc)",
- "Write(~/.profile)",
- "Write(~/.ssh/**)",
-
- "Bash(php:*)",
-
- "Bash(rm --no-preserve-root:*)",
- "Bash(rm -rf /)",
- "Bash(rm * /)",
- "Bash(rm * ~)",
- "Bash(rm * ~/)",
- "Bash(rm * $HOME)",
- "Bash(rm * $HOME/)",
-
- "Bash(git push:*)",
- "Bash(git pull:*)",
- "Bash(git fetch:*)",
- "Bash(git checkout:*)",
- "Bash(git switch:*)",
- "Bash(git restore:*)",
- "Bash(git reset:*)",
- "Bash(git merge:*)",
- "Bash(git rebase:*)",
- "Bash(git revert:*)",
- "Bash(git cherry-pick:*)",
- "Bash(git apply:*)",
- "Bash(git am:*)",
- "Bash(git stash push:*)",
- "Bash(git stash pop:*)",
- "Bash(git stash apply:*)",
- "Bash(git stash drop:*)",
- "Bash(git stash clear)",
- "Bash(git stash save:*)",
- "Bash(git branch -d:*)",
- "Bash(git branch -D:*)",
- "Bash(git branch -m:*)",
- "Bash(git branch -M:*)",
- "Bash(git branch -c:*)",
- "Bash(git branch -C:*)",
- "Bash(git tag -a:*)",
- "Bash(git tag -s:*)",
- "Bash(git tag -d:*)",
- "Bash(git tag -f:*)",
- "Bash(git tag --delete:*)",
- "Bash(git tag --force:*)",
- "Bash(git remote add:*)",
- "Bash(git remote remove:*)",
- "Bash(git remote rm:*)",
- "Bash(git remote rename:*)",
- "Bash(git remote set-url:*)",
- "Bash(git submodule:*)",
- "Bash(git worktree add:*)",
- "Bash(git worktree remove:*)",
- "Bash(git worktree prune:*)",
- "Bash(git filter-branch:*)",
- "Bash(git filter-repo:*)",
- "Bash(git replace:*)",
- "Bash(git notes:*)",
- "Bash(git clean:*)",
- "Bash(git gc:*)",
- "Bash(git prune:*)",
- "Bash(git reflog delete:*)",
- "Bash(git reflog expire:*)",
- "Bash(git config --add:*)",
- "Bash(git config --unset:*)",
- "Bash(git config --unset-all:*)",
- "Bash(git config --replace-all:*)",
- "Bash(git config --global:*)",
-
- "Bash(eval:*)",
-
- "Bash(sudo:*)",
- "Bash(mysql:*)",
- "Bash(dropdb:*)",
- "Bash(dd:*)",
- "Bash(chmod:*)",
- "Bash(chown:*)",
-
- "Bash(curl * | sh)",
- "Bash(curl * | bash)",
- "Bash(curl * | sudo:*)",
- "Bash(wget * | sh)",
- "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"
- }
- ]
- }
- ]
- }
-}
diff --git a/.claude/skills/commit-message/SKILL.md b/.claude/skills/commit-message/SKILL.md
deleted file mode 100644
index de37fcd..0000000
--- a/.claude/skills/commit-message/SKILL.md
+++ /dev/null
@@ -1,119 +0,0 @@
----
-name: commit-message
-description: Generate a git commit message in the tiny-blocks Conventional Commits format (type-prefixed, imperative, capitalized, period-terminated, no scopes). Use this skill whenever the user asks you to write, draft, suggest, or fix a commit message, or whenever you are about to propose commit text for staged changes, even if they do not say the words "conventional commits". Commit messages are produced on request only and are never generated automatically as part of another task.
----
-
-# Commit message
-
-Produce a single commit message in the tiny-blocks format. This skill formats the message only.
-It never stages, commits, or runs any Git command. That happens only when the user explicitly
-asks for it.
-
-All commit messages are written in English.
-
-## Format
-
-```
-:
-```
-
-The description starts with a capital letter, uses imperative present tense (`Add`, `Fix`,
-`Change`, not `Added`, `Adds`, or `Adding`), and ends with a period. Keep the subject under 300
-characters. If it does not fit, split the change into multiple commits or move detail into the
-body.
-
-**Scopes are prohibited.** `feat(orders): ...` is wrong. The type stands alone.
-
-## Trailers
-
-Commit messages carry no trailers, regardless of any default to the contrary. Never append a
-`Co-Authored-By` line or any other trailer. The message is the type-prefixed subject and, when
-justified, a body. Nothing follows the body.
-
-## Allowed types
-
-- `ci` for CI configuration changes.
-- `fix` for a bug fix.
-- `feat` for a user-facing feature.
-- `docs` for documentation only.
-- `test` for adding or correcting tests.
-- `chore` for maintenance with no production code change.
-- `build` for build or dependency changes.
-- `revert` for reverting a previous commit.
-- `refactor` for a code change that neither fixes a bug nor adds a feature.
-
-`style` is not used. Formatting is enforced by the linter and never appears as a standalone
-commit.
-
-## Subject examples
-
-**Example 1:**
-Input: handled the case where a transaction has a zero amount
-Output: `fix: Handle zero-amount transactions.`
-
-**Example 2:**
-Input: added an endpoint to cancel an order
-Output: `feat: Add order cancellation endpoint.`
-
-**Example 3:**
-Input: pulled OrderStatus out into its own enum, no behavior change
-Output: `refactor: Extract OrderStatus into its own enum.`
-
-Reject these shapes:
-
-- `Added order cancellation`: past tense, missing type, missing period.
-- `feat: Adds order cancellation.`: third-person singular instead of imperative.
-- `feat: added order cancellation.`: starts lowercase and is past tense.
-- `feat: Add cancellation, and fix billing rounding.`: bundles two changes, so split them.
-- `feat(orders): Add cancellation.`: uses a scope, which is prohibited.
-
-## Body
-
-The body is **optional and rarely needed**. Single-purpose commits never have a body. Add a body
-only when the reason cannot be inferred from the diff: a non-obvious trade-off, a workaround for
-an external bug, a decision worth recording.
-
-Separate the body from the subject with a blank line. Wrap at 72 characters per line. Explain
-**why**, not what. The diff already shows what.
-
-### Prose vs. bullets in the body
-
-Default to prose. One or two paragraphs fits almost every commit that has a body at all.
-
-Use bullets only when **all** of these are true:
-
-1. The commit covers 3 or more independent changes that genuinely belong in the same commit.
-2. The list cannot be expressed as continuous prose without becoming disconnected sentences.
-3. Each item is independently meaningful (no sub-bullets, no continuation across bullets).
-
-A two-item bullet list is the wrong shape. Use prose.
-
-When bullets are used, every bullet starts with a capital letter and ends with a period, with an
-imperative present-tense verb, same as the subject line.
-
-### Body example with prose (preferred)
-
-```
-fix: Handle zero-amount transactions.
-
-The payment gateway rejects zero-amount charges with a generic 400 instead
-of a documented error code, so the adapter short-circuits before the HTTP
-call and raises ZeroAmountNotAllowed directly.
-```
-
-### Body example with bullets
-
-```
-feat: Add order cancellation flow.
-
-- Add the OrderCancelling inbound port and OrderCancellingHandler.
-- Add the CancelOrder command and its validator.
-- Cover the cancellation path in the integration test suite.
-```
-
-## Commit splitting
-
-Prefer one logical change per commit. Refactor commits never modify behavior. When a task needs
-multiple types of change, produce multiple commits in order: `refactor` first, then `feat` or
-`fix` on top. When the staged diff mixes types, say so and propose the split rather than forcing
-one message over an incoherent change set.
diff --git a/.claude/skills/tiny-blocks-consume/SKILL.md b/.claude/skills/tiny-blocks-consume/SKILL.md
deleted file mode 100644
index c318df8..0000000
--- a/.claude/skills/tiny-blocks-consume/SKILL.md
+++ /dev/null
@@ -1,68 +0,0 @@
----
-name: tiny-blocks-consume
-description:
- Discover and reuse an existing tiny-blocks library as a dependency instead of writing or keeping hand-written code. Use this skill in two moments: before implementing a capability from scratch or adding a dependency from outside the ecosystem, and when reviewing or refactoring existing code, to catch where a tiny-blocks package now covers something already written by hand. It checks the catalog of published tiny-blocks packages for a candidate, adds the match with composer, and reads the installed library's own README and public API to use it correctly. Trigger on any request to implement, add, build, review, simplify, or refactor where an existing building block (collections, value objects, money, time, http, mapping, logging, identifiers, and similar) might apply.
----
-
-# tiny-blocks consume
-
-Reuse the ecosystem before building anew. Inside any library, when a capability is needed, the
-first move is to check whether a tiny-blocks package already provides it, adopt that package, and
-use its documented API. This is the consuming counterpart of `tiny-blocks-create`.
-
-The source of truth for how to use a package is the package itself. After adding a dependency, its
-README and public PHPDoc under `vendor/tiny-blocks//` are authoritative. This skill does not
-copy any package API. It only points to the catalog for discovery and to the installed package for
-usage.
-
-## When to use
-
-Use this before writing new code for a capability that is plausibly generic: collections, value
-objects, money or currency, time, country codes, http primitives, object mapping, logging,
-identifiers, encoding, environment variables, and similar. Also use it before reaching for any
-dependency from outside the ecosystem.
-
-Use it also when reviewing or refactoring existing code. A package may have been published after
-that code was written, so check whether hand-rolled logic can now be replaced by a tiny-blocks
-package. The catalog is what surfaces newly published packages, so refresh it (see below) before
-concluding that nothing applies.
-
-Do not use it for logic that is specific to the library being built and has no general building
-block. In that case, write the code following the rules.
-
-## Consume steps
-
-1. Name the capability in one phrase, whether it is something you are about to write or something
- the existing code already does by hand (for example, "type-safe ordered collection" or "ISO
- currency with fraction digits").
-2. Check `references/catalog.md` for a tiny-blocks candidate. If nothing matches and the need is
- generic, refresh the catalog (see below) and look again, since a newer package may exist.
-3. If a candidate fits, add it with `composer require tiny-blocks/`. Packages from the
- ecosystem are exempt from the freshness cooldown, and `composer require` prompts once before
- adding.
-4. Learn the API from the installed package, not from memory. Read
- `vendor/tiny-blocks//README.md` and the public classes, interfaces, and enums under
- `vendor/tiny-blocks//src/`. Their PHPDoc and the README examples are the contract.
-5. Use the package following its documented API. Transitive dependencies are resolved by composer,
- so depend on and use only the package that solves the capability directly.
-6. If no candidate fits, only then write the code from scratch, or consider a dependency from
- outside the ecosystem, subject to the freshness cooldown and the `composer require` prompt.
-
-## Catalog
-
-`references/catalog.md` is the committed index of published tiny-blocks packages, with a one-line
-purpose for each. It exists for fast, offline discovery. It is generated from Packagist, not hand
-maintained. Each entry points to a package whose full API lives in its own README and PHPDoc once
-installed.
-
-## Refresh the catalog
-
-Run `python3 scripts/refresh-catalog.py` to rebuild `references/catalog.md` from the `tiny-blocks`
-vendor on Packagist. The script uses only the Python standard library, with no curl or jq, pulls
-the package list plus each description, skips abandoned packages, and rewrites the list. Refresh
-when a new package shipped, or when the catalog looks stale and a needed capability is not listed.
-
-## Validate
-
-After adding a dependency and wiring it in, run `make review` and `make tests`. Both must be green
-before the work is complete. A new dependency that breaks either gate is not done.
diff --git a/.claude/skills/tiny-blocks-consume/references/catalog.md b/.claude/skills/tiny-blocks-consume/references/catalog.md
deleted file mode 100644
index 778be52..0000000
--- a/.claude/skills/tiny-blocks-consume/references/catalog.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# tiny-blocks catalog
-
-Index of published tiny-blocks packages and their one-line purpose. Generated from Packagist by
-scripts/refresh-catalog.py, not hand-maintained. For the full API of a package, read its README
-and public PHPDoc under vendor/tiny-blocks//.
-
-- `tiny-blocks/building-blocks`: Implements tactical DDD building blocks for PHP: entities, aggregate roots, domain
- events, snapshots, and upcasters.
-- `tiny-blocks/collection`: Models a type-safe, fluent collection API for PHP with eager and lazy pipelines over arrays,
- iterators, and generators.
-- `tiny-blocks/country`: Provides an ISO 3166-1 country value object for PHP, with Alpha-2, Alpha-3, numeric, and IANA
- timezone resolution.
-- `tiny-blocks/currency`: Models ISO-4217 currencies as a PHP enum, with per-currency fraction digit resolution.
-- `tiny-blocks/docker-container`: Manages Docker containers programmatically for PHP, aimed at integration tests and
- disposable infrastructure.
-- `tiny-blocks/encoder`: Encoder and decoder for arbitrary data.
-- `tiny-blocks/environment-variable`: Provides a type-safe environment variable reader for PHP, with strict integer and
- boolean conversion.
-- `tiny-blocks/http`: Implements PSR-7, PSR-15, PSR-17 and PSR-18 HTTP primitives for PHP, with a fluent response
- builder, cookies, cache control, and a PSR-18 client facade.
-- `tiny-blocks/immutable-object`: Provides immutable behavior for objects.
-- `tiny-blocks/ksuid`: K-Sortable Unique Identifier.
-- `tiny-blocks/logger`: Emits PSR-3 structured logs for PHP, with correlation tracking and configurable sensitive data
- redaction.
-- `tiny-blocks/mapper`: Maps PHP objects to and from arrays, JSON, and iterables through reflection and pluggable
- strategies.
-- `tiny-blocks/math`: Value Objects for handling arbitrary precision numbers.
-- `tiny-blocks/outbox`: Write-side adapter for the Transactional Outbox pattern that persists domain events atomically
- with aggregate state through Doctrine DBAL.
-- `tiny-blocks/time`: Models time as immutable value objects for PHP: instants, durations, periods, timezones, and
- time-of-day, all UTC-normalized.
-- `tiny-blocks/value-object`: Defines the default behavior contract for PHP value objects with structural equality.
diff --git a/.claude/skills/tiny-blocks-consume/scripts/refresh-catalog.py b/.claude/skills/tiny-blocks-consume/scripts/refresh-catalog.py
deleted file mode 100644
index b6fa359..0000000
--- a/.claude/skills/tiny-blocks-consume/scripts/refresh-catalog.py
+++ /dev/null
@@ -1,102 +0,0 @@
-#!/usr/bin/env python3
-"""Rebuild references/catalog.md from the tiny-blocks vendor on Packagist.
-
-Usage:
- python3 scripts/refresh-catalog.py
-
-Depends only on the Python standard library. No curl, no jq, no shell.
-"""
-
-from __future__ import annotations
-
-import json
-import sys
-import textwrap
-import urllib.error
-import urllib.request
-from pathlib import Path
-from typing import List, Optional
-
-VENDOR = "tiny-blocks"
-LIST_URL = f"https://packagist.org/packages/list.json?vendor={VENDOR}"
-CATALOG_PATH = Path(__file__).resolve().parent.parent / "references" / "catalog.md"
-LINE_WIDTH = 120
-REQUEST_TIMEOUT_SECONDS = 30
-
-CATALOG_HEADER = """\
-# tiny-blocks catalog
-
-Index of published tiny-blocks packages and their one-line purpose. Generated from Packagist by
-scripts/refresh-catalog.py, not hand-maintained. For the full API of a package, read its README
-and public PHPDoc under vendor/tiny-blocks//.
-
-"""
-
-
-def report(message: str) -> None:
- print(message, file=sys.stderr)
-
-
-def fetch_json(url: str) -> dict:
- request = urllib.request.Request(url=url, headers={"User-Agent": "tiny-blocks-catalog"})
-
- with urllib.request.urlopen(url=request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
- payload = json.load(fp=response)
- return payload
-
-
-def sanitize(description: str) -> str:
- collapsed = " ".join(description.split())
-
- for character in (";", "—", "–"):
- collapsed = collapsed.replace(character, ",")
- return collapsed
-
-
-def catalog_line(name: str) -> Optional[str]:
- try:
- metadata = fetch_json(url=f"https://packagist.org/packages/{name}.json").get("package", {})
- except (urllib.error.URLError, json.JSONDecodeError):
- report(message=f"Skipping {name}, metadata fetch failed.")
- return None
-
- if metadata.get("abandoned"):
- return None
-
- description = sanitize(description=metadata.get("description") or "")
-
- return textwrap.fill(
- text=f"- `{name}`: {description}",
- width=LINE_WIDTH,
- subsequent_indent=" ",
- break_long_words=False,
- break_on_hyphens=False,
- )
-
-
-def build_catalog() -> str:
- names = sorted(fetch_json(url=LIST_URL).get("packageNames", []))
- lines: List[str] = []
-
- for name in names:
- line = catalog_line(name=name)
-
- if line is not None:
- lines.append(line)
- return CATALOG_HEADER + "\n".join(lines) + "\n"
-
-
-def main() -> int:
- try:
- catalog = build_catalog()
- except (urllib.error.URLError, json.JSONDecodeError) as error:
- report(message=f"Failed to build the catalog: {error}")
- return 1
-
- CATALOG_PATH.write_text(data=catalog, encoding="utf-8")
- print(f"Wrote {CATALOG_PATH}")
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/.claude/skills/tiny-blocks-create/SKILL.md b/.claude/skills/tiny-blocks-create/SKILL.md
deleted file mode 100644
index efcc62b..0000000
--- a/.claude/skills/tiny-blocks-create/SKILL.md
+++ /dev/null
@@ -1,158 +0,0 @@
----
-name: tiny-blocks-create
-description: Scaffold a new PHP library for the tiny-blocks ecosystem, or restore a single canonical config/repository file (composer.json, phpcs.xml, phpunit.xml, phpstan.neon.dist, infection.json.dist, .editorconfig, .gitattributes, .gitignore, Makefile, the CI workflow, SECURITY.md, the issue templates, the PR template) to its standard shape. Use this skill whenever the user asks to create, bootstrap, set up, or initialize a new tiny-blocks library, to add the standard config/tooling files to a repository, or to fix/regenerate any of those files to match the ecosystem standard, even if they only mention one file by name. This skill owns the canonical bodies of those files. Do not hand-write them from memory.
----
-
-# tiny-blocks library scaffolding
-
-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).
-
-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
-govern the code you then write into it.
-
-## When to use which mode
-
-- **Full scaffold**: the user is starting a new library. Create the directory skeleton and copy
- every asset, substituting placeholders.
-- **Single-file restore**: the user wants one file brought back to standard (for example, "fix
- my Makefile" or "regenerate the CI workflow"). Copy only that asset. Do not touch the rest.
-
-## Asset map
-
-Copy each asset to the path on the right, relative to the repository root.
-
-| Asset (`assets/…`) | Destination | Placeholders |
-|--------------------------------------------|---------------------------------------------|--------------|
-| `config/composer.json` | `composer.json` | yes |
-| `config/phpcs.xml` | `phpcs.xml` | no |
-| `config/phpstan.neon.dist` | `phpstan.neon.dist` | no |
-| `config/phpunit.xml` | `phpunit.xml` | no |
-| `config/infection.json.dist` | `infection.json.dist` | no |
-| `config/.editorconfig` | `.editorconfig` | no |
-| `config/.gitattributes` | `.gitattributes` | no |
-| `config/.gitignore` | `.gitignore` | no |
-| `config/Makefile` | `Makefile` | no |
-| `github/workflows/ci.yml` | `.github/workflows/ci.yml` | no |
-| `github/ISSUE_TEMPLATE/bug_report.md` | `.github/ISSUE_TEMPLATE/bug_report.md` | no |
-| `github/ISSUE_TEMPLATE/feature_request.md` | `.github/ISSUE_TEMPLATE/feature_request.md` | no |
-| `github/PULL_REQUEST_TEMPLATE.md` | `.github/PULL_REQUEST_TEMPLATE.md` | no |
-| `docs/SECURITY.md` | `SECURITY.md` | yes |
-
-## Placeholders
-
-Two assets carry placeholders. Substitute every occurrence.
-
-| Placeholder | Meaning | Example |
-|---------------------------------------------------------|----------------------------------------------|---------------------------|
-| `` | Repository name, kebab-case | `event-sourcing` |
-| `` | PSR-4 namespace segment, PascalCase | `EventSourcing` |
-| `` | `composer.json` `description` (one sentence) | n/a |
-| ``, `` | `composer.json` `keywords` topic tokens | `psr-7`, `event-sourcing` |
-
-`` appears in `composer.json` (`name`, `homepage`, `support`) and in `SECURITY.md`
-(advisory URL). `` appears only in `composer.json` (`autoload` / `autoload-dev` PSR-4
-prefixes). The first `keywords` entry is always `tiny-blocks`. The topic tokens follow.
-
-## Full scaffold steps
-
-1. Confirm ``, ``, the one-sentence description, and the keyword topics with
- the user if not already known.
-2. Create the directory skeleton:
- ```
- src/
- tests/
- .github/workflows/
- .github/ISSUE_TEMPLATE/
- ```
-3. Copy every asset to its destination (table above), substituting placeholders.
-4. Author the files this skill does **not** carry, following the rules:
- - `README.md`: follow `php-library-documentation.md` (title, license badge, TOC, the fixed
- section order, code-example rules).
- - `LICENSE`: MIT, attributed to the author in `composer.json`.
- - Initial `src/` and `tests/`: follow `php-library-architecture.md`,
- `php-library-code-style.md`, `php-library-modeling.md`, and `php-library-testing.md`.
-5. Validate (see below) before reporting the scaffold complete.
-
-## The .dist naming split
-
-The assets deliberately commit `phpcs.xml` and `phpunit.xml` as live files, but
-`phpstan.neon.dist` and `infection.json.dist` with the `.dist` suffix. This is intentional and
-documented in `php-library-tooling.md`: the ruleset and the test config are stable and committed
-live. PHPStan and Infection are the two tools a contributor may tune locally, so a gitignored
-`phpstan.neon` / `infection.json` overrides the committed `.dist` baseline. Do not add a `.dist`
-twin for `phpcs.xml`/`phpunit.xml`, and do not commit a live `phpstan.neon`/`infection.json`.
-
-## Extending the CI tests job
-
-`ci.yml` is the minimal canonical workflow. Only the `tests` job may be extended, and only when
-the library's tests need external services, environment variables, or fixture preparation. Add
-them inside the `tests` job. Leave `resolve-php-version`, `build`, and `auto-review` untouched.
-Example with a MySQL service container:
-
-```yaml
-tests:
- name: Tests
- needs: [resolve-php-version, auto-review]
- runs-on: ubuntu-latest
- timeout-minutes: 15
- env:
- DB_HOST: 127.0.0.1
- DB_NAME: library_test
- DB_PORT: '3306'
- DB_USER: library
- DB_PASSWORD: library
- services:
- mysql:
- image: mysql:8
- ports:
- - 3306:3306
- env:
- MYSQL_DATABASE: library_test
- MYSQL_ROOT_PASSWORD: library
- options: >-
- --health-cmd="mysqladmin ping"
- --health-interval=10s
- --health-timeout=5s
- --health-retries=5
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- tools: composer:2
- php-version: ${{ needs.resolve-php-version.outputs.php-version }}
-
- - name: Download vendor artifact from build
- uses: actions/download-artifact@v8
- with:
- name: vendor-artifact
- path: .
-
- - name: Run tests
- run: composer tests
-```
-
-## Pinned action versions
-
-The action versions pinned in `ci.yml` (`actions/checkout@v6`, `shivammathur/setup-php@v2`,
-`actions/upload-artifact@v7`, `actions/download-artifact@v8`) may be outdated. Before adopting the
-workflow, verify the current major version of each action and update the pin while preserving the
-`@vN` prefix style, as required by `php-library-github-workflows.md` rule 8.
-
-## Validate
-
-After scaffolding (or restoring `composer.json`/the test config), run the toolchain through the
-Makefile and confirm both pass before reporting done:
-
-- `make review`: phpcs (PSR-12) and phpstan (`level: max`) must pass clean.
-- `make tests`: phpunit and infection must pass with MSI 100 / covered MSI 100.
-
-If `make` targets are missing, `make help` lists them. Do not claim the scaffold is complete on
-the strength of file creation alone. The definition of done is a clean `review` and `tests`.
diff --git a/.claude/skills/tiny-blocks-create/assets/config/.editorconfig b/.claude/skills/tiny-blocks-create/assets/config/.editorconfig
deleted file mode 100644
index be5640e..0000000
--- a/.claude/skills/tiny-blocks-create/assets/config/.editorconfig
+++ /dev/null
@@ -1,19 +0,0 @@
-root = true
-
-[*]
-charset = utf-8
-end_of_line = lf
-indent_size = 4
-indent_style = space
-max_line_length = 120
-insert_final_newline = true
-trim_trailing_whitespace = true
-
-[*.{yml,yaml}]
-indent_size = 2
-
-[Makefile]
-indent_style = tab
-
-[*.md]
-trim_trailing_whitespace = false
diff --git a/.claude/skills/tiny-blocks-create/assets/config/.gitattributes b/.claude/skills/tiny-blocks-create/assets/config/.gitattributes
deleted file mode 100644
index 51e1b06..0000000
--- a/.claude/skills/tiny-blocks-create/assets/config/.gitattributes
+++ /dev/null
@@ -1,22 +0,0 @@
-* text=auto eol=lf
-
-*.php text diff=php
-
-# Keep the 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
-/.claude export-ignore
-/CLAUDE.md export-ignore
-/.editorconfig export-ignore
-/.gitattributes export-ignore
-/.gitignore export-ignore
-/phpcs.xml export-ignore
-/phpunit.xml export-ignore
-/phpstan.neon.dist export-ignore
-/infection.json.dist export-ignore
-/Makefile export-ignore
-/reports export-ignore
-/.phpunit.cache export-ignore
diff --git a/.claude/skills/tiny-blocks-create/assets/config/.gitignore b/.claude/skills/tiny-blocks-create/assets/config/.gitignore
deleted file mode 100644
index 29546dd..0000000
--- a/.claude/skills/tiny-blocks-create/assets/config/.gitignore
+++ /dev/null
@@ -1,30 +0,0 @@
-# PHP dependencies
-/vendor/
-composer.lock
-
-# Local config overrides (committed baselines are the .dist files)
-/phpstan.neon
-/infection.json
-
-# Tooling cache
-.phpunit.cache/
-.phpunit.result.cache
-__pycache__/
-*.pyc
-
-# Coverage and reports
-build/
-reports/
-coverage/
-infection.log
-
-# Editors and agents
-.idea/
-.cursor/
-.vscode/
-/.claude/settings.local.json
-
-# OS
-Thumbs.db
-.DS_Store
-Desktop.ini
diff --git a/.claude/skills/tiny-blocks-create/assets/config/Makefile b/.claude/skills/tiny-blocks-create/assets/config/Makefile
deleted file mode 100644
index 90ab50d..0000000
--- a/.claude/skills/tiny-blocks-create/assets/config/Makefile
+++ /dev/null
@@ -1,74 +0,0 @@
-PWD := $(CURDIR)
-ARCH := $(shell uname -m)
-PLATFORM :=
-
-ifeq ($(ARCH),arm64)
- PLATFORM := --platform=linux/amd64
-endif
-
-TTY := $(shell [ -t 0 ] && echo -it)
-
-DOCKER_RUN = docker run ${PLATFORM} --rm ${TTY} --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine
-
-RESET := \033[0m
-GREEN := \033[0;32m
-YELLOW := \033[0;33m
-
-.DEFAULT_GOAL := help
-
-.PHONY: configure
-configure: ## Configure development environment
- @${DOCKER_RUN} composer configure
-
-.PHONY: configure-and-update
-configure-and-update: ## Configure development environment and update dependencies
- @${DOCKER_RUN} composer configure-and-update
-
-.PHONY: tests
-tests: ## Run unit and mutation tests with coverage
- @${DOCKER_RUN} composer tests
-
-.PHONY: test-file
-test-file: ## Run tests for a specific file (usage: make test-file FILE=ClassNameTest)
- @${DOCKER_RUN} composer test-file ${FILE}
-
-.PHONY: review
-review: ## Run lint and static analysis
- @${DOCKER_RUN} composer review
-
-.PHONY: show-reports
-show-reports: ## Open coverage and mutation reports in the browser
- @sensible-browser reports/coverage/coverage-html/index.html reports/coverage/mutation-report.html
-
-.PHONY: show-outdated
-show-outdated: ## Show outdated direct dependencies
- @${DOCKER_RUN} composer outdated --direct
-
-.PHONY: clean
-clean: ## Remove dependencies and generated artifacts
- @sudo chown -R ${USER}:${USER} ${PWD}
- @rm -rf reports vendor .phpunit.cache *.lock
-
-.PHONY: help
-help: ## Display this help message
- @echo "Usage: make [target]"
- @echo ""
- @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')"
- @grep -E '^(configure|configure-and-update):.*?## .*$$' $(MAKEFILE_LIST) \
- | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
- @echo ""
- @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')"
- @grep -E '^(tests|test-file):.*?## .*$$' $(MAKEFILE_LIST) \
- | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
- @echo ""
- @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')"
- @grep -E '^(review):.*?## .*$$' $(MAKEFILE_LIST) \
- | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
- @echo ""
- @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')"
- @grep -E '^(show-reports|show-outdated):.*?## .*$$' $(MAKEFILE_LIST) \
- | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
- @echo ""
- @echo "$$(printf '$(GREEN)')Cleanup$$(printf '$(RESET)')"
- @grep -E '^(clean):.*?## .*$$' $(MAKEFILE_LIST) \
- | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
diff --git a/.claude/skills/tiny-blocks-create/assets/config/composer.json b/.claude/skills/tiny-blocks-create/assets/config/composer.json
deleted file mode 100644
index e10a520..0000000
--- a/.claude/skills/tiny-blocks-create/assets/config/composer.json
+++ /dev/null
@@ -1,70 +0,0 @@
-{
- "name": "tiny-blocks/",
- "description": "",
- "license": "MIT",
- "type": "library",
- "keywords": [
- "tiny-blocks",
- "",
- ""
- ],
- "authors": [
- {
- "name": "Gustavo Freze de Araujo Santos",
- "homepage": "https://github.com/gustavofreze"
- }
- ],
- "homepage": "https://github.com/tiny-blocks/",
- "support": {
- "issues": "https://github.com/tiny-blocks//issues",
- "source": "https://github.com/tiny-blocks/"
- },
- "require": {
- "php": "^8.5"
- },
- "require-dev": {
- "ergebnis/composer-normalize": "^2.52",
- "infection/infection": "^0.33",
- "phpstan/phpstan": "^2.2",
- "phpunit/phpunit": "^13.1",
- "squizlabs/php_codesniffer": "^4.0"
- },
- "minimum-stability": "stable",
- "prefer-stable": true,
- "autoload": {
- "psr-4": {
- "TinyBlocks\\\\": "src/"
- }
- },
- "autoload-dev": {
- "psr-4": {
- "Test\\TinyBlocks\\\\": "tests/"
- }
- },
- "config": {
- "allow-plugins": {
- "ergebnis/composer-normalize": true,
- "infection/extension-installer": true
- },
- "sort-packages": true
- },
- "scripts": {
- "configure": [
- "@composer install --optimize-autoloader",
- "@composer normalize"
- ],
- "configure-and-update": [
- "@composer update --optimize-autoloader",
- "@composer normalize"
- ],
- "review": [
- "@php ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php ./src ./tests",
- "@php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress"
- ],
- "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter",
- "tests": [
- "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests",
- "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage"
- ]
- }
-}
diff --git a/.claude/skills/tiny-blocks-create/assets/config/infection.json.dist b/.claude/skills/tiny-blocks-create/assets/config/infection.json.dist
deleted file mode 100644
index aab8c7e..0000000
--- a/.claude/skills/tiny-blocks-create/assets/config/infection.json.dist
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "logs": {
- "text": "reports/infection/logs/infection-text.log",
- "summary": "reports/infection/logs/infection-summary.log"
- },
- "tmpDir": "reports/infection/",
- "minMsi": 100,
- "timeout": 30,
- "source": {
- "directories": [
- "src"
- ]
- },
- "phpUnit": {
- "configDir": "",
- "customPath": "./vendor/bin/phpunit"
- },
- "mutators": {
- "@default": true
- },
- "minCoveredMsi": 100,
- "testFramework": "phpunit"
-}
diff --git a/.claude/skills/tiny-blocks-create/assets/config/phpcs.xml b/.claude/skills/tiny-blocks-create/assets/config/phpcs.xml
deleted file mode 100644
index a52372c..0000000
--- a/.claude/skills/tiny-blocks-create/assets/config/phpcs.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
- Code style for the tiny-blocks library.
-
- src
- tests
-
diff --git a/.claude/skills/tiny-blocks-create/assets/config/phpstan.neon.dist b/.claude/skills/tiny-blocks-create/assets/config/phpstan.neon.dist
deleted file mode 100644
index 0df69df..0000000
--- a/.claude/skills/tiny-blocks-create/assets/config/phpstan.neon.dist
+++ /dev/null
@@ -1,6 +0,0 @@
-parameters:
- level: max
- paths:
- - src
- - tests
- reportUnmatchedIgnoredErrors: true
diff --git a/.claude/skills/tiny-blocks-create/assets/config/phpunit.xml b/.claude/skills/tiny-blocks-create/assets/config/phpunit.xml
deleted file mode 100644
index 9cc6d13..0000000
--- a/.claude/skills/tiny-blocks-create/assets/config/phpunit.xml
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
-
- src
-
-
-
-
-
- tests
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/.claude/skills/tiny-blocks-create/assets/github/workflows/ci.yml b/.claude/skills/tiny-blocks-create/assets/github/workflows/ci.yml
deleted file mode 100644
index 728bb3b..0000000
--- a/.claude/skills/tiny-blocks-create/assets/github/workflows/ci.yml
+++ /dev/null
@@ -1,105 +0,0 @@
-name: CI
-
-on:
- pull_request:
-
-concurrency:
- group: ci-${{ github.event.pull_request.number }}
- cancel-in-progress: true
-
-permissions:
- contents: read
-
-jobs:
- resolve-php-version:
- name: Resolve PHP version
- runs-on: ubuntu-latest
- timeout-minutes: 5
- outputs:
- php-version: ${{ steps.config.outputs.php-version }}
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Resolve PHP version from composer.json
- id: config
- run: |
- version=$(jq -r '.require.php' composer.json | grep -oP '\d+\.\d+' | head -1)
- echo "php-version=$version" >> "$GITHUB_OUTPUT"
-
- build:
- name: Build
- needs: resolve-php-version
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- tools: composer:2
- php-version: ${{ needs.resolve-php-version.outputs.php-version }}
-
- - name: Validate composer.json
- run: composer validate --no-interaction
-
- - name: Install dependencies
- run: composer install --no-progress --optimize-autoloader --prefer-dist --no-interaction
-
- - name: Upload vendor and composer.lock as artifact
- uses: actions/upload-artifact@v7
- with:
- name: vendor-artifact
- path: |
- vendor
- composer.lock
-
- auto-review:
- name: Auto review
- needs: [resolve-php-version, build]
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- tools: composer:2
- php-version: ${{ needs.resolve-php-version.outputs.php-version }}
-
- - name: Download vendor artifact from build
- uses: actions/download-artifact@v8
- with:
- name: vendor-artifact
- path: .
-
- - name: Run review
- run: composer review
-
- tests:
- name: Tests
- needs: [resolve-php-version, auto-review]
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- tools: composer:2
- php-version: ${{ needs.resolve-php-version.outputs.php-version }}
-
- - name: Download vendor artifact from build
- uses: actions/download-artifact@v8
- with:
- name: vendor-artifact
- path: .
-
- - name: Run tests
- run: composer tests
diff --git a/.gitattributes b/.gitattributes
index 51e1b06..f044953 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,14 +2,11 @@
*.php text diff=php
-# Keep the Claude tooling scripts out of GitHub's language statistics
-/.claude/**/*.py linguist-vendored
+# Keep Claude tooling scripts out of GitHub's language statistics
# Dev-only, excluded from the Packagist tarball
/.github export-ignore
/tests export-ignore
-/.claude export-ignore
-/CLAUDE.md export-ignore
/.editorconfig export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
diff --git a/.claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
similarity index 100%
rename from .claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/bug_report.md
rename to .github/ISSUE_TEMPLATE/bug_report.md
diff --git a/.claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
similarity index 100%
rename from .claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/feature_request.md
rename to .github/ISSUE_TEMPLATE/feature_request.md
diff --git a/.claude/skills/tiny-blocks-create/assets/github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
similarity index 100%
rename from .claude/skills/tiny-blocks-create/assets/github/PULL_REQUEST_TEMPLATE.md
rename to .github/PULL_REQUEST_TEMPLATE.md
diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml
index d0ba49e..e87e331 100644
--- a/.github/workflows/auto-assign.yml
+++ b/.github/workflows/auto-assign.yml
@@ -8,12 +8,19 @@ on:
types:
- opened
+concurrency:
+ group: auto-assign-${{ github.event.issue.number || github.event.pull_request.number }}
+ cancel-in-progress: true
+
+permissions:
+ issues: write
+ pull-requests: write
+
jobs:
- run:
+ auto-assign:
+ name: Auto assign
runs-on: ubuntu-latest
- permissions:
- issues: write
- pull-requests: write
+ timeout-minutes: 5
steps:
- name: Assign issues and pull requests
uses: gustavofreze/auto-assign@2.1.0
@@ -22,4 +29,4 @@ jobs:
github_token: '${{ secrets.GITHUB_TOKEN }}'
allow_self_assign: 'true'
allow_no_assignees: 'true'
- assignment_options: 'ISSUE,PULL_REQUEST'
\ No newline at end of file
+ assignment_options: 'ISSUE,PULL_REQUEST'
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
deleted file mode 100644
index aed6dab..0000000
--- a/.github/workflows/codeql.yml
+++ /dev/null
@@ -1,35 +0,0 @@
-name: Security checks
-
-on:
- push:
- branches: [ "main" ]
- pull_request:
- branches: [ "main" ]
- schedule:
- - cron: "0 0 * * *"
-
-permissions:
- actions: read
- contents: read
- security-events: write
-
-jobs:
- analyze:
- name: Analyze
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- language: [ "actions" ]
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v7
-
- - name: Initialize CodeQL
- uses: github/codeql-action/init@v4.37.3
- with:
- languages: ${{ matrix.language }}
-
- - name: Perform CodeQL analysis
- uses: github/codeql-action/analyze@v4.37.3
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index d7efbcf..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# tiny-blocks PHP library
-
-A library in the [tiny-blocks](https://github.com/tiny-blocks) ecosystem: small, focused,
-framework-agnostic PHP building blocks published to Packagist. Target runtime is **PHP 8.5**.
-
-This file is the index. The detailed conventions live in `.claude/rules/` (loaded automatically
-when you touch matching files) and in three skills under `.claude/skills/`. Keep this file short:
-when a convention needs explaining, it belongs in a rule or a skill, not here.
-
-## Validate
-
-Every PHP and Composer command runs inside Docker via the `Makefile` (image
-`gustavofreze/php:8.5-alpine`). Never run PHP on the host directly.
-
-- `make review`: phpcs (PSR-12) + phpstan (`level: max`). Run before claiming code is clean.
-- `make tests`: phpunit + infection. Mutation thresholds are `minMsi: 100` / `minCoveredMsi: 100`.
-- `make test-file FILE=`: one filtered test file, no coverage.
-- `make help`: discover all targets if any of the above is missing or has changed.
-
-Treat `make review` and `make tests` as the definition of done. Both gate every pull request in
-CI. Passing them locally is the bar before any "complete" / "fixed" / "passing" claim.
-
-## Conventions (`.claude/rules/`)
-
-Path-scoped. Each loads only when you edit matching files.
-
-- `php-library-architecture.md`: folder layout, public API boundary, `Internal/` semantics (`src/`).
-- `php-library-code-style.md`: semantic code rules, naming, PHPDoc, `self`/`static` (`src/`, `tests/`).
-- `php-library-modeling.md`: value objects, exceptions, enums, complexity (`src/`).
-- `php-library-testing.md`: BDD Given/When/Then, PHPUnit, fixtures, coverage discipline (`tests/`).
-- `php-library-tooling.md`: invariants for `composer.json`, `phpcs.xml`, `phpunit.xml`, etc.
-- `php-library-documentation.md`: README and `docs/` conventions.
-- `php-library-github-workflows.md`: GitHub Actions conventions.
-
-## Skills (`.claude/skills/`)
-
-- `tiny-blocks-create`: scaffold a new library or restore a canonical config/repo file. Holds
- the drop-in bodies of every config file, the CI workflow, and the issue/PR/security templates.
-- `tiny-blocks-consume`: discover and reuse a published tiny-blocks package as a dependency
- instead of writing the capability by hand. Checks the catalog, adds the match with Composer,
- and uses the installed package's own README and public API. The consuming counterpart of
- `tiny-blocks-create`.
-- `commit-message`: generate a Conventional Commits message in the ecosystem's format. Invoke
- when writing a commit. Commit messages are never generated automatically.
-
-## Global defaults
-
-- All identifiers, comments, documentation, and commit messages use American English.
-- In prose and headings, do not use semicolons or em-dashes. This applies to PHPDoc descriptions
- and to every Markdown file (README, docs). Use a period or a comma in place of a semicolon, and
- a colon, a comma, or parentheses in place of an em-dash. Hyphens in compound words and
- identifiers (`tiny-blocks`, `name-length`) are not affected, and semicolons that terminate PHP
- statements in code are not affected.
-- Prefer dependencies from the tiny-blocks ecosystem before reaching outside it.
-- Do not install or update any dependency to a version published less than 7 days ago. Freshly
- released versions can be yanked or compromised. Let them age past the cooldown first. Packages
- from the tiny-blocks ecosystem (`tiny-blocks/*`) are exempt, they are first-party. When a
- dependency bump is needed but the target version is too recent, report it and wait rather than
- pinning the new version.
-- Do not run any history-altering Git operation (branch, commit, push, merge, rebase, tag) unless
- explicitly asked.
diff --git a/README.md b/README.md
index 4492ac5..6ef29ab 100644
--- a/README.md
+++ b/README.md
@@ -9,15 +9,28 @@
+ [Correlation tracking](#correlation-tracking)
- [At creation time](#at-creation-time)
- [Derived from an existing logger](#derived-from-an-existing-logger)
+ + [Minimum log level](#minimum-log-level)
+ [Sensitive data redaction](#sensitive-data-redaction)
+ - [Strategy catalog](#strategy-catalog)
+ - [Choosing a mask](#choosing-a-mask)
+ - [Field name patterns](#field-name-patterns)
- [Document redaction](#document-redaction)
- [Email redaction](#email-redaction)
- [Phone redaction](#phone-redaction)
- [Password redaction](#password-redaction)
- [Name redaction](#name-redaction)
+ - [Visible edges redaction](#visible-edges-redaction)
+ - [Wordwise redaction](#wordwise-redaction)
+ - [Full mask redaction](#full-mask-redaction)
+ - [Scoped redaction](#scoped-redaction)
+ - [Allowed fields redaction](#allowed-fields-redaction)
+ - [Removed fields redaction](#removed-fields-redaction)
+ - [Pattern redaction](#pattern-redaction)
- [Composing multiple redactions](#composing-multiple-redactions)
- [Custom redaction](#custom-redaction)
+ [Custom log template](#custom-log-template)
+ + [Testing with the in-memory logger](#testing-with-the-in-memory-logger)
+* [FAQ](#faq)
* [License](#license)
* [Contributing](#contributing)
@@ -27,7 +40,13 @@
Emits PSR-3 structured logs for PHP, with each entry carrying timestamp, component, correlation id, level, and a
structured data payload. Supports pluggable redactions for sensitive fields such as passwords, emails, phone numbers,
-and identity documents. Built for consumption by log aggregators and SIEM pipelines in production environments.
+and identity documents, plus a severity threshold that drops quieter entries before they are rendered. Built for
+consumption by log aggregators and SIEM pipelines in production environments.
+
+Redaction is composable and split in two. The `Redactions` namespace holds strategies named after the kind of data they
+protect, ready to use. Nested under it, `Redactions\Rules` holds the general ones, named after the rule they apply: how
+much of a value stays visible, which branch of the payload a redaction reaches, which fields survive at all, and which
+patterns are masked wherever they appear.
@@ -108,10 +127,125 @@ $contextual = $logger->withContext(context: LogContext::from(correlationId: 'req
$contextual->info(message: 'payment.started', context: ['amount' => 100.50]);
```
+### Minimum log level
+
+Entries below the configured level are discarded before redaction and formatting run. The default is `LogLevel::DEBUG`,
+which writes everything.
+
+```php
+withComponent(component: 'order-service')
+ ->withMinimumLevel(minimumLevel: LogLevel::WARNING)
+ ->build();
+
+$logger->info(message: 'order.placed'); # discarded
+$logger->warning(message: 'stock.low'); # written
+```
+
+`LogLevel` also answers severity questions on its own:
+
+```php
+LogLevel::CRITICAL->isAtLeast(threshold: LogLevel::WARNING);
+```
+
### Sensitive data redaction
-Redaction is optional and configurable. Built-in redaction strategies are provided for common sensitive fields.
-Each strategy accepts multiple field name variations and a configurable masking length.
+Redaction is optional and configurable. Every strategy implements `Redaction`, and strategies compose: the logger
+applies each one in the order it was registered.
+
+#### Strategy catalog
+
+Strategies live in two namespaces. `Redactions` holds the ready-made ones, each named after the kind of data it
+protects. `Rules` holds the general ones, each named after the rule it applies, for whatever the ready-made set does not
+cover.
+
+`TinyBlocks\Logger\Redactions`, by kind of data:
+
+| Strategy | Keeps visible | Default field |
+|---------------------|-----------------------------------|---------------|
+| `DocumentRedaction` | Trailing characters | `document` |
+| `EmailRedaction` | Local part prefix and full domain | `email` |
+| `PhoneRedaction` | Trailing characters | `phone` |
+| `NameRedaction` | Leading characters | `name` |
+| `PasswordRedaction` | Nothing | `password` |
+
+`TinyBlocks\Logger\Redactions\Rules`, by rule:
+
+| Strategy | Keeps visible | Typical use |
+|--------------------------|-----------------------------------|---------------------------------------------------------|
+| `VisibleEdgesRedaction` | Leading and trailing characters | Any value needing a custom window |
+| `WordwiseRedaction` | The edges of every word | Full names, multi word labels |
+| `FullMaskRedaction` | Nothing | Secrets, free text, addresses, user agents |
+| `ScopedRedaction` | Delegates within one parent field | Field names that repeat with different meanings |
+| `AllowedFieldsRedaction` | Only the listed fields | Deny by default, so a new field is masked until allowed |
+| `RemovedFieldsRedaction` | Nothing, the field itself is gone | Stack traces, payment codes |
+| `PatternRedaction` | Everything except the matches | Sensitive data embedded in free text |
+
+#### Choosing a mask
+
+The general primitives take a `Mask`, which decides how the hidden portion is rendered.
+
+| Factory | Renders | Reveals the original length |
+|--------------------------------|------------------------------------------------------|-----------------------------|
+| `Mask::proportional()` | One mask character per hidden character | Yes |
+| `Mask::fixed(length: 8)` | The same number of characters every time | No |
+| `Mask::preservingSeparators()` | Letters and digits masked, everything else preserved | Partially |
+
+```php
+withComponent(component: 'payment-service')
+ ->withRedactions(
+ VisibleEdgesRedaction::from(
+ mask: Mask::proportional(),
+ fields: ['birth_date'],
+ visiblePrefixLength: 4
+ )
+ )
+ ->build();
+
+$logger->info(message: 'payer.registered', context: ['birth_date' => '1990-07-21']);
+# birth_date → "1990******"
+```
+
+A negative visible length is rejected with `NegativeVisibleLength`.
+
+#### Wordwise redaction
+
+Masks each word of the value on its own, so a full name stays readable as a shape instead of collapsing into a single
+run of mask characters.
+
+```php
+withComponent(component: 'user-service')
+ ->withRedactions(
+ WordwiseRedaction::from(
+ mask: Mask::fixed(length: 3),
+ fields: ['name', 'holder'],
+ visiblePrefixLength: 2
+ )
+ )
+ ->build();
+
+$logger->info(message: 'user.created', context: ['name' => 'Gustavo Freze']);
+# name → "Gu*** Fr***"
+```
+
+#### Full mask redaction
+
+Masks the value entirely. Suited to anything that carries no operational meaning once logged, such as secrets, free
+text, street addresses, and user agents.
+
+It is the named counterpart of `VisibleEdgesRedaction` with no visible edge. Both produce the same output, and the
+separate name exists so the intent reads at the call site, the same way `DocumentRedaction` and `PhoneRedaction` are
+both suffix strategies under two names.
+
+```php
+withComponent(component: 'audit-service')
+ ->withRedactions(
+ FullMaskRedaction::from(mask: Mask::fixed(length: 8), fields: ['ip', 'user_agent', 'session_id'])
+ )
+ ->build();
+
+$logger->info(message: 'request.received', context: ['ip' => '10.0.0.1', 'route' => '/v1/users']);
+# ip → "********"
+# route → "/v1/users" (unchanged)
+```
+
+`commonSecrets()` covers the field names that carry secrets across most systems (`*token*`, `*secret*`, `*api_key*`,
+`*password*`, `*private_key*`, `credentials`, and `authorization`) with a fixed mask:
+
+```php
+withComponent(component: 'payment-service')
+ ->withRedactions(
+ ScopedRedaction::under(
+ parent: 'document',
+ redaction: DocumentRedaction::from(fields: ['value'], visibleSuffixLength: 2)
+ )
+ )
+ ->build();
+
+$logger->info(message: 'payment.created', context: [
+ 'document' => ['type' => 'cpf', 'value' => '12345678901'],
+ 'metadata' => ['value' => 'operational-marker']
+]);
+# document.value → "*********01"
+# metadata.value → "operational-marker" (unchanged)
+```
+
+The scope applies at any depth, so a `document` nested under `charge` is covered by the same rule.
+
+#### Allowed fields redaction
+
+The inverse of the other strategies, which name what to hide. Naming what to keep removes the leak by omission, since a
+field added later is masked until it is explicitly allowed. Pair it with `ScopedRedaction` to apply the allow list to
+one branch of the payload instead of all of it.
+
+```php
+withComponent(component: 'payment-service')
+ ->withRedactions(
+ ScopedRedaction::under(
+ parent: 'address',
+ redaction: AllowedFieldsRedaction::from(mask: Mask::fixed(length: 3), fields: ['city', 'state'])
+ )
+ )
+ ->build();
+
+$logger->info(message: 'payment.created', context: [
+ 'address' => ['city' => 'São Paulo', 'state' => 'BR-SP', 'street' => 'Rua Example', 'number' => '123']
+]);
+# address.city → "São Paulo" (allowed)
+# address.state → "BR-SP" (allowed)
+# address.street → "***"
+# address.number → "***"
+```
+
+#### Removed fields redaction
+
+Drops the field instead of masking it. Preferred when the field carries no diagnostic value at all, so nothing about the
+original reaches the log, not even its presence.
+
+```php
+withComponent(component: 'error-service')
+ ->withRedactions(RemovedFieldsRedaction::from(fields: ['trace', '*_token']))
+ ->build();
+
+$logger->error(message: 'request.failed', context: [
+ 'message' => 'boom',
+ 'trace' => '#0 /app/src/Handler.php(42)',
+ 'nested' => ['access_token' => 'at-1', 'id' => '7']
+]);
+# data={"message":"boom","nested":{"id":"7"}}
+```
+
+#### Pattern redaction
+
+Field-based strategies cannot reach sensitive data embedded in free text: an exception message quoting a document, a
+stack trace, a URI carrying a query string. Matching on the value instead of the key closes that gap.
+
+```php
+withComponent(component: 'error-service')
+ ->withRedactions(PatternRedaction::from(pattern: '/\d{11}/', replacement: '[REDACTED]'))
+ ->build();
+
+$logger->error(message: 'request.failed', context: [
+ 'message' => 'Document 12345678901 is invalid.',
+ 'uri' => '/clients/12345678901'
+]);
+# message → "Document [REDACTED] is invalid."
+# uri → "/clients/[REDACTED]"
+```
+
+A pattern the regular expression engine rejects raises `InvalidRedactionPattern` at configuration time, not at the first
+log call.
+
#### Composing multiple redactions
```php
@@ -300,35 +664,35 @@ declare(strict_types=1);
use TinyBlocks\Logger\StructuredLogger;
use TinyBlocks\Logger\Redactions\DocumentRedaction;
use TinyBlocks\Logger\Redactions\EmailRedaction;
+use TinyBlocks\Logger\Redactions\Rules\FullMaskRedaction;
use TinyBlocks\Logger\Redactions\NameRedaction;
-use TinyBlocks\Logger\Redactions\PasswordRedaction;
use TinyBlocks\Logger\Redactions\PhoneRedaction;
$logger = StructuredLogger::create()
->withComponent(component: 'user-service')
->withRedactions(
- DocumentRedaction::default(),
+ NameRedaction::default(),
EmailRedaction::default(),
PhoneRedaction::default(),
- PasswordRedaction::default(),
- NameRedaction::default()
+ DocumentRedaction::default(),
+ FullMaskRedaction::commonSecrets()
)
->build();
$logger->info(message: 'user.registered', context: [
- 'document' => '12345678900',
- 'email' => 'john@example.com',
- 'phone' => '+5511999887766',
- 'password' => 's3cr3t!',
- 'name' => 'John',
- 'status' => 'active'
+ 'name' => 'John',
+ 'email' => 'john@example.com',
+ 'phone' => '+5511999887766',
+ 'status' => 'active',
+ 'document' => '12345678900',
+ 'access_token' => 'at-1'
]);
-# document → "********900"
-# email → "jo**@example.com"
-# phone → "**********7766"
-# password → "*******"
-# name → "Jo**"
-# status → "active" (unchanged)
+# name → "Jo**"
+# email → "jo**@example.com"
+# phone → "**********7766"
+# status → "active" (unchanged)
+# document → "********900"
+# access_token → "********"
```
#### Custom redaction
@@ -342,7 +706,7 @@ declare(strict_types=1);
use TinyBlocks\Logger\Redaction;
-final readonly class TokenRedaction implements Redaction
+final readonly class ReversedRedaction implements Redaction
{
public function redact(array $payload): array
{
@@ -352,8 +716,8 @@ final readonly class TokenRedaction implements Redaction
continue;
}
- if ($key === 'token' && is_string($value)) {
- $payload[$key] = '***REDACTED***';
+ if ($key === 'signature' && is_string($value)) {
+ $payload[$key] = strrev($value);
}
}
@@ -373,11 +737,11 @@ use TinyBlocks\Logger\StructuredLogger;
$logger = StructuredLogger::create()
->withComponent(component: 'auth-service')
- ->withRedactions(new TokenRedaction())
+ ->withRedactions(new ReversedRedaction())
->build();
-$logger->info(message: 'user.logged_in', context: ['token' => 'abc123']);
-# token → "***REDACTED***"
+$logger->info(message: 'user.logged_in', context: ['signature' => 'abc123']);
+# signature → "321cba"
```
### Custom log template
@@ -407,6 +771,66 @@ $logger->info(message: 'custom.event', context: ['value' => 42]);
# [2026-02-21T16:00:00+00:00] custom-service | | INFO | custom.event | {"value":42}
```
+### Testing with the in-memory logger
+
+`InMemoryLogger` records entries instead of writing them, so a test asserts on what was logged rather than on how it was
+rendered. Payloads are kept exactly as received, with no redaction and no formatting. Loggers derived through
+`withContext` record into the same store, so entries are visible from either instance.
+
+```php
+info(message: 'user.created', context: ['document' => '12345678900']);
+
+$logger->entries()->toArray();
+# [['key' => 'user.created', 'level' => 'INFO', 'context' => null, 'payload' => ['document' => '12345678900']]]
+```
+
+
+
+## FAQ
+
+### 01. Why does a masked value keep the width of the original?
+
+Only when the strategy asks for it. The field-specific strategies use `Mask::proportional()`, which emits one character
+per hidden character, because a support engineer reading `**********7766` can still tell a mobile number from a short
+extension. That convenience costs information: the width of the output is the width of the input.
+
+When the width itself is sensitive, a password being the clearest case, use `Mask::fixed(...)`, which emits the same run
+every time and is what `PasswordRedaction` and `FullMaskRedaction::commonSecrets()` already do.
+
+### 02. Why scope a redaction to a parent field instead of listing field names?
+
+Because field names are not unique. A payload carries `code` as a verification code in one branch and as an error code
+in another, `number` as a card number in one place and as a page number in a query string in another. A flat list of
+field names cannot tell them apart, so masking the sensitive one also destroys the diagnostic one.
+
+`ScopedRedaction` restricts a strategy to the sub payloads found under a given parent, which is the smallest piece of
+context needed to disambiguate.
+
+### 03. When should a field be dropped instead of masked?
+
+When the masked value would still be noise. A stack trace, a payment brcode, or a raw user agent tells a reader nothing
+once masked, and it still costs bytes in every aggregator downstream. `RemovedFieldsRedaction` removes the key entirely.
+
+Masking stays the right answer whenever the shape of the value carries meaning, for example knowing that a document was
+present and ended in `900`.
+
+### 04. Why does the logger match field names with wildcards?
+
+Because leaks happen by omission, not by mistake. A payload gains a `client_name` next to the `name` that was already
+covered, and nothing in the configuration reacts. Patterns such as `*_name` or `*token*` follow the naming convention
+rather than the current field list.
+
+For payloads where even that is not enough, `AllowedFieldsRedaction` inverts the default: everything is masked until it
+is named.
+
## License
diff --git a/.claude/skills/tiny-blocks-create/assets/docs/SECURITY.md b/SECURITY.md
similarity index 72%
rename from .claude/skills/tiny-blocks-create/assets/docs/SECURITY.md
rename to SECURITY.md
index a892afe..3eebbf3 100644
--- a/.claude/skills/tiny-blocks-create/assets/docs/SECURITY.md
+++ b/SECURITY.md
@@ -7,6 +7,6 @@ Only the latest release receives security updates.
## Reporting a vulnerability
Report security vulnerabilities privately via
-[GitHub Security Advisories](https://github.com/tiny-blocks//security/advisories/new).
+[GitHub Security Advisories](https://github.com/tiny-blocks/logger/security/advisories/new).
Please do not disclose the vulnerability publicly until it has been addressed.
diff --git a/composer.json b/composer.json
index 5717687..b8c6bd2 100644
--- a/composer.json
+++ b/composer.json
@@ -1,6 +1,6 @@
{
"name": "tiny-blocks/logger",
- "description": "Emits PSR-3 structured logs for PHP, with correlation tracking and configurable sensitive data redaction.",
+ "description": "Emits PSR-3 structured logs for PHP, with correlation tracking, a severity threshold, and configurable redaction.",
"license": "MIT",
"type": "library",
"keywords": [
@@ -24,13 +24,14 @@
"require": {
"php": "^8.5",
"psr/log": "^3.0",
- "tiny-blocks/collection": "^2.5"
+ "tiny-blocks/collection": "^2.6"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.52",
- "infection/infection": "^0.33",
+ "infection/infection": "^0.34",
"phpstan/phpstan": "^2.2",
- "phpunit/phpunit": "^13.1",
+ "phpunit/phpunit": "^13.2",
+ "slevomat/coding-standard": "^8.31",
"squizlabs/php_codesniffer": "^4.0"
},
"minimum-stability": "stable",
@@ -47,6 +48,7 @@
},
"config": {
"allow-plugins": {
+ "dealerdirect/phpcodesniffer-composer-installer": true,
"ergebnis/composer-normalize": true,
"infection/extension-installer": true
},
diff --git a/phpcs.xml b/phpcs.xml
index a52372c..96c803e 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -1,7 +1,97 @@
Code style for the tiny-blocks library.
-
+
src
tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index b00979a..30eafb3 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -8,26 +8,39 @@ parameters:
# PSR-3 LoggerInterface::log() types $level as mixed; the library casts it to string at that
# boundary to resolve the log level. The mixed origin is the PSR contract, not the library.
- identifier: cast.string
- path: src/StructuredLogger.php
+ path: src/LogLevel.php
# Internal collaborators and private factory constructors carry plain array shapes. Native PHP
# cannot annotate the value type, and PHPDoc is prohibited on Internal types and constructors.
- identifier: missingType.iterableValue
path: src/Internal/LogFormatter.php
- identifier: missingType.iterableValue
- path: src/Internal/Redactor/Redactions.php
+ path: src/Internal/LogEntryRecorder.php
- identifier: missingType.iterableValue
- path: src/Internal/Redactor/Redactor.php
+ path: src/Internal/Redactor/*.php
+ - identifier: missingType.iterableValue
+ path: src/LogEntry.php
- identifier: missingType.iterableValue
path: src/Redactions/*.php
- identifier: missingType.iterableValue
path: src/StructuredLoggerBuilder.php
- # The recursive redactor and the reduce pipeline pass a plain array into the
- # array contract of Redaction::redact; the values originate as mixed log
- # context, an irreducible boundary shape inside Internal collaborators.
+ # The redactors traverse plain arrays into the array contract of
+ # Redaction::redact, and the field matcher reads plain string patterns out of an untyped
+ # configuration array; the values originate as mixed log context, an irreducible boundary
+ # shape inside Internal collaborators.
+ - identifier: argument.type
+ path: src/Internal/Redactor/*.php
+ # The recorder accumulates entries in a plain array before handing them to the typed
+ # collection, and the untyped field lists of the redaction constructors reach the typed
+ # signatures of the primitives they delegate to. Both cross the same no-PHPDoc boundary.
- identifier: argument.type
- path: src/Internal/Redactor/Redactions.php
+ path: src/Internal/LogEntryRecorder.php
- identifier: argument.type
- path: src/Internal/Redactor/Redactor.php
+ path: src/Redactions/*.php
+ # The field matcher indexes its exact field names by value to answer in constant time. Those
+ # names come from the same untyped configuration array, so the language cannot prove the key
+ # type at that point.
+ - identifier: offsetAccess.invalidOffset
+ path: src/Internal/Redactor/FieldMatcher.php
# LogStream wraps a stream resource the language can only promote as mixed; fwrite then
# receives that mixed handle. Internal collaborator with intrinsic resource state.
- identifier: argument.type
@@ -46,7 +59,9 @@ parameters:
# parameters (assertStringStartsWith, assertStringContainsString, ...). Same mixed origin.
- identifier: argument.type
path: tests/StructuredLoggerTest.php
- # The password data provider returns a list of single-argument rows. Its iterable value type
- # cannot be expressed without PHPDoc, which tests/ forbids, so it is suppressed for this file.
+ # The data providers return lists of argument rows. Their iterable value type cannot be
+ # expressed without PHPDoc, which tests/ forbids, so it is suppressed for those files.
+ - identifier: missingType.iterableValue
+ path: tests/LogLevelTest.php
- identifier: missingType.iterableValue
path: tests/StructuredLoggerTest.php
diff --git a/src/Exceptions/InvalidRedactionPattern.php b/src/Exceptions/InvalidRedactionPattern.php
new file mode 100644
index 0000000..a091afd
--- /dev/null
+++ b/src/Exceptions/InvalidRedactionPattern.php
@@ -0,0 +1,14 @@
+Written for the tests of the systems that consume this library, where the assertion is about
+ * what was logged rather than how it was rendered. Payloads are recorded exactly as received, with
+ * no redaction and no formatting, so assertions read the original values.
+ *
+ * Loggers derived through {@see withContext} record into the same store as the instance they
+ * come from, so entries logged through a derived instance are visible from either one.
+ */
+final readonly class InMemoryLogger implements Logger
+{
+ use LoggerTrait;
+
+ private function __construct(private ?LogContext $context, private LogEntryRecorder $recorder)
+ {
+ }
+
+ /**
+ * Creates an InMemoryLogger with no bound context and no recorded entries.
+ *
+ * @return InMemoryLogger The created instance.
+ */
+ public static function create(): InMemoryLogger
+ {
+ return new InMemoryLogger(context: null, recorder: new LogEntryRecorder());
+ }
+
+ /**
+ * Records a log entry in memory.
+ *
+ * @param mixed $level The severity, as received by the PSR-3 contract.
+ * @param string|Stringable $message The message key identifying the event.
+ * @param array $context The context data carried by the entry.
+ * @throws UnknownLogLevel If the level is outside the supported PSR-3 set.
+ */
+ public function log(mixed $level, string|Stringable $message, array $context = []): void
+ {
+ $this->recorder->record(
+ entry: LogEntry::from(
+ key: (string)$message,
+ level: LogLevel::fromPsrLevel(level: $level),
+ context: $this->context,
+ payload: $context
+ )
+ );
+ }
+
+ /**
+ * Returns the entries recorded so far.
+ *
+ * @return LogEntries The recorded entries, in the order they were logged.
+ */
+ public function entries(): LogEntries
+ {
+ return $this->recorder->toLogEntries();
+ }
+
+ public function withContext(LogContext $context): InMemoryLogger
+ {
+ return new InMemoryLogger(context: $context, recorder: $this->recorder);
+ }
+}
diff --git a/src/Internal/LogEntryRecorder.php b/src/Internal/LogEntryRecorder.php
new file mode 100644
index 0000000..8e2ef37
--- /dev/null
+++ b/src/Internal/LogEntryRecorder.php
@@ -0,0 +1,23 @@
+entries[] = $entry;
+ }
+
+ public function toLogEntries(): LogEntries
+ {
+ return LogEntries::createFrom(elements: $this->entries);
+ }
+}
diff --git a/src/Internal/LogFormatter.php b/src/Internal/LogFormatter.php
index 2693484..69650ea 100644
--- a/src/Internal/LogFormatter.php
+++ b/src/Internal/LogFormatter.php
@@ -8,6 +8,7 @@
use DateTimeInterface;
use JsonException;
use TinyBlocks\Logger\LogContext;
+use TinyBlocks\Logger\LogLevel;
final readonly class LogFormatter
{
@@ -42,7 +43,7 @@ public function format(string $key, LogLevel $level, array $payload, ?LogContext
try {
$encodedData = json_encode(
$payload,
- JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
+ (JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)
);
} catch (JsonException) {
$encodedData = self::ENCODING_FAILURE_PAYLOAD;
diff --git a/src/Internal/LogLevel.php b/src/Internal/LogLevel.php
deleted file mode 100644
index 0d541fd..0000000
--- a/src/Internal/LogLevel.php
+++ /dev/null
@@ -1,17 +0,0 @@
-patterns = $patterns;
+ $this->exactFields = $exactFields;
+ }
+
+ public function matches(int|string $key): bool
+ {
+ $candidate = (string)$key;
+
+ return isset($this->exactFields[$candidate])
+ || ($this->patterns !== []
+ && array_any($this->patterns, static fn(string $pattern): bool => fnmatch($pattern, $candidate)));
+ }
+}
diff --git a/src/Internal/Redactor/FieldRemover.php b/src/Internal/Redactor/FieldRemover.php
new file mode 100644
index 0000000..1d813b9
--- /dev/null
+++ b/src/Internal/Redactor/FieldRemover.php
@@ -0,0 +1,29 @@
+ $value) {
+ if ($this->fields->matches(key: $key)) {
+ continue;
+ }
+
+ $retained[$key] = is_array($value) ? $this->redact(payload: $value) : $value;
+ }
+
+ return $retained;
+ }
+}
diff --git a/src/Internal/Redactor/MaskStyle.php b/src/Internal/Redactor/MaskStyle.php
new file mode 100644
index 0000000..00b80c8
--- /dev/null
+++ b/src/Internal/Redactor/MaskStyle.php
@@ -0,0 +1,40 @@
+ preg_match('/[\p{L}\p{N}]/u', $character) === 1
+ ? self::MASK_CHARACTER
+ : $character,
+ mb_str_split($value, 1, 'UTF-8')
+ );
+
+ return implode('', $masked);
+ }
+
+ public function applyTo(string $hidden, string $fixedMask): string
+ {
+ return match ($this) {
+ self::FIXED => $fixedMask,
+ self::PROPORTIONAL => str_repeat(self::MASK_CHARACTER, mb_strlen($hidden, 'UTF-8')),
+ self::PRESERVING_SEPARATORS => MaskStyle::withoutLettersAndDigits(value: $hidden)
+ };
+ }
+}
diff --git a/src/Internal/Redactor/PatternRedactor.php b/src/Internal/Redactor/PatternRedactor.php
new file mode 100644
index 0000000..a670036
--- /dev/null
+++ b/src/Internal/Redactor/PatternRedactor.php
@@ -0,0 +1,34 @@
+redactValue(...), $payload);
+ }
+
+ private function redactValue(mixed $value): mixed
+ {
+ if (is_array($value)) {
+ return $this->redact(payload: $value);
+ }
+
+ return is_string($value) ? preg_replace($this->pattern, $this->replacement, $value) : $value;
+ }
+}
diff --git a/src/Internal/Redactor/Redactor.php b/src/Internal/Redactor/Redactor.php
index c60a276..bc59fa1 100644
--- a/src/Internal/Redactor/Redactor.php
+++ b/src/Internal/Redactor/Redactor.php
@@ -9,23 +9,36 @@
final readonly class Redactor implements Redaction
{
- public function __construct(private array $fields, private Closure $maskingFunction)
+ public function __construct(private FieldMatcher $fields, private Closure $maskingFunction)
{
}
public function redact(array $payload): array
{
foreach ($payload as $key => $value) {
- if (is_array($value)) {
- $payload[$key] = $this->redact(payload: $value);
- continue;
- }
-
- if (in_array($key, $this->fields, true) && is_string($value)) {
- $payload[$key] = ($this->maskingFunction)($value);
- }
+ $payload[$key] = $this->redactValue(key: $key, value: $value);
}
return $payload;
}
+
+ private function maskedValue(mixed $value): mixed
+ {
+ if (is_array($value)) {
+ return array_is_list($value)
+ ? array_map($this->maskedValue(...), $value)
+ : $this->redact(payload: $value);
+ }
+
+ return is_scalar($value) ? ($this->maskingFunction)((string)$value) : $value;
+ }
+
+ private function redactValue(int|string $key, mixed $value): mixed
+ {
+ if ($this->fields->matches(key: $key)) {
+ return $this->maskedValue(value: $value);
+ }
+
+ return is_array($value) ? $this->redact(payload: $value) : $value;
+ }
}
diff --git a/src/Internal/Redactor/RetainedFieldRedactor.php b/src/Internal/Redactor/RetainedFieldRedactor.php
new file mode 100644
index 0000000..c5f4a28
--- /dev/null
+++ b/src/Internal/Redactor/RetainedFieldRedactor.php
@@ -0,0 +1,37 @@
+ $value) {
+ $payload[$key] = $this->redactValue(key: $key, value: $value);
+ }
+
+ return $payload;
+ }
+
+ private function redactValue(int|string $key, mixed $value): mixed
+ {
+ if (is_array($value)) {
+ return $this->redact(payload: $value);
+ }
+
+ if ($this->fields->matches(key: $key) || !is_scalar($value)) {
+ return $value;
+ }
+
+ return ($this->maskingFunction)((string)$value);
+ }
+}
diff --git a/src/Internal/Redactor/ScopedRedactor.php b/src/Internal/Redactor/ScopedRedactor.php
new file mode 100644
index 0000000..551245b
--- /dev/null
+++ b/src/Internal/Redactor/ScopedRedactor.php
@@ -0,0 +1,34 @@
+ $value) {
+ $payload[$key] = $this->redactValue(key: $key, value: $value);
+ }
+
+ return $payload;
+ }
+
+ private function redactValue(int|string $key, mixed $value): mixed
+ {
+ if (!is_array($value)) {
+ return $value;
+ }
+
+ $descended = $this->redact(payload: $value);
+
+ return $this->scope->matches(key: $key) ? $this->redaction->redact(payload: $descended) : $descended;
+ }
+}
diff --git a/src/Internal/Redactor/VisibleEdges.php b/src/Internal/Redactor/VisibleEdges.php
new file mode 100644
index 0000000..98ca1d5
--- /dev/null
+++ b/src/Internal/Redactor/VisibleEdges.php
@@ -0,0 +1,35 @@
+prefixLength - $this->suffixLength));
+ $hidden = mb_substr($value, $this->prefixLength, $hiddenLength, 'UTF-8');
+ $template = '%s%s%s';
+
+ return sprintf(
+ $template,
+ mb_substr($value, 0, $this->prefixLength, 'UTF-8'),
+ $this->mask->applyTo(hidden: $hidden),
+ mb_substr($value, ($this->prefixLength + $hiddenLength), null, 'UTF-8')
+ );
+ }
+}
diff --git a/src/Internal/Redactor/VisibleLocalPart.php b/src/Internal/Redactor/VisibleLocalPart.php
new file mode 100644
index 0000000..8796e93
--- /dev/null
+++ b/src/Internal/Redactor/VisibleLocalPart.php
@@ -0,0 +1,31 @@
+mask->applyTo(hidden: $value);
+ }
+
+ $template = '%s%s';
+
+ return sprintf(
+ $template,
+ $this->localPart->applyTo(value: mb_substr($value, 0, $atPosition, 'UTF-8')),
+ mb_substr($value, $atPosition, null, 'UTF-8')
+ );
+ }
+}
diff --git a/src/Internal/Redactor/VisibleWords.php b/src/Internal/Redactor/VisibleWords.php
new file mode 100644
index 0000000..0502c3e
--- /dev/null
+++ b/src/Internal/Redactor/VisibleWords.php
@@ -0,0 +1,19 @@
+edges->applyTo(...), $words));
+ }
+}
diff --git a/src/LogEntries.php b/src/LogEntries.php
new file mode 100644
index 0000000..2262ee9
--- /dev/null
+++ b/src/LogEntries.php
@@ -0,0 +1,16 @@
+
+ */
+final class LogEntries extends Collection
+{
+}
diff --git a/src/LogEntry.php b/src/LogEntry.php
new file mode 100644
index 0000000..279e6d8
--- /dev/null
+++ b/src/LogEntry.php
@@ -0,0 +1,33 @@
+ $payload The context data carried by the entry.
+ * @return LogEntry The created instance.
+ */
+ public static function from(string $key, LogLevel $level, ?LogContext $context, array $payload): LogEntry
+ {
+ return new LogEntry(key: $key, level: $level, context: $context, payload: $payload);
+ }
+}
diff --git a/src/LogLevel.php b/src/LogLevel.php
new file mode 100644
index 0000000..115ee45
--- /dev/null
+++ b/src/LogLevel.php
@@ -0,0 +1,72 @@
+ 0,
+ self::INFO => 1,
+ self::NOTICE => 2,
+ self::WARNING => 3,
+ self::ERROR => 4,
+ self::CRITICAL => 5,
+ self::ALERT => 6,
+ self::EMERGENCY => 7
+ };
+ }
+
+ /**
+ * Tells whether the level is at least as severe as the given threshold.
+ *
+ * @param LogLevel $threshold The lowest severity that is still emitted.
+ * @return bool Whether the level reaches the threshold.
+ */
+ public function isAtLeast(LogLevel $threshold): bool
+ {
+ return $this->severity() >= $threshold->severity();
+ }
+}
diff --git a/src/Logger.php b/src/Logger.php
index 2c90859..db06e0c 100644
--- a/src/Logger.php
+++ b/src/Logger.php
@@ -19,10 +19,11 @@ interface Logger extends LoggerInterface
/**
* Creates a new Logger instance bound to the given correlation context.
*
- * The original instance remains unchanged.
+ * The original instance remains unchanged. An implementation returns its own type, so
+ * declaring the concrete class name is the expected form.
*
* @param LogContext $context The log context containing the correlation ID.
- * @return static A new Logger instance bound to the given context.
+ * @return Logger A new Logger instance bound to the given context.
*/
- public function withContext(LogContext $context): static;
+ public function withContext(LogContext $context): Logger;
}
diff --git a/src/Mask.php b/src/Mask.php
new file mode 100644
index 0000000..6d05976
--- /dev/null
+++ b/src/Mask.php
@@ -0,0 +1,70 @@
+A proportional mask emits one character per hidden character, so the length of the original
+ * value stays visible in the output. A fixed mask emits the same number of characters regardless of
+ * the input, hiding the length as well.
+ */
+final readonly class Mask
+{
+ private function __construct(private MaskStyle $style, private string $fixedMask = '')
+ {
+ }
+
+ /**
+ * Creates a Mask that always emits the same number of mask characters.
+ *
+ * The length of the original value is never revealed, and the mask is emitted even when
+ * nothing was hidden, so a short value cannot be told apart from a long one.
+ *
+ * @param int $length The number of mask characters emitted.
+ * @return Mask The created instance.
+ */
+ public static function fixed(int $length): Mask
+ {
+ return new Mask(style: MaskStyle::FIXED, fixedMask: MaskStyle::fixedMaskOf(length: $length));
+ }
+
+ /**
+ * Creates a Mask that emits one mask character per hidden character.
+ *
+ * The length of the hidden portion is revealed by the output.
+ *
+ * @return Mask The created instance.
+ */
+ public static function proportional(): Mask
+ {
+ return new Mask(style: MaskStyle::PROPORTIONAL);
+ }
+
+ /**
+ * Creates a Mask that hides letters and digits while keeping every other character.
+ *
+ * Punctuation and spacing survive, so a formatted value keeps its shape.
+ *
+ * @return Mask The created instance.
+ */
+ public static function preservingSeparators(): Mask
+ {
+ return new Mask(style: MaskStyle::PRESERVING_SEPARATORS);
+ }
+
+ /**
+ * Renders the mask covering the given hidden portion of a value.
+ *
+ * @param string $hidden The portion of the original value that must not be shown.
+ * @return string The mask replacing that portion.
+ */
+ public function applyTo(string $hidden): string
+ {
+ return $this->style->applyTo(hidden: $hidden, fixedMask: $this->fixedMask);
+ }
+}
diff --git a/src/Redactions/DocumentRedaction.php b/src/Redactions/DocumentRedaction.php
index f824ef7..7d689c2 100644
--- a/src/Redactions/DocumentRedaction.php
+++ b/src/Redactions/DocumentRedaction.php
@@ -4,8 +4,10 @@
namespace TinyBlocks\Logger\Redactions;
-use TinyBlocks\Logger\Internal\Redactor\Redactor;
+use TinyBlocks\Logger\Exceptions\NegativeVisibleLength;
+use TinyBlocks\Logger\Mask;
use TinyBlocks\Logger\Redaction;
+use TinyBlocks\Logger\Redactions\Rules\VisibleEdgesRedaction;
/**
* Masks document field values, keeping a configurable number of trailing characters visible.
@@ -14,32 +16,24 @@
{
private const int DEFAULT_VISIBLE_SUFFIX_LENGTH = 3;
- private Redactor $redactor;
+ private Redaction $redactor;
private function __construct(array $fields, int $visibleSuffixLength)
{
- $this->redactor = new Redactor(
+ $this->redactor = VisibleEdgesRedaction::from(
+ mask: Mask::proportional(),
fields: $fields,
- maskingFunction: static function (string $value) use ($visibleSuffixLength): string {
- $length = mb_strlen($value, 'UTF-8');
- $maskedLength = max(0, $length - $visibleSuffixLength);
- $template = '%s%s';
-
- return sprintf(
- $template,
- str_repeat('*', $maskedLength),
- mb_substr($value, -$visibleSuffixLength, null, 'UTF-8')
- );
- }
+ visibleSuffixLength: $visibleSuffixLength
);
}
/**
* Creates a DocumentRedaction from the fields to mask and the number of visible trailing characters.
*
- * @param string[] $fields The field names whose values are masked.
+ * @param string[] $fields The field names whose values are masked, wildcards accepted.
* @param int $visibleSuffixLength The number of trailing characters left visible.
* @return DocumentRedaction The created instance.
+ * @throws NegativeVisibleLength If the visible suffix length is negative.
*/
public static function from(array $fields, int $visibleSuffixLength): DocumentRedaction
{
diff --git a/src/Redactions/EmailRedaction.php b/src/Redactions/EmailRedaction.php
index 1764de4..e35700d 100644
--- a/src/Redactions/EmailRedaction.php
+++ b/src/Redactions/EmailRedaction.php
@@ -4,7 +4,12 @@
namespace TinyBlocks\Logger\Redactions;
+use TinyBlocks\Logger\Exceptions\NegativeVisibleLength;
+use TinyBlocks\Logger\Internal\Redactor\FieldMatcher;
use TinyBlocks\Logger\Internal\Redactor\Redactor;
+use TinyBlocks\Logger\Internal\Redactor\VisibleEdges;
+use TinyBlocks\Logger\Internal\Redactor\VisibleLocalPart;
+use TinyBlocks\Logger\Mask;
use TinyBlocks\Logger\Redaction;
/**
@@ -14,36 +19,29 @@
{
private const int DEFAULT_VISIBLE_PREFIX_LENGTH = 2;
- private Redactor $redactor;
+ private Redaction $redactor;
private function __construct(array $fields, int $visiblePrefixLength)
{
- $this->redactor = new Redactor(
- fields: $fields,
- maskingFunction: static function (string $value) use ($visiblePrefixLength): string {
- $atPosition = mb_strpos($value, '@', 0, 'UTF-8');
-
- if ($atPosition === false) {
- return str_repeat('*', mb_strlen($value, 'UTF-8'));
- }
-
- $domain = mb_substr($value, $atPosition, null, 'UTF-8');
- $localPart = mb_substr($value, 0, $atPosition, 'UTF-8');
- $maskedSuffix = str_repeat('*', max(0, mb_strlen($localPart, 'UTF-8') - $visiblePrefixLength));
- $visiblePrefix = mb_substr($localPart, 0, $visiblePrefixLength, 'UTF-8');
- $template = '%s%s%s';
+ $mask = Mask::proportional();
+ $localPart = new VisibleLocalPart(
+ mask: $mask,
+ localPart: new VisibleEdges(mask: $mask, prefixLength: $visiblePrefixLength, suffixLength: 0)
+ );
- return sprintf($template, $visiblePrefix, $maskedSuffix, $domain);
- }
+ $this->redactor = new Redactor(
+ fields: new FieldMatcher(fields: $fields),
+ maskingFunction: $localPart->applyTo(...)
);
}
/**
* Creates an EmailRedaction from the fields to mask and the number of visible leading characters.
*
- * @param string[] $fields The field names whose values are masked.
+ * @param string[] $fields The field names whose values are masked, wildcards accepted.
* @param int $visiblePrefixLength The number of leading characters of the local part left visible.
* @return EmailRedaction The created instance.
+ * @throws NegativeVisibleLength If the visible prefix length is negative.
*/
public static function from(array $fields, int $visiblePrefixLength): EmailRedaction
{
diff --git a/src/Redactions/NameRedaction.php b/src/Redactions/NameRedaction.php
index 4b5d213..21d9c99 100644
--- a/src/Redactions/NameRedaction.php
+++ b/src/Redactions/NameRedaction.php
@@ -4,8 +4,10 @@
namespace TinyBlocks\Logger\Redactions;
-use TinyBlocks\Logger\Internal\Redactor\Redactor;
+use TinyBlocks\Logger\Exceptions\NegativeVisibleLength;
+use TinyBlocks\Logger\Mask;
use TinyBlocks\Logger\Redaction;
+use TinyBlocks\Logger\Redactions\Rules\VisibleEdgesRedaction;
/**
* Masks name field values, keeping a configurable number of leading characters visible.
@@ -14,31 +16,24 @@
{
private const int DEFAULT_VISIBLE_PREFIX_LENGTH = 2;
- private Redactor $redactor;
+ private Redaction $redactor;
private function __construct(array $fields, int $visiblePrefixLength)
{
- $this->redactor = new Redactor(
+ $this->redactor = VisibleEdgesRedaction::from(
+ mask: Mask::proportional(),
fields: $fields,
- maskingFunction: static function (string $value) use ($visiblePrefixLength): string {
- $maskedLength = max(0, mb_strlen($value, 'UTF-8') - $visiblePrefixLength);
- $template = '%s%s';
-
- return sprintf(
- $template,
- mb_substr($value, 0, $visiblePrefixLength, 'UTF-8'),
- str_repeat('*', $maskedLength)
- );
- }
+ visiblePrefixLength: $visiblePrefixLength
);
}
/**
* Creates a NameRedaction from the fields to mask and the number of visible leading characters.
*
- * @param string[] $fields The field names whose values are masked.
+ * @param string[] $fields The field names whose values are masked, wildcards accepted.
* @param int $visiblePrefixLength The number of leading characters left visible.
* @return NameRedaction The created instance.
+ * @throws NegativeVisibleLength If the visible prefix length is negative.
*/
public static function from(array $fields, int $visiblePrefixLength): NameRedaction
{
diff --git a/src/Redactions/PasswordRedaction.php b/src/Redactions/PasswordRedaction.php
index 8bbbfc4..ab49e0f 100644
--- a/src/Redactions/PasswordRedaction.php
+++ b/src/Redactions/PasswordRedaction.php
@@ -4,8 +4,9 @@
namespace TinyBlocks\Logger\Redactions;
-use TinyBlocks\Logger\Internal\Redactor\Redactor;
+use TinyBlocks\Logger\Mask;
use TinyBlocks\Logger\Redaction;
+use TinyBlocks\Logger\Redactions\Rules\FullMaskRedaction;
/**
* Masks password field values entirely with a fixed-length mask.
@@ -14,20 +15,17 @@
{
private const int DEFAULT_FIXED_MASK_LENGTH = 8;
- private Redactor $redactor;
+ private Redaction $redactor;
private function __construct(array $fields, int $fixedMaskLength)
{
- $this->redactor = new Redactor(
- fields: $fields,
- maskingFunction: static fn(): string => str_repeat('*', $fixedMaskLength)
- );
+ $this->redactor = FullMaskRedaction::from(mask: Mask::fixed(length: $fixedMaskLength), fields: $fields);
}
/**
* Creates a PasswordRedaction from the fields to mask and the fixed mask length.
*
- * @param string[] $fields The field names whose values are masked.
+ * @param string[] $fields The field names whose values are masked, wildcards accepted.
* @param int $fixedMaskLength The fixed number of mask characters emitted.
* @return PasswordRedaction The created instance.
*/
diff --git a/src/Redactions/PhoneRedaction.php b/src/Redactions/PhoneRedaction.php
index 682c276..3c2324a 100644
--- a/src/Redactions/PhoneRedaction.php
+++ b/src/Redactions/PhoneRedaction.php
@@ -4,8 +4,10 @@
namespace TinyBlocks\Logger\Redactions;
-use TinyBlocks\Logger\Internal\Redactor\Redactor;
+use TinyBlocks\Logger\Exceptions\NegativeVisibleLength;
+use TinyBlocks\Logger\Mask;
use TinyBlocks\Logger\Redaction;
+use TinyBlocks\Logger\Redactions\Rules\VisibleEdgesRedaction;
/**
* Masks phone field values, keeping a configurable number of trailing characters visible.
@@ -14,32 +16,24 @@
{
private const int DEFAULT_VISIBLE_SUFFIX_LENGTH = 4;
- private Redactor $redactor;
+ private Redaction $redactor;
private function __construct(array $fields, int $visibleSuffixLength)
{
- $this->redactor = new Redactor(
+ $this->redactor = VisibleEdgesRedaction::from(
+ mask: Mask::proportional(),
fields: $fields,
- maskingFunction: static function (string $value) use ($visibleSuffixLength): string {
- $length = mb_strlen($value, 'UTF-8');
- $maskedLength = max(0, $length - $visibleSuffixLength);
- $template = '%s%s';
-
- return sprintf(
- $template,
- str_repeat('*', $maskedLength),
- mb_substr($value, -$visibleSuffixLength, null, 'UTF-8')
- );
- }
+ visibleSuffixLength: $visibleSuffixLength
);
}
/**
* Creates a PhoneRedaction from the fields to mask and the number of visible trailing characters.
*
- * @param string[] $fields The field names whose values are masked.
+ * @param string[] $fields The field names whose values are masked, wildcards accepted.
* @param int $visibleSuffixLength The number of trailing characters left visible.
* @return PhoneRedaction The created instance.
+ * @throws NegativeVisibleLength If the visible suffix length is negative.
*/
public static function from(array $fields, int $visibleSuffixLength): PhoneRedaction
{
diff --git a/src/Redactions/Rules/AllowedFieldsRedaction.php b/src/Redactions/Rules/AllowedFieldsRedaction.php
new file mode 100644
index 0000000..5272284
--- /dev/null
+++ b/src/Redactions/Rules/AllowedFieldsRedaction.php
@@ -0,0 +1,48 @@
+The inverse of the other strategies, which name what to hide. Naming what to keep removes the
+ * leak by omission: a field added later is masked until it is explicitly allowed. Pair it with
+ * {@see ScopedRedaction} to apply the allow list to one branch of the payload instead of all of
+ * it.
+ */
+final readonly class AllowedFieldsRedaction implements Redaction
+{
+ private Redaction $redactor;
+
+ private function __construct(Mask $mask, array $fields)
+ {
+ $this->redactor = new RetainedFieldRedactor(
+ fields: new FieldMatcher(fields: $fields),
+ maskingFunction: $mask->applyTo(...)
+ );
+ }
+
+ /**
+ * Creates an AllowedFieldsRedaction from the mask and the fields left untouched.
+ *
+ * @param Mask $mask The strategy rendering every value outside the allow list.
+ * @param string[] $fields The field names left untouched, wildcards accepted.
+ * @return AllowedFieldsRedaction The created instance.
+ */
+ public static function from(Mask $mask, array $fields): AllowedFieldsRedaction
+ {
+ return new AllowedFieldsRedaction(mask: $mask, fields: $fields);
+ }
+
+ public function redact(array $payload): array
+ {
+ return $this->redactor->redact(payload: $payload);
+ }
+}
diff --git a/src/Redactions/Rules/FullMaskRedaction.php b/src/Redactions/Rules/FullMaskRedaction.php
new file mode 100644
index 0000000..f2e1cb8
--- /dev/null
+++ b/src/Redactions/Rules/FullMaskRedaction.php
@@ -0,0 +1,76 @@
+The strategy for values that carry no operational meaning once logged, such as secrets, free
+ * text, network addresses, and user agents.
+ */
+final readonly class FullMaskRedaction implements Redaction
+{
+ private const int SECRET_MASK_LENGTH = 8;
+
+ private const array COMMON_SECRET_FIELDS = [
+ '*token*',
+ '*secret*',
+ '*api_key*',
+ '*password*',
+ 'credentials',
+ 'authorization',
+ '*private_key*'
+ ];
+
+ private Redaction $redactor;
+
+ private function __construct(Mask $mask, array $fields)
+ {
+ $this->redactor = new Redactor(
+ fields: new FieldMatcher(fields: $fields),
+ maskingFunction: $mask->applyTo(...)
+ );
+ }
+
+ /**
+ * Creates a FullMaskRedaction from the mask and the fields to redact.
+ *
+ * @param Mask $mask The strategy rendering the masked value.
+ * @param string[] $fields The field names whose values are masked, wildcards accepted.
+ * @return FullMaskRedaction The created instance.
+ */
+ public static function from(Mask $mask, array $fields): FullMaskRedaction
+ {
+ return new FullMaskRedaction(mask: $mask, fields: $fields);
+ }
+
+ /**
+ * Builds a FullMaskRedaction covering the field names that carry secrets across most systems.
+ *
+ * Matches every field whose name contains token, secret,
+ * api_key, password, or private_key, plus
+ * credentials and authorization. The mask has a fixed width, so the
+ * length of the secret is never revealed.
+ *
+ * @return FullMaskRedaction The created instance.
+ */
+ public static function commonSecrets(): FullMaskRedaction
+ {
+ return FullMaskRedaction::from(
+ mask: Mask::fixed(length: self::SECRET_MASK_LENGTH),
+ fields: self::COMMON_SECRET_FIELDS
+ );
+ }
+
+ public function redact(array $payload): array
+ {
+ return $this->redactor->redact(payload: $payload);
+ }
+}
diff --git a/src/Redactions/Rules/PatternRedaction.php b/src/Redactions/Rules/PatternRedaction.php
new file mode 100644
index 0000000..af7da06
--- /dev/null
+++ b/src/Redactions/Rules/PatternRedaction.php
@@ -0,0 +1,44 @@
+Field-based strategies cannot reach sensitive data embedded in free text: an exception message
+ * quoting a document, a stack trace, a URI carrying a query string. Matching on the value instead of
+ * the key closes that gap.
+ */
+final readonly class PatternRedaction implements Redaction
+{
+ private Redaction $redactor;
+
+ private function __construct(string $pattern, string $replacement)
+ {
+ $this->redactor = new PatternRedactor(pattern: $pattern, replacement: $replacement);
+ }
+
+ /**
+ * Creates a PatternRedaction from the pattern to match and the text replacing every match.
+ *
+ * @param string $pattern The regular expression matched against every string value.
+ * @param string $replacement The text every match is replaced with.
+ * @return PatternRedaction The created instance.
+ * @throws InvalidRedactionPattern If the regular expression engine rejects the pattern.
+ */
+ public static function from(string $pattern, string $replacement): PatternRedaction
+ {
+ return new PatternRedaction(pattern: $pattern, replacement: $replacement);
+ }
+
+ public function redact(array $payload): array
+ {
+ return $this->redactor->redact(payload: $payload);
+ }
+}
diff --git a/src/Redactions/Rules/RemovedFieldsRedaction.php b/src/Redactions/Rules/RemovedFieldsRedaction.php
new file mode 100644
index 0000000..404781e
--- /dev/null
+++ b/src/Redactions/Rules/RemovedFieldsRedaction.php
@@ -0,0 +1,41 @@
+Preferred over a mask when the field carries no diagnostic value at all, such as a stack trace
+ * or a raw payment code. Nothing about the original value reaches the log, not even its presence.
+ */
+final readonly class RemovedFieldsRedaction implements Redaction
+{
+ private Redaction $redactor;
+
+ private function __construct(array $fields)
+ {
+ $this->redactor = new FieldRemover(fields: new FieldMatcher(fields: $fields));
+ }
+
+ /**
+ * Creates a RemovedFieldsRedaction from the fields to drop.
+ *
+ * @param string[] $fields The field names removed from the payload, wildcards accepted.
+ * @return RemovedFieldsRedaction The created instance.
+ */
+ public static function from(array $fields): RemovedFieldsRedaction
+ {
+ return new RemovedFieldsRedaction(fields: $fields);
+ }
+
+ public function redact(array $payload): array
+ {
+ return $this->redactor->redact(payload: $payload);
+ }
+}
diff --git a/src/Redactions/Rules/ScopedRedaction.php b/src/Redactions/Rules/ScopedRedaction.php
new file mode 100644
index 0000000..bc98b11
--- /dev/null
+++ b/src/Redactions/Rules/ScopedRedaction.php
@@ -0,0 +1,48 @@
+Field names repeat across a payload with different meanings. A value under
+ * document is an identity document, a value under metadata
+ * is an operational marker. Scoping a redaction to its parent keeps the first masked and the second
+ * readable.
+ */
+final readonly class ScopedRedaction implements Redaction
+{
+ private Redaction $redactor;
+
+ private function __construct(string $parent, Redaction $redaction)
+ {
+ $this->redactor = new ScopedRedactor(
+ scope: new FieldMatcher(fields: [$parent]),
+ redaction: $redaction
+ );
+ }
+
+ /**
+ * Creates a ScopedRedaction applying the given redaction only under the given parent field.
+ *
+ * @param string $parent The field name whose sub payloads the redaction is restricted to,
+ * wildcards accepted.
+ * @param Redaction $redaction The redaction applied within that scope.
+ * @return ScopedRedaction The created instance.
+ */
+ public static function under(string $parent, Redaction $redaction): ScopedRedaction
+ {
+ return new ScopedRedaction(parent: $parent, redaction: $redaction);
+ }
+
+ public function redact(array $payload): array
+ {
+ return $this->redactor->redact(payload: $payload);
+ }
+}
diff --git a/src/Redactions/Rules/VisibleEdgesRedaction.php b/src/Redactions/Rules/VisibleEdgesRedaction.php
new file mode 100644
index 0000000..804f521
--- /dev/null
+++ b/src/Redactions/Rules/VisibleEdgesRedaction.php
@@ -0,0 +1,66 @@
+The general primitive behind the field-specific strategies. Use it whenever a value must keep
+ * part of its head, part of its tail, or both, and the domain strategies do not fit.
+ */
+final readonly class VisibleEdgesRedaction implements Redaction
+{
+ private Redaction $redactor;
+
+ private function __construct(Mask $mask, array $fields, int $visiblePrefixLength, int $visibleSuffixLength)
+ {
+ $edges = new VisibleEdges(
+ mask: $mask,
+ prefixLength: $visiblePrefixLength,
+ suffixLength: $visibleSuffixLength
+ );
+
+ $this->redactor = new Redactor(
+ fields: new FieldMatcher(fields: $fields),
+ maskingFunction: $edges->applyTo(...)
+ );
+ }
+
+ /**
+ * Creates a VisibleEdgesRedaction from the mask, the fields to redact, and the visible edges.
+ *
+ * @param Mask $mask The strategy rendering the hidden portion of each value.
+ * @param string[] $fields The field names whose values are masked, wildcards accepted.
+ * @param int $visiblePrefixLength The number of leading characters left visible.
+ * @param int $visibleSuffixLength The number of trailing characters left visible.
+ * @return VisibleEdgesRedaction The created instance.
+ * @throws NegativeVisibleLength If either visible length is negative.
+ */
+ public static function from(
+ Mask $mask,
+ array $fields,
+ int $visiblePrefixLength = 0,
+ int $visibleSuffixLength = 0
+ ): VisibleEdgesRedaction {
+ return new VisibleEdgesRedaction(
+ mask: $mask,
+ fields: $fields,
+ visiblePrefixLength: $visiblePrefixLength,
+ visibleSuffixLength: $visibleSuffixLength
+ );
+ }
+
+ public function redact(array $payload): array
+ {
+ return $this->redactor->redact(payload: $payload);
+ }
+}
diff --git a/src/Redactions/Rules/WordwiseRedaction.php b/src/Redactions/Rules/WordwiseRedaction.php
new file mode 100644
index 0000000..5d76c13
--- /dev/null
+++ b/src/Redactions/Rules/WordwiseRedaction.php
@@ -0,0 +1,70 @@
+Suited to values made of several parts, such as a full name, where masking the value as a
+ * single run collapses it into an unreadable line. Words are separated by whitespace and rejoined
+ * with a single space.
+ */
+final readonly class WordwiseRedaction implements Redaction
+{
+ private Redaction $redactor;
+
+ private function __construct(Mask $mask, array $fields, int $visiblePrefixLength, int $visibleSuffixLength)
+ {
+ $words = new VisibleWords(
+ edges: new VisibleEdges(
+ mask: $mask,
+ prefixLength: $visiblePrefixLength,
+ suffixLength: $visibleSuffixLength
+ )
+ );
+
+ $this->redactor = new Redactor(
+ fields: new FieldMatcher(fields: $fields),
+ maskingFunction: $words->applyTo(...)
+ );
+ }
+
+ /**
+ * Creates a WordwiseRedaction from the mask, the fields to redact, and the visible edges.
+ *
+ * @param Mask $mask The strategy rendering the hidden portion of each word.
+ * @param string[] $fields The field names whose values are masked, wildcards accepted.
+ * @param int $visiblePrefixLength The number of leading characters of each word left visible.
+ * @param int $visibleSuffixLength The number of trailing characters of each word left visible.
+ * @return WordwiseRedaction The created instance.
+ * @throws NegativeVisibleLength If either visible length is negative.
+ */
+ public static function from(
+ Mask $mask,
+ array $fields,
+ int $visiblePrefixLength = 0,
+ int $visibleSuffixLength = 0
+ ): WordwiseRedaction {
+ return new WordwiseRedaction(
+ mask: $mask,
+ fields: $fields,
+ visiblePrefixLength: $visiblePrefixLength,
+ visibleSuffixLength: $visibleSuffixLength
+ );
+ }
+
+ public function redact(array $payload): array
+ {
+ return $this->redactor->redact(payload: $payload);
+ }
+}
diff --git a/src/StructuredLogger.php b/src/StructuredLogger.php
index 2d73482..670793d 100644
--- a/src/StructuredLogger.php
+++ b/src/StructuredLogger.php
@@ -8,7 +8,6 @@
use Stringable;
use TinyBlocks\Logger\Exceptions\UnknownLogLevel;
use TinyBlocks\Logger\Internal\LogFormatter;
-use TinyBlocks\Logger\Internal\LogLevel;
use TinyBlocks\Logger\Internal\Redactor\Redactions;
use TinyBlocks\Logger\Internal\Stream\LogStream;
@@ -23,17 +22,19 @@ private function __construct(
private LogStream $stream,
private ?LogContext $context,
private LogFormatter $formatter,
- private Redactions $redactions
+ private Redactions $redactions,
+ private LogLevel $minimumLevel
) {
}
/**
- * Creates a StructuredLogger from its stream, context, template, component, and redactions.
+ * Creates a StructuredLogger from its stream, context, template, component, level, and redactions.
*
* @param mixed $stream The stream the logger writes to, or null to fall back to standard error.
* @param LogContext|null $context The correlation context, or null when none is bound.
* @param string $template The format template, or an empty string to use the default template.
* @param string $component The component name identifying the log source.
+ * @param LogLevel $minimumLevel The lowest severity that is written, quieter entries are discarded.
* @param Redaction ...$redactions The redaction strategies applied to context data before writing.
* @return StructuredLogger The created logger instance.
*/
@@ -42,6 +43,7 @@ public static function from(
?LogContext $context,
string $template,
string $component,
+ LogLevel $minimumLevel,
Redaction ...$redactions
): StructuredLogger {
$formatter = $template === ''
@@ -52,7 +54,8 @@ public static function from(
stream: LogStream::from(resource: $stream),
context: $context,
formatter: $formatter,
- redactions: Redactions::createFrom(elements: $redactions)
+ redactions: Redactions::createFrom(elements: $redactions),
+ minimumLevel: $minimumLevel
);
}
@@ -66,23 +69,27 @@ public static function create(): StructuredLoggerBuilder
return new StructuredLoggerBuilder();
}
+ /**
+ * Writes a log entry to the stream when its severity reaches the configured minimum level.
+ *
+ * @param mixed $level The severity, as received by the PSR-3 contract.
+ * @param string|Stringable $message The message key identifying the event.
+ * @param array $context The context data carried by the entry.
+ * @throws UnknownLogLevel If the level is outside the supported PSR-3 set.
+ */
public function log(mixed $level, string|Stringable $message, array $context = []): void
{
- $logLevel = LogLevel::tryFrom(strtoupper((string)$level));
+ $logLevel = LogLevel::fromPsrLevel(level: $level);
- if (is_null($logLevel)) {
- $template = 'Unknown log level: %s.';
-
- throw new UnknownLogLevel(message: sprintf($template, (string)$level));
+ if (!$logLevel->isAtLeast(threshold: $this->minimumLevel)) {
+ return;
}
- $redactedPayload = $this->redactions->applyTo(payload: $context);
-
$formatted = $this->formatter->format(
key: (string)$message,
level: $logLevel,
- context: $this->context,
- payload: $redactedPayload
+ payload: $this->redactions->applyTo(payload: $context),
+ context: $this->context
);
$this->stream->write(content: $formatted);
@@ -94,7 +101,8 @@ public function withContext(LogContext $context): StructuredLogger
stream: $this->stream,
context: $context,
formatter: $this->formatter,
- redactions: $this->redactions
+ redactions: $this->redactions,
+ minimumLevel: $this->minimumLevel
);
}
}
diff --git a/src/StructuredLoggerBuilder.php b/src/StructuredLoggerBuilder.php
index 18bf8e9..b3e3a7d 100644
--- a/src/StructuredLoggerBuilder.php
+++ b/src/StructuredLoggerBuilder.php
@@ -5,7 +5,8 @@
namespace TinyBlocks\Logger;
/**
- * Fluent builder that assembles a StructuredLogger from a stream, context, template, component, and redactions.
+ * Fluent builder that assembles a StructuredLogger from a stream, context, template, component,
+ * minimum level, and redactions.
*/
final readonly class StructuredLoggerBuilder
{
@@ -14,12 +15,14 @@ public function __construct(
private ?LogContext $context = null,
private string $template = '',
private string $component = '',
- private array $redactions = []
+ private array $redactions = [],
+ private LogLevel $minimumLevel = LogLevel::DEBUG
) {
}
/**
- * Builds a StructuredLogger from the configured stream, context, template, component, and redactions.
+ * Builds a StructuredLogger from the configured stream, context, template, component, minimum
+ * level, and redactions.
*
* @return StructuredLogger The configured logger instance.
*/
@@ -30,6 +33,7 @@ public function build(): StructuredLogger
$this->context,
$this->template,
$this->component,
+ $this->minimumLevel,
...$this->redactions
);
}
@@ -47,7 +51,8 @@ public function withStream(mixed $stream): StructuredLoggerBuilder
context: $this->context,
template: $this->template,
component: $this->component,
- redactions: $this->redactions
+ redactions: $this->redactions,
+ minimumLevel: $this->minimumLevel
);
}
@@ -64,7 +69,8 @@ public function withContext(LogContext $context): StructuredLoggerBuilder
context: $context,
template: $this->template,
component: $this->component,
- redactions: $this->redactions
+ redactions: $this->redactions,
+ minimumLevel: $this->minimumLevel
);
}
@@ -81,7 +87,8 @@ public function withTemplate(string $template): StructuredLoggerBuilder
context: $this->context,
template: $template,
component: $this->component,
- redactions: $this->redactions
+ redactions: $this->redactions,
+ minimumLevel: $this->minimumLevel
);
}
@@ -98,7 +105,8 @@ public function withComponent(string $component): StructuredLoggerBuilder
context: $this->context,
template: $this->template,
component: $component,
- redactions: $this->redactions
+ redactions: $this->redactions,
+ minimumLevel: $this->minimumLevel
);
}
@@ -115,7 +123,26 @@ public function withRedactions(Redaction ...$redactions): StructuredLoggerBuilde
context: $this->context,
template: $this->template,
component: $this->component,
- redactions: array_merge($this->redactions, $redactions)
+ redactions: array_merge($this->redactions, $redactions),
+ minimumLevel: $this->minimumLevel
+ );
+ }
+
+ /**
+ * Returns a copy of the builder with the minimum level replaced.
+ *
+ * @param LogLevel $minimumLevel The lowest severity that is written, quieter entries are discarded.
+ * @return StructuredLoggerBuilder A copy of the builder with the minimum level set.
+ */
+ public function withMinimumLevel(LogLevel $minimumLevel): StructuredLoggerBuilder
+ {
+ return new StructuredLoggerBuilder(
+ stream: $this->stream,
+ context: $this->context,
+ template: $this->template,
+ component: $this->component,
+ redactions: $this->redactions,
+ minimumLevel: $minimumLevel
);
}
}
diff --git a/tests/InMemoryLoggerTest.php b/tests/InMemoryLoggerTest.php
new file mode 100644
index 0000000..468ff31
--- /dev/null
+++ b/tests/InMemoryLoggerTest.php
@@ -0,0 +1,84 @@
+entries();
+
+ /** @Then no entry is recorded */
+ self::assertTrue($entries->isEmpty());
+ }
+
+ public function testLogWhenContextDerivedThenBothInstancesSeeIt(): void
+ {
+ /** @Given an in-memory logger */
+ $logger = InMemoryLogger::create();
+
+ /** @And a contextual logger derived from it */
+ $contextual = $logger->withContext(context: LogContext::from(correlationId: 'req-abc-123'));
+
+ /** @When logging through the contextual logger */
+ $contextual->error(message: 'payment.failed');
+
+ /** @Then the entry carries the correlation context and is visible from the original logger */
+ self::assertSame(
+ [
+ [
+ 'key' => 'payment.failed',
+ 'level' => 'ERROR',
+ 'context' => 'req-abc-123',
+ 'payload' => []
+ ]
+ ],
+ $logger->entries()->toArray()
+ );
+ }
+
+ public function testLogWhenEntryRecordedThenKeepsLevelAndPayload(): void
+ {
+ /** @Given an in-memory logger */
+ $logger = InMemoryLogger::create();
+
+ /** @When logging an entry with a payload */
+ $logger->info(message: 'user.created', context: ['document' => '12345678900']);
+
+ /** @Then the entry is recorded with its level and its untouched payload */
+ self::assertSame(
+ [
+ [
+ 'key' => 'user.created',
+ 'level' => 'INFO',
+ 'context' => null,
+ 'payload' => ['document' => '12345678900']
+ ]
+ ],
+ $logger->entries()->toArray()
+ );
+ }
+
+ public function testLogWhenLevelIsUnknownThenThrowsUnknownLogLevel(): void
+ {
+ /** @Given an in-memory logger */
+ $logger = InMemoryLogger::create();
+
+ /** @Then logging at an unsupported level raises an unknown log level error */
+ $this->expectException(UnknownLogLevel::class);
+
+ /** @When logging at a level outside the supported set */
+ $logger->log('not-a-level', 'some.key');
+ }
+}
diff --git a/tests/LogLevelTest.php b/tests/LogLevelTest.php
new file mode 100644
index 0000000..5ebfb56
--- /dev/null
+++ b/tests/LogLevelTest.php
@@ -0,0 +1,134 @@
+logStream = InMemoryStream::create();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->logStream->close();
+ }
+
+ public function testLogWhenAtMinimumLevelThenWritesTheEntry(): void
+ {
+ /** @Given a structured logger that discards anything below warning */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'order-service')
+ ->withMinimumLevel(minimumLevel: LogLevel::WARNING)
+ ->build();
+
+ /** @When logging exactly at the minimum level */
+ $logger->warning(message: 'stock.low', context: ['remaining' => 2]);
+
+ /** @Then the entry reaches the stream */
+ self::assertStringContainsString('level=WARNING', $this->logStream->contents());
+ }
+
+ #[DataProvider('levelsWithSeverity')]
+ public function testSeverityWhenLevelGivenThenReturnsItsRank(LogLevel $level, int $severity): void
+ {
+ /** @Given a log level and the rank it holds in the PSR-3 scale */
+
+ /** @When reading the severity of the level */
+ $rank = $level->severity();
+
+ /** @Then the rank matches its position in the scale */
+ self::assertSame($severity, $rank);
+ }
+
+ public function testLogWhenBelowMinimumLevelThenWritesNothing(): void
+ {
+ /** @Given a structured logger that discards anything below warning */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'order-service')
+ ->withMinimumLevel(minimumLevel: LogLevel::WARNING)
+ ->build();
+
+ /** @When logging below the minimum level */
+ $logger->info(message: 'order.placed', context: ['orderId' => 42]);
+
+ /** @Then nothing reaches the stream */
+ self::assertSame('', $this->logStream->contents());
+ }
+
+ public function testIsAtLeastWhenSameLevelThenReachesThreshold(): void
+ {
+ /** @Given a threshold at the error level */
+ $threshold = LogLevel::ERROR;
+
+ /** @When comparing the same level against it */
+ $reaches = LogLevel::ERROR->isAtLeast(threshold: $threshold);
+
+ /** @Then the level reaches the threshold */
+ self::assertTrue($reaches);
+ }
+
+ public function testLogWhenLevelIsLowercaseThenResolvesTheLevel(): void
+ {
+ /** @Given a structured logger */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'order-service')
+ ->build();
+
+ /** @When logging through the PSR-3 entry point with a lowercase level */
+ $logger->log('notice', 'order.reviewed');
+
+ /** @Then the level is resolved and written in upper case */
+ self::assertStringContainsString('level=NOTICE', $this->logStream->contents());
+ }
+
+ public function testIsAtLeastWhenLouderLevelThenReachesThreshold(): void
+ {
+ /** @Given a threshold at the warning level */
+ $threshold = LogLevel::WARNING;
+
+ /** @When comparing a more severe level against it */
+ $reaches = LogLevel::CRITICAL->isAtLeast(threshold: $threshold);
+
+ /** @Then the level reaches the threshold */
+ self::assertTrue($reaches);
+ }
+
+ public function testIsAtLeastWhenQuieterLevelThenMissesThreshold(): void
+ {
+ /** @Given a threshold at the warning level */
+ $threshold = LogLevel::WARNING;
+
+ /** @When comparing a less severe level against it */
+ $reaches = LogLevel::NOTICE->isAtLeast(threshold: $threshold);
+
+ /** @Then the level misses the threshold */
+ self::assertFalse($reaches);
+ }
+
+ public static function levelsWithSeverity(): array
+ {
+ return [
+ 'debug' => [LogLevel::DEBUG, 0],
+ 'info' => [LogLevel::INFO, 1],
+ 'notice' => [LogLevel::NOTICE, 2],
+ 'warning' => [LogLevel::WARNING, 3],
+ 'error' => [LogLevel::ERROR, 4],
+ 'critical' => [LogLevel::CRITICAL, 5],
+ 'alert' => [LogLevel::ALERT, 6],
+ 'emergency' => [LogLevel::EMERGENCY, 7]
+ ];
+ }
+}
diff --git a/tests/RedactionsTest.php b/tests/RedactionsTest.php
new file mode 100644
index 0000000..b79c6ab
--- /dev/null
+++ b/tests/RedactionsTest.php
@@ -0,0 +1,497 @@
+logStream = InMemoryStream::create();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->logStream->close();
+ }
+
+ public function testRedactWhenVisibleEdgesThenKeepsBothEnds(): void
+ {
+ /** @Given a structured logger keeping the head and the tail of the phone */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'contact-service')
+ ->withRedactions(
+ VisibleEdgesRedaction::from(
+ mask: Mask::proportional(),
+ fields: ['phone'],
+ visiblePrefixLength: 5,
+ visibleSuffixLength: 4
+ )
+ )
+ ->build();
+
+ /** @When logging with a phone field */
+ $logger->info(message: 'contact.updated', context: ['phone' => '+5511999887766']);
+
+ /** @Then both edges of the value stay visible and the middle is masked */
+ self::assertStringContainsString('data={"phone":"+5511*****7766"}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenWordwiseThenMasksEachWordApart(): void
+ {
+ /** @Given a structured logger masking each word of a name with a fixed-width mask */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'user-service')
+ ->withRedactions(
+ WordwiseRedaction::from(
+ mask: Mask::fixed(length: 3),
+ fields: ['name'],
+ visiblePrefixLength: 2
+ )
+ )
+ ->build();
+
+ /** @When logging with a name made of several words */
+ $logger->info(message: 'user.created', context: ['name' => 'Gustavo Freze']);
+
+ /** @Then every word keeps its own visible prefix */
+ self::assertStringContainsString('data={"name":"Gu*** Fr***"}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenFullMaskThenHidesTheValueLength(): void
+ {
+ /** @Given a structured logger masking two fields of different lengths with a fixed mask */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'audit-service')
+ ->withRedactions(
+ FullMaskRedaction::from(mask: Mask::fixed(length: 4), fields: ['user_agent', 'session_id'])
+ )
+ ->build();
+
+ /** @When logging with both fields and an unrelated one */
+ $logger->info(message: 'request.received', context: [
+ 'user_agent' => 'Mozilla/5.0',
+ 'session_id' => 'abc',
+ 'route' => '/v1/users'
+ ]);
+
+ /** @Then both masks have the same width regardless of the original length */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('"user_agent":"****"', $output);
+ self::assertStringContainsString('"session_id":"****"', $output);
+ self::assertStringContainsString('"route":"/v1/users"', $output);
+ }
+
+ public function testRedactWhenValueIsNumericThenMasksItAsText(): void
+ {
+ /** @Given a structured logger with document redaction */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'kyc-service')
+ ->withRedactions(DocumentRedaction::default())
+ ->build();
+
+ /** @When logging with a document decoded from JSON as an integer */
+ $logger->info(message: 'kyc.verified', context: ['document' => 12345678900, 'amount' => 100]);
+
+ /** @Then the numeric document is masked as text and the unrelated number is preserved */
+ self::assertStringContainsString('data={"document":"********900","amount":100}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenListOfValuesThenMasksEachElement(): void
+ {
+ /** @Given a structured logger with phone redaction */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'notification-service')
+ ->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 4))
+ ->build();
+
+ /** @When logging with a sensitive field holding a list of values */
+ $logger->info(message: 'sms.queued', context: ['phone' => ['+5511999887766', '+5521988776655']]);
+
+ /** @Then every element of the list is masked */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('data={"phone":["**********7766","**********6655"]}', $output);
+ self::assertStringNotContainsString('+5511999887766', $output);
+ }
+
+ public function testRedactWhenValueIsNullThenLeavesItUntouched(): void
+ {
+ /** @Given a structured logger with document redaction */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'kyc-service')
+ ->withRedactions(DocumentRedaction::default())
+ ->build();
+
+ /** @When logging with a sensitive field holding no value */
+ $logger->info(message: 'kyc.skipped', context: ['document' => null, 'status' => 'pending']);
+
+ /** @Then the absent value is written as is */
+ self::assertStringContainsString('data={"document":null,"status":"pending"}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenWildcardFieldThenMasksEveryMatch(): void
+ {
+ /** @Given a structured logger with a name redaction targeting a field name pattern */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'messaging-service')
+ ->withRedactions(NameRedaction::from(fields: ['*_name'], visiblePrefixLength: 2))
+ ->build();
+
+ /** @When logging with a matching field, a field that does not match, and a list */
+ $logger->info(message: 'message.dispatched', context: [
+ 'client_name' => 'João Silva',
+ 'name' => 'Maria',
+ 'tags' => ['first', 'retry']
+ ]);
+
+ /** @Then only the matching field is masked */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('"client_name":"Jo********"', $output);
+ self::assertStringContainsString('"name":"Maria"', $output);
+
+ /** @And the numeric keys of the list are matched against the pattern without failing */
+ self::assertStringContainsString('"tags":["first","retry"]', $output);
+ }
+
+ public function testRedactWhenScopedThenMasksOnlyInsideTheScope(): void
+ {
+ /** @Given a structured logger masking the value field only under a document parent */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment-service')
+ ->withRedactions(
+ ScopedRedaction::under(
+ parent: 'document',
+ redaction: DocumentRedaction::from(fields: ['value'], visibleSuffixLength: 2)
+ )
+ )
+ ->build();
+
+ /** @When logging with the same field name inside and outside the scope */
+ $logger->info(message: 'payment.created', context: [
+ 'document' => ['type' => 'cpf', 'value' => '12345678901'],
+ 'metadata' => ['value' => 'operational-marker'],
+ 'charge' => ['document' => ['value' => '98765432100']]
+ ]);
+
+ /** @Then the value inside the scope is masked */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('"document":{"type":"cpf","value":"*********01"}', $output);
+
+ /** @And the value outside the scope is preserved */
+ self::assertStringContainsString('"metadata":{"value":"operational-marker"}', $output);
+
+ /** @And the scope is honored at any depth */
+ self::assertStringContainsString('"charge":{"document":{"value":"*********00"}}', $output);
+ }
+
+ public function testRedactWhenPatternMatchesTextThenMasksTheMatch(): void
+ {
+ /** @Given a structured logger masking eleven-digit runs found in any text */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'error-service')
+ ->withRedactions(PatternRedaction::from(pattern: '/\d{11}/', replacement: '[REDACTED]'))
+ ->build();
+
+ /** @When logging an exception message and a URI carrying the document */
+ $logger->error(message: 'request.failed', context: [
+ 'message' => 'Document 12345678901 is invalid.',
+ 'attempts' => 3,
+ 'nested' => ['uri' => '/clients/12345678901']
+ ]);
+
+ /** @Then the match inside the free text is replaced */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('"message":"Document [REDACTED] is invalid."', $output);
+
+ /** @And the match inside the nested URI is replaced */
+ self::assertStringContainsString('"nested":{"uri":"/clients/[REDACTED]"}', $output);
+
+ /** @And values that are not text are left alone */
+ self::assertStringContainsString('"attempts":3', $output);
+ }
+
+ public function testRedactWhenAllowListThenMasksEveryFieldOutsideIt(): void
+ {
+ /** @Given a structured logger allowing only the city and the state under the address */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment-service')
+ ->withRedactions(
+ ScopedRedaction::under(
+ parent: 'address',
+ redaction: AllowedFieldsRedaction::from(mask: Mask::fixed(length: 3), fields: ['city', 'state'])
+ )
+ )
+ ->build();
+
+ /** @When logging with an address and a sibling field outside the scope */
+ $logger->info(message: 'payment.created', context: [
+ 'address' => ['city' => 'São Paulo', 'state' => 'BR-SP', 'street' => 'Rua Example'],
+ 'name' => 'Maria'
+ ]);
+
+ /** @Then the allowed fields survive and everything else in the scope is masked */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('"city":"São Paulo","state":"BR-SP","street":"***"', $output);
+
+ /** @And fields outside the scope are untouched */
+ self::assertStringContainsString('"name":"Maria"', $output);
+ }
+
+ public function testRedactWhenScopeHoldsScalarThenLeavesItUntouched(): void
+ {
+ /** @Given a structured logger masking the value field only under a document parent */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment-service')
+ ->withRedactions(
+ ScopedRedaction::under(
+ parent: 'document',
+ redaction: DocumentRedaction::from(fields: ['value'], visibleSuffixLength: 2)
+ )
+ )
+ ->build();
+
+ /** @When logging with the scope parent holding a scalar instead of a sub payload */
+ $logger->info(message: 'payment.created', context: ['document' => 'plain-value']);
+
+ /** @Then the scalar is written as is */
+ self::assertStringContainsString('data={"document":"plain-value"}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenCommonSecretsThenMasksEverySecretField(): void
+ {
+ /** @Given a structured logger with the common secrets redaction */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'auth-service')
+ ->withRedactions(FullMaskRedaction::commonSecrets())
+ ->build();
+
+ /** @When logging with one field per secret naming convention */
+ $logger->info(message: 'auth.check', context: [
+ 'user_id' => 'u-1',
+ 'credentials' => 'basic',
+ 'access_token' => 'at-1',
+ 'authorization' => 'Bearer x',
+ 'client_secret' => 'cs-1',
+ 'stripe_api_key' => 'ak-1',
+ 'rsa_private_key' => 'pk-1',
+ 'current_password' => 'pw-1'
+ ]);
+
+ /** @Then every secret field is fully masked */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('"credentials":"********"', $output);
+ self::assertStringContainsString('"access_token":"********"', $output);
+ self::assertStringContainsString('"authorization":"********"', $output);
+ self::assertStringContainsString('"client_secret":"********"', $output);
+ self::assertStringContainsString('"stripe_api_key":"********"', $output);
+ self::assertStringContainsString('"rsa_private_key":"********"', $output);
+ self::assertStringContainsString('"current_password":"********"', $output);
+
+ /** @And fields outside the convention are preserved */
+ self::assertStringContainsString('"user_id":"u-1"', $output);
+ }
+
+ public function testRedactWhenFieldsRemovedThenDropsThemFromTheEntry(): void
+ {
+ /** @Given a structured logger dropping the trace and every token field */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'error-service')
+ ->withRedactions(RemovedFieldsRedaction::from(fields: ['trace', '*_token']))
+ ->build();
+
+ /** @When logging with those fields at the root and nested */
+ $logger->error(message: 'request.failed', context: [
+ 'message' => 'boom',
+ 'trace' => '#0 stack',
+ 'nested' => ['access_token' => 'at-1', 'id' => '7']
+ ]);
+
+ /** @Then the dropped fields leave no trace in the entry */
+ self::assertStringContainsString('data={"message":"boom","nested":{"id":"7"}}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenSeparatorsKeptThenPreservesPunctuation(): void
+ {
+ /** @Given a structured logger masking a postal code while keeping its separators */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment-service')
+ ->withRedactions(
+ VisibleEdgesRedaction::from(
+ mask: Mask::preservingSeparators(),
+ fields: ['postal_code', 'reference', 'coordinates'],
+ visiblePrefixLength: 3
+ )
+ )
+ ->build();
+
+ /** @When logging with a formatted postal code, an accented reference, and a coordinate */
+ $logger->info(message: 'address.saved', context: [
+ 'postal_code' => '01310-100',
+ 'reference' => 'Rua Açaí, 42',
+ 'coordinates' => 'S 23° 33'
+ ]);
+
+ /** @Then digits are hidden and the separator survives */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('"postal_code":"013**-***"', $output);
+
+ /** @And accented letters are hidden as single characters */
+ self::assertStringContainsString('"reference":"Rua ****, **"', $output);
+
+ /** @And symbols outside the letter and digit categories survive */
+ self::assertStringContainsString('"coordinates":"S 2*° **"', $output);
+ }
+
+ public function testRedactWhenMapOfValuesThenDescendsInsteadOfMasking(): void
+ {
+ /** @Given a structured logger with document redaction */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'kyc-service')
+ ->withRedactions(DocumentRedaction::default())
+ ->build();
+
+ /** @When logging with a sensitive field holding a map instead of a value */
+ $logger->info(message: 'kyc.verified', context: [
+ 'document' => ['type' => 'cnpj', 'value' => '12345678000199', 'issued_at' => '2020-01-01']
+ ]);
+
+ /** @Then the map is descended into, so the fields that carry their own meaning survive */
+ self::assertStringContainsString(
+ 'data={"document":{"type":"cnpj","value":"12345678000199","issued_at":"2020-01-01"}}',
+ $this->logStream->contents()
+ );
+ }
+
+ public function testRedactWhenPatternIsInvalidThenThrowsInvalidPattern(): void
+ {
+ /** @Then an exception describing the rejected pattern is raised */
+ $this->expectException(InvalidRedactionPattern::class);
+
+ /** @And the message names the offending pattern */
+ $this->expectExceptionMessage('Pattern is not a valid regular expression: /[unclosed/.');
+
+ /** @When building a redaction from a pattern the engine cannot compile */
+ PatternRedaction::from(pattern: '/[unclosed/', replacement: '[REDACTED]');
+ }
+
+ public function testRedactWhenPrefixIsNegativeThenThrowsNegativeLength(): void
+ {
+ /** @Then an exception describing the rejected configuration is raised */
+ $this->expectException(NegativeVisibleLength::class);
+
+ /** @And the message names both visible lengths */
+ $this->expectExceptionMessage('Visible length cannot be negative, got prefix -1 and suffix 4.');
+
+ /** @When building a redaction that leaves a negative number of leading characters visible */
+ VisibleEdgesRedaction::from(
+ mask: Mask::proportional(),
+ fields: ['phone'],
+ visiblePrefixLength: -1,
+ visibleSuffixLength: 4
+ );
+ }
+
+ public function testRedactWhenSuffixIsNegativeThenThrowsNegativeLength(): void
+ {
+ /** @Then an exception describing the rejected configuration is raised */
+ $this->expectException(NegativeVisibleLength::class);
+
+ /** @And the message names both visible lengths */
+ $this->expectExceptionMessage('Visible length cannot be negative, got prefix 2 and suffix -1.');
+
+ /** @When building a redaction that leaves a negative number of trailing characters visible */
+ VisibleEdgesRedaction::from(
+ mask: Mask::proportional(),
+ fields: ['phone'],
+ visiblePrefixLength: 2,
+ visibleSuffixLength: -1
+ );
+ }
+
+ public function testRedactWhenWordwiseHasNoPrefixThenMasksEveryWordHead(): void
+ {
+ /** @Given a structured logger masking each word down to its last character */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'user-service')
+ ->withRedactions(
+ WordwiseRedaction::from(
+ mask: Mask::proportional(),
+ fields: ['name'],
+ visibleSuffixLength: 1
+ )
+ )
+ ->build();
+
+ /** @When logging with a name made of several words */
+ $logger->info(message: 'user.created', context: ['name' => 'Gustavo Freze']);
+
+ /** @Then every word keeps only its trailing character */
+ self::assertStringContainsString('data={"name":"******o ****e"}', $this->logStream->contents());
+ }
+
+ public function testRedactWhenAllowListAndNullValueThenLeavesItUntouched(): void
+ {
+ /** @Given a structured logger allowing only the city field */
+ $logger = StructuredLogger::create()
+ ->withStream(stream: $this->logStream->handle())
+ ->withComponent(component: 'payment-service')
+ ->withRedactions(AllowedFieldsRedaction::from(mask: Mask::fixed(length: 3), fields: ['city']))
+ ->build();
+
+ /** @When logging with an absent value and a nested allowed field */
+ $logger->info(message: 'address.saved', context: [
+ 'city' => 'São Paulo',
+ 'street' => null,
+ 'nested' => ['city' => 'Rio', 'label' => 'x']
+ ]);
+
+ /** @Then the absent value is written as is and the nested allow list still applies */
+ $output = $this->logStream->contents();
+
+ self::assertStringContainsString('"city":"São Paulo","street":null', $output);
+ self::assertStringContainsString('"nested":{"city":"Rio","label":"***"}', $output);
+ }
+}