diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 8a5f881..5907267 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -1,70 +1,120 @@
name: Bug report
-description: Report a reproducible problem
-title: "[Bug]: "
+description: Report reproducible incorrect or regressed behavior
labels:
- bug
body:
- type: markdown
attributes:
value: |
- Thanks for reporting a bug. Please include enough detail to reproduce it.
+ Thanks for reporting a problem.
+
+ Do not report security vulnerabilities here. Follow `SECURITY.md` and use private vulnerability reporting.
+
+ - type: dropdown
+ id: problem_type
+ attributes:
+ label: Problem type
+ description: Select the option that best describes the problem.
+ options:
+ - Bug
+ - Regression
+ - Not sure
+ validations:
+ required: true
+
- type: textarea
id: summary
attributes:
label: Summary
- description: What is wrong?
- placeholder: Clear and short description of the bug.
+ description: Describe the incorrect behavior and its impact.
+ placeholder: A clear and concise description of the problem.
+ validations:
+ required: true
+
+ - type: input
+ id: package_version
+ attributes:
+ label: Affected package version
+ placeholder: e.g. 2.4.1 or dev-main@abc1234
validations:
required: true
+
+ - type: input
+ id: last_known_working
+ attributes:
+ label: Last known working version or commit
+ description: Complete this when reporting a regression, if known.
+ placeholder: e.g. 2.4.0 or abc1234
+
+ - type: input
+ id: first_known_broken
+ attributes:
+ label: First known broken version or commit
+ description: Complete this when known.
+ placeholder: e.g. 2.4.1 or def5678
+
- type: textarea
id: reproduce
attributes:
- label: Steps to reproduce
- description: Share exact commands, config, and steps.
+ label: Minimal reproduction
+ description: Provide the smallest code sample, command, configuration or repository that reproduces the problem.
placeholder: |
- 1. Run `composer ic:tests`
- 2. ...
+ 1. Install or configure ...
+ 2. Run ...
3. Observe ...
validations:
required: true
+
- type: textarea
id: expected
attributes:
label: Expected behavior
- placeholder: What did you expect to happen?
+ placeholder: Describe what should happen.
validations:
required: true
+
- type: textarea
id: actual
attributes:
label: Actual behavior
- placeholder: What happened instead? Include full error output if possible.
+ placeholder: Describe what happens instead.
validations:
required: true
+
+ - type: textarea
+ id: error_output
+ attributes:
+ label: Relevant output or errors
+ description: Include only the relevant, sanitized output.
+ render: shell
+
- type: input
id: php_version
attributes:
label: PHP version
- placeholder: "e.g. 8.3.8"
+ placeholder: e.g. 8.4.13
validations:
required: true
+
- type: input
id: composer_version
attributes:
label: Composer version
- placeholder: "e.g. 2.9.2"
+ placeholder: e.g. 2.9.2
validations:
required: true
+
- type: textarea
id: environment
attributes:
- label: Environment details
- description: OS, CI provider, shell, and anything else relevant.
- placeholder: Ubuntu 24.04, GitHub Actions, bash...
+ label: Environment
+ description: Include the operating system, relevant extensions, dependency mode, runtime and CI provider when applicable.
+ placeholder: Ubuntu 24.04, locked dependencies, ext-json enabled, GitHub Actions...
validations:
required: true
+
- type: textarea
id: additional
attributes:
label: Additional context
- description: Links, screenshots, logs, or related issues.
+ description: Add related issues, screenshots, logs, workarounds or other useful context.
diff --git a/.github/ISSUE_TEMPLATE/ci_failure.yml b/.github/ISSUE_TEMPLATE/ci_failure.yml
index 3dcbac9..9c3883f 100644
--- a/.github/ISSUE_TEMPLATE/ci_failure.yml
+++ b/.github/ISSUE_TEMPLATE/ci_failure.yml
@@ -1,48 +1,101 @@
name: CI failure
-description: Report a reproducible CI or workflow failure
-title: "[CI]: "
+description: Report a reproducible PHPForge or workflow failure
labels:
- ci
body:
- type: markdown
attributes:
value: |
- Use this form when CI fails unexpectedly and can be reproduced.
+ Use this form when a CI workflow or PHPForge check fails unexpectedly.
+
+ Do not report security vulnerabilities here. Follow `SECURITY.md` and use private vulnerability reporting.
+
- type: input
id: workflow
attributes:
- label: Workflow/job name
- placeholder: security-standards / phpforge
+ label: Workflow and job
+ placeholder: e.g. CI / PHP 8.4
validations:
required: true
+
- type: input
id: run_url
attributes:
label: Failing run URL
+ description: Provide a link when the run is accessible.
placeholder: https://github.com/OWNER/REPOSITORY/actions/runs/...
- validations:
- required: true
+
- type: textarea
- id: command
+ id: failing_step
attributes:
- label: Failing command
- description: Exact command or step that failed.
+ label: Failing step or command
+ description: Include the exact workflow step or command that failed.
placeholder: composer ic:ci
+ render: shell
validations:
required: true
+
- type: textarea
id: logs
attributes:
- label: Error output
- description: Paste the relevant error section.
+ label: Relevant error output
+ description: Paste the smallest useful, sanitized error section.
render: shell
validations:
required: true
- - type: textarea
- id: local_check
+
+ - type: dropdown
+ id: local_result
attributes:
label: Local reproduction
- description: Can you reproduce locally? If yes, include steps.
- placeholder: Yes/No + details
+ description: Does the same failure occur when running the relevant command locally?
+ options:
+ - Yes
+ - No
+ - Not attempted
+ validations:
+ required: true
+
+ - type: textarea
+ id: local_details
+ attributes:
+ label: Local reproduction details
+ description: Include the command, result and any differences from CI.
+ placeholder: composer ic:ci fails locally with the same error...
+
+ - type: input
+ id: php_version
+ attributes:
+ label: PHP version
+ placeholder: e.g. 8.4.13
+ validations:
+ required: true
+
+ - type: input
+ id: composer_version
+ attributes:
+ label: Composer version
+ placeholder: e.g. 2.9.2
validations:
required: true
+
+ - type: textarea
+ id: environment
+ attributes:
+ label: Runner and dependency environment
+ description: Include the runner OS, dependency mode, relevant extensions, matrix values and PHPForge version when known.
+ placeholder: ubuntu-latest, prefer-lowest, PHPForge 1.x, ext-json enabled...
+ validations:
+ required: true
+
+ - type: textarea
+ id: recent_changes
+ attributes:
+ label: Relevant recent changes
+ description: Mention dependency, configuration, workflow or source changes that may be related.
+
+ - type: textarea
+ id: additional
+ attributes:
+ label: Additional context
+ description: Add related issues, screenshots, logs or other useful context.
diff --git a/.github/ISSUE_TEMPLATE/docs_improvement.yml b/.github/ISSUE_TEMPLATE/docs_improvement.yml
index 80b9607..2ea49e9 100644
--- a/.github/ISSUE_TEMPLATE/docs_improvement.yml
+++ b/.github/ISSUE_TEMPLATE/docs_improvement.yml
@@ -1,34 +1,58 @@
-name: Docs improvement
-description: Report missing, unclear, or incorrect documentation
-title: "[Docs]: "
+name: Documentation improvement
+description: Report missing, outdated, unclear or incorrect documentation
labels:
- documentation
body:
- - type: textarea
+ - type: dropdown
+ id: problem_type
+ attributes:
+ label: Documentation problem
+ options:
+ - Incorrect
+ - Outdated
+ - Missing
+ - Unclear
+ - Example needed
+ - Other
+ validations:
+ required: true
+
+ - type: input
id: location
attributes:
label: Documentation location
- description: File path or URL.
- placeholder: README.md section "Quick Start"
+ description: Provide the file path, section, symbol or URL.
+ placeholder: README.md — Quick Start
validations:
required: true
+
- type: textarea
- id: issue
+ id: problem
attributes:
- label: What is unclear or incorrect?
- placeholder: This section says...
+ label: Problem
+ description: Explain what is missing, unclear, outdated or incorrect.
+ placeholder: The current documentation says or omits...
validations:
required: true
+
- type: textarea
- id: suggestion
+ id: expected
attributes:
- label: Suggested improvement
- description: Propose revised wording, structure, or examples.
- placeholder: It would be clearer if...
+ label: Expected documentation
+ description: Describe what readers should be able to understand or accomplish.
+ placeholder: Readers should be able to...
validations:
required: true
+
+ - type: textarea
+ id: suggestion
+ attributes:
+ label: Suggested improvement
+ description: Optionally propose wording, structure, examples or references.
+ placeholder: It may be clearer to...
+
- type: textarea
id: additional
attributes:
label: Additional context
- description: Related links, screenshots, or prior discussions.
+ description: Add related links, screenshots, discussions or examples.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index cc29614..bbee6d0 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -1,44 +1,54 @@
name: Feature request
-description: Suggest an improvement or new capability
-title: "[Feature]: "
+description: Suggest a new capability or improvement
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
- Thanks for the idea. Please describe the use case first, then the proposed solution.
+ Describe the problem or use case before proposing an implementation.
+
+ For substantial public API, architectural or compatibility changes, discussion may be requested before implementation.
+
- type: textarea
id: problem
attributes:
label: Problem or use case
- description: What limitation are you hitting?
- placeholder: I need to...
+ description: Explain the limitation, repeated difficulty or capability you need.
+ placeholder: I need to... because...
validations:
required: true
+
- type: textarea
- id: proposal
+ id: proposed_behavior
attributes:
- label: Proposed solution
- description: What should happen?
- placeholder: Add a command/config/workflow option that...
+ label: Proposed behavior
+ description: Describe the expected user-facing behavior or outcome.
+ placeholder: The library should...
validations:
required: true
+
+ - type: textarea
+ id: example
+ attributes:
+ label: Example usage
+ description: Optionally show the proposed API, configuration, command or workflow.
+ render: php
+
- type: textarea
id: alternatives
attributes:
- label: Alternatives considered
- description: Any workaround or alternative approach you evaluated.
+ label: Alternatives or workarounds
+ description: Describe existing approaches you considered or currently use.
+
- type: textarea
- id: impact
+ id: compatibility
attributes:
- label: Expected impact
- description: Who benefits and what changes for users/CI?
- placeholder: This would improve...
- validations:
- required: true
+ label: Compatibility considerations
+ description: Mention possible public API, behavior, PHP-version, extension, platform or dependency implications.
+
- type: textarea
id: additional
attributes:
label: Additional context
- description: Related issues, links, examples, or prior art.
+ description: Add related issues, prior art, links, benchmarks or other supporting information.
diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml
index 2ca776f..62e91a7 100644
--- a/.github/ISSUE_TEMPLATE/question.yml
+++ b/.github/ISSUE_TEMPLATE/question.yml
@@ -1,40 +1,56 @@
name: Question
-description: Ask a usage or integration question
-title: "[Question]: "
+description: Ask about usage, behavior, integration or configuration
labels:
- question
body:
- type: markdown
attributes:
value: |
- Use this form for usage questions. For confirmed defects, use the bug report form.
+ Use this form for usage and integration questions. Use the bug form for reproducible incorrect behavior.
+
+ Do not report security vulnerabilities here. Follow `SECURITY.md` and use private vulnerability reporting.
+
- type: textarea
- id: context
+ id: goal
attributes:
label: What are you trying to do?
- description: Describe your goal and expected outcome.
+ description: Describe the goal and expected outcome.
placeholder: I want to...
validations:
required: true
+
- type: textarea
id: attempted
attributes:
label: What have you tried?
- description: Include commands, config snippets, or links you already checked.
+ description: Include relevant code, commands, configuration, documentation or approaches already checked.
placeholder: I tried...
validations:
required: true
+
+ - type: textarea
+ id: relevant_code
+ attributes:
+ label: Relevant code or configuration
+ description: Include a minimal sanitized example when applicable.
+ render: php
+
- type: textarea
id: output
attributes:
- label: Current output or behavior
- description: Include relevant command output, logs, or errors.
+ label: Relevant output or errors
+ description: Include sanitized output only when it helps explain the question.
render: shell
+
- type: textarea
id: environment
attributes:
- label: Environment details
- description: PHP version, Composer version, OS, CI provider (if relevant).
- placeholder: PHP 8.3, Composer 2.9, Ubuntu 24.04...
- validations:
- required: true
+ label: Environment
+ description: Include package, PHP, Composer, OS, extensions or CI details only when relevant.
+ placeholder: Package 2.4.1, PHP 8.4, Composer 2.9, Ubuntu 24.04...
+
+ - type: textarea
+ id: additional
+ attributes:
+ label: Additional context
+ description: Add related links, screenshots or prior discussions.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 59ae734..dcab1f5 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,33 +1,121 @@
## Summary
-Describe what changed and why.
+Describe what changed, why it was needed and the expected behavior.
-## Related Issues
+
-Link issues with `Closes #...` or `Relates #...`.
+## Change
-## Type of Change
+### Type
-- [ ] Bug fix
-- [ ] New feature
-- [ ] Refactor
-- [ ] Documentation update
-- [ ] CI or tooling update
-- [ ] Other (describe in summary)
+* [ ] Bug fix
+* [ ] New feature
+* [ ] Refactor
+* [ ] Performance
+* [ ] Security or reliability
+* [ ] Documentation or examples
+* [ ] Dependency, CI or tooling
+* [ ] Other
+
+### Behavior and Compatibility
+
+* [ ] No observable behavior changed
+* [ ] Existing behavior was corrected
+* [ ] New behavior was introduced
+* [ ] Public API or documented behavior changed
+* [ ] Backward compatibility may be affected
+* [ ] PHP, extension, platform or dependency requirements changed
+
+
## Validation
-List the commands you ran and their result.
+* [ ] `composer ic:ci`
+
+
+
+
+Focused validation
+
+
+
+* [ ] `composer ic:test:syntax`
+* [ ] `composer ic:test:code`
+* [ ] `composer ic:test:lint`
+* [ ] `composer ic:test:sniff`
+* [ ] `composer ic:test:duplicates`
+* [ ] `composer ic:test:probe`
+* [ ] `composer ic:test:comments`
+* [ ] `composer ic:test:architecture`
+* [ ] `composer ic:test:static`
+* [ ] `composer ic:test:security`
+* [ ] `composer ic:test:refactor`
+
+
+
+
+
+## Performance
+
+
+
+* [ ] Relevant benchmarks were added or updated
+* [ ] Results were compared against a relevant baseline
+* [ ] `composer ic:benchmark`
+* [ ] `composer ic:bench:quick`
+* [ ] `composer ic:bench:chart`
+
+
+
+## Implementation Notes
+
+
+
+## Review Focus
-```bash
-composer ic:tests
-```
+
## Checklist
-- [ ] I followed `CONTRIBUTING.md`.
-- [ ] I added or updated tests for behavior changes.
-- [ ] I updated docs/config/examples when needed.
-- [ ] I confirmed no security-sensitive data is exposed.
+* [ ] The change is focused and excludes unrelated modifications.
+* [ ] Tests cover new, corrected and regression-prone behavior.
+* [ ] Public API and backward-compatibility implications were considered.
+* [ ] Documentation, examples and type information were updated where required.
+* [ ] Performance claims are supported by reproducible benchmarks.
+* [ ] No credentials, secrets, personal data or sensitive debug output are included.
+* [ ] I followed `CONTRIBUTING.md` and the engineering principles.
diff --git a/.github/PULL_REQUEST_TEMPLATE/bug_fix.md b/.github/PULL_REQUEST_TEMPLATE/bug_fix.md
new file mode 100644
index 0000000..66759b4
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/bug_fix.md
@@ -0,0 +1,94 @@
+## Problem
+
+Describe the incorrect behavior, its impact and how it can be reproduced.
+
+
+
+## Root Cause
+
+
+
+## Fix
+
+Describe how the change corrects the problem and the expected behavior after the fix.
+
+## Behavior and Compatibility
+
+* [ ] Existing documented behavior was restored
+* [ ] Existing undocumented behavior was corrected
+* [ ] Public API remains compatible
+* [ ] Public API or documented behavior changed
+* [ ] Backward compatibility may be affected
+* [ ] PHP, extension, platform or dependency requirements changed
+
+
+
+## Validation
+
+* [ ] `composer ic:ci`
+* [ ] The original failure no longer reproduces
+* [ ] A regression test was added or updated
+* [ ] Relevant boundary and failure paths were tested
+
+
+
+
+Focused validation
+
+
+
+* [ ] `composer ic:test:syntax`
+* [ ] `composer ic:test:code`
+* [ ] `composer ic:test:lint`
+* [ ] `composer ic:test:sniff`
+* [ ] `composer ic:test:duplicates`
+* [ ] `composer ic:test:probe`
+* [ ] `composer ic:test:comments`
+* [ ] `composer ic:test:architecture`
+* [ ] `composer ic:test:static`
+* [ ] `composer ic:test:security`
+* [ ] `composer ic:test:refactor`
+
+
+
+
+
+## Review Focus
+
+
+
+## Checklist
+
+* [ ] The fix is focused and excludes unrelated changes.
+* [ ] The fix addresses the root cause rather than only masking symptoms.
+* [ ] Regression-prone behavior is covered by tests.
+* [ ] Public API and backward-compatibility implications were considered.
+* [ ] Documentation and examples were updated where required.
+* [ ] No credentials, secrets, personal data or sensitive debug output are included.
+* [ ] I followed `CONTRIBUTING.md` and the engineering principles.
diff --git a/.github/PULL_REQUEST_TEMPLATE/documentation.md b/.github/PULL_REQUEST_TEMPLATE/documentation.md
new file mode 100644
index 0000000..f983b85
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/documentation.md
@@ -0,0 +1,51 @@
+## Documentation Change
+
+Describe what is being added, corrected, clarified or removed and why.
+
+
+
+## Affected Content
+
+* [ ] README or getting-started guidance
+* [ ] API or reference documentation
+* [ ] Configuration documentation
+* [ ] Examples or tutorials
+* [ ] Contribution or community documentation
+* [ ] Changelog or release documentation
+* [ ] Other
+
+## Verification
+
+* [ ] Links and references were checked
+* [ ] Code examples were executed or otherwise verified
+* [ ] Commands and configuration examples match current behavior
+* [ ] Terminology is consistent with the project
+* [ ] `composer ic:ci`
+* [ ] No executable behavior changed
+
+
+
+## Review Focus
+
+
+
+## Checklist
+
+* [ ] The change is focused and excludes unrelated code changes.
+* [ ] Documentation reflects the current public behavior.
+* [ ] Examples are minimal, accurate and safe to copy.
+* [ ] Sensitive or private information is not included.
+* [ ] I followed `CONTRIBUTING.md` and the engineering principles.
diff --git a/.github/PULL_REQUEST_TEMPLATE/feature.md b/.github/PULL_REQUEST_TEMPLATE/feature.md
new file mode 100644
index 0000000..f6ce9c6
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/feature.md
@@ -0,0 +1,106 @@
+## Motivation
+
+Describe the problem, use case or capability this feature addresses.
+
+
+
+## Solution
+
+Describe the proposed behavior and how consumers are expected to use it.
+
+## API and Compatibility
+
+* [ ] No new public API
+* [ ] New backward-compatible public API
+* [ ] Existing public API or documented behavior changed
+* [ ] Backward compatibility may be affected
+* [ ] PHP, extension, platform or dependency requirements changed
+
+
+
+## Validation
+
+* [ ] `composer ic:ci`
+* [ ] Expected behavior is covered
+* [ ] Boundary and edge cases are covered
+* [ ] Failure and exception paths are covered
+* [ ] Public API usage is covered
+
+
+
+
+Focused validation
+
+
+
+* [ ] `composer ic:test:syntax`
+* [ ] `composer ic:test:code`
+* [ ] `composer ic:test:lint`
+* [ ] `composer ic:test:sniff`
+* [ ] `composer ic:test:duplicates`
+* [ ] `composer ic:test:probe`
+* [ ] `composer ic:test:comments`
+* [ ] `composer ic:test:architecture`
+* [ ] `composer ic:test:static`
+* [ ] `composer ic:test:security`
+* [ ] `composer ic:test:refactor`
+
+
+
+
+
+## Performance
+
+
+
+* [ ] Relevant benchmarks were added or updated
+* [ ] Results were compared against a relevant baseline
+* [ ] `composer ic:benchmark`
+* [ ] `composer ic:bench:quick`
+* [ ] `composer ic:bench:chart`
+
+
+
+## Review Focus
+
+
+
+## Checklist
+
+* [ ] The feature is focused and excludes unrelated changes.
+* [ ] Tests cover the public contract and failure behavior.
+* [ ] Public API and backward-compatibility implications were considered.
+* [ ] Documentation, examples and type information were updated.
+* [ ] Performance claims are supported by reproducible benchmarks.
+* [ ] No credentials, secrets, personal data or sensitive debug output are included.
+* [ ] I followed `CONTRIBUTING.md` and the engineering principles.
diff --git a/.github/PULL_REQUEST_TEMPLATE/maintenance.md b/.github/PULL_REQUEST_TEMPLATE/maintenance.md
new file mode 100644
index 0000000..a4cab0a
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/maintenance.md
@@ -0,0 +1,91 @@
+## Maintenance Change
+
+Describe what changed, why it was needed and the expected effect on development, CI or releases.
+
+
+
+## Category
+
+* [ ] Dependency update
+* [ ] CI or workflow change
+* [ ] Build or release tooling
+* [ ] PHPForge configuration
+* [ ] Development tooling
+* [ ] Repository maintenance
+* [ ] Other
+
+## Impact and Compatibility
+
+* [ ] Runtime behavior is unaffected
+* [ ] Development workflow changed
+* [ ] CI or release behavior changed
+* [ ] Supported PHP, extension, platform or dependency requirements changed
+* [ ] Generated files or configuration changed
+* [ ] Backward compatibility may be affected
+
+
+
+## Validation
+
+* [ ] `composer ic:ci`
+* [ ] Relevant workflow or job was exercised
+* [ ] Supported matrix or dependency mode was considered
+* [ ] Generated or published files were verified
+* [ ] Failure and rollback behavior was considered
+
+
+
+
+Focused validation
+
+
+
+* [ ] `composer ic:test:syntax`
+* [ ] `composer ic:test:code`
+* [ ] `composer ic:test:lint`
+* [ ] `composer ic:test:sniff`
+* [ ] `composer ic:test:duplicates`
+* [ ] `composer ic:test:probe`
+* [ ] `composer ic:test:comments`
+* [ ] `composer ic:test:architecture`
+* [ ] `composer ic:test:static`
+* [ ] `composer ic:test:security`
+* [ ] `composer ic:test:refactor`
+
+
+
+
+
+## Review Focus
+
+
+
+## Checklist
+
+* [ ] The change is focused and excludes unrelated source refactoring.
+* [ ] Dependency or workflow changes are minimal and justified.
+* [ ] Public API and backward-compatibility implications were considered.
+* [ ] Documentation and generated files were updated where required.
+* [ ] No credentials, secrets, personal data or sensitive debug output are included.
+* [ ] I followed `CONTRIBUTING.md` and the engineering principles.
diff --git a/.github/PULL_REQUEST_TEMPLATE/performance.md b/.github/PULL_REQUEST_TEMPLATE/performance.md
new file mode 100644
index 0000000..ce2d10d
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/performance.md
@@ -0,0 +1,99 @@
+## Bottleneck
+
+Describe the measured performance problem, affected execution path and practical impact.
+
+
+
+## Optimization
+
+Describe the change, why it improves the measured path and any trade-offs introduced.
+
+## Correctness and Compatibility
+
+* [ ] Observable behavior remains unchanged
+* [ ] Public API remains compatible
+* [ ] Error and exception behavior remains compatible
+* [ ] Behavior or public API changed intentionally
+* [ ] PHP, extension, platform or dependency requirements changed
+
+
+
+## Benchmark Evidence
+
+* [ ] Relevant benchmarks were added or updated
+* [ ] Results were compared against a relevant baseline
+* [ ] Multiple stable runs were considered
+* [ ] Runtime impact was measured
+* [ ] Memory or allocation impact was measured where relevant
+* [ ] `composer ic:benchmark`
+* [ ] `composer ic:bench:quick`
+* [ ] `composer ic:bench:chart`
+
+
+
+## Validation
+
+* [ ] `composer ic:ci`
+* [ ] Expected behavior remains covered
+* [ ] Boundary and failure paths remain covered
+* [ ] Performance-sensitive behavior is covered
+
+
+
+
+Focused validation
+
+
+
+* [ ] `composer ic:test:syntax`
+* [ ] `composer ic:test:code`
+* [ ] `composer ic:test:lint`
+* [ ] `composer ic:test:sniff`
+* [ ] `composer ic:test:duplicates`
+* [ ] `composer ic:test:probe`
+* [ ] `composer ic:test:comments`
+* [ ] `composer ic:test:architecture`
+* [ ] `composer ic:test:static`
+* [ ] `composer ic:test:security`
+* [ ] `composer ic:test:refactor`
+
+
+
+
+
+## Review Focus
+
+
+
+## Checklist
+
+* [ ] The optimization targets a measured bottleneck.
+* [ ] Results are reproducible in comparable environments.
+* [ ] Correctness was not traded for an unverified micro-optimization.
+* [ ] Public API and backward-compatibility implications were considered.
+* [ ] Benchmark and documentation changes are included where required.
+* [ ] No credentials, secrets, personal data or sensitive debug output are included.
+* [ ] I followed `CONTRIBUTING.md` and the engineering principles.
diff --git a/.github/PULL_REQUEST_TEMPLATE/refactor.md b/.github/PULL_REQUEST_TEMPLATE/refactor.md
new file mode 100644
index 0000000..588be4f
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/refactor.md
@@ -0,0 +1,108 @@
+## Intent and Scope
+
+Describe what was restructured, why it was necessary and what remains intentionally unchanged.
+
+
+
+## Behavioral Guarantee
+
+* [ ] No observable behavior changed
+* [ ] Public API remains unchanged
+* [ ] Existing behavior was intentionally corrected
+* [ ] Public API or documented behavior changed
+* [ ] Backward compatibility may be affected
+
+
+
+## Design Notes
+
+
+
+## Validation
+
+* [ ] `composer ic:ci`
+* [ ] Existing behavior remains covered
+* [ ] Relevant regression and edge cases are covered
+* [ ] Public API compatibility was verified
+
+
+
+
+Focused validation
+
+
+
+* [ ] `composer ic:test:syntax`
+* [ ] `composer ic:test:code`
+* [ ] `composer ic:test:lint`
+* [ ] `composer ic:test:sniff`
+* [ ] `composer ic:test:duplicates`
+* [ ] `composer ic:test:probe`
+* [ ] `composer ic:test:comments`
+* [ ] `composer ic:test:architecture`
+* [ ] `composer ic:test:static`
+* [ ] `composer ic:test:security`
+* [ ] `composer ic:test:refactor`
+
+
+
+
+
+## Performance
+
+
+
+* [ ] Results were compared against a relevant baseline
+* [ ] `composer ic:benchmark`
+* [ ] `composer ic:bench:quick`
+* [ ] `composer ic:bench:chart`
+
+
+
+## Review Focus
+
+
+
+## Checklist
+
+* [ ] The refactor is focused and excludes unrelated behavior changes.
+* [ ] Complexity was reduced without unnecessary abstraction or file growth.
+* [ ] Existing contracts and failure behavior remain covered.
+* [ ] Public API and backward-compatibility implications were considered.
+* [ ] Documentation and type information were updated where required.
+* [ ] No credentials, secrets, personal data or sensitive debug output are included.
+* [ ] I followed `CONTRIBUTING.md` and the engineering principles.
diff --git a/.github/PULL_REQUEST_TEMPLATE/security_reliability.md b/.github/PULL_REQUEST_TEMPLATE/security_reliability.md
new file mode 100644
index 0000000..ef2ca28
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/security_reliability.md
@@ -0,0 +1,92 @@
+
+
+## Concern
+
+Describe the security weakness, reliability failure mode or defensive gap being addressed.
+
+
+
+## Mitigation
+
+Describe how the change reduces the risk and what assumptions or limitations remain.
+
+## Impact and Compatibility
+
+* [ ] Security hardening with no observable behavior change
+* [ ] Reliability improvement with no public API change
+* [ ] Failure or exception behavior changed
+* [ ] Public API or documented behavior changed
+* [ ] Backward compatibility may be affected
+* [ ] PHP, extension, platform or dependency requirements changed
+
+
+
+## Validation
+
+* [ ] `composer ic:ci`
+* [ ] Security-sensitive or failure behavior is covered
+* [ ] Abuse, malformed-input or failure paths are covered
+* [ ] Regression coverage was added or updated
+* [ ] `composer ic:test:security`
+
+
+
+
+Focused validation
+
+
+
+* [ ] `composer ic:test:syntax`
+* [ ] `composer ic:test:code`
+* [ ] `composer ic:test:lint`
+* [ ] `composer ic:test:sniff`
+* [ ] `composer ic:test:duplicates`
+* [ ] `composer ic:test:probe`
+* [ ] `composer ic:test:comments`
+* [ ] `composer ic:test:architecture`
+* [ ] `composer ic:test:static`
+* [ ] `composer ic:test:security`
+* [ ] `composer ic:test:refactor`
+
+
+
+
+
+## Review Focus
+
+
+
+## Checklist
+
+* [ ] Confidential vulnerability details are not exposed publicly.
+* [ ] The change is focused and avoids unrelated refactoring.
+* [ ] Security or reliability claims are supported by tests.
+* [ ] Failure paths and backward-compatibility implications were considered.
+* [ ] Documentation and upgrade guidance were updated where required.
+* [ ] No credentials, secrets, personal data or sensitive debug output are included.
+* [ ] I followed `SECURITY.md`, `CONTRIBUTING.md` and the engineering principles.
diff --git a/.gitignore b/.gitignore
index c151694..0201dc9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,4 @@ test.php
var
vendor
.codex
+plan.md
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 9c2638f..eff64bb 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -2,49 +2,71 @@
## Our Commitment
-We are committed to making participation in this project a harassment-free
-experience for everyone, regardless of age, body size, disability, ethnicity,
-gender identity and expression, level of experience, nationality, personal
-appearance, race, religion or sexual identity and orientation.
+We are committed to providing a welcoming, inclusive and harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity or expression, level of experience, nationality, personal appearance, race, religion, sexual identity or sexual orientation.
## Expected Behavior
-Examples of behavior that contributes to a positive environment:
+Examples of behavior that contributes to a positive environment include:
-- Be respectful and constructive.
-- Assume good intent and ask clarifying questions.
-- Give and receive feedback professionally.
-- Focus on what is best for the community and project.
+* Being respectful, constructive and considerate
+* Giving and receiving feedback professionally
+* Disagreeing with ideas without attacking individuals
+* Respecting differing viewpoints and experiences
+* Accepting responsibility, apologizing when appropriate and learning from mistakes
+* Focusing on what is best for the project and its community
## Unacceptable Behavior
Examples of unacceptable behavior include:
-- Harassment, discrimination or personal attacks.
-- Trolling, insulting or derogatory comments.
-- Publishing private information without consent.
-- Any conduct that is inappropriate in a professional setting.
-
-## Enforcement Responsibilities
-
-Project maintainers are responsible for clarifying and enforcing this code of
-conduct. They may remove, edit or reject comments, commits, code, issues, and
-other contributions that violate this policy.
+* Harassment, discrimination, intimidation or personal attacks
+* Trolling, insults, threats or derogatory comments
+* Sexualized language, imagery or unwanted attention
+* Repeated disruption of discussions or project activities
+* Publishing private or identifying information without permission
+* Retaliating against anyone who reports an incident or participates in an investigation
+* Any conduct that would reasonably be considered inappropriate in a professional setting
## Scope
This code of conduct applies in all project spaces, including:
-- Issue trackers
-- Pull requests
-- Discussions and chat related to the project
-- Any public or private communication where someone represents the project
+* Issues, pull requests and code reviews
+* Discussions and project-related chat
+* Documentation, commits and other contributions
+* Public or private communication where an individual represents the project or its community
## Reporting
-To report unacceptable behavior, contact project maintainers privately.
+Report unacceptable behavior privately to the project maintainers.
+
+Do not include sensitive incident details in a public issue, discussion or pull request. When no private contact method is available, open a public issue requesting a private communication channel without describing the incident.
+
+Reports should include, when available:
+
+* A description of what occurred
+* Relevant links, screenshots or other supporting information
+* The approximate date and location of the incident
+* Any immediate safety or confidentiality concerns
+
+All reports will be reviewed as confidentially and impartially as reasonably possible. Information will be shared only when necessary to investigate and respond to the report.
+
+## Enforcement Responsibilities
+
+Project maintainers are responsible for interpreting and enforcing this code of conduct.
+
+Maintainers may remove, edit or reject comments, commits, code, issues, pull requests and other contributions that violate this policy. Maintainers who have a conflict of interest regarding a report should not participate in its review.
## Enforcement
-Maintainers may take any action they deem appropriate, including warnings,
-temporary bans or permanent bans from community participation.
+Actions will be based on the severity, frequency and context of the behavior and may include:
+
+* A private warning
+* Removal or editing of inappropriate content
+* Temporary restrictions on project participation
+* Permanent removal from project spaces
+* Reporting serious threats or unlawful conduct to the relevant platform or authorities
+
+Enforcement decisions should be proportionate, documented privately and applied consistently.
+
+Retaliation against reporters, witnesses or participants in an investigation is prohibited.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9950065..ad81ec1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,18 +1,18 @@
# Contributing
-Thanks for contributing.
+Thanks for contributing to this project.
## Before You Start
-- Review the project code of conduct.
-- For security issues, use private reporting and avoid opening a public issue.
-- Check existing issues and pull requests first to avoid duplicates.
+* Review `CODE_OF_CONDUCT.md`.
+* Report security vulnerabilities privately according to `SECURITY.md`.
+* Search existing issues and pull requests to avoid duplicate work.
+* An issue is not required for small fixes or improvements discovered during development.
+* Discuss substantial API, architectural or compatibility changes before implementation.
## Local Setup
-Requirements:
-
-- See `README.md` for current PHP and Composer requirements.
+Review `README.md` and `composer.json` for supported PHP versions, extensions, dependencies and project-specific requirements.
Install dependencies:
@@ -20,49 +20,180 @@ Install dependencies:
composer install
```
+Inspect the detected PHPForge configuration:
+
+```bash
+composer ic:doctor
+```
+
+Do not modify files inside `vendor/`.
+
+## Engineering Standards
+
+Before changing or reviewing code, read and follow:
+
+```text
+vendor/infocyph/phpforge/resources/engineering-principles.md
+```
+
+These principles apply equally to human contributors and automated coding agents. They define the expected approach to implementation decisions, scope control, architecture, performance, security, compatibility, testing and maintainability.
+
+Project-specific requirements may extend these principles but should not silently weaken them.
+
## Development Workflow
-Typical contributor workflow:
+1. Create a branch from the repository’s default branch.
+2. Make one focused logical change.
+3. Add or update tests for changed behavior.
+4. Run relevant focused checks during development.
+5. Apply automated processing where appropriate.
+6. Review every automatically modified file.
+7. Run the complete CI suite before opening a pull request.
+8. Add reproducible benchmark evidence for performance-related changes.
+9. Complete the pull request template accurately.
+
+## Automated Processing
+
+Run all configured processors:
+
+```bash
+composer ic:process
+```
+
+Run an individual processor when only a targeted change is needed:
+
+```bash
+composer ic:process:refactor
+composer ic:process:lint
+composer ic:process:sniff
+```
+
+Automated processing may modify source files and `composer.json`. Review all resulting changes before committing.
-1. Create a branch from `main`.
-2. Make focused changes.
-3. Run quality checks locally.
-4. Open a pull request with context and verification notes.
+## Validation
-Recommended checks:
+Run the complete project validation suite before opening a pull request:
```bash
-composer ic:tests
+composer ic:ci
```
-Useful targeted commands:
+When `composer ic:ci` passes, running the same checks individually is unnecessary.
+
+Use focused commands while developing or when the complete suite cannot run:
+
+
+Focused validation commands
```bash
composer ic:test:syntax
composer ic:test:code
composer ic:test:lint
composer ic:test:sniff
+composer ic:test:duplicates
+composer ic:test:probe
+composer ic:test:comments
+composer ic:test:architecture
composer ic:test:static
composer ic:test:security
-composer ic:test:architecture
+composer ic:test:refactor
+```
+
+
+
+When `composer ic:ci` cannot complete, document:
+
+* Why it could not complete
+* Which focused checks passed
+* Relevant PHP, dependency, extension or platform limitations
+* Any remaining validation risk
+
+Do not suppress, baseline, exclude or weaken a check merely to make validation pass. Any configuration or baseline change must be intentional and explained in the pull request.
+
+## Tests
+
+Test observable behavior and public contracts rather than internal implementation details.
+
+Include relevant coverage for:
+
+* New or corrected behavior
+* Regression scenarios
+* Boundary and edge cases
+* Failure and exception paths
+* Public API compatibility
+* PHP-version, dependency, extension or platform-sensitive behavior
+
+A bug fix should normally include a regression test that fails without the fix.
+
+## Performance Changes
+
+Run benchmarks when performance is affected or claimed:
+
+```bash
+composer ic:benchmark
```
-Auto-fix and processing helpers:
+Additional benchmark commands:
```bash
-composer ic:process
+composer ic:bench:quick
+composer ic:bench:chart
+```
+
+Performance claims must include reproducible before-and-after results from comparable environments. Avoid conclusions based on a single unstable run.
+
+Add or update benchmark coverage when existing benchmarks do not represent the changed execution path.
+
+## Configuration
+
+Inspect the active PHPForge configuration sources:
+
+```bash
+composer ic:list-config
+composer ic:list-config --json
+```
+
+Publish a configuration file only when the project requires rules that differ from PHPForge defaults:
+
+```bash
+composer ic:publish-config
```
+When changing quality configuration:
+
+* Explain why the current rule is unsuitable
+* Keep exclusions narrow
+* Avoid weakening checks globally for one change
+* Document compatibility or baseline implications
+
## Pull Request Guidelines
-- Keep pull requests scoped to one logical change.
-- Include why the change is needed and what behavior changed.
-- Add or update tests when behavior changes.
-- Update docs when command behavior, config, or workflow behavior changes.
-- Ensure CI is green before requesting review.
+* Keep each pull request limited to one logical change.
+* Explain what changed, why it was needed and the expected behavior.
+* Identify public API, backward-compatibility, PHP, extension, platform or dependency impacts.
+* Select only validation and benchmark checkboxes that reflect work actually performed.
+* Add or update tests for behavior changes.
+* Update documentation, examples, types and configuration where required.
+* Exclude unrelated formatting, refactoring, dependency or generated-file changes.
+* Ensure CI passes before requesting review.
+* Address review feedback through focused follow-up changes.
+
+Draft pull requests are welcome for incomplete work or early design feedback, but validation claims and checklist items must remain accurate.
## Reporting Bugs and Requesting Features
-- Use issue templates for bugs, regressions, CI failures, documentation updates, questions, and feature requests.
-- Include reproducible steps, expected behavior, and actual behavior.
-- Share environment details (PHP version, OS, Composer version).
+Use the relevant issue template for bugs, regressions, CI failures, documentation problems, questions and feature requests.
+
+Include when relevant:
+
+* A clear description of the problem or proposed behavior
+* A minimal reproduction
+* Expected and actual behavior
+* Package and dependency versions
+* PHP and Composer versions
+* Operating system and relevant extensions
+* Logs or error output with sensitive information removed
+
+Small, self-contained fixes may be submitted directly as pull requests. Larger behavioral, architectural or compatibility changes should be discussed first.
+
+Security vulnerabilities must not be reported through public issues, discussions or pull requests.
diff --git a/README.md b/README.md
index b921b5d..6135d13 100644
--- a/README.md
+++ b/README.md
@@ -312,17 +312,33 @@ $host = $config->get('db.host');
## Security
-Protected by [PHPForge](https://github.com/infocyph/PHPForge) — an automated quality and security gate for PHP projects.
+Do not disclose suspected vulnerabilities in a public issue, discussion or pull request. Follow [SECURITY.md](SECURITY.md) and use [GitHub private vulnerability reporting](https://github.com/infocyph/ArrayKit/security/advisories/new).
+
+ArrayKit is protected by [PHPForge](https://github.com/infocyph/PHPForge), which provides automated tests, static and taint analysis, dependency auditing, architecture checks and release-readiness gates. Automated controls do not replace responsible disclosure or manual review.
+
---
diff --git a/SECURITY.md b/SECURITY.md
index 37a355e..ca14478 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,48 +2,51 @@
## Supported Versions
-The project currently supports security updates for the latest release.
+Security updates are provided for the latest stable release.
+
+Reports affecting older versions are welcome, but fixes may be released only for the latest version. Users should upgrade before confirming whether an issue remains present.
## Reporting a Vulnerability
-Please report vulnerabilities privately.
+Please report suspected vulnerabilities privately.
+
+1. Go to `Security` → `Advisories` → `Report a vulnerability`.
+2. If private vulnerability reporting is unavailable, open a public issue requesting a private security contact.
+3. Do not include vulnerability details in that issue or disclose them through public issues, discussions, pull requests or other public channels.
+
+Include when available:
-1. Use GitHub private vulnerability reporting for this repository (`Security` -> `Advisories` -> `Report a vulnerability`).
-2. If private reporting is unavailable, contact maintainers through a private channel.
-3. Do not open a public issue for security vulnerabilities.
+* Affected package version and component
+* PHP version and runtime environment
+* Relevant extensions or dependencies
+* Reproduction steps or a minimal proof of concept
+* Exploitation requirements and potential impact
+* Known workarounds or suggested remediation
-Please include:
+## Response and Disclosure
-- Affected package version(s)
-- PHP version and runtime environment
-- Reproduction steps or proof of concept
-- Impact assessment (confidentiality/integrity/availability)
-- Any known workaround
+The maintainers will make a best-effort attempt to:
-## Response Process
+* Acknowledge the report within five business days
+* Validate the report and assess its severity
+* Coordinate remediation and responsible disclosure
+* Publish a fix, mitigation or security advisory when appropriate
-- Initial acknowledgment: best effort, typically within a few days
-- Triage: best effort, based on maintainer availability
-- Fix and release timeline depends on severity and exploitability
+Resolution timelines depend on severity, exploitability, complexity and maintainer availability. These targets are not a service-level agreement.
-If a report is accepted, a patched release will be prepared and published. Credit will be provided unless you request otherwise.
+Please coordinate public disclosure with the maintainers so affected users have a reasonable opportunity to update or apply mitigations.
-## Protected by PHPForge
+Confirmed reporters will receive credit unless they request anonymity.
-This project is protected by [PHPForge](https://github.com/infocyph/PHPForge), an automated quality and security tooling layer for Infocyph PHP projects.
+## PHPForge Security Controls
-PHPForge helps keep the project reliable by running checks for:
+This project uses [PHPForge](https://github.com/infocyph/PHPForge) to automate security and quality checks, including:
-- Code style and standards
-- Tests and syntax validation
-- Static analysis and type safety
-- Security and taint analysis
-- Dependency vulnerability audit
-- Architecture boundary validation
-- Duplicate-code detection
-- API snapshot and comment-policy checks
-- Refactor safety checks
-- Benchmark and release-readiness checks
-- Git hooks and CI workflow protection
+* Test and syntax validation
+* Static and taint analysis
+* Dependency vulnerability auditing
+* Architecture validation
+* Release-readiness checks
+* Git hooks and CI enforcement
-These automated gates strengthen code quality, reduce security risk and help prevent regressions before merge or release.
+These controls help reduce security risk and prevent regressions, but they do not guarantee the absence of vulnerabilities or replace manual review and responsible reporting.
diff --git a/benchmarks/ArrayMultiBench.php b/benchmarks/ArrayMultiBench.php
new file mode 100644
index 0000000..92ae302
--- /dev/null
+++ b/benchmarks/ArrayMultiBench.php
@@ -0,0 +1,143 @@
+ */
+ private array $needles = [];
+
+ /** @var array */
+ private array $rows = [];
+
+ /** @param array{size:int, needles:int} $params */
+ public function setUp(array $params): void
+ {
+ $this->rows = [];
+ for ($index = 0; $index < $params['size']; $index++) {
+ $this->rows[] = [
+ 'id' => $index,
+ 'group' => 'group-' . ($index % 100),
+ 'score' => $index % 1000,
+ 'text' => 'row-' . $index,
+ 'derived' => [
+ 'id' => $index % max(1, intdiv($params['size'], 2)),
+ 'payload' => str_repeat('x', 96),
+ ],
+ ];
+ }
+
+ $this->needles = range(0, $params['needles'] - 1);
+ }
+
+ public function benchCountBy(): void
+ {
+ ArrayMulti::countBy($this->rows, 'group');
+ }
+
+ public function benchDuplicatesByStrict(): void
+ {
+ ArrayMulti::duplicatesBy($this->rows, 'derived', true);
+ }
+
+ public function benchFlatten(): void
+ {
+ ArrayMulti::flatten($this->rows);
+ }
+
+ public function benchGroupBy(): void
+ {
+ ArrayMulti::groupBy($this->rows, 'group');
+ }
+
+ public function benchKeyBy(): void
+ {
+ ArrayMulti::keyBy($this->rows, 'id');
+ }
+
+ public function benchKeyByNative(): void
+ {
+ $result = [];
+ foreach ($this->rows as $row) {
+ $result[$row['id']] = $row;
+ }
+ }
+
+ public function benchPluck(): void
+ {
+ ArrayMulti::pluck($this->rows, 'score', 'id');
+ }
+
+ public function benchSortBy(): void
+ {
+ ArrayMulti::sortBy($this->rows, 'score', true, SORT_NUMERIC);
+ }
+
+ public function benchSortByMany(): void
+ {
+ ArrayMulti::sortByMany($this->rows, [
+ ['group', 'asc', SORT_STRING],
+ ['score', 'desc', SORT_NUMERIC],
+ ]);
+ }
+
+ public function benchSum(): void
+ {
+ ArrayMulti::sum($this->rows, 'score');
+ }
+
+ public function benchUniqueByStrict(): void
+ {
+ ArrayMulti::uniqueBy($this->rows, 'derived', true);
+ }
+
+ public function benchWhere(): void
+ {
+ ArrayMulti::where($this->rows, 'score', '>=', 500);
+ }
+
+ public function benchWhereIn(): void
+ {
+ ArrayMulti::whereIn($this->rows, 'score', $this->needles, true);
+ }
+
+ public function benchWhereInNative(): void
+ {
+ $result = [];
+ foreach ($this->rows as $key => $row) {
+ if (in_array($row['score'], $this->needles, true)) {
+ $result[$key] = $row;
+ }
+ }
+ }
+
+ public function benchWhereNotIn(): void
+ {
+ ArrayMulti::whereNotIn($this->rows, 'score', $this->needles, true);
+ }
+
+ /** @return array */
+ public function provideWorkloads(): array
+ {
+ return [
+ '100x2' => ['size' => 100, 'needles' => 2],
+ '1kx10' => ['size' => 1000, 'needles' => 10],
+ '10kx2' => ['size' => 10000, 'needles' => 2],
+ '10kx10' => ['size' => 10000, 'needles' => 10],
+ '10kx100' => ['size' => 10000, 'needles' => 100],
+ '100kx100' => ['size' => 100000, 'needles' => 100],
+ ];
+ }
+}
diff --git a/benchmarks/ArraySingleBench.php b/benchmarks/ArraySingleBench.php
new file mode 100644
index 0000000..dd17467
--- /dev/null
+++ b/benchmarks/ArraySingleBench.php
@@ -0,0 +1,158 @@
+ */
+ private array $data = [];
+
+ /** @var array */
+ private array $keyed = [];
+
+ /** @var array */
+ private array $needles = [];
+
+ /** @param array{size:int} $params */
+ public function setUp(array $params): void
+ {
+ $uniqueValues = max(1, intdiv($params['size'], 2));
+ $this->data = [];
+ $this->keyed = [];
+
+ for ($index = 0; $index < $params['size']; $index++) {
+ $value = $index % $uniqueValues;
+ $this->data[] = $value;
+ $this->keyed['key-' . $index] = $value;
+ }
+
+ $this->needles = array_slice($this->data, 0, min(100, $params['size']));
+ }
+
+ public function benchContains(array $params): void
+ {
+ ArraySingle::contains($this->data, $params['size'] - 1, true);
+ }
+
+ public function benchContainsAll(): void
+ {
+ ArraySingle::containsAll($this->data, $this->needles, true);
+ }
+
+ public function benchContainsAny(array $params): void
+ {
+ ArraySingle::containsAny($this->data, [$params['size'], $this->needles[0]], true);
+ }
+
+ public function benchContainsNative(array $params): void
+ {
+ in_array($params['size'] - 1, $this->data, true);
+ }
+
+ public function benchDiff(): void
+ {
+ ArraySingle::diff($this->data, $this->needles, true);
+ }
+
+ public function benchDuplicates(): void
+ {
+ ArraySingle::duplicates($this->data);
+ }
+
+ public function benchIntersect(): void
+ {
+ ArraySingle::intersect($this->data, $this->needles, true);
+ }
+
+ public function benchMap(): void
+ {
+ ArraySingle::map($this->data, static fn(int $value): int => $value + 1);
+ }
+
+ public function benchMapArrayMap(): void
+ {
+ array_map(static fn(int $value): int => $value + 1, $this->data);
+ }
+
+ public function benchMapForeach(): void
+ {
+ $result = [];
+ foreach ($this->data as $key => $value) {
+ $result[$key] = $value + 1;
+ }
+ }
+
+ public function benchMedian(): void
+ {
+ ArraySingle::median($this->data);
+ }
+
+ public function benchMode(): void
+ {
+ ArraySingle::mode($this->data);
+ }
+
+ public function benchNth(): void
+ {
+ ArraySingle::nth($this->data, 10, 3);
+ }
+
+ public function benchPartition(): void
+ {
+ ArraySingle::partition($this->keyed, static fn(int $value): bool => ($value % 2) === 0);
+ }
+
+ public function benchSame(): void
+ {
+ ArraySingle::same($this->data, $this->data, true);
+ }
+
+ public function benchSeededShuffle(): void
+ {
+ ArraySingle::shuffle($this->data, 12345);
+ }
+
+ public function benchSum(): void
+ {
+ ArraySingle::sum($this->data);
+ }
+
+ public function benchUnique(): void
+ {
+ ArraySingle::unique($this->data, true);
+ }
+
+ public function benchUnseededShuffle(): void
+ {
+ ArraySingle::shuffle($this->data);
+ }
+
+ public function benchWhere(): void
+ {
+ ArraySingle::where($this->keyed, static fn(int $value): bool => ($value % 2) === 0);
+ }
+
+ /** @return array */
+ public function provideSizes(): array
+ {
+ return [
+ '10' => ['size' => 10],
+ '100' => ['size' => 100],
+ '1k' => ['size' => 1000],
+ '10k' => ['size' => 10000],
+ '100k' => ['size' => 100000],
+ ];
+ }
+}
diff --git a/benchmarks/ArrayValueSetBench.php b/benchmarks/ArrayValueSetBench.php
new file mode 100644
index 0000000..83ffd8b
--- /dev/null
+++ b/benchmarks/ArrayValueSetBench.php
@@ -0,0 +1,120 @@
+ */
+ private array $values = [];
+
+ /** @param array{bytes:int, distribution:string} $params */
+ public function setUp(array $params): void
+ {
+ $this->values = [];
+ $uniqueValues = $params['distribution'] === 'duplicate-heavy' ? 20 : 900;
+
+ for ($index = 0; $index < 1000; $index++) {
+ $id = $index % $uniqueValues;
+ $this->values[] = [
+ 'id' => $id,
+ 'payload' => str_repeat(chr(65 + ($id % 26)), $params['bytes']),
+ 'nested' => ['enabled' => ($id % 2) === 0, 'score' => (float) $id],
+ ];
+ }
+ }
+
+ public function benchCanonicalFingerprintSet(): void
+ {
+ $seen = [];
+ foreach ($this->values as $value) {
+ $seen[ArraySingleOps::fingerprint($value, true)] = true;
+ }
+ }
+
+ public function benchDigestWithVerifiedBuckets(): void
+ {
+ $buckets = [];
+ foreach ($this->values as $value) {
+ $fingerprint = ArraySingleOps::fingerprint($value, true);
+ $digest = hash('xxh128', $fingerprint, true);
+ if (!isset($buckets[$digest])) {
+ $buckets[$digest] = [$fingerprint];
+
+ continue;
+ }
+
+ if (!in_array($fingerprint, $buckets[$digest], true)) {
+ $buckets[$digest][] = $fingerprint;
+ }
+ }
+ }
+
+ public function benchLooseNestedUnique(): void
+ {
+ ArraySingle::unique($this->values);
+ }
+
+ public function benchMixedStrictUnique(): void
+ {
+ $object = new \stdClass();
+ ArraySingle::unique([
+ ...$this->values,
+ null,
+ false,
+ 0,
+ 0.0,
+ '0',
+ INF,
+ -INF,
+ NAN,
+ $object,
+ $object,
+ ], true);
+ }
+
+ public function benchStrictNestedScan(): void
+ {
+ $seen = [];
+ foreach ($this->values as $value) {
+ if (!in_array($value, $seen, true)) {
+ $seen[] = $value;
+ }
+ }
+ }
+
+ public function benchStrictNestedUnique(): void
+ {
+ ArraySingle::unique($this->values, true);
+ }
+
+ /** @return array */
+ public function provideValueSets(): array
+ {
+ $workloads = [];
+ foreach ([16, 32, 48, 64, 96, 128, 256, 512] as $bytes) {
+ $workloads[$bytes . 'b-duplicates'] = [
+ 'bytes' => $bytes,
+ 'distribution' => 'duplicate-heavy',
+ ];
+ $workloads[$bytes . 'b-unique'] = [
+ 'bytes' => $bytes,
+ 'distribution' => 'mostly-unique',
+ ];
+ }
+
+ return $workloads;
+ }
+}
diff --git a/benchmarks/CollectionBench.php b/benchmarks/CollectionBench.php
new file mode 100644
index 0000000..366b514
--- /dev/null
+++ b/benchmarks/CollectionBench.php
@@ -0,0 +1,87 @@
+ */
+ private array $data = [];
+
+ /** @param array{size:int} $params */
+ public function setUp(array $params): void
+ {
+ $this->data = range(1, $params['size']);
+ }
+
+ public function benchArraySingleMap(): void
+ {
+ ArraySingle::map($this->data, static fn(int $value): int => $value * 2);
+ }
+
+ public function benchCollectionMap(): void
+ {
+ Collection::make($this->data)->map(static fn(int $value): int => $value * 2);
+ }
+
+ public function benchLazyChunkMaterialization(): void
+ {
+ LazyCollection::fromFactory(fn(): array => $this->data)
+ ->chunkLazy(100)
+ ->all();
+ }
+
+ public function benchLazyFilterMaterialization(): void
+ {
+ LazyCollection::fromFactory(fn(): array => $this->data)
+ ->filterLazy(static fn(int $value): bool => ($value % 2) === 0)
+ ->all();
+ }
+
+ public function benchLazyMapFilterTake(): void
+ {
+ LazyCollection::fromFactory(fn(): array => $this->data)
+ ->mapLazy(static fn(int $value): int => $value * 2)
+ ->filterLazy(static fn(int $value): bool => ($value % 3) === 0)
+ ->take(100)
+ ->all();
+ }
+
+ public function benchLazyMapMaterialization(): void
+ {
+ LazyCollection::fromFactory(fn(): array => $this->data)
+ ->mapLazy(static fn(int $value): int => $value * 2)
+ ->all();
+ }
+
+ public function benchPipelineMap(): void
+ {
+ Collection::make($this->data)
+ ->process()
+ ->map(static fn(int $value): int => $value * 2);
+ }
+
+ /** @return array */
+ public function provideSizes(): array
+ {
+ return [
+ '1k' => ['size' => 1000],
+ '10k' => ['size' => 10000],
+ '100k' => ['size' => 100000],
+ '1m' => ['size' => 1000000],
+ ];
+ }
+}
diff --git a/benchmarks/ConfigBench.php b/benchmarks/ConfigBench.php
new file mode 100644
index 0000000..8d4a978
--- /dev/null
+++ b/benchmarks/ConfigBench.php
@@ -0,0 +1,117 @@
+ false,
+ 'one' => [
+ 'two' => [
+ 'three' => [
+ 'four' => [
+ 'five' => 5,
+ ],
+ ],
+ ],
+ ],
+ 'service.name' => 'escaped',
+ 'users' => [['name' => 'Alice'], ['name' => 'Bob']],
+ ];
+
+ $this->path = $params['path'];
+ $this->reads = $params['reads'];
+ $this->cached = new Config();
+ $this->cached->loadArray($items);
+ $this->uncached = new Config();
+ $this->uncached->loadArray($items);
+ $this->uncached->readCache(false);
+ $this->cached->get($this->path);
+ }
+
+ public function benchCachedGet(): void
+ {
+ for ($index = 0; $index < $this->reads; $index++) {
+ $this->cached->get($this->path);
+ }
+ }
+
+ public function benchFill(): void
+ {
+ $this->cached->fill('one.two.added', 1);
+ }
+
+ public function benchForget(): void
+ {
+ $this->cached->forget('one.two.three');
+ }
+
+ public function benchSet(): void
+ {
+ $this->cached->set('one.two.value', 1);
+ }
+
+ public function benchTopLevelGet(): void
+ {
+ for ($index = 0; $index < $this->reads; $index++) {
+ $this->cached->get('debug');
+ }
+ }
+
+ public function benchTypedGetter(): void
+ {
+ $this->cached->getInt('one.two.three.four.five');
+ }
+
+ public function benchUncachedGet(): void
+ {
+ for ($index = 0; $index < $this->reads; $index++) {
+ $this->uncached->get($this->path);
+ }
+ }
+
+ /** @return array */
+ public function provideReads(): array
+ {
+ $paths = [
+ 'plain' => 'debug',
+ 'one' => 'one',
+ 'three' => 'one.two.three',
+ 'five' => 'one.two.three.four.five',
+ 'escaped' => 'service\\.name',
+ 'wildcard' => 'users.*.name',
+ ];
+ $workloads = [];
+
+ foreach ($paths as $name => $path) {
+ foreach ([1, 2, 5, 10, 100, 1000] as $reads) {
+ $workloads[$name . '-' . $reads] = ['path' => $path, 'reads' => $reads];
+ }
+ }
+
+ return $workloads;
+ }
+}
diff --git a/benchmarks/DotNotationBench.php b/benchmarks/DotNotationBench.php
new file mode 100644
index 0000000..49a3bab
--- /dev/null
+++ b/benchmarks/DotNotationBench.php
@@ -0,0 +1,92 @@
+ */
+ private array $target = [];
+
+ /** @param array{path:string} $params */
+ public function setUp(array $params): void
+ {
+ $this->path = $params['path'];
+ $this->target = [
+ 'plain' => 1,
+ 'one' => ['two' => ['three' => ['four' => ['five' => ['six' => ['seven' => ['eight' => ['nine' => ['ten' => 10]]]]]]]]],
+ 'service.name' => 'escaped',
+ 'users' => [
+ ['name' => 'Alice', 'teams' => [['name' => 'A'], ['name' => 'B']]],
+ ['name' => 'Bob', 'teams' => [['name' => 'C'], ['name' => 'D']]],
+ ],
+ ];
+
+ DotNotation::get($this->target, $this->path);
+ }
+
+ public function benchColdMissingPath(): void
+ {
+ DotNotation::get($this->target, 'cold.' . $this->coldPath++ . '.missing');
+ }
+
+ public function benchFill(): void
+ {
+ $target = $this->target;
+ DotNotation::fill($target, 'one.two.filled', true);
+ }
+
+ public function benchForget(): void
+ {
+ $target = $this->target;
+ DotNotation::forget($target, 'users.*.teams.*.name');
+ }
+
+ public function benchNativeNestedGet(): void
+ {
+ $value = $this->target['one']['two']['three']['four']['five'] ?? null;
+ }
+
+ public function benchSet(): void
+ {
+ $target = $this->target;
+ DotNotation::set($target, 'users.*.teams.*.active', true);
+ }
+
+ public function benchWarmGet(): void
+ {
+ DotNotation::get($this->target, $this->path);
+ }
+
+ /** @return array */
+ public function providePaths(): array
+ {
+ return [
+ 'plain' => ['path' => 'plain'],
+ 'one-segment' => ['path' => 'one'],
+ 'three-segments' => ['path' => 'one.two.three'],
+ 'five-segments' => ['path' => 'one.two.three.four.five'],
+ 'ten-segments' => ['path' => 'one.two.three.four.five.six.seven.eight.nine.ten'],
+ 'escaped' => ['path' => 'service\\.name'],
+ 'wildcard' => ['path' => 'users.*.name'],
+ 'nested-wildcard' => ['path' => 'users.*.teams.*.name'],
+ 'first' => ['path' => 'users.{first}.name'],
+ 'last' => ['path' => 'users.{last}.name'],
+ ];
+ }
+}
diff --git a/benchmarks/EnvParserBench.php b/benchmarks/EnvParserBench.php
new file mode 100644
index 0000000..b3dcc13
--- /dev/null
+++ b/benchmarks/EnvParserBench.php
@@ -0,0 +1,60 @@
+ 'KEY_' . $index . '=value # comment',
+ 'escape-heavy' => 'KEY_' . $index . '="line\\n\\t\\"' . $index . '"',
+ 'interpolation' => 'KEY_' . $index . '=${BASE}-' . $index,
+ 'quoted' => 'KEY_' . $index . '="quoted value ' . $index . '"',
+ default => 'KEY_' . $index . '=value-' . $index,
+ };
+ }
+
+ $this->contents = implode("\n", $lines);
+ }
+
+ public function benchParse(): void
+ {
+ EnvParser::parse($this->contents);
+ }
+
+ public function benchParseRaw(): void
+ {
+ EnvParser::parseRaw($this->contents);
+ }
+
+ /** @return array */
+ public function provideInputs(): array
+ {
+ $workloads = [];
+ foreach ([10, 100, 1000, 10000] as $lines) {
+ foreach (['plain', 'quoted', 'interpolation', 'comments', 'escape-heavy'] as $mode) {
+ $workloads[$lines . '-' . $mode] = ['lines' => $lines, 'mode' => $mode];
+ }
+ }
+
+ return $workloads;
+ }
+}
diff --git a/benchmarks/LazyFileConfigBench.php b/benchmarks/LazyFileConfigBench.php
new file mode 100644
index 0000000..2be78f0
--- /dev/null
+++ b/benchmarks/LazyFileConfigBench.php
@@ -0,0 +1,122 @@
+sourceDirectory = $base . DIRECTORY_SEPARATOR . 'source';
+ $this->cacheDirectory = $base . DIRECTORY_SEPARATOR . 'cache';
+ mkdir($this->sourceDirectory, 0777, true);
+ mkdir($this->cacheDirectory, 0777, true);
+ file_put_contents(
+ $this->sourceDirectory . DIRECTORY_SEPARATOR . 'app.php',
+ " 'ArrayKit', 'nested' => ['value' => 42]];\n",
+ );
+
+ $warmer = new LazyFileConfig($this->sourceDirectory, 'php', [], $this->cacheDirectory);
+ $warmer->warmNamespaceCache('app');
+
+ $this->flatConfig = new LazyFileConfig($this->sourceDirectory, 'php', [], $this->cacheDirectory);
+ $this->namespaceCacheConfig = new LazyFileConfig($this->sourceDirectory, 'php', [], $this->cacheDirectory);
+ $this->loadedConfig = new LazyFileConfig($this->sourceDirectory, 'php', [], $this->cacheDirectory);
+ $this->loadedConfig->preload('app');
+ $this->sourceConfig = new LazyFileConfig($this->sourceDirectory);
+ $this->missingConfig = new LazyFileConfig($this->sourceDirectory);
+ $this->warmConfig = new LazyFileConfig($this->sourceDirectory, 'php', [], $this->cacheDirectory);
+ }
+
+ public function tearDown(): void
+ {
+ foreach (['app.php', '__flat.php'] as $file) {
+ $cachePath = $this->cacheDirectory . DIRECTORY_SEPARATOR . $file;
+ if (is_file($cachePath)) {
+ unlink($cachePath);
+ }
+ }
+
+ $sourcePath = $this->sourceDirectory . DIRECTORY_SEPARATOR . 'app.php';
+ if (is_file($sourcePath)) {
+ unlink($sourcePath);
+ }
+
+ if (is_dir($this->cacheDirectory)) {
+ rmdir($this->cacheDirectory);
+ }
+ if (is_dir($this->sourceDirectory)) {
+ rmdir($this->sourceDirectory);
+ }
+
+ $base = dirname($this->sourceDirectory);
+ if (is_dir($base)) {
+ rmdir($base);
+ }
+ }
+
+ public function benchAlreadyLoadedNamespace(): void
+ {
+ $this->loadedConfig->get('app.nested.value');
+ }
+
+ public function benchCacheWarm(): void
+ {
+ $this->warmConfig->warmNamespaceCache('app');
+ }
+
+ public function benchFlatLeafHit(): void
+ {
+ $this->flatConfig->get('app.nested.value');
+ }
+
+ public function benchMissingNamespace(): void
+ {
+ $this->missingConfig->get('missing.value');
+ }
+
+ public function benchNamespaceCacheLoad(): void
+ {
+ $this->namespaceCacheConfig->get('app');
+ }
+
+ public function benchRepeatedMissingLookup(): void
+ {
+ for ($index = 0; $index < 100; $index++) {
+ $this->missingConfig->get('missing.value');
+ }
+ }
+
+ public function benchSourceLoad(): void
+ {
+ $this->sourceConfig->get('app.nested.value');
+ }
+}
diff --git a/composer.json b/composer.json
index 890b2ee..e60268a 100644
--- a/composer.json
+++ b/composer.json
@@ -23,7 +23,7 @@
"ext-hash": "*"
},
"require-dev": {
- "infocyph/phpforge": "dev-main"
+ "infocyph/phpforge": "dev-main@dev"
},
"minimum-stability": "stable",
"prefer-stable": true,
diff --git a/docs/array-helpers.rst b/docs/array-helpers.rst
index e5034b9..58dc427 100644
--- a/docs/array-helpers.rst
+++ b/docs/array-helpers.rst
@@ -19,6 +19,10 @@ If you prefer one entry point, use ``Infocyph\ArrayKit\ArrayKit``:
$flat = ArrayKit::multi()->flatten([[1], [2, [3]]]);
$wrapped = ArrayKit::helper()->wrap('x');
+``wrap()`` returns ``[]`` only for ``null``, leaves arrays unchanged, and wraps
+every other value. Falsey values such as ``false``, ``0``, ``0.0``, ``'0'``,
+and ``''`` therefore remain data and become one-element arrays.
+
Choosing the Right Helper
-------------------------
@@ -200,6 +204,12 @@ ArrayMulti: Grouping, Ordering, and Projection
$scores = ArrayMulti::pluck($rows, 'score'); // [10,30,20]
$transposed = ArrayMulti::transpose($rows);
+For ``groupBy()``, ``keyBy()`` / ``indexBy()``, and ``countBy()``, rows missing
+the requested field are skipped. Derived keys must be strings or integers;
+``null``, booleans, floats, arrays, and objects raise
+``InvalidArgumentException``. Literal strings such as ``'_undefined'`` and
+``''`` are valid keys and are never used as missing-value sentinels.
+
ArrayMulti: Row Set Operations
------------------------------
@@ -227,6 +237,11 @@ ArrayMulti: Row Set Operations
$sumById = ArrayMulti::sum($rows, 'id');
$sumByCallback = ArrayMulti::sum($rows, fn ($row, $key) => $row['id'] + $key);
+Strict ``uniqueBy()`` and ``duplicatesBy()`` preserve PHP strict equality,
+original row order, and original keys. Their verified fingerprint buckets avoid
+quadratic scans for ordinary values while retaining collision checks and a safe
+fallback for values that cannot be fingerprinted, including ``NAN``.
+
ArrayShape Validation
---------------------
diff --git a/docs/collection.rst b/docs/collection.rst
index b2c1b7d..dd7413b 100644
--- a/docs/collection.rst
+++ b/docs/collection.rst
@@ -15,6 +15,11 @@ Available classes:
- ``Infocyph\ArrayKit\Collection\Pipeline``
- ``Infocyph\ArrayKit\Collection\LazyCollection``
+Choose the lowest-cost surface that fits the job: direct ``ArraySingle`` /
+``ArrayMulti`` static calls for hot loops, ``Collection`` or ``Pipeline`` for a
+convenient mutable chain, and ``LazyCollection`` for streaming or very large
+iterables where intermediate materialization should be avoided.
+
Creating Collections
--------------------
@@ -315,6 +320,8 @@ Behavior Notes
--------------
- Most pipeline methods return the underlying ``Collection`` for chaining.
+- Collection iteration is provided by ``IteratorAggregate``; manual internal
+ pointer methods are intentionally not part of the API.
- Terminal methods return scalar/array/bool and stop the chain.
- Dot-notation works in collection accessors and in ``HookedCollection`` get/set overrides.
- ``merge()`` follows PHP ``array_merge`` semantics (string-key overwrite, numeric append/reindex).
diff --git a/docs/config.rst b/docs/config.rst
index 95ff50c..1069d4a 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -447,7 +447,10 @@ Config methods:
- ``readonly()``, ``isReadonly()``
Read memoization is bounded to 1,024 resolved paths for predictable memory use
-in persistent workers. Mutations and reloads invalidate the memoized values.
+in persistent workers. Plain top-level string and integer keys use direct array
+lookup instead of entering the path cache; nested, escaped, and wildcard paths
+remain memoized. Every mutation family, restore, and reload invalidates the
+memoized values.
Hook-aware methods:
diff --git a/docs/dot-notation.rst b/docs/dot-notation.rst
index cb513fc..0dfe306 100644
--- a/docs/dot-notation.rst
+++ b/docs/dot-notation.rst
@@ -260,5 +260,7 @@ Behavior Notes
- Missing integer keys return the provided default.
- Defaults may be plain values or callables, and callables are only evaluated when path resolution fails.
- Wildcard traversal in ``get`` returns arrays of matched results.
-- ``set`` supports wildcard paths when wildcard is the first segment.
+- ``set`` supports wildcards at any path depth, including multiple wildcards.
- ``forget`` supports wildcard and nested removal across arrays.
+- Compiled paths are kept in a bounded cache, and traversal advances a segment
+ cursor without copying the remaining path on each step.
diff --git a/docs/migration.rst b/docs/migration.rst
index f628e37..b67de8c 100644
--- a/docs/migration.rst
+++ b/docs/migration.rst
@@ -34,6 +34,20 @@ Compatibility Notes
- Pipeline methods are mutable by design: most transformation methods update the same collection instance and return it.
- Use ``copy()`` or ``immutable()`` before pipeline operations when functional immutability is preferred.
+Behavior Changes
+----------------
+
+- ``wrap()`` now treats only ``null`` as absence. Falsey non-null scalars are
+ wrapped instead of being discarded.
+- Seeded ``ArraySingle::shuffle()`` uses an isolated randomizer and no longer
+ reseeds or advances PHP's global Mersenne Twister state.
+- ``groupBy()``, ``keyBy()`` / ``indexBy()``, and ``countBy()`` skip rows whose
+ derived field is missing. Present values must produce integer or string keys;
+ ``null`` and other invalid key types now throw ``InvalidArgumentException``.
+- ``Collection`` relies on ``IteratorAggregate``. Calls to the former manual
+ pointer surface (``current()``, ``key()``, ``next()``, ``rewind()``, and
+ ``valid()``) should be replaced with ``foreach`` or ``getIterator()``.
+
Recommended Upgrade Checklist
-----------------------------
diff --git a/docs/rule-reference.rst b/docs/rule-reference.rst
index edeaec8..6793681 100644
--- a/docs/rule-reference.rst
+++ b/docs/rule-reference.rst
@@ -197,7 +197,7 @@ ArraySingle
public static function isNegative(array $array): bool
public static function shuffle(array $array, ?int $seed = null): array
public static function isInt(array $array): bool
- public static function nonEmpty(array $array): array
+ public static function nonEmpty(array $array, bool $preserveKeys = false): array
public static function avg(array $array): float|int
public static function isUnique(array $array): bool
public static function positive(array $array): array
@@ -387,11 +387,6 @@ Collection uses ``BaseCollectionTrait``. Public API:
public function offsetSet(mixed $offset, mixed $value): void
public function offsetUnset(mixed $offset): void
public function getIterator(): Traversable
- public function current(): mixed
- public function key(): string|int|null
- public function next(): void
- public function valid(): bool
- public function rewind(): void
public function count(): int
public function jsonSerialize(): array
@@ -532,6 +527,7 @@ LazyFileConfig
--------------------------------------
LazyFileConfig loads top-level config files on first keyed access:
+Calling ``all()`` throws by design because lazy configuration requires a key.
.. code-block:: php
@@ -549,7 +545,7 @@ LazyFileConfig loads top-level config files on first keyed access:
public function namespaceCacheDirectory(): ?string
public function warmNamespaceCache(string|array|null $namespaces = null): static
public function flushNamespaceCache(string|array|null $namespaces = null): static
- public function all(): array // throws (design choice)
+ public function all(): array
Config Hook-Aware Variants
--------------------------------------
diff --git a/src/Array/ArraySingle.php b/src/Array/ArraySingle.php
index b0c2ded..ccc2091 100644
--- a/src/Array/ArraySingle.php
+++ b/src/Array/ArraySingle.php
@@ -5,6 +5,8 @@
namespace Infocyph\ArrayKit\Array;
use InvalidArgumentException;
+use Random\Engine\Mt19937;
+use Random\Randomizer;
class ArraySingle
{
@@ -800,28 +802,20 @@ public static function separate(array $array): array
}
/**
- * Randomly shuffles the elements in the given array.
+ * Shuffle values, using isolated deterministic state when a seed is given.
*
- * If no seed is given, the internal PHP random number generator is used.
- * If a seed is given, the Mersenne Twister random number generator is
- * seeded with the given value, used to shuffle the array, and then reset
- * to the current internal PHP random number generator seed.
- *
- * @param array $array The array to shuffle.
- * @param int|null $seed Optional seed for the Mersenne Twister.
- * @return array The shuffled array.
+ * @param array $array
+ * @return array
*/
public static function shuffle(array $array, ?int $seed = null): array
{
if ($seed === null) {
- \shuffle($array);
- } else {
- \mt_srand($seed);
- \shuffle($array);
- \mt_srand();
+ shuffle($array);
+
+ return $array;
}
- return $array;
+ return new Randomizer(new Mt19937($seed))->shuffleArray($array);
}
/**
diff --git a/src/Array/ArrayValueSetOps.php b/src/Array/ArrayValueSetOps.php
index 176ccf8..6a28a81 100644
--- a/src/Array/ArrayValueSetOps.php
+++ b/src/Array/ArrayValueSetOps.php
@@ -134,6 +134,40 @@ public static function same(array $left, array $right, bool $strict): bool
return $leftCounts === $rightCounts;
}
+ /**
+ * Track strict membership with canonical fingerprints and a safe scan fallback.
+ *
+ * Long fingerprints use verified digest buckets, so a digest collision cannot
+ * change equality. Values such as NaN that are not reflexive use PHP's strict
+ * comparison semantics in the fallback bucket.
+ *
+ * @param array $seen
+ * @param array> $digestBuckets
+ * @param array $fallback
+ */
+ public static function strictValueAlreadySeen(
+ mixed $value,
+ array &$seen,
+ array &$digestBuckets,
+ array &$fallback,
+ ): bool {
+ if (!self::isStrictHashable($value)) {
+ if (in_array($value, $fallback, true)) {
+ return true;
+ }
+
+ $fallback[] = $value;
+
+ return false;
+ }
+
+ return self::fingerprintAlreadySeen(
+ $seen,
+ $digestBuckets,
+ self::fingerprintStrict($value),
+ );
+ }
+
/**
* @param array $array
* @return array
@@ -147,16 +181,12 @@ public static function unique(array $array, bool $strict): array
return $unique;
}
- if (!self::allStrictHashable($array)) {
- return self::uniqueByScan($array);
- }
-
$seen = [];
$digestBuckets = [];
+ $fallback = [];
$result = [];
foreach ($array as $key => $item) {
- $fingerprint = self::fingerprintStrict($item);
- if (self::fingerprintAlreadySeen($seen, $digestBuckets, $fingerprint)) {
+ if (self::strictValueAlreadySeen($item, $seen, $digestBuckets, $fallback)) {
continue;
}
@@ -411,25 +441,4 @@ private static function sameByScan(array $left, array $right, bool $strict): boo
return true;
}
-
- /**
- * @param array $array
- * @return array
- */
- private static function uniqueByScan(array $array): array
- {
- $seen = [];
- $result = [];
-
- foreach ($array as $key => $item) {
- if (in_array($item, $seen, true)) {
- continue;
- }
-
- $seen[] = $item;
- $result[$key] = $item;
- }
-
- return $result;
- }
}
diff --git a/src/Array/BaseArrayHelper.php b/src/Array/BaseArrayHelper.php
index 3c3488a..d9b058f 100644
--- a/src/Array/BaseArrayHelper.php
+++ b/src/Array/BaseArrayHelper.php
@@ -331,16 +331,13 @@ public static function unWrap(mixed $value): mixed
}
/**
- * Wrap a value in an array if it's not already an array; otherwise return the array as is.
+ * Wrap a non-null value without discarding valid falsey data.
*
- * If the value is empty, an empty array is returned.
- *
- * @param mixed $value The value to wrap.
- * @return array The wrapped value.
+ * @return array
*/
public static function wrap(mixed $value): array
{
- if (empty($value)) {
+ if ($value === null) {
return [];
}
diff --git a/src/Array/Concerns/ArrayMultiQuerySortTrait.php b/src/Array/Concerns/ArrayMultiQuerySortTrait.php
index ecd0ca4..fd9cff8 100644
--- a/src/Array/Concerns/ArrayMultiQuerySortTrait.php
+++ b/src/Array/Concerns/ArrayMultiQuerySortTrait.php
@@ -7,6 +7,8 @@
use Infocyph\ArrayKit\Array\ArraySharedOps;
use Infocyph\ArrayKit\Array\ArraySingle;
use Infocyph\ArrayKit\Array\ArraySingleOps;
+use Infocyph\ArrayKit\Array\ArrayValueSetOps;
+use InvalidArgumentException;
use function Infocyph\ArrayKit\compare;
@@ -44,14 +46,16 @@ public static function between(array $array, string $key, float|int $from, float
public static function countBy(array $array, string|callable $groupBy): array
{
$counts = [];
+ $useCallback = is_callable($groupBy);
foreach ($array as $key => $row) {
- $bucket = is_callable($groupBy)
- ? $groupBy($row, $key)
- : ((is_array($row) && array_key_exists($groupBy, $row)) ? $row[$groupBy] : '_undefined');
+ if (!$useCallback && (!is_array($row) || !array_key_exists($groupBy, $row))) {
+ continue;
+ }
- $normalized = self::normalizeArrayKey($bucket);
- $counts[$normalized] = ($counts[$normalized] ?? 0) + 1;
+ $bucket = $useCallback ? $groupBy($row, $key) : $row[$groupBy];
+ $arrayKey = self::requireArrayKey($bucket, 'countBy');
+ $counts[$arrayKey] = ($counts[$arrayKey] ?? 0) + 1;
}
return $counts;
@@ -130,16 +134,15 @@ public static function firstWhereIn(array $array, string $key, array $values, bo
public static function groupBy(array $array, string|callable $groupBy, bool $preserveKeys = false): array
{
$results = [];
+ $useCallback = is_callable($groupBy);
+
foreach ($array as $key => $row) {
- $gKey = null;
- if (is_callable($groupBy)) {
- $gKey = $groupBy($row, $key);
- } elseif (is_array($row) && array_key_exists($groupBy, $row)) {
- $gKey = $row[$groupBy];
- } else {
- $gKey = '_undefined';
+ if (!$useCallback && (!is_array($row) || !array_key_exists($groupBy, $row))) {
+ continue;
}
- $groupKey = self::normalizeArrayKey($gKey);
+
+ $resolved = $useCallback ? $groupBy($row, $key) : $row[$groupBy];
+ $groupKey = self::requireArrayKey($resolved, 'groupBy');
if ($preserveKeys) {
$results[$groupKey][$key] = $row;
@@ -171,13 +174,15 @@ public static function indexBy(array $array, string|callable $indexBy): array
public static function keyBy(array $array, string|callable $keyBy): array
{
$results = [];
+ $useCallback = is_callable($keyBy);
foreach ($array as $index => $row) {
- $resolved = is_callable($keyBy)
- ? $keyBy($row, $index)
- : ((is_array($row) && array_key_exists($keyBy, $row)) ? $row[$keyBy] : '_undefined');
+ if (!$useCallback && (!is_array($row) || !array_key_exists($keyBy, $row))) {
+ continue;
+ }
- $results[self::normalizeArrayKey($resolved)] = $row;
+ $resolved = $useCallback ? $keyBy($row, $index) : $row[$keyBy];
+ $results[self::requireArrayKey($resolved, 'keyBy')] = $row;
}
return $results;
@@ -288,7 +293,7 @@ public static function pluck(array $array, string $column, ?string $indexBy = nu
$value = $row[$column];
if ($indexBy !== null && array_key_exists($indexBy, $row)) {
- $results[self::normalizeArrayKey($row[$indexBy])] = $value;
+ $results[self::requireArrayKey($row[$indexBy], 'pluck')] = $value;
} else {
$results[] = $value;
}
@@ -817,29 +822,46 @@ private static function collectByDerivedKey(
bool $strict,
bool $keepDuplicates,
): array {
- $seen = [];
$results = [];
+ $useCallback = is_callable($keyOrCallback);
- foreach ($array as $index => $row) {
- $derived = self::resolveDerivedValue($row, $keyOrCallback, $index);
- $alreadySeen = in_array($derived, $seen, $strict);
+ if (!$strict) {
+ $seen = [];
+ foreach ($array as $index => $row) {
+ $derived = $useCallback
+ ? self::invokeRowCallback($keyOrCallback, $row, $index)
+ : self::resolveDerivedValue($row, $keyOrCallback, $index);
+ $alreadySeen = in_array($derived, $seen, false);
- if ($keepDuplicates) {
- if ($alreadySeen) {
+ if ($alreadySeen === $keepDuplicates) {
$results[$index] = $row;
- } else {
+ }
+ if (!$alreadySeen) {
$seen[] = $derived;
}
-
- continue;
}
- if ($alreadySeen) {
- continue;
- }
+ return $results;
+ }
- $seen[] = $derived;
- $results[$index] = $row;
+ $seen = [];
+ $digestBuckets = [];
+ $fallback = [];
+
+ foreach ($array as $index => $row) {
+ $derived = $useCallback
+ ? self::invokeRowCallback($keyOrCallback, $row, $index)
+ : self::resolveDerivedValue($row, $keyOrCallback, $index);
+ $alreadySeen = ArrayValueSetOps::strictValueAlreadySeen(
+ $derived,
+ $seen,
+ $digestBuckets,
+ $fallback,
+ );
+
+ if ($alreadySeen === $keepDuplicates) {
+ $results[$index] = $row;
+ }
}
return $results;
@@ -1082,6 +1104,17 @@ private static function prepareSortByManyCriteria(array $array, array $criteria)
return $prepared;
}
+ private static function requireArrayKey(mixed $value, string $operation): int|string
+ {
+ if (is_int($value) || is_string($value)) {
+ return $value;
+ }
+
+ throw new InvalidArgumentException(
+ $operation . ' derived key must be an integer or string; ' . get_debug_type($value) . ' given.',
+ );
+ }
+
private static function resolveDerivedValue(mixed $row, string|callable $keyOrCallback, int|string $index): mixed
{
if (is_callable($keyOrCallback)) {
diff --git a/src/Array/DotNotation.php b/src/Array/DotNotation.php
index ea369f6..1050097 100644
--- a/src/Array/DotNotation.php
+++ b/src/Array/DotNotation.php
@@ -31,28 +31,27 @@ private static function flattenInto(array $array, string $prepend, array &$resul
* @param array $array
* @param array $segments
*/
- private static function forgetBySegments(array &$array, array $segments): void
+ private static function forgetBySegments(array &$array, array $segments, int $position = 0): void
{
- if ($segments === []) {
+ $segmentCount = count($segments);
+ if ($position >= $segmentCount) {
return;
}
- $segment = self::shiftSegment($segments);
- if ($segment === null) {
- return;
- }
+ $segment = $segments[$position];
+ $next = $position + 1;
if ($segment === '*') {
- if ($segments !== []) {
- self::forgetEach($array, $segments);
+ if ($next < $segmentCount) {
+ self::forgetEach($array, $segments, $next);
}
return;
}
$normalized = self::unescapeSegment($segment);
- if ($segments !== [] && ArraySingle::exists($array, $normalized) && is_array($array[$normalized])) {
- self::forgetBySegments($array[$normalized], $segments);
+ if ($next < $segmentCount && ArraySingle::exists($array, $normalized) && is_array($array[$normalized])) {
+ self::forgetBySegments($array[$normalized], $segments, $next);
return;
}
@@ -66,11 +65,11 @@ private static function forgetBySegments(array &$array, array $segments): void
* @param array $array
* @param array $segments
*/
- private static function forgetEach(array &$array, array $segments): void
+ private static function forgetEach(array &$array, array $segments, int $position): void
{
foreach ($array as &$inner) {
if (is_array($inner)) {
- self::forgetBySegments($inner, $segments);
+ self::forgetBySegments($inner, $segments, $position);
}
}
}
@@ -97,16 +96,19 @@ private static function getValueSafe(
/**
* Sets values in the target using dot-notation with wildcard support.
*
+ * @param array $target
* @param array $segments
*/
- private static function handleWildcardSet(mixed &$target, array $segments, mixed $value, bool $overwrite): void
- {
- if (!is_array($target)) {
- $target = [];
- }
- if (!empty($segments)) {
+ private static function handleWildcardSet(
+ array &$target,
+ array $segments,
+ int $position,
+ mixed $value,
+ bool $overwrite,
+ ): void {
+ if ($position < count($segments)) {
foreach ($target as &$inner) {
- self::setValueBySegments($inner, $segments, $value, $overwrite);
+ self::setValueBySegments($inner, $segments, $position, $value, $overwrite);
}
} elseif ($overwrite) {
foreach ($target as &$inner) {
@@ -178,26 +180,15 @@ private static function segmentExact(mixed $array, string $path, mixed $default)
private static function setValue(array &$target, string $key, mixed $value, bool $overwrite): void
{
$segments = self::splitPath($key);
- $first = self::shiftSegment($segments);
- if ($first === null) {
- return;
- }
+ $segment = $segments[0];
- if ($first === '*') {
- if ($segments !== []) {
- foreach ($target as &$inner) {
- self::setValueBySegments($inner, $segments, $value, $overwrite);
- }
- } elseif ($overwrite) {
- foreach ($target as &$inner) {
- $inner = $value;
- }
- }
+ if ($segment === '*') {
+ self::handleWildcardSet($target, $segments, 1, $value, $overwrite);
return;
}
- self::setValueArray($target, $first, $segments, $value, $overwrite);
+ self::setValueArray($target, $segment, $segments, 1, $value, $overwrite);
}
/**
@@ -206,15 +197,22 @@ private static function setValue(array &$target, string $key, mixed $value, bool
* @param array &$target
* @param array $segments
*/
- private static function setValueArray(array &$target, string $segment, array $segments, mixed $value, bool $overwrite): void
- {
+ private static function setValueArray(
+ array &$target,
+ string $segment,
+ array $segments,
+ int $position,
+ mixed $value,
+ bool $overwrite,
+ ): void {
$segment = self::unescapeSegment($segment);
- if (!empty($segments)) {
+ if ($position < count($segments)) {
if (!ArraySingle::exists($target, $segment)) {
$target[$segment] = [];
}
- self::setValueBySegments($target[$segment], $segments, $value, $overwrite);
+
+ self::setValueBySegments($target[$segment], $segments, $position, $value, $overwrite);
} else {
if ($overwrite || !ArraySingle::exists($target, $segment)) {
$target[$segment] = $value;
@@ -225,29 +223,36 @@ private static function setValueArray(array &$target, string $segment, array $se
/**
* @param array $segments
*/
- private static function setValueBySegments(mixed &$target, array $segments, mixed $value, bool $overwrite): void
- {
- if ($segments === []) {
+ private static function setValueBySegments(
+ mixed &$target,
+ array $segments,
+ int $position,
+ mixed $value,
+ bool $overwrite,
+ ): void {
+ if ($position >= count($segments)) {
return;
}
- $first = self::shiftSegment($segments);
- if ($first === null) {
- return;
- }
+ $segment = $segments[$position];
+ $next = $position + 1;
- if ($first === '*') {
- self::handleWildcardSet($target, $segments, $value, $overwrite);
+ if ($segment === '*') {
+ if (!is_array($target)) {
+ $target = [];
+ }
+
+ self::handleWildcardSet($target, $segments, $next, $value, $overwrite);
return;
}
if (is_array($target)) {
- self::setValueArray($target, $first, $segments, $value, $overwrite);
+ self::setValueArray($target, $segment, $segments, $next, $value, $overwrite);
} elseif (is_object($target)) {
- self::setValueObject($target, $first, $segments, $value, $overwrite);
+ self::setValueObject($target, $segment, $segments, $next, $value, $overwrite);
} else {
- self::setValueFallback($target, $first, $segments, $value, $overwrite);
+ self::setValueFallback($target, $segment, $segments, $next, $value, $overwrite);
}
}
@@ -256,12 +261,18 @@ private static function setValueBySegments(mixed &$target, array $segments, mixe
*
* @param array $segments
*/
- private static function setValueFallback(mixed &$target, string $segment, array $segments, mixed $value, bool $overwrite): void
- {
+ private static function setValueFallback(
+ mixed &$target,
+ string $segment,
+ array $segments,
+ int $position,
+ mixed $value,
+ bool $overwrite,
+ ): void {
$segment = self::unescapeSegment($segment);
$target = [];
- if (!empty($segments)) {
- self::setValueBySegments($target[$segment], $segments, $value, $overwrite);
+ if ($position < count($segments)) {
+ self::setValueBySegments($target[$segment], $segments, $position, $value, $overwrite);
} elseif ($overwrite) {
$target[$segment] = $value;
}
@@ -272,16 +283,23 @@ private static function setValueFallback(mixed &$target, string $segment, array
*
* @param array $segments
*/
- private static function setValueObject(object &$target, string $segment, array $segments, mixed $value, bool $overwrite): void
- {
+ private static function setValueObject(
+ object &$target,
+ string $segment,
+ array $segments,
+ int $position,
+ mixed $value,
+ bool $overwrite,
+ ): void {
$segment = self::unescapeSegment($segment);
$propertyExists = property_exists($target, $segment);
- if (!empty($segments)) {
+ if ($position < count($segments)) {
if (!$propertyExists) {
$target->{$segment} = [];
}
- self::setValueBySegments($target->{$segment}, $segments, $value, $overwrite);
+
+ self::setValueBySegments($target->{$segment}, $segments, $position, $value, $overwrite);
} else {
if ($overwrite || !$propertyExists) {
$target->{$segment} = $value;
@@ -289,21 +307,6 @@ private static function setValueObject(object &$target, string $segment, array $
}
}
- /**
- * @param array $segments
- */
- private static function shiftSegment(array &$segments): ?string
- {
- if ($segments === []) {
- return null;
- }
-
- $segment = $segments[0] ?? null;
- array_shift($segments);
-
- return is_string($segment) ? $segment : null;
- }
-
/**
* Parse a dot path into escaped segments and cache compiled segments.
*
diff --git a/src/Array/DotNotationPathOps.php b/src/Array/DotNotationPathOps.php
index ef85b1d..b8570a8 100644
--- a/src/Array/DotNotationPathOps.php
+++ b/src/Array/DotNotationPathOps.php
@@ -143,9 +143,11 @@ public static function traverseGet(
bool $throwOnTooDeep = false,
int $currentDepth = 1,
int &$visitedNodes = 0,
+ int $position = 0,
): mixed {
- foreach ($segments as $index => $segment) {
- unset($segments[$index]);
+ $segmentCount = count($segments);
+ for ($index = $position; $index < $segmentCount; $index++) {
+ $segment = $segments[$index];
$visitedNodes++;
if ($maxNodes > 0 && $visitedNodes > $maxNodes) {
@@ -164,6 +166,7 @@ public static function traverseGet(
$throwOnTooDeep,
$currentDepth,
$visitedNodes,
+ $index + 1,
);
}
@@ -210,6 +213,21 @@ private static function handleTraversalLimit(object $missing, bool $throwOnTooDe
return $missing;
}
+ /**
+ * @param array $segments
+ */
+ private static function hasWildcardFrom(array $segments, int $position): bool
+ {
+ $segmentCount = count($segments);
+ for ($index = $position; $index < $segmentCount; $index++) {
+ if ($segments[$index] === '*') {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/**
* Resolve the {first} segment for an array-like target.
*/
@@ -269,6 +287,7 @@ private static function traverseWildcard(
bool $throwOnTooDeep,
int $currentDepth,
int &$visitedNodes,
+ int $position,
): mixed {
$target = is_object($target) && method_exists($target, 'all') ? $target->all() : $target;
@@ -289,10 +308,12 @@ private static function traverseWildcard(
$throwOnTooDeep,
$currentDepth,
$visitedNodes,
+ $position,
);
$result[] = $resolved === $missing ? $defaultResolver($default) : $resolved;
}
- if (in_array('*', $segments, true)) {
+
+ if (self::hasWildcardFrom($segments, $position)) {
$result = ArrayMulti::collapse($result);
}
diff --git a/src/Collection/Concerns/BaseCollectionTrait.php b/src/Collection/Concerns/BaseCollectionTrait.php
index 06b3566..291fff2 100644
--- a/src/Collection/Concerns/BaseCollectionTrait.php
+++ b/src/Collection/Concerns/BaseCollectionTrait.php
@@ -219,18 +219,6 @@ public function count(): int
return count($this->data);
}
- /**
- * Returns the current element in the collection.
- *
- * This is part of the Iterator interface.
- *
- * @return mixed The current element in the collection.
- */
- public function current(): mixed
- {
- return current($this->data);
- }
-
/**
* Retrieve an item from the collection by key or keys.
*
@@ -380,19 +368,6 @@ public function jsonSerialize(): array
);
}
- /**
- * Return the key of the current element.
- *
- * This is part of the Iterator interface.
- *
- * @return string|int|null The key of the current element, or null if the
- * internal pointer is not valid.
- */
- public function key(): string|int|null
- {
- return key($this->data);
- }
-
/**
* Return an array of all the keys in the collection.
*
@@ -415,16 +390,6 @@ public function merge(mixed $items): static
return $this;
}
- /**
- * Advances the internal pointer to the next element.
- *
- * This is part of the Iterator interface.
- */
- public function next(): void
- {
- next($this->data);
- }
-
/*
|--------------------------------------------------------------------------
| ArrayAccess Interface
@@ -549,16 +514,6 @@ public function process(): Pipeline
return $this->pipeline ??= new Pipeline($this->data, $this);
}
- /**
- * Rewinds the internal pointer of the collection to the first element.
- *
- * This is part of the Iterator interface.
- */
- public function rewind(): void
- {
- reset($this->data);
- }
-
/**
* Set one or multiple items in the collection using dot notation.
*
@@ -596,16 +551,4 @@ public function toJson(int $options = 0): string
return $json === false ? 'null' : $json;
}
-
- /**
- * Checks if the current element is valid.
- *
- * This is part of the Iterator interface.
- *
- * @return bool True if the current element is valid, false otherwise.
- */
- public function valid(): bool
- {
- return key($this->data) !== null;
- }
}
diff --git a/src/Config/Concerns/BaseConfigTrait.php b/src/Config/Concerns/BaseConfigTrait.php
index 82df72f..8db5ad3 100644
--- a/src/Config/Concerns/BaseConfigTrait.php
+++ b/src/Config/Concerns/BaseConfigTrait.php
@@ -608,6 +608,15 @@ protected function resolveDefault(mixed $default): mixed
protected function resolveRawValue(int|string $key): mixed
{
+ if (
+ is_int($key)
+ || (!str_contains($key, '.') && !str_contains($key, '\\'))
+ ) {
+ return array_key_exists($key, $this->items)
+ ? $this->items[$key]
+ : $this->missingValueMarker();
+ }
+
if (!$this->readCacheEnabled) {
return DotNotation::get($this->items, $key, $this->missingValueMarker());
}
diff --git a/src/DTO/Concerns/DTOTrait.php b/src/DTO/Concerns/DTOTrait.php
index 308b527..ae6f171 100644
--- a/src/DTO/Concerns/DTOTrait.php
+++ b/src/DTO/Concerns/DTOTrait.php
@@ -16,8 +16,7 @@ trait DTOTrait
* Create a new instance of the using class and populate
* its public properties from the given array.
*
- * Unknown keys are ignored. Only properties matching
- * class property names will be set.
+ * Unknown keys are ignored. Matching public properties are assigned.
*
* @param array $values Key-value pairs matching property names
*/
diff --git a/tests/Feature/ApiSignatureTest.php b/tests/Feature/ApiSignatureTest.php
new file mode 100644
index 0000000..096b668
--- /dev/null
+++ b/tests/Feature/ApiSignatureTest.php
@@ -0,0 +1,167 @@
+ ArrayKit::class,
+ 'Facade ModuleProxy' => ModuleProxy::class,
+ 'BaseArrayHelper' => BaseArrayHelper::class,
+ 'ArraySharedOps (Internal)' => ArraySharedOps::class,
+ 'ArraySingle' => ArraySingle::class,
+ 'ArrayMulti' => ArrayMulti::class,
+ 'ArrayShape' => ArrayShape::class,
+ 'DotNotation' => DotNotation::class,
+ 'Collection' => Collection::class,
+ 'HookedCollection' => HookedCollection::class,
+ 'Pipeline' => Pipeline::class,
+ 'Config' => Config::class,
+ 'LazyFileConfig' => LazyFileConfig::class,
+ 'Config Hook-Aware Variants' => Config::class,
+ 'DTOTrait' => DTOTrait::class,
+ 'HookTrait' => HookTrait::class,
+ 'LazyCollection' => LazyCollection::class,
+ ];
+
+ $normalizeType = static function (?string $type, ?string $selfType = null): array {
+ if ($type === null || $type === '') {
+ return [];
+ }
+
+ $types = str_starts_with($type, '?')
+ ? [substr($type, 1), 'null']
+ : explode('|', $type);
+
+ $normalized = array_map(static function (string $part) use ($selfType): string {
+ $part = ltrim(trim($part), '\\');
+
+ if ($part === 'self' && $selfType !== null) {
+ return $selfType;
+ }
+
+ return str_contains($part, '\\') ? basename(str_replace('\\', '/', $part)) : $part;
+ }, $types);
+ sort($normalized);
+
+ return $normalized;
+ };
+
+ $reflectionType = static function (?ReflectionType $type, ?string $selfType = null) use ($normalizeType): array {
+ if ($type === null) {
+ return [];
+ }
+
+ if ($type instanceof ReflectionUnionType) {
+ $parts = array_map(static fn(ReflectionNamedType $part): string => $part->getName(), $type->getTypes());
+
+ return $normalizeType(implode('|', $parts), $selfType);
+ }
+
+ if ($type instanceof ReflectionIntersectionType) {
+ $parts = array_map(static fn(ReflectionNamedType $part): string => $part->getName(), $type->getTypes());
+ sort($parts);
+
+ return $parts;
+ }
+
+ $name = $type->getName();
+ if ($type->allowsNull() && $name !== 'mixed' && $name !== 'null') {
+ $name .= '|null';
+ }
+
+ return $normalizeType($name, $selfType);
+ };
+
+ $parseParameter = static function (string $parameter) use ($normalizeType): array {
+ $parameter = preg_replace('/^(?:(?:public|protected|private|readonly)\s+)+/', '', trim($parameter));
+ preg_match(
+ '/^(?:(?[?\\\\A-Za-z_][\\\\A-Za-z0-9_|?]*)\s+)?(?&)?(?\.\.\.)?\$(?[A-Za-z_][A-Za-z0-9_]*)(?:\s*=\s*(?.+))?$/',
+ (string) $parameter,
+ $matches,
+ );
+
+ return [
+ 'name' => $matches['name'] ?? '',
+ 'type' => $normalizeType($matches['type'] ?? null),
+ 'reference' => ($matches['reference'] ?? '') === '&',
+ 'variadic' => ($matches['variadic'] ?? '') === '...',
+ 'hasDefault' => array_key_exists('default', $matches) && $matches['default'] !== '',
+ 'default' => isset($matches['default']) ? ltrim(trim($matches['default']), '\\') : null,
+ ];
+ };
+
+ $document = file_get_contents(__DIR__ . '/../../docs/rule-reference.rst');
+ expect($document)->not->toBeFalse();
+ $lines = preg_split('/\R/', (string) $document);
+ expect($lines)->toBeArray();
+
+ $activeClass = null;
+ foreach ($lines as $index => $line) {
+ $nextLine = $lines[$index + 1] ?? '';
+ if (preg_match('/^-{3,}$/', $nextLine) === 1) {
+ $activeClass = $sections[$line] ?? null;
+
+ continue;
+ }
+
+ if ($activeClass === null || preg_match('/^\s+public (?static )?function (?[A-Za-z_][A-Za-z0-9_]*)\((?.*)\)(?:: (?[^\/]+))?/', $line, $matches) !== 1) {
+ continue;
+ }
+
+ $method = new ReflectionMethod($activeClass, $matches['name']);
+ $declaringType = $method->getDeclaringClass()->getShortName();
+ $documentedParameters = trim($matches['parameters']) === ''
+ ? []
+ : array_map($parseParameter, preg_split('/,\s*/', $matches['parameters']));
+ $actualParameters = $method->getParameters();
+
+ expect($method->isPublic())->toBeTrue($activeClass . '::' . $method->getName())
+ ->and($method->isStatic())->toBe(($matches['static'] ?? '') !== '', $activeClass . '::' . $method->getName())
+ ->and($documentedParameters)->toHaveCount(count($actualParameters), $activeClass . '::' . $method->getName())
+ ->and($reflectionType($method->getReturnType(), $declaringType))->toBe(
+ $normalizeType(isset($matches['return']) ? trim($matches['return']) : null, $declaringType),
+ $activeClass . '::' . $method->getName() . ' return type',
+ );
+
+ foreach ($actualParameters as $parameterIndex => $actual) {
+ $documented = $documentedParameters[$parameterIndex];
+ $actualDefault = null;
+ if ($actual->isDefaultValueAvailable()) {
+ if ($actual->isDefaultValueConstant()) {
+ $constantName = ltrim((string) $actual->getDefaultValueConstantName(), '\\');
+ $actualDefault = basename(str_replace('\\', '/', $constantName));
+ } elseif ($actual->getDefaultValue() === null) {
+ $actualDefault = 'null';
+ } elseif ($actual->getDefaultValue() === []) {
+ $actualDefault = '[]';
+ } else {
+ $actualDefault = var_export($actual->getDefaultValue(), true);
+ }
+ }
+
+ expect($documented['name'])->toBe($actual->getName(), $activeClass . '::' . $method->getName())
+ ->and($documented['type'])->toBe($reflectionType($actual->getType(), $declaringType), $activeClass . '::' . $method->getName() . ' $' . $actual->getName())
+ ->and($documented['reference'])->toBe($actual->isPassedByReference(), $activeClass . '::' . $method->getName() . ' $' . $actual->getName())
+ ->and($documented['variadic'])->toBe($actual->isVariadic(), $activeClass . '::' . $method->getName() . ' $' . $actual->getName())
+ ->and($documented['hasDefault'])->toBe($actual->isDefaultValueAvailable(), $activeClass . '::' . $method->getName() . ' $' . $actual->getName())
+ ->and($documented['default'])->toBe($actualDefault, $activeClass . '::' . $method->getName() . ' $' . $actual->getName());
+ }
+ }
+});
diff --git a/tests/Feature/ArrayMultiTest.php b/tests/Feature/ArrayMultiTest.php
index b7d6a6f..f2513dc 100644
--- a/tests/Feature/ArrayMultiTest.php
+++ b/tests/Feature/ArrayMultiTest.php
@@ -397,18 +397,42 @@
]);
});
-it('keeps null and missing grouping keys separate', function () {
+it('skips missing derived fields without colliding with literal user values', function () {
$rows = [
- ['id' => 1, 'role' => null],
- ['id' => 2],
+ ['id' => 1, 'role' => '_undefined'],
+ ['id' => 2, 'role' => ''],
+ ['id' => 3],
];
expect(ArrayMulti::groupBy($rows, 'role'))->toBe([
- '' => [['id' => 1, 'role' => null]],
- '_undefined' => [['id' => 2]],
+ '_undefined' => [['id' => 1, 'role' => '_undefined']],
+ '' => [['id' => 2, 'role' => '']],
+ ])->and(ArrayMulti::keyBy($rows, 'role'))->toBe([
+ '_undefined' => ['id' => 1, 'role' => '_undefined'],
+ '' => ['id' => 2, 'role' => ''],
+ ])->and(ArrayMulti::indexBy($rows, 'role'))->toBe([
+ '_undefined' => ['id' => 1, 'role' => '_undefined'],
+ '' => ['id' => 2, 'role' => ''],
+ ])->and(ArrayMulti::countBy($rows, 'role'))->toBe([
+ '_undefined' => 1,
+ '' => 1,
]);
});
+it('rejects null and other invalid derived array keys', function (mixed $invalid) {
+ $rows = [['id' => 1, 'group' => $invalid]];
+
+ expect(fn () => ArrayMulti::groupBy($rows, 'group'))->toThrow(InvalidArgumentException::class)
+ ->and(fn () => ArrayMulti::keyBy($rows, 'group'))->toThrow(InvalidArgumentException::class)
+ ->and(fn () => ArrayMulti::countBy($rows, 'group'))->toThrow(InvalidArgumentException::class);
+})->with([
+ 'null' => [null],
+ 'boolean' => [false],
+ 'float' => [1.5],
+ 'array' => [[]],
+ 'object' => [new stdClass()],
+]);
+
it('handles null values and missing keys correctly in whereNotIn()', function () {
$rows = [
['id' => 1, 'role' => null],
@@ -540,6 +564,47 @@
]);
});
+it('preserves strict derived equality for adversarial values', function () {
+ $firstObject = new stdClass();
+ $secondObject = new stdClass();
+ $resource = fopen('php://memory', 'rb');
+
+ $rows = [
+ ['value' => 0],
+ ['value' => '0'],
+ ['value' => false],
+ ['value' => 0.0],
+ ['value' => ['nested' => [1, '1']]],
+ ['value' => ['nested' => [1, '1']]],
+ ['value' => $firstObject],
+ ['value' => $firstObject],
+ ['value' => $secondObject],
+ ['value' => $resource],
+ ['value' => $resource],
+ ['value' => NAN],
+ ['value' => NAN],
+ ];
+
+ expect(array_keys(ArrayMulti::uniqueBy($rows, 'value', true)))
+ ->toBe([0, 1, 2, 3, 4, 6, 8, 9, 11, 12])
+ ->and(array_keys(ArrayMulti::duplicatesBy($rows, 'value', true)))
+ ->toBe([5, 7, 10]);
+
+ fclose($resource);
+});
+
+it('handles large strict derived sets without changing key order', function () {
+ $rows = [];
+ for ($index = 0; $index < 10000; $index++) {
+ $rows['row-' . $index] = [
+ 'derived' => ['id' => $index % 5000, 'payload' => str_repeat('x', 96)],
+ ];
+ }
+
+ expect(ArrayMulti::uniqueBy($rows, 'derived', true))->toHaveCount(5000)
+ ->and(array_key_first(ArrayMulti::duplicatesBy($rows, 'derived', true)))->toBe('row-5000');
+});
+
it('supports sortByMany with mixed sort directions', function () {
$rows = [
['team' => 'A', 'score' => 10, 'id' => 2],
diff --git a/tests/Feature/ArraySingleTest.php b/tests/Feature/ArraySingleTest.php
index 93a887b..878a04c 100644
--- a/tests/Feature/ArraySingleTest.php
+++ b/tests/Feature/ArraySingleTest.php
@@ -80,6 +80,23 @@
->and(ArraySingle::containsAny([INF], [-INF], true))->toBeFalse();
});
+it('isolates deterministic shuffle state from the global Mersenne Twister', function () {
+ $values = range(1, 20);
+
+ expect(ArraySingle::shuffle($values, 12345))
+ ->toBe(ArraySingle::shuffle($values, 12345))
+ ->not->toBe(ArraySingle::shuffle($values, 54321));
+
+ mt_srand(9876);
+ $first = mt_rand();
+ $second = mt_rand();
+
+ mt_srand(9876);
+ expect(mt_rand())->toBe($first);
+ ArraySingle::shuffle($values, 12345);
+ expect(mt_rand())->toBe($second);
+});
+
it('does not retry callbacks that throw argument count errors internally', function () {
$calls = 0;
$exception = null;
diff --git a/tests/Feature/BaseArrayHelperTest.php b/tests/Feature/BaseArrayHelperTest.php
index 145e32a..37a2244 100644
--- a/tests/Feature/BaseArrayHelperTest.php
+++ b/tests/Feature/BaseArrayHelperTest.php
@@ -15,6 +15,19 @@
expect($wrapped)->toBe(['hello']);
});
+it('preserves every falsey value except null when wrapping', function (mixed $value, array $expected) {
+ expect(BaseArrayHelper::wrap($value))->toBe($expected);
+})->with([
+ 'null' => [null, []],
+ 'empty array' => [[], []],
+ 'false' => [false, [false]],
+ 'integer zero' => [0, [0]],
+ 'float zero' => [0.0, [0.0]],
+ 'negative float zero' => [-0.0, [-0.0]],
+ 'numeric zero string' => ['0', ['0']],
+ 'empty string' => ['', ['']],
+]);
+
it('checks if at least one item meets a condition', function () {
$data = [1, 2, 3];
$res = BaseArrayHelper::haveAny($data, fn ($val) => $val > 2);
diff --git a/tests/Feature/ConfigTest.php b/tests/Feature/ConfigTest.php
index fb951c0..48280ac 100644
--- a/tests/Feature/ConfigTest.php
+++ b/tests/Feature/ConfigTest.php
@@ -173,6 +173,62 @@
->and($cfg->get('app.name'))->toBe('ArrayKitX');
});
+it('bypasses memoization for direct top-level reads', function () {
+ $cfg = new Config;
+ $cfg->loadArray([
+ 'debug' => false,
+ 'zero' => 0,
+ 'nullable' => null,
+ ]);
+
+ for ($index = 0; $index < 100; $index++) {
+ expect($cfg->get('debug'))->toBeFalse()
+ ->and($cfg->get('zero'))->toBe(0)
+ ->and($cfg->get('nullable', 'fallback'))->toBeNull()
+ ->and($cfg->get('missing', 'fallback'))->toBe('fallback');
+ }
+
+ $cacheSize = (fn (): int => count($this->resolvedValueCache))->call($cfg);
+ expect($cacheSize)->toBe(0);
+});
+
+it('invalidates memoized nested reads after every mutation family', function () {
+ $cfg = new Config;
+ $cfg->loadArray(['app' => ['value' => 1]]);
+
+ expect($cfg->get('app.value'))->toBe(1);
+
+ $cfg->set('app.value', 2);
+ expect($cfg->get('app.value'))->toBe(2);
+
+ $cfg->set(['app.value' => 3, 'app.extra' => 'set']);
+ expect($cfg->get('app.value'))->toBe(3);
+
+ $cfg->fill('app.filled', 4);
+ expect($cfg->get('app.filled'))->toBe(4);
+
+ $cfg->forget('app.filled');
+ expect($cfg->get('app.filled', 'missing'))->toBe('missing');
+
+ $cfg->replace(['app' => ['value' => 5]]);
+ expect($cfg->get('app.value'))->toBe(5);
+
+ $cfg->merge(['app' => ['value' => 6]]);
+ expect($cfg->get('app.value'))->toBe(6);
+
+ $cfg->overlay(['app' => ['value' => 7]]);
+ expect($cfg->get('app.value'))->toBe(7);
+
+ $cfg->snapshot();
+ $cfg->set('app.value', 8);
+ expect($cfg->get('app.value'))->toBe(8)
+ ->and($cfg->restore())->toBeTrue()
+ ->and($cfg->get('app.value'))->toBe(7);
+
+ $cfg->reload(['app' => ['value' => 9]]);
+ expect($cfg->get('app.value'))->toBe(9);
+});
+
it('bounds the in-memory read cache for long-running processes', function () {
$cfg = new Config;
diff --git a/tests/Feature/DotNotationTest.php b/tests/Feature/DotNotationTest.php
index 02f6770..f439755 100644
--- a/tests/Feature/DotNotationTest.php
+++ b/tests/Feature/DotNotationTest.php
@@ -196,6 +196,15 @@
->and(DotNotation::has($data, 'service\\.name'))->toBeTrue();
});
+it('supports escaped backslashes without corrupting compiled paths', function () {
+ $data = [
+ 'root\\name' => ['value' => 'found'],
+ ];
+
+ expect(DotNotation::get($data, 'root\\\\name.value'))->toBe('found')
+ ->and(DotNotation::get($data, 'root\\\\name.value'))->toBe('found');
+});
+
it('retrieves multiple keys when passed an array', function () {
$data = [
'user' => ['name' => 'Carol', 'email' => 'carol@example.com'],
@@ -288,6 +297,24 @@
]);
});
+it('supports multiple wildcards and missing wildcard branches', function () {
+ $data = [
+ 'companies' => [
+ ['teams' => [['name' => 'A'], ['name' => 'B']]],
+ ['teams' => [['name' => 'C'], []]],
+ ],
+ ];
+
+ expect(DotNotation::get($data, 'companies.*.teams.*.name', 'missing'))
+ ->toBe(['A', 'B', 'C', 'missing']);
+
+ DotNotation::set($data, 'companies.*.teams.*.active', true);
+ DotNotation::forget($data, 'companies.*.teams.*.active');
+
+ expect(DotNotation::get($data, 'companies.*.teams.*.active', 'missing'))
+ ->toBe(['missing', 'missing', 'missing', 'missing']);
+});
+
it('supports hasWildcard, paths and matches helpers', function () {
$data = [
'users' => [