Skip to content

Commit 7e14434

Browse files
Amoifrclaude
andcommitted
feat(config): D1 — @phpquality-ignore docblocks + baseline workflow
Two complementary suppression mechanisms — both make the tool usable on existing projects without rewriting them. == @phpquality-ignore docblock annotation == Parsed by IgnoreAnnotationParser from the docblock of any class / interface during AST traversal. Recognised codes: solid.srp, solid.dip, solid.isp, architecture.layer. Comma-separated values supported, end-of-line "— rationale" trailing text is ignored. /** * @phpquality-ignore solid.dip — wiring intentionnel */ final class FooService { /* … */ } ArchitectureAnalyzer collects per-class ignores from DependencyVisitor output and the project config (`ignore.violations` keys) before invoking SOLID and layer-violation checks. == Baseline == BaselineManager generates a stable hash per violation (filePath | line | code | source→target / className) and serialises a sorted, deduplicated list. The hash strips the path prefix up to /src/ or /app/ so the file is comparable across machines. phpquality:analyze … --generate-baseline=phpquality.baseline.json phpquality:analyze … --baseline=phpquality.baseline.json The latter filters violations whose hash is in the baseline; the analyser exposes summary.suppressedByBaseline. Entries that match no current violation are reported as obsolete (regenerate-please warning). `--generate-baseline` is exclusive of `--fail-on-violation` (by design, running it accepts the current state). Tests: IgnoreAnnotationParserTest (8 cases: case-insensitive tag, comma-separated, dedup, "—" terminator, multi-line); BaselineManagerTest (round-trip, partial application, obsolete-entry detection, path normalisation, malformed-file handling). Refs: D1 of the diagnostic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 42a549d commit 7e14434

4 files changed

Lines changed: 436 additions & 0 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpQuality\Analyzer\Ast;
6+
7+
/**
8+
* Parses `@phpquality-ignore` annotations from PHP docblocks.
9+
*
10+
* Supported syntax (case-insensitive on the tag, comma-separated codes):
11+
*
12+
* /**
13+
* * @phpquality-ignore solid.dip
14+
* * @phpquality-ignore solid.srp, architecture.layer
15+
* *\/
16+
*
17+
* Returns the list of ignore codes found across all matching tags.
18+
*
19+
* Recognised codes (validated by callers, not by this parser):
20+
* solid.srp, solid.dip, solid.isp, architecture.layer
21+
*/
22+
final class IgnoreAnnotationParser
23+
{
24+
/** @return array<string> */
25+
public static function parse(?string $docComment): array
26+
{
27+
if ($docComment === null || $docComment === '') {
28+
return [];
29+
}
30+
31+
if (!preg_match_all(
32+
'/@phpquality-ignore\s+([^\r\n*]+)/i',
33+
$docComment,
34+
$matches
35+
)) {
36+
return [];
37+
}
38+
39+
$codes = [];
40+
foreach ($matches[1] as $rawValue) {
41+
// Stop at "—" or "-" mid-line comments: the tag value ends at the
42+
// first non-code character. We accept letters, digits, dots, commas
43+
// and whitespace; anything else terminates the value.
44+
if (preg_match('/^([a-z0-9., ]+)/i', $rawValue, $valueMatch)) {
45+
$rawValue = $valueMatch[1];
46+
}
47+
foreach (preg_split('/\s*,\s*/', trim($rawValue)) as $code) {
48+
$code = trim($code);
49+
if ($code !== '') {
50+
$codes[] = strtolower($code);
51+
}
52+
}
53+
}
54+
55+
return array_values(array_unique($codes));
56+
}
57+
}

src/Config/BaselineManager.php

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpQuality\Config;
6+
7+
use PhpQuality\Analyzer\Result\LayerViolation;
8+
use PhpQuality\Analyzer\Result\SolidViolation;
9+
10+
/**
11+
* Generates and applies a baseline of accepted violations.
12+
*
13+
* Format on disk:
14+
* {
15+
* "version": 1,
16+
* "generatedAt": "ISO-8601",
17+
* "violations": ["<sha1-hash>", ...]
18+
* }
19+
*
20+
* Each hash is a stable digest of (filePath | line | code | source -> target).
21+
* On apply, any violation whose hash is in the baseline is filtered out and
22+
* counted in `summary.suppressedByBaseline`. Violations present in the
23+
* baseline but no longer detected are returned as `obsoleteEntries` so the
24+
* caller can warn the user to regenerate.
25+
*/
26+
final class BaselineManager
27+
{
28+
public const SOLID_CODE_PREFIX = 'solid.';
29+
public const LAYER_CODE = 'architecture.layer';
30+
31+
/**
32+
* Compute a stable hash for a SOLID violation.
33+
*/
34+
public function hashSolid(SolidViolation $v): string
35+
{
36+
$code = strtolower(self::SOLID_CODE_PREFIX . $v->principle);
37+
return $this->hash($v->filePath, 0, $code, $v->className);
38+
}
39+
40+
/**
41+
* Compute a stable hash for a layer violation.
42+
*/
43+
public function hashLayer(LayerViolation $v): string
44+
{
45+
return $this->hash(
46+
$v->filePath,
47+
$v->line,
48+
self::LAYER_CODE,
49+
$v->sourceClass . '->' . $v->targetClass
50+
);
51+
}
52+
53+
/**
54+
* Generate the JSON structure for the given violations.
55+
*
56+
* @param array<LayerViolation> $layerViolations
57+
* @param array<SolidViolation> $solidViolations
58+
*/
59+
public function generate(array $layerViolations, array $solidViolations): array
60+
{
61+
$hashes = [];
62+
foreach ($layerViolations as $v) {
63+
$hashes[] = $this->hashLayer($v);
64+
}
65+
foreach ($solidViolations as $v) {
66+
$hashes[] = $this->hashSolid($v);
67+
}
68+
sort($hashes);
69+
return [
70+
'version' => 1,
71+
'generatedAt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
72+
'violations' => array_values(array_unique($hashes)),
73+
];
74+
}
75+
76+
public function write(string $path, array $baseline): void
77+
{
78+
$dir = dirname($path);
79+
if (!is_dir($dir)) {
80+
mkdir($dir, 0755, true);
81+
}
82+
$json = json_encode($baseline, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
83+
if ($json === false) {
84+
throw new \RuntimeException('Failed to encode baseline: ' . json_last_error_msg());
85+
}
86+
file_put_contents($path, $json);
87+
}
88+
89+
/**
90+
* @return array<string, true> Set of hashes loaded from the baseline file.
91+
*/
92+
public function load(string $path): array
93+
{
94+
if (!is_file($path)) {
95+
throw new \RuntimeException('Baseline file not found: ' . $path);
96+
}
97+
$raw = file_get_contents($path);
98+
if ($raw === false) {
99+
throw new \RuntimeException('Cannot read baseline file: ' . $path);
100+
}
101+
$data = json_decode($raw, true);
102+
if (!is_array($data) || !isset($data['violations']) || !is_array($data['violations'])) {
103+
throw new \RuntimeException('Malformed baseline file: ' . $path);
104+
}
105+
$set = [];
106+
foreach ($data['violations'] as $hash) {
107+
if (is_string($hash)) {
108+
$set[$hash] = true;
109+
}
110+
}
111+
return $set;
112+
}
113+
114+
/**
115+
* Filter violations against a baseline set.
116+
*
117+
* @param array<LayerViolation> $layerViolations
118+
* @param array<SolidViolation> $solidViolations
119+
* @param array<string, true> $baseline
120+
* @return array{
121+
* layer: array<LayerViolation>,
122+
* solid: array<SolidViolation>,
123+
* suppressedCount: int,
124+
* obsoleteEntries: array<string>
125+
* }
126+
*/
127+
public function apply(array $layerViolations, array $solidViolations, array $baseline): array
128+
{
129+
$matchedHashes = [];
130+
$suppressed = 0;
131+
132+
$remainingLayer = [];
133+
foreach ($layerViolations as $v) {
134+
$h = $this->hashLayer($v);
135+
if (isset($baseline[$h])) {
136+
$matchedHashes[$h] = true;
137+
$suppressed++;
138+
continue;
139+
}
140+
$remainingLayer[] = $v;
141+
}
142+
143+
$remainingSolid = [];
144+
foreach ($solidViolations as $v) {
145+
$h = $this->hashSolid($v);
146+
if (isset($baseline[$h])) {
147+
$matchedHashes[$h] = true;
148+
$suppressed++;
149+
continue;
150+
}
151+
$remainingSolid[] = $v;
152+
}
153+
154+
$obsolete = array_keys(array_diff_key($baseline, $matchedHashes));
155+
156+
return [
157+
'layer' => $remainingLayer,
158+
'solid' => $remainingSolid,
159+
'suppressedCount' => $suppressed,
160+
'obsoleteEntries' => $obsolete,
161+
];
162+
}
163+
164+
private function hash(string $filePath, int $line, string $code, string $signature): string
165+
{
166+
// Normalise filePath to project-relative form when possible. We expose
167+
// both styles and rely on callers to pass the relative path; if an
168+
// absolute path is passed we strip everything up to the last "src/".
169+
$normalised = $filePath;
170+
if ($pos = strpos($normalised, '/src/')) {
171+
$normalised = substr($normalised, $pos + 1);
172+
} elseif ($pos = strpos($normalised, '/app/')) {
173+
$normalised = substr($normalised, $pos + 1);
174+
}
175+
return sha1(implode('|', [$normalised, (string) $line, $code, $signature]));
176+
}
177+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpQuality\Tests\Analyzer\Ast;
6+
7+
use PhpQuality\Analyzer\Ast\IgnoreAnnotationParser;
8+
use PHPUnit\Framework\TestCase;
9+
10+
class IgnoreAnnotationParserTest extends TestCase
11+
{
12+
public function testNullDocCommentReturnsEmpty(): void
13+
{
14+
$this->assertSame([], IgnoreAnnotationParser::parse(null));
15+
$this->assertSame([], IgnoreAnnotationParser::parse(''));
16+
}
17+
18+
public function testParsesSingleCode(): void
19+
{
20+
$doc = "/**\n * @phpquality-ignore solid.dip\n */";
21+
$this->assertSame(['solid.dip'], IgnoreAnnotationParser::parse($doc));
22+
}
23+
24+
public function testParsesCommaSeparatedCodes(): void
25+
{
26+
$doc = "/**\n * @phpquality-ignore solid.dip, architecture.layer\n */";
27+
$this->assertSame(['solid.dip', 'architecture.layer'], IgnoreAnnotationParser::parse($doc));
28+
}
29+
30+
public function testParsesMultipleAnnotations(): void
31+
{
32+
$doc = <<<'DOC'
33+
/**
34+
* @phpquality-ignore solid.dip
35+
* @phpquality-ignore solid.srp
36+
*/
37+
DOC;
38+
$this->assertSame(['solid.dip', 'solid.srp'], IgnoreAnnotationParser::parse($doc));
39+
}
40+
41+
public function testStopsAtCommentSeparator(): void
42+
{
43+
// The reason text after "—" or "-" is ignored.
44+
$doc = "/**\n * @phpquality-ignore solid.dip — wiring intentionnel\n */";
45+
$this->assertSame(['solid.dip'], IgnoreAnnotationParser::parse($doc));
46+
}
47+
48+
public function testIsCaseInsensitiveOnTagAndCodes(): void
49+
{
50+
$doc = "/**\n * @PHPQUALITY-Ignore SOLID.DIP\n */";
51+
$this->assertSame(['solid.dip'], IgnoreAnnotationParser::parse($doc));
52+
}
53+
54+
public function testDeduplicatesCodes(): void
55+
{
56+
$doc = "/**\n * @phpquality-ignore solid.dip, solid.dip\n * @phpquality-ignore solid.dip\n */";
57+
$this->assertSame(['solid.dip'], IgnoreAnnotationParser::parse($doc));
58+
}
59+
60+
public function testIgnoresUnrelatedTags(): void
61+
{
62+
$doc = "/**\n * @author Pascal\n * @phpquality-ignore solid.dip\n * @return void\n */";
63+
$this->assertSame(['solid.dip'], IgnoreAnnotationParser::parse($doc));
64+
}
65+
}

0 commit comments

Comments
 (0)