diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..6b45e5d
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,47 @@
+# 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
+
+[*.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/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/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/.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
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..790ee38
--- /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..18bdfd1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,29 @@
+# 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
+
+Adapter packages install Debug Core transitively. If you develop an adapter, run:
+
+```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..1d33ec4
--- /dev/null
+++ b/infection.json5
@@ -0,0 +1,12 @@
+{
+ $schema: "./vendor/infection/infection/resources/schema.json",
+ logs: {
+ text: "php://stderr",
+ stryker: {
+ report: "main",
+ },
+ },
+ source: {
+ directories: ["src"],
+ },
+}
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..e58b26c
--- /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:f0cafe474d79d769f397f2e8839c642ca3ef1a0ce38b6e9f82ed9e49bccf84f6",
+ "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..6d5f972
--- /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.
+ */
+ private 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..50a32d7
--- /dev/null
+++ b/src/Storage/DebugValue.php
@@ -0,0 +1,550 @@
+ $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) {
+ '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