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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
607 changes: 607 additions & 0 deletions .claude/hooks/php-ordering-conformance.py

Large diffs are not rendered by default.

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

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

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

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

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

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

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

MAX_ERRORS_REPORTED = 30


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


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

line: int
path: str
message: str

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


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

path: str
text: str


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


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


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

if in_fence or "|" in line:
continue

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


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


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


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


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

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

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


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


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


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

if not violations:
return 0

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

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


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

# Code style

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

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

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

*.php text diff=php

# Mantém os scripts de tooling do Claude fora da estatística de linguagem do GitHub
/.claude/**/*.py linguist-vendored

# Dev-only, excluded from the Packagist tarball
/.github export-ignore
/tests export-ignore
Expand Down
4 changes: 4 additions & 0 deletions .claude/skills/tiny-blocks-create/assets/config/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,7 @@ infection.log
Thumbs.db
.DS_Store
Desktop.ini

# Cache dos hooks Python do .claude
__pycache__/
*.pyc
2 changes: 2 additions & 0 deletions .claude/skills/tiny-blocks-create/assets/config/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"infection/infection": "^0.33",
"phpstan/phpstan": "^2.2",
"phpunit/phpunit": "^13.1",
"slevomat/coding-standard": "^8.30",
"squizlabs/php_codesniffer": "^4.0"
},
"minimum-stability": "stable",
Expand All @@ -43,6 +44,7 @@
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true,
"ergebnis/composer-normalize": true,
"infection/extension-installer": true
},
Expand Down
88 changes: 87 additions & 1 deletion .claude/skills/tiny-blocks-create/assets/config/phpcs.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,93 @@
<?xml version="1.0"?>
<ruleset name="tiny-blocks">
<description>Code style for the tiny-blocks library.</description>
<rule ref="PSR12"/>

<file>src</file>
<file>tests</file>

<rule ref="PSR12"/>

<rule ref="Squiz.Arrays.ArrayBracketSpacing"/>
<rule ref="Squiz.ControlStructures.ControlSignature"/>
<rule ref="Squiz.ControlStructures.ElseIfDeclaration"/>
<rule ref="Squiz.Formatting.OperatorBracket"/>
<rule ref="Squiz.Functions.FunctionDeclarationArgumentSpacing">
<properties>
<property name="equalsSpacing" value="1"/>
</properties>
</rule>
<rule ref="Squiz.Scope.MethodScope"/>
<rule ref="Squiz.WhiteSpace.ControlStructureSpacing"/>
<rule ref="Squiz.WhiteSpace.FunctionSpacing">
<properties>
<property name="spacing" value="1"/>
<property name="spacingBeforeFirst" value="0"/>
<property name="spacingAfterLast" value="0"/>
</properties>
</rule>
<rule ref="Squiz.WhiteSpace.SuperfluousWhitespace"/>

<rule ref="Generic.Arrays.DisallowLongArraySyntax"/>
<rule ref="Generic.CodeAnalysis.EmptyStatement"/>
<rule ref="Generic.ControlStructures.InlineControlStructure"/>
<rule ref="Generic.Formatting.DisallowMultipleStatements"/>
<rule ref="Generic.Metrics.CyclomaticComplexity">
<properties>
<property name="complexity" value="10"/>
<property name="absoluteComplexity" value="20"/>
</properties>
</rule>
<rule ref="Generic.Metrics.NestingLevel"/>
<rule ref="Generic.NamingConventions.ConstructorName"/>
<rule ref="Generic.PHP.DeprecatedFunctions"/>
<rule ref="Generic.PHP.LowerCaseKeyword"/>
<rule ref="Generic.Strings.UnnecessaryStringConcat"/>

<rule ref="PSR2.Classes.PropertyDeclaration"/>
<rule ref="PSR2.Files.EndFileNewline"/>
<rule ref="PSR2.Methods.MethodDeclaration"/>

<rule ref="SlevomatCodingStandard.Classes.ClassStructure">
<properties>
<property name="groups" type="array">
<element value="uses"/>
<element value="public constants"/>
<element value="protected constants"/>
<element value="private constants"/>
<element value="enum cases"/>
<element value="public properties, public static properties"/>
<element value="protected properties, protected static properties"/>
<element value="private properties, private static properties"/>
<element value="constructor, destructor"/>
<element value="all public methods, all protected methods, all private methods, static constructors"/>
<element value="magic methods"/>
</property>
</properties>
</rule>
<rule ref="SlevomatCodingStandard.Classes.ModernClassNameReference"/>
<rule ref="SlevomatCodingStandard.Complexity.Cognitive"/>
<rule ref="SlevomatCodingStandard.ControlStructures.RequireNullCoalesceOperator"/>
<rule ref="SlevomatCodingStandard.Namespaces.AlphabeticallySortedUses"/>
<rule ref="SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly"/>
<rule ref="SlevomatCodingStandard.Namespaces.UnusedUses">
<properties>
<property name="searchAnnotations" value="true"/>
</properties>
</rule>
<rule ref="SlevomatCodingStandard.Namespaces.UseFromSameNamespace"/>
<rule ref="SlevomatCodingStandard.TypeHints.DeclareStrictTypes">
<properties>
<property name="spacesCountAroundEqualsSign" value="0"/>
</properties>
</rule>
<rule ref="SlevomatCodingStandard.TypeHints.ParameterTypeHint">
<exclude name="SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingTraversableTypeHintSpecification"/>
</rule>
<rule ref="SlevomatCodingStandard.TypeHints.PropertyTypeHint">
<exclude name="SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingTraversableTypeHintSpecification"/>
</rule>
<rule ref="SlevomatCodingStandard.TypeHints.ReturnTypeHint">
<exclude name="SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingTraversableTypeHintSpecification"/>
</rule>
<rule ref="SlevomatCodingStandard.Variables.UnusedVariable"/>
</ruleset>
Loading