cornhsu-parity 1.1.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +271 -32
  3. package/bin/parity.js +18 -11
  4. package/package.json +8 -8
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 許彧銘 Hsu Yu-Ming (Cornhsu)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  ## GitHub Action
12
12
 
13
13
  ```yaml
14
- - uses: HSU-YU-MING/cornhsu-parity@v1 # @v1 tracks the latest 1.x (interfaces frozen since 1.0)
14
+ - uses: HSU-YU-MING/cornhsu-parity@v2 # @v2 tracks the latest 2.x (interfaces frozen within a major)
15
15
  with:
16
16
  config: parity.config.json # optional, defaults to parity.config.json
17
17
  target: /pricing # optional, check only this route (omit for all)
@@ -63,13 +63,13 @@ PR comment, updating the same comment rather than adding new ones.
63
63
 
64
64
  | | |
65
65
  |---|---|
66
- | Releases | 27 on NuGet (v0.1.0 → v1.1.0). Pushing a tag publishes via OIDC Trusted Publishing — zero long-lived keys in the repo |
66
+ | Releases | 29 on NuGet (v0.1.0 → v2.1.0). Pushing a tag publishes via OIDC Trusted Publishing — zero long-lived keys in the repo |
67
67
  | Compared properties | Size, padding, spacing, typography, color (CIEDE2000 ΔE), relative position — **absolute coordinates are deliberately excluded** (they are guaranteed false positives in a flexible layout) |
68
68
  | Design sources | 4: the Figma API, a rendering snapshot, an image + annotations (pixel sampling — any tool that exports a PNG works), or JSON |
69
69
  | Implementation sources | The web (including **shadow DOM, same-origin iframes, and multiple responsive breakpoints**) plus **Electron** (attached to the live window over CDP) |
70
- | Tests | **208**, covering the CIEDE2000 reference data set (Sharma), match disambiguation, position false-positive guards, image sampling, and real-browser capture regressions |
70
+ | Tests | **1047**, covering the CIEDE2000 reference data set (Sharma), match disambiguation, position false-positive guards, image sampling, and real-browser capture regressions |
71
71
  | Proven in CI | The GitHub Action is validated by **real PRs in an external repo**: blocking the PR, commenting automatically (updating in place), and baseline regression gating |
72
- | Proven in production | **All 21 pages of cornhsu.com are gated by Parity itself** — dogfooding has already found and fixed three flaky root causes (most recently a scroll-triggered entrance animation; see 0.11.1) |
72
+ | Proven in production | **All 26 pages of cornhsu.com are gated by Parity itself** — dogfooding has already found and fixed three flaky root causes (most recently a scroll-triggered entrance animation; see 0.11.1) |
73
73
 
74
74
  ## Install
75
75
 
@@ -86,15 +86,23 @@ dotnet tool install -g Cornhsu.Parity
86
86
  > The npm build is the same self-contained native executable; you only download the one for
87
87
  > your platform (win32-x64 / win32-arm64 / linux-x64 / linux-arm64 / darwin-x64 / darwin-arm64).
88
88
  > Playwright normally bundles its own copy of Node; the npm build strips that out and uses the
89
- > Node you already have, which saves about 88 MB per platform package.
89
+ > Node you already have, which saves at least 88 MB per platform package. That Node has to be
90
+ > **20 or newer** — the minimum of the bundled Playwright driver (`engines.node` says so).
91
+
92
+ > **`parity check` sends anonymous usage statistics by default** (counts and score ranges only, for
93
+ > academic research). What is sent, why, and every way to turn it off:
94
+ > [Anonymous usage statistics](#anonymous-usage-statistics). To see the exact data without sending
95
+ > anything: `parity check --stats-preview`.
90
96
 
91
97
  ## Quick start
92
98
 
93
99
  ```sh
94
100
  parity init # Generate a parity.config.json template
95
- parity install-browser # First run only: download Playwright Chromium
101
+ parity install-browser # Download Playwright Chromium: on first run, and again after an
102
+ # upgrade if check says Chromium is not installed
96
103
  export FIGMA_TOKEN=... # Only the file_content:read scope is needed
97
104
  parity check # Compare; writes a report and sets the exit code
105
+ # (--stats-preview: print the anonymous statistics it would send, send nothing)
98
106
  parity report # Re-render the Markdown report from an existing report.json
99
107
  # (no re-scan; --md writes a file, otherwise it prints to stdout)
100
108
  parity snapshot # Freeze the currently rendered UI as the design baseline —
@@ -105,6 +113,11 @@ parity lint # Design lint: are the design file's values inside th
105
113
  # design-token set? (design only, no implementation)
106
114
  parity check --reverse # Reverse view, for a designer redrawing an existing page:
107
115
  # how does my draft differ from what ships? (never gates)
116
+ parity bug-report # (experimental) Collect version / OS / Node / Chromium info and print
117
+ # it with a pre-filled GitHub issue link. bug-report itself connects to nothing;
118
+ # opening the link sends the masked report to github.com (it rides in
119
+ # the URL). No issue until you submit the form. --config adds a
120
+ # structural summary without URLs, routes, keys, tokens or paths
108
121
  ```
109
122
 
110
123
  When working inside this repo:
@@ -125,24 +138,29 @@ serious-severity gaps — which is exactly the behaviour you want from a CI gate
125
138
  "figmaFileKey": "abcd1234",
126
139
  "designToken": "env:FIGMA_TOKEN",
127
140
  "mapFile": "parity.map.json", // manual fallback mapping (layer name → selector)
128
- "tokensFile": "tokens.json", // optional: design tokens (name → value);
141
+ "tokensFile": "tokens.json", // optional: design tokens, a flat object of string values
142
+ // ({"brand": "#2563EB", "space-4": "16px"}; // comments ok);
129
143
  // suggested fixes will name the matching token
130
144
  "targets": [
131
145
  { "route": "/", "frame": "10:2", "url": "http://localhost:8080/" }
132
146
  ],
133
147
  "compare": { "position": "relative" }, // relative = compare relative position (default); none = skip position
134
- "tolerances": { "sizePx": 2, "spacingPx": 2, "colorDeltaE": 2.0, "positionPx": 4 },
148
+ "tolerances": { "sizePx": 2, "spacingPx": 2, "colorDeltaE": 2.0, "fontSizePx": 0.5, "positionPx": 4 },
135
149
  "ignore": ["[data-parity-ignore]"],
136
150
  "gate": {
137
- "failOn": ["critical", "serious"],
138
- "minMatchRate": 0 // optional: FAIL outright below this match rate (0–1); 0 = no threshold
139
- }
151
+ "failOn": ["critical", "serious"], // any of minor / medium / serious / critical, one per string
152
+ "minMatchRate": 0 // optional: below this match rate (0–1) → exit 3, see below; 0 = no threshold
153
+ },
154
+ "stats": { "share": true } // optional: false turns anonymous usage statistics off;
155
+ // see "Anonymous usage statistics" below (also projectId)
140
156
  }
141
157
  ```
142
158
 
143
159
  > Besides gaps, the gate also validates **match integrity**: zero matches (or zero design nodes)
144
- > is always a GATE FAIL — with nothing matched there are no gaps to catch, so a silent PASS
145
- > would be a false pass, usually caused by a wrong url or frame. `--baseline` mode is not exempt.
160
+ > always fails, with exit code 3 (`MATCH INTEGRITY TOO LOW`) rather than 1 — with nothing
161
+ > matched there are no gaps to catch, so a silent PASS would be a false pass, usually caused by
162
+ > a wrong url or frame. A match rate below `gate.minMatchRate` fails the same way, with exit
163
+ > code 3. `--baseline` mode is not exempt; `--reverse` never gates and always exits 0.
146
164
 
147
165
  ## Targets: a web page or an Electron desktop app
148
166
 
@@ -209,15 +227,20 @@ Only the things that should hold no matter how the layout flows:
209
227
  as references (TEXT/HUG boxes are never references, TEXT is never a target, and Y is honestly
210
228
  skipped when everything above is text), so line-height drift in a flowing layout does not
211
229
  produce false positives. Turn it off with `compare.position: "none"`.
212
- - **Typography**: size, weight, line-height and letter-spacing are compared exactly;
213
- font-family is a **soft gap** and never gates.
230
+ - **Typography**: size and letter-spacing are compared as numbers within `tolerances.fontSizePx`,
231
+ line-height within `tolerances.sizePx`; weight has no tolerance — any difference is a gap
232
+ (under 200 apart is medium, 200 or more is serious). font-family is a **soft gap**: it is
233
+ reported (severity `minor`, marked soft) but costs no fidelity points and **never gates** —
234
+ not even when `failOn` includes `minor`, and `--baseline` mode does not count it as new.
214
235
  - **Color**: thresholded on CIEDE2000 (ΔE), not hex equality. Parsing covers modern syntax
215
236
  (`rgb(37 99 235 / .5)`, `color(srgb …)`, `oklch()`, `color(display-p3 …)`).
216
237
  - **Absolute x/y is deliberately never compared**: it legitimately differs in a flexible layout,
217
238
  so comparing it means false positives, which means losing trust.
218
239
 
219
- > When the design source is Figma, layer names in the report (both the Markdown and the local UI)
220
- > **link back to that node in Figma** — a designer clicks once and lands on the layer.
240
+ > When the design source is Figma, layer names in the report (the Markdown written by
241
+ > `check --md` or re-rendered by `parity report`, and the local UI) **link back to that node in
242
+ > Figma** — a designer clicks once and lands on the layer. When `designFile` is set it wins over
243
+ > `figmaFileKey`, so a snapshot- or JSON-based report carries no Figma links.
221
244
 
222
245
  ## Two workflows for designers
223
246
 
@@ -245,7 +268,9 @@ frame's size**, so a 375-wide phone frame makes your media queries fire naturall
245
268
  ```
246
269
 
247
270
  `route` is only a label in the report, so pick something recognizable. You only need `width` /
248
- `height` on a target when the frame's width is not the viewport width you want to test.
271
+ `height` on a target when the frame's size is not the viewport you want to test. Each one
272
+ overrides its own axis: `width` alone keeps the default height of 800, `height` alone keeps the
273
+ frame's width.
249
274
 
250
275
  ## Shadow DOM / iframes
251
276
 
@@ -262,7 +287,8 @@ unaffected and works as usual).
262
287
 
263
288
  1. **Automatic text anchoring**: design TEXT content ↔ page text (matched only when unique;
264
289
  when several elements share the text, the layer name disambiguates — and it still refuses to force a match)
265
- 2. **Layer name ↔ id / class / aria-label**: `CTA Button` matches `class="cta-button"` automatically
290
+ 2. **Layer name ↔ id / class / aria-label** (falling back to `data-testid` when there is no
291
+ aria-label): `CTA Button` matches `class="cta-button"` automatically
266
292
  3. **Container inference**: an unmatched container is inferred from the lowest common ancestor of
267
293
  its already-matched descendants (pure structure — no guessing)
268
294
  4. **Manual fallback**: add `data-parity="Layer Name"` in your HTML, or write
@@ -286,11 +312,102 @@ src/Parity.Engine/ The engine; FidelityEngine is the only entry point
286
312
  DesignSources/ IDesignSource + Figma (REST + local cache) / Json
287
313
  ImplementationSources/ IImplementationSource + Web (Playwright)
288
314
  Comparison/ Normalizer / Matcher / DiffEngine / ColorDelta (CIEDE2000)
289
- src/Parity.Cli/ The dotnet tool shell: parity check / init / install-browser
315
+ src/Parity.Report/ The report contract: report.json types, ReportWire, the fidelity score,
316
+ baseline comparison — no package dependencies, so no Playwright
317
+ src/Parity.Cli/ The dotnet tool shell: parity check / init / install-browser,
318
+ the local report UI (parity serve) and parity push (experimental)
319
+ src/Parity.Storage/ Baseline history (EF Core + SQLite), behind parity baseline
320
+ src/Parity.Server/ Team dashboard shell (ASP.NET Core) — not published to NuGet
321
+ src/Parity.Server.Data/ Its database (EF Core + SQLite, migrations from day one)
322
+ web/ The dashboard front end (React + Vite + TypeScript); the build
323
+ output lands in src/Parity.Server/wwwroot
290
324
  tests/Parity.Tests/ Unit tests (including the CIEDE2000 reference data)
291
325
  samples/demo/ Offline demo: a deliberately broken page + a design JSON
292
326
  ```
293
327
 
328
+ There are three shells, and **the engine does not know that any of them exist**:
329
+
330
+ 1. **CLI** (`Parity.Cli`) — `parity check` in a terminal or in CI. This is the only shell
331
+ published as a package (NuGet / npm / the GitHub Action).
332
+ 2. **Local report UI** (`parity serve`) — a zero-build SPA bound to 127.0.0.1, for the
333
+ person who is actually fixing the gaps.
334
+ 3. **Team dashboard** (`Parity.Server` + `web/`) — somewhere the reports can accumulate, so
335
+ that trends exist and so that people who never touch the tool have a place to look. You
336
+ deploy it yourself; it is not on NuGet, and it is **still in development** (see M7 below).
337
+
338
+ > **Where the dashboard keeps its data.** The web host and the admin commands (`create-project`,
339
+ > `create-invite`) find the SQLite database the same way: `ConnectionStrings:parity` (from
340
+ > `appsettings.json` or the `ConnectionStrings__parity` environment variable), then
341
+ > `PARITY_SERVER_DB` (a file path), then `parity-server.db` in the working directory. This is the
342
+ > reverse of .NET's usual "environment beats appsettings": a connection string in
343
+ > `appsettings.json` wins over `PARITY_SERVER_DB` — to override it from the environment, set
344
+ > `ConnectionStrings__parity`. The reason: an explicit connection string is the more specific
345
+ > setting, and the integration tests inject one that a developer's `PARITY_SERVER_DB` must not
346
+ > redirect.
347
+
348
+ ### One dashboard, two tools
349
+
350
+ The same server also takes reports from **[XamlContrast](https://github.com/HSU-YU-MING/cornhsu-xamlcontrast)**
351
+ (the static WCAG contrast audit for XAML apps): `xamlcontrast --push <report.json>` sends the file
352
+ its `--json` option wrote, through the same `POST /api/runs`, marked with an `X-Parity-Tool: xamlcontrast`
353
+ header (`parity push` sends no such header, which the server reads as `parity`). A project can hold runs
354
+ from both tools. XamlContrast runs get their own overview card per project (pairs below AA, fail · warn,
355
+ coverage), a fail/warn trend, and an issue list (file and line, element, foreground and background as
356
+ written in the XAML, the ratio in each theme, the threshold). Each XamlContrast run also shows what changed
357
+ since the previous XamlContrast run on the same branch — new, worse, fixed and improved pairs — compared the
358
+ way XamlContrast's own `--baseline` does (a pair is file, element, foreground and background, without the line
359
+ number; more places or a lower worst ratio is worse). Like `parity push`, the XamlContrast side is
360
+ **experimental** until the dashboard is publicly deployed.
361
+
362
+ ### Hosting it yourself
363
+
364
+ The repo root has a `Dockerfile`. The image contains the server, its data layer and the report types,
365
+ and nothing else: no browser, no engine (CI checks this).
366
+
367
+ ```sh
368
+ docker build -t parity-server .
369
+ docker run -d --name parity -p 8080:8080 -v parity-data:/data \
370
+ -e Parity__PublicUrl=https://parity.example.com \
371
+ -e ASPNETCORE_FORWARDEDHEADERS_ENABLED=true \
372
+ parity-server
373
+ docker exec parity dotnet Parity.Server.dll create-project my-site # prints the CI token once
374
+ docker exec parity dotnet Parity.Server.dll create-invite my-site you@example.com owner
375
+ ```
376
+
377
+ | Setting | What it does |
378
+ |---|---|
379
+ | `Parity__PublicUrl` | The address people open. Invite links are built from it, not from the `Host` the server sees (behind a proxy that can be an internal name). It must be an absolute `http(s)` URL; a bad value stops the server and the admin commands. |
380
+ | `Parity__KeysDirectory` | Where the keys that sign the login cookie live. Preset to `/data/keys` in the image. Without it, every redeploy of a container signs everyone out. |
381
+ | `PARITY_SERVER_DB` | The SQLite database. Preset to `/data/parity-server.db` in the image. |
382
+ | `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` | Behind a reverse proxy, **required**, and only behind a proxy you trust (otherwise anyone can forge their client IP). Without it the server sees one IP for everybody, and a single person can lock all users out of login with 20 attempts a minute. |
383
+
384
+ - **Everything lives in `/data`**: mount a volume there. TLS is the job of the proxy or platform in front.
385
+ - **Run one instance.** The database is SQLite and login attempts are serialized inside one process;
386
+ more than one instance needs a different database first.
387
+ - **Backups**: `docker exec parity dotnet Parity.Server.dll backup /data/backup-2026-10-26.db` writes a
388
+ consistent copy while the server keeps running (SQLite `VACUUM INTO`; it never overwrites a file and
389
+ never creates a missing database). Copy it off the machine. To restore: stop the server, put the copy
390
+ where the database was, start it again.
391
+ - **Uptime**: `GET /healthz` is anonymous and answers `ok` only when the database answers, `503` otherwise.
392
+
393
+ ### The iron rule: the cloud never runs a browser
394
+
395
+ `Parity.Server`'s only endpoint that takes in scan results is `POST /api/runs` (its other
396
+ write endpoints only manage sign-in, invites, members, tokens and run deletion), and what it
397
+ accepts is **a report that has already finished running**. There is no "give me a URL and I will go scan it"
398
+ endpoint, and there will not be one: **scanning always happens on your own machine or in your
399
+ CI**, where the page, the credentials and the localhost port already are.
400
+
401
+ This is not an implementation detail — it is the security claim of the whole design. It is why
402
+ the dashboard has **no SSRF surface at all**: it is never handed a URL to fetch. And it is
403
+ enforced structurally rather than by policy — the server projects do not reference the engine
404
+ at all, only `Parity.Report` (the report types, which have no package dependencies), so neither
405
+ Playwright nor its driver is anywhere in the server's build output. A test walks the server's
406
+ whole project-reference graph and turns CI red if any project in it references Playwright or
407
+ the engine; another turns it red the moment server code references a Playwright type or the
408
+ engine's capture side (`FidelityEngine`, `IImplementationSource`, or anything in
409
+ `ImplementationSources.Web`, which is the code that opens Chromium).
410
+
294
411
  ## Local report UI (M3)
295
412
 
296
413
  ```sh
@@ -321,12 +438,12 @@ jobs:
321
438
  contents: read
322
439
  pull-requests: write # lets the action post the fidelity report as a PR comment
323
440
  steps:
324
- - uses: actions/checkout@v4
441
+ - uses: actions/checkout@v7
325
442
  # Start your site (or deploy to a preview URL and point the config's url at it)
326
443
  - run: |
327
444
  npm ci && npm run build
328
445
  npm run preview & # e.g. serving on localhost:8080
329
- - uses: HSU-YU-MING/cornhsu-parity@v1 # @v1 tracks the latest 1.x
446
+ - uses: HSU-YU-MING/cornhsu-parity@v2 # @v2 tracks the latest 2.x
330
447
  with:
331
448
  config: parity.config.json
332
449
  figma-token: ${{ secrets.FIGMA_TOKEN }} # omit when the design source is a local JSON file
@@ -334,25 +451,49 @@ jobs:
334
451
 
335
452
  Behaviour:
336
453
 
337
- - **PR comment**: posts a fidelity report (score + gap table + **suggested fixes**) and keeps
454
+ - **PR comment**: posts a fidelity report (score + gap table + **suggested fixes**, ending with a
455
+ "Report a misjudgment" link to the issue form — a link only, nothing is sent) and keeps
338
456
  updating that same comment instead of adding new ones — so PMs and reviewers see it without
339
457
  ever touching the tool.
340
458
  - **Blocks the PR**: gaps beyond `gate.failOn` → exit 1 → red PR. (**The comment is posted
341
459
  first, then the gate is enforced.**)
342
- - **Artifacts**: `report.json` plus the Markdown report are uploaded for download.
460
+ - **Artifacts**: `report.json` plus the Markdown report are uploaded for download (artifact
461
+ name `parity-report` unless you set `artifact-name`; `report.json` and `parity-report.md` at its
462
+ root). `report.json` is picked up from `.parity/report.json` next to the config file — where
463
+ `check` writes it — even when `config` points into a subdirectory.
464
+ - **Running it more than once per workflow run**: prefer a **matrix** (one job per site or config), and
465
+ give each call its own `artifact-name` (e.g. `parity-report-${{ matrix.site }}`). Each name gets its
466
+ own artifact and keeps its own PR comment; two calls with the same name collide on the artifact upload.
467
+ Two calls in **one job** work only while the first one passes: when its gate fails, the job stops at
468
+ that call's last step and the second call never runs (no report, no comment, no artifact for it).
469
+ - **Anonymous usage statistics**: on by default ([what and why](#anonymous-usage-statistics));
470
+ `share-stats: false` turns them off. The job summary gets one line saying whether they were sent, or why not.
343
471
 
344
472
  Action inputs: `config` / `target` / `working-directory` / `version` / `figma-token` /
345
- `baseline` (regression mode) / `comment` (turn off the PR comment) / `upload-report`.
473
+ `baseline` (regression mode) / `comment` (turn off the PR comment) / `upload-report` /
474
+ `artifact-name` (default `parity-report`; letters, digits, `.`, `_`, `-`, at most 100 characters, not starting with `-` and not only dots) /
475
+ `share-stats` (default `true`; exactly `true` or `false` — anything else stops the action before it installs Parity).
346
476
 
347
477
  > The action installs the tool with `dotnet tool install -g Cornhsu.Parity` (published on
348
- > nuget.org). This repo's own `.github/workflows/ci.yml` instead **builds from source** and runs
349
- > the offline demo as a self-check, so it does not depend on a published release.
478
+ > nuget.org) — the latest 2.x when `version` is omitted, so `@v2` never jumps to a future 3.x,
479
+ > or exactly the `version` you pass. **Within one job the tool is installed once per call into the same
480
+ > global location, and a later call cannot install a lower version than an earlier one** (`dotnet tool`
481
+ > refuses to downgrade) — e.g. a first call without `version` installs the latest 2.x, and a second call
482
+ > with `version: 2.0.0` fails. Give every call in a job the same `version`, or use a matrix. This repo's own `.github/workflows/ci.yml` instead **builds from
483
+ > source** and runs the offline demo as a self-check, so it does not depend on a published release.
484
+ >
485
+ > **Upgrading from `@v1`?** `v1` stays at 1.1.0, whose action installs the newest release of *any*
486
+ > major when `version` is omitted — so an unpinned `@v1` workflow already runs 2.x. Switch to `@v2` and
487
+ > work through the "upgrade required" list in the CHANGELOG's 2.0.0 entry, or set `version: 1.1.0` to
488
+ > stay on 1.x for now.
350
489
 
351
490
  ## Report vocabulary (for anyone parsing `report.json`)
352
491
 
492
+ - **Unknown keys** — consumers must ignore keys they do not recognize. Within 2.x, `report.json` only *gains* fields; existing keys are never removed, renamed or given a different type (that would be 3.0). Closed sets below never gain members within 2.x either.
353
493
  - `severity` — **closed set** (spelling and membership frozen): `none` / `minor` / `medium` / `serious` / `critical`. `status`: `mismatch` / `missing`.
354
494
  - `unmatched[].reason` — **open set**: parsers must tolerate unknown values; known spellings never change. Currently: `no-anchor`, `ambiguous-or-missing-text`, `randomized-id`, `size-implausible`.
355
495
  - `nodes[].matchedBy` — **open set**, same rule. Currently: `selector`, `explicit`, `auto-text`, `auto-name`, `auto-container`.
496
+ - `direction` (top level, since 2.0.0) — **closed set**: `forward` (a normal `check`) / `reverse` (`check --reverse`: expected and actual swapped, never gated). Always written; files without it read as `forward`. Readers accept exactly the lowercase strings `"forward"` and `"reverse"` — any other value (other casing, a number, `null`) is an error, not a guess. `parity report` does not gate a reverse report, and `parity push` refuses one.
356
497
 
357
498
  ## Regression gating: baselines (M5)
358
499
 
@@ -387,17 +528,111 @@ file is released immediately), and the engine's `BaselineComparer` is a pure, un
387
528
  - [x] **M3** Local report UI (`parity serve --watch`, Kestrel bound to 127.0.0.1) + `parity map` interactive matching
388
529
  - [x] **M4** GitHub Action: a reusable composite action (`action.yml`) + this repo's own CI (build / test / offline-demo self-check)
389
530
  - [x] **M5** EF Core + SQLite baselines and history (regression gating + score trend) + `ImageDesignSource` (image + annotations + pixel sampling) + `parity snapshot` (freeze the current state as the baseline)
390
- - [ ] **M6** (optional) Cloud shell: public-URL scanning + SSRF protection
531
+ - [ ] **M6** (optional) Cloud shell: public-URL scanning + SSRF protection — **deliberately
532
+ declined** (2026-07-18). This is *not* what M7 is: M7 never scans anything. The reasoning is
533
+ kept in [ROADMAP.md](ROADMAP.md).
534
+ - [ ] **M7** Team dashboard shell (`Parity.Server` + `Parity.Server.Data` + `web/`) — **in
535
+ progress, not yet released.** `parity push` (**experimental**: the server is not publicly deployed
536
+ yet, so the command and its server protocol may change in a 2.x minor release and join the interface
537
+ freeze once the server ships) sends a finished `report.json` to a server you host yourself; the dashboard renders the gaps as an engineering blueprint drawn from the
538
+ measured coordinates (no screenshot upload), an overview and score trend per project and
539
+ route, and accounts / project roles / invite links. It also takes XamlContrast reports
540
+ ([one dashboard, two tools](#one-dashboard-two-tools)). Its own internal M1–M4.6 are done; of its
541
+ M5, the deployment groundwork is done (a Docker image, the public URL, persisted sign-in keys,
542
+ online backups), the actual hosting (a host, a domain, TLS) is not. **It receives
543
+ reports, it never scans** — see the iron rule above.
391
544
 
392
545
  > Open work, known blind spots and the next priorities are in [ROADMAP.md](ROADMAP.md);
393
546
  > version history is in [CHANGELOG.md](CHANGELOG.md).
394
547
 
548
+ ## Anonymous usage statistics
549
+
550
+ From 2.1.0, `parity check` sends **anonymous usage statistics**, once per run — **on by default, in CI
551
+ too**. They are counts and score ranges used for **academic research** on how far real
552
+ implementations drift from their designs; only aggregate results are published. The public data page — what is collected, what it is used
553
+ for, how long it is kept — is **<https://stats.cornhsu.com/>**.
554
+
555
+ **When.** Only `parity check`, and only when it produced a report (exit 0, 1 or 3). `--reverse`,
556
+ `--stats-preview`, every other command and any run that ends in exit 2 send nothing.
557
+
558
+ **Everything that is sent:**
559
+
560
+ | Field | Meaning |
561
+ |---|---|
562
+ | `schema`, `tool` | Always `1` and `"parity"` |
563
+ | `toolVersion` | The Parity version, e.g. `2.1.0` (no commit hash) |
564
+ | `os` | `windows`, `linux`, `macos` or `other` |
565
+ | `ci` | Whether `CI` or `GITHUB_ACTIONS` is `true` |
566
+ | `projectId` | `null`, unless you set `stats.projectId` yourself (see below) |
567
+ | `data.score` | The fidelity score `check` printed (0–100) |
568
+ | `data.gateFailed` | Whether `check`'s exit code was not 0 (so `--baseline` and exit 3 count as they did in your CI) |
569
+ | `data.pages` | How many targets the report covers |
570
+ | `data.designSource` | `figma`, `snapshot` (a file written by `parity snapshot`), `file` (any other design JSON) or `image` |
571
+ | `data.designNodes`, `data.matched` | Design nodes and matched nodes, summed over all pages |
572
+ | `data.severity` | Gap counts per severity: `critical`, `serious`, `medium`, `minor` |
573
+ | `data.props` | For each property with at least one gap: how many gaps, how many were `missing`, and how many fall in each range of `abs(delta)` — px for lengths (`offsetX`/`offsetY`, size, padding, `itemSpacing`, `cornerRadius`, `fontSize`, `lineHeight`, `letterSpacing`: <1, 1–2, 2–4, 4–8, 8–16, 16–32, ≥32), ΔE for `color`/`background` (<1, 1–2, 2–5, 5–10, 10–20, ≥20), and weight steps for `fontWeight` (<100, 100–200, 200–300, ≥300). `fontFamily` has no ranges, only counts |
574
+
575
+ **Never sent:** URLs, routes, page or layer names, selectors, file names or paths, screenshots, colour
576
+ values, design content, tokens, repository names or IDs, user names, machine names. The request has no
577
+ User-Agent and no cookies. The collector does not store IP addresses or the time a record arrived (only
578
+ its date); Cloudflare, which hosts the collector, sees the connection address at its edge, as any network
579
+ provider does.
580
+
581
+ **No ID unless you add one.** Without `stats.projectId`, nothing links one run to another or to you —
582
+ which also means a record cannot be picked out and deleted afterwards. If you want your runs counted as
583
+ one project (and deletable on request), make up a random ID — for example `openssl rand -hex 12`, or
584
+ `[guid]::NewGuid().ToString('N')` in PowerShell. Parity never derives one from your repository, paths or
585
+ machine, and an ID that is not 16–64 characters of `A–Z a–z 0–9 _ -` is a config error (exit 2):
586
+
587
+ ```jsonc
588
+ "stats": {
589
+ "share": true, // false turns statistics off
590
+ "projectId": "REPLACE-with-your-own-random-id" // optional; never copy someone else's
591
+ }
592
+ ```
593
+
594
+ **Preview.** `parity check --stats-preview` runs the check as usual, sends nothing, and prints the exact
595
+ JSON to stderr, followed by whether it would be sent — or why not. The exit code, stdout and the report
596
+ files are unchanged.
597
+
598
+ **Turning it off.** Any one of these is enough; they are checked in this order, and the first that says
599
+ no wins:
600
+
601
+ 1. `DO_NOT_TRACK` set to anything except `0` or `false` — always wins.
602
+ 2. `CORNHSU_SHARE_STATS` (shared with [XamlContrast](https://github.com/HSU-YU-MING/cornhsu-xamlcontrast))
603
+ or `PARITY_SHARE_STATS` set to `false` — in fact any value other than `1`, `true`, `yes` or `on`, so a
604
+ typo errs on the side of *not* sending.
605
+ 3. `"stats": { "share": false }` in `parity.config.json`.
606
+ 4. The GitHub Action's `share-stats: false` (it sets `PARITY_SHARE_STATS=false`).
607
+ 5. Builds from source and other non-release builds (any version starting with `0.0.0-`, such as `0.0.0-dev`) never send.
608
+
609
+ Like every other key in the config, a misspelt one is ignored: `"stats": { "shrae": false }` turns
610
+ nothing off. `--stats-preview` tells you whether a switch took effect.
611
+
612
+ **Notice.** The first time `check` sends on a machine, it prints a short notice to stderr and leaves a
613
+ marker file, `cornhsu/parity-stats-notice` in your user settings directory (`%APPDATA%` on Windows,
614
+ `~/.config` elsewhere), so the notice is not repeated — delete the file to see it again. In CI it prints
615
+ one line every time statistics were sent. Nothing is printed when nothing is sent, and nothing ever goes
616
+ to stdout. The GitHub Action also writes one line to the job summary: sent, or why not.
617
+
618
+ **Cost.** The request waits at most 1.5 s to connect and 3 s in total, is never retried and never follows
619
+ a redirect. When it fails — offline, a firewall, the collector is down — nothing is printed and nothing
620
+ else changes: the exit code, stdout, the report files and the PR comment are exactly what they would have
621
+ been.
622
+
395
623
  ## Security
396
624
 
625
+ - **What Parity connects to**: the Figma API (only with a Figma design source), the dashboard you name
626
+ for `parity push`, and `stats.cornhsu.com` for the [anonymous usage statistics](#anonymous-usage-statistics)
627
+ of `parity check` (unless turned off). The page under test is opened by the local browser. Nothing else.
397
628
  - The Figma token comes from an environment variable (`env:FIGMA_TOKEN`); it never reaches a log
398
629
  and never goes in a URL (it is sent as the `X-Figma-Token` header).
399
- - Fetched frames are cached in `.parity/cache` (gitignored), so re-runs do not hit Figma and can
400
- compare offline.
630
+ - Fetched frames are cached in `.parity/cache` (add `.parity/` to your `.gitignore`), so re-runs do
631
+ not hit Figma and can compare offline.
632
+ - The local report UI binds 127.0.0.1 only, and additionally rejects unexpected `Host` headers
633
+ (DNS rebinding) and cross-origin `POST`s (CSRF).
634
+ - **The team dashboard never scans anything** — it only receives finished reports, so it has no
635
+ SSRF surface. See [the iron rule](#the-iron-rule-the-cloud-never-runs-a-browser).
401
636
 
402
637
  ## See also
403
638
 
@@ -413,8 +648,12 @@ same author, built on the same philosophy: numeric checks that gate CI. Parity a
413
648
  implementation match the design?**" (fidelity); XamlContrast answers "**can anyone actually see
414
649
  what we built?**" (WCAG contrast, scanned statically from XAML source). One guards fidelity, the
415
650
  other readability — and its static color resolution is also the groundwork for the expected values
416
- of this project's future `WpfImplementationSource` desktop adapter.
651
+ of this project's future `WpfImplementationSource` desktop adapter (see §4.5 of the
652
+ [project plan](docs/Parity專案規畫書.md), in Traditional Chinese).
417
653
 
418
654
  ## License
419
655
 
420
- MIT
656
+ MIT. The NuGet and npm packages also bundle third-party components (Playwright, ImageSharp,
657
+ SQLite and others; the npm packages also include the .NET runtime). Their licenses are listed in
658
+ [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md), which ships inside the NuGet package and every npm
659
+ platform package.
package/bin/parity.js CHANGED
@@ -17,24 +17,31 @@
17
17
  const { spawnSync } = require("node:child_process");
18
18
  const path = require("node:path");
19
19
 
20
- // 平台 → 子套件名 / 執行檔名
20
+ // 平台 → 子套件名 / 執行檔名。必須與 npm/prepare.mjs 的 TARGETS 一一對應
21
+ // (RepoInvariantsTests 會比對;1.1.0 漏了兩個 arm64,平台包有發佈、npx 卻說不支援)
21
22
  const TARGETS = {
22
23
  "win32 x64": { pkg: "@cornhsu/parity-win32-x64", bin: "parity.exe" },
24
+ "win32 arm64": { pkg: "@cornhsu/parity-win32-arm64", bin: "parity.exe" },
23
25
  "linux x64": { pkg: "@cornhsu/parity-linux-x64", bin: "parity" },
26
+ "linux arm64": { pkg: "@cornhsu/parity-linux-arm64", bin: "parity" },
24
27
  "darwin x64": { pkg: "@cornhsu/parity-darwin-x64", bin: "parity" },
25
28
  "darwin arm64": { pkg: "@cornhsu/parity-darwin-arm64", bin: "parity" },
26
29
  };
27
30
 
31
+ // 啟動腳本自己的錯誤一律 exit 2:CLI 的契約是 0 通過 / 1 落差超 gate / 2 執行錯誤 / 3 可信度不足,
32
+ // 回 1 會被 CI 讀成「設計還原度不合格」。訊息用英文,與 CLI 本體一致(0.12.0 起)。
33
+ const EXECUTION_ERROR = 2;
34
+
28
35
  const key = `${process.platform} ${process.arch}`;
29
36
  const target = TARGETS[key];
30
37
 
31
38
  if (!target) {
32
39
  console.error(
33
- `Cornhsu.Parity 沒有 ${key} 的預先建置版本。\n` +
34
- `目前支援:${Object.keys(TARGETS).join("、")}\n` +
35
- `其他平台請改用 .NET 版本:dotnet tool install -g Cornhsu.Parity`
40
+ `cornhsu-parity: there is no prebuilt build for ${key}.\n` +
41
+ `Supported: ${Object.keys(TARGETS).join(", ")}\n` +
42
+ `On other platforms use the .NET tool instead: dotnet tool install -g Cornhsu.Parity`
36
43
  );
37
- process.exit(1);
44
+ process.exit(EXECUTION_ERROR);
38
45
  }
39
46
 
40
47
  let binary;
@@ -45,11 +52,11 @@ try {
45
52
  binary = path.join(path.dirname(manifest), "bin", target.bin);
46
53
  } catch {
47
54
  console.error(
48
- `找不到平台套件 ${target.pkg}。\n` +
49
- `可能是安裝時加了 --no-optional,或安裝過程中斷。\n` +
50
- `請重新安裝:npm install cornhsu-parity`
55
+ `cornhsu-parity: the platform package ${target.pkg} is not installed.\n` +
56
+ `It is an optional dependency, so the install may have skipped it (--no-optional / --omit=optional) or been interrupted.\n` +
57
+ `Reinstall: npm install cornhsu-parity`
51
58
  );
52
- process.exit(1);
59
+ process.exit(EXECUTION_ERROR);
53
60
  }
54
61
 
55
62
  const result = spawnSync(binary, process.argv.slice(2), {
@@ -62,8 +69,8 @@ const result = spawnSync(binary, process.argv.slice(2), {
62
69
  });
63
70
 
64
71
  if (result.error) {
65
- console.error(`無法執行 ${binary}:${result.error.message}`);
66
- process.exit(1);
72
+ console.error(`cornhsu-parity: could not run ${binary}: ${result.error.message}`);
73
+ process.exit(EXECUTION_ERROR);
67
74
  }
68
75
 
69
76
  // 被訊號中斷時 status 會是 null;轉成慣例的 128+signal,別讓 CI 誤判為成功
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cornhsu-parity",
3
- "version": "1.1.0",
3
+ "version": "2.1.0",
4
4
  "description": "Property-Level Design QA that can gate your CI. Compares real numeric values from your Figma file against the rendered UI and fails the PR when the implementation drifts from the design.",
5
5
  "keywords": [
6
6
  "figma",
@@ -31,14 +31,14 @@
31
31
  "README.md"
32
32
  ],
33
33
  "engines": {
34
- "node": ">=18"
34
+ "node": ">=20"
35
35
  },
36
36
  "optionalDependencies": {
37
- "@cornhsu/parity-win32-x64": "1.1.0",
38
- "@cornhsu/parity-win32-arm64": "1.1.0",
39
- "@cornhsu/parity-linux-x64": "1.1.0",
40
- "@cornhsu/parity-linux-arm64": "1.1.0",
41
- "@cornhsu/parity-darwin-x64": "1.1.0",
42
- "@cornhsu/parity-darwin-arm64": "1.1.0"
37
+ "@cornhsu/parity-win32-x64": "2.1.0",
38
+ "@cornhsu/parity-win32-arm64": "2.1.0",
39
+ "@cornhsu/parity-linux-x64": "2.1.0",
40
+ "@cornhsu/parity-linux-arm64": "2.1.0",
41
+ "@cornhsu/parity-darwin-x64": "2.1.0",
42
+ "@cornhsu/parity-darwin-arm64": "2.1.0"
43
43
  }
44
44
  }