feat(code): add type hints enforcement hook (#111)

Add PHP type hints checker that:
- Checks for declare(strict_types=1)
- Validates parameter type hints
- Validates return type hints
- Validates property type hints
- Supports --auto-fix for automatic corrections

Can be used as a PostToolUse hook for PHP files.

Migrated from core-claude PR #55.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-02 07:31:38 +00:00 committed by GitHub
parent e3259257ac
commit 20359e22d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 253 additions and 0 deletions

View file

@ -0,0 +1,239 @@
<?php
if ($argc < 2) {
echo "Usage: php " . $argv[0] . " <file_path> [--auto-fix]\n";
exit(1);
}
$filePath = $argv[1];
$autoFix = isset($argv[2]) && $argv[2] === '--auto-fix';
if (!file_exists($filePath)) {
echo "Error: File not found at " . $filePath . "\n";
exit(1);
}
$content = file_get_contents($filePath);
$tokens = token_get_all($content);
function checkStrictTypes(array $tokens, string $filePath, bool $autoFix, string &$content): void
{
$hasStrictTypes = false;
foreach ($tokens as $i => $token) {
if (!is_array($token) || $token[0] !== T_DECLARE) {
continue;
}
// Found a declare statement, now check if it's strict_types=1
$next = findNextMeaningfulToken($tokens, $i + 1);
if ($next && is_string($tokens[$next]) && $tokens[$next] === '(') {
$next = findNextMeaningfulToken($tokens, $next + 1);
if ($next && is_array($tokens[$next]) && $tokens[$next][0] === T_STRING && $tokens[$next][1] === 'strict_types') {
$next = findNextMeaningfulToken($tokens, $next + 1);
if ($next && is_string($tokens[$next]) && $tokens[$next] === '=') {
$next = findNextMeaningfulToken($tokens, $next + 1);
if ($next && is_array($tokens[$next]) && $tokens[$next][0] === T_LNUMBER && $tokens[$next][1] === '1') {
$hasStrictTypes = true;
break;
}
}
}
}
}
if (!$hasStrictTypes) {
fwrite(STDERR, "⚠ Line 1: Missing declare(strict_types=1)\n");
if ($autoFix) {
$content = str_replace('<?php', "<?php\n\ndeclare(strict_types=1);", $content);
file_put_contents($filePath, $content);
fwrite(STDERR, "✓ Auto-fixed: Added declare(strict_types=1)\n");
}
}
}
function findNextMeaningfulToken(array $tokens, int $index): ?int
{
for ($i = $index; $i < count($tokens); $i++) {
if (is_array($tokens[$i]) && in_array($tokens[$i][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) {
continue;
}
return $i;
}
return null;
}
function checkParameterTypeHints(array $tokens): void
{
foreach ($tokens as $i => $token) {
if (!is_array($token) || $token[0] !== T_FUNCTION) {
continue;
}
$parenStart = findNextMeaningfulToken($tokens, $i + 1);
if (!$parenStart || !is_array($tokens[$parenStart]) || $tokens[$parenStart][0] !== T_STRING) {
continue; // Not a standard function definition, maybe an anonymous function
}
$parenStart = findNextMeaningfulToken($tokens, $parenStart + 1);
if (!$parenStart || !is_string($tokens[$parenStart]) || $tokens[$parenStart] !== '(') {
continue;
}
$paramIndex = $parenStart + 1;
while (true) {
$nextParam = findNextMeaningfulToken($tokens, $paramIndex);
if (!$nextParam || (is_string($tokens[$nextParam]) && $tokens[$nextParam] === ')')) {
break; // End of parameter list
}
// We are at the start of a parameter declaration. It could be a type hint or the variable itself.
$currentToken = $tokens[$nextParam];
if (is_array($currentToken) && $currentToken[0] === T_VARIABLE) {
// This variable has no type hint.
fwrite(STDERR, "⚠ Line {$currentToken[2]}: Parameter {$currentToken[1]} has no type hint\n");
}
// Move to the next parameter
$comma = findNextToken($tokens, $nextParam, ',');
$closingParen = findNextToken($tokens, $nextParam, ')');
if ($comma !== null && $comma < $closingParen) {
$paramIndex = $comma + 1;
} else {
break; // No more commas, so no more parameters
}
}
}
}
function findNextToken(array $tokens, int $index, $tokenType): ?int
{
for ($i = $index; $i < count($tokens); $i++) {
if (is_string($tokens[$i]) && $tokens[$i] === $tokenType) {
return $i;
}
if (is_array($tokens[$i]) && $tokens[$i][0] === $tokenType) {
return $i;
}
}
return null;
}
function checkReturnTypeHints(array $tokens, string $filePath, bool $autoFix, string &$content): void
{
foreach ($tokens as $i => $token) {
if (!is_array($token) || $token[0] !== T_FUNCTION) {
continue;
}
$functionNameToken = findNextMeaningfulToken($tokens, $i + 1);
if (!$functionNameToken || !is_array($tokens[$functionNameToken]) || $tokens[$functionNameToken][0] !== T_STRING) {
continue; // Not a standard function definition
}
$functionName = $tokens[$functionNameToken][1];
if (in_array($functionName, ['__construct', '__destruct'])) {
continue; // Constructors and destructors do not have return types
}
$parenStart = findNextMeaningfulToken($tokens, $functionNameToken + 1);
if (!$parenStart || !is_string($tokens[$parenStart]) || $tokens[$parenStart] !== '(') {
continue;
}
$parenEnd = findNextToken($tokens, $parenStart + 1, ')');
if ($parenEnd === null) {
continue; // Malformed function
}
$nextToken = findNextMeaningfulToken($tokens, $parenEnd + 1);
if (!$nextToken || !(is_string($tokens[$nextToken]) && $tokens[$nextToken] === ':')) {
fwrite(STDERR, "⚠ Line {$tokens[$functionNameToken][2]}: Method {$functionName}() has no return type\n");
if ($autoFix) {
// Check if the function has a return statement
$bodyStart = findNextToken($tokens, $parenEnd + 1, '{');
if ($bodyStart !== null) {
$bodyEnd = findMatchingBrace($tokens, $bodyStart);
if ($bodyEnd !== null) {
$hasReturn = false;
for ($j = $bodyStart; $j < $bodyEnd; $j++) {
if (is_array($tokens[$j]) && $tokens[$j][0] === T_RETURN) {
$hasReturn = true;
break;
}
}
if (!$hasReturn) {
$offset = 0;
for ($k = 0; $k < $parenEnd; $k++) {
if (is_array($tokens[$k])) {
$offset += strlen($tokens[$k][1]);
} else {
$offset += strlen($tokens[$k]);
}
}
$original = ')';
$replacement = ') : void';
$content = substr_replace($content, $replacement, $offset, strlen($original));
file_put_contents($filePath, $content);
fwrite(STDERR, "✓ Auto-fixed: Added : void return type to {$functionName}()\n");
}
}
}
}
}
}
}
function findMatchingBrace(array $tokens, int $startIndex): ?int
{
$braceLevel = 0;
for ($i = $startIndex; $i < count($tokens); $i++) {
if (is_string($tokens[$i]) && $tokens[$i] === '{') {
$braceLevel++;
} elseif (is_string($tokens[$i]) && $tokens[$i] === '}') {
$braceLevel--;
if ($braceLevel === 0) {
return $i;
}
}
}
return null;
}
function checkPropertyTypeHints(array $tokens): void
{
foreach ($tokens as $i => $token) {
if (!is_array($token) || !in_array($token[0], [T_PUBLIC, T_PROTECTED, T_PRIVATE, T_VAR])) {
continue;
}
$nextToken = findNextMeaningfulToken($tokens, $i + 1);
if ($nextToken && is_array($tokens[$nextToken]) && $tokens[$nextToken][0] === T_STATIC) {
$nextToken = findNextMeaningfulToken($tokens, $nextToken + 1);
}
if ($nextToken && is_array($tokens[$nextToken]) && $tokens[$nextToken][0] === T_VARIABLE) {
// This is a property without a type hint
fwrite(STDERR, "⚠ Line {$tokens[$nextToken][2]}: Property {$tokens[$nextToken][1]} has no type hint\n");
}
}
}
function tokensToCode(array $tokens): string
{
$code = '';
foreach ($tokens as $token) {
if (is_array($token)) {
$code .= $token[1];
} else {
$code .= $token;
}
}
return $code;
}
checkStrictTypes($tokens, $filePath, $autoFix, $content);
checkParameterTypeHints($tokens);
checkReturnTypeHints($tokens, $filePath, $autoFix, $content);
checkPropertyTypeHints($tokens);

View file

@ -0,0 +1,14 @@
#!/bin/bash
# Enforce strict type hints in PHP files.
read -r input
FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty')
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then
php "${SCRIPT_DIR}/check-types.php" "$FILE_PATH"
fi
# Pass through the input
echo "$input"