Skip to content

Commit de1e21c

Browse files
Luc45claude
andcommitted
Reports/Ctrf: add CTRF reporter
Adds a new `ctrf` report which emits results in the Common Test Report Format (https://ctrf.io/), an open JSON standard for test results. Each sniff violation maps to a CTRF test entry: ERROR violations have status "failed" and WARNING violations have status "other". Files with no violations emit a single "passed" test so consumers see the full set of files that were processed. PHPCS-native metadata (sniff source, severity, fixability) is preserved via CTRF's rawStatus, tags, and extra fields. The output validates against the CTRF schema and works in both sequential and parallel scanning modes. Use `--report=ctrf` for stdout, or `--report-ctrf=/path` to write to a file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c3eb74d commit de1e21c

4 files changed

Lines changed: 809 additions & 0 deletions

File tree

CHANGELOG-4.x.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
The file documents changes to the PHP_CodeSniffer project for the 4.x series of releases.
44

5+
## [Unreleased]
6+
7+
### Added
8+
- New `ctrf` report which emits results in the [Common Test Report Format](https://ctrf.io/) (CTRF), an open JSON standard for test results.
9+
- Each violation becomes one CTRF test entry. ERROR violations have status `failed`; WARNING violations have status `other`.
10+
- Files with no violations emit one `passed` test, so the report contains the full set of files that were processed.
11+
- Native PHPCS metadata (sniff source, severity, fixability) is preserved via CTRF's `rawStatus`, `tags`, and `extra` fields.
12+
- Output validates against the CTRF schema (e.g. `npx --package=ctrf-cli@0.0.5 -- ctrf-cli validate <file>`).
13+
- Use `--report=ctrf` for stdout output, or `--report-ctrf=/path/to/file.json` to write to a file.
14+
515
## [4.0.1] - 2025-11-10
616

717
This release includes all improvements and bugfixes from PHP_CodeSniffer [3.13.5].

src/Reports/Ctrf.php

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
<?php
2+
/**
3+
* CTRF (Common Test Report Format) report for PHP_CodeSniffer.
4+
*
5+
* Emits results in the open standard CTRF JSON format (https://ctrf.io/),
6+
* mapping each sniff violation to a CTRF test entry. ERROR violations are
7+
* reported with status "failed", WARNING violations with status "other",
8+
* and clean files emit a single "passed" test so the report contains the
9+
* full set of files that were processed.
10+
*
11+
* Implementation note: PHPCS supports parallel scanning by forking child
12+
* processes which each call generateFileReport() and append the captured
13+
* output to a shared temp file. The parent process later reads the merged
14+
* file and calls generate() once. This means we cannot share PHP state
15+
* across the boundary, so:
16+
* - generateFileReport() emits each test as a self-contained JSON object
17+
* terminated by a comma. The order-independent stream concatenates
18+
* correctly regardless of which child wrote which file.
19+
* - generate() derives the run-level start time and the passed count by
20+
* scanning the merged cached data (mirroring the regex-on-cached-data
21+
* pattern used by the JUnit reporter). This is safe because json_encode
22+
* escapes inner quotes, so the only `"status":"passed"` and `"start":N`
23+
* occurrences in the cached stream are top-level keys we emitted.
24+
*
25+
* @author Lucas Bustamante <lucasfbustamante@gmail.com>
26+
* @copyright 2006-2023 Squiz Pty Ltd (ABN 77 084 670 600)
27+
* @copyright 2023 PHPCSStandards and contributors
28+
* @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/HEAD/licence.txt BSD Licence
29+
*/
30+
31+
namespace PHP_CodeSniffer\Reports;
32+
33+
use PHP_CodeSniffer\Config;
34+
use PHP_CodeSniffer\Files\File;
35+
36+
class Ctrf implements Report
37+
{
38+
39+
/**
40+
* The CTRF specification version targeted by this reporter.
41+
*
42+
* @var string
43+
*/
44+
private const SPEC_VERSION = '1.0.0';
45+
46+
/**
47+
* Flags passed to every json_encode() call.
48+
*
49+
* `JSON_INVALID_UTF8_SUBSTITUTE` substitutes the Unicode replacement
50+
* character (U+FFFD) for invalid UTF-8 byte sequences. Without it,
51+
* a single bad byte in a violation message or filename would cause
52+
* json_encode() to return false and silently drop the entire test
53+
* entry, leaving the summary counts disagreeing with the tests array.
54+
*
55+
* @var int
56+
*/
57+
private const JSON_FLAGS = JSON_INVALID_UTF8_SUBSTITUTE;
58+
59+
60+
/**
61+
* Generate a partial report for a single processed file.
62+
*
63+
* Each violation produces one CTRF test entry. A file with no violations
64+
* emits a single "passed" test entry so consumers can see clean files in
65+
* the report. Each emitted test is a complete JSON object terminated with
66+
* a trailing comma, which is later joined and de-trailed in {@see generate()}.
67+
*
68+
* @param array<string, string|int|array> $report Prepared report data.
69+
* See the {@see Report} interface for a detailed specification.
70+
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being reported on.
71+
* @param bool $showSources Show sources?
72+
* @param int $width Maximum allowed line width.
73+
*
74+
* @return bool
75+
*/
76+
public function generateFileReport(array $report, File $phpcsFile, bool $showSources = false, int $width = 80)
77+
{
78+
$now = (int) (microtime(true) * 1000);
79+
80+
if (count($report['messages']) === 0) {
81+
$test = [
82+
'name' => $report['filename'],
83+
'status' => 'passed',
84+
'duration' => 0,
85+
'start' => $now,
86+
'stop' => $now,
87+
'filePath' => $report['filename'],
88+
'suite' => [$report['filename']],
89+
'type' => 'lint',
90+
];
91+
echo json_encode($test, self::JSON_FLAGS) . ',';
92+
return true;
93+
}
94+
95+
$encoding = $phpcsFile->config->encoding;
96+
foreach ($report['messages'] as $line => $lineErrors) {
97+
foreach ($lineErrors as $column => $colErrors) {
98+
foreach ($colErrors as $error) {
99+
$message = $error['message'];
100+
if ($encoding !== 'utf-8') {
101+
$message = iconv($encoding, 'utf-8', $message);
102+
}
103+
104+
if ($error['type'] === 'ERROR') {
105+
$status = 'failed';
106+
} else {
107+
$status = 'other';
108+
}
109+
110+
$tags = [$error['type']];
111+
if ($error['fixable'] === true) {
112+
$tags[] = 'fixable';
113+
}
114+
115+
$test = [
116+
'name' => $error['source'] . ' at ' . $report['filename'] . " ($line:$column)",
117+
'status' => $status,
118+
'duration' => 0,
119+
'start' => $now,
120+
'stop' => $now,
121+
'message' => $message,
122+
'filePath' => $report['filename'],
123+
'line' => $line,
124+
'suite' => [$report['filename']],
125+
'rawStatus' => $error['type'],
126+
'type' => 'lint',
127+
'tags' => $tags,
128+
'extra' => [
129+
'column' => $column,
130+
'severity' => $error['severity'],
131+
'fixable' => $error['fixable'],
132+
'source' => $error['source'],
133+
],
134+
];
135+
echo json_encode($test, self::JSON_FLAGS) . ',';
136+
}
137+
}
138+
}
139+
140+
return true;
141+
}
142+
143+
144+
/**
145+
* Generates a CTRF JSON report.
146+
*
147+
* @param string $cachedData Any partial report data that was returned from
148+
* generateFileReport during the run.
149+
* @param int $totalFiles Total number of files processed during the run.
150+
* @param int $totalErrors Total number of errors found during the run.
151+
* @param int $totalWarnings Total number of warnings found during the run.
152+
* @param int $totalFixable Total number of problems that can be fixed.
153+
* @param bool $showSources Show sources?
154+
* @param int $width Maximum allowed line width.
155+
* @param bool $interactive Are we running in interactive mode?
156+
* @param bool $toScreen Is the report being printed to screen?
157+
*
158+
* @return void
159+
*/
160+
public function generate(
161+
string $cachedData,
162+
int $totalFiles,
163+
int $totalErrors,
164+
int $totalWarnings,
165+
int $totalFixable,
166+
bool $showSources = false,
167+
int $width = 80,
168+
bool $interactive = false,
169+
bool $toScreen = true
170+
) {
171+
$now = (int) (microtime(true) * 1000);
172+
173+
// Run start = earliest per-test start in the cached partials; run stop
174+
// = the moment we are generating the envelope. If no tests were emitted
175+
// (e.g. no files processed), both fall back to "now".
176+
$start = $now;
177+
if ($cachedData !== '') {
178+
$matches = [];
179+
if (preg_match_all('/"start":(\d+)/', $cachedData, $matches) > 0) {
180+
$start = min(array_map('intval', $matches[1]));
181+
}
182+
}
183+
184+
$stop = $now;
185+
$duration = ($stop - $start);
186+
187+
$passed = substr_count($cachedData, '"status":"passed"');
188+
$failed = $totalErrors;
189+
$other = $totalWarnings;
190+
$tests = ($passed + $failed + $other);
191+
192+
$tool = [
193+
'name' => 'PHP_CodeSniffer',
194+
'version' => Config::VERSION,
195+
];
196+
197+
$summary = [
198+
'tests' => $tests,
199+
'passed' => $passed,
200+
'failed' => $failed,
201+
'skipped' => 0,
202+
'pending' => 0,
203+
'other' => $other,
204+
'suites' => $totalFiles,
205+
'start' => $start,
206+
'stop' => $stop,
207+
'duration' => $duration,
208+
'extra' => ['fixable' => $totalFixable],
209+
];
210+
211+
// Build the envelope as a regular PHP array and json_encode it whole.
212+
// The `tests` array contains the cached per-file output, which is already
213+
// a comma-separated stream of JSON objects. We splice it in via a
214+
// unique placeholder so we don't have to re-decode and re-encode each
215+
// test entry just to wrap the envelope around them.
216+
$placeholder = '__ctrf_tests_' . bin2hex(random_bytes(8)) . '__';
217+
$envelope = [
218+
'reportFormat' => 'CTRF',
219+
'specVersion' => self::SPEC_VERSION,
220+
'reportId' => $this->generateUuidV4(),
221+
'timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
222+
'generatedBy' => 'PHP_CodeSniffer ' . Config::VERSION,
223+
'results' => [
224+
'tool' => $tool,
225+
'summary' => $summary,
226+
'tests' => $placeholder,
227+
],
228+
];
229+
230+
$json = json_encode($envelope, self::JSON_FLAGS);
231+
$json = str_replace('"' . $placeholder . '"', '[' . rtrim($cachedData, ',') . ']', $json);
232+
echo $json . PHP_EOL;
233+
}
234+
235+
236+
/**
237+
* Generate a random RFC 4122 v4 UUID.
238+
*
239+
* @return string
240+
*/
241+
private function generateUuidV4()
242+
{
243+
$data = random_bytes(16);
244+
$data[6] = chr((ord($data[6]) & 0x0F) | 0x40);
245+
$data[8] = chr((ord($data[8]) & 0x3F) | 0x80);
246+
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
247+
}
248+
}

0 commit comments

Comments
 (0)