Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
0978ec3
docs(testbench): plan correctness and lifecycle audit
binaryfire Aug 9, 2026
9118e9d
Merge branch '0.4' into audit/testbench-correctness-parity-lifecycles
binaryfire Aug 9, 2026
c6bca9c
docs(testbench): correct lifecycle audit plan
binaryfire Aug 10, 2026
5e022ad
fix(testbench): harden worker runtime cloning
binaryfire Aug 10, 2026
84014c3
fix(testbench): validate configuration boundaries
binaryfire Aug 10, 2026
5461a1b
fix(testbench): enforce eager configuration attributes
binaryfire Aug 10, 2026
7a7ad0c
fix(testbench): preserve application lifecycle ownership
binaryfire Aug 10, 2026
285f6f5
fix(testbench): make SQLite file swaps lossless
binaryfire Aug 10, 2026
6eb5f0f
fix(testbench): make console teardown exhaustive
binaryfire Aug 10, 2026
c9eedca
fix(filesystem): avoid traversing directory symlinks
binaryfire Aug 10, 2026
e5ea922
fix(testbench): enforce skeleton file ownership
binaryfire Aug 10, 2026
200f9b6
fix(testbench): propagate skeleton command failures
binaryfire Aug 10, 2026
eac7f26
fix(testbench): publish Workbench links safely
binaryfire Aug 10, 2026
cb072c9
fix(testbench): make route cleanup exact
binaryfire Aug 10, 2026
2f269e9
fix(testbench): own exact migration batches
binaryfire Aug 10, 2026
44de83f
fix(testbench): align path and environment helpers
binaryfire Aug 10, 2026
f84ba3f
fix(testing): complete parallel application cleanup
binaryfire Aug 10, 2026
a62e527
refactor(testbench): remove dead Pest machinery
binaryfire Aug 10, 2026
d82cf99
build(testbench): complete split package dependencies
binaryfire Aug 10, 2026
dd78026
fix(telescope): preserve forked context snapshots
binaryfire Aug 10, 2026
399f4ce
docs(audit): close Testbench lifecycle findings
binaryfire Aug 10, 2026
235b43e
docs(testbench): record completed correctness plan
binaryfire Aug 10, 2026
8a88d19
Merge branch '0.4' into audit/testbench-correctness-parity-lifecycles
binaryfire Aug 10, 2026
bc09812
test(testbench): guard metadata constraint comparisons
binaryfire Aug 10, 2026
4cf397f
Merge branch '0.4' into audit/testbench-correctness-parity-lifecycles
binaryfire Aug 10, 2026
ec63af9
Merge branch '0.4' into audit/testbench-correctness-parity-lifecycles
binaryfire Aug 10, 2026
5b357f2
test(testbench): make vendor unlink failure deterministic
binaryfire Aug 10, 2026
70a5a91
fix(testbench): correct bootstrap state ownership
binaryfire Aug 10, 2026
e07b34c
docs(testbench): record bootstrap ownership fixes
binaryfire Aug 10, 2026
185017d
Merge pull request #501 from hypervel/audit/testbench-correctness-par…
binaryfire Aug 10, 2026
b01e5a6
fix(support): define consistent JSON nesting semantics
binaryfire Aug 11, 2026
071fd79
fix(collections): preserve maximum-depth JSON round trips
binaryfire Aug 11, 2026
7799f7c
fix(filesystem): read JSON at the supported nesting limit
binaryfire Aug 11, 2026
f84ef12
fix(support): preserve Composer JSON file round trips
binaryfire Aug 11, 2026
95fbcb6
fix: preserve framework-owned JSON round trips
binaryfire Aug 11, 2026
7982f86
refactor(concurrency): own serialized closure responses
binaryfire Aug 11, 2026
cae3ec6
fix(validation): validate JSON casts consistently
binaryfire Aug 11, 2026
80375af
fix(database): make Eloquent JSON corruption explicit
binaryfire Aug 11, 2026
2f155e9
fix(database): reject invalid JSON query bindings
binaryfire Aug 11, 2026
c835b9d
fix(database): fail loudly on invalid console JSON
binaryfire Aug 11, 2026
9b68b70
fix(telescope): harden JSON storage and redaction
binaryfire Aug 11, 2026
ae12786
fix(foundation): reject corrupt package manifests
binaryfire Aug 11, 2026
f8fb1d7
docs: add JSON correctness implementation plan
binaryfire Aug 11, 2026
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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,9 @@ Do not add stable callback slots, handler identity comparisons, a register-once
- `src/testbench/src/TestCase.php`
- `src/testbench/src/Concerns/InteractsWithTestCase.php`
- `src/testbench/src/Concerns/InteractsWithPHPUnit.php`
- `tests/Foundation/Testing/Concerns/InteractsWithTestCaseTest.php`
- `tests/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycleTest.php`
- `tests/Testbench/TestCaseTest.php`
- new `tests/Testbench/Concerns/InteractsWithTestCaseTest.php`
- `tests/Testbench/Concerns/InteractsWithTestCaseTest.php`
- `tests/Testbench/Concerns/InteractsWithPHPUnitTest.php`
- focused fixtures local to those Testbench test files for attribute, Pest-wrapper, and class-teardown failure order

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ The originating audit's labels are evidence-local and collide with the already d
| `api-client-09` | terminal/query parity and omitted-argument preservation |
| `api-client-10` | static types, documentation, test isolation, and package metadata |
| `http-28` | shared Request media/data/query/subtype correctness |
| `telescope-42` | raw HTTP body payload capture and structured-payload ownership |
| `broadcasting-18`, `contracts-13`, `foundation-20`, `http-29`, `inertia-24`, `socialite-28`, `support-35`, `telescope-41` | exact direct dependency ownership at each split package |
| `telescope-43` | raw HTTP body payload capture and structured-payload ownership |
| `broadcasting-18`, `contracts-13`, `foundation-20`, `http-29`, `inertia-24`, `socialite-28`, `support-35`, `telescope-42` | exact direct dependency ownership at each split package |
| `database-34` | Faker suggestion for Eloquent model factories |
| `reverb-41` | false API Client dependency removal |

Expand Down
501 changes: 501 additions & 0 deletions docs/plans/2026-08-11-0932-json-correctness-and-package-metadata.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions dogfood/testbench-package/tests/PackageRuntimeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use PHPUnit\Framework\Attributes\Test;

use function Hypervel\Testbench\remote;
use function Hypervel\Testbench\workbench_relative_path;

class PackageRuntimeTest extends TestCase
{
Expand All @@ -29,6 +30,13 @@ public function itLoadsWorkbenchProviderAndConfig(): void
$this->assertTrue($config->boolean('dogfood.workbench_config_loaded'));
}

#[Test]
public function itResolvesPathsRelativeToThePackageWorkbench(): void
{
$this->assertSame('workbench/config/dogfood.php', workbench_relative_path('config', 'dogfood.php'));
$this->assertSame('workbench/config/dogfood.php', workbench_relative_path('./config', 'dogfood.php'));
}

#[Test]
public function itRunsRemoteCommandsInsideThePackageRuntime(): void
{
Expand Down
9 changes: 7 additions & 2 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,14 @@ parameters:
- %currentWorkingDirectory%/src/*/resources/views/*
- %currentWorkingDirectory%/src/*/publish/*
- %currentWorkingDirectory%/src/foundation/src/ComposerScripts.php
# @TODO: Add pest to root composer.json require-dev and remove this line once Pest 5 is released (required for PHPUnit 13 support).
- %currentWorkingDirectory%/src/testbench/src/Pest/Autoload.php
ignoreErrors:
# CreatesApplication is an open trait consumed by both PHPUnit test cases and the
# standalone Testbench application. PHPStan reports this shared capability guard
# in the consumer contexts, where an inline suppression in the trait cannot bind.
- message: "#^Call to function method_exists\\(\\) with .+ and 'beforeApplicationDe[^']*' will always evaluate to true\\.$#"
identifier: function.alreadyNarrowedType
path: src/testbench/src/Concerns/CreatesApplication.php

# protoc 35.1 emits a two-argument enum check, while google/protobuf 5.35.1
# declares the userland method with one argument and relies on PHP ignoring the extra value.
- identifier: arguments.count
Expand Down
3 changes: 2 additions & 1 deletion src/collections/src/Arr.php
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,8 @@ public static function from(mixed $items): array
$items instanceof Arrayable => $items->toArray(),
$items instanceof WeakMap => iterator_to_array($items, false),
$items instanceof Traversable => iterator_to_array($items),
$items instanceof Jsonable => json_decode($items->toJson(), true),
// Support depends on Collections, so this native depth cannot reference Support\Json; 513 reads 512 containers.
$items instanceof Jsonable => json_decode($items->toJson(), true, 513),
$items instanceof JsonSerializable => (array) $items->jsonSerialize(),
is_object($items) => (array) $items, // @phpstan-ignore function.alreadyNarrowedType
default => throw new InvalidArgumentException('Items cannot be represented by a scalar value.'),
Expand Down
6 changes: 4 additions & 2 deletions src/collections/src/Traits/EnumeratesValues.php
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,9 @@ public static function times(int $number, ?callable $callback = null, mixed ...$
*
* @return static<TKey, TValue>
*/
public static function fromJson(string $json, int $depth = 512, int $flags = 0, mixed ...$args): static
public static function fromJson(string $json, int $depth = 513, int $flags = 0, mixed ...$args): static
{
// Support depends on Collections, so this native depth cannot reference Support\Json; 513 reads 512 containers.
return new static(json_decode($json, true, $depth, $flags), ...$args);
}

Expand Down Expand Up @@ -934,7 +935,8 @@ public function jsonSerialize(): array
return array_map(function ($value) {
return match (true) {
$value instanceof JsonSerializable => $value->jsonSerialize(),
$value instanceof Jsonable => json_decode($value->toJson(), true),
// Support depends on Collections, so this native depth cannot reference Support\Json; 513 reads 512 containers.
$value instanceof Jsonable => json_decode($value->toJson(), true, 513),
$value instanceof Arrayable => $value->toArray(),
default => $value,
};
Expand Down
3 changes: 2 additions & 1 deletion src/concurrency/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"hypervel/coroutine": "^0.4",
"hypervel/process": "^0.4",
"hypervel/support": "^0.4",
"nesbot/carbon": "^3.13.1"
"nesbot/carbon": "^3.13.1",
"symfony/console": "^8.1"
},
"config": {
"sort-packages": true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

declare(strict_types=1);

namespace Hypervel\Foundation\Console;
namespace Hypervel\Concurrency\Console;

use Error;
use Exception;
Expand Down Expand Up @@ -53,6 +53,7 @@ public function handle(): int

if ($parameters !== null) {
// Named arguments must survive JSON without changing types or nested state.
// This subtree is one container shallower than the envelope decoded at native depth 513.
$encodedParameters = json_encode($parameters, self::JSON_FLAGS);

if (json_decode($encodedParameters, true, 512, JSON_THROW_ON_ERROR) !== $parameters) {
Expand Down
66 changes: 1 addition & 65 deletions src/concurrency/src/ProcessDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
use Hypervel\Support\Arr;
use Hypervel\Support\Defer\DeferredCallback;
use Laravel\SerializableClosure\SerializableClosure;
use RuntimeException;
use Throwable;

use function Hypervel\Support\defer;

Expand Down Expand Up @@ -55,69 +53,7 @@ public function run(Closure|array $tasks, CarbonInterval|int|null $timeout = nul
throw new Exception('Concurrent process failed with exit code [' . $result->exitCode() . ']. Message: ' . $result->errorOutput());
}

$output = $result->output();

if (($position = strpos($output, "\x1f\x8b")) !== false) {
$output = substr($output, 0, $position);
}

$payload = json_decode($output, true, 512, JSON_THROW_ON_ERROR);

if (! is_array($payload)
|| ! array_key_exists('successful', $payload)
|| ! is_bool($payload['successful'])) {
throw new RuntimeException('Invalid concurrent process response envelope.');
}

/** @var array{
* successful: bool,
* result?: string,
* exception?: class-string<Throwable>,
* message?: string,
* parameters?: array<string, mixed>
* } $payload
*/
if ($payload['successful'] === false) {
if ((array_key_exists('exception', $payload) && ! is_string($payload['exception']))
|| (array_key_exists('message', $payload) && ! is_string($payload['message']))
|| (array_key_exists('parameters', $payload) && ! is_array($payload['parameters']))) {
throw new RuntimeException('Invalid concurrent process response envelope.');
}

$exceptionClass = $payload['exception'] ?? RuntimeException::class;
$message = $payload['message'] ?? 'Serialized closure execution failed.';
$parameters = $payload['parameters'] ?? ['message' => $message];

try {
$exception = new $exceptionClass(...$parameters);
} catch (Throwable $constructionException) {
throw new RuntimeException($message, previous: $constructionException);
}

if (! $exception instanceof Throwable) {
throw new RuntimeException($message);
}

throw $exception;
}

$encodedResult = $payload['result'] ?? null;
$serializedResult = is_string($encodedResult)
? base64_decode($encodedResult, true)
: false;

if ($serializedResult === false) {
throw new RuntimeException('Unable to decode the concurrent process result.');
}

// Malformed payloads warn and return false, which is also a valid serialized result.
$unserializedResult = @unserialize($serializedResult);

if ($unserializedResult === false && $serializedResult !== serialize(false)) {
throw new RuntimeException('Unable to decode the concurrent process result.');
}

return [$key => $unserializedResult];
return [$key => SerializedClosureResult::decode($result->output())];
})->all();
}

Expand Down
85 changes: 85 additions & 0 deletions src/concurrency/src/SerializedClosureResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace Hypervel\Concurrency;

use Hypervel\Support\Json;
use RuntimeException;
use Throwable;

/**
* @internal
*/
class SerializedClosureResult
{
/**
* Return the unserialized result or throw the reconstructed remote exception.
*
* @throws Throwable
*/
public static function decode(string $output): mixed
{
if (($position = strpos($output, "\x1f\x8b")) !== false) {
$output = substr($output, 0, $position);
}

$payload = Json::decode($output);

if (! is_array($payload)
|| ! array_key_exists('successful', $payload)
|| ! is_bool($payload['successful'])) {
throw new RuntimeException('Invalid serialized closure response envelope.');
}

/** @var array{
* successful: bool,
* result?: string,
* exception?: class-string<Throwable>,
* message?: string,
* parameters?: array<string, mixed>
* } $payload
*/
if ($payload['successful'] === false) {
if ((array_key_exists('exception', $payload) && ! is_string($payload['exception']))
|| (array_key_exists('message', $payload) && ! is_string($payload['message']))
|| (array_key_exists('parameters', $payload) && ! is_array($payload['parameters']))) {
throw new RuntimeException('Invalid serialized closure response envelope.');
}

$exceptionClass = $payload['exception'] ?? RuntimeException::class;
$message = $payload['message'] ?? 'Serialized closure execution failed.';
$parameters = $payload['parameters'] ?? ['message' => $message];

try {
$exception = new $exceptionClass(...$parameters);
} catch (Throwable $constructionException) {
throw new RuntimeException($message, previous: $constructionException);
}

if (! $exception instanceof Throwable) {
throw new RuntimeException($message);
}

throw $exception;
}

$encodedResult = $payload['result'] ?? null;
$serializedResult = is_string($encodedResult)
? base64_decode($encodedResult, true)
: false;

if ($serializedResult === false) {
throw new RuntimeException('Unable to decode the serialized closure result.');
}

// Malformed payloads warn and return false, which is also a valid serialized result.
$unserializedResult = @unserialize($serializedResult);

if ($unserializedResult === false && $serializedResult !== serialize(false)) {
throw new RuntimeException('Unable to decode the serialized closure result.');
}

return $unserializedResult;
}
}
2 changes: 1 addition & 1 deletion src/database/src/Console/ShowCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ protected function display(array $data): void
*/
protected function displayJson(array $data): void
{
$this->output->writeln(json_encode($data));
$this->output->writeln(json_encode($data, JSON_THROW_ON_ERROR));
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/database/src/Console/TableCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ protected function display(array $data): void
*/
protected function displayJson(array $data): void
{
$this->output->writeln(json_encode($data));
$this->output->writeln(json_encode($data, JSON_THROW_ON_ERROR));
}

/**
Expand Down
16 changes: 12 additions & 4 deletions src/database/src/Eloquent/Casts/AsArrayObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Hypervel\Contracts\Database\Eloquent\Castable;
use Hypervel\Contracts\Database\Eloquent\CastsAttributes;
use Hypervel\Database\Eloquent\JsonEncodingException;
use Hypervel\Database\Eloquent\Model;

class AsArrayObject implements Castable
{
Expand All @@ -17,7 +19,7 @@ class AsArrayObject implements Castable
public static function castUsing(array $arguments): CastsAttributes
{
return new class implements CastsAttributes {
public function get(mixed $model, string $key, mixed $value, array $attributes): ?ArrayObject
public function get(Model $model, string $key, mixed $value, array $attributes): ?ArrayObject
{
if (! isset($attributes[$key])) {
return null;
Expand All @@ -28,12 +30,18 @@ public function get(mixed $model, string $key, mixed $value, array $attributes):
return is_array($data) ? new ArrayObject($data, ArrayObject::ARRAY_AS_PROPS) : null;
}

public function set(mixed $model, string $key, mixed $value, array $attributes): array
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
return [$key => Json::encode($value)];
$encoded = Json::encode($value);

if ($encoded === false) {
throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg());
}

return [$key => $encoded];
}

public function serialize(mixed $model, string $key, mixed $value, array $attributes): array
public function serialize(Model $model, string $key, mixed $value, array $attributes): array
{
return $value->getArrayCopy();
}
Expand Down
14 changes: 11 additions & 3 deletions src/database/src/Eloquent/Casts/AsCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Hypervel\Contracts\Database\Eloquent\Castable;
use Hypervel\Contracts\Database\Eloquent\CastsAttributes;
use Hypervel\Database\Eloquent\JsonEncodingException;
use Hypervel\Database\Eloquent\Model;
use Hypervel\Support\Collection;
use Hypervel\Support\Str;
use InvalidArgumentException;
Expand All @@ -25,7 +27,7 @@ public function __construct(protected array $arguments)
$this->arguments = array_pad(array_values($this->arguments), 2, '');
}

public function get(mixed $model, string $key, mixed $value, array $attributes): ?Collection
public function get(Model $model, string $key, mixed $value, array $attributes): ?Collection
{
if (! isset($attributes[$key])) {
return null;
Expand Down Expand Up @@ -58,9 +60,15 @@ public function get(mixed $model, string $key, mixed $value, array $attributes):
: $instance->mapInto($this->arguments[1][0]);
}

public function set(mixed $model, string $key, mixed $value, array $attributes): array
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
return [$key => Json::encode($value)];
$encoded = Json::encode($value);

if ($encoded === false) {
throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg());
}

return [$key => $encoded];
}
};
}
Expand Down
Loading
Loading