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. + ---
Made with ❤️ for the PHP community
MIT Licensed
- Documentation • + DocumentationSecurityCode of Conduct • - Contributing • - Report Bug • - Request Feature + Contributing
+ 🗂️ + Bug • + Feature • + Documentation • + Question • + CI failure
+ 🔀 + General • + Bug fix • + Feature • + Refactor • + Performance • + Security & reliability • + Documentation • + Maintenance
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' => [