diff --git a/composer.json b/composer.json index 9542532547..44f4cae62f 100644 --- a/composer.json +++ b/composer.json @@ -198,7 +198,7 @@ "psr/http-message": "^2.0", "psr/log": "^3.0", "psr/simple-cache": "^3.0", - "psy/psysh": "^0.12.22", + "psy/psysh": "dev-main", "sentry/sentry": "dev-master", "spomky-labs/otphp": "^11.0", "symfony/console": "^8.1", diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md index a00986705f..dd158ade37 100644 --- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md @@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** `testbench`; the complete correctness, parallel-ownership, parity, and lifecycle audit is recorded under `Complete Testbench correctness, parallel ownership, and current parity`; detail plan `2026-08-09-0615-testbench-correctness-parallel-ownership-and-current-parity.md`. -- **Ledger entries required for the active work:** `Complete Testbench correctness, parallel ownership, and current parity`; `Restore Conditionable proxy truthiness`; `Normalize framework enum identifiers at string boundaries`; `Make coroutine creation and copied context failure-safe`; `Make process concurrency transport lossless and reconstruct failures safely`; `Complete Foundation runtime lifecycles and safe publication`; `Complete Database persistence lifecycles and current Laravel parity`; `Complete Cache parity, cleanup, permanence, and tagged ownership`; `Complete View correctness, lifecycle, and current parity`; `Complete Wayfinder correctness, generation parity, and bounded lifecycles`; `Complete Testing correctness, parallel ownership, and current parity`; and `Complete Telescope correctness, current parity, and watcher lifecycles`. -- **Pending revalidation carried into the active work:** Testbench closes every routed revalidation. `telescope-41` also completes the previously unrecorded `coroutine-08` Telescope consumer check; no pending work remains. +- **Active package or work unit:** None; Tinker is complete under `Complete Tinker correctness and PsySH lifecycles`; detail plan `2026-08-09-0219-tinker-correctness-psysh-lifecycles-and-current-parity.md`. +- **Ledger entries required for the active work:** None. +- **Pending revalidation carried into the active work:** None. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index 6bb5566a22..e7e55c8c61 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -2305,6 +2305,29 @@ Append package entries in checklist order. Keep each entry compact but complete - **Validation and review:** Every changed file was exercised through its focused suite; affected Testing, Console, Foundation, Mail, Testbench, and metadata coverage is green. Canonical assertion, view, console-test, and Testing-package documentation was checked against the final public surfaces. Root/split Composer validation, formatting, both PHPStan configurations, the complete parallel components suite, Testbench package mode, dogfood, stale-symbol scans, and `git diff --check` passed. Fresh caller/callee, token/application ownership, cleanup, API, fixture, retained-state, hot-path, dead-code, and overengineering review is complete, and independent code review signed off with no remaining implementation finding. - **Assessment:** Testing now has exact parallel/resource ownership, current assertion and response parity, failure-truthful diagnostics, bounded metadata, and exception-safe fixture cleanup. Every accepted finding is fixed at its lowest owner without a workaround, speculative abstraction, stale path, production hot-path regression, unintended Laravel API break, or unresolved accepted defect. +### Complete Tinker correctness and PsySH lifecycles + +- **Status and inspected surface:** Complete; implementation, the current `0.4` merge, PsySH `dev-main` consumption, the approved project-trust correction, focused validation, load-bearing counterfactuals, final self-review, and independent code review are complete. Publishing Hypervel 0.4 is blocked until `dev-main` is replaced with the first compatible stable PsySH release containing the required behavior. The audit covered every reported Tinker finding, all Tinker source/tests/configuration/metadata/documentation, current Laravel Tinker, current PsySH execution/include/trust lifecycles, and connected Console programmatic execution. The detailed design is recorded in [`2026-08-09-0219-tinker-correctness-psysh-lifecycles-and-current-parity.md`](2026-08-09-0219-tinker-correctness-psysh-lifecycles-and-current-parity.md). + +| Findings | Final decision | +|---|---| +| `tinker-01` | Use PsySH's paired execution cleanup through its normal `Shell`; do not retain a local listener filter. | +| `tinker-02`, `tinker-05`, `tinker-11` | Select direct execution for every non-null value, preserve requested exit codes without error rendering, and stop mutating the shared Console application's exception policy. | +| `tinker-03`, `tinker-04` | Depend temporarily on PsySH `dev-main`, which loads configured includes once per outer execution, contains each include failure, and restores the caller's error handler. | +| `tinker-06` | Normalize configured aliases once and match exact classes, namespace descendants, and vendor directory children on semantic boundaries. | +| `tinker-07` | Contain each Application presentation getter's `Throwable` independently so later virtual properties remain visible. | +| `tinker-08` | Omit disabled configured commands when Symfony returns `null`. | +| `tinker-09`, `tinker-10` | Remove the unused Contracts dependency and misleading Database suggestion, pin root/split metadata and provider discovery, add upstream provenance and the no-fork difference, and document direct execution, process-forking policy, aliases, casters, and project trust. | +| `tinker-12` | Default project trust to PsySH's native `prompt` mode so an unfamiliar working directory cannot silently execute `.psysh.php`; preserve explicit `always`, `never`, boolean, and null configuration. | + +- **Architecture and ownership:** Each command invocation owns one normal PsySH shell and alias loader. PsySH owns include loading and execution cleanup, interactive execution retains Ctrl-C handling, and Hypervel continues to disable process forking before shell construction. The shell command set is invocation-local while configured command registration on the Kernel-cached Console application follows upstream Tinker behavior. No static state, coroutine context, registry, cache, lock, retry, background task, or retained worker allocation was added. +- **Console revalidation:** Programmatic Console execution already bypasses Symfony's process-global wrapper through `Application::runProgrammatically()`, while root CLI execution remains owned by Kernel/Symfony. Removing Tinker's redundant `setCatchExceptions(false)` preserves both paths and the caller's configured policy. +- **Important rejected concerns:** No local shell subclass, reflected private method, copied include loop, generated `require_once` code, compatibility branch, shell factory, signal snapshot around yielding Hypervel code, PTY harness, alias index, path canonicalization, classmap cache, process isolation, project-trust layer, path allowlist, or new trust command option is retained. `TESTBENCH_BASE_PATH` is intentionally absent from the falsey-execute child harness because the disposable clone's own `artisan` defines `BASE_PATH` directly. PsySH-derived trust flags are overridden by application config but unreachable because neither Laravel nor Hypervel defines them on the Tinker command. +- **Regression coverage:** Focused tests cover falsey direct values through the real disposable child application with open stdin, requested exit status, ordinary failures, SIGINT preservation, exception-policy preservation, enabled-command placement and disabled-command omission, coroutine execution, exact alias/vendor boundaries, per-property native errors, split metadata, provider discovery, nullable and default project trust, rejection of untrusted local project configuration, existing registration/configuration behavior, positional and explicitly trusted local include scope, malformed-include reporting, later-include continuation, error-handler restoration, and success status after a reported include failure. +- **Performance and compatibility:** Changes run only while starting or using the developer command. Matching and filtering are bounded in-memory work over configured aliases/listeners/commands; no application request, queue, database, network, or worker hot path changes. Laravel-facing Tinker options, project-trust values, and configuration keys remain compatible; Hypervel deliberately uses PsySH's safer `prompt` default rather than Laravel Tinker's `always`, and the no-fork Swoole adaptation remains intact. +- **Validation:** Both Composer manifests are valid and aligned, the installed PsySH source is the required `dev-main`, formatting, both PHPStan configurations, and the complete Tinker suite in normal and random order pass. Both include tests fail under their targeted source counterfactuals; selecting the old `always` trust default makes the untrusted-project and default-pinning regressions fail while the explicitly trusted include test passes. Fresh caller/callee, process-global state, public API, cold-path performance, retained-memory, stale-code, and overengineering review is complete. +- **Assessment:** Every accepted finding is corrected directly without a workaround or speculative mechanism, and independent code review signed off with no remaining implementation finding. + ### Complete Testbench correctness, parallel ownership, and current parity - **Status and inspected surface:** Complete; implementation, focused validation, the authoritative gate, fresh self-review, and independent review are signed off through `testbench-31`. The audit covered Testbench bootstrap, application and PHPUnit lifecycles, worker-local runtime clones, process-global environment and timezone state, cached routes/configuration, SQLite and migration ownership, commands and file actions, Workbench/vendor symlinks, package helpers and metadata, every routed Foundation/Testing consumer, current Orchestra Testbench 11.4.0 source/tests, and the intentionally different Hypervel worker-startup configuration model. The detailed design is recorded in [`2026-08-09-0615-testbench-correctness-parallel-ownership-and-current-parity.md`](2026-08-09-0615-testbench-correctness-parallel-ownership-and-current-parity.md). diff --git a/docs/plans/2026-08-09-0219-tinker-correctness-psysh-lifecycles-and-current-parity.md b/docs/plans/2026-08-09-0219-tinker-correctness-psysh-lifecycles-and-current-parity.md new file mode 100644 index 0000000000..d44665b04f --- /dev/null +++ b/docs/plans/2026-08-09-0219-tinker-correctness-psysh-lifecycles-and-current-parity.md @@ -0,0 +1,303 @@ +# Tinker Correctness, PsySH Lifecycles, and Current Parity + +## Status + +Complete. The implementation, current `0.4` merge, PsySH `dev-main` update, project-trust correction, focused validation, load-bearing counterfactuals, final self-review, and independent code review are complete. Replace `dev-main` with the first compatible stable PsySH release containing the required behavior before Hypervel 0.4 is released. + +## Scope + +Correct the verified Tinker findings without turning this targeted maintenance unit into a second package-wide audit. Preserve Hypervel's coroutine-aware Console execution, prohibition on PsySH process forking, upstream Tinker APIs and configuration, Database/Process presentation, and operation-local shell/alias-loader ownership. + +References checked: + +- current Hypervel Components `0.4`, including all Tinker source/tests and connected Console behavior; +- current Laravel Tinker `3.x` and Laravel documentation; +- current PsySH `main`. + +This plan is the post-compaction implementation reference. It reproduces the core plan's "What this audit is not" section and principles 7–10 verbatim below. + +## What this audit is not + +This audit is not permission to add defensive machinery for every imaginable failure. Do not add an abstraction, state machine, retry loop, configurable timeout, registry, mutex, context slot, cache, or compatibility API merely because it sounds robust. + +Complexity must pay for itself with at least one of: + +- a demonstrated failure; +- a complete source trace proving a realistic vulnerable schedule; +- a clear general capability with real consumers and owner approval; +- deletion of greater or riskier complexity elsewhere. + +Typical Laravel lifecycle semantics define the supported contract. A package that intentionally relies on model events, middleware, listeners, transactions, or another documented mechanism is not defective merely because userland can explicitly bypass that mechanism. Do not build a parallel enforcement path for `withoutEvents()`, raw database writes, disabled middleware, direct transport access, or comparable deliberate bypasses unless the public contract explicitly promises behavior through that bypass. + +Underengineering is equally a failure. Fix every verified defect completely at its lowest owning boundary, never with a partial fix or a local patch over a broken shared contract, and always surface meaningful evidence-backed improvements rather than dropping them to avoid effort. Restraint applies to speculative machinery and cosmetic change, not to complete fixes or worthwhile opportunities. + +Do not treat an upstream difference as a bug without tracing it. Do not treat upstream parity as proof of correctness. A real Hypervel defect remains a defect when Laravel, Hyperf, Symfony, or an SDK has the same hole. + +The audit categories are discovery lenses, not boundaries around what may be corrected. Any genuine issue discovered while auditing, implementing, testing, or reviewing must be investigated, assigned to its lowest owning boundary, and taken through the applicable consensus, implementation, validation, review, and approval workflow—even when it is outside the current package, initial taxonomy, or changed diff. Do not dismiss a verified issue as unrelated or defer it merely to preserve package order. This rule applies only after the evidence threshold is met; it does not turn speculative concerns, deliberate bypasses, unsupported use, or contract violations into work. + +### 7. Preserve hot-path quality + +For every fix, inspect: + +- additional allocations; +- container or facade resolutions; +- locking and atomics; +- hashing and serialization; +- new yields or sleeps; +- retries and polling; +- logging or exception construction; +- retained worker memory; +- cache invalidation and eviction. + +A correctness guard on a cold failure path has a different cost from a new lock or resolver on every request. State the difference explicitly. + +Any proposed change with a measured or source-proven hot-path regression requires explicit owner approval before implementation, even when it fixes a defect. Present the expected frequency and magnitude, the evidence, and the viable alternatives. Do not hide an unavoidable tradeoff inside a general correctness claim. + +Performance improvements must provide a meaningful practical benefit after accounting for code complexity and divergence from upstream. Measure representative behavior where practical. Always surface an evidence-backed opportunity to the owner, but do not implement it without approval; a micro-optimization within measurement noise is neither a reason to diverge nor an actionable finding. + +### 8. Remove superseded design completely + +When a fix changes the owning model, delete obsolete helpers, callbacks, properties, config keys, comments, tests, and documentation. Do not leave a compatibility path or comment describing behavior that no longer exists. Preserve intentional upstream comments unless the new design makes them incorrect. + +### 9. Treat remediation patterns as candidates + +The established patterns later in this plan are a vocabulary, not a lookup table. Choose among per-call parameters, immutable values, scoped bindings, cloning, CoroutineContext, factories, explicit ownership, static reset, or resource teardown only after proving the real lifetime and owner. + +### 10. Reject speculative complexity + +Record low-confidence concerns under rejected or unresolved analysis. Do not implement them. Surface every evidence-backed, meaningful non-defect improvement to the owner with its benefit, cost, and alternatives, then stop for explicit approval. This requirement exists to keep worthwhile opportunities visible, not to discourage finding them. + +## Contracts and performance budget + +- Keep `--execute`, positional `include`, `commands`, `alias`, `dont_alias`, and `casters` Laravel-shaped. Keep the `trust_project` name, accepted values, and semantics while defaulting to PsySH's safer `prompt` mode instead of Laravel Tinker's `always`. +- Keep PsySH process forking disabled before shell construction. Local `.psysh.php` configuration cannot re-enable `ProcessForker`: listeners are constructed before local config is loaded and are never rebuilt. +- Use PsySH's normal `Shell`; current `dev-main` owns include loading and direct-execution signal cleanup without a Hypervel subclass. +- Interactive Tinker retains Ctrl-C handling. One-shot execution must not leave process-global signal or error-handler state behind. +- User casters keep overriding defaults. Database model, Process result, and Foundation Application casters remain built in; Foundation brings Database and Process as hard transitive dependencies. +- No HTTP/request path changes. All added comparisons, filtering, and loading occur only while starting or running the developer command. There is no lock, yield, retry, cache, static registry, coroutine context, retained worker allocation, or repeated filesystem I/O beyond includes explicitly requested by the caller. + +## Final findings + +| ID | Defect | Final treatment | +|---|---|---| +| `tinker-01` | Direct execution invoked PsySH's `SignalHandler::onExecute()` without matching cleanup, replacing process-global SIGINT state in surviving programmatic/ParaTest processes. | Use PsySH's paired execution cleanup through its normal `Shell`; do not retain a local listener filter. | +| `tinker-02` | Truthy option checks send valid `--execute=0` and `--execute=''` values to the REPL branch. | Treat every non-null `--execute` value as direct execution. | +| `tinker-03` | `setIncludes()` configured files, but direct `Shell::execute()` did not load them. | Depend temporarily on PsySH `dev-main`, which loads configured includes at the outermost `run()` or `execute()` boundary while keeping the loader private. | +| `tinker-04` | PsySH caught only `Exception` while loading includes and restored its error handler only normally, so `ParseError` aborted later includes and left PsySH's process-global handler installed. | Use the corrected `dev-main` include lifecycle, which restores the handler in `finally` and contains each `Throwable`. | +| `tinker-05` | `execute($code, true)` rethrows `BreakException`; Tinker's broad catch renders `exit(3)` as an error and returns 1. | Return the embedded exit code without error rendering. | +| `tinker-06` | Raw prefixes make `App\Nova` also match `App\NovaThing` and make `/app/vendor-local/...` look like `/app/vendor/...`. | Match normalized aliases and vendor directories on semantic boundaries. | +| `tinker-07` | One Application presentation getter throwing `Error` or `TypeError` escapes the per-property `Exception` boundary and aborts the dump. | Contain `Throwable` from each getter. | +| `tinker-08` | Symfony returns `null` for a disabled configured command, which PsySH forwards to its `callable\|Command` parameter and rejects with `TypeError`. | Omit disabled command results. | +| `tinker-09` | Split metadata declares unused Contracts and a misleading Database suggestion, lacks durable dependency coverage, and omits upstream provenance. | Correct dependencies/provenance and add focused metadata coverage. | +| `tinker-10` | Public guidance omits execute/alias/caster/trust behavior and incorrectly says all PCNTL support is disabled. | Complete the concise Tinker guide in Laravel-docs prose. | +| `tinker-11` | Tinker redundantly writes the Kernel-cached Console application's exception policy and can leave a caller's explicit setting changed. | Remove the mutation. | +| `tinker-12` | Laravel Tinker's `always` project-trust default silently executes `.psysh.php` from the current working directory, opting out of PsySH's protection against untrusted project configuration. | Use PsySH's native `prompt` mode by default; retain explicit `always` and `never` configuration. | + +## Implementation + +### 1. Merge current `0.4` and consume PsySH `dev-main` + +Merge current `0.4` into this branch before further source changes. Resolve overlaps by preserving all newer Tinker behavior from `0.4`, including lazy command resolution, optional command and alias lists, the configured caster map, model appends through `getAppends()`, and documentation at `src/docs/artisan.md`. Combine those changes with the audit fixes; do not restore the old Boost documentation path, eager command resolution, direct model-property access, or narrower caster failure boundary. + +Use Composer to change the root `psy/psysh` requirement to `dev-main`, set the split package requirement in `src/tinker/composer.json` to the same constraint, and update the installed dependency. Current PsySH `main` provides all behavior Hypervel needs: + +- configured includes load once at the outermost `run()` or `execute()` boundary; +- each include `Throwable` is reported without stopping later includes, and the caller's error handler is restored; +- execution callbacks are paired, and `SignalHandler` restores the exact SIGINT handler and async-signal setting it replaced. + +Replace `dev-main` with the first compatible stable release containing these behaviors before Hypervel 0.4 is released. Do not copy or reflect into PsySH internals, expose its include loader, add a version branch, or keep a local shell subclass. + +### 2. Make one-shot execution exact + +Resolve the option once and select the shell once: + +```php +/** @var ?string $code */ +$code = $this->option('execute'); + +if ($code !== null) { + $config->setRawOutput(true); +} + +$shell = new Shell($config); +``` + +Use the same PsySH `Shell` for direct and interactive execution. Current PsySH `main` owns direct-execution signal cleanup. Keep `setUsePcntl(false)` before shell construction because `ProcessForker` remains incompatible with Swoole; do not add a local shell subclass or listener filter. + +The direct branch becomes: + +```php +if ($code !== null) { + try { + $shell->setOutput($this->output); + $shell->execute($code, true); + } catch (BreakException $e) { + return $e->getCode(); + } catch (Throwable $e) { + $shell->writeException($e); + + return 1; + } finally { + $loader->unregister(); + } + + return 0; +} +``` + +Keep the existing `$shell->setIncludes($this->argument('include'))` call before alias-loader registration and before either execution branch. PsySH's `execute()` boots before loading includes because project `.psysh.php` may contribute `defaultIncludes`. Keep `setOutput()` before execution because include failures use the configured output. + +PsySH reports include failures per file and continues loading. Preserve that contract: a malformed include does not itself change Tinker's exit status; the status reflects the subsequently executed code. Do not reintroduce the rejected abort-on-first-failure divergence. + +Delete `$this->getApplication()->setCatchExceptions(false)`: Console already sets this policy, programmatic dispatch bypasses Symfony's wrapper, and Tinker must not mutate shared application state. Keep loader cleanup in both paths. + +In `handle()`, keep scalar/null trust configuration on `get()` and use typed array retrieval for alias configuration: + +```php +$config->setTrustProject($appConfig->get('tinker.trust_project')); + +$loader = ClassAliasAutoloader::register( + $shell, + $path, + $appConfig->array('tinker.alias', []), + $appConfig->array('tinker.dont_alias', []), +); +``` + +In `getCommands()`, the local `$config` is Hypervel's Config Repository. Use its typed array accessor and omit disabled configured commands: + +```php +$config = $this->getHypervel()->make('config'); + +foreach ($config->array('tinker.commands', []) as $command) { + if (($command = $this->getApplication()->addCommand( + $this->getHypervel()->make($command), + )) !== null) { + $commands[] = $command; + } +} +``` + +Do not extract a shell factory or command registry. The branch and null check are the whole required policy. + +### 3. Correct alias boundaries + +Normalize configured class/namespace names once in the constructor. Preserve Collection matching and explicit-include precedence: + +```php +$this->includedAliases = collect($includedAliases) + ->map(static fn (string $alias): string => trim($alias, '\\')); +$this->excludedAliases = collect($excludedAliases) + ->map(static fn (string $alias): string => trim($alias, '\\')); +``` + +Match exact classes or namespace descendants only: + +```php +private static function matchesAlias(string $class, string $alias): bool +{ + return $class === $alias || Str::startsWith($class, $alias . '\\'); +} +``` + +Use the matcher for both included and excluded aliases. A Composer classmap value is a file path, so vendor exclusion needs only the directory-child boundary: + +```php +if (Str::startsWith($path, $this->vendorPath . DIRECTORY_SEPARATOR)) { + return false; +} +``` + +Do not canonicalize paths, inspect the filesystem per class, cache the classmap across invocations, or add an alias index. + +### 4. Keep presentation failures local + +In `TinkerCaster::castApplication()`, import and catch `Throwable` around each getter, without an unused catch variable, so one failing optional virtual property does not suppress later ones: + +```php +foreach (self::$appProperties as $property) { + try { + $value = $app->{$property}(); + + if ($value !== null) { + $results[Caster::PREFIX_VIRTUAL . $property] = $value; + } + } catch (Throwable) { + } +} +``` + +Register the Foundation Application, Database model, and Process result casters unconditionally. `hypervel/foundation` is a direct hard dependency, Foundation directly requires Database, and Foundation's Concurrency dependency requires Process. Symfony stores caster class-string keys without resolving them, so conditional registration would not protect a runtime boundary even if a class were absent. + +### 5. Correct metadata, provenance, and documentation + +In `src/tinker/composer.json`: + +- remove unused `hypervel/contracts`; +- keep root-consistent `symfony/console:^8.1` and `symfony/var-dumper:^8.1`; +- require PsySH `dev-main` as described in section 1; +- omit `suggest`: Database and Process are already hard transitive dependencies. + +Add `tests/Tinker/PackageMetadataTest.php` to pin direct dependency/root-constraint agreement, the absent Contracts dependency and `suggest` section, and provider discovery. Add upstream provenance and concise `Differences From Laravel` notes about the user-visible no-fork behavior and safer project-trust default to the README. + +Default `trust_project` to `prompt`, using PsySH's existing trust implementation. Interactive Tinker asks before loading an unfamiliar local `.psysh.php`; noninteractive execution skips untrusted project configuration without blocking. Keep `always`, `never`, boolean, and null values available. Do not add path allowlists, change working directories, or expose PsySH's `--trust-project` options: Hypervel's command does not define those options, and the existing environment variable is sufficient for one-run automation. + +Update only the Tinker section of `src/docs/artisan.md`, following the surrounding Laravel-docs prose. Document: + +- `--execute` and its zero/non-zero exit-status behavior, including that a reported include failure does not alter the status produced by the executed code; +- positional includes before direct execution; +- that Hypervel disables process forking, not all PCNTL support; +- `tinker.alias` vendor opt-in and `dont_alias` exclusions; +- custom `tinker.casters`; +- the `prompt` project-trust default, interactive confirmation, noninteractive skip, and working remedies: answer the prompt, configure `trust_project`, or set `TINKER_TRUST_PROJECT=always` for one trusted run. + +Keep the guide concise: no exhaustive config reference, internal listener discussion, or default-caster listing. + +Add one concise porting-guide entry explaining that Laravel applications which rely on implicit `.psysh.php` loading must explicitly select `always` in a trusted environment. + +### 6. Update durable records + +Add one compact Tinker ledger section covering `tinker-01` through `tinker-12`, the temporary PsySH `dev-main` constraint, Console revalidation, final API/performance result, and rejected designs. Route the core Tinker line to this work unit. Preserve every newer `0.4` record while resolving the audit-plan and ledger conflicts. Check the core package checklist only after current `0.4` is merged, `dev-main` is installed, and implementation, validation, self-review, and code review are complete. + +## Tests and validation + +Run changed test files after each coherent source slice. Touch test methods with `: void`; make `ClassAliasAutoloaderTest::$loader` nullable and conditionally unregister it so setup failures remain primary. Use `ParallelTesting::tempDir()` and exception-safe cleanup instead of global `tempnam()`. + +Required Hypervel regressions: + +1. Successful and failing direct execution preserve a sentinel SIGINT handler; test cleanup restores the sentinel even after assertion failure. Do not assert async-signal mode at the Hypervel boundary because Symfony Console owns additional signal state. +2. A bounded subprocess runs the disposable runtime clone's own `artisan` at `BASE_PATH` to prove `--execute=0` and `--execute=''` select direct execution. The clone does not discover the root package, so temporarily add `TinkerServiceProvider` to its `bootstrap/providers.php` through the existing provider-file API and restore the original file in `finally`. Pass `COMPOSER_VENDOR_DIR` and `HYPERVEL_AUTOLOAD_PATH` to the child; `TESTBENCH_BASE_PATH` is not involved because the clone's entry point already owns `BASE_PATH`. Give the child an open stdin pipe that is deliberately not closed while awaiting it: the wrong REPL branch sees piped input and blocks in `getInput(false)`, while the direct branch returns immediately. Use a ten-second failure budget, treat timeout as test failure, and close every pipe in `finally`; do not require a PTY, invent another bootstrap, or add a production shell factory. +3. One integration test changes into an isolated temporary project, explicitly selects `always`, and proves that a positional include and trusted local `.psysh.php` include both share variables with directly executed code. A second uses unique include paths, disables mocked console output, and proves that a malformed positional include reports `ParseError`, a later include still loads, the prior error handler remains installed, and successful executed code still returns 0. Inspect and rebalance the handler stack before any assertion so a regression cannot contaminate later tests. +4. A direct-execution test changes into an isolated temporary project under the shipped `prompt` default and proves that an untrusted `.psysh.php` cannot create its sentinel file. Do not assert PsySH's warning text. +5. `exit(3)` returns 3 without evaluation-error output; ordinary throwables still return 1. +6. Direct `getCommands()` coverage proves that an enabled configured command is retained after the whitelist while a disabled configured command is omitted. +7. The public `isAliasable()` matrix covers exact class, namespace child, common-prefix sibling, trailing separator, exclusion, real vendor child, and vendor-prefix sibling. The loader exclusion test invokes `aliasClass()` directly and relies on its shell mock because PHP class aliases are permanent and make `class_exists()` order-dependent. +8. An Application getter throwing `Error` is omitted while later virtual properties remain. +9. Metadata/provenance, nullable project-trust configuration, and existing coroutine execution remain correct. + +Validation order: + +1. Run each changed Tinker test file, the alias-loader file in reverse order, then the complete `tests/Tinker` group. +2. Validate both Composer manifests and confirm the installed PsySH source is current `dev-main`. +3. Confirm the include tests are load-bearing by temporarily removing the positional `setIncludes()` call and moving direct-execution `setOutput()` after `execute()`, running the matching test after each change, and reverting immediately. +4. Confirm the project-trust regression is load-bearing by running the negative trust regression, positive include test, and default-pinning config test with `TINKER_TRUST_PROJECT=always`: the negative and default-pinning tests must fail while the positive include test still passes through its explicit `always` setting. +5. Run `composer lint:fix`, `composer analyse`, and the complete `tests/Tinker` group in that order. +6. Perform a fresh caller/callee, process-global state, terminal/signal, public API, cold-path performance, retained-memory, stale-code, and overengineering review. +7. Apply review corrections, rerun affected focused tests, and repeat the complete gate when changes warrant it. + +## Rejected designs and non-findings + +- No local shell subclass, listener filter, public/protected include loader, nested include reloading, generated `require_once` source, private-method reflection, copied include loop, switch to PsySH's noninteractive runner, or version-specific compatibility path. +- No signal/error-handler snapshot around yielding Hypervel code, process isolation, lock, listener registry, mode router, or coroutine context. +- No removal of interactive signal handling. Keep the existing `setUsePcntl(false)` invariant; do not add a `ProcessForker` listener filter. +- No class-alias registry, unalias attempt, path canonicalization, classmap cache, or concurrency machinery. PHP has no coroutine-local class table, and concurrent REPLs in one worker are unsupported. +- Keep `ClassAliasAutoloader::__destruct()`: while registered, the autoload callback retains the object; normal `finally` cleanup unregisters it first, and destruction remains an idempotent fallback. +- Keep the shell's command set invocation-local and preserve existing caster precedence. Configured command registration on the Kernel-cached Console application follows upstream Tinker behavior. +- Keep the null guard around dynamic Application getter results; only its failure boundary widens. +- Do not add default caster config, exhaustive docs, suggestions for packages already required transitively, or tests that merely mirror trivial mappings. +- Do not add `--trust-project` or `--no-trust-project` to Tinker. Supporting them would add two options and reorder trust configuration to duplicate the existing environment-variable control. +- `setTrustProject()` would override trust flags parsed by PsySH, but neither Laravel nor Hypervel defines those flags on the Tinker command. This unreachable shared behavior is not a defect. + +## Expected result + +Tinker preserves its Laravel-facing API and Hypervel's coroutine/no-fork adaptations while direct execution becomes exact for falsey code, includes, exit status, disabled commands, process-global cleanup, and untrusted project configuration. Alias discovery respects semantic boundaries; presentation degrades per property; metadata and docs describe the real package. All work remains cold developer-console work, with no application hot-path or high-scale footprint. No accepted defect, workaround, stale branch, compatibility shim, TODO, or speculative machinery remains in the completed Hypervel package. diff --git a/src/docs/artisan.md b/src/docs/artisan.md index 1055a6e185..987fb86aa3 100644 --- a/src/docs/artisan.md +++ b/src/docs/artisan.md @@ -65,6 +65,22 @@ Tinker allows you to interact with your entire Hypervel application on the comma php artisan tinker ``` +You may also execute code without opening the interactive shell using the `--execute` option: + +```shell +php artisan tinker --execute='echo App\Models\User::count();' +``` + +The command returns an exit status of zero when the code completes successfully. If the code calls `exit`, Artisan returns the requested exit status. Uncaught exceptions return an exit status of one. + +You may pass one or more PHP files to load before Tinker executes your code: + +```shell +php artisan tinker bootstrap.php --execute='echo $message;' +``` + +If an included file cannot be loaded, Tinker reports the error and continues. The command's exit status still reflects the executed code. + You can publish Tinker's configuration file using the `vendor:publish` command and Tinker's publish tag: ```shell @@ -81,7 +97,7 @@ php artisan vendor:publish --provider="Hypervel\Tinker\TinkerServiceProvider" > The `dispatch` helper function and `dispatch` method on the `Dispatchable` class depend on garbage collection to place the job on the queue. Therefore, when using Tinker, you should use `Bus::dispatch` or `Queue::push` to dispatch jobs. > [!NOTE] -> Hypervel Tinker disables PsySH's pcntl support because `pcntl_fork` is incompatible with Swoole's coroutine scheduler. +> Hypervel Tinker disables PsySH's process forking because `pcntl_fork` is incompatible with Swoole's coroutine scheduler. #### Command Allow List @@ -95,9 +111,17 @@ Tinker utilizes an "allow" list to determine which Artisan commands are allowed ``` -#### Classes That Should Not Be Aliased +#### Class Aliases + +Tinker does not automatically alias classes from your application's dependencies. To allow a specific vendor class or namespace, add its fully qualified name to the `alias` array of your `tinker.php` configuration file: + +```php +'alias' => [ + 'Vendor\Package', +], +``` -Typically, Tinker automatically aliases classes as you interact with them in Tinker. However, you may wish to never alias some classes. You may accomplish this by listing the classes in the `dont_alias` array of your `tinker.php` configuration file: +You may also prevent application classes from being aliased by adding them to the `dont_alias` array: ```php 'dont_alias' => [ @@ -105,6 +129,32 @@ Typically, Tinker automatically aliases classes as you interact with them in Tin ], ``` + +#### Custom Casters + +Tinker uses Symfony VarDumper casters to present objects in the shell. You may register custom casters in your `tinker.php` configuration file: + +```php +'casters' => [ + App\Money::class => App\Tinker\MoneyCaster::class . '::cast', +], +``` + +Application casters take precedence over Tinker's default casters. + + +#### Trusting Project Configuration + +PsySH may load project-specific configuration from a local `.psysh.php` file. By default, Tinker asks you to trust an unfamiliar project before loading this file. During non-interactive execution, untrusted project configuration is skipped. If PsySH suggests the `--trust-project` option, use the environment variable below instead; Artisan does not expose this option. + +If Tinker only runs from a trusted working directory, you may set the `trust_project` option in your `tinker.php` configuration file to `always`. You may also trust the project for a single command using the `TINKER_TRUST_PROJECT` environment variable: + +```shell +TINKER_TRUST_PROJECT=always php artisan tinker --execute='echo App\Models\User::count();' +``` + +To prevent Tinker from loading local project configuration, set `trust_project` to `never`. + ## Writing Commands diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index a86cbf65d6..2dc4397875 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -32,6 +32,7 @@ - [Dates](#dates) - [UUIDs](#uuids) - [Filesystem](#filesystem) + - [Tinker](#tinker) - [Database, Cache, Sessions, and Queues](#database-cache-sessions-and-queues) - [Database](#database) - [Redis](#redis) @@ -554,6 +555,11 @@ Hypervel's `Filesystem::hash()` method uses `xxh128` by default. Pass `md5` expl Unlike Laravel, Hypervel honors `read-only` on scoped disk records. Remove that option from any scoped disk that must accept writes. + +### Tinker + +Hypervel uses PsySH's prompt project-trust mode by default, while Laravel Tinker trusts `.psysh.php` configuration automatically. Interactive sessions ask before loading an unfamiliar project, and non-interactive sessions skip its configuration. Applications that rely on loading this file without confirmation should set `trust_project` or `TINKER_TRUST_PROJECT` to `always` when Tinker runs from a trusted working directory. See the [Tinker documentation](/docs/{{version}}/artisan#trusting-project-configuration) for more information. + ## Database, Cache, Sessions, and Queues diff --git a/src/tinker/README.md b/src/tinker/README.md index 6a7894d78b..dd3ddd2bdb 100644 --- a/src/tinker/README.md +++ b/src/tinker/README.md @@ -2,3 +2,11 @@ Tinker for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/tinker) + +## Differences From Laravel + +Hypervel disables PsySH process forking because it is incompatible with Swoole. A fatal error ends the Tinker session instead of only ending the current evaluation. + +Hypervel uses PsySH's prompt project-trust mode by default, while Laravel Tinker trusts `.psysh.php` configuration automatically. Interactive sessions ask before loading an unfamiliar project, and non-interactive sessions skip its configuration. + +Ported from: https://github.com/laravel/tinker diff --git a/src/tinker/composer.json b/src/tinker/composer.json index 7b8fcae928..fb7dcd1772 100644 --- a/src/tinker/composer.json +++ b/src/tinker/composer.json @@ -34,16 +34,12 @@ "php": "^8.4", "hypervel/collections": "^0.4", "hypervel/console": "^0.4", - "hypervel/contracts": "^0.4", "hypervel/foundation": "^0.4", "hypervel/support": "^0.4", - "psy/psysh": "^0.12.22", + "psy/psysh": "dev-main", "symfony/console": "^8.1", "symfony/var-dumper": "^8.1" }, - "suggest": { - "hypervel/database": "Required for Eloquent model casting in Tinker (^0.4)." - }, "config": { "sort-packages": true }, diff --git a/src/tinker/config/tinker.php b/src/tinker/config/tinker.php index 36116ed871..8067974b54 100644 --- a/src/tinker/config/tinker.php +++ b/src/tinker/config/tinker.php @@ -63,11 +63,12 @@ | Project Trust Mode |-------------------------------------------------------------------------- | - | PsySH restricts local project features unless your project is trusted. - | Set this to "always" to avoid untrusted project warnings in Tinker. + | PsySH asks before loading local features from an unfamiliar project. + | Non-interactive runs skip untrusted project configuration instead. + | You may use "always" when Tinker only runs from trusted directories. | Accepted values: "prompt", "always", "never", true, false, null. | */ - 'trust_project' => env('TINKER_TRUST_PROJECT', 'always'), + 'trust_project' => env('TINKER_TRUST_PROJECT', 'prompt'), ]; diff --git a/src/tinker/src/ClassAliasAutoloader.php b/src/tinker/src/ClassAliasAutoloader.php index c84e2d51ac..26137a149c 100644 --- a/src/tinker/src/ClassAliasAutoloader.php +++ b/src/tinker/src/ClassAliasAutoloader.php @@ -50,8 +50,10 @@ public function __construct( array $excludedAliases = [], ) { $this->vendorPath = dirname(dirname($classMapPath)); - $this->includedAliases = collect($includedAliases); - $this->excludedAliases = collect($excludedAliases); + $this->includedAliases = collect($includedAliases) + ->map(static fn (string $alias): string => trim($alias, '\\')); + $this->excludedAliases = collect($excludedAliases) + ->map(static fn (string $alias): string => trim($alias, '\\')); $classes = require $classMapPath; @@ -111,22 +113,30 @@ public function isAliasable(string $class, string $path): bool return false; } - if ($this->includedAliases->contains(function ($alias) use ($class) { - return Str::startsWith($class, $alias); - })) { + if ($this->includedAliases->contains( + static fn (string $alias): bool => self::matchesAlias($class, $alias) + )) { return true; } - if (Str::startsWith($path, $this->vendorPath)) { + if (Str::startsWith($path, $this->vendorPath . DIRECTORY_SEPARATOR)) { return false; } - if ($this->excludedAliases->contains(function ($alias) use ($class) { - return Str::startsWith($class, $alias); - })) { + if ($this->excludedAliases->contains( + static fn (string $alias): bool => self::matchesAlias($class, $alias) + )) { return false; } return true; } + + /** + * Determine whether a class matches an alias boundary. + */ + private static function matchesAlias(string $class, string $alias): bool + { + return $class === $alias || Str::startsWith($class, $alias . '\\'); + } } diff --git a/src/tinker/src/Console/TinkerCommand.php b/src/tinker/src/Console/TinkerCommand.php index 34fb4b49a0..1c469c9c59 100644 --- a/src/tinker/src/Console/TinkerCommand.php +++ b/src/tinker/src/Console/TinkerCommand.php @@ -8,6 +8,7 @@ use Hypervel\Support\Env; use Hypervel\Tinker\ClassAliasAutoloader; use Psy\Configuration; +use Psy\Exception\BreakException; use Psy\Shell; use Psy\VersionUpdater\Checker; use Symfony\Component\Console\Attribute\AsCommand; @@ -40,8 +41,6 @@ class TinkerCommand extends Command */ public function handle(): int { - $this->getApplication()->setCatchExceptions(false); - $config = Configuration::fromInput($this->input); $config->setUpdateCheck(Checker::NEVER); @@ -57,11 +56,15 @@ public function handle(): int $this->getCasters() ); - if ($this->option('execute')) { + /** @var ?string $code */ + $code = $this->option('execute'); + + if ($code !== null) { $config->setRawOutput(true); } $shell = new Shell($config); + $shell->addCommands($this->getCommands()); $shell->setIncludes($this->argument('include')); @@ -76,10 +79,12 @@ public function handle(): int $appConfig->array('tinker.dont_alias', []) ); - if ($code = $this->option('execute')) { + if ($code !== null) { try { $shell->setOutput($this->output); $shell->execute($code, true); + } catch (BreakException $e) { + return $e->getCode(); } catch (Throwable $e) { $shell->writeException($e); @@ -114,9 +119,11 @@ protected function getCommands(): array $config = $this->getHypervel()->make('config'); foreach ($config->array('tinker.commands', []) as $command) { - $commands[] = $this->getApplication()->addCommand( + if (($command = $this->getApplication()->addCommand( $this->getHypervel()->make($command) - ); + )) !== null) { + $commands[] = $command; + } } return $commands; @@ -131,20 +138,11 @@ protected function getCasters(): array 'Hypervel\Support\Collection' => 'Hypervel\Tinker\TinkerCaster::castCollection', 'Hypervel\Support\HtmlString' => 'Hypervel\Tinker\TinkerCaster::castHtmlString', 'Hypervel\Support\Stringable' => 'Hypervel\Tinker\TinkerCaster::castStringable', + 'Hypervel\Database\Eloquent\Model' => 'Hypervel\Tinker\TinkerCaster::castModel', + 'Hypervel\Process\ProcessResult' => 'Hypervel\Tinker\TinkerCaster::castProcessResult', + 'Hypervel\Foundation\Application' => 'Hypervel\Tinker\TinkerCaster::castApplication', ]; - if (class_exists('Hypervel\Database\Eloquent\Model')) { - $casters['Hypervel\Database\Eloquent\Model'] = 'Hypervel\Tinker\TinkerCaster::castModel'; - } - - if (class_exists('Hypervel\Process\ProcessResult')) { - $casters['Hypervel\Process\ProcessResult'] = 'Hypervel\Tinker\TinkerCaster::castProcessResult'; - } - - if (class_exists('Hypervel\Foundation\Application')) { - $casters['Hypervel\Foundation\Application'] = 'Hypervel\Tinker\TinkerCaster::castApplication'; - } - $config = $this->getHypervel()->make('config'); return array_merge($casters, $config->array('tinker.casters', [])); diff --git a/src/tinker/src/TinkerCaster.php b/src/tinker/src/TinkerCaster.php index 5833009b06..9b04a90361 100644 --- a/src/tinker/src/TinkerCaster.php +++ b/src/tinker/src/TinkerCaster.php @@ -4,7 +4,6 @@ namespace Hypervel\Tinker; -use Exception; use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Application; use Hypervel\Process\ProcessResult; @@ -12,6 +11,7 @@ use Hypervel\Support\HtmlString; use Hypervel\Support\Stringable; use Symfony\Component\VarDumper\Caster\Caster; +use Throwable; class TinkerCaster { @@ -45,13 +45,13 @@ public static function castApplication(Application $app): array foreach (self::$appProperties as $property) { try { - $val = $app->{$property}(); + $value = $app->{$property}(); - if (! is_null($val)) { - $results[Caster::PREFIX_VIRTUAL . $property] = $val; + if ($value !== null) { + $results[Caster::PREFIX_VIRTUAL . $property] = $value; } - } catch (Exception) { - // An unavailable probe is omitted without hiding the remaining details. + } catch (Throwable) { + // An unavailable presentation value should not hide the remaining properties. } } diff --git a/tests/Tinker/ClassAliasAutoloaderTest.php b/tests/Tinker/ClassAliasAutoloaderTest.php index d543aa3e4f..22120a001d 100644 --- a/tests/Tinker/ClassAliasAutoloaderTest.php +++ b/tests/Tinker/ClassAliasAutoloaderTest.php @@ -15,7 +15,7 @@ class ClassAliasAutoloaderTest extends TestCase { protected string $classmapPath; - protected ClassAliasAutoloader $loader; + protected ?ClassAliasAutoloader $loader = null; protected function setUp(): void { @@ -26,12 +26,14 @@ protected function setUp(): void protected function tearDown(): void { - $this->loader->unregister(); - - parent::tearDown(); + try { + $this->loader?->unregister(); + } finally { + parent::tearDown(); + } } - public function testCanAliasClasses() + public function testCanAliasClasses(): void { $this->loader = ClassAliasAutoloader::register( $shell = m::mock(Shell::class), @@ -46,7 +48,7 @@ public function testCanAliasClasses() $this->assertInstanceOf(TinkerBar::class, new \TinkerBar); } - public function testCanExcludeNamespacesFromAliasing() + public function testCanExcludeNamespacesFromAliasing(): void { $this->loader = ClassAliasAutoloader::register( $shell = m::mock(Shell::class), @@ -60,19 +62,21 @@ public function testCanExcludeNamespacesFromAliasing() $this->assertFalse(class_exists('TinkerQux')); } - public function testVendorClassesAreExcluded() + public function testVendorClassesAreExcluded(): void { - $this->loader = ClassAliasAutoloader::register( + $loader = new ClassAliasAutoloader( $shell = m::mock(Shell::class), $this->classmapPath ); $shell->shouldNotReceive('writeStdout'); - $this->assertFalse(class_exists('TinkerThree')); + // PHP class aliases are permanent, so call the loader directly and let the + // Mockery expectation prove this vendor class was excluded. + $loader->aliasClass('TinkerThree'); } - public function testVendorClassesCanBeWhitelisted() + public function testVendorClassesCanBeWhitelisted(): void { $this->loader = ClassAliasAutoloader::register( $shell = m::mock(Shell::class), @@ -87,4 +91,43 @@ public function testVendorClassesCanBeWhitelisted() $this->assertTrue(class_exists('TinkerThree')); $this->assertInstanceOf(TinkerThree::class, new \TinkerThree); } + + public function testIncludedAliasesMatchClassAndNamespaceBoundaries(): void + { + $loader = new ClassAliasAutoloader( + m::mock(Shell::class), + $this->classmapPath, + ['Acme\Package\Thing\\'], + ); + $vendorPath = dirname($this->classmapPath, 2); + + $this->assertTrue($loader->isAliasable('Acme\Package\Thing', $vendorPath . '/Thing.php')); + $this->assertTrue($loader->isAliasable('Acme\Package\Thing\Child', $vendorPath . '/Child.php')); + $this->assertFalse($loader->isAliasable('Acme\Package\ThingElse', $vendorPath . '/ThingElse.php')); + } + + public function testExcludedAliasesMatchClassAndNamespaceBoundaries(): void + { + $loader = new ClassAliasAutoloader( + m::mock(Shell::class), + $this->classmapPath, + [], + ['App\Nova\\'], + ); + $applicationPath = dirname($this->classmapPath, 3) . '/App'; + + $this->assertFalse($loader->isAliasable('App\Nova', $applicationPath . '/Nova.php')); + $this->assertFalse($loader->isAliasable('App\Nova\Resource', $applicationPath . '/Resource.php')); + $this->assertTrue($loader->isAliasable('App\NovaThing', $applicationPath . '/NovaThing.php')); + } + + public function testVendorPathsMatchDirectoryBoundaries(): void + { + $loader = new ClassAliasAutoloader(m::mock(Shell::class), $this->classmapPath); + $vendorPath = dirname($this->classmapPath, 2); + + $this->assertFalse($loader->isAliasable('Vendor\Package\Thing', $vendorPath . '/Package/Thing.php')); + $this->assertTrue($loader->isAliasable('App\VendorThing', $vendorPath . '-local/VendorThing.php')); + $this->assertFalse($loader->isAliasable('VendorThing', $vendorPath . '-local/VendorThing.php')); + } } diff --git a/tests/Tinker/Fixtures/Vendor/composer/autoload_classmap.php b/tests/Tinker/Fixtures/Vendor/composer/autoload_classmap.php index 3803afe911..3393484009 100644 --- a/tests/Tinker/Fixtures/Vendor/composer/autoload_classmap.php +++ b/tests/Tinker/Fixtures/Vendor/composer/autoload_classmap.php @@ -8,5 +8,4 @@ 'Hypervel\Tests\Tinker\Fixtures\App\Foo\TinkerBar' => $baseDir . '/App/Foo/TinkerBar.php', 'Hypervel\Tests\Tinker\Fixtures\App\Baz\TinkerQux' => $baseDir . '/App/Baz/TinkerQux.php', 'Hypervel\Tests\Tinker\Fixtures\Vendor\One\Two\TinkerThree' => $vendorDir . '/One/Two/TinkerThree.php', - 'Four\Five\Six' => $vendorDir . '/Four/Five/Six.php', ]; diff --git a/tests/Tinker/PackageMetadataTest.php b/tests/Tinker/PackageMetadataTest.php new file mode 100644 index 0000000000..878c3400e1 --- /dev/null +++ b/tests/Tinker/PackageMetadataTest.php @@ -0,0 +1,57 @@ +assertArrayHasKey($dependency, $composer['require']); + } + + foreach (['psy/psysh', 'symfony/console', 'symfony/var-dumper'] as $dependency) { + $this->assertSame($rootComposer['require'][$dependency], $composer['require'][$dependency]); + } + + $this->assertArrayNotHasKey('hypervel/contracts', $composer['require']); + $this->assertArrayNotHasKey('suggest', $composer); + $this->assertSame([ + TinkerServiceProvider::class, + ], $composer['extra']['hypervel']['providers']); + $this->assertContains( + TinkerServiceProvider::class, + $rootComposer['extra']['hypervel']['providers'], + ); + } +} diff --git a/tests/Tinker/TinkerCasterTest.php b/tests/Tinker/TinkerCasterTest.php index d30df9ec50..f234497b95 100644 --- a/tests/Tinker/TinkerCasterTest.php +++ b/tests/Tinker/TinkerCasterTest.php @@ -4,10 +4,13 @@ namespace Hypervel\Tests\Tinker; +use Error; use Hypervel\Database\Eloquent\Model; +use Hypervel\Foundation\Application; use Hypervel\Support\Collection; use Hypervel\Tests\TestCase; use Hypervel\Tinker\TinkerCaster; +use Mockery as m; use Symfony\Component\VarDumper\Caster\Caster; class TinkerCasterTest extends TestCase @@ -19,6 +22,17 @@ public function testCanCastCollection(): void $this->assertSame([['foo', 'bar']], array_values($result)); } + public function testApplicationPropertyErrorsDoNotSuppressLaterValues(): void + { + $application = m::mock(Application::class); + $application->shouldReceive('configurationIsCached')->once()->andThrow(new Error('Unavailable')); + $application->shouldReceive('version')->once()->andReturn('1.0.0'); + + $result = TinkerCaster::castApplication($application); + + $this->assertSame('1.0.0', $result[Caster::PREFIX_VIRTUAL . 'version']); + } + public function testCanCastModelWithoutAppendedAttributes(): void { $model = new TinkerCasterModel; diff --git a/tests/Tinker/TinkerCommandTest.php b/tests/Tinker/TinkerCommandTest.php index 6d7cc47734..9775333d9e 100644 --- a/tests/Tinker/TinkerCommandTest.php +++ b/tests/Tinker/TinkerCommandTest.php @@ -4,16 +4,26 @@ namespace Hypervel\Tests\Tinker; +use Hypervel\Console\Application as ConsoleApplication; +use Hypervel\Console\Command; +use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Contracts\Foundation\Application; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\ClassInvoker; use Hypervel\Support\Env; +use Hypervel\Support\ServiceProvider; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; use Hypervel\Tinker\Console\TinkerCommand; use Hypervel\Tinker\TinkerServiceProvider; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; use Psy\Configuration; use Psy\VarDumper\Presenter; +use Symfony\Component\Console\Command\Command as SymfonyCommand; +use Symfony\Component\Process\Exception\ProcessTimedOutException; +use Symfony\Component\Process\InputStream; +use Symfony\Component\Process\Process; use Symfony\Component\VarDumper\Caster\Caster; class TinkerCommandTest extends TestCase @@ -91,12 +101,242 @@ public function testOptionalCommandAndAliasListsMayBeOmitted(): void ->assertExitCode(0); } + public function testNullProjectTrustIsAccepted(): void + { + config()->set('tinker.trust_project', null); + + $this->artisan('tinker', ['--execute' => 'echo "hello";']) + ->assertExitCode(0); + } + public function testExecuteFailure(): void { $this->artisan('tinker', ['--execute' => 'throw new \Exception("fail");']) ->assertExitCode(1); } + public function testExecuteReturnsRequestedExitCodeWithoutRenderingAnError(): void + { + $this->artisan('tinker', ['--execute' => 'exit(3);']) + ->doesntExpectOutput() + ->assertExitCode(3); + } + + public function testExecuteLoadsPositionalAndProjectIncludes(): void + { + // This test covers include scope, so trust its isolated project explicitly. + config()->set('tinker.trust_project', 'always'); + + $workingDirectory = getcwd(); + $positionalInclude = $this->temporaryDirectory . '/scope-positional.php'; + $projectInclude = $this->temporaryDirectory . '/scope-project.php'; + $result = $this->temporaryDirectory . '/scope-result.txt'; + + file_put_contents($positionalInclude, 'temporaryDirectory . '/.psysh.php', + ' [' . var_export($projectInclude, true) . ']];', + ); + + $this->assertTrue(chdir($this->temporaryDirectory)); + + try { + $this->artisan('tinker', [ + 'include' => [$positionalInclude], + '--execute' => sprintf( + "file_put_contents('%s', \$positionalValue . ':' . \$projectValue);", + addslashes($result), + ), + ])->assertExitCode(0); + } finally { + chdir($workingDirectory); + } + + $this->assertSame('positional:project', file_get_contents($result)); + } + + public function testExecuteDoesNotLoadUntrustedProjectConfigurationByDefault(): void + { + $workingDirectory = getcwd(); + $sentinel = $this->temporaryDirectory . '/untrusted-project.txt'; + + file_put_contents( + $this->temporaryDirectory . '/.psysh.php', + 'assertTrue(chdir($this->temporaryDirectory)); + + try { + $this->artisan('tinker', ['--execute' => 'echo "hello";']) + ->assertExitCode(0); + } finally { + chdir($workingDirectory); + } + + $this->assertFileDoesNotExist($sentinel); + } + + public function testExecuteContinuesAfterMalformedIncludeAndRestoresErrorHandler(): void + { + $invalidInclude = $this->temporaryDirectory . '/failure-invalid.php'; + $validInclude = $this->temporaryDirectory . '/failure-valid.php'; + $result = $this->temporaryDirectory . '/failure-result.txt'; + + file_put_contents($invalidInclude, 'withoutMockingConsoleOutput(); + + $exitCode = $this->artisan('tinker', [ + 'include' => [$invalidInclude, $validInclude], + '--execute' => sprintf( + "file_put_contents('%s', \$includedValue);", + addslashes($result), + ), + ]); + } finally { + $observedHandler = set_error_handler(static function (): bool { + return true; + }); + restore_error_handler(); + + if ($observedHandler !== $handler) { + restore_error_handler(); + } + + restore_error_handler(); + } + + $output = $this->app->make(KernelContract::class)->output(); + + $this->assertSame(0, $exitCode); + $this->assertStringContainsString('ParseError', $output); + $this->assertSame('included', file_get_contents($result)); + $this->assertSame($handler, $observedHandler); + } + + #[DataProvider('falseyExecuteCodeProvider')] + public function testFalseyExecuteValuesUseDirectExecution(string $code): void + { + $providersPath = BASE_PATH . '/bootstrap/providers.php'; + $originalProviders = file_get_contents($providersPath); + + if (! is_string($originalProviders)) { + $this->fail('Unable to read the Testbench provider file.'); + } + + $input = new InputStream; + $process = new Process( + command: [PHP_BINARY, BASE_PATH . '/artisan', 'tinker', '--execute=' . $code], + cwd: dirname(__DIR__, 2), + env: array_merge($_ENV, [ + 'COMPOSER_VENDOR_DIR' => (string) Env::get('COMPOSER_VENDOR_DIR'), + 'HYPERVEL_AUTOLOAD_PATH' => dirname(__DIR__, 2) . '/vendor/autoload.php', + ]), + timeout: 10, + ); + $process->setInput($input); + + try { + $this->assertTrue(ServiceProvider::addProviderToBootstrapFile( + TinkerServiceProvider::class, + $providersPath, + )); + + $process->run(); + } catch (ProcessTimedOutException) { + $this->fail('Falsey execute input started the interactive shell.'); + } finally { + $input->close(); + $process->stop(); + file_put_contents($providersPath, $originalProviders); + } + + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + } + + public static function falseyExecuteCodeProvider(): array + { + return [ + ['0'], + [''], + ]; + } + + #[DataProvider('directExecutionOutcomeProvider')] + #[RequiresPhpExtension('pcntl')] + #[RequiresPhpExtension('posix')] + public function testDirectExecutionPreservesTheSigintHandler(string $code, int $exitCode): void + { + $originalHandler = pcntl_signal_get_handler(SIGINT); + $sentinelHandler = static function (): void { + }; + + pcntl_signal(SIGINT, $sentinelHandler); + + try { + $this->artisan('tinker', ['--execute' => $code]) + ->assertExitCode($exitCode); + + $this->assertSame($sentinelHandler, pcntl_signal_get_handler(SIGINT)); + } finally { + pcntl_signal(SIGINT, $originalHandler); + } + } + + public static function directExecutionOutcomeProvider(): array + { + return [ + ['echo "hello";', 0], + ['throw new \Exception("fail");', 1], + ]; + } + + public function testExecuteDoesNotChangeTheConsoleExceptionPolicy(): void + { + $application = $this->app->make(KernelContract::class)->getArtisan(); + + $this->assertInstanceOf(ConsoleApplication::class, $application); + + $application->setCatchExceptions(true); + + $this->artisan('tinker', ['--execute' => 'echo "hello";']) + ->assertExitCode(0); + + $this->assertTrue($application->areExceptionsCaught()); + } + + public function testConfiguredCommandsIncludeEnabledCommandsInOrderAndIgnoreDisabledCommands(): void + { + config()->set('tinker.commands', [ + EnabledTinkerCommand::class, + DisabledTinkerCommand::class, + ]); + + /** @var TinkerCommand $command */ + $command = $this->app->make(TinkerCommand::class); + $command->setHypervel($this->app); + $command->setApplication($this->app->make(KernelContract::class)->getArtisan()); + + $commands = (new ClassInvoker($command))->getCommands(); + $names = array_map( + static fn (SymfonyCommand $command): ?string => $command->getName(), + $commands, + ); + + $this->assertContains('env', $names); + $this->assertSame('tinker:enabled', $names[array_key_last($names)]); + $this->assertNotContains('tinker:disabled', $names); + } + public function testExecuteRunsInsideCoroutine(): void { $file = $this->temporaryDirectory . '/coroutine.txt'; @@ -136,6 +376,24 @@ public function testConfiguredCasterIsAppliedByTheTinkerPresenter(): void } } +class DisabledTinkerCommand extends Command +{ + protected ?string $name = 'tinker:disabled'; + + /** + * Determine whether the command is enabled. + */ + public function isEnabled(): bool + { + return false; + } +} + +class EnabledTinkerCommand extends Command +{ + protected ?string $name = 'tinker:enabled'; +} + class TinkerCommandTestValue { } diff --git a/tests/Tinker/TinkerServiceProviderTest.php b/tests/Tinker/TinkerServiceProviderTest.php index 58610c2446..fabb0892ea 100644 --- a/tests/Tinker/TinkerServiceProviderTest.php +++ b/tests/Tinker/TinkerServiceProviderTest.php @@ -56,7 +56,7 @@ public function testTinkerConfigIsMerged(): void $this->assertIsArray($config->get('tinker.commands')); $this->assertIsArray($config->get('tinker.alias')); $this->assertIsArray($config->get('tinker.dont_alias')); - $this->assertNotNull($config->get('tinker.trust_project')); + $this->assertSame('prompt', $config->get('tinker.trust_project')); } public function testPublishedConfigDoesNotExcludeApplicationNamespacesByDefault(): void