Skip to content

Feature enforcement - #3

Merged
distvan merged 26 commits into
mainfrom
feature-enf-mapping
Apr 9, 2026
Merged

distvan merged 26 commits into
mainfrom
feature-enf-mapping

Conversation

@distvan

@distvan distvan commented Mar 22, 2026

Copy link
Copy Markdown
Owner

PHPDecide / Architecture decisions

What architectural decision(s) apply to this change?

  • I ran composer phpdecide -- explain "<question>" --path <changed-file> for the key changed files
  • I added/updated a decision in .decisions/ if this PR introduces a new rule or exception

Checks

  • composer decisions:lint
  • composer test

Notes for reviewers

  • If you’re unsure whether a decision applies, try:
    • composer phpdecide -- explain "What rules apply here?" --path <file>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an initial “enforcement-ready” workflow to PHPDecide by adding an enforce CLI command that maps external analyzer findings (generic JSON, Semgrep JSON, PHPStan JSON) back to active decision IDs via rules.forbid, and adds example assets/docs to support CI integrations.

Changes:

  • Added phpdecide enforce command + enforcement domain classes to load findings, match them to decisions, and emit text/JSON outputs.
  • Added PHPUnit coverage for the new command and refactored test-only filesystem exceptions to a shared helper exception.
  • Added Semgrep/PHPStan example assets, GitHub Actions example workflows, and documentation updates describing the enforcement mapping contract.

Reviewed changes

Copilot reviewed 27 out of 28 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/Decision/YamlDecisionLoaderTest.php Reuses shared test filesystem exception instead of local class.
tests/CLI/ExplainCommandTest.php Reuses shared test filesystem exception instead of local classes.
tests/CLI/EnforceCommandTest.php Adds coverage for enforce across generic/Semgrep/PHPStan inputs + JSON output + UTF-16.
src/Enforcement/AnalyzerFinding.php Introduces normalized finding DTO used across loaders/matching/output.
src/Enforcement/AnalyzerFindingLoader.php Loads “generic” enforcement JSON findings.
src/Enforcement/SemgrepFindingLoader.php Parses native Semgrep JSON into normalized findings.
src/Enforcement/PhpStanFindingLoader.php Parses native PHPStan JSON into normalized findings.
src/Enforcement/JsonFileDecoder.php Shared JSON decoding with UTF/BOM normalization for reports.
src/Enforcement/DecisionViolation.php Wraps a decision + finding pair for reporting.
src/Enforcement/EnforcementMatchResult.php Holds matched violations + unmapped findings + summary helpers.
src/Enforcement/DecisionViolationMatcher.php Matches findings to active scoped decisions via rules.forbid.
src/CLI/EnforceCommand.php Implements the enforce CLI command with text/JSON renderers.
bin/phpdecide Registers the new EnforceCommand with the CLI application.
semgrep/rules/no-orm-in-order-domain.yaml Adds example Semgrep rule ID intended to match a decision token.
semgrep/rules/no-business-logic-in-templates.yaml Adds a second example Semgrep rule for templates.
examples/phpstan/README.md Adds PHPStan example documentation + token/identifier contract.
examples/phpstan/no-orm-in-order-domain-report.json Adds a checked-in native PHPStan JSON report example.
examples/github-actions/phpdecide-semgrep-enforce.yaml Adds a Semgrep-based CI example for enforcement + rendering outputs.
examples/github-actions/phpdecide-phpstan-enforce.yaml Adds a PHPStan-based CI example for enforcement + rendering outputs.
examples/github-actions/render-phpdecide-pr-comment.php Adds a JSON→markdown renderer for PR comments/step summaries.
examples/github-actions/render-phpdecide-annotations.php Adds a JSON→GitHub annotation commands renderer.
examples/fixtures/templates/order/show.html.twig Adds allowed template fixture for Semgrep example.
examples/fixtures/templates/order/calculate_total.html.twig Adds violating template fixture for Semgrep example.
examples/fixtures/phpstan/src/Order/OrderService.php Adds violating PHPStan fixture file.
examples/fixtures/phpstan/src/Infrastructure/Persistence/Doctrine/OrderRecord.php Adds allowed PHPStan fixture file.
docs/decision-file-anatomy.md Documents the enforcement token matching contract.
README.md Documents the enforcement workflow, formats, and example assets.
CHANGELOG.md Describes the new enforcement workflow and example assets.

Comment thread README.md Outdated
Comment on lines +142 to +145
- Active repo sample decision: [.decisions/DEC-0003.no-orm-in-order-domain.yaml](.decisions/DEC-0003.no-orm-in-order-domain.yaml)
- Active repo sample decision: [.decisions/DEC-0004.no-business-logic-in-templates.yaml](.decisions/DEC-0004.no-business-logic-in-templates.yaml)
- Active repo sample decision: [.decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml](.decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml)

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README links to sample decisions under .decisions/ (DEC-0003/4/5), but this PR doesn't add a .decisions directory (and those referenced files don't exist in the repo). As written, the links are broken and the documented enforce/CI workflows that rely on default .decisions will fail. Either add the referenced decision files under .decisions/, or update the docs to point to the actual checked-in location (and adjust the examples accordingly).

Suggested change
- Active repo sample decision: [.decisions/DEC-0003.no-orm-in-order-domain.yaml](.decisions/DEC-0003.no-orm-in-order-domain.yaml)
- Active repo sample decision: [.decisions/DEC-0004.no-business-logic-in-templates.yaml](.decisions/DEC-0004.no-business-logic-in-templates.yaml)
- Active repo sample decision: [.decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml](.decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml)
These are example decision files you would place under your own `.decisions/` directory:
- Active repo sample decision: DEC-0003.no-orm-in-order-domain.yaml
- Active repo sample decision: DEC-0004.no-business-logic-in-templates.yaml
- Active repo sample decision: DEC-0005.no-orm-in-order-domain-via-phpstan.yaml

Copilot uses AI. Check for mistakes.
Comment thread docs/decision-file-anatomy.md Outdated
- Prefer a small number of strong rules over many weak ones.
- In `v1.3.0`, 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 [../.decisions/DEC-0004.no-business-logic-in-templates.yaml](../.decisions/DEC-0004.no-business-logic-in-templates.yaml) and [semgrep/rules/no-business-logic-in-templates.yaml](../semgrep/rules/no-business-logic-in-templates.yaml) for a second example pairing using the token `twig/business-logic`.

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section references ../.decisions/DEC-0004... as a checked-in example, but .decisions isn't present in the repo and the linked file doesn't exist. Update the link/wording to match the actual example location, or add the referenced decision file under .decisions/ so the documentation stays accurate.

Suggested change
- See [../.decisions/DEC-0004.no-business-logic-in-templates.yaml](../.decisions/DEC-0004.no-business-logic-in-templates.yaml) and [semgrep/rules/no-business-logic-in-templates.yaml](../semgrep/rules/no-business-logic-in-templates.yaml) for a second example pairing using the token `twig/business-logic`.
- See [DEC-0004.no-business-logic-in-templates.yaml](DEC-0004.no-business-logic-in-templates.yaml) and [semgrep/rules/no-business-logic-in-templates.yaml](../semgrep/rules/no-business-logic-in-templates.yaml) for a second example pairing using the token `twig/business-logic`.

Copilot uses AI. Check for mistakes.
Comment thread CHANGELOG.md Outdated
Comment on lines +20 to +27
- Added a second active sample decision/rule pair for template boundaries:
- `.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:
- `.decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml`

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changelog entries claim new sample decision files were added under .decisions/ (e.g. DEC-0004/DEC-0005), but the PR doesn't include a .decisions directory or those files. Either add the referenced decision files, or adjust the changelog to reflect what was actually added so consumers aren't misled.

Suggested change
- Added a second active sample decision/rule pair for template boundaries:
- `.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:
- `.decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml`
- Added a second sample Semgrep rule for template boundaries:
- `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:

Copilot uses AI. Check for mistakes.
Comment thread examples/phpstan/README.md Outdated
Comment on lines +9 to +14
Matching decision and fixture files:

- `.decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml`
- `examples/fixtures/phpstan/src/Order/OrderService.php`
- `examples/fixtures/phpstan/src/Infrastructure/Persistence/Doctrine/OrderRecord.php`

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example README points to .decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml, but that file (and the .decisions/ directory) isn't present in the repo. Either add the sample decision under .decisions/ or update this documentation to reference the correct checked-in decision/example location.

Suggested change
Matching decision and fixture files:
- `.decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml`
- `examples/fixtures/phpstan/src/Order/OrderService.php`
- `examples/fixtures/phpstan/src/Infrastructure/Persistence/Doctrine/OrderRecord.php`
Matching fixture files:
- `examples/fixtures/phpstan/src/Order/OrderService.php`
- `examples/fixtures/phpstan/src/Infrastructure/Persistence/Doctrine/OrderRecord.php`
In a real project, you should also have a decision file under `.decisions/` whose `rules.forbid` array contains the token `phpstan.doctrine.orm`.

Copilot uses AI. Check for mistakes.
run: python -m pip install --upgrade pip semgrep

- name: Lint decision files
run: php ./bin/phpdecide decisions:lint --require-any

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workflow runs phpdecide decisions:lint --require-any, which will fail in this repo because the PR does not include a .decisions/ directory with at least one .yaml decision file. Either commit the sample decisions under .decisions/ (as referenced in the README/CHANGELOG) or remove --require-any/create decisions as part of the workflow so the example is runnable.

Suggested change
run: php ./bin/phpdecide decisions:lint --require-any
run: php ./bin/phpdecide decisions:lint

Copilot uses AI. Check for mistakes.

- name: Install PHP dependencies
run: composer install --no-interaction --prefer-dist

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workflow runs phpdecide decisions:lint --require-any, which will fail in this repo because the PR does not include a .decisions/ directory with at least one .yaml decision file. Either commit the sample decisions under .decisions/, or change the workflow to not require any decisions (or to create them) so the example works out of the box.

Suggested change
- name: Initialize sample decisions for example workflow
run: |
mkdir -p .decisions
cat > .decisions/DEC-0001-sample.yaml << 'EOF'
id: DEC-0001
title: Sample decision for PHPDecide GitHub Actions example
status: accepted
date: 2024-01-01
scope: Example workflows
decision: >
This is a sample architectural decision record created by the
example GitHub Actions workflow so that `phpdecide decisions:lint`
with `--require-any` has at least one decision file to validate.
EOF

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 32 changed files in this pull request and generated 2 comments.

Comment thread src/CLI/EnforceCommand.php Outdated
private function writeJson(OutputInterface $output, array $payload): void
{
try {
$output->writeln(json_encode($payload, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writeJson() wraps JsonException into an InvalidArgumentException, but execute() only catches exceptions around loading/matching — not around result/error rendering. If json_encode() fails (e.g., invalid UTF-8 in an analyzer message), the command will throw and crash instead of returning a structured error + Command::FAILURE. Consider catching Throwable around renderResult() / renderError() as well, and/or using JSON_INVALID_UTF8_SUBSTITUTE to make JSON output robust for CI consumers.

Suggested change
$output->writeln(json_encode($payload, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
$output->writeln(json_encode(
$payload,
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE
));

Copilot uses AI. Check for mistakes.
Comment thread CHANGELOG.md
Comment on lines +20 to +31
- Added a second active sample decision/rule pair for template boundaries:
- `.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:
- `.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`

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changelog references example decision files under .decisions/…, but this PR adds them under examples/decisions/… (e.g. examples/decisions/DEC-0004.no-business-logic-in-templates.yaml and examples/decisions/DEC-0005.no-orm-in-order-domain-via-phpstan.yaml). Updating these paths will avoid confusing users who try to locate the assets described here.

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 32 changed files in this pull request and generated 2 comments.

Comment thread tests/CLI/EnforceCommandTest.php Outdated
}

$decisionsDir = $dir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR;
mkdir($decisionsDir);

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this test helper, mkdir($decisionsDir) is not checked for failure. If directory creation fails (permissions, disk full, etc.), later assertions will fail with less actionable errors. Consider validating the return value (and is_dir) and throwing TestFilesystemException, consistent with other CLI tests.

Suggested change
mkdir($decisionsDir);
if (!mkdir($decisionsDir) && !is_dir($decisionsDir)) {
throw new TestFilesystemException('Unable to create decisions dir: ' . $decisionsDir);
}

Copilot uses AI. Check for mistakes.
Comment thread tests/CLI/EnforceCommandTest.php Outdated

private function writeFile(string $path, string $contents): void
{
file_put_contents($path, $contents);

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writeFile() ignores file_put_contents() failures. If a write fails, tests will proceed and may produce misleading failures later. Consider checking the return value and throwing TestFilesystemException (as done in DecisionsLintCommandTest).

Suggested change
file_put_contents($path, $contents);
$result = file_put_contents($path, $contents);
if ($result === false) {
throw new TestFilesystemException(sprintf('Failed to write file: %s', $path));
}

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 32 changed files in this pull request and generated 1 comment.

Comment on lines +631 to +638
$command = new EnforceCommand();
$output = new BufferedOutput();
$writeJson = new ReflectionMethod(EnforceCommand::class, 'writeJson');

$writeJson->invoke($command, $output, [
'ok' => false,
'error' => "Bad\xB1Message",
]);

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test invokes the private EnforceCommand::writeJson() via ReflectionMethod without making it accessible. On PHP 8.2 this will typically fail with a visibility error when invoking a private method. Prefer asserting the behavior through the public command output, or explicitly allow reflective access (e.g., via setAccessible(true)) before invoke().

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 32 changed files in this pull request and generated 2 comments.

Comment thread src/CLI/EnforceCommand.php Outdated
} catch (Throwable) {
try {
if ($format === self::FORMAT_JSON) {
$output->writeln('{"ok":false,"error":"Unable to render enforcement output."}');

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON fallback emitted when rendering fails a second time includes backslashes (e.g. {\"ok\":false...}), which will produce invalid JSON output. Please output a valid JSON string here (or reuse the same encoder with a minimal payload) so --format json is always parseable even in the fallback path.

Suggested change
$output->writeln('{"ok":false,"error":"Unable to render enforcement output."}');
$output->writeln(json_encode([
'ok' => false,
'error' => 'Unable to render enforcement output.',
], JSON_THROW_ON_ERROR));

Copilot uses AI. Check for mistakes.
Comment on lines +181 to +203
function githubCommand(string $level, array $properties, string $message): string
{
$prefix = '::' . $level;
if ($properties === []) {
return $prefix . '::' . escapeCommandValue($message);
}

$pairs = [];
foreach ($properties as $key => $value) {
$pairs[] = $key . '=' . escapeCommandValue($value);
}

return sprintf('%s %s::%s', $prefix, implode(',', $pairs), escapeCommandValue($message));
}

function escapeCommandValue(string $value): string
{
return str_replace(
['%', "\r", "\n", ':', ','],
['%25', '%0D', '%0A', '%3A', '%2C'],
$value
);
}

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

escapeCommandValue() is used for both annotation properties and the message. Escaping : and , in the message can cause those characters to be percent-encoded in the final annotation text (GitHub’s runner only requires :/, escaping for the properties segment). Consider splitting this into two functions (escapeData for message: %, \r, \n; escapeProperty for properties: %, \r, \n, :, ,) to avoid over-escaping the annotation message content.

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 32 changed files in this pull request and generated 2 comments.

Comment thread tests/CLI/EnforceCommandTest.php Outdated
Comment on lines +741 to +768
private function decisionYamlWithRules(string $id, string $title, array $paths, array $forbidRules): string
{
$yamlPaths = '';
foreach ($paths as $path) {
$yamlPaths .= " - {$path}\n";
}

$yamlRules = '';
foreach ($forbidRules as $rule) {
$yamlRules .= " - {$rule}\n";
}

return <<<YAML
id: {$id}
title: {$title}
status: active
date: '2026-03-19'
scope:
type: path
paths:
{$yamlPaths}decision:
summary: Keep ORM out of the domain layer.
rationale:
- Preserve persistence ignorance.
rules:
forbid:
{$yamlRules} allow: []
YAML;

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The YAML generated by decisionYamlWithRules() relies on leading-space interpolation (e.g., {$yamlPaths}decision: / {$yamlRules} allow: []). This becomes invalid YAML when $paths or $forbidRules is empty (it would indent decision:/allow: incorrectly under the previous key). Consider generating these blocks with explicit newlines/indentation (or building an array and dumping via Symfony YAML) so the output is valid regardless of empty inputs and the indentation is stable/readable.

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +10
scope:
type: path
paths:
- 'src/Order/*'

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example decision scope uses src/Order/*, but scope matching uses fnmatch, so * will not match nested paths (e.g., src/Order/Sub/Foo.php). This is inconsistent with the Semgrep example rule which scans src/Order/**/*.php, and would lead to unmapped findings for nested Order paths. Consider adding a recursive pattern like src/Order/**/* (or narrowing the Semgrep include to src/Order/*.php) to keep the examples and enforcement behavior aligned.

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 40 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/Decision/Rules.php:33

  • There are blank lines containing trailing whitespace in this file (e.g., around the property declarations and methods). Please remove trailing whitespace to keep diffs clean and avoid style-checker noise.
    /** @var list<string> */
    private readonly array $forbid;

    /** @var list<string> */
    private readonly array $allow;
    
    public function forbid(): array
    {
        return $this->forbid;
    }
    
    public function allow(): array
    {
        return $this->allow;
    }

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 40 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 40 changed files in this pull request and generated 2 comments.

Comment on lines +15 to +46
public function match(DecisionRepository $repository, array $findings): EnforcementMatchResult
{
$violationsByDecisionId = [];
$unmappedFindings = [];

$applicableDecisionsByPath = [];
foreach ($findings as $finding) {
$matched = false;
$path = $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($violationsByDecisionId, $unmappedFindings);
}

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$violationsByDecisionId is built in the order findings are processed, so both the text output and the JSON payload ordering will vary with analyzer result ordering. For CI/bot consumption (PR comments, annotations), this can cause noisy diffs between runs. Consider sorting the result deterministically (e.g., ksort() by decision ID, and optionally sorting each decision’s violations by path/line/tool) before returning the EnforcementMatchResult.

Copilot uses AI. Check for mistakes.
Comment on lines +89 to +92
try {
$repository = new FileDecisionRepository(new YamlDecisionLoader($dir));
$findings = $this->loadFindings($reportPath, $semgrepReportPath, $phpStanReportPath);
$result = (new DecisionViolationMatcher())->match($repository, $findings);

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enforce always instantiates YamlDecisionLoader with caching enabled, which will create/update .phpdecide-decisions.cache inside the decisions directory. For CI usage and consistency with explain, consider adding a --no-cache flag (and/or honoring PHPDECIDE_DECISIONS_CACHE) so users can opt out of writing cache files when they want a read-only run.

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 40 changed files in this pull request and generated 1 comment.

Comment on lines +21 to +27
foreach ($findings as $finding) {
$matched = false;
$path = $finding->path();

if (!array_key_exists($path, $applicableDecisionsByPath)) {
$applicableDecisionsByPath[$path] = $repository->applicableTo($path);
}

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scope::appliesTo() uses fnmatch() against decision path patterns that are documented/expected to use forward slashes. If analyzer reports provide Windows-style paths (e.g. from PHPStan on Windows), \ separators will not match patterns like src/Order/**/*, causing false “unmapped” findings. Normalize AnalyzerFinding::path() (e.g. replace \\ with / and trim leading ./) before calling $repository->applicableTo() and before using the path as a cache key.

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 40 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/CLI/DecisionsLintCommand.php:175

  • DecisionsLintCommand now always calls DecisionFactory::fromArray() even when validateDecisionArrays() already reported schema errors for the same file. Since Rules now validates/normalizes tokens, this can lead to duplicate error messages (one from validateDecisionArrays, one from the thrown exception) and unnecessary work. Consider short-circuiting: if validateDecisionArrays() returns any errors for a file, skip DecisionFactory::fromArray() for that file.
            $errors = array_merge($errors, $this->validateDecisionArrays($data, $fileLabel));

            try {
                $decision = DecisionFactory::fromArray($data);
                $decisions[] = $decision;
            } catch (InvalidArgumentException $e) {
                $errors[] = sprintf('%s: %s', $fileLabel, $e->getMessage());
                continue;

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 41 changed files in this pull request and generated 1 comment.

Comment thread tests/CLI/DecisionsLintCommandTest.php Outdated
Comment on lines +280 to +311
public function testDoesNotDuplicateRulesForbidErrorsAfterArrayValidationFails(): void
{
$projectDir = $this->createTempProjectDir();
$decisionsDir = $projectDir . DIRECTORY_SEPARATOR . PhpDecideDefaults::DECISIONS_DIR;

$this->writeFile(
$decisionsDir . DIRECTORY_SEPARATOR . 'DEC-0001-invalid-rules.yaml',
<<<YAML
id: DEC-0001
title: Invalid rules
status: active
date: '2026-02-03'
scope:
type: global
decision:
summary: Something
rationale:
- Because.
rules:
forbid:
- ''
YAML
);

$tester = new CommandTester(new DecisionsLintCommand());
$exitCode = $tester->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'));
}

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new test method is indented inconsistently with the rest of the file (extra leading spaces on the method declaration and most statements). It still runs, but it violates the file’s established formatting and makes future edits/error diffs harder to read. Reformat the method body and its heredoc call to match the surrounding PSR-12-style indentation.

Copilot uses AI. Check for mistakes.
@sonarqubecloud

sonarqubecloud Bot commented Apr 3, 2026

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 41 changed files in this pull request and generated no new comments.

@distvan distvan changed the title adding enforcement related files Feature enforcement Apr 4, 2026
@distvan
distvan requested a review from Copilot April 4, 2026 13:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 41 changed files in this pull request and generated no new comments.

@distvan
distvan merged commit 18b0c8e into main Apr 9, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants