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
8 changes: 3 additions & 5 deletions docs/advanced/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,13 @@ TypePHP solves this using `TypePHPPrinter` and regex post-processing. Injected g

### Disk Caching

Once transformed, TypePHP saves the resulting code to disk in `sys_get_temp_dir() . '/typephp-cache/'`. On all subsequent requests:
Once transformed, TypePHP saves the resulting code to disk in your configured `cache_dir` (which defaults to `sys_get_temp_dir() . '/typephp-cache/'`). On all subsequent requests:
* AST parsing runs **0 times**.
* PHP's **OPCache** compiles the cached file once into bytecode in RAM.
* Stream file reads execute natively at C-level speed inside Zend Engine.

*(TypePHP's stream wrapper automatically detects and skips intercepting files inside your configured `cache_dir` to prevent infinite loops and double-transformation overhead).*

---

## Typed Arrays and Array Shapes
Expand Down Expand Up @@ -182,10 +184,6 @@ For function-level templates (`@template T`), TypePHP pushes a temporary call fr

---

Here is the updated, brief **Validation Error Messages and Trace Attribution** section for `docs/architecture/how-it-works.md`:

---

## Validation Error Messages and Trace Attribution

When a type contract fails, TypePHP constructs informative error messages through a 3-tier pipeline:
Expand Down
58 changes: 36 additions & 22 deletions docs/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,17 @@ return [

/*
|--------------------------------------------------------------------------
| Enable Caching
|--------------------------------------------------------------------------
| Pre-transforms and caches PHP files on disk for maximum speed.
| Enable Caching & Cache Directory
|--------------------------------------------------------------------------
| Pre-transforms and caches PHP files on disk for OPcache optimization.
|
| 'cache_dir' determines where these files are stored. By default (null),
| it uses your system's temp directory. You can change this to a path
| inside your project (e.g., __DIR__ . '/storage/framework/typephp').
| TypePHP automatically protects this directory from being double-transformed.
*/
'cache' => true,
'cache_dir' => null,

/*
|--------------------------------------------------------------------------
Expand Down Expand Up @@ -90,7 +96,7 @@ return [
| Included Paths & Whitelisting
|--------------------------------------------------------------------------
| Globs or specific file paths that should be intercepted and type-checked.
| Note: you can just specify "*" glob pattern to include all files including in the root folder.
| Note: you can just specify "**" glob pattern to include all files including in the root folder.
*/
'include' => [
'src/**',
Expand Down Expand Up @@ -129,34 +135,42 @@ Key options explained:
| **`'magic_properties'`** | `true` | Enforces class-level `@property`, `@property-read`, and `@property-write` annotations on dynamic assignments (`__set`). |
| **`'magic_methods'`** | `true` | Enforces class-level `@method` annotations on dynamic method calls (`__call` / `__callStatic`). |
| **`'respect_ignore_tags'`** | `true` | Respects `@typephp-ignore` and `@typephp-ignore-file` tags. Set to `false` in CI/CD to force audit checks. |
| **`'cache'`** | `true` | Pre-transforms and caches PHP files on disk (`typephp-cache/`) for OPcache optimization. |
| **`'cache'`** | `true` | Pre-transforms and caches PHP files on disk. |
| **`'cache_dir'`** | `null` | Custom path to store cached files. Defaults to system temporary directory (`sys_get_temp_dir() . '/typephp-cache/'`). |

---

## Inline Variable Categories Reference (`inline_vars`)

How each `inline_vars` toggle maps to PHPDoc type annotations:
*(... rest of the file remains exactly the same ...)*
```

| Config Option | Covered PHPDoc Types | Examples |
| :--- | :--- | :--- |
| **`'scalars'`** | Primitive & Refined Scalars | `int`, `string`, `bool`, `positive-int`, `non-empty-string`, `truthy` |
| **`'objects'`** | Class Instances & Bare Class References | `User`, `stdClass`, `class-string`, `interface-string`, `enum-string` |
| **`'generics'`** | Template & Bound Types | `Collection<User>`, `Producer<T>`, `class-string<T>` |
| **`'arrays'`** | All Arrays, Shapes, & Lists | `array{id: int}`, `int[]`, `User[]`, `list<string>`, `array<string, int>` |
| **`'callables'`** | Callables & Closures | `callable`, `Closure`, `callable(int): string`, `static-closure` |
| **`'properties'`** | Class Property Writes | `$this->id = 1`, `UserProfile::$username = 'Alice'` |
---

### 2. `docs/advanced/how-it-works.md`

*(Find the "Zero Line-Drift Formatting and Caching" section and update the "Disk Caching" part to this:)*

### Important Notes on `inline_vars` Behavior
```markdown
### Disk Caching

* **Inner Structural Types Are Always Validated:** Disabling `'scalars' => false` only turns off standalone scalar assignments (such as `/** @var positive-int $x */`). If `'arrays'` or `'generics'` is enabled, TypePHP **will still validate inner scalar constraints** inside array shapes (`array{id: positive-int}`), lists (`list<positive-int>`), or generic containers (`Collection<positive-int>`) to maintain structural type integrity.
* **Active Generic Instance Prebinding:** Enabling `'generics' => true` allows inline `@var` annotations on object instantiations (such as `/** @var Collection<User> $users */ $users = new Collection();`) to **actively prebind generic template parameters (`T = User`)** directly to that object instance in `WeakMap` memory. Every subsequent method call on that instance (`$users->add()`, `$users->get()`) will enforce `T = User`!
Once transformed, TypePHP saves the resulting code to disk in your configured `cache_dir` (which defaults to `sys_get_temp_dir() . '/typephp-cache/'`). On all subsequent requests:
* AST parsing runs **0 times**.
* PHP's **OPCache** compiles the cached file once into bytecode in RAM.
* Stream file reads execute natively at C-level speed inside Zend Engine.

*(TypePHP's stream wrapper automatically detects and skips intercepting files inside your configured `cache_dir` to prevent infinite loops and double-transformation overhead).*
```

---

## Pattern Specificity Rules
### 3. `docs/troubleshooting.md`

*(Find the "How do I know if TypePHP is actively transforming a file?" question and update the answer:)*

If a file matches both an `include` rule and an `exclude` rule, TypePHP compares pattern lengths:
```markdown

* **Specific Whitelist Wins:** `'vendor/my-org/package/**'` (length 25) takes precedence over `'vendor/**'` (length 8).
* **Single File Override:** `'src/LegacyFile.php'` (length 22) takes precedence over `'src/**'` (length 6).
* **Tie-Breaker:** If pattern lengths are equal, `exclude` takes precedence to ensure application safety.
```

---
All docs are updated! What's our next target? Should we start refactoring validators, expanding the Extension System, or write a quick web-framework mock test?
10 changes: 9 additions & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,15 @@ If TypePHP is not enforcing contracts on a specific file or method, check the fo
You can verify that a file is being intercepted and transformed in two ways:

1. **Intentionally Trigger an Error:** Pass an invalid argument (such as a negative integer to a `positive-int` parameter). If a `TypePHP\Exception\TypeError` is thrown, TypePHP is active.
2. **Inspect the Cache Directory:** Look inside your system temporary directory (`sys_get_temp_dir() . '/typephp-cache/'`). You will see transformed PHP files containing injected `RuntimeTypeChecker` calls.
2. **Inspect the Cache Directory:** Look inside your configured `cache_dir` (if undefined, this defaults to your system temporary directory: `sys_get_temp_dir() . '/typephp-cache/'`). You will see transformed PHP files containing injected `RuntimeTypeChecker` calls.

---

### Why are files inside my custom `cache_dir` not being intercepted?

If you configured a custom `cache_dir` inside your project directory (e.g., `__DIR__ . '/storage/typephp'`) and set your include paths to `['**']`, you might wonder why the cache files aren't being transformed.

**This is a built-in safety mechanism.** TypePHP automatically detects your `cache_dir` and unconditionally excludes it from its internal `StreamWrapper` and `FileFilter`. This prevents catastrophic infinite loops and double-parsing overhead that would occur if TypePHP tried to intercept and transform its own cached files.

---

Expand Down
8 changes: 7 additions & 1 deletion src/Command/ConfigInitCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,18 @@ private static function getTemplate(): string

/*
|--------------------------------------------------------------------------
| Enable Caching
| Enable Caching & Cache Directory
|--------------------------------------------------------------------------
| When enabled, transformed PHP files are cached on disk for speed.
| Set to false to run AST transformations purely in RAM (php://memory).
|
| 'cache_dir' determines where these files are stored. By default (null),
| it uses your system's temp directory. You can change this to a path
| inside your project, e.g., __DIR__ . '/storage/framework/typephp'.
| TypePHP will automatically protect this directory from being re-transformed.
*/
'cache' => true,
'cache_dir' => null,

/*
|--------------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions src/Contract/FileFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace TypePHP\Contract;

use TypePHP\Internal\CacheManager;
use TypePHP\Internal\Config;

/**
Expand All @@ -28,6 +29,11 @@ public static function isFileExcluded(string|false|null $fileName): bool
return true;
}

$normalizedCacheDir = rtrim(str_replace('\\', '/', CacheManager::getCacheDir()), '/') . '/';
if (str_starts_with($normalizedPath, $normalizedCacheDir)) {
return true;
}

$config = Config::get();
/** @var array<mixed> $includes */
$includes = \is_array($config['include'] ?? null) ? $config['include'] : ['**'];
Expand Down
36 changes: 31 additions & 5 deletions src/Internal/StreamWrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ public static function transformSource(string $source, string $filePath = ''): s
return $source;
}

$originalLineCount = substr_count($source, "\n");

$parser = (new ParserFactory())->createForNewestSupportedVersion();

try {
Expand Down Expand Up @@ -150,9 +152,26 @@ public static function transformSource(string $source, string $filePath = ''): s
$printer = new TypePHPPrinter();
$transformed = $printer->printFormatPreserving($newStmts, $oldStmts, $oldTokens);

// Critical: Remove the newline and indentation preceding any injected statement.
$transformed = preg_replace('/[ \t]*\r?\n[ \t]*\/\*__TYPEPHP_INJECTED__\*\//', ' /*__TYPEPHP_INJECTED__*/', $transformed) ?? $transformed;
$transformed = str_replace('/*__TYPEPHP_INJECTED__*/', '', $transformed);
$transformed = preg_replace('/[ \t]*\r?\n[ \t]*\/\*__TYPEPHP_INJECTED_START__\*\//', ' /*__TYPEPHP_INJECTED_START__*/', $transformed) ?? $transformed;

$transformedLineCount = substr_count($transformed, "\n");
$drift = $transformedLineCount - $originalLineCount;

if ($drift > 0) {
$transformed = preg_replace('/[ \t]*\r?\n[ \t]*\{[ \t]*\/\*__TYPEPHP_INJECTED_START__\*\//', ' { /*__TYPEPHP_INJECTED_START__*/', $transformed, $drift, $count1) ?? $transformed;
$drift -= $count1;
}

if ($drift > 0) {
$transformed = preg_replace('/\/\*__TYPEPHP_INJECTED_END__\*\/[ \t]*\r?\n[ \t]*\}/', '/*__TYPEPHP_INJECTED_END__*/ }', $transformed, $drift, $count2) ?? $transformed;
$drift -= $count2;
}

if ($drift > 0) {
$transformed = preg_replace('/\/\*__TYPEPHP_INJECTED_END__\*\/[ \t]*\r?\n[ \t]*/', '/*__TYPEPHP_INJECTED_END__*/ ', $transformed, $drift) ?? $transformed;
}

$transformed = str_replace(['/*__TYPEPHP_INJECTED_START__*/', '/*__TYPEPHP_INJECTED_END__*/'], '', $transformed);

return $transformed;
}
Expand Down Expand Up @@ -486,8 +505,8 @@ private static function isReadOnlyCall(): bool
}

/**
* Determines whether a target PHP file path should be intercepted using Pattern Specificity.
*/
* Determines whether a target PHP file path should be intercepted using Pattern Specificity.
*/
private static function isApplicationFile(string $path, string|false $resolvedPath): bool
{
if (! (bool) (Config::get()['enabled'] ?? true)) {
Expand All @@ -500,13 +519,20 @@ private static function isApplicationFile(string $path, string|false $resolvedPa

$normalizedPath = str_replace('\\', '/', $resolvedPath);

// Prevent parsing TypePHP's own source code
$parentDir = realpath(__DIR__ . '/..');
$libSrcDir = $parentDir !== false ? str_replace('\\', '/', $parentDir) : '';

if ($libSrcDir !== '' && str_starts_with($normalizedPath, $libSrcDir)) {
return false;
}

// Unconditionally prevent double-parsing cached files!
$normalizedCacheDir = rtrim(str_replace('\\', '/', self::$cacheDir), '/') . '/';
if (str_starts_with($normalizedPath, $normalizedCacheDir)) {
return false;
}

$longestIncludeMatch = 0;
foreach (self::$includeRawPatterns as $pattern => $regex) {
if (preg_match($regex, $normalizedPath) === 1) {
Expand Down
4 changes: 2 additions & 2 deletions src/Internal/TypePHPPrinter.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ final class TypePHPPrinter extends Standard
{
/**
* Overrides base node printing to intercept injected statements, squash their
* formatting, and tag them with a unique marker for post-processing.
* formatting, and tag them with unique markers for post-processing.
*/
protected function p(
Node $node,
Expand All @@ -31,7 +31,7 @@ protected function p(
if ($node instanceof Node\Stmt && $node->getAttribute('typephp_injected') === true) {
$output = preg_replace('/\s+/', ' ', trim($output)) ?? $output;

return '/*__TYPEPHP_INJECTED__*/' . $output;
return '/*__TYPEPHP_INJECTED_START__*/' . $output . '/*__TYPEPHP_INJECTED_END__*/';
}

return $output;
Expand Down
50 changes: 39 additions & 11 deletions src/Internal/Visitor/PropertyHookInjector.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,49 @@ public static function process(Node\Stmt\Property $node): void
: 'value';

$checkCall = NodeBuilder::createPropertyCheckCall(new Node\Expr\Variable($paramName), new Node\Expr\Variable('this'), $propertyName);
$paramCheckStmt = new Node\Stmt\Expression(
new Node\Expr\Assign(
new Node\Expr\Variable($paramName),
NodeBuilder::createTernaryThrowExpr($checkCall)
)
);
$paramCheckStmt->setAttribute('typephp_injected', true);

if (\is_array($hook->body)) {
$paramCheckStmt = new Node\Stmt\Expression(
new Node\Expr\Assign(
new Node\Expr\Variable($paramName),
NodeBuilder::createTernaryThrowExpr($checkCall)
)
);
$paramCheckStmt->setAttribute('typephp_injected', true);
array_unshift($hook->body, $paramCheckStmt);
} elseif ($hook->body instanceof Node\Expr) {
$hook->body = [
$paramCheckStmt,
new Node\Stmt\Expression($hook->body),
];
// Bypass php-parser formatting bugs by keeping short hooks as Expressions
$hook->body = new Node\Expr\Ternary(
new Node\Expr\Instanceof_(
new Node\Expr\Assign(
new Node\Expr\Variable('__typephpVal'),
$checkCall
),
new Node\Name('\TypePHP\Internal\ErrorMessage')
),
new Node\Expr\Throw_(
new Node\Expr\StaticCall(
new Node\Name('\TypePHP\Internal\ErrorFactory'),
'prepareException',
[
new Node\Arg(
new Node\Expr\New_(
new Node\Name('\TypePHP\Exception\TypeError'),
[
new Node\Arg(
new Node\Expr\MethodCall(
new Node\Expr\Variable('__typephpVal'),
'getMessage'
)
),
]
)
),
]
)
),
$hook->body // False branch evaluates the original assignment
);
}
}
}
Expand Down
21 changes: 21 additions & 0 deletions tests/Contract/FileFilterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,25 @@

Config::reset();
});

test('unconditionally excludes the configured cache directory even if include pattern is **', function () {
$customCacheDir = getcwd() . '/storage/typephp-cache';

Config::set([
'cache_dir' => $customCacheDir,
'include' => [
'**',
],
'exclude' => [],
]);

$cachedFilePath = str_replace('\\', '/', $customCacheDir . '/v0.1_hash123.php');
$normalFilePath = str_replace('\\', '/', getcwd() . '/app/Models/User.php');

expect(FileFilter::isFileExcluded($cachedFilePath))->toBeTrue()
->and(FileFilter::isFileExcluded($normalFilePath))->toBeFalse()
;

Config::reset();
});
});
Loading
Loading