Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.65% covered (success)
97.65%
83 / 85
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Manifest
97.65% covered (success)
97.65%
83 / 85
88.89% covered (warning)
88.89%
8 / 9
39
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 classify
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 loadFromDirectory
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
12
 outOfScopeRules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 pendingSubstrateRules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 matches
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 globToRegex
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 loadJsonFile
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 normaliseRules
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\WptHarness;
6
7use Phpdftk\Filesystem\LocalFilesystem;
8
9/**
10 * WPT test classifier — maps each test identifier to its scope status
11 * per `docs/spec/out-of-scope.md` and the Phase 4 substrate readiness
12 * matrix.
13 *
14 * The manifest is the authoritative answer to: "does this test count
15 * toward the in-scope pass rate?" Possible outcomes:
16 *
17 *  - `OutOfScope`        — surface listed in the permanent out-of-scope
18 *                          ledger. Skipped at runtime; excluded from
19 *                          both numerator and denominator.
20 *  - `PendingSubstrate`  — surface is in-scope but the substrate
21 *                          dependency (4C raster, 4D shaping, 4E
22 *                          color, 4F resource loader, 4G paged
23 *                          media) hasn't shipped. Skipped at
24 *                          runtime; tracked separately in the
25 *                          dashboard so callers can see "N tests
26 *                          blocked on 4C".
27 *  - `null` (in-scope)   — no rule matched; the runner renders the
28 *                          test and scores it `Pass` / `Fail` based
29 *                          on the visual diff.
30 *
31 * Rule storage. Rules live in JSON files under
32 * `packages/wpt-harness/manifest/`:
33 *
34 *   _global.json   Cross-cutting rules (network APIs, sensors,
35 *                  workers, etc.) — loaded first.
36 *   css.json       CSS-module-specific rules.
37 *   html.json      HTML-spec-section-specific rules.
38 *   svg.json       SVG-2-section-specific rules.
39 *
40 * First-match wins so narrower rules in per-spec files can override
41 * broader rules in `_global.json`. Within a file, out-of-scope
42 * rules win over pending-substrate when both match.
43 *
44 * Glob syntax. Test IDs are POSIX-style paths (`css/css-color/lab-001`).
45 * Patterns use shell-glob conventions extended for cross-directory
46 * matching:
47 *
48 *   *     matches any sequence of non-separator chars (single
49 *         segment)
50 *   **    matches any sequence including separators (recursive)
51 *
52 * All other regex metacharacters are escaped literally so authors
53 * can write `at-page-*` without worrying about the `-` or `()`.
54 */
55final class Manifest
56{
57    /**
58     * @param list<array{glob: string, reason: string}> $outOfScopeRules
59     * @param list<array{glob: string, reason: string, phase?: string}> $pendingSubstrateRules
60     */
61    public function __construct(
62        private readonly array $outOfScopeRules = [],
63        private readonly array $pendingSubstrateRules = [],
64    ) {}
65
66    /**
67     * Classify a single test by ID. Returns `null` when the test is
68     * in-scope (no rule matched — the runner is expected to render
69     * and score it). Returns a verdict array when a rule matched.
70     *
71     * @return array{status: TestStatus, reason: string, phase?: string}|null
72     */
73    public function classify(string $testId): ?array
74    {
75        foreach ($this->outOfScopeRules as $rule) {
76            if (self::matches($rule['glob'], $testId)) {
77                return [
78                    'status' => TestStatus::OutOfScope,
79                    'reason' => $rule['reason'],
80                ];
81            }
82        }
83        foreach ($this->pendingSubstrateRules as $rule) {
84            if (self::matches($rule['glob'], $testId)) {
85                $verdict = [
86                    'status' => TestStatus::PendingSubstrate,
87                    'reason' => $rule['reason'],
88                ];
89                if (isset($rule['phase'])) {
90                    $verdict['phase'] = $rule['phase'];
91                }
92                return $verdict;
93            }
94        }
95        return null;
96    }
97
98    /**
99     * Load the manifest from a rule directory. `_global.json` is
100     * loaded first (if present) so narrower per-spec files can
101     * override broader cross-cutting rules. Within a directory, all
102     * `*.json` files except those starting with `_` (other than
103     * `_global.json` itself) are merged in alphabetical order.
104     *
105     * Each JSON file may declare `out-of-scope` and / or
106     * `pending-substrate` arrays of `{glob, reason, phase?}` objects.
107     * Invalid entries are silently skipped — the manifest is meant
108     * to be tolerant of comments and additional metadata so authors
109     * can keep notes alongside the rules.
110     */
111    public static function loadFromDirectory(string $manifestDir): self
112    {
113        $files = glob(rtrim($manifestDir, '/') . '/*.json');
114        if ($files === false || $files === []) {
115            return new self();
116        }
117        // `_global.json` first, others alphabetically. Any other
118        // `_*.json` is treated as a private include and skipped so
119        // authors can stash drafts under `_wip.json` without
120        // affecting classification.
121        usort($files, static function (string $a, string $b): int {
122            $aIsGlobal = basename($a) === '_global.json';
123            $bIsGlobal = basename($b) === '_global.json';
124            if ($aIsGlobal !== $bIsGlobal) {
125                return $aIsGlobal ? -1 : 1;
126            }
127            return strcmp(basename($a), basename($b));
128        });
129
130        $outOfScope = [];
131        $pendingSubstrate = [];
132        foreach ($files as $file) {
133            $name = basename($file);
134            if ($name !== '_global.json' && str_starts_with($name, '_')) {
135                continue;
136            }
137            $data = self::loadJsonFile($file);
138            if ($data === null) {
139                continue;
140            }
141            foreach (self::normaliseRules($data['out-of-scope'] ?? null) as $rule) {
142                $outOfScope[] = ['glob' => $rule['glob'], 'reason' => $rule['reason']];
143            }
144            foreach (self::normaliseRules($data['pending-substrate'] ?? null) as $rule) {
145                $entry = ['glob' => $rule['glob'], 'reason' => $rule['reason']];
146                if (isset($rule['phase'])) {
147                    $entry['phase'] = $rule['phase'];
148                }
149                $pendingSubstrate[] = $entry;
150            }
151        }
152        return new self($outOfScope, $pendingSubstrate);
153    }
154
155    /**
156     * Read-only access to the rule tables. Used by harness tests +
157     * the `wpt classify` CLI to introspect the loaded manifest.
158     *
159     * @return list<array{glob: string, reason: string}>
160     */
161    public function outOfScopeRules(): array
162    {
163        return $this->outOfScopeRules;
164    }
165
166    /**
167     * @return list<array{glob: string, reason: string, phase?: string}>
168     */
169    public function pendingSubstrateRules(): array
170    {
171        return $this->pendingSubstrateRules;
172    }
173
174    /**
175     * Match a single test ID against a glob pattern.
176     *
177     *  - `*`  matches any sequence of non-`/` characters
178     *  - `**` matches any sequence including `/`
179     *  - All other regex metacharacters are escaped literally.
180     */
181    public static function matches(string $glob, string $testId): bool
182    {
183        $regex = self::globToRegex($glob);
184        return preg_match($regex, $testId) === 1;
185    }
186
187    private static function globToRegex(string $glob): string
188    {
189        $regex = '';
190        $length = strlen($glob);
191        $i = 0;
192        while ($i < $length) {
193            $char = $glob[$i];
194            if ($char === '*') {
195                if ($i + 1 < $length && $glob[$i + 1] === '*') {
196                    $regex .= '.*';
197                    $i += 2;
198                    continue;
199                }
200                $regex .= '[^/]*';
201                $i++;
202                continue;
203            }
204            // Escape regex metacharacters — preg_quote handles all of
205            // them. We can't use preg_quote on the whole glob because
206            // it would escape `*` too.
207            $regex .= preg_quote($char, '#');
208            $i++;
209        }
210        return '#^' . $regex . '$#';
211    }
212
213    /**
214     * @return array<string, mixed>|null
215     */
216    private static function loadJsonFile(string $path): ?array
217    {
218        try {
219            $contents = LocalFilesystem::readFile($path, 'WPT manifest file');
220        } catch (\Throwable) {
221            return null;
222        }
223        $data = json_decode($contents, true);
224        if (!is_array($data)) {
225            return null;
226        }
227        /** @var array<string, mixed> $data */
228        return $data;
229    }
230
231    /**
232     * Coerce a JSON `out-of-scope` / `pending-substrate` array into
233     * the canonical rule shape. Drops malformed entries silently.
234     *
235     * @param mixed $raw
236     * @return list<array{glob: string, reason: string, phase?: string}>
237     */
238    private static function normaliseRules(mixed $raw): array
239    {
240        if (!is_array($raw)) {
241            return [];
242        }
243        $rules = [];
244        foreach ($raw as $entry) {
245            if (
246                !is_array($entry)
247                || !isset($entry['glob'], $entry['reason'])
248                || !is_string($entry['glob'])
249                || !is_string($entry['reason'])
250            ) {
251                continue;
252            }
253            $rule = ['glob' => $entry['glob'], 'reason' => $entry['reason']];
254            if (isset($entry['phase']) && is_string($entry['phase'])) {
255                $rule['phase'] = $entry['phase'];
256            }
257            $rules[] = $rule;
258        }
259        return $rules;
260    }
261}