@binclusive/a11y 0.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 (80) hide show
  1. package/README.md +285 -0
  2. package/bin/a11y.mjs +36 -0
  3. package/bin/diff-scope.mjs +29 -0
  4. package/data/baseline-rules.json +892 -0
  5. package/package.json +68 -0
  6. package/src/agent-lane.ts +138 -0
  7. package/src/agents-block.ts +157 -0
  8. package/src/baseline/gen-baseline.ts +166 -0
  9. package/src/cli.ts +1026 -0
  10. package/src/collect-dom.ts +119 -0
  11. package/src/collect-liquid.ts +103 -0
  12. package/src/collect-swift.ts +227 -0
  13. package/src/collect-unity.ts +99 -0
  14. package/src/collect.ts +54 -0
  15. package/src/commands.ts +314 -0
  16. package/src/config-scan.ts +177 -0
  17. package/src/contract.ts +355 -0
  18. package/src/core.ts +546 -0
  19. package/src/detect-stack.ts +207 -0
  20. package/src/diff-scope-cli.ts +12 -0
  21. package/src/diff-scope.ts +150 -0
  22. package/src/emit-contract.ts +181 -0
  23. package/src/enforce.ts +1125 -0
  24. package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
  25. package/src/evidence.ts +308 -0
  26. package/src/github-identity.ts +201 -0
  27. package/src/hook.ts +242 -0
  28. package/src/impact-gate.ts +107 -0
  29. package/src/imports-resolve.ts +248 -0
  30. package/src/index.ts +183 -0
  31. package/src/liquid-ast.ts +203 -0
  32. package/src/liquid-rules.ts +691 -0
  33. package/src/mcp.ts +363 -0
  34. package/src/module-scope.ts +137 -0
  35. package/src/phone-home.ts +470 -0
  36. package/src/pr-comment.ts +250 -0
  37. package/src/pr-summary-cli.ts +182 -0
  38. package/src/pr-summary.ts +291 -0
  39. package/src/registry.ts +605 -0
  40. package/src/reporter/contract.ts +154 -0
  41. package/src/reporter/finding.ts +87 -0
  42. package/src/reporter/github-adapter.ts +183 -0
  43. package/src/reporter/null-adapter.ts +51 -0
  44. package/src/reporter/registry.ts +22 -0
  45. package/src/reporter-cli.ts +48 -0
  46. package/src/resolve-components.ts +579 -0
  47. package/src/runner/budget.ts +90 -0
  48. package/src/runner/codegraph-lookup.test.ts +93 -0
  49. package/src/runner/codegraph-lookup.ts +197 -0
  50. package/src/runner/index.ts +86 -0
  51. package/src/runner/lookup.ts +69 -0
  52. package/src/runner/provider.ts +72 -0
  53. package/src/runner/providers/anthropic.ts +168 -0
  54. package/src/runner/providers/openai.ts +191 -0
  55. package/src/runner/reasoner.ts +73 -0
  56. package/src/runner/reasoning/index.ts +53 -0
  57. package/src/runner/reasoning/prompt.ts +200 -0
  58. package/src/runner/reasoning/react.ts +149 -0
  59. package/src/runner/reasoning/shopify.ts +214 -0
  60. package/src/runner/reasoning/skills-reasoner.ts +117 -0
  61. package/src/runner/reasoning/types.ts +99 -0
  62. package/src/runner/runner.ts +203 -0
  63. package/src/sarif.ts +148 -0
  64. package/src/source-identity.ts +0 -0
  65. package/src/source-trace.ts +814 -0
  66. package/src/suggest.ts +328 -0
  67. package/src/suppression-ranges.ts +354 -0
  68. package/src/suppressor-map.ts +189 -0
  69. package/src/suppressors.ts +284 -0
  70. package/src/tsconfig-aliases.ts +155 -0
  71. package/src/unity-ast.ts +331 -0
  72. package/src/unity-findings.ts +91 -0
  73. package/src/unity-guid-registry.ts +82 -0
  74. package/src/unity-label-resolve.ts +249 -0
  75. package/src/unity-rule-color-only.ts +127 -0
  76. package/src/unity-rule-missing-label.ts +156 -0
  77. package/src/unity-rules-baseline.ts +273 -0
  78. package/src/wcag-map.ts +35 -0
  79. package/src/wcag-tags.ts +35 -0
  80. package/src/workspace-resolve.ts +405 -0
package/README.md ADDED
@@ -0,0 +1,285 @@
1
+ # a11y-checker — review build
2
+
3
+ A local accessibility checker for React/TSX code, grounded in a real-world audit corpus. It finds accessibility bugs at the source — **including in the design-system components a normal linter is blind to** — and tells you how common each failure is across real audits, with the fix that worked.
4
+
5
+ > **It runs entirely on your machine. No network, no account, no upload — your code never leaves the laptop.** That's not a privacy policy, it's how it's built: there's nothing to upload. Point it at a private repo with zero hesitation.
6
+
7
+ This is a private review build. Clone it, point it at any React codebase (yours, ours), and see what it finds — no setup, no explanation needed.
8
+
9
+ > **New here? Start with the [Getting Started](docs/GETTING-STARTED.md) walkthrough.** Zero to your first fix — install, `init`, wire your editor, read a finding, clear it, gate CI.
10
+
11
+ ---
12
+
13
+ ## See it in 30 seconds
14
+
15
+ On **an open-source component library's own demo app**, `eslint-plugin-jsx-a11y` (the linter everyone runs) passes the docs search box **clean** — while a11y-checker catches its unlabeled `<Input>`, ranks it (`22/26 orgs`), and hands you the fix.
16
+
17
+ ![a11y-checker vs eslint on a component library demo app](demo/component-library.gif)
18
+
19
+ **▶ [Watch all five demos →](demo/README.md)** — the head-to-head above, a getting-started walkthrough on an open-source monorepo, the `binclusive.json` config reference, the state of accessibility across 31 OSS repos, and the agentic self-fix loop. Each is a replayable [asciinema](https://asciinema.org) cast (`asciinema play demo/<name>.cast`), not just a GIF.
20
+
21
+ ---
22
+
23
+ ## Try it (≈3 minutes)
24
+
25
+ Requires **Node ≥ 20** and **pnpm** (or npm).
26
+
27
+ ```bash
28
+ pnpm install # or: npm install
29
+ pnpm scan path/to/any/app/src # any folder of .tsx files
30
+ ```
31
+
32
+ That's the whole thing. It scans every `.tsx` under the folder and prints a coverage report + the findings. Run it on code you know — you'll be able to judge instantly whether each finding is real.
33
+
34
+ No clone handy? Point it at this repo's own test fixtures: `pnpm scan ./test/fixtures`.
35
+
36
+ > **No React source?** (A live site, an ASP.NET/Razor app, plain HTML.) The same checker can render a real page in a browser and audit the live DOM — `pnpm scan:url https://example.com`. See **[Auditing HTML & live pages (non-React)](#auditing-html--live-pages-non-react)** below.
37
+
38
+ > **Using your own design system?** (Almost everyone is.) A cold scan leaves most of your components in `declare` — *that's expected, not a failure.* To turn on its best trick (finding bugs *inside* your own components), it needs to know which of your components are buttons, inputs, etc. You don't write that by hand:
39
+ >
40
+ > ```bash
41
+ > pnpm a11y-checker init --suggest # scaffolds the config for you
42
+ > ```
43
+ >
44
+ > It guesses a host for each of your design-system primitives, flags the uncertain ones with `⚠`, and leaves composites alone — so adoption is a **~2-minute review**, not hand-written config. Full on-ramp: **[WALKTHROUGH.md](WALKTHROUGH.md)** — read it before judging a cold run.
45
+
46
+ ---
47
+
48
+ ## What you'll see
49
+
50
+ ```
51
+ a11y coverage:
52
+ checked 70 — elements we inspected (findings come from here)
53
+ trusted 60 — from a known-accessible design system — the library handles these
54
+ declare 702 — unrecognized; declare in binclusive.json to inspect them
55
+
56
+ components/.../AddIntegrationModal.tsx
57
+ AddIntegrationModal.tsx:276
58
+ rule: enforce/input-no-name [block] (call-site content check)
59
+ wcag: 1.3.1, 3.3.2
60
+ corpus: [VERY COMMON] SC 1.3.1 — 22/26 orgs
61
+ fix: Associate every form field with a <label> via id (not placeholder-only)...
62
+
63
+ 91 finding(s) VERY COMMON: 87 | COMMON: 4
64
+ enforcement: 91 blocking · 0 warning
65
+ ```
66
+
67
+ - **coverage is honest.** Most of a design-system app is *trusted* library components — nothing to flag there, and that's correct, not blindness. The number that matters is "did it find the real bugs," not "what % did it inspect."
68
+ - **each finding carries real-world weight** — its WCAG criterion, how widespread it is across our audits (`X/26 orgs`), and the representative fix.
69
+ - **`(call-site content check)`** marks findings that reach *trusted* components a normal linter skips. That's the recall win — "trusted" stops being false reassurance.
70
+
71
+ > **About the exit code:** `scan` exits non-zero when it finds *blocking* issues, so it can gate a CI build. If your run ends with `Command failed with exit code 1`, that's **not** an error — it means it found something. Read the report above it.
72
+
73
+ ---
74
+
75
+ ## What is this, in 30 seconds
76
+
77
+ Two passes + one corpus:
78
+
79
+ 1. the normal structural lint (`eslint-plugin-jsx-a11y`) over a resolved component map, **plus**
80
+ 2. a **content check at the call site** that catches bugs hiding inside "trusted" library components (an icon-only button with no name, an input with no label), **plus**
81
+ 3. every finding **matched to a corpus** of real Binclusive audit failures — so it says not just *that* it's wrong, but *how common* it is in the wild and the fix that worked.
82
+
83
+ A generic linter can't do 2 or 3. The deeper story (and why the corpus is a moat) is in `docs/`.
84
+
85
+ There's also a **second producer**: a rendered-DOM collector that drives a real browser to a URL and runs axe-core against the live page — same corpus, same WCAG, same enforcement gate, no source required. That's the next section.
86
+
87
+ ---
88
+
89
+ ## Auditing HTML & live pages (non-React)
90
+
91
+ The scan above works on `.tsx` source. But not every page *has* React source on disk — a deployed customer site, an ASP.NET/Razor app, a plain HTML/Bootstrap/jQuery page. For those, point the checker at the **rendered page** instead of the source:
92
+
93
+ ```bash
94
+ pnpm exec playwright install chromium # one-time: the browser the render path drives
95
+ ```
96
+
97
+ ```bash
98
+ pnpm scan:url https://example.com # any deployed URL
99
+ pnpm scan:url http://localhost:5000 # your local dev server
100
+ pnpm scan:url ./wwwroot/index.html # a local static .html file (bare path works)
101
+ ```
102
+
103
+ `<target>` takes an `http(s)://` URL, a `file://` URL, or a **bare local path** (auto-converted to `file://`). Under the hood it renders the page in real Chromium (via Playwright), runs **axe-core** against the live DOM, then flows every finding through the *same* corpus / WCAG / enforcement machinery as the source scan — so a contrast bug on example.com comes back tiered and gated exactly like a missing label in your `.tsx`.
104
+
105
+ This is the source-less path — one command audits any live site, React or not.
106
+
107
+ - **Templates need a running server.** A server-side template (`.cshtml` Razor, `.erb`, etc.) is **not** valid standalone HTML — it's `@`-directives, loops, interpolation — so `file://` can't render it. Point `check-url` at the **running app** (`localhost`) for templates. Only plain `.html` files render directly via `file://`.
108
+ - **It catches what static analysis can't.** A real browser render surfaces categories the `.tsx` scan and even headless DOMs (jsdom) are blind to — notably **color-contrast (WCAG 1.4.3)**, computed ARIA roles, and layout-dependent rules.
109
+ - **Honest edge:** the seed corpus snapshot currently covers ~10 success criteria and does **not** yet include some SCs this path surfaces (e.g. 1.4.3 contrast, 1.4.1, 2.4.4). Those findings still appear — the render catches them regardless — but they roll up as tier `UNKNOWN` (no corpus fix text) until the corpus is extended.
110
+
111
+ The full walkthrough — install once, read the output, the `(rendered-DOM / axe)` provenance tag — is in **`docs/AUDIT-URL.md`**.
112
+
113
+ ---
114
+
115
+ ## Use it in CI (GitHub Action)
116
+
117
+ Drop the Action into a pull-request workflow. On every PR it scans the changed
118
+ `.tsx` files, posts inline review comments, and writes a SARIF file. Feed that
119
+ file to GitHub's own `upload-sarif` step and the findings also render as
120
+ **native code-scanning annotations** on the PR diff — the reference UX, à la
121
+ CodeQL. The scan is **advisory by default: it exits 0** and never blocks a merge
122
+ — unless you [opt into a blocking check](#optional--opt-into-a-blocking-check-default-off).
123
+
124
+ ```yaml
125
+ name: a11y
126
+ on: pull_request
127
+
128
+ permissions:
129
+ contents: read
130
+ pull-requests: write # inline review comments
131
+ security-events: write # upload SARIF as code-scanning annotations
132
+
133
+ jobs:
134
+ a11y:
135
+ runs-on: ubuntu-latest
136
+ steps:
137
+ - uses: actions/checkout@v4
138
+ - id: a11y
139
+ uses: Binclusive/a11y-checker-plugin@main
140
+ - if: always() # advisory gate exits 0; upload regardless of findings
141
+ uses: github/codeql-action/upload-sarif@v3
142
+ with:
143
+ sarif_file: ${{ steps.a11y.outputs.sarif-file }}
144
+ ```
145
+
146
+ Annotations land on the exact changed file + line, and each carries its
147
+ provenance (`deterministic` vs `agent`) in the SARIF property bag. The SARIF
148
+ file exists only to render on **your** GitHub — it carries file/line for local
149
+ annotation and is never sent to the Binclusive dashboard.
150
+
151
+ ### Optional — opt into a blocking check (default off)
152
+
153
+ The check is **non-blocking by default**: it exits 0 on any severity or volume of
154
+ findings, so it never breaks your CI. Blocking is **strictly opt-in**. Set either
155
+ input below and the check **fails** (non-zero, surfaced as an Action failure) when
156
+ the threshold is met — the inline comments, PR summary, and SARIF still post either
157
+ way.
158
+
159
+ | Input | Set it to | Effect | Absent (default) |
160
+ |---|---|---|---|
161
+ | `fail-on` | `critical` \| `major` \| `minor` | Fail when any finding is **at or above** that severity (the engine's `critical < major < minor` ordering) | Non-blocking — findings never fail the check on severity |
162
+ | `max-violations` | an integer `N` | Fail when the total finding count **exceeds** `N` | No volume gate |
163
+
164
+ ```yaml
165
+ - id: a11y
166
+ uses: Binclusive/a11y-checker-plugin@main
167
+ with:
168
+ fail-on: critical # optional — block only on critical findings
169
+ # max-violations: 0 # optional — block on any finding at all
170
+ ```
171
+
172
+ Both use the **same** severity vocabulary as the engine's `check` command — there
173
+ is no separate CI severity map. Leaving both unset keeps the reference,
174
+ always-green advisory behavior.
175
+
176
+ ### Optional secrets — AI lane & dashboard
177
+
178
+ The deterministic floor above needs **no account and no secret**. Two optional,
179
+ independent inputs unlock the extra lanes — supply neither, one, or both. Absent
180
+ means "lane off", never an error; the scan still exits 0.
181
+
182
+ | Input | Secret it carries | Absent → | Notes |
183
+ |---|---|---|---|
184
+ | `llm-api-key` | Your **own** LLM provider key (BYOK) | AI enrichment lane skipped; deterministic floor still runs | Provider-agnostic — no provider is baked into the Action or image. Your credential; it never leaves the runner for the dashboard. |
185
+ | `llm-model` | — (a model id, not a secret) | Engine default — Anthropic `claude-haiku-4-5-20251001` | Overrides the model the AI lane uses. Only meaningful with `llm-api-key`. |
186
+ | `llm-provider` | — (a provider id, not a secret) | Engine default — `anthropic` | Selects the AI-lane provider. Only meaningful with `llm-api-key`; an unrecognized value degrades to the deterministic floor. |
187
+ | `b8e-token` | A Binclusive `b8e_` apiKey | No phone-home; scan stays fully local | Mint it in the Binclusive dashboard. Metadata-only ingestion bearer. Unrelated to the LLM key. |
188
+
189
+ ```yaml
190
+ - id: a11y
191
+ uses: Binclusive/a11y-checker-plugin@main
192
+ with:
193
+ llm-api-key: ${{ secrets.LLM_API_KEY }} # optional — your BYOK model key
194
+ llm-model: "" # optional — override the model
195
+ llm-provider: "" # optional — override the provider
196
+ b8e-token: ${{ secrets.B8E_TOKEN }} # optional — dashboard ingestion
197
+ ```
198
+
199
+ Store both as encrypted repo (or org) secrets. The `b8e-token` authenticates the
200
+ phone-home to Binclusive; the `llm-api-key` never touches Binclusive auth — the
201
+ two are orthogonal. No Binclusive LLM credential ships in the image or the
202
+ Action defaults.
203
+
204
+ ### Optional — post comments as the branded Binclusive bot
205
+
206
+ By default the PR comments (both the inline per-finding comments and the single
207
+ rollup comment) post under the workflow's `github-token`, which GitHub attributes
208
+ to **`github-actions[bot]`**. Install the **Binclusive GitHub App** and supply its
209
+ id + private key to have the comments post under the branded App identity — name +
210
+ avatar — instead. This is a pure identity swap: *who* posts changes, *what* posts
211
+ does not, and nothing new crosses the wire.
212
+
213
+ | Input | Carries | Absent → |
214
+ |---|---|---|
215
+ | `binclusive-app-id` | The Binclusive GitHub App id | Comments post under `github-token` as `github-actions[bot]` (no change) |
216
+ | `binclusive-app-private-key` | The App's PEM private key (a secret) | Same fallback |
217
+ | `binclusive-app-installation-id` | The App's installation id (optional) | Discovered automatically from the repo |
218
+
219
+ ```yaml
220
+ - id: a11y
221
+ uses: Binclusive/a11y-checker-plugin@main
222
+ with:
223
+ binclusive-app-id: ${{ vars.BINCLUSIVE_APP_ID }}
224
+ binclusive-app-private-key: ${{ secrets.BINCLUSIVE_APP_PRIVATE_KEY }}
225
+ ```
226
+
227
+ **Least privilege:** the App needs only **`Pull requests: write`** — enough to
228
+ post inline review comments and the rollup comment, nothing more. If the App is
229
+ unconfigured, the id/key is wrong, or the token mint fails for any reason, the
230
+ Action **falls back to `github-token` and still exits 0** — a failed brand is
231
+ never a failed check.
232
+
233
+ ## Use it on any other CI/CD (generic `--ci` mode)
234
+
235
+ Not on GitHub? The engine runs the same scan on **CircleCI, Jenkins, Drone, or a
236
+ bare `docker run`** with no native adapter — just run the image and emit a standard
237
+ artifact:
238
+
239
+ ```sh
240
+ docker run --rm -v "$PWD:/workspace" -w /workspace -e A11Y_PLATFORM=null \
241
+ ghcr.io/binclusive/a11y-checker:latest \
242
+ check /workspace/src --ci --format sarif > a11y.sarif
243
+ ```
244
+
245
+ `--format sarif` emits a valid **SARIF 2.1.0** log (or `--format json` for the raw
246
+ report); `--ci` makes the **non-blocking exit-0 a first-class engine mode** — the
247
+ run always exits 0 even with blocking findings, so any platform can consume the
248
+ artifact without failing the build. With no PR/MR context nothing is posted and the
249
+ artifacts still emit. Opt into a failing build with `--fail-on` / `--max-violations`.
250
+
251
+ Copy-paste CircleCI / Jenkins / Drone snippets, and the config-scaffold pattern that
252
+ native platform adapters build on, are in **[`docs/CI.md`](docs/CI.md)**.
253
+
254
+ ---
255
+
256
+ ## Dig deeper
257
+
258
+ | If you want… | Open / read |
259
+ |---|---|
260
+ | **Adopt it with your own design system** | **`WALKTHROUGH.md`** |
261
+ | **Run it on any CI/CD (CircleCI / Jenkins / Drone / generic)** | **`docs/CI.md`** |
262
+ | **Audit a live URL or HTML page (non-React)** | **`docs/AUDIT-URL.md`** |
263
+ | The pitch + the moat, with numbers | `docs/decks/numbers.html` |
264
+ | Real findings on real OSS projects | `docs/decks/showcase.html` |
265
+ | How the machine works, conceptually | `docs/decks/engineering.html` |
266
+ | How it's built — the craft | `docs/decks/engineering-deep.html` |
267
+ | **The code map — which file does what** | `docs/ARCHITECTURE.md` |
268
+ | **The questions you're about to ask** | `FAQ.md` |
269
+
270
+ The decks are self-contained HTML — open in a browser, arrow keys to navigate, `O` for contents.
271
+
272
+ ---
273
+
274
+ ## Kick the tires
275
+
276
+ ```bash
277
+ pnpm test # all green
278
+ pnpm typecheck # clean
279
+ ```
280
+
281
+ Editor surfaces (an MCP server + a Claude Code auto-whisper hook that fixes a11y as the AI writes) are in `plugin/` — `pnpm mcp` starts the local MCP server. The CLI above is the fastest way to feel what it does.
282
+
283
+ ---
284
+
285
+ *Structure note: this is the `a11y-checker` package extracted to run standalone. Where `docs/ARCHITECTURE.md` says `packages/a11y-checker/src/…`, in this repo it's just `src/…`.*
package/bin/a11y.mjs ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runnable entry point for the published package.
4
+ *
5
+ * The package ships TypeScript source (no build step) and is launched as
6
+ * `npx @binclusive/a11y <command>` by the plugin's `.mcp.json` / `hooks.json`.
7
+ * Node can't execute `.ts` directly, so this thin JS wrapper registers the
8
+ * `tsx` loader (resolved from THIS package's own node_modules, so it works no
9
+ * matter what the caller's cwd is) and hands argv straight to `src/cli.ts`.
10
+ *
11
+ * eslint and its plugins stay ordinary dependencies — npm installs them — so
12
+ * there is nothing to bundle and nothing fragile to keep in sync.
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ import { createRequire } from "node:module";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
+
18
+ const require = createRequire(import.meta.url);
19
+ // Absolute path to tsx's self-registering entry inside this package's deps.
20
+ const tsxEntry = pathToFileURL(require.resolve("tsx")).href;
21
+ const cli = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
22
+
23
+ const child = spawn(
24
+ process.execPath,
25
+ ["--import", tsxEntry, cli, ...process.argv.slice(2)],
26
+ { stdio: "inherit" },
27
+ );
28
+
29
+ child.on("exit", (code, signal) => {
30
+ if (signal) process.kill(process.pid, signal);
31
+ else process.exit(code ?? 0);
32
+ });
33
+ child.on("error", (err) => {
34
+ console.error(err instanceof Error ? err.stack : String(err));
35
+ process.exit(1);
36
+ });
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runnable entry for the changed-file diff-scoper (`src/diff-scope-cli.ts`),
4
+ * called by `entrypoint.sh` so the CI Action shares the engine's ONE scoping
5
+ * module instead of an inline `git diff | grep`. Mirrors `bin/a11y.mjs`: the
6
+ * package ships TypeScript source (no build step), so this thin JS wrapper
7
+ * registers the `tsx` loader (resolved from THIS package's own deps, cwd-proof)
8
+ * and hands off to the CLI. Prints changed `.tsx` paths, one per line.
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ import { createRequire } from "node:module";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const tsxEntry = pathToFileURL(require.resolve("tsx")).href;
16
+ const cli = fileURLToPath(new URL("../src/diff-scope-cli.ts", import.meta.url));
17
+
18
+ const child = spawn(process.execPath, ["--import", tsxEntry, cli, ...process.argv.slice(2)], {
19
+ stdio: "inherit",
20
+ });
21
+
22
+ child.on("exit", (code, signal) => {
23
+ if (signal) process.kill(process.pid, signal);
24
+ else process.exit(code ?? 0);
25
+ });
26
+ child.on("error", (err) => {
27
+ console.error(err instanceof Error ? err.stack : String(err));
28
+ process.exit(1);
29
+ });