From ff34671dd0e7f2da67eb3d3616e197e5563a9e04 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 14 Aug 2026 17:23:25 -0400 Subject: [PATCH 01/13] feat: add framework agnostic debug snapshot contracts, strict JSON hydration, and filesystem persistence. --- .editorconfig | 48 ++ .gitattributes | 43 ++ .github/linters/.codespellrc | 2 + .github/linters/.editorconfig-checker.json | 23 + .github/linters/.gitleaks.toml | 4 + .github/linters/.markdown-lint.yml | 5 + .gitignore | 64 ++- .prettierignore | 28 + .prettierrc.json | 22 + .styleci.yml | 88 +++ .stylelintignore | 1 + CHANGELOG.md | 10 + README.md | 27 + composer-require-checker.json | 16 + composer.json | 74 +++ ecs.php | 8 + infection.json5 | 10 + phpstan.neon | 20 + phpunit.xml.dist | 27 + rector.php | 10 + runtime/.gitignore | 2 + scaffold-lock.json | 98 ++++ src/Storage/ArrayPayloadSnapshot.php | 127 ++++ src/Storage/DebugArray.php | 103 ++++ src/Storage/DebugSnapshot.php | 102 ++++ src/Storage/DebugValue.php | 543 ++++++++++++++++++ src/Storage/ExceptionSnapshot.php | 377 ++++++++++++ src/Storage/HydrationException.php | 34 ++ src/Storage/Manifest.php | 94 +++ src/Storage/PanelFailure.php | 111 ++++ src/Storage/PanelRow.php | 29 + src/Storage/PanelSnapshot.php | 26 + src/Storage/Payload.php | 466 +++++++++++++++ src/Storage/RequestSummary.php | 176 ++++++ src/Storage/SnapshotStore.php | 439 ++++++++++++++ src/Storage/StorageException.php | 12 + tests/Storage/ArrayPayloadSnapshotTest.php | 33 ++ tests/Storage/DebugArrayTest.php | 39 ++ tests/Storage/DebugSnapshotTest.php | 100 ++++ tests/Storage/DebugValueTest.php | 335 +++++++++++ tests/Storage/ExceptionSnapshotTest.php | 106 ++++ tests/Storage/ManifestTest.php | 80 +++ tests/Storage/PanelFailureTest.php | 50 ++ tests/Storage/PayloadTest.php | 182 ++++++ tests/Storage/RequestSummaryTest.php | 114 ++++ tests/Storage/SnapshotStoreTest.php | 301 ++++++++++ tests/Support/ArrayPayloadSnapshotFixture.php | 35 ++ tests/Support/MockerExtension.php | 83 +++ tests/Support/mocker-stubs.php | 8 + 49 files changed, 4729 insertions(+), 6 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/linters/.codespellrc create mode 100644 .github/linters/.editorconfig-checker.json create mode 100644 .github/linters/.gitleaks.toml create mode 100644 .github/linters/.markdown-lint.yml create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 .styleci.yml create mode 100644 .stylelintignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 composer-require-checker.json create mode 100644 composer.json create mode 100644 ecs.php create mode 100644 infection.json5 create mode 100644 phpstan.neon create mode 100644 phpunit.xml.dist create mode 100644 rector.php create mode 100644 runtime/.gitignore create mode 100644 scaffold-lock.json create mode 100644 src/Storage/ArrayPayloadSnapshot.php create mode 100644 src/Storage/DebugArray.php create mode 100644 src/Storage/DebugSnapshot.php create mode 100644 src/Storage/DebugValue.php create mode 100644 src/Storage/ExceptionSnapshot.php create mode 100644 src/Storage/HydrationException.php create mode 100644 src/Storage/Manifest.php create mode 100644 src/Storage/PanelFailure.php create mode 100644 src/Storage/PanelRow.php create mode 100644 src/Storage/PanelSnapshot.php create mode 100644 src/Storage/Payload.php create mode 100644 src/Storage/RequestSummary.php create mode 100644 src/Storage/SnapshotStore.php create mode 100644 src/Storage/StorageException.php create mode 100644 tests/Storage/ArrayPayloadSnapshotTest.php create mode 100644 tests/Storage/DebugArrayTest.php create mode 100644 tests/Storage/DebugSnapshotTest.php create mode 100644 tests/Storage/DebugValueTest.php create mode 100644 tests/Storage/ExceptionSnapshotTest.php create mode 100644 tests/Storage/ManifestTest.php create mode 100644 tests/Storage/PanelFailureTest.php create mode 100644 tests/Storage/PayloadTest.php create mode 100644 tests/Storage/RequestSummaryTest.php create mode 100644 tests/Storage/SnapshotStoreTest.php create mode 100644 tests/Support/ArrayPayloadSnapshotFixture.php create mode 100644 tests/Support/MockerExtension.php create mode 100644 tests/Support/mocker-stubs.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..949335f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,48 @@ +# editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{json,json5,neon,yaml,yml}] +[*.js] +indent_size = 2 + +[*.scss] +indent_size = 2 + +[*.css] +indent_size = 2 + +[package.json] +indent_size = 2 + +[package-lock.json] +indent_size = 2 + +[*.md] +indent_size = 2 +trim_trailing_whitespace = false + +[*.php] +ij_php_space_before_short_closure_left_parenthesis = false +ij_php_space_after_type_cast = true + +[*.yaml] +indent_size = 2 + +[*.yml] +indent_size = 2 + +[*.xml.dist] +indent_size = 2 + +[LICENSE*] +indent_style = unset +indent_size = unset diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2360366 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,43 @@ +# Autodetect text files +* text=auto eol=lf + +# ...Unless the name matches the following overriding patterns + +# Definitively text files +*.php text +*.css text +*.js text +*.txt text +*.md text +*.xml text +*.json text +*.bat text +*.sql text +*.yml text + +# Ensure those won't be messed up with +*.png binary +*.jpg binary +*.gif binary +*.ttf binary + +# Exclude files from the archive +/.editorconfig export-ignore +/.gitattributes export-ignore +/.github export-ignore +/.gitignore export-ignore +/.styleci.yml export-ignore +/codeception.yml export-ignore +/composer-require-checker.json export-ignore +/docs export-ignore +/ecs.php export-ignore +/infection.json* export-ignore +/phpstan*.neon* export-ignore +/phpunit.xml.dist export-ignore +/rector.php export-ignore +/runtime export-ignore +/tests export-ignore + +# Avoid merge conflicts in CHANGELOG +# https://about.gitlab.com/2015/02/10/gitlab-reduced-merge-conflicts-by-90-percent-with-changelog-placeholders/ +/CHANGELOG.md merge=union diff --git a/.github/linters/.codespellrc b/.github/linters/.codespellrc new file mode 100644 index 0000000..636396c --- /dev/null +++ b/.github/linters/.codespellrc @@ -0,0 +1,2 @@ +[codespell] +skip = ./.git,.claude/*,.codex/*,.config/gh/*,.github/agents/*,.github/copilot-instructions.md,.github/copilot/*,.github/instructions/*,.github/prompts/*,.github/skills/*,.vscode/*,*/composer.json,*/composer.lock,*/dist/*,*/node_modules/*,*/package-lock.json,*/tests/*,code_coverage/*,CHANGELOG.md,UPGRADE.md,runtime/*,scaffold-lock.json,vendor/* diff --git a/.github/linters/.editorconfig-checker.json b/.github/linters/.editorconfig-checker.json new file mode 100644 index 0000000..3b52d57 --- /dev/null +++ b/.github/linters/.editorconfig-checker.json @@ -0,0 +1,23 @@ +{ + "Exclude": [ + "\\.min\\.css$", + "\\.min\\.js$", + "^\\.claude/", + "^\\.codex/", + "^\\.config/gh/", + "^\\.github/agents/", + "^\\.github/copilot-instructions\\.md$", + "^\\.github/copilot/", + "^\\.github/instructions/", + "^\\.github/prompts/", + "^\\.github/skills/", + "^\\.vscode/", + "^code_coverage/", + "^composer\\.lock$", + "^runtime/", + "^tests/runtime/", + "^vendor/", + "phpstan-baseline\\.neon$", + "scaffold-lock\\.json$" + ] +} diff --git a/.github/linters/.gitleaks.toml b/.github/linters/.gitleaks.toml new file mode 100644 index 0000000..1f08510 --- /dev/null +++ b/.github/linters/.gitleaks.toml @@ -0,0 +1,4 @@ +title = "gitleaks config" + +[extend] +useDefault = true diff --git a/.github/linters/.markdown-lint.yml b/.github/linters/.markdown-lint.yml new file mode 100644 index 0000000..0a6d8fc --- /dev/null +++ b/.github/linters/.markdown-lint.yml @@ -0,0 +1,5 @@ +--- +MD007: + indent: 2 +MD013: false +MD033: false diff --git a/.gitignore b/.gitignore index 70f0875..2d02692 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,58 @@ -assets/* -!assets/.gitignore -protected/runtime/* -!protected/runtime/.gitignore -protected/data/*.db -themes/classic/views/ +# Local AI agent, editor, and GitHub CLI config (if present) +.claude +.codex +.config/gh + +# codecoverage (if present) +code_coverage + +# codeception (if present) +c3.php + +# composer +composer.lock + +# GitHub Copilot config (if present) +.github/agents/** +.github/copilot-instructions.md +.github/copilot/** +.github/instructions/** +.github/prompts/** +.github/skills/** + +# mac ds_store (if present) +.DS_Store + +# netbeans project (if present) +nbproject + +# node_modules (if present) +node_modules +package-lock.json + +# phpstorm project (if present) +.idea + +# phpactor (if present) +.phpactor.* + +# phpunit (if present) +.phpunit.cache +.phpunit.result.cache + +# vagrant (if present) +.vagrant + +# vendor +vendor + +# vscode project (if present) +.vscode + +# windows thumbnail cache (if present) +Thumbs.db + +# zend studio for eclipse project (if present) +.buildpath +.project +.settings diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7890c21 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,28 @@ +# Local agent, editor, coverage, and generated runtime state. +.claude/** +.codex/** +.config/gh/** +.github/agents/** +.github/copilot-instructions.md +.github/copilot/** +.github/instructions/** +.github/prompts/** +.github/skills/** +.vscode/** +code_coverage/** +runtime/** + +# Dependencies. +vendor/** + +# Dependency lockfile ignored by git. +composer.lock + +# License texts ship verbatim. +**/LICENSE* + +# Tool config maintained by hand to keep array order meaningful. +composer-require-checker.json + +# Auto-generated by yii2-extensions/scaffold. +scaffold-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..c9f33af --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,22 @@ +{ + "tabWidth": 4, + "useTabs": false, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 80, + "overrides": [ + { + "files": ["**/*.{yml,yaml}"], + "options": { + "tabWidth": 2 + } + }, + { + "files": "**/*.md", + "options": { + "embeddedLanguageFormatting": "off", + "tabWidth": 2 + } + } + ] +} diff --git a/.styleci.yml b/.styleci.yml new file mode 100644 index 0000000..8995a69 --- /dev/null +++ b/.styleci.yml @@ -0,0 +1,88 @@ +preset: psr12 +risky: true + +version: 8.3 + +finder: + exclude: + - docs + - vendor + +enabled: + - alpha_ordered_traits + - array_indentation + - array_push + - combine_consecutive_issets + - combine_consecutive_unsets + - combine_nested_dirname + - declare_strict_types + - dir_constant + - empty_loop_body_braces + - function_to_constant + - hash_to_slash_comment + - integer_literal_case + - is_null + - logical_operators + - magic_constant_casing + - magic_method_casing + - method_separation + - modernize_types_casting + - native_function_casing + - native_function_type_declaration_casing + - no_alias_functions + - no_empty_comment + - no_empty_phpdoc + - no_empty_statement + - no_extra_block_blank_lines + - no_short_bool_cast + - no_superfluous_elseif + - no_unneeded_control_parentheses + - no_unneeded_curly_braces + - no_unneeded_final_method + - no_unset_cast + - no_unused_imports + - no_unused_lambda_imports + - no_useless_else + - no_useless_return + - normalize_index_brace + - php_unit_dedicate_assert + - php_unit_dedicate_assert_internal_type + - php_unit_expectation + - php_unit_mock + - php_unit_mock_short_will_return + - php_unit_namespaced + - php_unit_no_expectation_annotation + - phpdoc_no_empty_return + - phpdoc_no_useless_inheritdoc + - phpdoc_order + - phpdoc_param_order + - phpdoc_property + - phpdoc_scalar + - phpdoc_singular_inheritdoc + - phpdoc_trim + - phpdoc_trim_consecutive_blank_line_separation + - phpdoc_type_to_var + - phpdoc_types + - phpdoc_types_order + - print_to_echo + - regular_callable_call + - return_assignment + - self_accessor + - self_static_accessor + - set_type_to_cast + - short_array_syntax + - short_list_syntax + - simplified_if_return + - single_quote + - standardize_not_equals + - ternary_to_null_coalescing + - trailing_comma_in_multiline_array + - unalign_double_arrow + - unalign_equals + - union_type_without_spaces + +disabled: + - function_declaration + - new_with_parentheses + - psr12_braces + - psr12_class_definition diff --git a/.stylelintignore b/.stylelintignore new file mode 100644 index 0000000..fd04c4f --- /dev/null +++ b/.stylelintignore @@ -0,0 +1 @@ +**/*.min.css diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d591958 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.1.0 Under development + +- feat: add framework agnostic debug snapshot contracts, strict JSON hydration, and filesystem persistence. diff --git a/README.md b/README.md new file mode 100644 index 0000000..786a27c --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Debug Core + +Framework-agnostic contracts, snapshots, and storage primitives for PHP debugger adapters. + +This package is the shared engine used by framework-specific integrations. Applications should install an adapter +instead of requiring this package directly. + +## Installation + +```shell +composer require php-forge/debug-core +``` + +## Architecture + +The core package owns portable debug data and persistence. It does not depend on Yii2, Yii3, an application container, +or a framework request lifecycle. Adapters collect framework data and convert it into the immutable snapshots provided +by this package. + +Current adapters: + +- `yii2-extensions/debug` +- `yii3/debug` + +## License + +The package is released under the BSD-3-Clause license. See `LICENSE`. diff --git a/composer-require-checker.json b/composer-require-checker.json new file mode 100644 index 0000000..af5da6d --- /dev/null +++ b/composer-require-checker.json @@ -0,0 +1,16 @@ +{ + "symbol-whitelist": [], + "php-core-extensions": [ + "Core", + "date", + "json", + "hash", + "pcre", + "Phar", + "Reflection", + "SPL", + "random", + "standard" + ], + "scan-files": [] +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..16df7a7 --- /dev/null +++ b/composer.json @@ -0,0 +1,74 @@ +{ + "name": "php-forge/debug-core", + "type": "library", + "description": "Framework-agnostic contracts, snapshots, and storage primitives for PHP debug adapters.", + "keywords": [ + "debug", + "debugger", + "developer-tools", + "php", + "profiling" + ], + "license": "BSD-3-Clause", + "authors": [ + { + "name": "Wilmer Arambula", + "email": "terabytesoftw@gmail.com", + "homepage": "https://github.com/terabytesoftw", + "role": "Developer" + } + ], + "require": { + "php": ">=8.3", + "ext-mbstring": "*" + }, + "require-dev": { + "infection/infection": "^0.34", + "maglnet/composer-require-checker": "^4.1", + "php-forge/baseline": "^0.1", + "php-forge/coding-standard": "^0.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-strict-rules": "^2.0.3", + "phpunit/phpunit": "^12.5", + "xepozz/internal-mocker": "^1.4", + "yii2-extensions/scaffold": "^0.2" + }, + "autoload": { + "psr-4": { + "PHPForge\\Debug\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "PHPForge\\Debug\\Tests\\": "tests" + } + }, + "extra": { + "branch-alias": { + "dev-main": "0.1.x-dev" + }, + "scaffold": { + "allowed-packages": [ + "php-forge/baseline", + "php-forge/coding-standard" + ] + } + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "infection/extension-installer": true, + "phpstan/extension-installer": true, + "yii2-extensions/scaffold": true + } + }, + "scripts": { + "check-dependencies": "./vendor/bin/composer-require-checker check", + "ecs": "./vendor/bin/ecs --fix", + "mutation": "php -d memory_limit=-1 vendor/bin/infection --threads=4 --ignore-msi-with-no-mutations --min-msi=100 --min-covered-msi=100", + "mutation-static": "php -d memory_limit=-1 vendor/bin/infection --threads=4 --ignore-msi-with-no-mutations --min-msi=100 --min-covered-msi=100 --static-analysis-tool=phpstan --static-analysis-tool-options='--memory-limit=-1'", + "rector": "./vendor/bin/rector process", + "static": "./vendor/bin/phpstan --memory-limit=-1", + "tests": "./vendor/bin/phpunit" + } +} diff --git a/ecs.php b/ecs.php new file mode 100644 index 0000000..01e883d --- /dev/null +++ b/ecs.php @@ -0,0 +1,8 @@ +withPaths([__DIR__ . '/src', __DIR__ . '/tests']); diff --git a/infection.json5 b/infection.json5 new file mode 100644 index 0000000..ede5a5e --- /dev/null +++ b/infection.json5 @@ -0,0 +1,10 @@ +{ + "$schema": "vendor/infection/infection/resources/schema.json", + "source": { + "directories": ["src"] + }, + "logs": { + "text": "runtime/infection.log" + }, + "tmpDir": "runtime/infection" +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..490633e --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,20 @@ +includes: + - phar://phpstan.phar/conf/bleedingEdge.neon + +parameters: + level: max + + paths: + - src + - tests + + tmpDir: %currentWorkingDirectory%/runtime + + checkImplicitMixed: true + checkBenevolentUnionTypes: true + checkUninitializedProperties: true + checkMissingCallableSignature: true + checkTooWideReturnTypesInProtectedAndPublicMethods: true + reportAnyTypeWideningInVarTag: true + reportPossiblyNonexistentConstantArrayOffset: true + reportPossiblyNonexistentGeneralArrayOffset: true diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..2058c61 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,27 @@ + + + + + ./tests + + + + + src + + + + + + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..4c86b22 --- /dev/null +++ b/rector.php @@ -0,0 +1,10 @@ +withPaths([__DIR__ . '/src', __DIR__ . '/tests']) + ->withPhpSets(php83: true) + ->withPreparedSets(codeQuality: true, deadCode: true, typeDeclarations: true); diff --git a/runtime/.gitignore b/runtime/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/runtime/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/scaffold-lock.json b/scaffold-lock.json new file mode 100644 index 0000000..bd6dbaf --- /dev/null +++ b/scaffold-lock.json @@ -0,0 +1,98 @@ +{ + "providers": { + "php-forge/baseline": { + "version": "0.1.7", + "path": "vendor/php-forge/baseline" + }, + "php-forge/coding-standard": { + "version": "0.3.3", + "path": "vendor/php-forge/coding-standard" + } + }, + "files": { + ".editorconfig": { + "hash": "sha256:c536c9768f05fe8e6870a9bed8a0190c092d91f3842118579e0ed0ce722d525c", + "provider": "php-forge/baseline", + "source": "metadata/.editorconfig", + "mode": "append" + }, + ".gitattributes": { + "hash": "sha256:88b62c371df01eac6e0298625a03baee372bd4048a6452cc2a1aeee0d49eba10", + "provider": "php-forge/baseline", + "source": "metadata/gitattributes", + "mode": "replace" + }, + ".gitignore": { + "hash": "sha256:7d7753b2cc6184d74fb22f78c6b351b6c9c9dc04bcabe5481a0ed64b06858597", + "provider": "php-forge/baseline", + "source": "metadata/.gitignore", + "mode": "append" + }, + ".styleci.yml": { + "hash": "sha256:825f8c744ffbd2bc2369712e7192b8694129a8ecbc3fd91dc9300fccecf827bd", + "provider": "php-forge/baseline", + "source": "metadata/.styleci.yml", + "mode": "replace" + }, + ".prettierignore": { + "hash": "sha256:f9190dda61229a5daf8df6113600eb8183bbf98d2a89d7b4944a889ddca0ac0d", + "provider": "php-forge/baseline", + "source": "metadata/.prettierignore", + "mode": "replace" + }, + ".prettierrc.json": { + "hash": "sha256:a03ca958c95e39bbfd12af061402d0caab14bb4db2997e2b5279f1cd801c71e0", + "provider": "php-forge/baseline", + "source": "metadata/.prettierrc.json", + "mode": "replace" + }, + ".stylelintignore": { + "hash": "sha256:12186f0a5c42b4f894f7bdda1b1ca5ebc69f92d906aa9075a459d4d08fe4edc1", + "provider": "php-forge/baseline", + "source": "metadata/.stylelintignore", + "mode": "replace" + }, + "composer-require-checker.json": { + "hash": "sha256:0cc82e1bb44b9b090ea8ff2f5c6ee51605d031eb8ac6ae8042a8584c2f14c91a", + "provider": "php-forge/baseline", + "source": "metadata/composer-require-checker.json", + "mode": "preserve" + }, + ".github/linters/.codespellrc": { + "hash": "sha256:0ec9825d6022c7b0edcdbc6f8a8e3fdea30bbd65f76084cc9f046bdd432bdc1f", + "provider": "php-forge/baseline", + "source": "metadata/.github/linters/.codespellrc", + "mode": "replace" + }, + ".github/linters/.editorconfig-checker.json": { + "hash": "sha256:17587db6b4d163e43b47dd1f55809b2ab6f3a2d9b170ca88fdd23d703feb3064", + "provider": "php-forge/baseline", + "source": "metadata/.github/linters/.editorconfig-checker.json", + "mode": "replace" + }, + ".github/linters/.gitleaks.toml": { + "hash": "sha256:4016e5e7237b525fd32eaf50c10577c21f452863ce95a901c059fb90d6b9318a", + "provider": "php-forge/baseline", + "source": "metadata/.github/linters/.gitleaks.toml", + "mode": "replace" + }, + ".github/linters/.markdown-lint.yml": { + "hash": "sha256:5d41c7471e15fea1b307a39791436179c5db6ae7c7daf00042d21750117bead4", + "provider": "php-forge/baseline", + "source": "metadata/.github/linters/.markdown-lint.yml", + "mode": "replace" + }, + "ecs.php": { + "hash": "sha256:b70701973dcf941f47b94c887745312865eea23bb11ff09fed0aac4f0b83bc03", + "provider": "php-forge/coding-standard", + "source": "src/config/ecs.php", + "mode": "preserve" + }, + "rector.php": { + "hash": "sha256:37b4e62167891b24d7669e0640136991f9b299375568fe50b765adc41b375297", + "provider": "php-forge/coding-standard", + "source": "src/config/rector.php", + "mode": "preserve" + } + } +} \ No newline at end of file diff --git a/src/Storage/ArrayPayloadSnapshot.php b/src/Storage/ArrayPayloadSnapshot.php new file mode 100644 index 0000000..694a86e --- /dev/null +++ b/src/Storage/ArrayPayloadSnapshot.php @@ -0,0 +1,127 @@ + true]); + * ``` + * + * @param array $values Raw payload captured for the request. + * + * @return self Snapshot containing tagged debug values. + */ + public static function capture(array $values): self + { + return new self( + DebugArray::capture($values), + ); + } + + /** + * Hydrates a dynamic payload from decoded JSON data. + * + * Usage example: + * + * ```php + * $prototype = new class(\PHPForge\Debug\Storage\DebugArray::capture([])) { + * use \PHPForge\Debug\Storage\ArrayPayloadSnapshot; + * + * protected static function payloadKey(): string + * { + * return 'data'; + * } + * }; + * $data = $prototype::capture(['enabled' => true])->jsonSerialize(); + * $snapshot = $prototype::fromArray($data, '$.panel'); + * ``` + * + * @param mixed $data Decoded JSON payload. + * @param string $path Payload path used in hydration errors. + * + * @return self Hydrated dynamic payload snapshot. + */ + public static function fromArray(mixed $data, string $path): self + { + $key = self::payloadKey(); + + return new self( + Payload::object($data, $path) + ->shape([$key]) + ->debugArray($key), + ); + } + + /** + * Returns the tagged payload for JSON serialization. + * + * Usage example: + * + * ```php + * $prototype = new class(\PHPForge\Debug\Storage\DebugArray::capture([])) { + * use \PHPForge\Debug\Storage\ArrayPayloadSnapshot; + * + * protected static function payloadKey(): string + * { + * return 'data'; + * } + * }; + * $data = $prototype::capture(['enabled' => true])->jsonSerialize(); + * ``` + * + * @return array Tagged payload indexed by its persistence key. + */ + public function jsonSerialize(): array + { + return [ + self::payloadKey() => $this->payload->jsonSerialize(), + ]; + } + + /** + * Returns the JSON key used to persist the dynamic payload. + * + * @return string Persisted payload key. + */ + abstract protected static function payloadKey(): string; + + /** + * Returns the payload restored to plain PHP values. + * + * @return array Payload restored to plain PHP values. + */ + protected function values(): array + { + return $this->payload->values(); + } +} diff --git a/src/Storage/DebugArray.php b/src/Storage/DebugArray.php new file mode 100644 index 0000000..41b0c56 --- /dev/null +++ b/src/Storage/DebugArray.php @@ -0,0 +1,103 @@ + true]); + * ``` + * + * @param array $value PHP values to capture. + * + * @return self Tagged array facade. + */ + public static function capture(array $value): self + { + return new self( + DebugValue::capture($value), + ); + } + + /** + * Hydrates a tagged debug array from decoded JSON data. + * + * Usage example: + * + * ```php + * $data = \PHPForge\Debug\Storage\DebugArray::capture(['enabled' => true])->jsonSerialize(); + * $array = \PHPForge\Debug\Storage\DebugArray::fromArray($data, '$.panel.data'); + * ``` + * + * @param mixed $value Decoded tagged value. + * @param string $path Payload path used in hydration errors. + * + * @return self Hydrated array facade. + */ + public static function fromArray(mixed $value, string $path): self + { + $debugValue = DebugValue::fromArray($value, $path); + + if ($debugValue->type !== 'array') { + throw HydrationException::at( + $path, + 'a tagged array', + ); + } + + return new self( + $debugValue, + ); + } + + /** + * Returns the tagged array for JSON serialization. + * + * Usage example: + * + * ```php + * $data = \PHPForge\Debug\Storage\DebugArray::capture(['enabled' => true])->jsonSerialize(); + * ``` + * + * @return array Tagged array payload. + */ + public function jsonSerialize(): array + { + return $this->value->jsonSerialize(); + } + + /** + * Returns captured entries as display-safe PHP values. + * + * Usage example: + * + * ```php + * $values = \PHPForge\Debug\Storage\DebugArray::capture(['enabled' => true])->values(); + * ``` + * + * @return array Display-safe PHP values. + */ + public function values(): array + { + return $this->value->toDisplayEntries(); + } +} diff --git a/src/Storage/DebugSnapshot.php b/src/Storage/DebugSnapshot.php new file mode 100644 index 0000000..e46b291 --- /dev/null +++ b/src/Storage/DebugSnapshot.php @@ -0,0 +1,102 @@ +> $panels Serialized panel payloads indexed by panel ID. + * @param array $failures Panel failures indexed by panel ID. + */ + public function __construct(public RequestSummary $summary, public array $panels, public array $failures) {} + + /** + * Hydrates a versioned request envelope from decoded JSON data. + * + * Usage example: + * + * ```php + * $snapshot = \PHPForge\Debug\Storage\DebugSnapshot::fromArray($data); + * ``` + * + * @param mixed $data Decoded snapshot envelope. + * + * @return self Hydrated request snapshot. + */ + public static function fromArray(mixed $data): self + { + $payload = Payload::object($data) + ->shape( + [ + 'version', + 'summary', + 'panels', + 'failures', + ], + ); + + if ($payload->int('version') !== self::VERSION) { + throw HydrationException::at( + '$.version', + 'storage version ' . self::VERSION, + ); + } + + $panels = []; + + foreach ($payload->map('panels') as $id => $panel) { + $panels[$id] = Payload::object($panel, "$.panels.{$id}") + ->all(); + } + + $failures = []; + + foreach ($payload->map('failures') as $id => $failure) { + $failures[$id] = PanelFailure::fromArray($failure, "$.failures.{$id}"); + } + + return new self( + RequestSummary::fromArray($payload->raw('summary')), + $panels, + $failures, + ); + } + + /** + * Returns the request envelope for JSON serialization. + * + * Usage example: + * + * ```php + * $data = $snapshot->jsonSerialize(); + * ``` + * + * @return array Versioned request envelope. + */ + public function jsonSerialize(): array + { + return [ + 'version' => self::VERSION, + 'summary' => $this->summary->jsonSerialize(), + 'panels' => $this->panels, + 'failures' => array_map( + static fn(PanelFailure $failure): array => $failure->jsonSerialize(), + $this->failures, + ), + ]; + } +} diff --git a/src/Storage/DebugValue.php b/src/Storage/DebugValue.php new file mode 100644 index 0000000..44e28f7 --- /dev/null +++ b/src/Storage/DebugValue.php @@ -0,0 +1,543 @@ + $entries Captured child values. + * @param string|null $className Captured object class or `null` for non-object values. + * @param string|null $resourceType Captured resource type or `null` for non-resource values. + * @param string|null $reason Truncation or unsupported-value reason, or `null` when not applicable. + */ + private function __construct( + public string $type, + public bool|float|int|string|null $value = null, + public array $entries = [], + public string|null $className = null, + public string|null $resourceType = null, + public string|null $reason = null, + ) {} + + /** + * Captures an arbitrary PHP value as JSON-safe tagged data. + * + * Usage example: + * + * ```php + * $value = \PHPForge\Debug\Storage\DebugValue::capture(['enabled' => true]); + * ``` + * + * @param mixed $value PHP value to capture. + * + * @return self Tagged debug value. + */ + public static function capture(mixed $value): self + { + $objects = new SplObjectStorage(); + + $nodes = 0; + + return self::normalize($value, 0, $nodes, $objects); + } + + /** + * Hydrates a tagged debug value from decoded JSON data. + * + * Usage example: + * + * ```php + * $value = \PHPForge\Debug\Storage\DebugValue::fromArray(['type' => 'int', 'value' => 42]); + * ``` + * + * @param mixed $data Decoded tagged value. + * @param string $path Payload path used in hydration errors. + * + * @return self Hydrated debug value. + */ + public static function fromArray(mixed $data, string $path = '$'): self + { + $payload = Payload::object($data, $path); + + $type = $payload->string('type'); + + $payload->shape( + match ($type) { + 'null' => ['type'], + 'bool', 'int', 'float', 'special-float', 'string' => ['type', 'value'], + 'binary' => ['type', 'encoding', 'data'], + 'array' => ['type', 'entries'], + 'object' => ['type', 'value', 'entries', 'class'], + 'resource' => ['type', 'resourceType'], + 'truncated', 'recursion', 'unsupported' => ['type', 'value', 'reason'], + default => throw HydrationException::at( + "{$path}.type", + 'a known debug-value type', + ), + } + ); + + return match ($type) { + 'null' => new self('null'), + 'bool' => new self('bool', $payload->bool('value')), + 'int' => new self('int', $payload->int('value')), + 'float' => new self('float', $payload->number('value')), + 'special-float' => self::fromSpecialFloat($payload, $path), + 'string' => new self('string', $payload->string('value')), + 'binary' => self::fromBinary($payload, $path), + 'array' => new self('array', entries: self::hydrateEntries($payload, $path)), + 'object' => new self( + 'object', + value: $payload->nullableString('value'), + entries: self::hydrateEntries($payload, $path), + className: $payload->string('class'), + ), + 'resource' => new self( + 'resource', + resourceType: $payload->string('resourceType'), + ), + 'truncated', 'recursion', 'unsupported' => new self( + $type, + value: $payload->nullableString('value'), + reason: $payload->string('reason'), + ), + }; + } + + /** + * Returns the tagged value for JSON serialization. + * + * Usage example: + * + * ```php + * $data = \PHPForge\Debug\Storage\DebugValue::capture(['enabled' => true])->jsonSerialize(); + * ``` + * + * @return array Tagged debug value payload. + */ + public function jsonSerialize(): array + { + $data = ['type' => $this->type]; + + if (in_array($this->type, ['bool', 'int', 'float', 'special-float', 'string'], true)) { + $data['value'] = $this->value; + } + + if ($this->type === 'binary') { + $data['encoding'] = 'base64'; + + $data['data'] = base64_encode(is_string($this->value) ? $this->value : ''); + } + + if ($this->type === 'array' || $this->type === 'object') { + $data['entries'] = array_map( + static fn(array $entry): array => [ + 'keyType' => $entry['keyType'], + 'key' => $entry['key'], + 'value' => $entry['value']->jsonSerialize(), + ], + $this->entries, + ); + } + + if ($this->className !== null) { + $data['class'] = $this->className; + } + + if ($this->resourceType !== null) { + $data['resourceType'] = $this->resourceType; + } + + if ($this->reason !== null) { + $data['reason'] = $this->reason; + + $data['value'] = is_string($this->value) ? $this->value : null; + } + + if ($this->type === 'object') { + $data['value'] = is_string($this->value) ? $this->value : null; + } + + return $data; + } + + /** + * Returns the entries as a plain PHP array, keeping the key types captured from the original value. + * + * Usage example: + * + * ```php + * $entries = \PHPForge\Debug\Storage\DebugValue::capture(['enabled' => true])->toDisplayEntries(); + * ``` + * + * @return array Display-safe child values retaining captured key types. + */ + public function toDisplayEntries(): array + { + $result = []; + + foreach ($this->entries as $entry) { + $result[$entry['key']] = $entry['value']->toDisplayValue(); + } + + return $result; + } + + /** + * Returns a safe PHP representation suitable for existing dump and table renderers. + * + * Usage example: + * + * ```php + * $displayValue = \PHPForge\Debug\Storage\DebugValue::capture(new \stdClass())->toDisplayValue(); + * ``` + * + * @return mixed Display-safe PHP value. + */ + public function toDisplayValue(): mixed + { + return match ($this->type) { + 'null' => null, + 'bool', 'int', 'float', 'string' => $this->value, + 'special-float', 'binary', 'resource', 'truncated', 'recursion', 'unsupported' => $this->displayLabel(), + 'array' => $this->toDisplayEntries(), + 'object' => ['__class' => $this->className ?? 'object'] + $this->toDisplayEntries(), + default => '(unsupported)', + }; + } + + /** + * Returns the display label for a non-scalar tagged value. + * + * @return string Display-safe label. + */ + private function displayLabel(): string + { + return match ($this->type) { + 'special-float' => (string) $this->value, + 'binary' => sprintf( + '(binary: base64 %s)', + base64_encode(is_string($this->value) ? $this->value : ''), + ), + 'resource' => sprintf('(resource: %s)', $this->resourceType ?? 'unknown'), + default => is_string($this->value) && $this->value !== '' + ? $this->value + : sprintf('(%s: %s)', $this->type, $this->reason ?? 'unknown'), + }; + } + + /** + * Hydrates a base64-encoded binary value. + * + * @param Payload $payload Validated tagged value payload. + * @param string $path Payload path used in hydration errors. + * + * @return self Hydrated binary value. + */ + private static function fromBinary(Payload $payload, string $path): self + { + if ($payload->string('encoding') !== 'base64') { + throw HydrationException::at( + "{$path}.encoding", + 'base64', + ); + } + + $decoded = base64_decode($payload->string('data'), true); + + if ($decoded === false) { + throw HydrationException::at( + "{$path}.data", + 'valid base64 data', + ); + } + + return new self( + 'binary', + $decoded, + ); + } + + /** + * Hydrates a non-finite floating-point label. + * + * @param Payload $payload Validated tagged value payload. + * @param string $path Payload path used in hydration errors. + * + * @return self Hydrated non-finite floating-point value. + */ + private static function fromSpecialFloat(Payload $payload, string $path): self + { + $value = $payload->string('value'); + + if (!in_array($value, ['NAN', 'INF', '-INF'], true)) { + throw HydrationException::at( + "{$path}.value", + 'NAN, INF, or -INF', + ); + } + + return new self( + 'special-float', + $value, + ); + } + + /** + * Hydrates tagged array or object entries. + * + * @param Payload $payload Validated tagged value payload. + * @param string $path Payload path used in hydration errors. + * + * @return list Hydrated entries. + */ + private static function hydrateEntries(Payload $payload, string $path): array + { + $entries = []; + + foreach ($payload->list('entries') as $index => $rawEntry) { + $entryPath = "{$path}.entries[{$index}]"; + + $entry = Payload::object($rawEntry, $entryPath) + ->shape( + [ + 'keyType', + 'key', + 'value', + ], + ); + $keyType = $entry->string('keyType'); + $key = $entry->raw('key'); + + if ( + ($keyType !== 'int' && $keyType !== 'string') + || ($keyType === 'int' && !is_int($key)) + || ($keyType === 'string' && !is_string($key)) + ) { + throw HydrationException::at( + "{$entryPath}.key", + 'a key matching keyType', + ); + } + + $entries[] = [ + 'keyType' => $keyType, + 'key' => $key, + 'value' => self::fromArray($entry->raw('value'), "{$entryPath}.value"), + ]; + } + + return $entries; + } + + /** + * Normalizes a PHP value while enforcing depth and node limits. + * + * @param mixed $value PHP value to normalize. + * @param int $depth Current nesting depth. + * @param int $nodes Number of values visited across the capture operation. + * @param SplObjectStorage $objects Objects active in the current traversal path. + * + * @return self Normalized debug value. + */ + private static function normalize( + mixed $value, + int $depth, + int &$nodes, + SplObjectStorage $objects, + ): self { + if (++$nodes > self::MAX_NODES) { + return new self( + 'truncated', + value: '*SKIPPED over 10000 nodes*', + reason: 'size', + ); + } + + if ($depth > self::MAX_DEPTH) { + return new self( + 'truncated', + value: '*DEEP NESTED VALUE*', + reason: 'depth', + ); + } + + if ($value === null) { + return new self( + 'null', + ); + } + + if (is_bool($value)) { + return new self( + 'bool', + $value, + ); + } + + if (is_int($value)) { + return new self( + 'int', + $value, + ); + } + + if (is_float($value)) { + return is_finite($value) + ? new self( + 'float', + $value, + ) + : new self( + 'special-float', + match (true) { + is_nan($value) => 'NAN', + $value > 0 => 'INF', + default => '-INF', + }, + ); + } + + if (is_string($value)) { + return mb_check_encoding($value, 'UTF-8') + ? new self( + 'string', + $value, + ) + : new self( + 'binary', + $value, + ); + } + + if (is_array($value)) { + $entries = []; + + foreach ($value as $key => $entry) { + $entries[] = [ + 'keyType' => is_int($key) ? 'int' : 'string', + 'key' => $key, + 'value' => self::normalize($entry, $depth + 1, $nodes, $objects), + ]; + } + + return new self( + 'array', + entries: $entries, + ); + } + + if (is_object($value)) { + if ($objects->offsetExists($value)) { + return new self( + 'recursion', + value: $value::class, + reason: 'object-cycle', + ); + } + + $objects->offsetSet($value); + $entries = []; + + foreach (get_object_vars($value) as $key => $entry) { + $entries[] = [ + 'keyType' => 'string', + 'key' => $key, + 'value' => self::normalize($entry, $depth + 1, $nodes, $objects), + ]; + } + + $objects->offsetUnset($value); + + return new self( + 'object', + value: self::objectLabel($value), + entries: $entries, + className: $value::class, + ); + } + + if (is_resource($value)) { + return new self( + 'resource', + resourceType: get_resource_type($value), + ); + } + + // Closed resources report `false` from is_resource(), so they land here rather than in the branch above. + return new self( + 'unsupported', + value: '(unsupported)', + reason: 'unknown-type', + ); + } + + /** + * Returns a safe display label for an object. + * + * @param object $value Object to describe. + * + * @return string Display-safe object label. + */ + private static function objectLabel(object $value): string + { + if ($value instanceof Closure) { + return '\\Closure'; + } + + if ($value instanceof Throwable) { + $class = $value::class; + $message = $value->getMessage(); + + return "{$class}: {$message}"; + } + + if ($value instanceof Stringable) { + try { + return (string) $value; + } catch (Throwable) { + // Fall through to the class name when userland string conversion fails. + } + } + + return $value::class; + } +} diff --git a/src/Storage/ExceptionSnapshot.php b/src/Storage/ExceptionSnapshot.php new file mode 100644 index 0000000..56f4f47 --- /dev/null +++ b/src/Storage/ExceptionSnapshot.php @@ -0,0 +1,377 @@ + $trace Captured trace frames. + * @param string $toString Original throwable text. + * @param self|null $previous Previous throwable snapshot or `null`. + */ + public function __construct( + private string $class, + private string $message, + private int|string $code, + private string $file, + private int $line, + private array $trace, + private string $toString, + private self|null $previous, + ) {} + + /** + * Returns the original throwable text captured in the snapshot. + * + * @return string Original throwable text. + */ + public function __toString(): string + { + return $this->toString; + } + + /** + * Hydrates a throwable snapshot from decoded JSON data. + * + * Usage example: + * + * ```php + * $captured = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException('Failed.')); + * $snapshot = \PHPForge\Debug\Storage\ExceptionSnapshot::fromArray($captured->jsonSerialize()); + * ``` + * + * @param mixed $data Decoded throwable payload. + * @param string $path Payload path used in hydration errors. + * + * @return self Hydrated throwable snapshot. + */ + public static function fromArray(mixed $data, string $path = '$.exception'): self + { + $payload = Payload::object($data, $path) + ->shape( + [ + 'class', + 'message', + 'code', + 'file', + 'line', + 'trace', + 'toString', + 'previous', + ], + ); + + $rawCode = $payload->raw('code'); + + if (!is_int($rawCode) && !is_string($rawCode)) { + throw HydrationException::at( + "{$path}.code", + 'an integer or string', + ); + } + + $trace = []; + + foreach ($payload->list('trace') as $index => $rawFrame) { + $framePath = "{$path}.trace[{$index}]"; + + $frame = Payload::object($rawFrame, $framePath) + ->shape( + [ + 'namespace', + 'short_class', + 'class', + 'type', + 'function', + 'file', + 'line', + 'args', + ], + ); + + $trace[] = [ + 'namespace' => $frame->string('namespace'), + 'short_class' => $frame->string('short_class'), + 'class' => $frame->string('class'), + 'type' => $frame->string('type'), + 'function' => $frame->nullableString('function'), + 'file' => $frame->nullableString('file'), + 'line' => $frame->nullableInt('line'), + 'args' => DebugArray::fromArray($frame->raw('args'), "{$framePath}.args"), + ]; + } + + $previous = $payload->raw('previous'); + + return new self( + class: $payload->string('class'), + message: $payload->string('message'), + code: $rawCode, + file: $payload->string('file'), + line: $payload->int('line'), + trace: $trace, + toString: $payload->string('toString'), + previous: $previous === null ? null : self::fromArray($previous, "{$path}.previous"), + ); + } + + /** + * Captures a throwable and its previous-exception chain without executable state. + * + * Usage example: + * + * ```php + * $snapshot = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable( + * new \RuntimeException('Capture failed.'), + * ); + * ``` + * + * @param Throwable $throwable Throwable to capture. + * + * @return self Captured throwable snapshot. + */ + public static function fromThrowable(Throwable $throwable): self + { + $trace = []; + + foreach ($throwable->getTrace() as $entry) { + $class = is_string($entry['class'] ?? null) ? $entry['class'] : ''; + $args = is_array($entry['args'] ?? null) ? $entry['args'] : []; + + $trace[] = [ + 'namespace' => self::namespacePart($class), + 'short_class' => self::shortName($class), + 'class' => $class, + 'type' => is_string($entry['type'] ?? null) ? $entry['type'] : '', + 'function' => $entry['function'], + 'file' => is_string($entry['file'] ?? null) ? $entry['file'] : null, + 'line' => is_int($entry['line'] ?? null) ? $entry['line'] : null, + 'args' => DebugArray::capture($args), + ]; + } + + $code = $throwable->getCode(); + + return new self( + class: $throwable::class, + message: $throwable->getMessage(), + code: $code, + file: $throwable->getFile(), + line: $throwable->getLine(), + trace: $trace, + toString: (string) $throwable, + previous: $throwable->getPrevious() !== null ? self::fromThrowable($throwable->getPrevious()) : null, + ); + } + + /** + * Returns the captured throwable class. + * + * Usage example: + * + * ```php + * $class = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException())->getClass(); + * ``` + * + * @return string Captured throwable class. + */ + public function getClass(): string + { + return $this->class; + } + + /** + * Returns the captured throwable code. + * + * Usage example: + * + * ```php + * $code = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException('', 42))->getCode(); + * ``` + * + * @return int|string Captured throwable code. + */ + public function getCode(): int|string + { + return $this->code; + } + + /** + * Returns the file where the throwable originated. + * + * Usage example: + * + * ```php + * $file = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException())->getFile(); + * ``` + * + * @return string Origin file path. + */ + public function getFile(): string + { + return $this->file; + } + + /** + * Returns the line where the throwable originated. + * + * Usage example: + * + * ```php + * $line = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException())->getLine(); + * ``` + * + * @return int Origin line number. + */ + public function getLine(): int + { + return $this->line; + } + + /** + * Returns the captured throwable message. + * + * Usage example: + * + * ```php + * $message = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable( + * new \RuntimeException('Capture failed.'), + * )->getMessage(); + * ``` + * + * @return string Captured throwable message. + */ + public function getMessage(): string + { + return $this->message; + } + + /** + * Returns the previous throwable snapshot or `null`. + * + * Usage example: + * + * ```php + * $snapshot = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable( + * new \RuntimeException('Outer.', 0, new \LogicException('Inner.')), + * ); + * $previous = $snapshot->getPrevious(); + * ``` + * + * @return self|null Previous throwable snapshot or `null`. + */ + public function getPrevious(): self|null + { + return $this->previous; + } + + /** + * Returns the trace frames with their arguments projected to plain display values. + * + * Usage example: + * + * ```php + * $trace = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable(new \RuntimeException())->getTrace(); + * ``` + * + * @return list> Display-safe trace frames. + */ + public function getTrace(): array + { + return array_map( + static fn(array $frame): array => [...$frame, 'args' => $frame['args']->values()], + $this->trace, + ); + } + + /** + * Returns the throwable snapshot for JSON serialization. + * + * Usage example: + * + * ```php + * $data = \PHPForge\Debug\Storage\ExceptionSnapshot::fromThrowable( + * new \RuntimeException('Capture failed.'), + * )->jsonSerialize(); + * ``` + * + * @return array Serialized throwable snapshot. + */ + public function jsonSerialize(): array + { + return [ + 'class' => $this->class, + 'message' => $this->message, + 'code' => $this->code, + 'file' => $this->file, + 'line' => $this->line, + 'trace' => array_map( + static fn(array $frame): array => [...$frame, 'args' => $frame['args']->jsonSerialize()], + $this->trace, + ), + 'toString' => $this->toString, + 'previous' => $this->previous?->jsonSerialize(), + ]; + } + + /** + * Returns the namespace portion of a fully qualified class name. + * + * @param string $class Fully qualified class name. + * + * @return string Namespace portion or an empty string. + */ + private static function namespacePart(string $class): string + { + $position = strrpos($class, '\\'); + + return $position === false ? '' : substr($class, 0, $position); + } + + /** + * Returns the short portion of a fully qualified class name. + * + * @param string $class Fully qualified class name. + * + * @return string Short class name. + */ + private static function shortName(string $class): string + { + $position = strrpos($class, '\\'); + + return $position === false ? $class : substr($class, $position + 1); + } +} diff --git a/src/Storage/HydrationException.php b/src/Storage/HydrationException.php new file mode 100644 index 0000000..427dde7 --- /dev/null +++ b/src/Storage/HydrationException.php @@ -0,0 +1,34 @@ + $entries Request summaries indexed by tag. + */ + public function __construct(public array $entries) {} + + /** + * Hydrates a versioned manifest from decoded JSON data. + * + * Usage example: + * + * ```php + * $manifest = \PHPForge\Debug\Storage\Manifest::fromArray($data); + * ``` + * + * @param mixed $data Decoded manifest payload. + * + * @return self Hydrated request-summary index. + */ + public static function fromArray(mixed $data): self + { + $payload = Payload::object($data) + ->shape( + [ + 'version', + 'entries', + ], + ); + + if ($payload->int('version') !== DebugSnapshot::VERSION) { + throw HydrationException::at( + '$.version', + 'storage version ' . DebugSnapshot::VERSION, + ); + } + + $entries = []; + + foreach ($payload->map('entries') as $tag => $entry) { + $summary = RequestSummary::fromArray($entry, "$.entries.{$tag}"); + + if ($summary->tag !== $tag) { + throw HydrationException::at( + "$.entries.{$tag}.tag", + "the manifest key '{$tag}'", + ); + } + + $entries[$tag] = $summary; + } + + return new self( + $entries, + ); + } + + /** + * Returns the versioned manifest for JSON serialization. + * + * Usage example: + * + * ```php + * $data = (new \PHPForge\Debug\Storage\Manifest([]))->jsonSerialize(); + * ``` + * + * @return array Versioned manifest payload. + */ + public function jsonSerialize(): array + { + return [ + 'version' => DebugSnapshot::VERSION, + 'entries' => array_map( + static fn(RequestSummary $summary): array => $summary->jsonSerialize(), + $this->entries, + ), + ]; + } +} diff --git a/src/Storage/PanelFailure.php b/src/Storage/PanelFailure.php new file mode 100644 index 0000000..46b6d61 --- /dev/null +++ b/src/Storage/PanelFailure.php @@ -0,0 +1,111 @@ +shape( + [ + 'stage', + 'exception', + ], + ); + + $stage = $payload->string('stage'); + + if ($stage !== self::CAPTURE && $stage !== self::HYDRATE) { + throw HydrationException::at( + "{$path}.stage", + 'capture or hydrate', + ); + } + + return new self( + $stage, + ExceptionSnapshot::fromArray($payload->raw('exception'), "{$path}.exception"), + ); + } + + /** + * Captures a throwable raised during a panel lifecycle stage. + * + * Usage example: + * + * ```php + * $failure = \PHPForge\Debug\Storage\PanelFailure::fromThrowable( + * \PHPForge\Debug\Storage\PanelFailure::CAPTURE, + * new \RuntimeException('Capture failed.'), + * ); + * ``` + * + * @param 'capture'|'hydrate' $stage Lifecycle stage the panel failed in. + * @param Throwable $throwable Panel exception to capture. + * + * @return self Captured panel failure. + */ + public static function fromThrowable(string $stage, Throwable $throwable): self + { + return new self( + $stage, + ExceptionSnapshot::fromThrowable($throwable), + ); + } + + /** + * Returns the failure record for JSON serialization. + * + * Usage example: + * + * ```php + * $data = \PHPForge\Debug\Storage\PanelFailure::fromThrowable( + * \PHPForge\Debug\Storage\PanelFailure::CAPTURE, + * new \RuntimeException('Capture failed.'), + * )->jsonSerialize(); + * ``` + * + * @return array Serialized failure stage and exception. + */ + public function jsonSerialize(): array + { + return [ + 'stage' => $this->stage, + 'exception' => $this->exception->jsonSerialize(), + ]; + } +} diff --git a/src/Storage/PanelRow.php b/src/Storage/PanelRow.php new file mode 100644 index 0000000..aea5367 --- /dev/null +++ b/src/Storage/PanelRow.php @@ -0,0 +1,29 @@ +jsonSerialize(); + * ``` + * + * @return array Serialized row fields. + */ + public function jsonSerialize(): array; +} diff --git a/src/Storage/PanelSnapshot.php b/src/Storage/PanelSnapshot.php new file mode 100644 index 0000000..7bcaeb2 --- /dev/null +++ b/src/Storage/PanelSnapshot.php @@ -0,0 +1,26 @@ +jsonSerialize(); + * ``` + * + * @return array Serialized panel fields. + */ + public function jsonSerialize(): array; +} diff --git a/src/Storage/Payload.php b/src/Storage/Payload.php new file mode 100644 index 0000000..8b42487 --- /dev/null +++ b/src/Storage/Payload.php @@ -0,0 +1,466 @@ + $data Decoded JSON object. + * @param string $path Object path used in hydration errors. + */ + private function __construct(private array $data, private string $path) {} + + /** + * Returns every decoded field without conversion. + * + * Usage example: + * + * ```php + * $data = \PHPForge\Debug\Storage\Payload::object(['name' => 'debug'])->all(); + * ``` + * + * @return array Decoded object fields. + */ + public function all(): array + { + return $this->data; + } + + /** + * Returns a required boolean field. + * + * Usage example: + * + * ```php + * $enabled = \PHPForge\Debug\Storage\Payload::object(['enabled' => true])->bool('enabled'); + * ``` + * + * @param string $key Required field name. + * + * @return bool Boolean field value. + */ + public function bool(string $key): bool + { + $value = $this->value($key); + + if (!is_bool($value)) { + throw HydrationException::at( + $this->keyPath($key), + 'a boolean', + ); + } + + return $value; + } + + /** + * Reads a tagged array value, keeping the path of the enclosing payload for error reporting. + * + * Usage example: + * + * ```php + * $data = \PHPForge\Debug\Storage\DebugArray::capture(['enabled' => true])->jsonSerialize(); + * $array = \PHPForge\Debug\Storage\Payload::object(['data' => $data])->debugArray('data'); + * ``` + * + * @param string $key Required field name. + * + * @return DebugArray Hydrated tagged array. + */ + public function debugArray(string $key): DebugArray + { + return DebugArray::fromArray($this->value($key), $this->keyPath($key)); + } + + /** + * Returns a required integer field. + * + * Usage example: + * + * ```php + * $count = \PHPForge\Debug\Storage\Payload::object(['count' => 3])->int('count'); + * ``` + * + * @param string $key Required field name. + * + * @return int Integer field value. + */ + public function int(string $key): int + { + $value = $this->value($key); + + if (!is_int($value)) { + throw HydrationException::at( + $this->keyPath($key), + 'an integer', + ); + } + + return $value; + } + + /** + * Returns a required list field. + * + * Usage example: + * + * ```php + * $items = \PHPForge\Debug\Storage\Payload::object(['items' => ['one', 'two']])->list('items'); + * ``` + * + * @param string $key Required field name. + * + * @return list List field value. + */ + public function list(string $key): array + { + $value = $this->value($key); + + if (!is_array($value) || !array_is_list($value)) { + throw HydrationException::at( + $this->keyPath($key), + 'a list', + ); + } + + return $value; + } + + /** + * Returns a required JSON object as a `string`-keyed array. + * + * Usage example: + * + * ```php + * $options = \PHPForge\Debug\Storage\Payload::object(['options' => ['enabled' => true]])->map('options'); + * ``` + * + * @param string $key Required field name. + * + * @return array Object field value. + */ + public function map(string $key): array + { + return self::object($this->value($key), $this->keyPath($key))->data; + } + + /** + * Returns an integer field or `null`. + * + * Usage example: + * + * ```php + * $line = \PHPForge\Debug\Storage\Payload::object(['line' => null])->nullableInt('line'); + * ``` + * + * @param string $key Required field name. + * + * @return int|null Integer field value or `null`. + */ + public function nullableInt(string $key): int|null + { + $value = $this->value($key); + + if ($value === null) { + return null; + } + + if (!is_int($value)) { + throw HydrationException::at( + $this->keyPath($key), + 'an integer or null', + ); + } + + return $value; + } + + /** + * Returns a numeric field as a float or `null`. + * + * Usage example: + * + * ```php + * $duration = \PHPForge\Debug\Storage\Payload::object(['duration' => 1.5])->nullableNumber('duration'); + * ``` + * + * @param string $key Required field name. + * + * @return float|null Numeric field value or `null`. + */ + public function nullableNumber(string $key): float|null + { + $value = $this->value($key); + + if ($value === null) { + return null; + } + + if (!is_int($value) && !is_float($value)) { + throw HydrationException::at( + $this->keyPath($key), + 'a number or null', + ); + } + + return $value; + } + + /** + * Returns a string field or `null`. + * + * Usage example: + * + * ```php + * $action = \PHPForge\Debug\Storage\Payload::object(['action' => null])->nullableString('action'); + * ``` + * + * @param string $key Required field name. + * + * @return string|null String field value or `null`. + */ + public function nullableString(string $key): string|null + { + $value = $this->value($key); + + if ($value === null) { + return null; + } + + if (!is_string($value)) { + throw HydrationException::at( + $this->keyPath($key), + 'a string or null', + ); + } + + return $value; + } + + /** + * Returns a required numeric field as a float. + * + * Usage example: + * + * ```php + * $duration = \PHPForge\Debug\Storage\Payload::object(['duration' => 1.5])->number('duration'); + * ``` + * + * @param string $key Required field name. + * + * @return float Numeric field value. + */ + public function number(string $key): float + { + $value = $this->value($key); + + if (!is_int($value) && !is_float($value)) { + throw HydrationException::at( + $this->keyPath($key), + 'a number', + ); + } + + return (float) $value; + } + + /** + * Creates a strict reader for a decoded JSON object. + * + * Usage example: + * + * ```php + * $payload = \PHPForge\Debug\Storage\Payload::object(['name' => 'debug'], '$.panel'); + * ``` + * + * @param mixed $value Decoded JSON value. + * @param string $path Object path used in hydration errors. + * + * @return self Strict object reader. + */ + public static function object(mixed $value, string $path = '$'): self + { + if (!is_array($value) || (array_is_list($value) && $value !== [])) { + throw HydrationException::at( + $path, + 'an object', + ); + } + + foreach (array_keys($value) as $key) { + if (!is_string($key)) { + throw HydrationException::at( + $path, + 'an object with string keys', + ); + } + } + + /** @var array $value */ + return new self( + $value, + $path, + ); + } + + /** + * Returns a required field without conversion. + * + * Usage example: + * + * ```php + * $value = \PHPForge\Debug\Storage\Payload::object(['value' => ['nested']])->raw('value'); + * ``` + * + * @param string $key Required field name. + * + * @return mixed Unconverted field value. + */ + public function raw(string $key): mixed + { + return $this->value($key); + } + + /** + * Reads a list of JSON objects, validating each element's shape but leaving its values untouched. + * + * Usage example: + * + * ```php + * $rows = \PHPForge\Debug\Storage\Payload::object(['rows' => [['name' => 'debug']]])->rows('rows'); + * ``` + * + * @param string $key Required field name. + * + * @return list> Validated object rows. + */ + public function rows(string $key): array + { + $path = $this->keyPath($key); + + $rows = []; + + foreach ($this->list($key) as $index => $row) { + $rows[] = self::object($row, "{$path}[{$index}]")->data; + } + + return $rows; + } + + /** + * Validates required, optional, and undeclared fields. + * + * Usage example: + * + * ```php + * $payload = \PHPForge\Debug\Storage\Payload::object(['name' => 'debug'])->shape(['name'], ['description']); + * ``` + * + * @param list $required Required field names. + * @param list $optional Optional field names. + * + * @return self Validated object reader. + */ + public function shape(array $required, array $optional = []): self + { + foreach ($required as $key) { + if (!array_key_exists($key, $this->data)) { + throw HydrationException::at( + "{$this->path}.{$key}", + 'a required field', + ); + } + } + + $unknown = array_diff(array_keys($this->data), [...$required, ...$optional]); + + if ($unknown !== []) { + $key = array_values($unknown)[0]; + + throw HydrationException::at( + "{$this->path}.{$key}", + 'a declared field', + ); + } + + return $this; + } + + /** + * Returns a required `string` field. + * + * Usage example: + * + * ```php + * $name = \PHPForge\Debug\Storage\Payload::object(['name' => 'debug'])->string('name'); + * ``` + * + * @param string $key Required field name. + * + * @return string String field value. + */ + public function string(string $key): string + { + $value = $this->value($key); + + if (!is_string($value)) { + throw HydrationException::at( + $this->keyPath($key), + 'a string', + ); + } + + return $value; + } + + /** + * Returns the absolute payload path for a field. + * + * @param string $key Field name. + * + * @return string Absolute field path. + */ + private function keyPath(string $key): string + { + return "{$this->path}.{$key}"; + } + + /** + * Returns a required field without conversion. + * + * @param string $key Required field name. + * + * @return mixed Unconverted field value. + */ + private function value(string $key): mixed + { + if (!array_key_exists($key, $this->data)) { + throw HydrationException::at( + $this->keyPath($key), + 'a required field', + ); + } + + return $this->data[$key]; + } +} diff --git a/src/Storage/RequestSummary.php b/src/Storage/RequestSummary.php new file mode 100644 index 0000000..7d25ce3 --- /dev/null +++ b/src/Storage/RequestSummary.php @@ -0,0 +1,176 @@ + $mailFiles Captured mail file paths. + * @param float|null $processingTime Processing duration in seconds or `null` when unavailable. + * @param int|null $peakMemory Peak memory in bytes or `null` when unavailable. + */ + public function __construct( + public string $tag, + public string $url, + public bool $ajax, + public string $method, + public string $ip, + public float $time, + public int $statusCode, + public int $sqlCount, + public int $excessiveCallersCount, + public int $mailCount, + public array $mailFiles, + public float|null $processingTime, + public int|null $peakMemory, + ) {} + + /** + * Hydrates request metadata from decoded JSON data. + * + * Usage example: + * + * ```php + * $summary = \PHPForge\Debug\Storage\RequestSummary::fromArray($data); + * ``` + * + * @param mixed $data Decoded request metadata. + * @param string $path Payload path used in hydration errors. + * + * @return self Hydrated request metadata. + */ + public static function fromArray(mixed $data, string $path = '$.summary'): self + { + $payload = Payload::object($data, $path) + ->shape( + [ + 'tag', + 'url', + 'ajax', + 'method', + 'ip', + 'time', + 'statusCode', + 'sqlCount', + 'excessiveCallersCount', + 'mailCount', + 'mailFiles', + 'processingTime', + 'peakMemory', + ], + ); + + $mailFiles = []; + + foreach ($payload->list('mailFiles') as $index => $file) { + if (!is_string($file)) { + throw HydrationException::at( + "{$path}.mailFiles[{$index}]", + 'a string', + ); + } + + $mailFiles[] = $file; + } + + return new self( + tag: $payload->string('tag'), + url: $payload->string('url'), + ajax: $payload->bool('ajax'), + method: $payload->string('method'), + ip: $payload->string('ip'), + time: $payload->number('time'), + statusCode: $payload->int('statusCode'), + sqlCount: $payload->int('sqlCount'), + excessiveCallersCount: $payload->int('excessiveCallersCount'), + mailCount: $payload->int('mailCount'), + mailFiles: $mailFiles, + processingTime: $payload->nullableNumber('processingTime'), + peakMemory: $payload->nullableInt('peakMemory'), + ); + } + + /** + * Returns the request metadata for JSON serialization. + * + * Usage example: + * + * ```php + * $data = $summary->jsonSerialize(); + * ``` + * + * @return array Serialized request metadata. + */ + public function jsonSerialize(): array + { + return [ + 'tag' => $this->tag, + 'url' => $this->url, + 'ajax' => $this->ajax, + 'method' => $this->method, + 'ip' => $this->ip, + 'time' => $this->time, + 'statusCode' => $this->statusCode, + 'sqlCount' => $this->sqlCount, + 'excessiveCallersCount' => $this->excessiveCallersCount, + 'mailCount' => $this->mailCount, + 'mailFiles' => $this->mailFiles, + 'processingTime' => $this->processingTime, + 'peakMemory' => $this->peakMemory, + ]; + } + + /** + * Returns a copy enriched with processing time and peak memory usage. + * + * Usage example: + * + * ```php + * $profiledSummary = $summary->withProfiling(0.015, 2_097_152); + * ``` + * + * @param float $processingTime Processing duration in seconds. + * @param int $peakMemory Peak memory in bytes. + * + * @return self Request metadata enriched with profiling values. + */ + public function withProfiling(float $processingTime, int $peakMemory): self + { + return new self( + tag: $this->tag, + url: $this->url, + ajax: $this->ajax, + method: $this->method, + ip: $this->ip, + time: $this->time, + statusCode: $this->statusCode, + sqlCount: $this->sqlCount, + excessiveCallersCount: $this->excessiveCallersCount, + mailCount: $this->mailCount, + mailFiles: $this->mailFiles, + processingTime: $processingTime, + peakMemory: $peakMemory, + ); + } +} diff --git a/src/Storage/SnapshotStore.php b/src/Storage/SnapshotStore.php new file mode 100644 index 0000000..59242f9 --- /dev/null +++ b/src/Storage/SnapshotStore.php @@ -0,0 +1,439 @@ +clear(); + * ``` + */ + public function clear(): void + { + $patterns = [ + "{$this->path}/*.data", + "{$this->path}/*.json", + "{$this->path}/.debug-*", + $this->lockFile(), + ]; + + foreach ($patterns as $pattern) { + $files = glob($pattern); + + foreach ($files === false ? [] : $files as $file) { + if (is_file($file) && !@unlink($file)) { + throw new StorageException( + "Unable to remove debug data file: {$file}", + ); + } + } + } + + $this->initialized = false; + } + + /** + * Returns manifest entries ordered from newest to oldest. + * + * Usage example: + * + * ```php + * $store = new \PHPForge\Debug\Storage\SnapshotStore(sys_get_temp_dir() . '/debug', 0o775, null); + * $entries = $store->loadManifest(); + * ``` + * + * @return array Newest entries first. + */ + public function loadManifest(): array + { + $lock = @fopen($this->lockFile(), 'c+'); + + if ($lock === false) { + return []; + } + + @flock($lock, LOCK_SH); + + $manifest = $this->readManifestFile(); + + @flock($lock, LOCK_UN); + fclose($lock); + + return $manifest === null ? [] : array_reverse($manifest->entries, true); + } + + /** + * Returns a stored snapshot or `null` when the tag or persisted payload is invalid. + * + * Usage example: + * + * ```php + * $store = new \PHPForge\Debug\Storage\SnapshotStore(sys_get_temp_dir() . '/debug', 0o775, null); + * $snapshot = $store->readSnapshot('request-1'); + * ``` + * + * @param string $tag Snapshot tag. + * + * @return DebugSnapshot|null Hydrated snapshot or `null` when the stored value is unavailable or invalid. + */ + public function readSnapshot(string $tag): DebugSnapshot|null + { + if (!self::isValidTag($tag)) { + return null; + } + + $raw = @file_get_contents($this->snapshotFile($tag)); + + if ($raw === false || $raw === '') { + return null; + } + + try { + return DebugSnapshot::fromArray(self::decode($raw)); + } catch (Throwable) { + return null; + } + } + + /** + * Adds a summary and returns entries evicted by history garbage collection. + * + * Usage example: + * + * ```php + * $removed = $store->updateManifest($summary, 50); + * ``` + * + * @param RequestSummary $summary Request metadata to add or replace. + * @param int $historySize Maximum number of entries retained after garbage collection. + * + * @return list Entries evicted from the manifest. + */ + public function updateManifest(RequestSummary $summary, int $historySize): array + { + $this->initialize(); + + $lock = @fopen($this->lockFile(), 'c+'); + + if ($lock === false) { + throw new StorageException( + "Unable to open debug data lock file: {$this->lockFile()}", + ); + } + + @flock($lock, LOCK_EX); + + try { + $manifest = $this->readManifestFile(); + $resetStorage = $manifest === null && is_file($this->indexFile()); + $entries = $manifest instanceof Manifest ? $manifest->entries : []; + $entries[$summary->tag] = $summary; + + $removed = $this->collectGarbage($entries, $historySize); + + $this->atomicWrite($this->indexFile(), self::encode(new Manifest($entries))); + + if ($resetStorage) { + $this->removeStaleSnapshots($entries); + } + } finally { + @flock($lock, LOCK_UN); + fclose($lock); + } + + return $removed; + } + + /** + * Writes a snapshot atomically under a validated tag. + * + * Usage example: + * + * ```php + * $store->writeSnapshot('request-1', $snapshot); + * ``` + * + * @param string $tag Snapshot tag. + * @param DebugSnapshot $snapshot Snapshot to persist. + */ + public function writeSnapshot(string $tag, DebugSnapshot $snapshot): void + { + $this->initialize(); + + $this->atomicWrite($this->snapshotFile($tag), self::encode($snapshot)); + } + + /** + * Replaces a target file through a temporary file in the same directory. + * + * @param string $file Target file path. + * @param string $contents JSON contents to write. + */ + private function atomicWrite(string $file, string $contents): void + { + $temporary = @tempnam($this->path, '.debug-'); + + if ($temporary === false) { + throw new StorageException( + "Unable to write temporary debug data file for: {$file}", + ); + } + + if (file_put_contents($temporary, $contents) === false) { + @unlink($temporary); + + throw new StorageException( + "Unable to write temporary debug data file for: {$file}", + ); + } + + if ($this->fileMode !== null) { + @chmod($temporary, $this->fileMode); + } + + if (!@rename($temporary, $file)) { + @unlink($temporary); + + throw new StorageException( + "Unable to replace debug data file: {$file}", + ); + } + } + + /** + * Removes expired manifest entries and their snapshots. + * + * @param array $entries Manifest entries, updated in place. + * @param int $historySize Maximum number of retained entries. + * + * @return list Removed manifest entries. + */ + private function collectGarbage(array &$entries, int $historySize): array + { + if (count($entries) <= $historySize + 10) { + return []; + } + + $remaining = count($entries) - $historySize; + + $removed = []; + + foreach (array_keys($entries) as $tag) { + $removed[] = $entries[$tag]; + + @unlink($this->snapshotFile($tag)); + unset($entries[$tag]); + + if (--$remaining <= 0) { + break; + } + } + + $this->removeStaleSnapshots($entries); + + return $removed; + } + + /** + * Decodes JSON and throws when the payload is invalid. + * + * @param string $json JSON document to decode. + * + * @throws JsonException if the JSON is invalid. + * + * @return mixed Decoded JSON value. + */ + private static function decode(string $json): mixed + { + return json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } + + /** + * Encodes a value as deterministic JSON. + * + * @param mixed $value Value to encode. + * + * @throws JsonException if the value cannot be encoded. + * + * @return string Encoded JSON document. + */ + private static function encode(mixed $value): string + { + return json_encode($value, self::JSON_FLAGS); + } + + /** + * Returns the manifest file path. + * + * @return string Manifest file path. + */ + private function indexFile(): string + { + return "{$this->path}/" . self::INDEX_FILE; + } + + /** + * Creates the storage directory and removes legacy serialized files once per store instance. + */ + private function initialize(): void + { + if ($this->initialized) { + return; + } + + if (!is_dir($this->path) && !@mkdir($this->path, $this->dirMode, true) && !is_dir($this->path)) { + throw new StorageException( + "Unable to create debug data directory: {$this->path}", + ); + } + + $legacy = glob($this->path . '/*.data'); + + foreach ($legacy === false ? [] : $legacy as $file) { + @unlink($file); + } + + $this->initialized = true; + } + + /** + * Determines whether a tag can form a safe snapshot filename. + * + * @param string $tag Snapshot tag to validate. + * + * @return bool Whether the tag is safe for a filename. + */ + private static function isValidTag(string $tag): bool + { + return $tag !== '' && preg_match('/\A[A-Za-z0-9._-]+\z/D', $tag) === 1; + } + + /** + * Returns the manifest lock file path. + * + * @return string Manifest lock file path. + */ + private function lockFile(): string + { + return "{$this->path}/" . self::LOCK_FILE; + } + + /** + * Reads the manifest or returns `null` when persisted JSON is invalid. + * + * @return Manifest|null Hydrated manifest or `null` for invalid persisted JSON. + */ + private function readManifestFile(): Manifest|null + { + $raw = @file_get_contents($this->indexFile()); + + if ($raw === false || $raw === '') { + return new Manifest([]); + } + + try { + return Manifest::fromArray(self::decode($raw)); + } catch (Throwable) { + return null; + } + } + + /** + * Removes snapshot files whose tags no longer appear in the manifest. + * + * @param array $entries Retained manifest entries. + */ + private function removeStaleSnapshots(array $entries): void + { + $storedTags = []; + + $files = glob("{$this->path}/*.json"); + + foreach ($files === false ? [] : $files as $file) { + if ($file === $this->indexFile()) { + continue; + } + + $storedTags[] = pathinfo($file, PATHINFO_FILENAME); + } + + foreach (array_diff($storedTags, array_keys($entries)) as $tag) { + @unlink($this->snapshotFile($tag)); + } + } + + /** + * Returns the snapshot file path for a validated tag. + * + * @param string $tag Snapshot tag. + * + * @return string Snapshot file path. + */ + private function snapshotFile(string $tag): string + { + if (!self::isValidTag($tag)) { + throw new StorageException( + "Invalid debug snapshot tag: {$tag}", + ); + } + + return $this->path . "/{$tag}.json"; + } +} diff --git a/src/Storage/StorageException.php b/src/Storage/StorageException.php new file mode 100644 index 0000000..940b02d --- /dev/null +++ b/src/Storage/StorageException.php @@ -0,0 +1,12 @@ + 'debug', 3 => true]); + $hydrated = ArrayPayloadSnapshotFixture::fromArray($captured->jsonSerialize(), '$.fixture'); + + self::assertSame( + ['name' => 'debug', 3 => true], + $hydrated->data(), + 'Plain values must retain their keys and types.', + ); + self::assertSame( + $captured->jsonSerialize(), + $hydrated->jsonSerialize(), + 'Tagged payload must remain byte-for-byte equivalent.', + ); + } +} diff --git a/tests/Storage/DebugArrayTest.php b/tests/Storage/DebugArrayTest.php new file mode 100644 index 0000000..64dcf1b --- /dev/null +++ b/tests/Storage/DebugArrayTest.php @@ -0,0 +1,39 @@ + 1, 'b' => ['c' => true]]); + + self::assertSame( + ['a' => 1, 'b' => ['c' => true]], + DebugArray::fromArray($captured->jsonSerialize(), '$.panels.config.data')->values(), + 'Nested values must survive the round-trip.', + ); + } + + public function testThrowHydrationExceptionWhenTheTaggedValueIsNotAnArray(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.panels.config.data': expected a tagged array.", + ); + + DebugArray::fromArray(DebugValue::capture('a string')->jsonSerialize(), '$.panels.config.data'); + } +} diff --git a/tests/Storage/DebugSnapshotTest.php b/tests/Storage/DebugSnapshotTest.php new file mode 100644 index 0000000..1292273 --- /dev/null +++ b/tests/Storage/DebugSnapshotTest.php @@ -0,0 +1,100 @@ +summary(), + [], + [ + 'log' => PanelFailure::fromThrowable(PanelFailure::CAPTURE, new RuntimeException('boom')), + ], + ); + + $failures = $snapshot->jsonSerialize()['failures'] ?? null; + + self::assertIsArray( + $failures, + 'The failure collection must serialize as an array.', + ); + + $failure = $failures['log'] ?? null; + + self::assertIsArray( + $failure, + 'Serialized failures must not expose storage objects.', + ); + self::assertSame( + PanelFailure::CAPTURE, + $failure['stage'] ?? null, + 'The failure stage must be retained.', + ); + + $exception = $failure['exception'] ?? null; + + self::assertIsArray( + $exception, + 'The exception must serialize as an array.', + ); + self::assertSame( + 'boom', + $exception['message'] ?? null, + 'The serialized exception payload must be retained.', + ); + } + + public function testThrowHydrationExceptionWhenTheStorageVersionDoesNotMatch(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.version': expected storage version " . DebugSnapshot::VERSION . '.', + ); + + DebugSnapshot::fromArray( + [ + 'version' => DebugSnapshot::VERSION - 1, + 'summary' => [], + 'panels' => [], + 'failures' => [], + ], + ); + } + + /** + * Creates representative request metadata for snapshot tests. + * + * @return RequestSummary Representative request metadata. + */ + private function summary(): RequestSummary + { + return new RequestSummary( + tag: 'tag-1', + url: 'https://example.test/', + ajax: false, + method: 'GET', + ip: '127.0.0.1', + time: 1_700_000_000.0, + statusCode: 200, + sqlCount: 0, + excessiveCallersCount: 0, + mailCount: 0, + mailFiles: [], + processingTime: null, + peakMemory: null, + ); + } +} diff --git a/tests/Storage/DebugValueTest.php b/tests/Storage/DebugValueTest.php new file mode 100644 index 0000000..d04fcdf --- /dev/null +++ b/tests/Storage/DebugValueTest.php @@ -0,0 +1,335 @@ +type, + 'The value stays a tagged object.', + ); + self::assertStringContainsString( + 'Stringable@anonymous', + (string) $value->value, + 'The class name is the fallback.', + ); + } + + public function testCaptureLabelsAClosedResourceAsUnsupported(): void + { + $handle = fopen('php://memory', 'r'); + + self::assertIsResource( + $handle, + 'The fixture must open a stream.', + ); + + fclose($handle); + + $value = DebugValue::capture($handle); + + self::assertSame( + 'unsupported', + $value->type, + 'A closed resource is no longer a resource.', + ); + self::assertSame( + 'unknown-type', + $value->reason, + 'The reason must record why it was rejected.', + ); + } + + public function testCaptureLabelsAnOpenResourceWithItsType(): void + { + $handle = fopen('php://memory', 'r'); + + self::assertIsResource( + $handle, + 'The fixture must open a stream.', + ); + + $value = DebugValue::capture($handle); + + fclose($handle); + + self::assertSame( + 'resource', + $value->type, + 'An open resource keeps its tagged type.', + ); + self::assertSame( + 'stream', + $value->resourceType, + 'The resource type must be recorded.', + ); + self::assertSame( + '(resource: stream)', + $value->toDisplayValue(), + 'Display value must name the resource.', + ); + self::assertSame( + ['type' => 'resource', 'resourceType' => 'stream'], + $value->jsonSerialize(), + 'Serialized form must carry the resource type.', + ); + self::assertEquals( + $value, + DebugValue::fromArray($value->jsonSerialize()), + 'A resource must round-trip through hydration.', + ); + } + + public function testCaptureLabelsAThrowableWithItsMessage(): void + { + $value = DebugValue::capture(new RuntimeException('boom')); + + self::assertStringContainsString( + 'boom', + (string) $value->value, + 'The label must carry the message.', + ); + } + + public function testCaptureStringifiesAStringableObject(): void + { + $value = DebugValue::capture( + new class implements Stringable { + /** + * Returns the fixture label. + * + * @return string Fixture label. + */ + public function __toString(): string + { + return 'rendered'; + } + }, + ); + + self::assertSame( + 'rendered', + $value->value, + 'A Stringable must be labelled with its string form.', + ); + } + + public function testCaptureTruncatesBeyondTheDepthLimit(): void + { + $deep = 'leaf'; + + for ($i = 0; $i < 12; $i++) { + $deep = [$deep]; + } + + self::assertStringContainsString( + 'DEEP NESTED VALUE', + $this->flatten(DebugValue::capture($deep)), + 'Values nested past the depth limit must be truncated.', + ); + } + + public function testCaptureTruncatesBeyondTheNodeLimit(): void + { + self::assertStringContainsString( + 'SKIPPED over 10000 nodes', + $this->flatten(DebugValue::capture(range(1, 10_050))), + 'Values past the node budget must be truncated.', + ); + } + + public function testRoundTripPreservesJsonSafeValuesAndLabelsUnsafeValues(): void + { + $object = new stdClass(); + + $object->name = 'debug'; + $object->self = $object; + + $value = DebugValue::capture( + [ + 'binary' => "\xB1\x31", + 'nan' => NAN, + 'positiveInfinity' => INF, + 'negativeInfinity' => -INF, + 'closure' => static fn(): bool => true, + 'object' => $object, + ], + ); + + $encoded = json_encode($value, JSON_THROW_ON_ERROR); + $decoded = json_decode($encoded, true, 512, JSON_THROW_ON_ERROR); + + $display = DebugValue::fromArray($decoded)->toDisplayValue(); + + self::assertIsArray( + $display, + 'Top-level display value must remain an array.', + ); + self::assertIsString( + $display['binary'] ?? null, + 'Binary data must project to a label.', + ); + self::assertStringStartsWith( + '(binary: base64 ', + $display['binary'], + 'Binary label must identify its base64 representation.', + ); + self::assertSame( + 'NAN', + $display['nan'] ?? null, + 'NAN must retain its non-finite label.', + ); + self::assertSame( + 'INF', + $display['positiveInfinity'] ?? null, + 'Positive infinity must retain its non-finite label.', + ); + self::assertSame( + '-INF', + $display['negativeInfinity'] ?? null, + 'Negative infinity must retain its non-finite label.', + ); + self::assertSame( + ['__class' => Closure::class], + $display['closure'] ?? null, + 'Closure must project only its class marker.', + ); + + $capturedObject = $display['object'] ?? null; + + self::assertIsArray( + $capturedObject, + 'Captured object must project to an array.', + ); + self::assertSame( + stdClass::class, + $capturedObject['__class'] ?? null, + 'Object projection must retain its class marker.', + ); + self::assertSame( + 'debug', + $capturedObject['name'] ?? null, + 'Public object properties must retain their values.', + ); + self::assertSame( + stdClass::class, + $capturedObject['self'] ?? null, + 'Recursive reference must project to the object class.', + ); + } + + public function testThrowHydrationExceptionForAnEntryKeyThatDoesNotMatchItsKeyType(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '.key', + ); + + DebugValue::fromArray( + [ + 'type' => 'array', + 'entries' => [ + [ + 'keyType' => 'int', + 'key' => 'not-an-int', + 'value' => ['type' => 'null'], + ], + ], + ], + ); + } + + public function testThrowHydrationExceptionForAnUnknownSpecialFloat(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.value', + ); + + DebugValue::fromArray(['type' => 'special-float', 'value' => 'NOPE']); + } + + public function testThrowHydrationExceptionForAnUnsupportedBinaryEncoding(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.encoding', + ); + + DebugValue::fromArray(['type' => 'binary', 'encoding' => 'hex', 'data' => 'ff']); + } + + public function testThrowHydrationExceptionForFieldsThatDoNotBelongToTheTaggedType(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.value', + ); + + DebugValue::fromArray(['type' => 'null', 'value' => null]); + } + + public function testThrowHydrationExceptionForInvalidBinaryData(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.data', + ); + + DebugValue::fromArray(['type' => 'binary', 'encoding' => 'base64', 'data' => '*invalid*']); + } + + public function testThrowHydrationExceptionForUnknownFields(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.unexpected', + ); + + DebugValue::fromArray(['type' => 'null', 'unexpected' => true]); + } + + /** + * Renders the tagged value as a `string` so truncation labels can be asserted regardless of nesting depth. + * + * @param DebugValue $value Tagged value to render. + * + * @return string JSON representation used by truncation assertions. + */ + private function flatten(DebugValue $value): string + { + return (string) json_encode($value->jsonSerialize()); + } +} diff --git a/tests/Storage/ExceptionSnapshotTest.php b/tests/Storage/ExceptionSnapshotTest.php new file mode 100644 index 0000000..8a5e94c --- /dev/null +++ b/tests/Storage/ExceptionSnapshotTest.php @@ -0,0 +1,106 @@ +getClass(), + 'The hydrated exception must retain its original class.', + ); + self::assertSame( + 'outer failure', + $hydrated->getMessage(), + 'The hydrated exception must retain its original message.', + ); + self::assertSame( + 42, + $hydrated->getCode(), + 'The hydrated exception must retain its original code.', + ); + self::assertSame( + $throwable->getFile(), + $hydrated->getFile(), + 'The hydrated exception must retain its original file.', + ); + self::assertSame( + $throwable->getLine(), + $hydrated->getLine(), + 'The hydrated exception must retain its original line.', + ); + self::assertSame( + (string) $throwable, + (string) $hydrated, + 'The hydrated exception must retain its original string representation.', + ); + + $frame = $hydrated->getTrace()[0] ?? self::fail('Expected the helper call in the captured trace.'); + + self::assertSame( + ['namespace', 'short_class', 'class', 'type', 'function', 'file', 'line', 'args'], + array_keys($frame), + 'Trace projection must retain frame metadata alongside its arguments.', + ); + + $previous = $hydrated->getPrevious(); + + self::assertNotNull( + $previous, + 'The hydrated exception must retain its previous exception.', + ); + self::assertSame( + LogicException::class, + $previous->getClass(), + 'The hydrated previous exception must retain its original class.', + ); + self::assertSame( + 'inner failure', + $previous->getMessage(), + 'The hydrated previous exception must retain its original message.', + ); + self::assertSame( + 7, + $previous->getCode(), + 'The hydrated previous exception must retain its original code.', + ); + } + + public function testThrowHydrationExceptionForInvalidCodeType(): void + { + $payload = ExceptionSnapshot::fromThrowable(new RuntimeException('failure')) + ->jsonSerialize(); + + $payload['code'] = false; + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.exception.code', + ); + + ExceptionSnapshot::fromArray($payload); + } +} diff --git a/tests/Storage/ManifestTest.php b/tests/Storage/ManifestTest.php new file mode 100644 index 0000000..f796f92 --- /dev/null +++ b/tests/Storage/ManifestTest.php @@ -0,0 +1,80 @@ + $this->summary('tag-1')]); + + $restored = Manifest::fromArray($manifest->jsonSerialize()); + + self::assertSame( + ['tag-1'], + array_keys($restored->entries), + 'Entries must stay keyed by tag.', + ); + } + + public function testThrowHydrationExceptionWhenAnEntryTagDoesNotMatchItsKey(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.entries.tag-1.tag'", + ); + + Manifest::fromArray( + [ + 'version' => DebugSnapshot::VERSION, + 'entries' => ['tag-1' => $this->summary('other-tag')->jsonSerialize()], + ], + ); + } + + public function testThrowHydrationExceptionWhenTheStorageVersionDoesNotMatch(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.version': expected storage version " . DebugSnapshot::VERSION . '.', + ); + + Manifest::fromArray(['version' => DebugSnapshot::VERSION - 1, 'entries' => []]); + } + + /** + * Creates representative request metadata for a manifest entry. + * + * @param string $tag Request tag. + * + * @return RequestSummary Representative manifest entry. + */ + private function summary(string $tag): RequestSummary + { + return new RequestSummary( + tag: $tag, + url: 'https://example.test/', + ajax: false, + method: 'GET', + ip: '127.0.0.1', + time: 1_700_000_000.0, + statusCode: 200, + sqlCount: 0, + excessiveCallersCount: 0, + mailCount: 0, + mailFiles: [], + processingTime: null, + peakMemory: null, + ); + } +} diff --git a/tests/Storage/PanelFailureTest.php b/tests/Storage/PanelFailureTest.php new file mode 100644 index 0000000..f1a79e3 --- /dev/null +++ b/tests/Storage/PanelFailureTest.php @@ -0,0 +1,50 @@ +jsonSerialize(), '$.failures.log'); + + self::assertSame( + PanelFailure::HYDRATE, + $restored->stage, + 'The stage must round-trip.', + ); + self::assertSame( + 'boom', + $restored->exception->getMessage(), + 'The message must round-trip.', + ); + } + + public function testThrowRuntimeExceptionForAnUnknownStage(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.failures.log.stage'", + ); + + PanelFailure::fromArray( + [ + 'stage' => 'render', + 'exception' => ExceptionSnapshot::fromThrowable(new RuntimeException('boom'))->jsonSerialize(), + ], + '$.failures.log', + ); + } +} diff --git a/tests/Storage/PayloadTest.php b/tests/Storage/PayloadTest.php new file mode 100644 index 0000000..0302da3 --- /dev/null +++ b/tests/Storage/PayloadTest.php @@ -0,0 +1,182 @@ + 1, 'b' => 'two'], + Payload::object(['a' => 1, 'b' => 'two'])->all(), + 'Every decoded entry must survive.', + ); + } + + public function testNullableNumberReturnsIntegerInputAsFloat(): void + { + self::assertSame( + 7.0, + Payload::object(['duration' => 7])->nullableNumber('duration'), + 'Integer JSON numbers must satisfy the nullable float contract.', + ); + } + + public function testObjectAcceptsAnEmptyArrayAsAnEmptyObject(): void + { + self::assertSame( + [], + Payload::object([])->all(), + 'An empty JSON object decodes to an empty array.', + ); + } + + public function testRowsValidatesEveryElementAsAnObject(): void + { + self::assertSame( + [['file' => 'a.php'], ['file' => 'b.php']], + Payload::object(['trace' => [['file' => 'a.php'], ['file' => 'b.php']]])->rows('trace'), + 'Each element must round-trip as a string-keyed map.', + ); + } + + public function testThrowHydrationExceptionForAnObjectWithIntegerKeys(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$': expected an object with string keys.", + ); + + Payload::object([1 => 'a']); + } + + public function testThrowHydrationExceptionForANonBooleanValue(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.flag': expected a boolean.", + ); + + Payload::object(['flag' => 1])->bool('flag'); + } + + public function testThrowHydrationExceptionForANonIntegerValue(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.count': expected an integer.", + ); + + Payload::object(['count' => '7'])->int('count'); + } + + public function testThrowHydrationExceptionForANonListValue(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.entries': expected a list.", + ); + + Payload::object(['entries' => ['a' => 1]])->list('entries'); + } + + public function testThrowHydrationExceptionForANonNumberValue(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '$.time': expected a number.", + ); + + Payload::object(['time' => '1.5'])->number('time'); + } + + public function testThrowHydrationExceptionForANonStringValue(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.name': expected a string.", + ); + + Payload::object(['name' => 42])->string('name'); + } + + public function testThrowHydrationExceptionForANullableIntegerCarryingAString(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '$.line': expected an integer or null.", + ); + + Payload::object(['line' => '7'])->nullableInt('line'); + } + + public function testThrowHydrationExceptionForANullableNumberCarryingAString(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '$.duration': expected a number or null.", + ); + + Payload::object(['duration' => '1.5'])->nullableNumber('duration'); + } + + public function testThrowHydrationExceptionForANullableStringCarryingAnInteger(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '$.action': expected a string or null.", + ); + + Payload::object(['action' => 42])->nullableString('action'); + } + + public function testThrowHydrationExceptionForAnUndeclaredField(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.extra': expected a declared field.", + ); + + Payload::object(['name' => 'a', 'extra' => 1])->shape(['name']); + } + + public function testThrowHydrationExceptionForAValueThatIsNotAnObject(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$': expected an object.", + ); + + Payload::object(['a', 'b']); + } + + public function testThrowHydrationExceptionWhenAMissingKeyIsRead(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.absent': expected a required field.", + ); + + Payload::object([])->raw('absent'); + } + + public function testThrowHydrationExceptionWhenARequiredFieldIsMissing(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.name': expected a required field.", + ); + + Payload::object([])->shape(['name']); + } +} diff --git a/tests/Storage/RequestSummaryTest.php b/tests/Storage/RequestSummaryTest.php new file mode 100644 index 0000000..e3b2e86 --- /dev/null +++ b/tests/Storage/RequestSummaryTest.php @@ -0,0 +1,114 @@ +payload()); + + self::assertSame( + 200, + $summary->statusCode, + 'Status code must remain an integer.', + ); + self::assertSame( + 1_700_000_000.0, + $summary->time, + 'Request timestamp must remain a float.', + ); + self::assertFalse( + $summary->ajax, + 'Synchronous request flag must remain `false`.', + ); + } + + public function testThrowHydrationExceptionForNumericString(): void + { + $payload = $this->payload(); + + $payload['statusCode'] = '200'; + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.summary.statusCode', + ); + + RequestSummary::fromArray($payload); + } + + public function testThrowHydrationExceptionForUnknownField(): void + { + $payload = $this->payload(); + + $payload['unexpected'] = true; + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '$.summary.unexpected', + ); + + RequestSummary::fromArray($payload); + } + + public function testThrowHydrationExceptionWhenAMailFileEntryIsNotAString(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '\$.summary.mailFiles[1]'", + ); + + RequestSummary::fromArray( + [ + 'tag' => 'tag-1', + 'url' => 'https://example.test/', + 'ajax' => false, + 'method' => 'GET', + 'ip' => '127.0.0.1', + 'time' => 1_700_000_000.0, + 'statusCode' => 200, + 'sqlCount' => 0, + 'excessiveCallersCount' => 0, + 'mailCount' => 2, + 'mailFiles' => ['a.eml', 42], + 'processingTime' => null, + 'peakMemory' => null, + ], + ); + } + + /** + * Returns representative decoded request metadata. + * + * @return array Representative decoded request metadata. + */ + private function payload(): array + { + return [ + 'tag' => 'tag-1', + 'url' => 'https://example.test/', + 'ajax' => false, + 'method' => 'GET', + 'ip' => '127.0.0.1', + 'time' => 1_700_000_000.0, + 'statusCode' => 200, + 'sqlCount' => 0, + 'excessiveCallersCount' => 0, + 'mailCount' => 0, + 'mailFiles' => [], + 'processingTime' => null, + 'peakMemory' => null, + ]; + } +} diff --git a/tests/Storage/SnapshotStoreTest.php b/tests/Storage/SnapshotStoreTest.php new file mode 100644 index 0000000..0726982 --- /dev/null +++ b/tests/Storage/SnapshotStoreTest.php @@ -0,0 +1,301 @@ +store(); + + $summary = $this->summary('current', 1_700_000_000.0); + + $store->writeSnapshot( + 'current', + new DebugSnapshot($summary, [], []), + ); + $store->updateManifest($summary, 10); + $store->clear(); + + self::assertNull( + $store->readSnapshot('current'), + 'Cleared snapshot must read as `null`.', + ); + self::assertSame( + [], + $store->loadManifest(), + 'Cleared manifest must contain no entries.', + ); + } + + public function testInvalidJsonIsRejectedWithoutExecutingPayloads(): void + { + mkdir($this->path, recursive: true); + file_put_contents("{$this->path}/invalid.json", '{invalid'); + + self::assertNull( + $this->store()->readSnapshot('invalid'), + 'Malformed JSON must read as `null`.', + ); + } + + public function testLoadManifestReturnsNothingWhenTheLockFileCannotBeOpened(): void + { + $store = $this->store(); + + $store->updateManifest($this->summary('tag-1', 1_700_000_000.0), 10); + + MockerState::addCondition('PHPForge\\Debug\\Storage', 'fopen', [], false, true); + + self::assertSame( + [], + $store->loadManifest(), + 'An unopenable lock file must yield an empty manifest instead of throwing.', + ); + } + + public function testReadRejectsTagThatEscapesTheStorageDirectory(): void + { + self::assertNull( + $this->store()->readSnapshot('../outside'), + 'Unsafe read tag must yield `null`.', + ); + } + + public function testReadSnapshotReturnsNullForATagThatWasNeverWritten(): void + { + mkdir($this->path, recursive: true); + + self::assertNull( + $this->store()->readSnapshot('never-written'), + 'A missing snapshot file must read back as `null`.', + ); + } + + public function testRemovesOrphanSnapshotsMissingFromTheManifest(): void + { + $store = $this->store(); + + $store->writeSnapshot( + 'kept', + new DebugSnapshot($this->summary('kept', 1_700_000_000.0), [], []), + ); + + file_put_contents("{$this->path}/orphan.json", '{}'); + + for ($index = 0; $index < 13; $index++) { + $store->updateManifest($this->summary("tag-{$index}", 1_700_000_000.0 + $index), 2); + } + + self::assertFileDoesNotExist( + "{$this->path}/orphan.json", + 'A snapshot with no manifest entry must be swept.', + ); + } + + public function testSnapshotAndManifestRoundTripThroughJson(): void + { + $store = $this->store(); + $older = $this->summary('older', 1_700_000_000.0); + $newer = $this->summary('newer', 1_700_000_001.0); + + $store->writeSnapshot( + 'newer', + new DebugSnapshot($newer, ['panel' => ['value' => 1]], []), + ); + $store->updateManifest($older, 10); + $store->updateManifest($newer, 10); + + $snapshot = $store->readSnapshot('newer'); + + self::assertNotNull( + $snapshot, + 'Persisted snapshot must remain readable.', + ); + self::assertSame( + 'newer', + $snapshot->summary->tag, + 'Request tag must survive persistence.', + ); + self::assertSame( + ['value' => 1], + $snapshot->panels['panel'] ?? null, + 'Panel payload must survive persistence.', + ); + self::assertSame( + ['newer', 'older'], + array_keys($store->loadManifest()), + 'Manifest entries must be ordered newest first.', + ); + } + + public function testThrowStorageExceptionForTagThatEscapesTheStorageDirectory(): void + { + $summary = $this->summary('../outside', 1_700_000_000.0); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Invalid debug snapshot tag: ../outside', + ); + + $this->store()->writeSnapshot( + '../outside', + new DebugSnapshot($summary, [], []), + ); + } + + public function testThrowStorageExceptionWhenTheSnapshotCannotBeMovedIntoPlace(): void + { + MockerState::addCondition('PHPForge\\Debug\\Storage', 'rename', [], false, true); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Unable to replace debug data file', + ); + + $this->store()->writeSnapshot( + 'blocked', + new DebugSnapshot($this->summary('blocked', 1_700_000_000.0), [], []), + ); + } + + public function testThrowStorageExceptionWhenTheTemporaryFileCannotBeCreated(): void + { + MockerState::addCondition('PHPForge\\Debug\\Storage', 'tempnam', [], false, true); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Unable to write temporary debug data file', + ); + + $this->store()->writeSnapshot( + 'blocked', + new DebugSnapshot($this->summary('blocked', 1_700_000_000.0), [], []), + ); + } + + public function testThrowStorageExceptionWhenTheTemporaryFileCannotBeWritten(): void + { + MockerState::addCondition('PHPForge\\Debug\\Storage', 'file_put_contents', [], false, true); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Unable to write temporary debug data file', + ); + + $this->store()->writeSnapshot( + 'blocked', + new DebugSnapshot($this->summary('blocked', 1_700_000_000.0), [], []), + ); + } + + public function testWritingJsonSnapshotRemovesLegacySerializedFiles(): void + { + mkdir($this->path, recursive: true); + file_put_contents("{$this->path}/legacy.data", 'serialized payload'); + + $summary = $this->summary('current', 1_700_000_000.0); + + $this->store()->writeSnapshot( + 'current', + new DebugSnapshot($summary, [], []), + ); + + self::assertFileDoesNotExist( + "{$this->path}/legacy.data", + 'Legacy serialized file must be removed.', + ); + self::assertFileExists( + "{$this->path}/current.json", + 'JSON snapshot file must be created.', + ); + } + + /** + * Creates an isolated temporary storage path. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->path = sys_get_temp_dir() . '/yii-debug-storage-' . uniqid('', true); + } + + /** + * Removes the temporary storage directory after each test. + */ + protected function tearDown(): void + { + $this->removeDirectory($this->path); + + parent::tearDown(); + } + + /** + * Removes a directory tree created by a test. + * + * @param string $path Directory path to remove. + */ + private function removeDirectory(string $path): void + { + $files = glob($path . '/*'); + + foreach ($files === false ? [] : $files as $file) { + is_dir($file) ? $this->removeDirectory($file) : unlink($file); + } + + if (is_dir($path)) { + rmdir($path); + } + } + + /** + * Creates a store for the isolated temporary path. + * + * @return SnapshotStore Store configured for the current test. + */ + private function store(): SnapshotStore + { + return new SnapshotStore($this->path, 0o777, null); + } + + /** + * Creates representative request metadata for storage tests. + * + * @param string $tag Request tag. + * @param float $time Request start timestamp. + * @return RequestSummary Representative request metadata. + */ + private function summary(string $tag, float $time): RequestSummary + { + return new RequestSummary( + tag: $tag, + url: 'https://example.test/', + ajax: false, + method: 'GET', + ip: '127.0.0.1', + time: $time, + statusCode: 200, + sqlCount: 0, + excessiveCallersCount: 0, + mailCount: 0, + mailFiles: [], + processingTime: null, + peakMemory: null, + ); + } +} diff --git a/tests/Support/ArrayPayloadSnapshotFixture.php b/tests/Support/ArrayPayloadSnapshotFixture.php new file mode 100644 index 0000000..2a9b6d1 --- /dev/null +++ b/tests/Support/ArrayPayloadSnapshotFixture.php @@ -0,0 +1,35 @@ + Restored fixture payload. + */ + public function data(): array + { + return $this->values(); + } + + /** + * Returns the fixture payload key. + * + * @return string Fixture payload key. + */ + protected static function payloadKey(): string + { + return 'values'; + } +} diff --git a/tests/Support/MockerExtension.php b/tests/Support/MockerExtension.php new file mode 100644 index 0000000..a41f926 --- /dev/null +++ b/tests/Support/MockerExtension.php @@ -0,0 +1,83 @@ +registerSubscribers( + new class implements StartedSubscriber { + /** + * Loads function mocks when the test suite starts. + * + * @param Started $event Test suite event. + */ + public function notify(Started $event): void + { + MockerExtension::load(); + } + }, + new class implements PreparationStartedSubscriber { + /** + * Resets function mocks before each test. + * + * @param PreparationStarted $event Test preparation event. + */ + public function notify(PreparationStarted $event): void + { + MockerState::resetState(); + MockerExtension::resetDefaults(); + } + }, + ); + } + + /** + * Loads filesystem function stubs for the storage namespace. + */ + public static function load(): void + { + $mocks = []; + + foreach (['file_put_contents', 'fopen', 'mkdir', 'rename', 'tempnam'] as $name) { + $mocks[] = [ + 'namespace' => 'PHPForge\Debug\Storage', + 'name' => $name, + ]; + } + + (new Mocker(stubPath: __DIR__ . '/mocker-stubs.php'))->load($mocks); + + MockerState::saveState(); + } + + /** + * Clears mock defaults between tests. + */ + public static function resetDefaults(): void + { + $defaults = (new ReflectionClass(MockerState::class))->getProperty('defaults'); + + $defaults->setValue(null, []); + } +} diff --git a/tests/Support/mocker-stubs.php b/tests/Support/mocker-stubs.php new file mode 100644 index 0000000..0a916c6 --- /dev/null +++ b/tests/Support/mocker-stubs.php @@ -0,0 +1,8 @@ + + */ +return []; From 78894f1bfe2a9e1f10bea9a4c418b4ad6c5b20ea Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 14 Aug 2026 17:33:32 -0400 Subject: [PATCH 02/13] Add Workflow ci. --- .github/dependabot.yml | 36 ++++++++++++++++++++++++++ .github/workflows/build.yml | 26 +++++++++++++++++++ .github/workflows/dependency-check.yml | 20 ++++++++++++++ .github/workflows/ecs.yml | 20 ++++++++++++++ .github/workflows/mutation.yml | 25 ++++++++++++++++++ .github/workflows/quality.yml | 22 ++++++++++++++++ .github/workflows/security.yml | 19 ++++++++++++++ .github/workflows/static.yml | 20 ++++++++++++++ 8 files changed, 188 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/dependency-check.yml create mode 100644 .github/workflows/ecs.yml create mode 100644 .github/workflows/mutation.yml create mode 100644 .github/workflows/quality.yml create mode 100644 .github/workflows/security.yml create mode 100644 .github/workflows/static.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c718a98 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,36 @@ +--- +version: 2 +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + cooldown: + default-days: 7 + groups: + github-actions: + patterns: ["*"] + + # Maintain dependencies for Composer + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "daily" + cooldown: + default-days: 7 + groups: + composer: + patterns: ["*"] + versioning-strategy: increase-if-necessary + + # Maintain dependencies for npm + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + cooldown: + default-days: 7 + groups: + npm: + patterns: ["*"] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6924f18 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,26 @@ +--- +on: + pull_request: &ignore-paths + paths-ignore: + - ".gitattributes" + - ".gitignore" + - "CHANGELOG.md" + - "docs/**" + - "README.md" + + push: *ignore-paths + +name: build + +permissions: + contents: read + +jobs: + phpunit: + uses: yii2-framework/actions/.github/workflows/phpunit.yml@670b6444a11798dc2b38f2563dbbcef063dfc0ab # v2.0.1 + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + concurrency-group: phpunit-${{ github.workflow }}-${{ github.ref }} + extensions: mbstring + php-version: '["8.3","8.4","8.5"]' diff --git a/.github/workflows/dependency-check.yml b/.github/workflows/dependency-check.yml new file mode 100644 index 0000000..b3d45f9 --- /dev/null +++ b/.github/workflows/dependency-check.yml @@ -0,0 +1,20 @@ +--- +on: + pull_request: &ignore-paths + paths-ignore: + - ".gitattributes" + - ".gitignore" + - "CHANGELOG.md" + - "docs/**" + - "README.md" + + push: *ignore-paths + +name: Composer require checker + +permissions: + contents: read + +jobs: + composer-require-checker: + uses: yii2-framework/actions/.github/workflows/composer-require-checker.yml@670b6444a11798dc2b38f2563dbbcef063dfc0ab # v2.0.1 diff --git a/.github/workflows/ecs.yml b/.github/workflows/ecs.yml new file mode 100644 index 0000000..47e733c --- /dev/null +++ b/.github/workflows/ecs.yml @@ -0,0 +1,20 @@ +--- +on: + pull_request: &ignore-paths + paths-ignore: + - ".gitattributes" + - ".gitignore" + - "CHANGELOG.md" + - "docs/**" + - "README.md" + + push: *ignore-paths + +name: ecs + +permissions: + contents: read + +jobs: + easy-coding-standard: + uses: yii2-framework/actions/.github/workflows/ecs.yml@670b6444a11798dc2b38f2563dbbcef063dfc0ab # v2.0.1 diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 0000000..907a3c9 --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,25 @@ +--- +on: + pull_request: &ignore-paths + paths-ignore: + - ".gitattributes" + - ".gitignore" + - "CHANGELOG.md" + - "docs/**" + - "README.md" + + push: *ignore-paths + +name: mutation test + +permissions: + contents: read + +jobs: + mutation: + uses: yii2-framework/actions/.github/workflows/infection.yml@670b6444a11798dc2b38f2563dbbcef063dfc0ab # v2.0.1 + with: + command-options: --threads=4 --ignore-msi-with-no-mutations + phpstan: true + secrets: + STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..301ca10 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,22 @@ +--- +on: + - pull_request + - push + +name: quality + +permissions: + contents: read + +jobs: + quality: + uses: yii2-framework/actions/.github/workflows/quality.yml@670b6444a11798dc2b38f2563dbbcef063dfc0ab # v2.0.1 + permissions: + contents: read + with: + codespell-config: .github/linters/.codespellrc + editorconfig-checker-config: .github/linters/.editorconfig-checker.json + markdownlint-config: .github/linters/.markdown-lint.yml + prettier-config: .prettierrc.json + prettier-ignore-path: .prettierignore + yamllint-targets: .github diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..029e387 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,19 @@ +--- +on: + - pull_request + - push + +name: security + +permissions: + contents: read + +jobs: + security: + uses: yii2-framework/actions/.github/workflows/security.yml@670b6444a11798dc2b38f2563dbbcef063dfc0ab # v2.0.1 + permissions: + contents: read + with: + gitleaks-config: .github/linters/.gitleaks.toml + secrets: + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml new file mode 100644 index 0000000..970f063 --- /dev/null +++ b/.github/workflows/static.yml @@ -0,0 +1,20 @@ +--- +on: + pull_request: &ignore-paths + paths-ignore: + - ".gitattributes" + - ".gitignore" + - "CHANGELOG.md" + - "docs/**" + - "README.md" + + push: *ignore-paths + +name: static analysis + +permissions: + contents: read + +jobs: + phpstan: + uses: yii2-framework/actions/.github/workflows/phpstan.yml@670b6444a11798dc2b38f2563dbbcef063dfc0ab # v2.0.1 From 0580adfed40f263ef605c9905f2d167ec3aed511 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 14 Aug 2026 18:31:44 -0400 Subject: [PATCH 03/13] Apply fixed Coderabbitai review. --- src/Storage/SnapshotStore.php | 155 +++++++++++++---------- tests/Storage/PanelFailureTest.php | 6 +- tests/Storage/SnapshotStoreTest.php | 184 +++++++++++++++++++++------- tests/Support/MockerExtension.php | 2 +- 4 files changed, 229 insertions(+), 118 deletions(-) diff --git a/src/Storage/SnapshotStore.php b/src/Storage/SnapshotStore.php index 59242f9..070998d 100644 --- a/src/Storage/SnapshotStore.php +++ b/src/Storage/SnapshotStore.php @@ -14,7 +14,6 @@ use function count; use function fclose; use function file_get_contents; -use function flock; use function glob; use function is_dir; use function is_file; @@ -38,7 +37,7 @@ final class SnapshotStore private const string LOCK_FILE = 'index.lock'; /** - * Tracks whether the data directory has already been created and swept of pre-JSON `*.data` files. + * Tracks whether the data directory has already been created. */ private bool $initialized = false; @@ -56,7 +55,7 @@ public function __construct( ) {} /** - * Removes stored manifests, snapshots, temporary files, and legacy data files. + * Removes stored manifests, snapshots, and temporary files. * * Usage example: * @@ -67,26 +66,30 @@ public function __construct( */ public function clear(): void { - $patterns = [ - "{$this->path}/*.data", - "{$this->path}/*.json", - "{$this->path}/.debug-*", - $this->lockFile(), - ]; - - foreach ($patterns as $pattern) { - $files = glob($pattern); - - foreach ($files === false ? [] : $files as $file) { - if (is_file($file) && !@unlink($file)) { - throw new StorageException( - "Unable to remove debug data file: {$file}", - ); + $this->initialize(); + + $lock = $this->acquireLock(LOCK_EX); + + try { + $patterns = [ + "{$this->path}/*.json", + "{$this->path}/.debug-*", + ]; + + foreach ($patterns as $pattern) { + $files = glob($pattern); + + foreach ($files === false ? [] : $files as $file) { + if (is_file($file) && !@unlink($file)) { + throw new StorageException( + "Unable to remove debug data file: {$file}", + ); + } } } + } finally { + fclose($lock); } - - $this->initialized = false; } /** @@ -103,20 +106,19 @@ public function clear(): void */ public function loadManifest(): array { - $lock = @fopen($this->lockFile(), 'c+'); - - if ($lock === false) { + try { + $lock = $this->acquireLock(LOCK_SH); + } catch (StorageException) { return []; } - @flock($lock, LOCK_SH); - - $manifest = $this->readManifestFile(); - - @flock($lock, LOCK_UN); - fclose($lock); + try { + $manifest = $this->readManifestFile(); - return $manifest === null ? [] : array_reverse($manifest->entries, true); + return $manifest === null ? [] : array_reverse($manifest->entries, true); + } finally { + fclose($lock); + } } /** @@ -153,71 +155,96 @@ public function readSnapshot(string $tag): DebugSnapshot|null } /** - * Adds a summary and returns entries evicted by history garbage collection. + * Writes a snapshot, updates the manifest, and runs garbage collection under one exclusive lock. * * Usage example: * * ```php - * $removed = $store->updateManifest($summary, 50); + * $removed = $store->writeSnapshot($snapshot, 50); * ``` * - * @param RequestSummary $summary Request metadata to add or replace. - * @param int $historySize Maximum number of entries retained after garbage collection. + * @param DebugSnapshot $snapshot Snapshot to persist. + * @param int $historySize Maximum number of retained entries. * * @return list Entries evicted from the manifest. */ - public function updateManifest(RequestSummary $summary, int $historySize): array + public function writeSnapshot(DebugSnapshot $snapshot, int $historySize): array { - $this->initialize(); + self::assertValidHistorySize($historySize); - $lock = @fopen($this->lockFile(), 'c+'); + $tag = $snapshot->summary->tag; - if ($lock === false) { - throw new StorageException( - "Unable to open debug data lock file: {$this->lockFile()}", - ); - } + $snapshotFile = $this->snapshotFile($tag); + $snapshotJson = self::encode($snapshot); - @flock($lock, LOCK_EX); + $this->initialize(); + + $lock = $this->acquireLock(LOCK_EX); try { $manifest = $this->readManifestFile(); + $resetStorage = $manifest === null && is_file($this->indexFile()); $entries = $manifest instanceof Manifest ? $manifest->entries : []; - $entries[$summary->tag] = $summary; + $entries[$tag] = $snapshot->summary; $removed = $this->collectGarbage($entries, $historySize); + $this->atomicWrite($snapshotFile, $snapshotJson); $this->atomicWrite($this->indexFile(), self::encode(new Manifest($entries))); if ($resetStorage) { $this->removeStaleSnapshots($entries); } + + return $removed; } finally { - @flock($lock, LOCK_UN); fclose($lock); } - - return $removed; } /** - * Writes a snapshot atomically under a validated tag. - * - * Usage example: + * Opens and acquires a checked filesystem lock. * - * ```php - * $store->writeSnapshot('request-1', $snapshot); - * ``` + * @param int<0, 7> $operation Lock operation passed to {@see flock()}. * - * @param string $tag Snapshot tag. - * @param DebugSnapshot $snapshot Snapshot to persist. + * @return resource Acquired lock handle. */ - public function writeSnapshot(string $tag, DebugSnapshot $snapshot): void + private function acquireLock(int $operation): mixed { - $this->initialize(); + $lockFile = $this->lockFile(); + + $lock = @fopen($lockFile, 'c+'); + + if ($lock === false) { + throw new StorageException( + "Unable to open debug data lock file: {$lockFile}", + ); + } - $this->atomicWrite($this->snapshotFile($tag), self::encode($snapshot)); + if (!@flock($lock, $operation)) { + fclose($lock); + + throw new StorageException( + "Unable to acquire debug data lock: {$lockFile}", + ); + } + + return $lock; + } + + /** + * Validates a manifest history size. + * + * @param int $historySize Maximum number of retained entries. + */ + private static function assertValidHistorySize(int $historySize): void + { + if ($historySize < 0) { + throw new StorageException( + "Invalid debug history size: {$historySize}", + ); + } } /** @@ -258,7 +285,7 @@ private function atomicWrite(string $file, string $contents): void } /** - * Removes expired manifest entries and their snapshots. + * Removes expired manifest entries. * * @param array $entries Manifest entries, updated in place. * @param int $historySize Maximum number of retained entries. @@ -330,7 +357,7 @@ private function indexFile(): string } /** - * Creates the storage directory and removes legacy serialized files once per store instance. + * Creates the storage directory once per store instance. */ private function initialize(): void { @@ -344,12 +371,6 @@ private function initialize(): void ); } - $legacy = glob($this->path . '/*.data'); - - foreach ($legacy === false ? [] : $legacy as $file) { - @unlink($file); - } - $this->initialized = true; } @@ -362,7 +383,7 @@ private function initialize(): void */ private static function isValidTag(string $tag): bool { - return $tag !== '' && preg_match('/\A[A-Za-z0-9._-]+\z/D', $tag) === 1; + return $tag !== 'index' && $tag !== '' && preg_match('/\A[A-Za-z0-9._-]+\z/D', $tag) === 1; } /** diff --git a/tests/Storage/PanelFailureTest.php b/tests/Storage/PanelFailureTest.php index f1a79e3..798134a 100644 --- a/tests/Storage/PanelFailureTest.php +++ b/tests/Storage/PanelFailureTest.php @@ -4,7 +4,7 @@ namespace PHPForge\Debug\Tests\Storage; -use PHPForge\Debug\Storage\{ExceptionSnapshot, PanelFailure}; +use PHPForge\Debug\Storage\{ExceptionSnapshot, HydrationException, PanelFailure}; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -32,9 +32,9 @@ public function testRoundTripsTheCapturedStageAndException(): void ); } - public function testThrowRuntimeExceptionForAnUnknownStage(): void + public function testThrowHydrationExceptionForAnUnknownStage(): void { - $this->expectException(RuntimeException::class); + $this->expectException(HydrationException::class); $this->expectExceptionMessage( "Invalid debug snapshot value at '\$.failures.log.stage'", ); diff --git a/tests/Storage/SnapshotStoreTest.php b/tests/Storage/SnapshotStoreTest.php index 0726982..c2c8ca6 100644 --- a/tests/Storage/SnapshotStoreTest.php +++ b/tests/Storage/SnapshotStoreTest.php @@ -24,11 +24,7 @@ public function testClearRemovesSnapshotsAndManifest(): void $summary = $this->summary('current', 1_700_000_000.0); - $store->writeSnapshot( - 'current', - new DebugSnapshot($summary, [], []), - ); - $store->updateManifest($summary, 10); + $store->writeSnapshot(new DebugSnapshot($summary, [], []), 10); $store->clear(); self::assertNull( @@ -40,6 +36,10 @@ public function testClearRemovesSnapshotsAndManifest(): void $store->loadManifest(), 'Cleared manifest must contain no entries.', ); + self::assertFileExists( + "{$this->path}/index.lock", + 'Clear must preserve the shared lock file.', + ); } public function testInvalidJsonIsRejectedWithoutExecutingPayloads(): void @@ -57,7 +57,9 @@ public function testLoadManifestReturnsNothingWhenTheLockFileCannotBeOpened(): v { $store = $this->store(); - $store->updateManifest($this->summary('tag-1', 1_700_000_000.0), 10); + $summary = $this->summary('tag-1', 1_700_000_000.0); + + $store->writeSnapshot(new DebugSnapshot($summary, [], []), 10); MockerState::addCondition('PHPForge\\Debug\\Storage', 'fopen', [], false, true); @@ -68,6 +70,22 @@ public function testLoadManifestReturnsNothingWhenTheLockFileCannotBeOpened(): v ); } + public function testLoadManifestReturnsNothingWhenTheSharedLockCannotBeAcquired(): void + { + $store = $this->store(); + $summary = $this->summary('tag-1', 1_700_000_000.0); + + $store->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + + MockerState::addCondition('PHPForge\\Debug\\Storage', 'flock', [], false, true); + + self::assertSame( + [], + $store->loadManifest(), + 'An unavailable shared lock must yield an empty manifest.', + ); + } + public function testReadRejectsTagThatEscapesTheStorageDirectory(): void { self::assertNull( @@ -90,15 +108,16 @@ public function testRemovesOrphanSnapshotsMissingFromTheManifest(): void { $store = $this->store(); - $store->writeSnapshot( - 'kept', - new DebugSnapshot($this->summary('kept', 1_700_000_000.0), [], []), - ); + $kept = $this->summary('kept', 1_700_000_000.0); + + $store->writeSnapshot(new DebugSnapshot($kept, [], []), 2); file_put_contents("{$this->path}/orphan.json", '{}'); for ($index = 0; $index < 13; $index++) { - $store->updateManifest($this->summary("tag-{$index}", 1_700_000_000.0 + $index), 2); + $summary = $this->summary("tag-{$index}", 1_700_000_000.0 + $index); + + $store->writeSnapshot(new DebugSnapshot($summary, [], []), 2); } self::assertFileDoesNotExist( @@ -113,12 +132,8 @@ public function testSnapshotAndManifestRoundTripThroughJson(): void $older = $this->summary('older', 1_700_000_000.0); $newer = $this->summary('newer', 1_700_000_001.0); - $store->writeSnapshot( - 'newer', - new DebugSnapshot($newer, ['panel' => ['value' => 1]], []), - ); - $store->updateManifest($older, 10); - $store->updateManifest($newer, 10); + $store->writeSnapshot(new DebugSnapshot($older, [], []), 10); + $store->writeSnapshot(new DebugSnapshot($newer, ['panel' => ['value' => 1]], []), 10); $snapshot = $store->readSnapshot('newer'); @@ -143,6 +158,39 @@ public function testSnapshotAndManifestRoundTripThroughJson(): void ); } + public function testThrowStorageExceptionForNegativeHistorySize(): void + { + $store = $this->store(); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Invalid debug history size: -1', + ); + + try { + $summary = $this->summary('current', 1_700_000_000.0); + + $store->writeSnapshot(new DebugSnapshot($summary, [], []), -1); + } finally { + self::assertDirectoryDoesNotExist( + $this->path, + 'Invalid history size must be rejected before storage initialization.', + ); + } + } + + public function testThrowStorageExceptionForReservedManifestTag(): void + { + $summary = $this->summary('index', 1_700_000_000.0); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Invalid debug snapshot tag: index', + ); + + $this->store()->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + } + public function testThrowStorageExceptionForTagThatEscapesTheStorageDirectory(): void { $summary = $this->summary('../outside', 1_700_000_000.0); @@ -152,10 +200,31 @@ public function testThrowStorageExceptionForTagThatEscapesTheStorageDirectory(): 'Invalid debug snapshot tag: ../outside', ); - $this->store()->writeSnapshot( - '../outside', - new DebugSnapshot($summary, [], []), + $this->store()->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + } + + public function testThrowStorageExceptionWhenClearCannotAcquireTheExclusiveLock(): void + { + $store = $this->store(); + $summary = $this->summary('current', 1_700_000_000.0); + + $store->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + + MockerState::addCondition('PHPForge\\Debug\\Storage', 'flock', [], false, true); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Unable to acquire debug data lock', ); + + try { + $store->clear(); + } finally { + self::assertFileExists( + "{$this->path}/current.json", + 'Failed lock acquisition must leave the snapshot intact.', + ); + } } public function testThrowStorageExceptionWhenTheSnapshotCannotBeMovedIntoPlace(): void @@ -167,10 +236,9 @@ public function testThrowStorageExceptionWhenTheSnapshotCannotBeMovedIntoPlace() 'Unable to replace debug data file', ); - $this->store()->writeSnapshot( - 'blocked', - new DebugSnapshot($this->summary('blocked', 1_700_000_000.0), [], []), - ); + $summary = $this->summary('blocked', 1_700_000_000.0); + + $this->store()->writeSnapshot(new DebugSnapshot($summary, [], []), 10); } public function testThrowStorageExceptionWhenTheTemporaryFileCannotBeCreated(): void @@ -182,10 +250,9 @@ public function testThrowStorageExceptionWhenTheTemporaryFileCannotBeCreated(): 'Unable to write temporary debug data file', ); - $this->store()->writeSnapshot( - 'blocked', - new DebugSnapshot($this->summary('blocked', 1_700_000_000.0), [], []), - ); + $summary = $this->summary('blocked', 1_700_000_000.0); + + $this->store()->writeSnapshot(new DebugSnapshot($summary, [], []), 10); } public function testThrowStorageExceptionWhenTheTemporaryFileCannotBeWritten(): void @@ -197,32 +264,55 @@ public function testThrowStorageExceptionWhenTheTemporaryFileCannotBeWritten(): 'Unable to write temporary debug data file', ); - $this->store()->writeSnapshot( - 'blocked', - new DebugSnapshot($this->summary('blocked', 1_700_000_000.0), [], []), - ); + $summary = $this->summary('blocked', 1_700_000_000.0); + + $this->store()->writeSnapshot(new DebugSnapshot($summary, [], []), 10); } - public function testWritingJsonSnapshotRemovesLegacySerializedFiles(): void + public function testThrowStorageExceptionWhenWriteCannotAcquireTheExclusiveLock(): void { - mkdir($this->path, recursive: true); - file_put_contents("{$this->path}/legacy.data", 'serialized payload'); - - $summary = $this->summary('current', 1_700_000_000.0); + MockerState::addCondition('PHPForge\\Debug\\Storage', 'flock', [], false, true); - $this->store()->writeSnapshot( - 'current', - new DebugSnapshot($summary, [], []), + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Unable to acquire debug data lock', ); - self::assertFileDoesNotExist( - "{$this->path}/legacy.data", - 'Legacy serialized file must be removed.', - ); - self::assertFileExists( - "{$this->path}/current.json", - 'JSON snapshot file must be created.', + try { + $summary = $this->summary('current', 1_700_000_000.0); + + $this->store()->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + } finally { + self::assertFileDoesNotExist( + "{$this->path}/current.json", + 'Failed lock acquisition must not write the snapshot.', + ); + self::assertFileDoesNotExist( + "{$this->path}/index.json", + 'Failed lock acquisition must not write the manifest.', + ); + } + } + + public function testThrowStorageExceptionWhenWriteCannotOpenTheLockFile(): void + { + MockerState::addCondition('PHPForge\\Debug\\Storage', 'fopen', [], false, true); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Unable to open debug data lock file', ); + + try { + $summary = $this->summary('current', 1_700_000_000.0); + + $this->store()->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + } finally { + self::assertFileDoesNotExist( + "{$this->path}/current.json", + 'An unopenable lock file must prevent snapshot persistence.', + ); + } } /** diff --git a/tests/Support/MockerExtension.php b/tests/Support/MockerExtension.php index a41f926..e183011 100644 --- a/tests/Support/MockerExtension.php +++ b/tests/Support/MockerExtension.php @@ -59,7 +59,7 @@ public static function load(): void { $mocks = []; - foreach (['file_put_contents', 'fopen', 'mkdir', 'rename', 'tempnam'] as $name) { + foreach (['file_put_contents', 'flock', 'fopen', 'mkdir', 'rename', 'tempnam'] as $name) { $mocks[] = [ 'namespace' => 'PHPForge\Debug\Storage', 'name' => $name, From ec3d9685b22c9b67e37c5f5efae18ffdc4e0e1e1 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 14 Aug 2026 18:34:17 -0400 Subject: [PATCH 04/13] fix: update infection configuration for logging and schema paths. --- infection.json5 | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/infection.json5 b/infection.json5 index ede5a5e..1d33ec4 100644 --- a/infection.json5 +++ b/infection.json5 @@ -1,10 +1,12 @@ { - "$schema": "vendor/infection/infection/resources/schema.json", - "source": { - "directories": ["src"] - }, - "logs": { - "text": "runtime/infection.log" - }, - "tmpDir": "runtime/infection" + $schema: "./vendor/infection/infection/resources/schema.json", + logs: { + text: "php://stderr", + stryker: { + report: "main", + }, + }, + source: { + directories: ["src"], + }, } From 60c174e556fbb87a0f476c7d2d9ff2528439f140 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 14 Aug 2026 18:39:25 -0400 Subject: [PATCH 05/13] fix: update .editorconfig and scaffold-lock.json for consistency. --- .editorconfig | 1 - scaffold-lock.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.editorconfig b/.editorconfig index 949335f..6b45e5d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,7 +10,6 @@ indent_style = space indent_size = 4 trim_trailing_whitespace = true -[*.{json,json5,neon,yaml,yml}] [*.js] indent_size = 2 diff --git a/scaffold-lock.json b/scaffold-lock.json index bd6dbaf..e58b26c 100644 --- a/scaffold-lock.json +++ b/scaffold-lock.json @@ -11,7 +11,7 @@ }, "files": { ".editorconfig": { - "hash": "sha256:c536c9768f05fe8e6870a9bed8a0190c092d91f3842118579e0ed0ce722d525c", + "hash": "sha256:f0cafe474d79d769f397f2e8839c642ca3ef1a0ce38b6e9f82ed9e49bccf84f6", "provider": "php-forge/baseline", "source": "metadata/.editorconfig", "mode": "append" From fcd4ae79367c927397856dda7aed416069d2d57f Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 14 Aug 2026 19:00:15 -0400 Subject: [PATCH 06/13] Apply fixed Coderabbitai review. --- src/Storage/SnapshotStore.php | 12 +- tests/Storage/SnapshotStoreTest.php | 251 +++++++++++++++++++++++++--- 2 files changed, 233 insertions(+), 30 deletions(-) diff --git a/src/Storage/SnapshotStore.php b/src/Storage/SnapshotStore.php index 070998d..f652aca 100644 --- a/src/Storage/SnapshotStore.php +++ b/src/Storage/SnapshotStore.php @@ -190,10 +190,13 @@ public function writeSnapshot(DebugSnapshot $snapshot, int $historySize): array $removed = $this->collectGarbage($entries, $historySize); - $this->atomicWrite($snapshotFile, $snapshotJson); + if ($historySize > 0) { + $this->atomicWrite($snapshotFile, $snapshotJson); + } + $this->atomicWrite($this->indexFile(), self::encode(new Manifest($entries))); - if ($resetStorage) { + if ($removed !== [] || $resetStorage) { $this->removeStaleSnapshots($entries); } @@ -294,7 +297,7 @@ private function atomicWrite(string $file, string $contents): void */ private function collectGarbage(array &$entries, int $historySize): array { - if (count($entries) <= $historySize + 10) { + if (count($entries) <= $historySize) { return []; } @@ -305,7 +308,6 @@ private function collectGarbage(array &$entries, int $historySize): array foreach (array_keys($entries) as $tag) { $removed[] = $entries[$tag]; - @unlink($this->snapshotFile($tag)); unset($entries[$tag]); if (--$remaining <= 0) { @@ -313,8 +315,6 @@ private function collectGarbage(array &$entries, int $historySize): array } } - $this->removeStaleSnapshots($entries); - return $removed; } diff --git a/tests/Storage/SnapshotStoreTest.php b/tests/Storage/SnapshotStoreTest.php index c2c8ca6..52b0c96 100644 --- a/tests/Storage/SnapshotStoreTest.php +++ b/tests/Storage/SnapshotStoreTest.php @@ -24,7 +24,11 @@ public function testClearRemovesSnapshotsAndManifest(): void $summary = $this->summary('current', 1_700_000_000.0); - $store->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + $store->clear(); self::assertNull( @@ -42,6 +46,57 @@ public function testClearRemovesSnapshotsAndManifest(): void ); } + public function testHistorySizeIsStrictMaximum(): void + { + $store = $this->store(); + + for ($index = 0; $index < 3; $index++) { + $summary = $this->summary("tag-{$index}", 1_700_000_000.0 + $index); + + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 2, + ); + } + + self::assertSame( + ['tag-2', 'tag-1'], + array_keys($store->loadManifest()), + 'Manifest must contain only the configured number of entries.', + ); + self::assertFileDoesNotExist( + "{$this->path}/tag-0.json", + 'Oldest snapshot must be removed at the retention boundary.', + ); + } + + public function testHistorySizeZeroPersistsNoSnapshots(): void + { + $store = $this->store(); + + $summary = $this->summary('current', 1_700_000_000.0); + + $removed = $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 0, + ); + + self::assertSame( + [$summary], + $removed, + 'Discarded request summary must be reported for dependent cleanup.', + ); + self::assertSame( + [], + $store->loadManifest(), + 'Manifest must remain empty.', + ); + self::assertFileDoesNotExist( + "{$this->path}/current.json", + 'Discarded snapshot must not leave an orphan file.', + ); + } + public function testInvalidJsonIsRejectedWithoutExecutingPayloads(): void { mkdir($this->path, recursive: true); @@ -59,9 +114,18 @@ public function testLoadManifestReturnsNothingWhenTheLockFileCannotBeOpened(): v $summary = $this->summary('tag-1', 1_700_000_000.0); - $store->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); - MockerState::addCondition('PHPForge\\Debug\\Storage', 'fopen', [], false, true); + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'fopen', + [], + false, + true, + ); self::assertSame( [], @@ -73,11 +137,21 @@ public function testLoadManifestReturnsNothingWhenTheLockFileCannotBeOpened(): v public function testLoadManifestReturnsNothingWhenTheSharedLockCannotBeAcquired(): void { $store = $this->store(); + $summary = $this->summary('tag-1', 1_700_000_000.0); - $store->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); - MockerState::addCondition('PHPForge\\Debug\\Storage', 'flock', [], false, true); + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'flock', + [], + false, + true, + ); self::assertSame( [], @@ -110,14 +184,20 @@ public function testRemovesOrphanSnapshotsMissingFromTheManifest(): void $kept = $this->summary('kept', 1_700_000_000.0); - $store->writeSnapshot(new DebugSnapshot($kept, [], []), 2); + $store->writeSnapshot( + new DebugSnapshot($kept, [], []), + 2, + ); file_put_contents("{$this->path}/orphan.json", '{}'); for ($index = 0; $index < 13; $index++) { $summary = $this->summary("tag-{$index}", 1_700_000_000.0 + $index); - $store->writeSnapshot(new DebugSnapshot($summary, [], []), 2); + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 2, + ); } self::assertFileDoesNotExist( @@ -129,11 +209,18 @@ public function testRemovesOrphanSnapshotsMissingFromTheManifest(): void public function testSnapshotAndManifestRoundTripThroughJson(): void { $store = $this->store(); + $older = $this->summary('older', 1_700_000_000.0); $newer = $this->summary('newer', 1_700_000_001.0); - $store->writeSnapshot(new DebugSnapshot($older, [], []), 10); - $store->writeSnapshot(new DebugSnapshot($newer, ['panel' => ['value' => 1]], []), 10); + $store->writeSnapshot( + new DebugSnapshot($older, [], []), + 10, + ); + $store->writeSnapshot( + new DebugSnapshot($newer, ['panel' => ['value' => 1]], []), + 10, + ); $snapshot = $store->readSnapshot('newer'); @@ -170,7 +257,10 @@ public function testThrowStorageExceptionForNegativeHistorySize(): void try { $summary = $this->summary('current', 1_700_000_000.0); - $store->writeSnapshot(new DebugSnapshot($summary, [], []), -1); + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + -1, + ); } finally { self::assertDirectoryDoesNotExist( $this->path, @@ -188,7 +278,10 @@ public function testThrowStorageExceptionForReservedManifestTag(): void 'Invalid debug snapshot tag: index', ); - $this->store()->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); } public function testThrowStorageExceptionForTagThatEscapesTheStorageDirectory(): void @@ -200,17 +293,30 @@ public function testThrowStorageExceptionForTagThatEscapesTheStorageDirectory(): 'Invalid debug snapshot tag: ../outside', ); - $this->store()->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); } public function testThrowStorageExceptionWhenClearCannotAcquireTheExclusiveLock(): void { $store = $this->store(); + $summary = $this->summary('current', 1_700_000_000.0); - $store->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); - MockerState::addCondition('PHPForge\\Debug\\Storage', 'flock', [], false, true); + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'flock', + [], + false, + true, + ); $this->expectException(StorageException::class); $this->expectExceptionMessage( @@ -227,9 +333,67 @@ public function testThrowStorageExceptionWhenClearCannotAcquireTheExclusiveLock( } } + public function testThrowStorageExceptionWhenTheManifestCannotBeWrittenAtHistoryLimit(): void + { + $store = $this->store(); + + $older = $this->summary('older', 1_700_000_000.0); + $newer = $this->summary('newer', 1_700_000_001.0); + + $store->writeSnapshot( + new DebugSnapshot($older, [], []), + 2, + ); + $store->writeSnapshot( + new DebugSnapshot($newer, [], []), + 2, + ); + + $temporaryFileCalls = 0; + + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'tempnam', + [$this->path, '.debug-'], + static function (string $directory, string $prefix) use (&$temporaryFileCalls): string|false { + return ++$temporaryFileCalls === 2 ? false : tempnam($directory, $prefix); + }, + ); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + 'Unable to write temporary debug data file', + ); + + try { + $summary = $this->summary('blocked', 1_700_000_002.0); + + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 2, + ); + } finally { + self::assertSame( + ['newer', 'older'], + array_keys($store->loadManifest()), + 'Failed manifest commit must preserve the previous manifest.', + ); + self::assertNotNull( + $store->readSnapshot('older'), + 'Oldest retained snapshot must remain readable.', + ); + } + } + public function testThrowStorageExceptionWhenTheSnapshotCannotBeMovedIntoPlace(): void { - MockerState::addCondition('PHPForge\\Debug\\Storage', 'rename', [], false, true); + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'rename', + [], + false, + true, + ); $this->expectException(StorageException::class); $this->expectExceptionMessage( @@ -238,12 +402,21 @@ public function testThrowStorageExceptionWhenTheSnapshotCannotBeMovedIntoPlace() $summary = $this->summary('blocked', 1_700_000_000.0); - $this->store()->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); } public function testThrowStorageExceptionWhenTheTemporaryFileCannotBeCreated(): void { - MockerState::addCondition('PHPForge\\Debug\\Storage', 'tempnam', [], false, true); + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'tempnam', + [], + false, + true, + ); $this->expectException(StorageException::class); $this->expectExceptionMessage( @@ -252,12 +425,21 @@ public function testThrowStorageExceptionWhenTheTemporaryFileCannotBeCreated(): $summary = $this->summary('blocked', 1_700_000_000.0); - $this->store()->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); } public function testThrowStorageExceptionWhenTheTemporaryFileCannotBeWritten(): void { - MockerState::addCondition('PHPForge\\Debug\\Storage', 'file_put_contents', [], false, true); + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'file_put_contents', + [], + false, + true, + ); $this->expectException(StorageException::class); $this->expectExceptionMessage( @@ -266,12 +448,21 @@ public function testThrowStorageExceptionWhenTheTemporaryFileCannotBeWritten(): $summary = $this->summary('blocked', 1_700_000_000.0); - $this->store()->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); } public function testThrowStorageExceptionWhenWriteCannotAcquireTheExclusiveLock(): void { - MockerState::addCondition('PHPForge\\Debug\\Storage', 'flock', [], false, true); + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'flock', + [], + false, + true, + ); $this->expectException(StorageException::class); $this->expectExceptionMessage( @@ -281,7 +472,10 @@ public function testThrowStorageExceptionWhenWriteCannotAcquireTheExclusiveLock( try { $summary = $this->summary('current', 1_700_000_000.0); - $this->store()->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); } finally { self::assertFileDoesNotExist( "{$this->path}/current.json", @@ -296,7 +490,13 @@ public function testThrowStorageExceptionWhenWriteCannotAcquireTheExclusiveLock( public function testThrowStorageExceptionWhenWriteCannotOpenTheLockFile(): void { - MockerState::addCondition('PHPForge\\Debug\\Storage', 'fopen', [], false, true); + MockerState::addCondition( + 'PHPForge\\Debug\\Storage', + 'fopen', + [], + false, + true, + ); $this->expectException(StorageException::class); $this->expectExceptionMessage( @@ -306,7 +506,10 @@ public function testThrowStorageExceptionWhenWriteCannotOpenTheLockFile(): void try { $summary = $this->summary('current', 1_700_000_000.0); - $this->store()->writeSnapshot(new DebugSnapshot($summary, [], []), 10); + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); } finally { self::assertFileDoesNotExist( "{$this->path}/current.json", From cf3f712078ffad9356146bdafc2208fa58f1335f Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 14 Aug 2026 19:34:25 -0400 Subject: [PATCH 07/13] Apply fixed Coderabbitai review. --- CHANGELOG.md | 2 +- README.md | 2 ++ src/Storage/SnapshotStore.php | 2 +- tests/Storage/SnapshotStoreTest.php | 22 +++++++++++++++++++++- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d591958..790ee38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,4 +7,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 0.1.0 Under development -- feat: add framework agnostic debug snapshot contracts, strict JSON hydration, and filesystem persistence. +- feat: add framework-agnostic debug snapshot contracts, strict JSON hydration, and filesystem persistence. diff --git a/README.md b/README.md index 786a27c..18bdfd1 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ instead of requiring this package directly. ## Installation +Adapter packages install Debug Core transitively. If you develop an adapter, run: + ```shell composer require php-forge/debug-core ``` diff --git a/src/Storage/SnapshotStore.php b/src/Storage/SnapshotStore.php index f652aca..b9e56b9 100644 --- a/src/Storage/SnapshotStore.php +++ b/src/Storage/SnapshotStore.php @@ -383,7 +383,7 @@ private function initialize(): void */ private static function isValidTag(string $tag): bool { - return $tag !== 'index' && $tag !== '' && preg_match('/\A[A-Za-z0-9._-]+\z/D', $tag) === 1; + return $tag !== 'index' && preg_match('/\A(?!-?(?:0|[1-9][0-9]*)\z)[A-Za-z0-9._-]+\z/D', $tag) === 1; } /** diff --git a/tests/Storage/SnapshotStoreTest.php b/tests/Storage/SnapshotStoreTest.php index 52b0c96..7e1f2b8 100644 --- a/tests/Storage/SnapshotStoreTest.php +++ b/tests/Storage/SnapshotStoreTest.php @@ -5,7 +5,7 @@ namespace PHPForge\Debug\Tests\Storage; use PHPForge\Debug\Storage\{DebugSnapshot, RequestSummary, SnapshotStore, StorageException}; -use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\{Group, TestWith}; use PHPUnit\Framework\TestCase; use Xepozz\InternalMocker\MockerState; @@ -269,6 +269,26 @@ public function testThrowStorageExceptionForNegativeHistorySize(): void } } + /** + * @param string $tag Numeric tag. + */ + #[TestWith(['0'])] + #[TestWith(['7'])] + public function testThrowStorageExceptionForNumericTag(string $tag): void + { + $summary = $this->summary($tag, 1_700_000_000.0); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot tag: {$tag}", + ); + + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + } + public function testThrowStorageExceptionForReservedManifestTag(): void { $summary = $this->summary('index', 1_700_000_000.0); From 058381b5468988404b5bb5468c5d799cc94612c3 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 14 Aug 2026 19:49:41 -0400 Subject: [PATCH 08/13] Apply fixed Coderabbitai review. --- src/Storage/SnapshotStore.php | 3 ++- tests/Storage/SnapshotStoreTest.php | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/Storage/SnapshotStore.php b/src/Storage/SnapshotStore.php index b9e56b9..ddfabd4 100644 --- a/src/Storage/SnapshotStore.php +++ b/src/Storage/SnapshotStore.php @@ -383,7 +383,8 @@ private function initialize(): void */ private static function isValidTag(string $tag): bool { - return $tag !== 'index' && preg_match('/\A(?!-?(?:0|[1-9][0-9]*)\z)[A-Za-z0-9._-]+\z/D', $tag) === 1; + return $tag !== 'index' + && preg_match('/\A(?!-?(?:0|[1-9][0-9]*)\z)[A-Za-z0-9_-][A-Za-z0-9._-]*\z/D', $tag) === 1; } /** diff --git a/tests/Storage/SnapshotStoreTest.php b/tests/Storage/SnapshotStoreTest.php index 7e1f2b8..e343329 100644 --- a/tests/Storage/SnapshotStoreTest.php +++ b/tests/Storage/SnapshotStoreTest.php @@ -245,6 +245,26 @@ public function testSnapshotAndManifestRoundTripThroughJson(): void ); } + /** + * @param string $tag Leading-dot tag. + */ + #[TestWith(['.'])] + #[TestWith(['.hidden'])] + public function testThrowStorageExceptionForLeadingDotTag(string $tag): void + { + $summary = $this->summary($tag, 1_700_000_000.0); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot tag: {$tag}", + ); + + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + } + public function testThrowStorageExceptionForNegativeHistorySize(): void { $store = $this->store(); From f375d00068d418c1c05c06a98a49ef9f29feddcc Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 14 Aug 2026 20:14:48 -0400 Subject: [PATCH 09/13] Apply fixed Codex review. --- src/Storage/DebugValue.php | 14 ++++- src/Storage/ExceptionSnapshot.php | 20 +++--- src/Storage/Json.php | 40 ++++++++++++ tests/Storage/DebugValueTest.php | 83 ++++++++++++++++++++++++- tests/Storage/ExceptionSnapshotTest.php | 15 +++++ tests/Storage/JsonTest.php | 34 ++++++++++ 6 files changed, 192 insertions(+), 14 deletions(-) create mode 100644 src/Storage/Json.php create mode 100644 tests/Storage/JsonTest.php diff --git a/src/Storage/DebugValue.php b/src/Storage/DebugValue.php index 44e28f7..c90270b 100644 --- a/src/Storage/DebugValue.php +++ b/src/Storage/DebugValue.php @@ -457,6 +457,10 @@ private static function normalize( 'key' => $key, 'value' => self::normalize($entry, $depth + 1, $nodes, $objects), ]; + + if ($nodes > self::MAX_NODES) { + break; + } } return new self( @@ -483,6 +487,10 @@ private static function normalize( 'key' => $key, 'value' => self::normalize($entry, $depth + 1, $nodes, $objects), ]; + + if ($nodes > self::MAX_NODES) { + break; + } } $objects->offsetUnset($value); @@ -527,17 +535,17 @@ private static function objectLabel(object $value): string $class = $value::class; $message = $value->getMessage(); - return "{$class}: {$message}"; + return Json::safeString($class) . ': ' . Json::safeString($message); } if ($value instanceof Stringable) { try { - return (string) $value; + return Json::safeString((string) $value); } catch (Throwable) { // Fall through to the class name when userland string conversion fails. } } - return $value::class; + return Json::safeString($value::class); } } diff --git a/src/Storage/ExceptionSnapshot.php b/src/Storage/ExceptionSnapshot.php index 56f4f47..142fa63 100644 --- a/src/Storage/ExceptionSnapshot.php +++ b/src/Storage/ExceptionSnapshot.php @@ -172,12 +172,12 @@ public static function fromThrowable(Throwable $throwable): self $args = is_array($entry['args'] ?? null) ? $entry['args'] : []; $trace[] = [ - 'namespace' => self::namespacePart($class), - 'short_class' => self::shortName($class), - 'class' => $class, - 'type' => is_string($entry['type'] ?? null) ? $entry['type'] : '', - 'function' => $entry['function'], - 'file' => is_string($entry['file'] ?? null) ? $entry['file'] : null, + 'namespace' => Json::safeString(self::namespacePart($class)), + 'short_class' => Json::safeString(self::shortName($class)), + 'class' => Json::safeString($class), + 'type' => Json::safeString(is_string($entry['type'] ?? null) ? $entry['type'] : ''), + 'function' => Json::safeString($entry['function']), + 'file' => is_string($entry['file'] ?? null) ? Json::safeString($entry['file']) : null, 'line' => is_int($entry['line'] ?? null) ? $entry['line'] : null, 'args' => DebugArray::capture($args), ]; @@ -186,13 +186,13 @@ public static function fromThrowable(Throwable $throwable): self $code = $throwable->getCode(); return new self( - class: $throwable::class, - message: $throwable->getMessage(), + class: Json::safeString($throwable::class), + message: Json::safeString($throwable->getMessage()), code: $code, - file: $throwable->getFile(), + file: Json::safeString($throwable->getFile()), line: $throwable->getLine(), trace: $trace, - toString: (string) $throwable, + toString: Json::safeString((string) $throwable), previous: $throwable->getPrevious() !== null ? self::fromThrowable($throwable->getPrevious()) : null, ); } diff --git a/src/Storage/Json.php b/src/Storage/Json.php new file mode 100644 index 0000000..44d8047 --- /dev/null +++ b/src/Storage/Json.php @@ -0,0 +1,40 @@ +value, + 'A binary Stringable label must be represented as base64.', + ); + self::assertJson( + json_encode($value, JSON_THROW_ON_ERROR), + 'A binary Stringable label must remain JSON-safe.', + ); + } + + public function testCaptureNormalizesInvalidUtf8ThrowableMessage(): void + { + $value = DebugValue::capture(new RuntimeException("\xB1\x31")); + + self::assertSame( + RuntimeException::class . ': (binary: base64 sTE=)', + $value->value, + 'A binary throwable message must be represented as base64.', + ); + self::assertJson( + json_encode($value, JSON_THROW_ON_ERROR), + 'A binary throwable message must remain JSON-safe.', + ); + } + + public function testCaptureStopsTraversingObjectBeyondTheNodeLimit(): void + { + $object = new stdClass(); + + for ($index = 0; $index < 10_050; $index++) { + $object->{"property-{$index}"} = $index; + } + + $value = DebugValue::capture($object); + $entries = $value->jsonSerialize()['entries'] ?? null; + + self::assertIsArray( + $entries, + 'A captured object must contain serialized entries.', + ); + self::assertCount( + 10_000, + $entries, + 'Object traversal must stop after recording the node-limit marker.', + ); + self::assertStringContainsString( + 'SKIPPED over 10000 nodes', + $this->flatten($value), + 'Object values past the node budget must be truncated.', + ); + } + public function testCaptureStringifiesAStringableObject(): void { $value = DebugValue::capture( @@ -163,9 +232,21 @@ public function testCaptureTruncatesBeyondTheDepthLimit(): void public function testCaptureTruncatesBeyondTheNodeLimit(): void { + $value = DebugValue::capture(range(1, 10_050)); + $entries = $value->jsonSerialize()['entries'] ?? null; + + self::assertIsArray( + $entries, + 'A captured array must contain serialized entries.', + ); + self::assertCount( + 10_000, + $entries, + 'Traversal must stop after recording the node-limit marker.', + ); self::assertStringContainsString( 'SKIPPED over 10000 nodes', - $this->flatten(DebugValue::capture(range(1, 10_050))), + $this->flatten($value), 'Values past the node budget must be truncated.', ); } diff --git a/tests/Storage/ExceptionSnapshotTest.php b/tests/Storage/ExceptionSnapshotTest.php index 8a5e94c..bd32c28 100644 --- a/tests/Storage/ExceptionSnapshotTest.php +++ b/tests/Storage/ExceptionSnapshotTest.php @@ -89,6 +89,21 @@ public function testThrowableRoundTripsThroughJson(): void ); } + public function testThrowableWithInvalidUtf8MessageRemainsJsonSafe(): void + { + $snapshot = ExceptionSnapshot::fromThrowable(new RuntimeException("\xB1\x31")); + + self::assertSame( + '(binary: base64 sTE=)', + $snapshot->getMessage(), + 'A binary throwable message must be represented as base64.', + ); + self::assertJson( + json_encode($snapshot, JSON_THROW_ON_ERROR), + 'A binary throwable message must not break snapshot serialization.', + ); + } + public function testThrowHydrationExceptionForInvalidCodeType(): void { $payload = ExceptionSnapshot::fromThrowable(new RuntimeException('failure')) diff --git a/tests/Storage/JsonTest.php b/tests/Storage/JsonTest.php new file mode 100644 index 0000000..4cf7d83 --- /dev/null +++ b/tests/Storage/JsonTest.php @@ -0,0 +1,34 @@ + Date: Fri, 14 Aug 2026 20:38:40 -0400 Subject: [PATCH 10/13] Raise msi mutation `100%`. --- src/Storage/ArrayPayloadSnapshot.php | 2 +- src/Storage/DebugValue.php | 3 +- src/Storage/SnapshotStore.php | 62 +++++------ tests/Storage/DebugValueTest.php | 141 ++++++++++++++++++++++++ tests/Storage/ExceptionSnapshotTest.php | 40 +++++++ tests/Storage/SnapshotStoreTest.php | 99 +++++++++++++++++ 6 files changed, 310 insertions(+), 37 deletions(-) diff --git a/src/Storage/ArrayPayloadSnapshot.php b/src/Storage/ArrayPayloadSnapshot.php index 694a86e..6d5f972 100644 --- a/src/Storage/ArrayPayloadSnapshot.php +++ b/src/Storage/ArrayPayloadSnapshot.php @@ -120,7 +120,7 @@ abstract protected static function payloadKey(): string; * * @return array Payload restored to plain PHP values. */ - protected function values(): array + private function values(): array { return $this->payload->values(); } diff --git a/src/Storage/DebugValue.php b/src/Storage/DebugValue.php index c90270b..61ad3d5 100644 --- a/src/Storage/DebugValue.php +++ b/src/Storage/DebugValue.php @@ -252,7 +252,6 @@ public function toDisplayValue(): mixed private function displayLabel(): string { return match ($this->type) { - 'special-float' => (string) $this->value, 'binary' => sprintf( '(binary: base64 %s)', base64_encode(is_string($this->value) ? $this->value : ''), @@ -430,7 +429,7 @@ private static function normalize( 'special-float', match (true) { is_nan($value) => 'NAN', - $value > 0 => 'INF', + $value === INF => 'INF', default => '-INF', }, ); diff --git a/src/Storage/SnapshotStore.php b/src/Storage/SnapshotStore.php index ddfabd4..71e5787 100644 --- a/src/Storage/SnapshotStore.php +++ b/src/Storage/SnapshotStore.php @@ -36,11 +36,6 @@ final class SnapshotStore | JSON_PRESERVE_ZERO_FRACTION; private const string LOCK_FILE = 'index.lock'; - /** - * Tracks whether the data directory has already been created. - */ - private bool $initialized = false; - /** * Creates a filesystem store with directory and file permission modes. * @@ -69,27 +64,24 @@ public function clear(): void $this->initialize(); $lock = $this->acquireLock(LOCK_EX); - - try { - $patterns = [ - "{$this->path}/*.json", - "{$this->path}/.debug-*", - ]; - - foreach ($patterns as $pattern) { - $files = glob($pattern); - - foreach ($files === false ? [] : $files as $file) { - if (is_file($file) && !@unlink($file)) { - throw new StorageException( - "Unable to remove debug data file: {$file}", - ); - } + $patterns = [ + "{$this->path}/*.json", + "{$this->path}/.debug-*", + ]; + + foreach ($patterns as $pattern) { + $files = glob($pattern); + + foreach ($files === false ? [] : $files as $file) { + if (is_file($file) && !@unlink($file)) { + throw new StorageException( + "Unable to remove debug data file: {$file}", + ); } } - } finally { - fclose($lock); } + + fclose($lock); } /** @@ -184,19 +176,23 @@ public function writeSnapshot(DebugSnapshot $snapshot, int $historySize): array try { $manifest = $this->readManifestFile(); - $resetStorage = $manifest === null && is_file($this->indexFile()); + $removeStaleSnapshots = $manifest === null; $entries = $manifest instanceof Manifest ? $manifest->entries : []; $entries[$tag] = $snapshot->summary; $removed = $this->collectGarbage($entries, $historySize); - if ($historySize > 0) { + if ($entries !== []) { $this->atomicWrite($snapshotFile, $snapshotJson); } + if ($removed !== []) { + $removeStaleSnapshots = true; + } + $this->atomicWrite($this->indexFile(), self::encode(new Manifest($entries))); - if ($removed !== [] || $resetStorage) { + if ($removeStaleSnapshots) { $this->removeStaleSnapshots($entries); } @@ -329,7 +325,7 @@ private function collectGarbage(array &$entries, int $historySize): array */ private static function decode(string $json): mixed { - return json_decode($json, true, 512, JSON_THROW_ON_ERROR); + return json_decode($json, true, flags: JSON_THROW_ON_ERROR); } /** @@ -357,21 +353,19 @@ private function indexFile(): string } /** - * Creates the storage directory once per store instance. + * Creates the storage directory when it does not exist. */ private function initialize(): void { - if ($this->initialized) { - return; + if (!is_dir($this->path)) { + @mkdir($this->path, $this->dirMode, true); } - if (!is_dir($this->path) && !@mkdir($this->path, $this->dirMode, true) && !is_dir($this->path)) { + if (!is_dir($this->path)) { throw new StorageException( "Unable to create debug data directory: {$this->path}", ); } - - $this->initialized = true; } /** @@ -384,7 +378,7 @@ private function initialize(): void private static function isValidTag(string $tag): bool { return $tag !== 'index' - && preg_match('/\A(?!-?(?:0|[1-9][0-9]*)\z)[A-Za-z0-9_-][A-Za-z0-9._-]*\z/D', $tag) === 1; + && preg_match('/\A(?!-?(?:0|[1-9][0-9]*)\z)[A-Za-z0-9_-][A-Za-z0-9._-]*\z/', $tag) === 1; } /** diff --git a/tests/Storage/DebugValueTest.php b/tests/Storage/DebugValueTest.php index 70cfd6b..7212cd1 100644 --- a/tests/Storage/DebugValueTest.php +++ b/tests/Storage/DebugValueTest.php @@ -165,6 +165,54 @@ public function testCaptureNormalizesInvalidUtf8ThrowableMessage(): void ); } + public function testCapturePreservesTheArrayDepthBoundary(): void + { + $atLimit = 'leaf'; + $beyondLimit = 'leaf'; + + for ($depth = 0; $depth < 10; $depth++) { + $atLimit = [$atLimit]; + $beyondLimit = [$beyondLimit]; + } + + $beyondLimit = [$beyondLimit]; + + self::assertStringNotContainsString( + 'DEEP NESTED VALUE', + $this->flatten(DebugValue::capture($atLimit)), + 'Depth ten must remain capturable.', + ); + self::assertStringContainsString( + 'DEEP NESTED VALUE', + $this->flatten(DebugValue::capture($beyondLimit)), + 'Depth eleven must be truncated.', + ); + } + + public function testCapturePreservesTheObjectDepthBoundary(): void + { + $atLimit = 'leaf'; + $beyondLimit = 'leaf'; + + for ($depth = 0; $depth < 10; $depth++) { + $atLimit = (object) ['value' => $atLimit]; + $beyondLimit = (object) ['value' => $beyondLimit]; + } + + $beyondLimit = (object) ['value' => $beyondLimit]; + + self::assertStringNotContainsString( + 'DEEP NESTED VALUE', + $this->flatten(DebugValue::capture($atLimit)), + 'Object depth ten must remain capturable.', + ); + self::assertStringContainsString( + 'DEEP NESTED VALUE', + $this->flatten(DebugValue::capture($beyondLimit)), + 'Object depth eleven must be truncated.', + ); + } + public function testCaptureStopsTraversingObjectBeyondTheNodeLimit(): void { $object = new stdClass(); @@ -213,6 +261,34 @@ public function __toString(): string $value->value, 'A Stringable must be labelled with its string form.', ); + self::assertSame( + 'rendered', + $value->jsonSerialize()['value'] ?? null, + 'Serialized object must retain its label.', + ); + } + + public function testCaptureTreatsRepeatedObjectReferencesAsIndependentBranches(): void + { + $shared = (object) ['name' => 'shared']; + + $display = DebugValue::capture([$shared, $shared]) + ->toDisplayValue(); + + self::assertIsArray( + $display, + 'Top-level value must remain an array.', + ); + self::assertSame( + ['__class' => stdClass::class, 'name' => 'shared'], + $display[0] ?? null, + 'First branch must retain the shared object.', + ); + self::assertSame( + ['__class' => stdClass::class, 'name' => 'shared'], + $display[1] ?? null, + 'Second branch must not be mistaken for recursion.', + ); } public function testCaptureTruncatesBeyondTheDepthLimit(): void @@ -233,6 +309,7 @@ public function testCaptureTruncatesBeyondTheDepthLimit(): void public function testCaptureTruncatesBeyondTheNodeLimit(): void { $value = DebugValue::capture(range(1, 10_050)); + $entries = $value->jsonSerialize()['entries'] ?? null; self::assertIsArray( @@ -249,6 +326,46 @@ public function testCaptureTruncatesBeyondTheNodeLimit(): void $this->flatten($value), 'Values past the node budget must be truncated.', ); + + $lastInBudget = $entries[9_998] ?? null; + $firstOutOfBudget = $entries[9_999] ?? null; + + self::assertIsArray( + $lastInBudget, + 'Last in-budget entry must retain its tagged structure.', + ); + self::assertIsArray( + $firstOutOfBudget, + 'First out-of-budget entry must retain its tagged structure.', + ); + self::assertSame( + ['type' => 'int', 'value' => 9_999], + $lastInBudget['value'] ?? null, + 'Last in-budget node must retain its value.', + ); + + $truncatedValue = $firstOutOfBudget['value'] ?? null; + + self::assertIsArray( + $truncatedValue, + 'Truncation marker must retain its tagged structure.', + ); + self::assertSame( + 'truncated', + $truncatedValue['type'] ?? null, + 'First out-of-budget node must carry the truncation marker.', + ); + } + + public function testCaptureUsesTheCanonicalClosureLabel(): void + { + $value = DebugValue::capture(static fn(): bool => true); + + self::assertSame( + '\\Closure', + $value->value, + 'Closure label must retain its canonical prefix.', + ); } public function testRoundTripPreservesJsonSafeValuesAndLabelsUnsafeValues(): void @@ -402,6 +519,30 @@ public function testThrowHydrationExceptionForUnknownFields(): void DebugValue::fromArray(['type' => 'null', 'unexpected' => true]); } + public function testToDisplayValuePreservesScalarAndDiagnosticTypes(): void + { + self::assertNull( + DebugValue::fromArray(['type' => 'null'])->toDisplayValue(), + 'Null tag must project to `null`.', + ); + self::assertSame( + 1.5, + DebugValue::fromArray(['type' => 'float', 'value' => 1.5])->toDisplayValue(), + 'Finite float must retain its value.', + ); + self::assertSame( + '*LIMIT*', + DebugValue::fromArray(['type' => 'truncated', 'value' => '*LIMIT*', 'reason' => 'size'])->toDisplayValue(), + 'Truncation label must remain visible.', + ); + self::assertSame( + '*UNKNOWN*', + DebugValue::fromArray(['type' => 'unsupported', 'value' => '*UNKNOWN*', 'reason' => 'fixture']) + ->toDisplayValue(), + 'Unsupported-value label must remain visible.', + ); + } + /** * Renders the tagged value as a `string` so truncation labels can be asserted regardless of nesting depth. * diff --git a/tests/Storage/ExceptionSnapshotTest.php b/tests/Storage/ExceptionSnapshotTest.php index bd32c28..deb0114 100644 --- a/tests/Storage/ExceptionSnapshotTest.php +++ b/tests/Storage/ExceptionSnapshotTest.php @@ -118,4 +118,44 @@ public function testThrowHydrationExceptionForInvalidCodeType(): void ExceptionSnapshot::fromArray($payload); } + public function testTraceRetainsClassMetadataAndPlainArguments(): void + { + try { + $this->throwFromTrace('trace argument'); + } catch (RuntimeException $throwable) { + $frame = ExceptionSnapshot::fromThrowable($throwable) + ->getTrace()[0] ?? self::fail('Expected the throwing method in the captured trace.'); + } + + self::assertSame( + __NAMESPACE__, + $frame['namespace'] ?? null, + 'Frame namespace must exclude the class name.', + ); + self::assertSame( + self::class, + $frame['class'] ?? null, + 'Frame class must retain its fully qualified name.', + ); + self::assertSame( + 'ExceptionSnapshotTest', + $frame['short_class'] ?? null, + 'Short class must exclude its namespace.', + ); + self::assertSame( + ['trace argument'], + $frame['args'] ?? null, + 'Frame arguments must be restored to plain values.', + ); + } + + /** + * Throws from a class method so trace metadata and arguments can be verified. + * + * @param string $argument Trace argument. + */ + private function throwFromTrace(string $argument): never + { + throw new RuntimeException($argument); + } } diff --git a/tests/Storage/SnapshotStoreTest.php b/tests/Storage/SnapshotStoreTest.php index e343329..ac8a390 100644 --- a/tests/Storage/SnapshotStoreTest.php +++ b/tests/Storage/SnapshotStoreTest.php @@ -18,6 +18,20 @@ final class SnapshotStoreTest extends TestCase { private string $path = ''; + public function testClearInitializesEmptyStorage(): void + { + $this->store()->clear(); + + self::assertDirectoryExists( + $this->path, + 'Storage directory must be created before locking.', + ); + self::assertFileExists( + "{$this->path}/index.lock", + 'Empty storage must retain its lock file.', + ); + } + public function testClearRemovesSnapshotsAndManifest(): void { $store = $this->store(); @@ -46,6 +60,38 @@ public function testClearRemovesSnapshotsAndManifest(): void ); } + public function testGarbageCollectionReportsEveryRemovedSummary(): void + { + $store = $this->store(); + + for ($index = 0; $index < 3; $index++) { + $summary = $this->summary("tag-{$index}", 1_700_000_000.0 + $index); + + $store->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + } + + $current = $this->summary('current', 1_700_000_003.0); + + $removed = $store->writeSnapshot( + new DebugSnapshot($current, [], []), + 1, + ); + + self::assertSame( + ['tag-0', 'tag-1', 'tag-2'], + array_map(static fn(RequestSummary $summary): string => $summary->tag, $removed), + 'Eviction report must include every discarded summary.', + ); + self::assertSame( + ['current'], + array_keys($store->loadManifest()), + 'Manifest must retain only the newest request.', + ); + } + public function testHistorySizeIsStrictMaximum(): void { $store = $this->store(); @@ -108,6 +154,35 @@ public function testInvalidJsonIsRejectedWithoutExecutingPayloads(): void ); } + public function testInvalidManifestResetsStaleSnapshots(): void + { + mkdir($this->path, recursive: true); + + file_put_contents("{$this->path}/index.json", '{invalid'); + file_put_contents("{$this->path}/stale.json", '{}'); + + $summary = $this->summary('current', 1_700_000_000.0); + + $this->store()->writeSnapshot( + new DebugSnapshot($summary, [], []), + 10, + ); + + self::assertFileDoesNotExist( + "{$this->path}/stale.json", + 'Reset manifest must discard snapshots it cannot reference.', + ); + self::assertFileExists( + "{$this->path}/current.json", + 'Replacement snapshot must remain stored.', + ); + self::assertSame( + ['current'], + array_keys($this->store()->loadManifest()), + 'Replacement manifest must contain the new request.', + ); + } + public function testLoadManifestReturnsNothingWhenTheLockFileCannotBeOpened(): void { $store = $this->store(); @@ -204,6 +279,14 @@ public function testRemovesOrphanSnapshotsMissingFromTheManifest(): void "{$this->path}/orphan.json", 'A snapshot with no manifest entry must be swept.', ); + self::assertFileExists( + "{$this->path}/tag-12.json", + 'Newest retained snapshot must survive stale-file cleanup.', + ); + self::assertFileExists( + "{$this->path}/tag-11.json", + 'Second retained snapshot must survive stale-file cleanup.', + ); } public function testSnapshotAndManifestRoundTripThroughJson(): void @@ -373,6 +456,22 @@ public function testThrowStorageExceptionWhenClearCannotAcquireTheExclusiveLock( } } + public function testThrowStorageExceptionWhenStoragePathCannotBeCreated(): void + { + file_put_contents($this->path, 'not a directory'); + + $this->expectException(StorageException::class); + $this->expectExceptionMessage( + "Unable to create debug data directory: {$this->path}", + ); + + try { + $this->store()->clear(); + } finally { + unlink($this->path); + } + } + public function testThrowStorageExceptionWhenTheManifestCannotBeWrittenAtHistoryLimit(): void { $store = $this->store(); From 865e94d483bc6691522a7a427ee5d70ea455e104 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 14 Aug 2026 20:42:59 -0400 Subject: [PATCH 11/13] Fix Workflow ci. --- tests/Storage/ExceptionSnapshotTest.php | 53 +++++++++++++++++++------ 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/tests/Storage/ExceptionSnapshotTest.php b/tests/Storage/ExceptionSnapshotTest.php index deb0114..c4f22f2 100644 --- a/tests/Storage/ExceptionSnapshotTest.php +++ b/tests/Storage/ExceptionSnapshotTest.php @@ -5,7 +5,7 @@ namespace PHPForge\Debug\Tests\Storage; use LogicException; -use PHPForge\Debug\Storage\{ExceptionSnapshot, HydrationException}; +use PHPForge\Debug\Storage\{DebugArray, ExceptionSnapshot, HydrationException}; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -118,10 +118,44 @@ public function testThrowHydrationExceptionForInvalidCodeType(): void ExceptionSnapshot::fromArray($payload); } - public function testTraceRetainsClassMetadataAndPlainArguments(): void + + public function testTraceProjectsArgumentsToPlainValues(): void + { + $snapshot = new ExceptionSnapshot( + class: RuntimeException::class, + message: 'failure', + code: 0, + file: __FILE__, + line: __LINE__, + trace: [ + [ + 'namespace' => __NAMESPACE__, + 'short_class' => 'ExceptionSnapshotTest', + 'class' => self::class, + 'type' => '->', + 'function' => 'fixture', + 'file' => __FILE__, + 'line' => __LINE__, + 'args' => DebugArray::capture(['trace argument']), + ], + ], + toString: 'failure', + previous: null, + ); + + $frame = $snapshot->getTrace()[0] ?? self::fail('Expected the deterministic fixture frame.'); + + self::assertSame( + ['trace argument'], + $frame['args'] ?? null, + 'Frame arguments must be restored to plain values.', + ); + } + + public function testTraceRetainsClassMetadata(): void { try { - $this->throwFromTrace('trace argument'); + $this->throwFromTrace(); } catch (RuntimeException $throwable) { $frame = ExceptionSnapshot::fromThrowable($throwable) ->getTrace()[0] ?? self::fail('Expected the throwing method in the captured trace.'); @@ -142,20 +176,13 @@ public function testTraceRetainsClassMetadataAndPlainArguments(): void $frame['short_class'] ?? null, 'Short class must exclude its namespace.', ); - self::assertSame( - ['trace argument'], - $frame['args'] ?? null, - 'Frame arguments must be restored to plain values.', - ); } /** - * Throws from a class method so trace metadata and arguments can be verified. - * - * @param string $argument Trace argument. + * Throws from a class method so trace metadata can be verified. */ - private function throwFromTrace(string $argument): never + private function throwFromTrace(): never { - throw new RuntimeException($argument); + throw new RuntimeException('trace fixture'); } } From 81fadcde855eba1bf4a60d11f807c04c90a17db9 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sat, 15 Aug 2026 08:51:37 -0400 Subject: [PATCH 12/13] Apply fixed Codex review. --- src/Storage/DebugValue.php | 6 +-- src/Storage/ExceptionSnapshot.php | 8 +++- src/Storage/SnapshotStore.php | 3 ++ tests/Storage/DebugValueTest.php | 60 +++++++++++++++++++++++++ tests/Storage/ExceptionSnapshotTest.php | 23 ++++++++++ tests/Storage/SnapshotStoreTest.php | 34 ++++++++++++++ 6 files changed, 130 insertions(+), 4 deletions(-) diff --git a/src/Storage/DebugValue.php b/src/Storage/DebugValue.php index 61ad3d5..50a32d7 100644 --- a/src/Storage/DebugValue.php +++ b/src/Storage/DebugValue.php @@ -43,7 +43,7 @@ * * @param string $type Tagged value type. * @param bool|float|int|string|null $value Captured scalar value or display label. - * @param list $entries Captured child values. + * @param list $entries Captured child values. * @param string|null $className Captured object class or `null` for non-object values. * @param string|null $resourceType Captured resource type or `null` for non-resource values. * @param string|null $reason Truncation or unsupported-value reason, or `null` when not applicable. @@ -453,7 +453,7 @@ private static function normalize( foreach ($value as $key => $entry) { $entries[] = [ 'keyType' => is_int($key) ? 'int' : 'string', - 'key' => $key, + 'key' => is_int($key) ? $key : Json::safeString($key), 'value' => self::normalize($entry, $depth + 1, $nodes, $objects), ]; @@ -483,7 +483,7 @@ private static function normalize( foreach (get_object_vars($value) as $key => $entry) { $entries[] = [ 'keyType' => 'string', - 'key' => $key, + 'key' => Json::safeString((string) $key), 'value' => self::normalize($entry, $depth + 1, $nodes, $objects), ]; diff --git a/src/Storage/ExceptionSnapshot.php b/src/Storage/ExceptionSnapshot.php index 142fa63..799810e 100644 --- a/src/Storage/ExceptionSnapshot.php +++ b/src/Storage/ExceptionSnapshot.php @@ -185,6 +185,12 @@ public static function fromThrowable(Throwable $throwable): self $code = $throwable->getCode(); + try { + $toString = (string) $throwable; + } catch (Throwable) { + $toString = $throwable::class . ': ' . $throwable->getMessage(); + } + return new self( class: Json::safeString($throwable::class), message: Json::safeString($throwable->getMessage()), @@ -192,7 +198,7 @@ class: Json::safeString($throwable::class), file: Json::safeString($throwable->getFile()), line: $throwable->getLine(), trace: $trace, - toString: Json::safeString((string) $throwable), + toString: Json::safeString($toString), previous: $throwable->getPrevious() !== null ? self::fromThrowable($throwable->getPrevious()) : null, ); } diff --git a/src/Storage/SnapshotStore.php b/src/Storage/SnapshotStore.php index 71e5787..5005917 100644 --- a/src/Storage/SnapshotStore.php +++ b/src/Storage/SnapshotStore.php @@ -178,6 +178,9 @@ public function writeSnapshot(DebugSnapshot $snapshot, int $historySize): array $removeStaleSnapshots = $manifest === null; $entries = $manifest instanceof Manifest ? $manifest->entries : []; + + unset($entries[$tag]); + $entries[$tag] = $snapshot->summary; $removed = $this->collectGarbage($entries, $historySize); diff --git a/tests/Storage/DebugValueTest.php b/tests/Storage/DebugValueTest.php index 7212cd1..b153740 100644 --- a/tests/Storage/DebugValueTest.php +++ b/tests/Storage/DebugValueTest.php @@ -123,6 +123,66 @@ public function testCaptureLabelsAThrowableWithItsMessage(): void ); } + public function testCaptureNormalizesBinaryArrayAndObjectKeys(): void + { + $binaryKey = "\xFF"; + $safeKey = '(binary: base64 /w==)'; + + $capturedArray = DebugValue::capture([$binaryKey => 'array value']); + + $arrayEntries = $capturedArray->jsonSerialize()['entries'] ?? null; + + self::assertIsArray( + $arrayEntries, + 'Serialized array must contain tagged entries.', + ); + + $arrayEntry = $arrayEntries[0] ?? null; + + self::assertIsArray( + $arrayEntry, + 'First serialized array entry must retain its structure.', + ); + self::assertSame( + $safeKey, + $arrayEntry['key'] ?? null, + 'Binary array key must use a JSON-safe label.', + ); + self::assertJson( + json_encode($capturedArray, JSON_THROW_ON_ERROR), + 'Binary array key must not break JSON serialization.', + ); + + $object = new stdClass(); + + $object->{$binaryKey} = 'binary property'; + + $capturedObject = DebugValue::capture($object); + + $objectEntries = $capturedObject->jsonSerialize()['entries'] ?? null; + + self::assertIsArray( + $objectEntries, + 'Serialized object must contain tagged entries.', + ); + + $objectEntry = $objectEntries[0] ?? null; + + self::assertIsArray( + $objectEntry, + 'First serialized object entry must retain its structure.', + ); + self::assertSame( + $safeKey, + $objectEntry['key'] ?? null, + 'Binary object key must use a JSON-safe label.', + ); + self::assertJson( + json_encode($capturedObject, JSON_THROW_ON_ERROR), + 'Binary object key must not break JSON serialization.', + ); + } + public function testCaptureNormalizesInvalidUtf8StringableLabel(): void { $value = DebugValue::capture( diff --git a/tests/Storage/ExceptionSnapshotTest.php b/tests/Storage/ExceptionSnapshotTest.php index c4f22f2..38fdfd6 100644 --- a/tests/Storage/ExceptionSnapshotTest.php +++ b/tests/Storage/ExceptionSnapshotTest.php @@ -89,6 +89,29 @@ public function testThrowableRoundTripsThroughJson(): void ); } + public function testThrowableWhoseStringConversionFailsUsesClassAndMessage(): void + { + $throwable = new class ('conversion failure') extends RuntimeException { + /** + * Simulates a failing throwable string conversion. + * + * @return string Throwable text. + */ + public function __toString(): string + { + throw new LogicException('cannot stringify'); + } + }; + + $snapshot = ExceptionSnapshot::fromThrowable($throwable); + + self::assertSame( + $throwable::class . ': conversion failure', + (string) $snapshot, + 'A failed throwable string conversion must fall back to its class and message.', + ); + } + public function testThrowableWithInvalidUtf8MessageRemainsJsonSafe(): void { $snapshot = ExceptionSnapshot::fromThrowable(new RuntimeException("\xB1\x31")); diff --git a/tests/Storage/SnapshotStoreTest.php b/tests/Storage/SnapshotStoreTest.php index ac8a390..3411359 100644 --- a/tests/Storage/SnapshotStoreTest.php +++ b/tests/Storage/SnapshotStoreTest.php @@ -289,6 +289,40 @@ public function testRemovesOrphanSnapshotsMissingFromTheManifest(): void ); } + public function testRewrittenSnapshotBecomesNewestManifestEntry(): void + { + $store = $this->store(); + + $store->writeSnapshot( + new DebugSnapshot($this->summary('first', 1_700_000_000.0), [], []), + 2, + ); + $store->writeSnapshot( + new DebugSnapshot($this->summary('second', 1_700_000_001.0), [], []), + 2, + ); + $store->writeSnapshot( + new DebugSnapshot($this->summary('first', 1_700_000_002.0), [], []), + 2, + ); + + $removed = $store->writeSnapshot( + new DebugSnapshot($this->summary('third', 1_700_000_003.0), [], []), + 2, + ); + + self::assertSame( + ['second'], + array_map(static fn(RequestSummary $summary): string => $summary->tag, $removed), + 'Eviction must target the least recently written snapshot.', + ); + self::assertSame( + ['third', 'first'], + array_keys($store->loadManifest()), + 'Manifest order must reflect the latest write for each tag.', + ); + } + public function testSnapshotAndManifestRoundTripThroughJson(): void { $store = $this->store(); From 80e5041c83748e5c1a80722073ca042e18660229 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sat, 15 Aug 2026 09:19:46 -0400 Subject: [PATCH 13/13] Apply fixed Codex review. --- src/Storage/Payload.php | 5 +++-- tests/Storage/PayloadTest.php | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/Storage/Payload.php b/src/Storage/Payload.php index 8b42487..58c1b20 100644 --- a/src/Storage/Payload.php +++ b/src/Storage/Payload.php @@ -11,6 +11,7 @@ use function array_values; use function is_array; use function is_bool; +use function is_finite; use function is_float; use function is_int; use function is_string; @@ -216,7 +217,7 @@ public function nullableNumber(string $key): float|null return null; } - if (!is_int($value) && !is_float($value)) { + if (!is_int($value) && (!is_float($value) || !is_finite($value))) { throw HydrationException::at( $this->keyPath($key), 'a number or null', @@ -274,7 +275,7 @@ public function number(string $key): float { $value = $this->value($key); - if (!is_int($value) && !is_float($value)) { + if (!is_int($value) && (!is_float($value) || !is_finite($value))) { throw HydrationException::at( $this->keyPath($key), 'a number', diff --git a/tests/Storage/PayloadTest.php b/tests/Storage/PayloadTest.php index 0302da3..92b541e 100644 --- a/tests/Storage/PayloadTest.php +++ b/tests/Storage/PayloadTest.php @@ -70,6 +70,26 @@ public function testThrowHydrationExceptionForANonBooleanValue(): void Payload::object(['flag' => 1])->bool('flag'); } + public function testThrowHydrationExceptionForANonFiniteNullableNumber(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '$.duration': expected a number or null.", + ); + + Payload::object(['duration' => json_decode('-1e400', flags: JSON_THROW_ON_ERROR)])->nullableNumber('duration'); + } + + public function testThrowHydrationExceptionForANonFiniteNumber(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + "Invalid debug snapshot value at '$.time': expected a number.", + ); + + Payload::object(['time' => json_decode('1e400', flags: JSON_THROW_ON_ERROR)])->number('time'); + } + public function testThrowHydrationExceptionForANonIntegerValue(): void { $this->expectException(HydrationException::class);