diff --git a/.gitignore b/.gitignore index 23502f3..0786989 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,12 @@ env.bash.sh docs/env.bash.sh .env .env.* + +.decisions +.vscode +build + +# Ignore Markdown files except the anatomy guide +docs/*.md +docs/*.pem +!docs/decision-file-anatomy.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ca8da6..6e38580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,46 @@ This project aims to follow [Keep a Changelog](https://keepachangelog.com/en/1.1 ## [Unreleased] +## [1.3.0] - 2026-03-23 + +### Added +- CLI command `enforce` for the first `v1.3.0` enforcement-ready workflow. + - Consumes a generic JSON analyzer report from `--report`. + - Consumes native Semgrep JSON from `--semgrep-report`. + - Consumes native PHPStan JSON from `--phpstan-report`. + - Supports `--format json` for machine-readable CI/bot integration output. + - Matches findings to active decisions by scope and `rules.forbid` token. + - Prints violations grouped by decision ID and exits non-zero when decision-linked violations are found. +- Checked-in Semgrep example assets for the `DEC-0003` flow: + - `semgrep/rules/no-orm-in-order-domain.yaml` + - `examples/github-actions/phpdecide-semgrep-enforce.yaml` +- Added a second active sample decision/rule pair for template boundaries: + - `examples/decisions/DEC-0004.no-business-logic-in-templates.yaml` + - `semgrep/rules/no-business-logic-in-templates.yaml` +- Added example Twig fixtures so the template-boundary rule has a concrete allowed/violating path pair: + - `examples/fixtures/templates/order/show.html.twig` + - `examples/fixtures/templates/order/calculate_total.html.twig` +- Added checked-in PHPStan example assets for the native identifier mapping path: + - `examples/decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml` + - `examples/phpstan/no-orm-in-order-domain-report.json` + - `examples/phpstan/README.md` + - `examples/fixtures/phpstan/src/Order/OrderService.php` + - `examples/fixtures/phpstan/src/Infrastructure/Persistence/Doctrine/OrderRecord.php` + - includes a minimal custom-rule sketch showing how to emit `phpstan.doctrine.orm` +- Added a parallel GitHub Actions example for the PHPStan mapping path: + - `examples/github-actions/phpdecide-phpstan-enforce.yaml` +- The checked-in GitHub Actions example now consumes `enforce --format json`, writes a job summary from the JSON payload, uploads the JSON artifact, and fails the workflow based on the recorded enforcement exit code. +- Added a reusable example markdown renderer for enforcement JSON output: + - `examples/github-actions/render-phpdecide-pr-comment.php` +- Added a second reusable example consumer for enforcement JSON output: + - `examples/github-actions/render-phpdecide-annotations.php` + +### Fixed +- Enforcement JSON report loading now normalizes UTF-8 BOM and UTF-16/UTF-32 encoded files, improving compatibility with shell-generated reports on Windows and CI. + +### Changed +- Decision `rules.forbid` tokens now have an operational contract for enforcement mapping: analyzer `rule_id` values can be mapped back to decisions through the `enforce` command. + ## [1.2.0] - 2026-03-19 ### Added @@ -30,6 +70,7 @@ This project aims to follow [Keep a Changelog](https://keepachangelog.com/en/1.1 - `PHPDECIDE_AI_OMIT_MODEL` to omit the JSON `model` field when the gateway encodes the model/deployment in the URL. - `PHPDECIDE_AI_AUTH_HEADER_NAME` and `PHPDECIDE_AI_AUTH_PREFIX` to support non-Bearer authentication (e.g. `Api-Key: `). - Decision loading cache for `.decisions/*.yaml` to speed up repeated runs when files are unchanged. + This cache applies to decision files in your project's `.decisions/` directory. - New CLI flag: `explain --no-cache`. - New env var: `PHPDECIDE_DECISIONS_CACHE=0`. @@ -53,17 +94,17 @@ This project aims to follow [Keep a Changelog](https://keepachangelog.com/en/1.1 ### Highlights - First stable release of PHPDecide: decision files as structured, version-controlled project knowledge. -- CI-friendly linting for `.decisions/*.yaml` (syntax + schema checks). +- CI-friendly linting for decision files in your project's `.decisions/` directory (syntax + schema checks). - “Explain” workflow with optional AI summarization (presentation only; decisions remain the source of truth). ### Added - CLI command `decisions:lint` - - Validates `.yaml` decision files in a directory (default: `.decisions/`). + - Validates `.yaml` decision files in a directory (default: your project's `.decisions/`). - `--require-any` option to fail if no `.yaml` files exist. - Detects unsupported `.yml` extension and reports it explicitly. - Detects duplicate decision IDs across multiple files. - CLI command `explain ` - - Loads recorded decisions from `.decisions/`. + - Loads recorded decisions from your project's `.decisions/` directory. - `--path ` option to only consider decisions applicable to a specific file path. - Optional `--ai` mode to summarize recorded decisions. - `--ai-strict` option to fail if AI is enabled but unavailable/errors (default: falls back to plain output). @@ -89,4 +130,5 @@ This project aims to follow [Keep a Changelog](https://keepachangelog.com/en/1.1 [1.0.0]: https://github.com/distvan/phpdecide/releases/tag/v1.0.0 [1.1.0]: https://github.com/distvan/phpdecide/releases/tag/v1.1.0 [1.2.0]: https://github.com/distvan/phpdecide/releases/tag/v1.2.0 -[Unreleased]: https://github.com/distvan/phpdecide/compare/v1.2.0...HEAD +[1.3.0]: https://github.com/distvan/phpdecide/releases/tag/v1.3.0 +[Unreleased]: https://github.com/distvan/phpdecide/compare/v1.3.0...HEAD diff --git a/README.md b/README.md index 4f2d72d..96b5050 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,143 @@ When enforced: This turns architecture from "guideline" into living constraints. +### First enforcement-ready workflow (`v1.3.0`) + +The first enforcement slice does not build a new analyzer. +Instead, PHPDecide maps external analyzer findings back to decision IDs. + +Run it with a generic JSON report: + +`php ./bin/phpdecide enforce --report build/phpdecide-findings.json` + +Emit a machine-readable result for CI or bots: + +`php ./bin/phpdecide enforce --report build/phpdecide-findings.json --format json` + +Or feed Semgrep output directly: + +`semgrep scan --config semgrep/rules --json --output build/semgrep.json` + +`php ./bin/phpdecide enforce --semgrep-report build/semgrep.json` + +Or feed PHPStan output directly: + +`phpstan analyse --error-format=json > build/phpstan.json` + +`php ./bin/phpdecide enforce --phpstan-report build/phpstan.json` + +Checked-in example assets: + +- Example decision: [examples/decisions/DEC-0003.no-orm-in-order-domain.yaml](examples/decisions/DEC-0003.no-orm-in-order-domain.yaml) +- Example decision: [examples/decisions/DEC-0004.no-business-logic-in-templates.yaml](examples/decisions/DEC-0004.no-business-logic-in-templates.yaml) +- Example decision: [examples/decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml](examples/decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml) +- Semgrep rule example: [semgrep/rules/no-orm-in-order-domain.yaml](semgrep/rules/no-orm-in-order-domain.yaml) +- Semgrep rule example: [semgrep/rules/no-business-logic-in-templates.yaml](semgrep/rules/no-business-logic-in-templates.yaml) +- PHPStan report example: [examples/phpstan/no-orm-in-order-domain-report.json](examples/phpstan/no-orm-in-order-domain-report.json) +- PHPStan example notes: [examples/phpstan/README.md](examples/phpstan/README.md) +- GitHub Actions example: [examples/github-actions/phpdecide-semgrep-enforce.yaml](examples/github-actions/phpdecide-semgrep-enforce.yaml) +- GitHub Actions example: [examples/github-actions/phpdecide-phpstan-enforce.yaml](examples/github-actions/phpdecide-phpstan-enforce.yaml) +- PR comment renderer: [examples/github-actions/render-phpdecide-pr-comment.php](examples/github-actions/render-phpdecide-pr-comment.php) +- Annotation renderer: [examples/github-actions/render-phpdecide-annotations.php](examples/github-actions/render-phpdecide-annotations.php) + +Matching decision tokens for your own project decisions: + +- `doctrine/orm` +- `twig/business-logic` +- `phpstan.doctrine.orm` + +Record those tokens in decision files under your own project's `.decisions/` directory. + +Sample fixture files used by the second rule: + +- Allowed fixture: [examples/fixtures/templates/order/show.html.twig](examples/fixtures/templates/order/show.html.twig) +- Violating fixture: [examples/fixtures/templates/order/calculate_total.html.twig](examples/fixtures/templates/order/calculate_total.html.twig) + +Sample fixture files used by the PHPStan example: + +- Allowed fixture: [examples/fixtures/phpstan/src/Infrastructure/Persistence/Doctrine/OrderRecord.php](examples/fixtures/phpstan/src/Infrastructure/Persistence/Doctrine/OrderRecord.php) +- Violating fixture: [examples/fixtures/phpstan/src/Order/OrderService.php](examples/fixtures/phpstan/src/Order/OrderService.php) + +Supported JSON shapes: + +```json +{ + "findings": [ + { + "tool": "semgrep", + "rule_id": "doctrine/orm", + "path": "src/Order/OrderService.php", + "line": 12, + "severity": "error", + "message": "Doctrine ORM import detected." + } + ] +} +``` + +Or a top-level JSON array of the same finding objects. + +Initial mapping behavior: + +- active decisions only +- scope must match the finding path +- the finding `rule_id` must equal a token listed in `rules.forbid` + +Analyzer report paths are normalized for scope matching, so Windows-style separators and leading `./` do not prevent a finding from matching path-based decision scopes. + +This gives CI a stable way to print violations grouped by decision ID without forcing PHPDecide to own static analysis itself. +For Semgrep, PHPDecide reads native `results[*].check_id`, `path`, `start.line`, and `extra.message` / `extra.severity` fields and converts them internally. +For PHPStan, PHPDecide reads native `files[*].messages[*]` entries and maps the message `identifier` field to the decision token in `rules.forbid`. +The repository includes a checked-in native example report at [examples/phpstan/no-orm-in-order-domain-report.json](examples/phpstan/no-orm-in-order-domain-report.json) so teams can copy the JSON shape and token contract directly. +There is also a short authoring note in [examples/phpstan/README.md](examples/phpstan/README.md) showing how a real PHPStan custom rule should emit the same identifier token. + +When `--format json` is used, the command emits a structured payload with: + +- `ok` +- `summary` +- `violations_by_decision` +- `unmapped_findings` + +The `summary` object contains: + +- `violating_decision_count`: number of decision IDs that have at least one matched violation +- `violation_count`: total number of matched violations across all decisions +- `unmapped_finding_count`: number of analyzer findings that did not map to an active scoped decision rule + +The exit code behavior does not change: matched decision violations still produce a non-zero exit code. + +The checked-in GitHub Actions example now demonstrates the full structured path: + +- run Semgrep +- call `phpdecide enforce --format json` +- render PR-comment markdown from the JSON payload +- render GitHub annotation commands from the same JSON payload +- write a human-readable step summary from that markdown +- upload the JSON artifact +- upload the markdown artifact +- upload the annotation command artifact +- fail the job if the enforcement exit code was non-zero + +There is also a parallel PHPStan-flavored workflow example: + +- stage a native PHPStan JSON report +- call `phpdecide enforce --phpstan-report ... --format json` +- reuse the same markdown and annotation renderers +- upload the JSON, markdown, and annotation artifacts +- fail the job if the enforcement exit code was non-zero + +See [examples/github-actions/phpdecide-phpstan-enforce.yaml](examples/github-actions/phpdecide-phpstan-enforce.yaml). + +The renderer can also be used locally: + +`php examples/github-actions/render-phpdecide-pr-comment.php build/phpdecide-enforce.json` + +The annotation renderer can also be used locally: + +`php examples/github-actions/render-phpdecide-annotations.php build/phpdecide-enforce.json` + +Use the PR-comment renderer when you want one grouped markdown summary. Use the annotation renderer when you want file-level warnings/errors that show up inline in GitHub Actions logs and checks. + ### Philosophy of AI Usage in PHPDecide - AI is an assistant, not an Authority @@ -123,7 +260,7 @@ PHPDecide is ideal for: ### Quickstart (Phase 1: explain adoption) -1) Create a `.decisions/` folder in your repo root and add your first decision file. +1) Create a `.decisions/` folder in your project root and add your first decision file. 2) Validate decisions locally (fast feedback): @@ -138,13 +275,42 @@ PHPDecide is ideal for: Tip: use [docs/decision-file-anatomy.md](docs/decision-file-anatomy.md) as the schema guide. +### Quickstart (Phase 2: enforcement-ready mapping) + +1) Add stable machine-oriented tokens in decision `rules.forbid`. + +2) Configure your analyzer to emit matching `rule_id` values. + +3) Feed the analyzer report into PHPDecide: + +`php ./bin/phpdecide enforce --report build/phpdecide-findings.json` + +Or use Semgrep directly: + +`php ./bin/phpdecide enforce --semgrep-report build/semgrep.json` + +Or use PHPStan directly: + +`php ./bin/phpdecide enforce --phpstan-report build/phpstan.json` + +For CI integration, prefer: + +`php ./bin/phpdecide enforce --semgrep-report build/semgrep.json --format json` + +If you want a starting point, this repository includes visible example decision files under [examples/decisions](examples/decisions), plus Semgrep and PHPStan example assets that use the same stable tokens. The GitHub Actions examples stage those decision files into a temporary directory before running `decisions:lint --require-any` and `enforce`, so the workflow stays self-contained. The second Semgrep sample intentionally targets example fixture files under `examples/fixtures/templates/` rather than a real application template directory. + +The command fails when it finds decision-linked violations, which makes it suitable for CI. + +By default, `enforce` uses the same decision cache as `explain` and may create or refresh `.decisions/.phpdecide-decisions.cache` during a run. Disable cache writes for read-only CI jobs or debugging with either `--no-cache` or `PHPDECIDE_DECISIONS_CACHE=0`. + ### Decision loading cache (optional) -By default, `explain` caches parsed decisions in `.decisions/.phpdecide-decisions.cache` to speed up repeated runs. +By default, `explain` and `enforce` cache parsed decisions in `.decisions/.phpdecide-decisions.cache` to speed up repeated runs. Disable cache if you are debugging loader behavior: - CLI: `php ./bin/phpdecide explain "Why no ORMs?" --no-cache` +- CLI: `php ./bin/phpdecide enforce --report build/phpdecide-findings.json --no-cache` - Env: set `PHPDECIDE_DECISIONS_CACHE=0` ### AI configuration (optional) diff --git a/bin/phpdecide b/bin/phpdecide index 5756d6f..d39473b 100644 --- a/bin/phpdecide +++ b/bin/phpdecide @@ -6,6 +6,7 @@ require_once __DIR__ . '/../vendor/autoload.php'; use Symfony\Component\Console\Application; use PhpDecide\CLI\ExplainCommand; use PhpDecide\CLI\DecisionsLintCommand; +use PhpDecide\CLI\EnforceCommand; $version = 'dev'; try { @@ -24,5 +25,6 @@ $application = new Application('PHP Decide', $version); $application->addCommand(new ExplainCommand()); $application->addCommand(new DecisionsLintCommand()); +$application->addCommand(new EnforceCommand()); $application->run(); diff --git a/docs/DEC-0003.no-orm-in-order-domain.yaml b/docs/DEC-0003.no-orm-in-order-domain.yaml index 882e72d..cf38f2c 100644 --- a/docs/DEC-0003.no-orm-in-order-domain.yaml +++ b/docs/DEC-0003.no-orm-in-order-domain.yaml @@ -7,6 +7,7 @@ scope: type: path paths: - 'src/Order/*' + - 'src/Order/**/*' decision: summary: > @@ -28,6 +29,7 @@ examples: - 'src/Infrastructure/Persistence/Doctrine/*' forbidden: - 'src/Order/*' + - 'src/Order/**/*' rules: forbid: diff --git a/docs/decision-class-diagram.puml b/docs/decision-class-diagram.puml index 8d5f079..3bef80e 100644 --- a/docs/decision-class-diagram.puml +++ b/docs/decision-class-diagram.puml @@ -1,6 +1,6 @@ @startuml -' PHPDecide - Diagram 1/2 -' Domain model + Explain + CLI +' PHPDecide - Expanded diagram 1/2 +' Domain model + Explain + Enforcement + CLI skinparam shadowing false skinparam classAttributeIconSize 0 @@ -9,7 +9,7 @@ left to right direction set namespaceSeparator :: -title PHPDecide - Domain and CLI (src) +title PHPDecide - Domain, Enforcement and CLI (src) package PhpDecide::Config { class PhpDecideDefaults @@ -52,6 +52,17 @@ package PhpDecide::Explain { class Explanation } +package PhpDecide::Enforcement { + class AnalyzerFinding + class AnalyzerFindingLoader + class SemgrepFindingLoader + class PhpStanFindingLoader + class JsonFileDecoder + class DecisionViolation + class DecisionViolationMatcher + class EnforcementMatchResult +} + package PhpDecide::AI { interface AiClient <> class AiClientFactory <> @@ -60,6 +71,7 @@ package PhpDecide::AI { package PhpDecide::CLI { class ExplainCommand class DecisionsLintCommand + class EnforceCommand } ' --- Domain composition --- @@ -82,8 +94,9 @@ AiClientExplainer ..|> AiExplainer ' --- CLI inheritance --- ExplainCommand --|> Command DecisionsLintCommand --|> Command +EnforceCommand --|> Command -' --- Dependencies --- +' --- Domain dependencies --- YamlDecisionLoader ..> DecisionFileCollector FileDecisionRepository ..> DecisionLoader FileDecisionRepository ..> Decision @@ -110,6 +123,26 @@ Explanation o-- "0..*" Decision AiClientExplainer ..> AiClient +' --- Enforcement model --- +DecisionViolation *-- "1" AnalyzerFinding : finding +DecisionViolation *-- "1" Decision : decision +EnforcementMatchResult o-- "0..*" DecisionViolation : violationsByDecisionId +EnforcementMatchResult o-- "0..*" AnalyzerFinding : unmappedFindings + +' --- Enforcement dependencies --- +AnalyzerFindingLoader ..> JsonFileDecoder +AnalyzerFindingLoader ..> AnalyzerFinding +SemgrepFindingLoader ..> JsonFileDecoder +SemgrepFindingLoader ..> AnalyzerFinding +PhpStanFindingLoader ..> JsonFileDecoder +PhpStanFindingLoader ..> AnalyzerFinding +DecisionViolationMatcher ..> DecisionRepository +DecisionViolationMatcher ..> Decision +DecisionViolationMatcher ..> AnalyzerFinding +DecisionViolationMatcher ..> DecisionViolation +DecisionViolationMatcher ..> EnforcementMatchResult + +' --- CLI dependencies --- ExplainCommand ..> PhpDecideDefaults ExplainCommand ..> YamlDecisionLoader ExplainCommand ..> FileDecisionRepository @@ -123,10 +156,21 @@ DecisionsLintCommand ..> DecisionFactory DecisionsLintCommand ..> Yaml DecisionsLintCommand ..> ParseException +EnforceCommand ..> PhpDecideDefaults +EnforceCommand ..> YamlDecisionLoader +EnforceCommand ..> FileDecisionRepository +EnforceCommand ..> AnalyzerFindingLoader +EnforceCommand ..> SemgrepFindingLoader +EnforceCommand ..> PhpStanFindingLoader +EnforceCommand ..> DecisionViolationMatcher +EnforceCommand ..> EnforcementMatchResult +EnforceCommand ..> AnalyzerFinding +EnforceCommand ..> DecisionViolation + @enduml @startuml -' PHPDecide - Diagram 2/2 +' PHPDecide - Expanded diagram 2/2 ' AI client + transport + egress guard skinparam shadowing false @@ -163,6 +207,7 @@ package PhpDecide::AI::Guard { class GuardPolicy class GuardPolicyFactory class DlpScanner + class DlpScannerException class Finding interface AuditLogger class NullAuditLogger @@ -203,7 +248,9 @@ EgressGuardAiClient ..> DecisionPayloadNormalizer EgressGuardAiClient ..> ExplainPromptBuilder EgressGuardAiClient ..> AiClientException EgressGuardAiClient ..> Decision +EgressGuardAiClient ..> Finding GuardPolicyFactory ..> GuardPolicy DlpScanner ..> Finding +DlpScanner ..> DlpScannerException @enduml diff --git a/docs/decision-file-anatomy.md b/docs/decision-file-anatomy.md index 388312a..77cc4aa 100644 --- a/docs/decision-file-anatomy.md +++ b/docs/decision-file-anatomy.md @@ -12,7 +12,7 @@ This document describes a **recommended structure** that also matches what PHPDe ## File placement and naming -- Put decisions under `.decisions/` in the repo root. +- Put decisions under `.decisions/` in your project root. - Use the `.yaml` extension (the loader currently loads only `.yaml`, not `.yml`). - Keep filenames stable and sortable, e.g.: - `DEC-0001-no-orms.yaml` @@ -32,7 +32,7 @@ Required top-level fields: Optional top-level fields (currently supported by the parser/model): - `examples` (object) – allowed/forbidden examples -- `rules` (object) – allow/forbid rule keywords (future: enforcement) +- `rules` (object) – allow/forbid rule keywords - `references` (object) – links to issues/commits/ADRs - `ai` (object) – AI-friendly metadata for explanations @@ -66,14 +66,16 @@ Extra fields are allowed in YAML, but are currently ignored by the loader. ```yaml scope: - type: global|path|module - paths: - - 'src/Order/*' + type: global|path|module + paths: + - 'src/Order/*' + - 'src/Order/**/*' ``` - `type: global` means “applies everywhere”; omit `paths`. - `type: path` uses glob matching (`fnmatch`) against repo-relative paths. - Prefer forward slashes: `src/Order/*` + - When you want both direct files and nested subdirectories, include both patterns explicitly: `src/Order/*` and `src/Order/**/*`. - Use patterns that are hard to misinterpret. - If you want to scope by module later, keep module boundaries reflected in the path patterns. @@ -101,6 +103,7 @@ examples: - 'src/Infrastructure/Persistence/Doctrine/*' forbidden: - 'src/Order/*' + - 'src/Order/**/*' ``` - Use real paths from your repo; examples help onboarding. @@ -117,6 +120,9 @@ rules: - Rules should be **machine-oriented**: stable tokens that can be checked automatically. - Prefer a small number of strong rules over many weak ones. +- As of `v1.3.0`, enforcement is implemented for `rules.forbid`: the `enforce` command matches external analyzer findings by comparing the finding `rule_id` to `rules.forbid`, after scope filtering. +- See [semgrep/rules/no-orm-in-order-domain.yaml](../semgrep/rules/no-orm-in-order-domain.yaml) for a checked-in example where the Semgrep rule ID matches the decision token `doctrine/orm`. +- See [semgrep/rules/no-business-logic-in-templates.yaml](../semgrep/rules/no-business-logic-in-templates.yaml) for a second example token pairing using `twig/business-logic`. In your own project, record the matching token in a decision file under `.decisions/`. ### `references` (optional) @@ -209,4 +215,4 @@ ai: ## Example -- See [DEC-0003.no-orm-in-order-domain.yaml](DEC-0003.no-orm-in-order-domain.yaml) for a complete example that matches the current schema. +- See [DEC-0003.no-orm-in-order-domain.yaml](DEC-0003.no-orm-in-order-domain.yaml) for a complete example decision document that matches the current schema. diff --git a/examples/decisions/DEC-0003.no-orm-in-order-domain.yaml b/examples/decisions/DEC-0003.no-orm-in-order-domain.yaml new file mode 100644 index 0000000..e17cf81 --- /dev/null +++ b/examples/decisions/DEC-0003.no-orm-in-order-domain.yaml @@ -0,0 +1,50 @@ +id: DEC-0003 +title: No ORM in Order domain +status: active +date: '2024-11-23' + +scope: + type: path + paths: + - 'src/Order/*' + - 'src/Order/**/*' + +decision: + summary: > + The Order domain must not depend on any ORM library. + Persistence concerns must be handled outside the domain layer. + + rationale: + - The Order domain contains core business rules and should stay persistence-ignorant. + - ORMs tend to leak infrastructure concepts (lazy-loading, proxies, annotations) into the model. + - Keeping persistence outside the domain improves testability and makes behavior easier to reason about. + + alternatives: + - Use an ORM everywhere for speed of delivery. + - Use an ORM only for read models and keep the write domain persistence-ignorant. + - Use a lightweight mapper (hand-written mapping, query builder) at the boundary. + +examples: + allowed: + - 'src/Infrastructure/Persistence/Doctrine/*' + forbidden: + - 'src/Order/*' + - 'src/Order/**/*' + +rules: + forbid: + - 'doctrine/orm' + allow: [] + +references: + issues: [] + commits: [] + adr: null + +ai: + explain_style: 'Explain as an architecture mentor: focus on coupling, boundaries, and testability.' + keywords: + - 'persistence ignorance' + - 'domain model' + - 'coupling' + - 'testability' \ No newline at end of file diff --git a/examples/decisions/DEC-0004.no-business-logic-in-templates.yaml b/examples/decisions/DEC-0004.no-business-logic-in-templates.yaml new file mode 100644 index 0000000..f2a16fb --- /dev/null +++ b/examples/decisions/DEC-0004.no-business-logic-in-templates.yaml @@ -0,0 +1,48 @@ +id: DEC-0004 +title: No business logic in templates +status: active +date: '2026-03-19' + +scope: + type: path + paths: + - 'examples/fixtures/templates/*' + - 'examples/fixtures/templates/**/*' + +decision: + summary: > + Templates should stay presentation-focused and must not contain business + logic or service access. + + rationale: + - Business rules embedded in templates are hard to test and easy to duplicate. + - Keeping templates simple preserves a clear boundary between rendering and application behavior. + - Reviewers can reason about presentation changes faster when domain logic stays outside the view layer. + + alternatives: + - Allow lightweight conditional logic directly in templates. + - Permit service calls from templates for convenience in small pages. + +examples: + allowed: + - 'examples/fixtures/templates/order/show.html.twig' + forbidden: + - 'examples/fixtures/templates/order/calculate_total.html.twig' + +rules: + forbid: + - 'twig/business-logic' + allow: [] + +references: + issues: [] + commits: [] + adr: null + +ai: + explain_style: 'Explain as a maintainability rule: focus on separation of concerns and reviewability.' + keywords: + - 'templates' + - 'presentation' + - 'business logic' + - 'separation of concerns' \ No newline at end of file diff --git a/examples/decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml b/examples/decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml new file mode 100644 index 0000000..6fbbfbd --- /dev/null +++ b/examples/decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml @@ -0,0 +1,48 @@ +id: DEC-0005 +title: No ORM in Order domain via PHPStan +status: active +date: '2026-03-19' + +scope: + type: path + paths: + - 'examples/fixtures/phpstan/src/Order/*' + - 'examples/fixtures/phpstan/src/Order/**/*' + +decision: + summary: > + The Order domain example used for PHPStan enforcement must not depend on + Doctrine ORM symbols. + + rationale: + - The example keeps the same architecture boundary as the main no-ORM decision, but shows the PHPStan identifier mapping path. + - A checked-in, scoped sample is easier to copy into another repository than a docs-only description. + - Keeping the violating path under examples preserves a runnable demonstration without asserting policy over application code. + + alternatives: + - Document the PHPStan identifier contract without a checked-in example report. + - Keep PHPStan support test-only and rely on teams to create their own first mapping. + +examples: + allowed: + - 'examples/fixtures/phpstan/src/Infrastructure/Persistence/Doctrine/OrderRecord.php' + forbidden: + - 'examples/fixtures/phpstan/src/Order/OrderService.php' + +rules: + forbid: + - 'phpstan.doctrine.orm' + allow: [] + +references: + issues: [] + commits: [] + adr: null + +ai: + explain_style: 'Explain as an architecture mentor: focus on keeping PHP-specific dependency rules aligned with recorded decisions.' + keywords: + - 'phpstan' + - 'orm' + - 'domain boundary' + - 'architecture rule' \ No newline at end of file diff --git a/examples/fixtures/phpstan/src/Infrastructure/Persistence/Doctrine/OrderRecord.php b/examples/fixtures/phpstan/src/Infrastructure/Persistence/Doctrine/OrderRecord.php new file mode 100644 index 0000000..08cb409 --- /dev/null +++ b/examples/fixtures/phpstan/src/Infrastructure/Persistence/Doctrine/OrderRecord.php @@ -0,0 +1,8 @@ + 100 %} +

Discount applied: {{ total }}

+{% endif %} diff --git a/examples/fixtures/templates/order/show.html.twig b/examples/fixtures/templates/order/show.html.twig new file mode 100644 index 0000000..03e976b --- /dev/null +++ b/examples/fixtures/templates/order/show.html.twig @@ -0,0 +1,2 @@ +

Order Summary

+

Total: {{ total }}

diff --git a/examples/github-actions/phpdecide-phpstan-enforce.yaml b/examples/github-actions/phpdecide-phpstan-enforce.yaml new file mode 100644 index 0000000..9d50544 --- /dev/null +++ b/examples/github-actions/phpdecide-phpstan-enforce.yaml @@ -0,0 +1,91 @@ +name: PHPDecide PHPStan Enforcement Example + +on: + pull_request: + push: + branches: + - main + +jobs: + policy: + name: Decision enforcement with PHPStan + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 + with: + php-version: '8.2' + + - name: Install PHP dependencies + run: composer install --no-interaction --prefer-dist + + - name: Stage example decision files + run: | + mkdir -p build/example-decisions + cp examples/decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml build/example-decisions/ + + - name: Lint decision files + run: php ./bin/phpdecide decisions:lint --dir build/example-decisions --require-any + + - name: Stage a native PHPStan JSON report + run: | + mkdir -p build + cp examples/phpstan/no-orm-in-order-domain-report.json build/phpstan.json + + - name: Map PHPStan findings back to decisions + id: phpdecide_enforce + run: | + set +e + php ./bin/phpdecide enforce \ + --dir build/example-decisions \ + --phpstan-report build/phpstan.json \ + --format json \ + > build/phpdecide-enforce.json + status=$? + echo "exit_code=$status" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Summarize decision enforcement + if: always() + run: | + php examples/github-actions/render-phpdecide-pr-comment.php \ + build/phpdecide-enforce.json \ + build/phpdecide-comment.md + cat build/phpdecide-comment.md >> "$GITHUB_STEP_SUMMARY" + + - name: Render workflow annotations + if: always() + run: | + php examples/github-actions/render-phpdecide-annotations.php \ + build/phpdecide-enforce.json \ + build/phpdecide-annotations.txt + cat build/phpdecide-annotations.txt + + - name: Upload decision enforcement JSON + if: always() + uses: actions/upload-artifact@v4 + with: + name: phpdecide-phpstan-enforce-json + path: build/phpdecide-enforce.json + + - name: Upload PR comment markdown + if: always() + uses: actions/upload-artifact@v4 + with: + name: phpdecide-phpstan-pr-comment-markdown + path: build/phpdecide-comment.md + + - name: Upload annotation commands + if: always() + uses: actions/upload-artifact@v4 + with: + name: phpdecide-phpstan-annotation-commands + path: build/phpdecide-annotations.txt + + - name: Fail job on decision-linked violations + if: steps.phpdecide_enforce.outputs.exit_code != '0' + run: exit 1 \ No newline at end of file diff --git a/examples/github-actions/phpdecide-semgrep-enforce.yaml b/examples/github-actions/phpdecide-semgrep-enforce.yaml new file mode 100644 index 0000000..b47248b --- /dev/null +++ b/examples/github-actions/phpdecide-semgrep-enforce.yaml @@ -0,0 +1,103 @@ +name: PHPDecide Semgrep Enforcement Example + +on: + pull_request: + push: + branches: + - main + +jobs: + policy: + name: Decision enforcement with Semgrep + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 + with: + php-version: '8.2' + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install PHP dependencies + run: composer install --no-interaction --prefer-dist + + - name: Install Semgrep + run: python -m pip install --upgrade pip semgrep==1.70.0 + + - name: Stage example decision files + run: | + mkdir -p build/example-decisions + cp examples/decisions/DEC-0003.no-orm-in-order-domain.yaml build/example-decisions/ + cp examples/decisions/DEC-0004.no-business-logic-in-templates.yaml build/example-decisions/ + + - name: Lint decision files + run: php ./bin/phpdecide decisions:lint --dir build/example-decisions --require-any + + - name: Run Semgrep example rules + run: | + mkdir -p build + semgrep scan \ + --config semgrep/rules \ + --json \ + --output build/semgrep.json + + - name: Map findings back to decisions + id: phpdecide_enforce + run: | + set +e + php ./bin/phpdecide enforce \ + --dir build/example-decisions \ + --semgrep-report build/semgrep.json \ + --format json \ + > build/phpdecide-enforce.json + status=$? + echo "exit_code=$status" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Summarize decision enforcement + if: always() + run: | + php examples/github-actions/render-phpdecide-pr-comment.php \ + build/phpdecide-enforce.json \ + build/phpdecide-comment.md + cat build/phpdecide-comment.md >> "$GITHUB_STEP_SUMMARY" + + - name: Render workflow annotations + if: always() + run: | + php examples/github-actions/render-phpdecide-annotations.php \ + build/phpdecide-enforce.json \ + build/phpdecide-annotations.txt + cat build/phpdecide-annotations.txt + + - name: Upload decision enforcement JSON + if: always() + uses: actions/upload-artifact@v4 + with: + name: phpdecide-enforce-json + path: build/phpdecide-enforce.json + + - name: Upload PR comment markdown + if: always() + uses: actions/upload-artifact@v4 + with: + name: phpdecide-pr-comment-markdown + path: build/phpdecide-comment.md + + - name: Upload annotation commands + if: always() + uses: actions/upload-artifact@v4 + with: + name: phpdecide-annotation-commands + path: build/phpdecide-annotations.txt + + - name: Fail job on decision-linked violations + if: steps.phpdecide_enforce.outputs.exit_code != '0' + run: exit 1 diff --git a/examples/github-actions/render-phpdecide-annotations.php b/examples/github-actions/render-phpdecide-annotations.php new file mode 100644 index 0000000..3ef1a01 --- /dev/null +++ b/examples/github-actions/render-phpdecide-annotations.php @@ -0,0 +1,220 @@ +#!/usr/bin/env php + 3) { + fwrite(STDERR, "Usage: php render-phpdecide-annotations.php [output-commands-path]\n"); + exit(1); +} + +$inputPath = $argv[1]; +$outputPath = $argv[2] ?? null; + +if (!is_file($inputPath)) { + fwrite(STDERR, sprintf("Input JSON file not found: %s\n", $inputPath)); + exit(1); +} + +$json = file_get_contents($inputPath); +if ($json === false) { + fwrite(STDERR, sprintf("Unable to read input JSON file: %s\n", $inputPath)); + exit(1); +} + +$json = normalizeInputEncoding($json); + +try { + /** @var mixed $payload */ + $payload = json_decode($json, true, 512, JSON_THROW_ON_ERROR); +} catch (JsonException $exception) { + fwrite(STDERR, sprintf("Invalid JSON input: %s\n", $exception->getMessage())); + exit(1); +} + +if (!is_array($payload)) { + fwrite(STDERR, "JSON input must decode to an object.\n"); + exit(1); +} + +$commands = renderAnnotationCommands($payload); + +if ($outputPath !== null) { + $bytes = file_put_contents($outputPath, $commands); + if ($bytes === false) { + fwrite(STDERR, sprintf("Unable to write output commands file: %s\n", $outputPath)); + exit(1); + } + + exit(0); +} + +fwrite(STDOUT, $commands); +exit(0); + +/** + * @param array $payload + */ +function renderAnnotationCommands(array $payload): string +{ + $commands = []; + + $error = $payload['error'] ?? null; + if (is_string($error) && $error !== '') { + $commands[] = githubCommand('error', [], 'PHPDecide enforcement failed: ' . $error); + + return implode("\n", $commands) . "\n"; + } + + foreach (decisionEntries($payload) as $entry) { + $decisionId = stringOrDefault($entry, 'decision_id', 'unknown'); + $decisionTitle = stringOrDefault($entry, 'decision_title', 'Unknown decision'); + + foreach (findingEntries($entry, 'violations') as $finding) { + $commands[] = githubCommand( + 'error', + findingProperties($finding, sprintf('[%s] %s', $decisionId, $decisionTitle)), + renderFindingMessage($finding) + ); + } + } + + foreach (findingEntries($payload, 'unmapped_findings') as $finding) { + $commands[] = githubCommand( + 'warning', + findingProperties($finding, 'Unmapped finding'), + renderFindingMessage($finding) + ); + } + + if ($commands === []) { + $commands[] = githubCommand('notice', [], 'PHPDecide enforcement found no decision-linked violations.'); + } + + return implode("\n", $commands) . "\n"; +} + +function normalizeInputEncoding(string $contents): string +{ + $encoding = mb_detect_encoding( + $contents, + ['UTF-8', 'UTF-16LE', 'UTF-16BE', 'UTF-32LE', 'UTF-32BE'], + true + ); + + if ($encoding === false) { + return removeUtf8Bom($contents); + } + + $normalized = $encoding === 'UTF-8' + ? $contents + : mb_convert_encoding($contents, 'UTF-8', $encoding); + + return removeUtf8Bom($normalized); +} + +function removeUtf8Bom(string $contents): string +{ + $bom = "\xEF\xBB\xBF"; + if (str_starts_with($contents, $bom)) { + return substr($contents, 3); + } + + return $contents; +} + +/** + * @return list> + */ +function decisionEntries(array $payload): array +{ + return findingEntries($payload, 'violations_by_decision'); +} + +/** + * @return list> + */ +function findingEntries(array $payload, string $field): array +{ + $value = $payload[$field] ?? []; + if (!is_array($value)) { + return []; + } + + return array_values(array_filter($value, 'is_array')); +} + +/** + * @param array $finding + * @return array + */ +function findingProperties(array $finding, string $title): array +{ + $properties = [ + 'title' => $title, + 'file' => stringOrDefault($finding, 'path', 'unknown-path'), + ]; + + if (isset($finding['line']) && is_int($finding['line'])) { + $properties['line'] = (string) $finding['line']; + } + + return $properties; +} + +/** + * @param array $finding + */ +function renderFindingMessage(array $finding): string +{ + return sprintf( + '[%s via %s] %s', + stringOrDefault($finding, 'rule_id', 'unknown-rule'), + stringOrDefault($finding, 'tool', 'unknown-tool'), + stringOrDefault($finding, 'message', 'No message provided.') + ); +} + +/** + * @param array $properties + */ +function githubCommand(string $level, array $properties, string $message): string +{ + $prefix = '::' . $level; + if ($properties === []) { + return $prefix . '::' . escapeCommandData($message); + } + + $pairs = []; + foreach ($properties as $key => $value) { + $pairs[] = $key . '=' . escapeCommandProperty($value); + } + + return sprintf('%s %s::%s', $prefix, implode(',', $pairs), escapeCommandData($message)); +} + +function escapeCommandData(string $value): string +{ + return str_replace( + ['%', "\r", "\n"], + ['%25', '%0D', '%0A'], + $value + ); +} + +function escapeCommandProperty(string $value): string +{ + return str_replace( + ['%', "\r", "\n", ':', ','], + ['%25', '%0D', '%0A', '%3A', '%2C'], + $value + ); +} + +/** + * @param array $data + */ +function stringOrDefault(array $data, string $field, string $default): string +{ + return isset($data[$field]) && is_string($data[$field]) ? $data[$field] : $default; +} diff --git a/examples/github-actions/render-phpdecide-pr-comment.php b/examples/github-actions/render-phpdecide-pr-comment.php new file mode 100644 index 0000000..1b0b0ce --- /dev/null +++ b/examples/github-actions/render-phpdecide-pr-comment.php @@ -0,0 +1,229 @@ +#!/usr/bin/env php + 3) { + fwrite(STDERR, "Usage: php render-phpdecide-pr-comment.php [output-markdown-path]\n"); + exit(1); +} + +$inputPath = $argv[1]; +$outputPath = $argv[2] ?? null; + +if (!is_file($inputPath)) { + fwrite(STDERR, sprintf("Input JSON file not found: %s\n", $inputPath)); + exit(1); +} + +$json = file_get_contents($inputPath); +if ($json === false) { + fwrite(STDERR, sprintf("Unable to read input JSON file: %s\n", $inputPath)); + exit(1); +} + +$json = normalizeInputEncoding($json); + +try { + /** @var mixed $payload */ + $payload = json_decode($json, true, 512, JSON_THROW_ON_ERROR); +} catch (JsonException $exception) { + fwrite(STDERR, sprintf("Invalid JSON input: %s\n", $exception->getMessage())); + exit(1); +} + +if (!is_array($payload)) { + fwrite(STDERR, "JSON input must decode to an object.\n"); + exit(1); +} + +$markdown = renderMarkdownComment($payload); + +if ($outputPath !== null) { + $bytes = file_put_contents($outputPath, $markdown); + if ($bytes === false) { + fwrite(STDERR, sprintf("Unable to write output markdown file: %s\n", $outputPath)); + exit(1); + } + + exit(0); +} + +fwrite(STDOUT, $markdown); +exit(0); + +/** + * @param array $payload + */ +function renderMarkdownComment(array $payload): string +{ + $lines = ['## PHPDecide enforcement']; + + $ok = (bool) ($payload['ok'] ?? false); + appendSummaryLines($lines, $ok, summaryData($payload)); + + $error = $payload['error'] ?? null; + if (is_string($error) && $error !== '') { + appendErrorLines($lines, $error); + + return implode("\n", $lines) . "\n"; + } + + appendDecisionLines($lines, decisionEntries($payload)); + appendUnmappedLines($lines, findingEntries($payload, 'unmapped_findings')); + + return implode("\n", $lines) . "\n"; +} + +function normalizeInputEncoding(string $contents): string +{ + $encoding = mb_detect_encoding( + $contents, + ['UTF-8', 'UTF-16LE', 'UTF-16BE', 'UTF-32LE', 'UTF-32BE'], + true + ); + + if ($encoding === false) { + return removeUtf8Bom($contents); + } + + $normalized = $encoding === 'UTF-8' + ? $contents + : mb_convert_encoding($contents, 'UTF-8', $encoding); + + return removeUtf8Bom($normalized); +} + +function removeUtf8Bom(string $contents): string +{ + $bom = "\xEF\xBB\xBF"; + if (str_starts_with($contents, $bom)) { + return substr($contents, 3); + } + + return $contents; +} + +/** + * @return array + */ +function summaryData(array $payload): array +{ + return is_array($payload['summary'] ?? null) ? $payload['summary'] : []; +} + +/** + * @param list $lines + * @param array $summary + */ +function appendSummaryLines(array &$lines, bool $ok, array $summary): void +{ + $lines[] = ''; + $lines[] = sprintf('- ok: %s', $ok ? 'true' : 'false'); + $lines[] = sprintf('- violating_decision_count: %d', (int) ($summary['violating_decision_count'] ?? 0)); + $lines[] = sprintf('- violation_count: %d', (int) ($summary['violation_count'] ?? 0)); + $lines[] = sprintf('- unmapped_finding_count: %d', (int) ($summary['unmapped_finding_count'] ?? 0)); +} + +/** + * @param list $lines + */ +function appendErrorLines(array &$lines, string $error): void +{ + $lines[] = ''; + $lines[] = '### Error'; + $lines[] = ''; + $lines[] = $error; +} + +/** + * @return list> + */ +function decisionEntries(array $payload): array +{ + return findingEntries($payload, 'violations_by_decision'); +} + +/** + * @return list> + */ +function findingEntries(array $payload, string $field): array +{ + $value = $payload[$field] ?? []; + if (!is_array($value)) { + return []; + } + + return array_values(array_filter($value, 'is_array')); +} + +/** + * @param list $lines + * @param list> $entries + */ +function appendDecisionLines(array &$lines, array $entries): void +{ + foreach ($entries as $entry) { + $lines[] = ''; + $lines[] = sprintf( + '### [%s] %s', + stringOrDefault($entry, 'decision_id', 'unknown'), + stringOrDefault($entry, 'decision_title', 'Unknown decision') + ); + + $violations = findingEntries($entry, 'violations'); + if ($violations === []) { + $lines[] = ''; + $lines[] = '- No violations listed.'; + continue; + } + + $lines[] = ''; + foreach ($violations as $violation) { + $lines[] = '- ' . renderFindingLine($violation); + } + } +} + +/** + * @param list $lines + * @param list> $entries + */ +function appendUnmappedLines(array &$lines, array $entries): void +{ + if ($entries === []) { + return; + } + + $lines[] = ''; + $lines[] = '### Unmapped findings'; + $lines[] = ''; + + foreach ($entries as $entry) { + $lines[] = '- ' . renderFindingLine($entry); + } +} + +/** + * @param array $finding + */ +function renderFindingLine(array $finding): string +{ + $path = stringOrDefault($finding, 'path', 'unknown-path'); + $line = isset($finding['line']) && is_int($finding['line']) ? $finding['line'] : null; + $ruleId = stringOrDefault($finding, 'rule_id', 'unknown-rule'); + $tool = stringOrDefault($finding, 'tool', 'unknown-tool'); + $message = stringOrDefault($finding, 'message', 'No message provided.'); + + $lineSuffix = $line !== null ? ':' . (string) $line : ''; + + return sprintf('%s%s [%s via %s] %s', $path, $lineSuffix, $ruleId, $tool, $message); +} + +/** + * @param array $data + */ +function stringOrDefault(array $data, string $field, string $default): string +{ + return isset($data[$field]) && is_string($data[$field]) ? $data[$field] : $default; +} diff --git a/examples/phpstan/README.md b/examples/phpstan/README.md new file mode 100644 index 0000000..79620bc --- /dev/null +++ b/examples/phpstan/README.md @@ -0,0 +1,97 @@ +# PHPStan example assets + +This directory contains a sample native PHPStan JSON report that maps to a decision token you can record in your own project's `.decisions/` directory. + +Files: + +- `no-orm-in-order-domain-report.json`: native `--error-format=json` style report using the decision token `phpstan.doctrine.orm` + +Matching fixture files: + +- `examples/fixtures/phpstan/src/Order/OrderService.php` +- `examples/fixtures/phpstan/src/Infrastructure/Persistence/Doctrine/OrderRecord.php` +- `examples/decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml` + +In your own project, create a decision file under `.decisions/` that includes the token `phpstan.doctrine.orm` in `rules.forbid`. + +Try it locally: + +```bash +php ./bin/phpdecide enforce --phpstan-report examples/phpstan/no-orm-in-order-domain-report.json +``` + +Or emit structured output: + +```bash +php ./bin/phpdecide enforce --phpstan-report examples/phpstan/no-orm-in-order-domain-report.json --format json +``` + +The important contract is that the PHPStan message `identifier` field must equal a token listed in the matching decision's `rules.forbid` array. + +## Emitting the identifier from a real PHPStan rule + +In a real project, the native JSON report usually comes from a custom PHPStan rule or extension. +The key requirement for PHPDecide is simple: the rule must emit a stable identifier that matches the decision token. + +For this example flow, that token is: + +- `phpstan.doctrine.orm` + +That means a real PHPStan rule should report errors using that same identifier. + +Minimal sketch: + +```php + */ +final class NoOrmInOrderDomainRule implements Rule +{ + public function getNodeType(): string + { + return Node\Stmt\Use_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $file = str_replace('\\', '/', $scope->getFile()); + + if (!str_contains($file, '/src/Order/')) { + return []; + } + + foreach ($node->uses as $useUse) { + $import = $useUse->name->toString(); + + if (str_starts_with($import, 'Doctrine\\ORM\\')) { + return [ + IdentifierRuleErrorBuilder::message('Doctrine ORM symbol detected in the Order domain.') + ->identifier('phpstan.doctrine.orm') + ->build(), + ]; + } + } + + return []; + } +} +``` + +When PHPStan is run with `--error-format=json`, that identifier appears in `files[*].messages[*].identifier`, which is exactly what `phpdecide enforce --phpstan-report ...` reads. + +The mapping chain is: + +- PHPStan rule emits `identifier: phpstan.doctrine.orm` +- native PHPStan JSON contains that identifier +- decision `rules.forbid` contains `phpstan.doctrine.orm` +- PHPDecide maps the finding back to `DEC-0005` \ No newline at end of file diff --git a/examples/phpstan/no-orm-in-order-domain-report.json b/examples/phpstan/no-orm-in-order-domain-report.json new file mode 100644 index 0000000..5dfc0c1 --- /dev/null +++ b/examples/phpstan/no-orm-in-order-domain-report.json @@ -0,0 +1,19 @@ +{ + "totals": { + "errors": 0, + "file_errors": 1 + }, + "files": { + "examples/fixtures/phpstan/src/Order/OrderService.php": { + "errors": 1, + "messages": [ + { + "message": "Doctrine ORM symbol detected in the Order domain example.", + "line": 7, + "identifier": "phpstan.doctrine.orm" + } + ] + } + }, + "errors": [] +} \ No newline at end of file diff --git a/semgrep/rules/no-business-logic-in-templates.yaml b/semgrep/rules/no-business-logic-in-templates.yaml new file mode 100644 index 0000000..242979b --- /dev/null +++ b/semgrep/rules/no-business-logic-in-templates.yaml @@ -0,0 +1,11 @@ +rules: + - id: twig/business-logic + message: Business logic is not allowed in templates. + severity: ERROR + languages: + - generic + paths: + include: + - examples/fixtures/templates/**/*.twig + pattern-regex: '(?m)\b(set|for|if)\b.*\b(total|price|amount|discount|tax|calculate|service|repository)\b' + diff --git a/semgrep/rules/no-orm-in-order-domain.yaml b/semgrep/rules/no-orm-in-order-domain.yaml new file mode 100644 index 0000000..eab205c --- /dev/null +++ b/semgrep/rules/no-orm-in-order-domain.yaml @@ -0,0 +1,10 @@ +rules: + - id: doctrine/orm + message: Doctrine ORM usage is not allowed in the Order domain. + severity: ERROR + languages: + - php + paths: + include: + - src/Order/**/*.php + pattern-regex: '(?m)^\s*use\s+Doctrine\\ORM\\' diff --git a/src/CLI/DecisionsLintCommand.php b/src/CLI/DecisionsLintCommand.php index f12a63f..b1b8a5f 100644 --- a/src/CLI/DecisionsLintCommand.php +++ b/src/CLI/DecisionsLintCommand.php @@ -165,6 +165,13 @@ private function lintDecisionFiles(array $files): array continue; } + $validationErrors = $this->validateDecisionArrays($data, $fileLabel); + $errors = array_merge($errors, $validationErrors); + + if ($validationErrors !== []) { + continue; + } + try { $decision = DecisionFactory::fromArray($data); $decisions[] = $decision; @@ -180,8 +187,6 @@ private function lintDecisionFiles(array $files): array ); continue; } - - $errors = array_merge($errors, $this->validateDecisionArrays($data, $fileLabel)); } return [$decisions, $errors]; diff --git a/src/CLI/EnforceCommand.php b/src/CLI/EnforceCommand.php new file mode 100644 index 0000000..8027217 --- /dev/null +++ b/src/CLI/EnforceCommand.php @@ -0,0 +1,426 @@ +addOption( + 'dir', + null, + InputOption::VALUE_REQUIRED, + 'Directory that contains decision files.', + PhpDecideDefaults::DECISIONS_DIR + ); + + $this->addOption( + 'report', + null, + InputOption::VALUE_REQUIRED, + 'Path to a JSON analyzer report. Expected fields per finding: tool, rule_id, path, message, optional line and severity.' + ); + + $this->addOption( + 'semgrep-report', + null, + InputOption::VALUE_REQUIRED, + 'Path to a native Semgrep JSON report (object with results array).' + ); + + $this->addOption( + 'phpstan-report', + null, + InputOption::VALUE_REQUIRED, + 'Path to a native PHPStan JSON report (object with files/messages structure).' + ); + + $this->addOption( + 'format', + null, + InputOption::VALUE_REQUIRED, + 'Output format: text or json.', + self::FORMAT_TEXT + ); + + $this->addOption( + 'no-cache', + null, + InputOption::VALUE_NONE, + 'Disable decision cache (forces re-reading and re-parsing YAML files).' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $dir = $this->resolveDir($input); + $reportPath = (string) $input->getOption('report'); + $semgrepReportPath = (string) $input->getOption('semgrep-report'); + $phpStanReportPath = (string) $input->getOption('phpstan-report'); + $format = $this->normalizeFormat((string) $input->getOption('format')); + $noCache = (bool) $input->getOption('no-cache'); + $envDecisionsCache = getenv(self::ENV_DECISIONS_CACHE); + $envCacheEnabled = true; + if ($envDecisionsCache !== false && $envDecisionsCache !== '') { + $envCacheEnabled = !in_array(strtolower(trim((string) $envDecisionsCache)), ['0', 'false', 'no', 'off'], true); + } + + $enableCache = !$noCache && $envCacheEnabled; + + $validationError = $this->validateInputs($dir, $reportPath, $semgrepReportPath, $phpStanReportPath, $format); + if ($validationError !== null) { + return $this->renderFailure($output, $validationError, $format); + } + + try { + $repository = new FileDecisionRepository(new YamlDecisionLoader($dir, enableCache: $enableCache)); + $findings = $this->loadFindings($reportPath, $semgrepReportPath, $phpStanReportPath); + $result = (new DecisionViolationMatcher())->match($repository, $findings); + } catch (Throwable $e) { + $message = $e instanceof InvalidArgumentException + ? $e->getMessage() + : sprintf('Unable to run enforcement mapping: %s', $e->getMessage()); + + return $this->renderFailure($output, $message, $format); + } + + $exitCode = Command::FAILURE; + + try { + $exitCode = $this->renderResult($output, $result, $format); + } catch (Throwable $e) { + $exitCode = $this->renderFailure( + $output, + sprintf('Unable to render enforcement output: %s', $e->getMessage()), + $format + ); + } + + return $exitCode; + } + + private function resolveDir(InputInterface $input): string + { + $dir = (string) $input->getOption('dir'); + if ($dir === '') { + return PhpDecideDefaults::DECISIONS_DIR; + } + + return $dir; + } + + private function validateInputs(string $dir, string $reportPath, string $semgrepReportPath, string $phpStanReportPath, string $format): ?string + { + $hasGenericReport = $reportPath !== ''; + $hasSemgrepReport = $semgrepReportPath !== ''; + $hasPhpStanReport = $phpStanReportPath !== ''; + $error = null; + $selectedReportCount = 0; + + if ($hasGenericReport) { + $selectedReportCount++; + } + if ($hasSemgrepReport) { + $selectedReportCount++; + } + if ($hasPhpStanReport) { + $selectedReportCount++; + } + + if (!in_array($format, [self::FORMAT_TEXT, self::FORMAT_JSON], true)) { + $error = sprintf('Unsupported format: %s. Expected one of: text, json.', $format); + } + + if ($error === null && $selectedReportCount === 0) { + $error = 'One of --report, --semgrep-report, or --phpstan-report is required.'; + } + + if ($error === null && $selectedReportCount > 1) { + $error = 'Use only one of --report, --semgrep-report, or --phpstan-report.'; + } + + if ($error === null && !is_dir($dir)) { + $error = sprintf('Decisions directory not found: %s', $dir); + } + + return $error; + } + + /** + * @return list + */ + private function loadFindings(string $reportPath, string $semgrepReportPath, string $phpStanReportPath): array + { + if ($semgrepReportPath !== '') { + return (new SemgrepFindingLoader())->loadFromFile($semgrepReportPath); + } + + if ($phpStanReportPath !== '') { + return (new PhpStanFindingLoader())->loadFromFile($phpStanReportPath); + } + + return (new AnalyzerFindingLoader())->loadFromFile($reportPath); + } + + private function renderResult(OutputInterface $output, EnforcementMatchResult $result, string $format): int + { + if ($format === self::FORMAT_JSON) { + return $this->renderJsonResult($output, $result); + } + + return $this->renderTextResult($output, $result); + } + + private function renderTextResult(OutputInterface $output, EnforcementMatchResult $result): int + { + if (!$result->hasViolations()) { + $output->writeln('No decision-linked violations found.'); + $this->printUnmappedSummary($output, $result->unmappedFindings()); + return Command::SUCCESS; + } + + $output->writeln(sprintf( + 'Decision enforcement failed. Matched %d violation(s) across %d decision(s).', + $result->totalViolations(), + count($result->violationsByDecisionId()) + )); + + foreach ($result->violationsByDecisionId() as $violations) { + if ($violations === []) { + continue; + } + + $decision = $violations[0]->decision(); + $output->writeln(''); + $output->writeln(sprintf('[%s] %s', $decision->id()->value(), $decision->title())); + + foreach ($violations as $violation) { + $output->writeln(' - ' . $this->formatViolation($violation)); + } + } + + $this->printUnmappedSummary($output, $result->unmappedFindings()); + + return Command::FAILURE; + } + + private function renderJsonResult(OutputInterface $output, EnforcementMatchResult $result): int + { + $hasViolations = $result->hasViolations(); + $exitCode = $hasViolations ? Command::FAILURE : Command::SUCCESS; + $payload = [ + 'ok' => !$hasViolations, + 'summary' => [ + 'violating_decision_count' => count($result->violationsByDecisionId()), + 'violation_count' => $result->totalViolations(), + 'unmapped_finding_count' => count($result->unmappedFindings()), + ], + 'violations_by_decision' => $this->violationsByDecisionPayload($result), + 'unmapped_findings' => $this->findingsPayload($result->unmappedFindings()), + ]; + + $this->writeJson($output, $payload); + + return $exitCode; + } + + private function renderError(OutputInterface $output, string $message, string $format): void + { + if ($format === self::FORMAT_JSON) { + $this->writeJson($output, [ + 'ok' => false, + 'error' => $message, + ]); + + return; + } + + $output->writeln('' . $message . ''); + } + + private function renderFailure(OutputInterface $output, string $message, string $format): int + { + try { + $this->renderError($output, $message, $format); + } catch (Throwable) { + try { + if ($format === self::FORMAT_JSON) { + $output->writeln(self::JSON_FALLBACK_ERROR); + } else { + $output->writeln('Unable to render enforcement output.'); + } + } catch (Throwable $ignored) { + return Command::FAILURE; + } + } + + return Command::FAILURE; + } + + private function normalizeFormat(string $format): string + { + return strtolower(trim($format)); + } + + /** + * @return list> + */ + private function violationsByDecisionPayload(EnforcementMatchResult $result): array + { + $payload = []; + + foreach ($result->violationsByDecisionId() as $violations) { + if ($violations === []) { + continue; + } + + $decision = $violations[0]->decision(); + $payload[] = [ + 'decision_id' => $decision->id()->value(), + 'decision_title' => $decision->title(), + 'violations' => $this->violationsPayload($violations), + ]; + } + + return $payload; + } + + /** + * @param list $violations + * @return list> + */ + private function violationsPayload(array $violations): array + { + $payload = []; + + foreach ($violations as $violation) { + $payload[] = $this->findingPayload($violation->finding()); + } + + return $payload; + } + + /** + * @param list $findings + * @return list> + */ + private function findingsPayload(array $findings): array + { + $payload = []; + + foreach ($findings as $finding) { + $payload[] = $this->findingPayload($finding); + } + + return $payload; + } + + /** + * @return array + */ + private function findingPayload(AnalyzerFinding $finding): array + { + return [ + 'tool' => $finding->tool(), + 'rule_id' => $finding->ruleId(), + 'path' => $finding->path(), + 'line' => $finding->line(), + 'severity' => $finding->severity(), + 'message' => $finding->message(), + ]; + } + + /** + * @param array $payload + */ + private function writeJson(OutputInterface $output, array $payload): void + { + try { + $output->writeln(json_encode($payload, JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR)); + } catch (JsonException $e) { + throw new InvalidArgumentException('Unable to encode JSON enforcement output.', 0, $e); + } + } + + private function formatViolation(DecisionViolation $violation): string + { + $finding = $violation->finding(); + $lineSuffix = $this->lineSuffix($finding); + $severity = $this->severityPrefix($finding); + + return sprintf( + '%s%s [%s via %s] %s', + $finding->path(), + $lineSuffix, + $finding->ruleId(), + $finding->tool(), + $severity . $finding->message() + ); + } + + /** + * @param list $unmappedFindings + */ + private function printUnmappedSummary(OutputInterface $output, array $unmappedFindings): void + { + if ($unmappedFindings === []) { + return; + } + + $output->writeln(''); + $output->writeln(sprintf( + '%d finding(s) did not map to any active scoped decision rule.', + count($unmappedFindings) + )); + } + + private function lineSuffix(AnalyzerFinding $finding): string + { + $line = $finding->line(); + if ($line === null) { + return ''; + } + + return ':' . (string) $line; + } + + private function severityPrefix(AnalyzerFinding $finding): string + { + $severity = $finding->severity(); + if ($severity === null) { + return ''; + } + + return strtoupper($severity) . ': '; + } +} diff --git a/src/Decision/Rules.php b/src/Decision/Rules.php index 1a9c2ba..d53bf5b 100644 --- a/src/Decision/Rules.php +++ b/src/Decision/Rules.php @@ -4,25 +4,60 @@ namespace PhpDecide\Decision; +use InvalidArgumentException; + final class Rules { public function __construct( - private readonly array $forbid = [], - private readonly array $allow = [] - ) {} - + array $forbid = [], + array $allow = [] + ) { + $this->forbid = self::normalizeTokenList($forbid, 'rules.forbid'); + $this->allow = self::normalizeTokenList($allow, 'rules.allow'); + } + + /** @var list */ + private readonly array $forbid; + + /** @var list */ + private readonly array $allow; + public function forbid(): array { return $this->forbid; } - + public function allow(): array { return $this->allow; } - + public function hasRules(): bool { return !empty($this->forbid) || !empty($this->allow); } + + /** + * @param array $tokens + * @return list + */ + private static function normalizeTokenList(array $tokens, string $field): array + { + $normalized = []; + + foreach ($tokens as $index => $token) { + if (!is_string($token)) { + throw new InvalidArgumentException(sprintf('%s[%s] must be a non-empty string.', $field, (string) $index)); + } + + $token = trim($token); + if ($token === '') { + throw new InvalidArgumentException(sprintf('%s[%s] must be a non-empty string.', $field, (string) $index)); + } + + $normalized[] = $token; + } + + return $normalized; + } } diff --git a/src/Enforcement/AnalyzerFinding.php b/src/Enforcement/AnalyzerFinding.php new file mode 100644 index 0000000..a3fab4c --- /dev/null +++ b/src/Enforcement/AnalyzerFinding.php @@ -0,0 +1,47 @@ +tool; + } + + public function ruleId(): string + { + return $this->ruleId; + } + + public function path(): string + { + return $this->path; + } + + public function message(): string + { + return $this->message; + } + + public function line(): ?int + { + return $this->line; + } + + public function severity(): ?string + { + return $this->severity; + } +} diff --git a/src/Enforcement/AnalyzerFindingLoader.php b/src/Enforcement/AnalyzerFindingLoader.php new file mode 100644 index 0000000..a3bdf35 --- /dev/null +++ b/src/Enforcement/AnalyzerFindingLoader.php @@ -0,0 +1,98 @@ + + */ + public function loadFromFile(string $filePath): array + { + $decoded = (new JsonFileDecoder())->decodeFile($filePath, 'Enforcement report'); + + $findingsData = $this->extractFindings($decoded); + $findings = []; + + foreach ($findingsData as $index => $findingData) { + if (!is_array($findingData)) { + throw new InvalidArgumentException(sprintf('findings[%d] must be an object', $index)); + } + + $findings[] = new AnalyzerFinding( + tool: $this->stringField($findingData, 'tool', $index), + ruleId: $this->stringField($findingData, 'rule_id', $index), + path: $this->stringField($findingData, 'path', $index), + message: $this->stringField($findingData, 'message', $index), + line: $this->optionalPositiveIntField($findingData, 'line', $index), + severity: $this->optionalStringField($findingData, 'severity', $index), + ); + } + + return $findings; + } + + /** + * @return list + */ + private function extractFindings(mixed $decoded): array + { + if (is_array($decoded) && array_is_list($decoded)) { + return array_values($decoded); + } + + if (is_array($decoded) && array_key_exists('findings', $decoded) && is_array($decoded['findings'])) { + return array_values($decoded['findings']); + } + + throw new InvalidArgumentException('Enforcement report must be a JSON array or an object with a findings array.'); + } + + /** + * @param array $data + */ + private function stringField(array $data, string $field, int $index): string + { + if (!array_key_exists($field, $data) || !is_string($data[$field]) || trim($data[$field]) === '') { + throw new InvalidArgumentException(sprintf('findings[%d].%s must be a non-empty string', $index, $field)); + } + + return trim($data[$field]); + } + + /** + * @param array $data + */ + private function optionalPositiveIntField(array $data, string $field, int $index): ?int + { + if (!array_key_exists($field, $data) || $data[$field] === null) { + return null; + } + + if (!is_int($data[$field]) || $data[$field] < 1) { + throw new InvalidArgumentException(sprintf('findings[%d].%s must be a positive integer', $index, $field)); + } + + return $data[$field]; + } + + /** + * @param array $data + */ + private function optionalStringField(array $data, string $field, int $index): ?string + { + if (!array_key_exists($field, $data) || $data[$field] === null) { + return null; + } + + if (!is_string($data[$field]) || trim($data[$field]) === '') { + throw new InvalidArgumentException(sprintf('findings[%d].%s must be a non-empty string', $index, $field)); + } + + return trim($data[$field]); + } +} diff --git a/src/Enforcement/DecisionViolation.php b/src/Enforcement/DecisionViolation.php new file mode 100644 index 0000000..cc4c20c --- /dev/null +++ b/src/Enforcement/DecisionViolation.php @@ -0,0 +1,25 @@ +decision; + } + + public function finding(): AnalyzerFinding + { + return $this->finding; + } +} diff --git a/src/Enforcement/DecisionViolationMatcher.php b/src/Enforcement/DecisionViolationMatcher.php new file mode 100644 index 0000000..92b178e --- /dev/null +++ b/src/Enforcement/DecisionViolationMatcher.php @@ -0,0 +1,124 @@ + $findings + */ + public function match(DecisionRepository $repository, array $findings): EnforcementMatchResult + { + $violationsByDecisionId = []; + $unmappedFindings = []; + + $applicableDecisionsByPath = []; + foreach ($findings as $finding) { + $matched = false; + $path = $this->normalizeFindingPath($finding->path()); + + if (!array_key_exists($path, $applicableDecisionsByPath)) { + $applicableDecisionsByPath[$path] = $repository->applicableTo($path); + } + + foreach ($applicableDecisionsByPath[$path] as $decision) { + if (!$this->matchesDecision($decision, $finding)) { + continue; + } + + $decisionId = $decision->id()->value(); + $violationsByDecisionId[$decisionId] ??= []; + $violationsByDecisionId[$decisionId][] = new DecisionViolation($decision, $finding); + $matched = true; + } + + if (!$matched) { + $unmappedFindings[] = $finding; + } + } + + return new EnforcementMatchResult( + $this->sortViolationsByDecisionId($violationsByDecisionId), + $this->sortFindings($unmappedFindings), + ); + } + + /** + * @param array> $violationsByDecisionId + * @return array> + */ + private function sortViolationsByDecisionId(array $violationsByDecisionId): array + { + foreach ($violationsByDecisionId as $decisionId => $violations) { + usort( + $violations, + fn(DecisionViolation $left, DecisionViolation $right): int => $this->compareFindings($left->finding(), $right->finding()) + ); + + $violationsByDecisionId[$decisionId] = $violations; + } + + ksort($violationsByDecisionId); + + return $violationsByDecisionId; + } + + /** + * @param list $findings + * @return list + */ + private function sortFindings(array $findings): array + { + usort( + $findings, + fn(AnalyzerFinding $left, AnalyzerFinding $right): int => $this->compareFindings($left, $right) + ); + + return $findings; + } + + private function compareFindings(AnalyzerFinding $left, AnalyzerFinding $right): int + { + return [ + $left->path(), + $left->line() ?? -1, + $left->tool(), + $left->ruleId(), + $left->severity() ?? '', + $left->message(), + ] <=> [ + $right->path(), + $right->line() ?? -1, + $right->tool(), + $right->ruleId(), + $right->severity() ?? '', + $right->message(), + ]; + } + + private function normalizeFindingPath(string $path): string + { + $normalizedPath = str_replace('\\', '/', trim($path)); + + while (str_starts_with($normalizedPath, './')) { + $normalizedPath = substr($normalizedPath, 2); + } + + return $normalizedPath; + } + + private function matchesDecision(Decision $decision, AnalyzerFinding $finding): bool + { + $rules = $decision->rules(); + if ($rules === null) { + return false; + } + + return in_array($finding->ruleId(), $rules->forbid(), true); + } +} diff --git a/src/Enforcement/EnforcementMatchResult.php b/src/Enforcement/EnforcementMatchResult.php new file mode 100644 index 0000000..27867e4 --- /dev/null +++ b/src/Enforcement/EnforcementMatchResult.php @@ -0,0 +1,49 @@ +> $violationsByDecisionId + * @param list $unmappedFindings + */ + public function __construct( + private readonly array $violationsByDecisionId, + private readonly array $unmappedFindings, + ) {} + + /** + * @return array> + */ + public function violationsByDecisionId(): array + { + return $this->violationsByDecisionId; + } + + /** + * @return list + */ + public function unmappedFindings(): array + { + return $this->unmappedFindings; + } + + public function totalViolations(): int + { + $total = 0; + + foreach ($this->violationsByDecisionId as $violations) { + $total += count($violations); + } + + return $total; + } + + public function hasViolations(): bool + { + return $this->totalViolations() > 0; + } +} diff --git a/src/Enforcement/JsonFileDecoder.php b/src/Enforcement/JsonFileDecoder.php new file mode 100644 index 0000000..b41a0bd --- /dev/null +++ b/src/Enforcement/JsonFileDecoder.php @@ -0,0 +1,88 @@ + + */ + public function decodeFile(string $filePath, string $label): array + { + if (!is_file($filePath)) { + throw new InvalidArgumentException(sprintf('%s not found: %s', $label, $filePath)); + } + + if (!is_readable($filePath)) { + throw new InvalidArgumentException(sprintf('Unable to read %s: %s', strtolower($label), $filePath)); + } + + $contents = $this->trapWarnings(static fn() => file_get_contents($filePath)); + if (!is_string($contents)) { + throw new InvalidArgumentException(sprintf('Unable to read %s: %s', strtolower($label), $filePath)); + } + + try { + $decoded = json_decode($this->normalizeEncoding($contents), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new InvalidArgumentException(sprintf('Invalid %s JSON: %s', strtolower($label), $e->getMessage()), 0, $e); + } + + if (!is_array($decoded)) { + throw new InvalidArgumentException(sprintf('%s must decode to a JSON object or array.', $label)); + } + + return $decoded; + } + + private function normalizeEncoding(string $contents): string + { + $encoding = mb_detect_encoding( + $contents, + ['UTF-8', 'UTF-16LE', 'UTF-16BE', 'UTF-32LE', 'UTF-32BE'], + true + ); + + if ($encoding === false || $encoding === 'UTF-8') { + return $this->removeUtf8Bom($contents); + } + + return $this->removeUtf8Bom(mb_convert_encoding($contents, 'UTF-8', $encoding)); + } + + private function removeUtf8Bom(string $contents): string + { + $bom = "\xEF\xBB\xBF"; + if (str_starts_with($contents, $bom)) { + return substr($contents, 3); + } + + return $contents; + } + + private function trapWarnings(callable $fn): mixed + { + $hadWarning = false; + set_error_handler( + static function (int $_) use (&$hadWarning): bool { + $hadWarning = true; + return true; + }, + E_WARNING | E_NOTICE | E_USER_WARNING | E_USER_NOTICE + ); + + try { + $result = $fn(); + } finally { + restore_error_handler(); + } + + return $hadWarning ? null : $result; + } +} + diff --git a/src/Enforcement/PhpStanFindingLoader.php b/src/Enforcement/PhpStanFindingLoader.php new file mode 100644 index 0000000..126635b --- /dev/null +++ b/src/Enforcement/PhpStanFindingLoader.php @@ -0,0 +1,149 @@ + + */ + public function loadFromFile(string $filePath): array + { + $decoded = (new JsonFileDecoder())->decodeFile($filePath, 'PHPStan report'); + $this->assertReportShape($decoded); + + $findings = $this->fileFindings($decoded['files']); + foreach ($this->generalErrorFindings($decoded['errors'] ?? []) as $errorFinding) { + $findings[] = $errorFinding; + } + + return $findings; + } + + /** + * @param array $decoded + */ + private function assertReportShape(array $decoded): void + { + $files = $decoded['files'] ?? null; + $errors = $decoded['errors'] ?? null; + + if (!is_array($files)) { + throw new InvalidArgumentException('PHPStan report must contain a files object.'); + } + + if ($errors !== null && !is_array($errors)) { + throw new InvalidArgumentException('PHPStan report errors field must be an array when provided.'); + } + } + + /** + * @param array $files + * @return list + */ + private function fileFindings(array $files): array + { + $findings = []; + + foreach ($files as $path => $fileData) { + if (!is_string($path) || trim($path) === '') { + throw new InvalidArgumentException('PHPStan report file keys must be non-empty strings.'); + } + + if (!is_array($fileData)) { + throw new InvalidArgumentException(sprintf('PHPStan file entry for %s must be an object.', $path)); + } + + $messages = $fileData['messages'] ?? null; + if (!is_array($messages)) { + throw new InvalidArgumentException(sprintf('PHPStan file entry for %s must contain a messages array.', $path)); + } + + foreach (array_values($messages) as $index => $messageData) { + $findings[] = $this->messageFinding($path, $messageData, $index); + } + } + + return $findings; + } + + private function messageFinding(string $path, mixed $messageData, int $index): AnalyzerFinding + { + if (!is_array($messageData)) { + throw new InvalidArgumentException(sprintf('PHPStan message %d for %s must be an object.', $index, $path)); + } + + return new AnalyzerFinding( + tool: 'phpstan', + ruleId: $this->identifierValue($messageData['identifier'] ?? null), + path: trim($path), + message: $this->stringValue($messageData['message'] ?? null, sprintf('PHPStan message %d for %s', $index, $path)), + line: $this->optionalPositiveIntValue($messageData['line'] ?? null, sprintf('PHPStan message %d line for %s', $index, $path)), + severity: 'error', + ); + } + + /** + * @param array $errors + * @return list + */ + private function generalErrorFindings(array $errors): array + { + $findings = []; + + foreach (array_values($errors) as $index => $error) { + if (!is_string($error) || trim($error) === '') { + throw new InvalidArgumentException(sprintf('PHPStan general error %d must be a non-empty string.', $index)); + } + + $findings[] = new AnalyzerFinding( + tool: 'phpstan', + ruleId: self::GENERAL_ERROR_IDENTIFIER, + path: '.', + message: trim($error), + line: null, + severity: 'error', + ); + } + + return $findings; + } + + private function identifierValue(mixed $value): string + { + if (!is_string($value) || trim($value) === '') { + return self::UNKNOWN_IDENTIFIER; + } + + return trim($value); + } + + private function stringValue(mixed $value, string $field): string + { + if (!is_string($value) || trim($value) === '') { + throw new InvalidArgumentException(sprintf('%s must be a non-empty string.', $field)); + } + + return trim($value); + } + + private function optionalPositiveIntValue(mixed $value, string $field): ?int + { + if ($value === null) { + return null; + } + + if (!is_int($value) || $value < 1) { + throw new InvalidArgumentException(sprintf('%s must be a positive integer when provided.', $field)); + } + + return $value; + } +} diff --git a/src/Enforcement/SemgrepFindingLoader.php b/src/Enforcement/SemgrepFindingLoader.php new file mode 100644 index 0000000..52a31d3 --- /dev/null +++ b/src/Enforcement/SemgrepFindingLoader.php @@ -0,0 +1,118 @@ + + */ + public function loadFromFile(string $filePath): array + { + $decoded = (new JsonFileDecoder())->decodeFile($filePath, 'Semgrep report'); + + if (!is_array($decoded) || !array_key_exists('results', $decoded) || !is_array($decoded['results'])) { + throw new InvalidArgumentException('Semgrep report must be a JSON object with a results array.'); + } + + $findings = []; + foreach (array_values($decoded['results']) as $index => $result) { + $findings[] = $this->parseResult($result, $index); + } + + return $findings; + } + + private function parseResult(mixed $result, int $index): AnalyzerFinding + { + if (!is_array($result)) { + throw new InvalidArgumentException(sprintf('results[%d] must be an object', $index)); + } + + $extra = $this->extraData($result, $index); + + return new AnalyzerFinding( + tool: 'semgrep', + ruleId: $this->stringValue($result['check_id'] ?? null, sprintf('results[%d].check_id', $index)), + path: $this->stringValue($result['path'] ?? null, sprintf('results[%d].path', $index)), + message: $this->stringValue($extra['message'] ?? null, sprintf('results[%d].extra.message', $index)), + line: $this->startLine($result, $index), + severity: $this->optionalStringValue($extra['severity'] ?? null, sprintf('results[%d].extra.severity', $index)), + ); + } + + /** + * @param array $result + * @return array + */ + private function extraData(array $result, int $index): array + { + $extra = $result['extra'] ?? null; + if ($extra === null) { + return []; + } + + if (!is_array($extra)) { + throw new InvalidArgumentException(sprintf('results[%d].extra must be an object when provided', $index)); + } + + return $extra; + } + + /** + * @param array $result + */ + private function startLine(array $result, int $index): ?int + { + $start = $result['start'] ?? null; + if ($start === null) { + return null; + } + + if (!is_array($start)) { + throw new InvalidArgumentException(sprintf('results[%d].start must be an object when provided', $index)); + } + + return $this->optionalPositiveIntValue($start['line'] ?? null, sprintf('results[%d].start.line', $index)); + } + + private function stringValue(mixed $value, string $field): string + { + if (!is_string($value) || trim($value) === '') { + throw new InvalidArgumentException(sprintf('%s must be a non-empty string', $field)); + } + + return trim($value); + } + + private function optionalStringValue(mixed $value, string $field): ?string + { + if ($value === null) { + return null; + } + + if (!is_string($value) || trim($value) === '') { + throw new InvalidArgumentException(sprintf('%s must be a non-empty string when provided', $field)); + } + + return trim($value); + } + + private function optionalPositiveIntValue(mixed $value, string $field): ?int + { + if ($value === null) { + return null; + } + + if (!is_int($value) || $value < 1) { + throw new InvalidArgumentException(sprintf('%s must be a positive integer when provided', $field)); + } + + return $value; + } +} + diff --git a/tests/CLI/DecisionsLintCommandTest.php b/tests/CLI/DecisionsLintCommandTest.php index 256027c..ff6fb41 100644 --- a/tests/CLI/DecisionsLintCommandTest.php +++ b/tests/CLI/DecisionsLintCommandTest.php @@ -277,6 +277,39 @@ public function testFailsWhenStringListFieldsContainNonStringOrEmptyItems(): voi self::assertStringContainsString('ai.keywords[0] must be a non-empty string', $display); } + public function testDoesNotDuplicateRulesForbidErrorsAfterArrayValidationFails(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0001-invalid-rules.yaml', + <<execute(['--dir' => $decisionsDir]); + + self::assertSame(Command::FAILURE, $exitCode); + + $display = $tester->getDisplay(true); + self::assertSame(1, substr_count($display, 'rules.forbid[0] must be a non-empty string')); + } + public function testDefaultsToProjectDecisionsDirWhenDirOptionIsEmptyString(): void { $projectDir = $this->createTempProjectDir(); diff --git a/tests/CLI/EnforceCommandTest.php b/tests/CLI/EnforceCommandTest.php new file mode 100644 index 0000000..ca969ba --- /dev/null +++ b/tests/CLI/EnforceCommandTest.php @@ -0,0 +1,1109 @@ + */ + private array $tempDirs = []; + + private string|false $originalDecisionsCacheEnv; + + protected function setUp(): void + { + $this->originalDecisionsCacheEnv = getenv(self::ENV_DECISIONS_CACHE); + } + + public function testFailsWhenReportContainsDecisionLinkedViolations(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'findings.json'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0003-no-orm.yaml', + $this->decisionYamlWithRules('DEC-0003', 'No ORM in Order domain', ['src/Order/*'], ['doctrine/orm']) + ); + + $this->writeFile( + $reportPath, + json_encode([ + 'findings' => [[ + 'tool' => 'semgrep', + 'rule_id' => 'doctrine/orm', + 'path' => 'src/Order/OrderService.php', + 'line' => 12, + 'severity' => 'error', + 'message' => 'Doctrine ORM import detected.', + ]], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--report' => $reportPath, + ]); + + self::assertSame(Command::FAILURE, $exitCode); + + $display = $tester->getDisplay(true); + self::assertStringContainsString('Decision enforcement failed.', $display); + self::assertStringContainsString('[DEC-0003] No ORM in Order domain', $display); + self::assertStringContainsString('src/Order/OrderService.php:12 [doctrine/orm via semgrep]', $display); + } + + public function testJsonFormatReturnsStructuredViolations(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'findings.json'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0003-no-orm.yaml', + $this->decisionYamlWithRules('DEC-0003', 'No ORM in Order domain', ['src/Order/*'], ['doctrine/orm']) + ); + + $this->writeFile( + $reportPath, + json_encode([ + 'findings' => [[ + 'tool' => 'semgrep', + 'rule_id' => 'doctrine/orm', + 'path' => 'src/Order/OrderService.php', + 'line' => 12, + 'severity' => 'error', + 'message' => 'Doctrine ORM import detected.', + ]], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--report' => $reportPath, + '--format' => 'json', + ]); + + self::assertSame(Command::FAILURE, $exitCode); + + $payload = $this->decodeJsonOutput($tester->getDisplay(true)); + self::assertFalse($payload['ok']); + self::assertSame(1, $payload['summary']['violating_decision_count']); + self::assertSame(1, $payload['summary']['violation_count']); + self::assertSame('DEC-0003', $payload['violations_by_decision'][0]['decision_id']); + self::assertSame('No ORM in Order domain', $payload['violations_by_decision'][0]['decision_title']); + self::assertSame('doctrine/orm', $payload['violations_by_decision'][0]['violations'][0]['rule_id']); + self::assertSame('src/Order/OrderService.php', $payload['violations_by_decision'][0]['violations'][0]['path']); + } + + public function testJsonFormatMatchesScopedDecisionRulesForWindowsStylePaths(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'findings.json'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0003-no-orm.yaml', + $this->decisionYamlWithRules('DEC-0003', 'No ORM in Order domain', ['src/Order/*'], ['doctrine/orm']) + ); + + $this->writeFile( + $reportPath, + json_encode([ + 'findings' => [[ + 'tool' => 'phpstan', + 'rule_id' => 'doctrine/orm', + 'path' => '.\\src\\Order\\OrderService.php', + 'line' => 12, + 'severity' => 'error', + 'message' => 'Doctrine ORM import detected.', + ]], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--report' => $reportPath, + '--format' => 'json', + ]); + + self::assertSame(Command::FAILURE, $exitCode); + + $payload = $this->decodeJsonOutput($tester->getDisplay(true)); + self::assertFalse($payload['ok']); + self::assertSame(1, $payload['summary']['violating_decision_count']); + self::assertSame('DEC-0003', $payload['violations_by_decision'][0]['decision_id']); + self::assertSame('.\\src\\Order\\OrderService.php', $payload['violations_by_decision'][0]['violations'][0]['path']); + } + + public function testJsonFormatMatchesDecisionRulesAfterTrimmingRuleTokens(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'findings.json'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0003-no-orm.yaml', + $this->decisionYamlWithRules('DEC-0003', 'No ORM in Order domain', ['src/Order/*'], [' doctrine/orm ']) + ); + + $this->writeFile( + $reportPath, + json_encode([ + 'findings' => [[ + 'tool' => 'semgrep', + 'rule_id' => 'doctrine/orm', + 'path' => 'src/Order/OrderService.php', + 'line' => 12, + 'severity' => 'error', + 'message' => 'Doctrine ORM import detected.', + ]], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--report' => $reportPath, + '--format' => 'json', + ]); + + self::assertSame(Command::FAILURE, $exitCode); + + $payload = $this->decodeJsonOutput($tester->getDisplay(true)); + self::assertFalse($payload['ok']); + self::assertSame(1, $payload['summary']['violating_decision_count']); + self::assertSame('DEC-0003', $payload['violations_by_decision'][0]['decision_id']); + self::assertSame('doctrine/orm', $payload['violations_by_decision'][0]['violations'][0]['rule_id']); + } + + public function testSucceedsWhenFindingsDoNotMapToScopedDecisionRules(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'findings.json'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0003-no-orm.yaml', + $this->decisionYamlWithRules('DEC-0003', 'No ORM in Order domain', ['src/Order/*'], ['doctrine/orm']) + ); + + $this->writeFile( + $reportPath, + json_encode([[ + 'tool' => 'semgrep', + 'rule_id' => 'doctrine/orm', + 'path' => 'src/Infrastructure/Persistence/Doctrine/OrderRecord.php', + 'message' => 'Doctrine ORM import detected.', + ]], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--report' => $reportPath, + ]); + + self::assertSame(Command::SUCCESS, $exitCode); + + $display = $tester->getDisplay(true); + self::assertStringContainsString('No decision-linked violations found.', $display); + self::assertStringContainsString('1 finding(s) did not map to any active scoped decision rule.', $display); + } + + public function testJsonFormatReturnsStructuredSuccessWithUnmappedFindings(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'findings.json'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0003-no-orm.yaml', + $this->decisionYamlWithRules('DEC-0003', 'No ORM in Order domain', ['src/Order/*'], ['doctrine/orm']) + ); + + $this->writeFile( + $reportPath, + json_encode([[ + 'tool' => 'semgrep', + 'rule_id' => 'doctrine/orm', + 'path' => 'src/Infrastructure/Persistence/Doctrine/OrderRecord.php', + 'message' => 'Doctrine ORM import detected.', + ]], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--report' => $reportPath, + '--format' => 'json', + ]); + + self::assertSame(Command::SUCCESS, $exitCode); + + $payload = $this->decodeJsonOutput($tester->getDisplay(true)); + self::assertTrue($payload['ok']); + self::assertSame(0, $payload['summary']['violating_decision_count']); + self::assertSame(0, $payload['summary']['violation_count']); + self::assertSame(1, $payload['summary']['unmapped_finding_count']); + self::assertCount(0, $payload['violations_by_decision']); + self::assertSame('doctrine/orm', $payload['unmapped_findings'][0]['rule_id']); + } + + public function testLoadsGenericReportEncodedAsUtf16Json(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'findings.json'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0003-no-orm.yaml', + $this->decisionYamlWithRules('DEC-0003', 'No ORM in Order domain', ['src/Order/*'], ['doctrine/orm']) + ); + + $json = json_encode([ + 'findings' => [[ + 'tool' => 'semgrep', + 'rule_id' => 'doctrine/orm', + 'path' => 'src/Order/OrderService.php', + 'line' => 12, + 'severity' => 'error', + 'message' => 'Doctrine ORM import detected.', + ]], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); + self::assertIsString($json); + + $this->writeFile($reportPath, mb_convert_encoding($json, 'UTF-16LE', 'UTF-8')); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--report' => $reportPath, + '--format' => 'json', + ]); + + self::assertSame(Command::FAILURE, $exitCode); + + $payload = $this->decodeJsonOutput($tester->getDisplay(true)); + self::assertFalse($payload['ok']); + self::assertSame('DEC-0003', $payload['violations_by_decision'][0]['decision_id']); + } + + public function testFailsForInvalidReportShape(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'findings.json'; + + $this->writeFile( + $reportPath, + json_encode([ + 'findings' => [[ + 'tool' => 'semgrep', + 'path' => 'src/Order/OrderService.php', + 'message' => 'Doctrine ORM import detected.', + ]], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--report' => $reportPath, + ]); + + self::assertSame(Command::FAILURE, $exitCode); + self::assertStringContainsString('findings[0].rule_id must be a non-empty string', $tester->getDisplay(true)); + } + + public function testFailsWhenNativeSemgrepReportContainsDecisionLinkedViolations(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'semgrep.json'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0003-no-orm.yaml', + $this->decisionYamlWithRules('DEC-0003', 'No ORM in Order domain', ['src/Order/*'], ['doctrine/orm']) + ); + + $this->writeFile( + $reportPath, + json_encode([ + 'results' => [[ + 'check_id' => 'doctrine/orm', + 'path' => 'src/Order/OrderService.php', + 'start' => ['line' => 7], + 'extra' => [ + 'message' => 'Doctrine ORM import detected.', + 'severity' => 'error', + ], + ]], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--semgrep-report' => $reportPath, + ]); + + self::assertSame(Command::FAILURE, $exitCode); + + $display = $tester->getDisplay(true); + self::assertStringContainsString('[DEC-0003] No ORM in Order domain', $display); + self::assertStringContainsString('src/Order/OrderService.php:7 [doctrine/orm via semgrep]', $display); + } + + public function testLoadsSemgrepReportEncodedAsUtf16Json(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'semgrep.json'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0003-no-orm.yaml', + $this->decisionYamlWithRules('DEC-0003', 'No ORM in Order domain', ['src/Order/*'], ['doctrine/orm']) + ); + + $json = json_encode([ + 'results' => [[ + 'check_id' => 'doctrine/orm', + 'path' => 'src/Order/OrderService.php', + 'start' => ['line' => 7], + 'extra' => [ + 'message' => 'Doctrine ORM import detected.', + 'severity' => 'error', + ], + ]], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); + self::assertIsString($json); + + $this->writeFile($reportPath, mb_convert_encoding($json, 'UTF-16LE', 'UTF-8')); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--semgrep-report' => $reportPath, + '--format' => 'json', + ]); + + self::assertSame(Command::FAILURE, $exitCode); + + $payload = $this->decodeJsonOutput($tester->getDisplay(true)); + self::assertFalse($payload['ok']); + self::assertSame('DEC-0003', $payload['violations_by_decision'][0]['decision_id']); + } + + public function testFailsWhenNativePhpStanReportContainsDecisionLinkedViolations(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'phpstan.json'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0005-phpstan-no-orm.yaml', + $this->decisionYamlWithRules('DEC-0005', 'No ORM in Order domain via PHPStan', ['src/Order/*'], ['phpstan.doctrine.orm']) + ); + + $this->writeFile( + $reportPath, + json_encode([ + 'totals' => [ + 'errors' => 0, + 'file_errors' => 1, + ], + 'files' => [ + 'src/Order/OrderService.php' => [ + 'errors' => 1, + 'messages' => [[ + 'message' => 'Doctrine ORM usage is not allowed in the Order domain.', + 'line' => 14, + 'identifier' => 'phpstan.doctrine.orm', + ]], + ], + ], + 'errors' => [], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--phpstan-report' => $reportPath, + '--format' => 'json', + ]); + + self::assertSame(Command::FAILURE, $exitCode); + + $payload = $this->decodeJsonOutput($tester->getDisplay(true)); + self::assertFalse($payload['ok']); + self::assertSame('DEC-0005', $payload['violations_by_decision'][0]['decision_id']); + self::assertSame('phpstan', $payload['violations_by_decision'][0]['violations'][0]['tool']); + self::assertSame('phpstan.doctrine.orm', $payload['violations_by_decision'][0]['violations'][0]['rule_id']); + } + + public function testLoadsPhpStanReportEncodedAsUtf16Json(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'phpstan.json'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0005-phpstan-no-orm.yaml', + $this->decisionYamlWithRules('DEC-0005', 'No ORM in Order domain via PHPStan', ['src/Order/*'], ['phpstan.doctrine.orm']) + ); + + $json = json_encode([ + 'totals' => [ + 'errors' => 0, + 'file_errors' => 1, + ], + 'files' => [ + 'src/Order/OrderService.php' => [ + 'errors' => 1, + 'messages' => [[ + 'message' => 'Doctrine ORM usage is not allowed in the Order domain.', + 'line' => 14, + 'identifier' => 'phpstan.doctrine.orm', + ]], + ], + ], + 'errors' => [], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); + self::assertIsString($json); + + $this->writeFile($reportPath, mb_convert_encoding($json, 'UTF-16LE', 'UTF-8')); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--phpstan-report' => $reportPath, + '--format' => 'json', + ]); + + self::assertSame(Command::FAILURE, $exitCode); + + $payload = $this->decodeJsonOutput($tester->getDisplay(true)); + self::assertFalse($payload['ok']); + self::assertSame('DEC-0005', $payload['violations_by_decision'][0]['decision_id']); + } + + public function testPhpStanReportMapsToDec0005WithoutUsingRepositoryDecisions(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'phpstan.json'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0005-phpstan-no-orm.yaml', + $this->decisionYamlWithRules( + 'DEC-0005', + 'No ORM in Order domain via PHPStan', + ['examples/fixtures/phpstan/src/Order/*', 'examples/fixtures/phpstan/src/Order/**/*'], + ['phpstan.doctrine.orm'] + ) + ); + + $this->writeFile( + $reportPath, + json_encode([ + 'totals' => [ + 'errors' => 0, + 'file_errors' => 1, + ], + 'files' => [ + 'examples/fixtures/phpstan/src/Order/OrderService.php' => [ + 'errors' => 1, + 'messages' => [[ + 'message' => 'Doctrine ORM symbol detected in the Order domain example.', + 'line' => 7, + 'identifier' => 'phpstan.doctrine.orm', + ]], + ], + ], + 'errors' => [], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--phpstan-report' => $reportPath, + '--format' => 'json', + ]); + + self::assertSame(Command::FAILURE, $exitCode); + + $payload = $this->decodeJsonOutput($tester->getDisplay(true)); + self::assertFalse($payload['ok']); + self::assertSame(1, $payload['summary']['violating_decision_count']); + self::assertSame(1, $payload['summary']['violation_count']); + self::assertSame(0, $payload['summary']['unmapped_finding_count']); + self::assertSame('DEC-0005', $payload['violations_by_decision'][0]['decision_id']); + self::assertSame('No ORM in Order domain via PHPStan', $payload['violations_by_decision'][0]['decision_title']); + self::assertSame('phpstan', $payload['violations_by_decision'][0]['violations'][0]['tool']); + self::assertSame('phpstan.doctrine.orm', $payload['violations_by_decision'][0]['violations'][0]['rule_id']); + self::assertSame('examples/fixtures/phpstan/src/Order/OrderService.php', $payload['violations_by_decision'][0]['violations'][0]['path']); + self::assertSame(7, $payload['violations_by_decision'][0]['violations'][0]['line']); + } + + public function testFailsWhenNativeSemgrepReportMatchesTemplateDecision(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'semgrep.json'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0004-no-template-logic.yaml', + $this->decisionYamlWithRules( + 'DEC-0004', + 'No business logic in templates', + ['examples/fixtures/templates/*', 'examples/fixtures/templates/**/*'], + ['twig/business-logic'] + ) + ); + + $this->writeFile( + $reportPath, + json_encode([ + 'results' => [[ + 'check_id' => 'twig/business-logic', + 'path' => 'examples/fixtures/templates/order/calculate_total.html.twig', + 'start' => ['line' => 1], + 'extra' => [ + 'message' => 'Business logic is not allowed in templates.', + 'severity' => 'error', + ], + ]], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--semgrep-report' => $reportPath, + ]); + + self::assertSame(Command::FAILURE, $exitCode); + + $display = $tester->getDisplay(true); + self::assertStringContainsString('[DEC-0004] No business logic in templates', $display); + self::assertStringContainsString('examples/fixtures/templates/order/calculate_total.html.twig:1 [twig/business-logic via semgrep]', $display); + } + + public function testFailsWhenBothReportOptionsAreProvided(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'findings.json'; + $semgrepReportPath = $projectDir . DIRECTORY_SEPARATOR . 'semgrep.json'; + $phpStanReportPath = $projectDir . DIRECTORY_SEPARATOR . 'phpstan.json'; + + $this->writeFile($reportPath, json_encode([], JSON_THROW_ON_ERROR)); + $this->writeFile($semgrepReportPath, json_encode(['results' => []], JSON_THROW_ON_ERROR)); + $this->writeFile($phpStanReportPath, json_encode(['files' => [], 'errors' => []], JSON_THROW_ON_ERROR)); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--report' => $reportPath, + '--semgrep-report' => $semgrepReportPath, + '--phpstan-report' => $phpStanReportPath, + ]); + + self::assertSame(Command::FAILURE, $exitCode); + self::assertStringContainsString('Use only one of --report, --semgrep-report, or --phpstan-report.', $tester->getDisplay(true)); + } + + public function testDeterministicallyOrdersViolationsAndUnmappedFindingsAcrossOutputs(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'findings.json'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0009-no-billing-api.yaml', + $this->decisionYamlWithRules('DEC-0009', 'No billing API client in Billing domain', ['src/Billing/*'], ['billing/api-client']) + ); + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0003-no-orm.yaml', + $this->decisionYamlWithRules('DEC-0003', 'No ORM in Order domain', ['src/Order/*'], ['doctrine/orm']) + ); + + $this->writeFile( + $reportPath, + json_encode([ + 'findings' => [ + [ + 'tool' => 'semgrep', + 'rule_id' => 'unmapped/z-rule', + 'path' => 'src/Zeta/Feature.php', + 'line' => 20, + 'message' => 'Later unmapped finding.', + ], + [ + 'tool' => 'semgrep', + 'rule_id' => 'billing/api-client', + 'path' => 'src/Billing/BillingService.php', + 'line' => 8, + 'message' => 'Billing API client detected.', + ], + [ + 'tool' => 'semgrep', + 'rule_id' => 'doctrine/orm', + 'path' => 'src/Order/OrderService.php', + 'line' => 40, + 'message' => 'Later ORM usage detected.', + ], + [ + 'tool' => 'phpstan', + 'rule_id' => 'unmapped/a-rule', + 'path' => 'src/Alpha/Feature.php', + 'line' => 2, + 'message' => 'Earlier unmapped finding.', + ], + [ + 'tool' => 'ztool', + 'rule_id' => 'doctrine/orm', + 'path' => 'src/Order/OrderService.php', + 'line' => 12, + 'message' => 'Same line, later tool sort key.', + ], + [ + 'tool' => 'atool', + 'rule_id' => 'doctrine/orm', + 'path' => 'src/Order/OrderService.php', + 'line' => 12, + 'message' => 'Same line, earlier tool sort key.', + ], + ], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $textTester = new CommandTester(new EnforceCommand()); + $textExitCode = $textTester->execute([ + '--dir' => $decisionsDir, + '--report' => $reportPath, + ]); + + self::assertSame(Command::FAILURE, $textExitCode); + + $display = $textTester->getDisplay(true); + $dec0003Position = strpos($display, '[DEC-0003] No ORM in Order domain'); + $dec0009Position = strpos($display, '[DEC-0009] No billing API client in Billing domain'); + $atoolPosition = strpos($display, 'src/Order/OrderService.php:12 [doctrine/orm via atool] Same line, earlier tool sort key.'); + $ztoolPosition = strpos($display, 'src/Order/OrderService.php:12 [doctrine/orm via ztool] Same line, later tool sort key.'); + $laterLinePosition = strpos($display, 'src/Order/OrderService.php:40 [doctrine/orm via semgrep] Later ORM usage detected.'); + + self::assertIsInt($dec0003Position); + self::assertIsInt($dec0009Position); + self::assertIsInt($atoolPosition); + self::assertIsInt($ztoolPosition); + self::assertIsInt($laterLinePosition); + self::assertLessThan($dec0009Position, $dec0003Position); + self::assertLessThan($ztoolPosition, $atoolPosition); + self::assertLessThan($laterLinePosition, $ztoolPosition); + + $jsonTester = new CommandTester(new EnforceCommand()); + $jsonExitCode = $jsonTester->execute([ + '--dir' => $decisionsDir, + '--report' => $reportPath, + '--format' => 'json', + ]); + + self::assertSame(Command::FAILURE, $jsonExitCode); + + $payload = $this->decodeJsonOutput($jsonTester->getDisplay(true)); + self::assertSame('DEC-0003', $payload['violations_by_decision'][0]['decision_id']); + self::assertSame('DEC-0009', $payload['violations_by_decision'][1]['decision_id']); + self::assertSame('atool', $payload['violations_by_decision'][0]['violations'][0]['tool']); + self::assertSame('ztool', $payload['violations_by_decision'][0]['violations'][1]['tool']); + self::assertSame(40, $payload['violations_by_decision'][0]['violations'][2]['line']); + self::assertSame('src/Alpha/Feature.php', $payload['unmapped_findings'][0]['path']); + self::assertSame('src/Zeta/Feature.php', $payload['unmapped_findings'][1]['path']); + } + + public function testNoCacheOptionPreventsDecisionCacheWrites(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'findings.json'; + $cachePath = $decisionsDir . DIRECTORY_SEPARATOR . '.phpdecide-decisions.cache'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0003-no-orm.yaml', + $this->decisionYamlWithRules('DEC-0003', 'No ORM in Order domain', ['src/Order/*'], ['doctrine/orm']) + ); + + $this->writeFile( + $reportPath, + json_encode([ + 'findings' => [[ + 'tool' => 'semgrep', + 'rule_id' => 'doctrine/orm', + 'path' => 'src/Order/OrderService.php', + 'line' => 12, + 'severity' => 'error', + 'message' => 'Doctrine ORM import detected.', + ]], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--report' => $reportPath, + '--no-cache' => true, + ]); + + self::assertSame(Command::FAILURE, $exitCode); + self::assertFileDoesNotExist($cachePath); + } + + public function testDecisionsCacheEnvironmentVariableDisablesCacheWrites(): void + { + putenv(self::ENV_DECISIONS_CACHE . '=0'); + + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'findings.json'; + $cachePath = $decisionsDir . DIRECTORY_SEPARATOR . '.phpdecide-decisions.cache'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0003-no-orm.yaml', + $this->decisionYamlWithRules('DEC-0003', 'No ORM in Order domain', ['src/Order/*'], ['doctrine/orm']) + ); + + $this->writeFile( + $reportPath, + json_encode([ + 'findings' => [[ + 'tool' => 'semgrep', + 'rule_id' => 'doctrine/orm', + 'path' => 'src/Order/OrderService.php', + 'line' => 12, + 'severity' => 'error', + 'message' => 'Doctrine ORM import detected.', + ]], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--report' => $reportPath, + ]); + + self::assertSame(Command::FAILURE, $exitCode); + self::assertFileDoesNotExist($cachePath); + } + + public function testDecisionYamlWithRulesProducesValidYamlWhenPathsAndRulesAreEmpty(): void + { + $parsed = Yaml::parse($this->decisionYamlWithRules('DEC-0999', 'Empty lists remain valid YAML', [], [])); + + self::assertIsArray($parsed); + self::assertSame([], $parsed['scope']['paths']); + self::assertSame([], $parsed['rules']['forbid']); + self::assertSame([], $parsed['rules']['allow']); + } + + public function testJsonFormatReturnsStructuredErrorForInvalidReport(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'findings.json'; + + $this->writeFile( + $reportPath, + json_encode([ + 'findings' => [[ + 'tool' => 'semgrep', + 'path' => 'src/Order/OrderService.php', + 'message' => 'Doctrine ORM import detected.', + ]], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $decisionsDir, + '--report' => $reportPath, + '--format' => 'json', + ]); + + self::assertSame(Command::FAILURE, $exitCode); + + $payload = $this->decodeJsonOutput($tester->getDisplay(true)); + self::assertFalse($payload['ok']); + self::assertStringContainsString('findings[0].rule_id must be a non-empty string', $payload['error']); + } + + public function testJsonFormatReturnsStructuredErrorWhenInitialRenderFails(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'findings.json'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0003-no-orm.yaml', + $this->decisionYamlWithRules('DEC-0003', 'No ORM in Order domain', ['src/Order/*'], ['doctrine/orm']) + ); + + $this->writeFile( + $reportPath, + json_encode([ + 'findings' => [[ + 'tool' => 'semgrep', + 'rule_id' => 'doctrine/orm', + 'path' => 'src/Order/OrderService.php', + 'line' => 12, + 'severity' => 'error', + 'message' => 'Doctrine ORM import detected.', + ]], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $command = new EnforceCommand(); + $output = new class extends BufferedOutput { + private bool $shouldFail = true; + + public function writeln(string|iterable $messages, int $options = self::OUTPUT_NORMAL): void + { + if ($this->shouldFail) { + $this->shouldFail = false; + + throw new TestFilesystemException('Simulated output failure.'); + } + + parent::writeln($messages, $options); + } + }; + + $exitCode = $command->run(new ArrayInput([ + '--dir' => $decisionsDir, + '--report' => $reportPath, + '--format' => 'json', + ]), $output); + + self::assertSame(Command::FAILURE, $exitCode); + + $payload = $this->decodeJsonOutput($output->fetch()); + self::assertFalse($payload['ok']); + self::assertStringContainsString('Unable to render enforcement output: Simulated output failure.', $payload['error']); + } + + public function testJsonFormatFallbackOutputIsValidJsonWhenErrorRenderingFailsTwice(): void + { + $projectDir = $this->createTempProjectDir(); + $decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'findings.json'; + + $this->writeFile( + $decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0003-no-orm.yaml', + $this->decisionYamlWithRules('DEC-0003', 'No ORM in Order domain', ['src/Order/*'], ['doctrine/orm']) + ); + + $this->writeFile( + $reportPath, + json_encode([ + 'findings' => [[ + 'tool' => 'semgrep', + 'rule_id' => 'doctrine/orm', + 'path' => 'src/Order/OrderService.php', + 'line' => 12, + 'severity' => 'error', + 'message' => 'Doctrine ORM import detected.', + ]], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + ); + + $command = new EnforceCommand(); + $output = new class extends BufferedOutput { + private int $failureCount = 0; + + public function writeln(string|iterable $messages, int $options = self::OUTPUT_NORMAL): void + { + if ($this->failureCount < 2) { + $this->failureCount++; + + throw new TestFilesystemException('Simulated repeated output failure.'); + } + + parent::writeln($messages, $options); + } + }; + + $exitCode = $command->run(new ArrayInput([ + '--dir' => $decisionsDir, + '--report' => $reportPath, + '--format' => 'json', + ]), $output); + + self::assertSame(Command::FAILURE, $exitCode); + $rawOutput = trim($output->fetch()); + self::assertSame( + '{"ok":false,"error":"Unable to render enforcement output."}', + $rawOutput + ); + + $payload = $this->decodeJsonOutput($rawOutput); + self::assertFalse($payload['ok']); + self::assertSame('Unable to render enforcement output.', $payload['error']); + } + + public function testJsonFormatSubstitutesInvalidUtf8InErrorMessages(): void + { + $projectDir = $this->createTempProjectDir(); + $reportPath = $projectDir . DIRECTORY_SEPARATOR . 'findings.json'; + $invalidDir = $projectDir . DIRECTORY_SEPARATOR . "bad\xB1dir"; + + $this->writeFile($reportPath, json_encode([], JSON_THROW_ON_ERROR)); + + $tester = new CommandTester(new EnforceCommand()); + $exitCode = $tester->execute([ + '--dir' => $invalidDir, + '--report' => $reportPath, + '--format' => 'json', + ]); + + self::assertSame(Command::FAILURE, $exitCode); + + $payload = $this->decodeJsonOutput($tester->getDisplay(true)); + self::assertFalse($payload['ok']); + self::assertStringContainsString('Decisions directory not found:', $payload['error']); + self::assertStringContainsString('efbfbd', bin2hex($payload['error'])); + } + + protected function tearDown(): void + { + if ($this->originalDecisionsCacheEnv !== false && $this->originalDecisionsCacheEnv !== '') { + putenv(self::ENV_DECISIONS_CACHE . '=' . $this->originalDecisionsCacheEnv); + } else { + putenv(self::ENV_DECISIONS_CACHE); + } + + foreach ($this->tempDirs as $dir) { + if (is_dir($dir)) { + $this->removeDirRecursive($dir); + } + } + + $this->tempDirs = []; + } + + private function createTempProjectDir(): string + { + $dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'phpdecide_enforce_tests_' . bin2hex(random_bytes(8)); + if (!mkdir($dir) && !is_dir($dir)) { + throw new TestFilesystemException('Unable to create temp dir: ' . $dir); + } + + $decisionsDir = $dir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR; + if (!mkdir($decisionsDir) && !is_dir($decisionsDir)) { + throw new TestFilesystemException('Unable to create decisions dir: ' . $decisionsDir); + } + + $this->tempDirs[] = $dir; + + return $dir; + } + + /** @param list $paths + * @param list $forbidRules + */ + private function decisionYamlWithRules(string $id, string $title, array $paths, array $forbidRules): string + { + $lines = [ + "id: {$id}", + "title: {$title}", + 'status: active', + "date: '2026-03-19'", + 'scope:', + ' type: path', + ]; + + if ($paths === []) { + $lines[] = ' paths: []'; + } else { + $lines[] = ' paths:'; + foreach ($paths as $path) { + $lines[] = " - {$path}"; + } + } + + $lines[] = 'decision:'; + $lines[] = ' summary: Keep ORM out of the domain layer.'; + $lines[] = ' rationale:'; + $lines[] = ' - Preserve persistence ignorance.'; + $lines[] = 'rules:'; + + if ($forbidRules === []) { + $lines[] = ' forbid: []'; + } else { + $lines[] = ' forbid:'; + foreach ($forbidRules as $rule) { + $lines[] = " - {$rule}"; + } + } + + $lines[] = ' allow: []'; + + return implode("\n", $lines) . "\n"; + } + + private function writeFile(string $path, string $contents): void + { + if (file_put_contents($path, $contents) === false) { + throw new TestFilesystemException(sprintf('Unable to write file: %s', $path)); + } + } + + /** + * @return array + */ + private function decodeJsonOutput(string $output): array + { + $decoded = json_decode($output, true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($decoded); + + return $decoded; + } + + private function removeDirRecursive(string $dir): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $fileInfo) { + if ($fileInfo->isDir()) { + rmdir($fileInfo->getPathname()); + continue; + } + + unlink($fileInfo->getPathname()); + } + + rmdir($dir); + } +} diff --git a/tests/CLI/ExplainCommandTest.php b/tests/CLI/ExplainCommandTest.php index b835cb6..c04dc10 100644 --- a/tests/CLI/ExplainCommandTest.php +++ b/tests/CLI/ExplainCommandTest.php @@ -6,6 +6,7 @@ use PhpDecide\CLI\ExplainCommand; use PhpDecide\Config\PhpDecideDefaults; +use PhpDecide\Tests\Support\TestFilesystemException; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; @@ -24,7 +25,7 @@ protected function setUp(): void { $cwd = getcwd(); if ($cwd === false) { - throw new UnableToDetermineCwd('Unable to determine current working directory.'); + throw new TestFilesystemException('Unable to determine current working directory.'); } $this->originalCwd = $cwd; @@ -154,7 +155,7 @@ private function createTempProjectDir(bool $withDecisionsDir = true): string { $dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'phpdecide_cli_tests_' . bin2hex(random_bytes(8)); if (!mkdir($dir) && !is_dir($dir)) { - throw new TempDirectoryCreationFailed("Unable to create temp dir: {$dir}"); + throw new TestFilesystemException("Unable to create temp dir: {$dir}"); } $this->tempDirs[] = $dir; @@ -229,11 +230,3 @@ private function removeDirRecursive(string $dir): void rmdir($dir); } } - -final class UnableToDetermineCwd extends \RuntimeException -{ -} - -final class TempDirectoryCreationFailed extends \RuntimeException -{ -} diff --git a/tests/CLI/RenderPhpDecidePrCommentTest.php b/tests/CLI/RenderPhpDecidePrCommentTest.php new file mode 100644 index 0000000..afb05ca --- /dev/null +++ b/tests/CLI/RenderPhpDecidePrCommentTest.php @@ -0,0 +1,94 @@ + */ + private array $tempFiles = []; + + protected function tearDown(): void + { + foreach ($this->tempFiles as $path) { + if (is_file($path)) { + unlink($path); + } + } + + $this->tempFiles = []; + } + + public function testRendererUsesViolatingDecisionCountSummaryField(): void + { + $inputPath = $this->createTempJsonFile(json_encode([ + 'ok' => false, + 'summary' => [ + 'violating_decision_count' => 2, + 'violation_count' => 3, + 'unmapped_finding_count' => 1, + ], + 'violations_by_decision' => [], + 'unmapped_findings' => [], + ], JSON_THROW_ON_ERROR)); + + $result = $this->runRenderer($inputPath); + + self::assertSame(0, $result['exitCode'], $result['stderr']); + self::assertStringContainsString("\n- violating_decision_count: 2\n", $result['stdout']); + self::assertStringNotContainsString("\n- decision_count: 2\n", $result['stdout']); + } + + private function createTempJsonFile(string $contents): string + { + $path = tempnam(sys_get_temp_dir(), 'phpdecide_pr_comment_'); + if ($path === false) { + throw new TestFilesystemException('Unable to create temp file for PR comment renderer test.'); + } + + if (file_put_contents($path, $contents) === false) { + throw new TestFilesystemException(sprintf('Unable to write temp file: %s', $path)); + } + + $this->tempFiles[] = $path; + + return $path; + } + + /** + * @return array{exitCode: int, stdout: string, stderr: string} + */ + private function runRenderer(string $inputPath): array + { + $scriptPath = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'examples' . DIRECTORY_SEPARATOR . 'github-actions' . DIRECTORY_SEPARATOR . 'render-phpdecide-pr-comment.php'; + $command = sprintf('%s %s %s', escapeshellarg(PHP_BINARY), escapeshellarg($scriptPath), escapeshellarg($inputPath)); + + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($command, $descriptorSpec, $pipes); + if (!is_resource($process)) { + throw new TestFilesystemException('Unable to start PR comment renderer process.'); + } + + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[2]); + $exitCode = proc_close($process); + + return [ + 'exitCode' => $exitCode, + 'stdout' => $stdout === false ? '' : $stdout, + 'stderr' => $stderr === false ? '' : $stderr, + ]; + } +} diff --git a/tests/Decision/DecisionFactoryTest.php b/tests/Decision/DecisionFactoryTest.php index 71a6047..da04f30 100644 --- a/tests/Decision/DecisionFactoryTest.php +++ b/tests/Decision/DecisionFactoryTest.php @@ -151,6 +151,20 @@ public function testFromArrayThrowsWhenReferencesIsNotArray(): void DecisionFactory::fromArray($data); } + public function testFromArrayTrimsRuleTokens(): void + { + $data = $this->validData(); + $data['rules'] = [ + 'forbid' => [' doctrine/* '], + 'allow' => ["\tpdo/*\n"], + ]; + + $decision = DecisionFactory::fromArray($data); + + self::assertSame(['doctrine/*'], $decision->rules()?->forbid()); + self::assertSame(['pdo/*'], $decision->rules()?->allow()); + } + private function validData(): array { return [ diff --git a/tests/Decision/YamlDecisionLoaderTest.php b/tests/Decision/YamlDecisionLoaderTest.php index 7cb5d9e..e2de95e 100644 --- a/tests/Decision/YamlDecisionLoaderTest.php +++ b/tests/Decision/YamlDecisionLoaderTest.php @@ -7,6 +7,7 @@ use InvalidArgumentException; use PhpDecide\Decision\Decision; use PhpDecide\Decision\YamlDecisionLoader; +use PhpDecide\Tests\Support\TestFilesystemException; use PHPUnit\Framework\TestCase; final class YamlDecisionLoaderTest extends TestCase @@ -160,7 +161,7 @@ private function createTempDir(): string { $dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'phpdecide_tests_' . bin2hex(random_bytes(8)); if (!mkdir($dir) && !is_dir($dir)) { - throw new TempDirectoryCreationFailed("Unable to create temp dir: {$dir}"); + throw new TestFilesystemException("Unable to create temp dir: {$dir}"); } $this->tempDirs[] = $dir; @@ -185,7 +186,3 @@ private function removeDirRecursive(string $dir): void rmdir($dir); } } - -final class TempDirectoryCreationFailed extends \RuntimeException -{ -} diff --git a/tests/Enforcement/AnalyzerFindingLoaderTest.php b/tests/Enforcement/AnalyzerFindingLoaderTest.php new file mode 100644 index 0000000..35569c3 --- /dev/null +++ b/tests/Enforcement/AnalyzerFindingLoaderTest.php @@ -0,0 +1,85 @@ + */ + private array $tempFiles = []; + + public function testLoadFromFileRejectsObjectWithoutFindingsArray(): void + { + $reportPath = $this->createTempJsonFile([ + 'results' => [[ + 'check_id' => 'doctrine/orm', + 'path' => 'src/Order/OrderService.php', + ]], + ]); + + $loader = new AnalyzerFindingLoader(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Enforcement report must be a JSON array or an object with a findings array.'); + + $loader->loadFromFile($reportPath); + } + + public function testLoadFromFileAcceptsObjectWithFindingsArray(): void + { + $reportPath = $this->createTempJsonFile([ + 'findings' => [[ + 'tool' => 'semgrep', + 'rule_id' => 'doctrine/orm', + 'path' => 'src/Order/OrderService.php', + 'message' => 'Doctrine ORM import detected.', + 'line' => 12, + ]], + ]); + + $findings = (new AnalyzerFindingLoader())->loadFromFile($reportPath); + + self::assertCount(1, $findings); + self::assertSame('semgrep', $findings[0]->tool()); + self::assertSame('doctrine/orm', $findings[0]->ruleId()); + self::assertSame('src/Order/OrderService.php', $findings[0]->path()); + self::assertSame('Doctrine ORM import detected.', $findings[0]->message()); + self::assertSame(12, $findings[0]->line()); + } + + protected function tearDown(): void + { + foreach ($this->tempFiles as $tempFile) { + if (is_file($tempFile)) { + unlink($tempFile); + } + } + + $this->tempFiles = []; + } + + /** + * @param array|list $payload + */ + private function createTempJsonFile(array $payload): string + { + $path = tempnam(sys_get_temp_dir(), 'phpdecide_analyzer_findings_'); + if ($path === false) { + self::fail('Unable to create temp JSON file.'); + } + + $written = file_put_contents($path, json_encode($payload, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)); + if ($written === false) { + self::fail('Unable to write temp JSON file.'); + } + + $this->tempFiles[] = $path; + + return $path; + } +} diff --git a/tests/Enforcement/JsonFileDecoderTest.php b/tests/Enforcement/JsonFileDecoderTest.php new file mode 100644 index 0000000..9ba8db5 --- /dev/null +++ b/tests/Enforcement/JsonFileDecoderTest.php @@ -0,0 +1,153 @@ + '{}', + 'readable' => false, + 'open_succeeds' => true, + ]; + + $decoder = new JsonFileDecoder(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to read enforcement report: ' . self::STREAM_SCHEME . '://report.json'); + + $decoder->decodeFile(self::STREAM_SCHEME . '://report.json', 'Enforcement report'); + } + + public function testDecodeFileSuppressesWarningsWhenFileReadFails(): void + { + JsonFileDecoderTestStreamWrapper::$files['warning.json'] = [ + 'contents' => '{}', + 'readable' => true, + 'open_succeeds' => false, + ]; + + $decoder = new JsonFileDecoder(); + $warnings = []; + + set_error_handler(static function (int $severity, string $message) use (&$warnings): bool { + $warnings[] = [$severity, $message]; + return true; + }); + + try { + try { + $decoder->decodeFile(self::STREAM_SCHEME . '://warning.json', 'Enforcement report'); + self::fail('Expected InvalidArgumentException to be thrown.'); + } catch (InvalidArgumentException $e) { + self::assertSame( + 'Unable to read enforcement report: ' . self::STREAM_SCHEME . '://warning.json', + $e->getMessage() + ); + } + } finally { + restore_error_handler(); + } + + self::assertSame([], $warnings); + } +} + +final class JsonFileDecoderTestStreamWrapper +{ + /** @var array */ + public static array $files = []; + + public mixed $context = null; + + private string $path = ''; + private int $position = 0; + + public function stream_open(string $path, string $_mode, int $_options, ?string &$openedPath): bool // NOSONAR + { + $key = self::normalizePath($path); + $file = self::$files[$key] ?? null; + if ($file === null) { + return false; + } + + if ($file['open_succeeds'] !== true) { + return false; + } + + $this->path = $key; + $this->position = 0; + $openedPath = $path; + + return true; + } + + public function stream_read(int $count): string // NOSONAR + { + $contents = self::$files[$this->path]['contents']; + $chunk = substr($contents, $this->position, $count); + $this->position += strlen($chunk); + + return $chunk; + } + + public function stream_eof(): bool // NOSONAR + { + return $this->position >= strlen(self::$files[$this->path]['contents']); + } + + public function stream_stat(): array // NOSONAR + { + return $this->buildStat($this->path); + } + + public function url_stat(string $path, int $_flags): array|false // NOSONAR + { + $key = self::normalizePath($path); + if (!isset(self::$files[$key])) { + return false; + } + + return $this->buildStat($key); + } + + private function buildStat(string $key): array + { + $stat = stat(__FILE__); + if ($stat === false) { + return array_fill(0, 13, 0); + } + + $mode = 0100000 | (self::$files[$key]['readable'] ? 0444 : 0000); + $stat[2] = $mode; + $stat['mode'] = $mode; + + return $stat; + } + + private static function normalizePath(string $path): string + { + return substr($path, strlen(JsonFileDecoderTest::STREAM_SCHEME . '://')); + } +}