From fa1bda524d23b83e3b309a02ee1d0fd33fea0394 Mon Sep 17 00:00:00 2001 From: Chaim Marcus Date: Wed, 2 Sep 2026 04:07:19 +0300 Subject: [PATCH] =?UTF-8?q?chore(ci):=20=D7=A9=D7=A2=D7=A8-CI=20=D7=9C-INV?= =?UTF-8?q?-INT9=20=E2=80=94=20issues.update({status})=20=D7=A8=D7=A7=20?= =?UTF-8?q?=D7=91=D7=9E=D7=A1=D7=9C=D7=95=D7=9C=20sync-case-status=20(lega?= =?UTF-8?q?l-ai=20#618)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ל-`issue.status` כותב לגיטימי אחד בפלאגין: הג'וב `sync-case-status` (`ctx.jobs.register("sync-case-status", …)` ב-`src/worker.ts`). עד היום האכיפה הייתה code-review בלבד — וזה בדיוק מה שאפשר את הבאג של legal-ai ‏#446 (flip-flop `done → in_progress`), כותב שני שנכנס בלי שאיש שם לב. `scripts/int9-guard.mjs` — Node ESM חסר-תלויות. טוקנייזר תו-תו (מחרוזות/תבניות עם `${}` מקונן/הערות/רגקס-ליטרלים) בונה מסכת "קוד רגיל", ומעליה: כל `jobs.register("sync-case-status", …)` מגדיר **טווח-תווים מותר** (איזון-סוגריים), וכל `issues.update(…)` שרשימת-הארגומנטים שלו מכילה את המזהה `status` חייב לשבת בתוכו. הגרעיניות היא טווח-קוד ולא רשימת-קבצים (הדפוס של `leak_guard.py` ב-legal-ai) במכוון: הקריאה המותרת היחידה וכל קריאה עתידית אסורה יושבות באותו `worker.ts`. הטווח נגזר מהמבנה האמיתי, ולכן שורד מעבר של הג'וב לקובץ אחר — ושינוי שם-הג'וב מפיל את השער בקול במקום לפתוח חור שקט. חריגה מכוונת: `// noqa: INT9 — <נימוק>`. `noqa: INT9` בלי נימוק הוא הפרה בעצמו — אין השתקה שקטה. כשל-פענוח (סוגר שלא נסגר) מפיל את השער ולא נבלע. `--self-test` מריץ 5 פיקסצ'רים (`scripts/fixtures/int9/`, מחוץ ל-`src/` בכוונה) ומאמת ספירת-הפרות מדויקת — הוכחה בכל ריצת-CI שהשער נושך. מגבלה מוצהרת: תיל-מעידה סטטי, לא הוכחה — `issues.update(id, patch)` שבו אובייקט-העדכון נבנה במקום אחר אינו נתפס. Refs ezer-mishpati/legal-ai#618 Co-Authored-By: Claude Opus 5 (1M context) --- .gitea/workflows/int9-guard.yaml | 33 ++ scripts/fixtures/int9/allowed-inside-job.ts | 16 + scripts/fixtures/int9/non-status-update.ts | 15 + scripts/fixtures/int9/noqa-bare.ts | 9 + scripts/fixtures/int9/noqa-justified.ts | 9 + scripts/fixtures/int9/violating.ts | 16 + scripts/int9-guard.mjs | 534 ++++++++++++++++++++ 7 files changed, 632 insertions(+) create mode 100644 .gitea/workflows/int9-guard.yaml create mode 100644 scripts/fixtures/int9/allowed-inside-job.ts create mode 100644 scripts/fixtures/int9/non-status-update.ts create mode 100644 scripts/fixtures/int9/noqa-bare.ts create mode 100644 scripts/fixtures/int9/noqa-justified.ts create mode 100644 scripts/fixtures/int9/violating.ts create mode 100644 scripts/int9-guard.mjs diff --git a/.gitea/workflows/int9-guard.yaml b/.gitea/workflows/int9-guard.yaml new file mode 100644 index 0000000..b6e63c5 --- /dev/null +++ b/.gitea/workflows/int9-guard.yaml @@ -0,0 +1,33 @@ +name: INV-INT9 issue.status write-ownership + +# Hard gate for INV-INT9 (legal-ai docs/spec/X7-paperclip-client-params.md +# §4): `issue.status` has exactly one legitimate writer — the +# `sync-case-status` scheduled job (`ctx.jobs.register("sync-case-status", …)` +# in src/worker.ts), which mirrors legal-ai's case status onto the linked +# Paperclip issue. Built after legal-ai issue #446, where a second, unnoticed +# writer flip-flopped an issue `done → in_progress` — nobody caught it because +# nothing enforced write-ownership. This gate makes sure a third writer never +# lands the same way again: any `ctx.issues.update(...)` call that touches +# `status` outside the `sync-case-status` job fails the build, unless it +# carries an explicit, justified `// noqa: INT9 — ` (a bare noqa with +# no reason is itself a violation — exceptions must be argued in writing). +# +# Pure Node, zero dependencies (scripts/int9-guard.mjs) — no `npm ci` needed. + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + int9-guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: INV-INT9 — issue.status write-ownership guard + run: node scripts/int9-guard.mjs + + - name: Self-test — הוכחה שהשער נושך (AC5) + run: node scripts/int9-guard.mjs --self-test diff --git a/scripts/fixtures/int9/allowed-inside-job.ts b/scripts/fixtures/int9/allowed-inside-job.ts new file mode 100644 index 0000000..7f732c8 --- /dev/null +++ b/scripts/fixtures/int9/allowed-inside-job.ts @@ -0,0 +1,16 @@ +// פיקסצ'ר ל-scripts/int9-guard.mjs --self-test. לא קוד-ריצה. +// +// קריאה ל-issues.update({status}) בתוך jobs.register("sync-case-status", …) +// — המסלול המותר היחיד. כולל תבנית עם סוגריים לא-מאוזנים בתוך תבנית-מחרוזת +// והערה, כדי לבחון בפועל את הטוקנייזר (מחרוזת/הערה לא נספרות ב-matchParen). + +export function register(ctx: any, api: any) { + ctx.jobs.register("sync-case-status", async (job: unknown) => { + // סוגר בודד בהערה — לא אמור לבלבל את חישוב-הטווח: ) + const label = `📋 (${api.name}`; + + const target = await api.pickTarget(); + + await ctx.issues.update(target.id, { status: label }, target.companyId); + }); +} diff --git a/scripts/fixtures/int9/non-status-update.ts b/scripts/fixtures/int9/non-status-update.ts new file mode 100644 index 0000000..6531f31 --- /dev/null +++ b/scripts/fixtures/int9/non-status-update.ts @@ -0,0 +1,15 @@ +// פיקסצ'ר ל-scripts/int9-guard.mjs --self-test. לא קוד-ריצה. +// +// שתי קריאות ל-issues.update שאינן נוגעות ב-status כלל — לא הפרה. +// השנייה בודקת שגבול-מילה אמיתי: "statusLabel" אינו תואם ל-/\bstatus\b/ +// (אין גבול-מילה בין ה-s של status ל-L של Label). + +export async function renameOnly(ctx: any, target: any, statusLabel: string) { + await ctx.issues.update(target.id, { title: "x" }, target.companyId); + + await ctx.issues.update( + target.id, + { description: statusLabel }, + target.companyId, + ); +} diff --git a/scripts/fixtures/int9/noqa-bare.ts b/scripts/fixtures/int9/noqa-bare.ts new file mode 100644 index 0000000..4ea2bef --- /dev/null +++ b/scripts/fixtures/int9/noqa-bare.ts @@ -0,0 +1,9 @@ +// פיקסצ'ר ל-scripts/int9-guard.mjs --self-test. לא קוד-ריצה. +// +// קריאה ל-issues.update({status}) מחוץ ל-sync-case-status, עם noqa בלי נימוק — +// עדיין הפרה (AC3: חריגה חייבת להיות מנומקת בכתב). + +export async function sloppyBackfill(ctx: any, target: any) { + // noqa: INT9 + await ctx.issues.update(target.id, { status: "done" }, target.companyId); +} diff --git a/scripts/fixtures/int9/noqa-justified.ts b/scripts/fixtures/int9/noqa-justified.ts new file mode 100644 index 0000000..b135fb2 --- /dev/null +++ b/scripts/fixtures/int9/noqa-justified.ts @@ -0,0 +1,9 @@ +// פיקסצ'ר ל-scripts/int9-guard.mjs --self-test. לא קוד-ריצה. +// +// קריאה ל-issues.update({status}) מחוץ ל-sync-case-status, עם חריגה מנומקת — +// מותר. + +export async function migrationBackfill(ctx: any, target: any) { + // noqa: INT9 — מיגרציה חד-פעמית לתיקון סטטוסים תקועים מלפני #618, מאושרת ע"י דפנה + await ctx.issues.update(target.id, { status: "done" }, target.companyId); +} diff --git a/scripts/fixtures/int9/violating.ts b/scripts/fixtures/int9/violating.ts new file mode 100644 index 0000000..b339467 --- /dev/null +++ b/scripts/fixtures/int9/violating.ts @@ -0,0 +1,16 @@ +// פיקסצ'ר ל-scripts/int9-guard.mjs --self-test. לא קוד-ריצה. +// +// קריאה ל-issues.update({status}) מחוץ לכל jobs.register("sync-case-status", …) +// — זו בדיוק ההפרה שהשער אמור לתפוס. + +export async function badHandler(ctx: any, target: any) { + await ctx.jobs.register("stale-case-reminder", async (_job: unknown) => { + ctx.logger.info("stale-case-reminder: starting"); + }); + + await ctx.issues.update( + target.id, + { status: "in_progress" }, + target.companyId, + ); +} diff --git a/scripts/int9-guard.mjs b/scripts/int9-guard.mjs new file mode 100644 index 0000000..7aab50d --- /dev/null +++ b/scripts/int9-guard.mjs @@ -0,0 +1,534 @@ +#!/usr/bin/env node +/** + * INV-INT9 guard — enforce single write-ownership of `issue.status` + * (legal-ai docs/spec/X7-paperclip-client-params.md §4 INV-INT9). + * + * `issues.status` has exactly one legitimate writer in this plugin: the + * `sync-case-status` scheduled job (`ctx.jobs.register("sync-case-status", …)` + * in `src/worker.ts`), which mirrors legal-ai's case status onto the linked + * Paperclip issue. Any OTHER `ctx.issues.update(...)` call that touches + * `status` is a second writer racing the sync job — exactly the flip-flop + * (`done` → `in_progress`) bug class legal-ai issue #618 exists to close off + * for good, before a second call site is ever added. + * + * IN SCOPE: every `.ts`/`.tsx` file under `src/` (including `*.test.ts` — + * there is no false positive there today, and the noqa escape hatch below + * covers the day there is one). + * + * OUT OF SCOPE: this is NOT a general call-graph analysis. It is a + * structural, single-pass lexer over the source text — no type information, + * no cross-file resolution. It answers exactly one question per call site: + * "does the character range of this `ctx.issues.update(...)` call sit + * inside the character range of the one whitelisted + * `ctx.jobs.register("sync-case-status", …)` call?". Deliberately NOT a + * per-file allowlist (the pattern `scripts/leak_guard.py` in legal-ai uses) + * — that shape does not fit here, because the one legitimate call site and + * every future illegitimate one live in the very same file (`worker.ts`). + * The granularity has to be a code RANGE derived from the real structure, + * not a file name. + * + * Escape hatch (G1 — fix at the source, don't paper over a false positive): + * a call can be justified in place with + * // noqa: INT9 — + * on the line immediately above the call, or on any line spanned by its + * argument list. A bare `// noqa: INT9` with no reason is itself a + * violation (AC3 — exceptions must be justified in writing, not silent). + * + * Usage: + * node scripts/int9-guard.mjs # scan src/**\/*.{ts,tsx}; exit 1 on any violation + * node scripts/int9-guard.mjs ... # scan only the given files/directories + * node scripts/int9-guard.mjs --self-test # run the fixture suite (scripts/fixtures/int9/), prove the gate bites + * + * Zero dependencies (node:fs / node:path / node:process only) — no `npm ci` + * needed in CI; the runner image ships node 24. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import process from "node:process"; + +const REPO_ROOT = resolve(import.meta.dirname, ".."); +const SKIP_DIRS = new Set(["node_modules", "dist", ".git"]); +const SOURCE_EXT = new Set([".ts", ".tsx"]); + +const SPEC_REF = "docs/spec/X7-paperclip-client-params.md §4 INV-INT9"; + +const NOQA_RE = /\/\/\s*noqa\s*:\s*INT9\b(?:\s*[-—]\s*(.*))?/i; + +const REGEX_PREV_CHARS = new Set("(,=:[!&|?{};+-*%~^<>".split("")); +const REGEX_PREV_WORDS = new Set([ + "return", + "typeof", + "case", + "in", + "of", + "new", + "delete", + "void", + "instanceof", + "do", + "else", + "yield", + "await", +]); + +// --------------------------------------------------------------------- +// Tokenizer — classify every character as "plain code" or not, so that +// structural regexes and paren-matching never see string/template +// contents, comments, or regex-literal bodies. +// --------------------------------------------------------------------- + +/** @returns {Uint8Array} mask[i] === 1 iff text[i] is plain code. */ +function classify(text) { + const n = text.length; + const mask = new Uint8Array(n); + /** @type {Array<{kind: "template"} | {kind: "exprCode", depth: number}>} */ + const stack = []; + let state = "code"; + let lastSignificant = ""; + let lastWord = ""; + let i = 0; + + function noteCodeChar(ch) { + if (/\s/.test(ch)) return; + lastSignificant = ch; + if (/[A-Za-z0-9_$]/.test(ch)) { + lastWord += ch; + } else { + lastWord = ""; + } + } + + while (i < n) { + const ch = text[i]; + + if (state === "code") { + const next = text[i + 1]; + if (ch === "/" && next === "/") { + state = "lcomment"; + i += 2; + continue; + } + if (ch === "/" && next === "*") { + state = "bcomment"; + i += 2; + continue; + } + if (ch === "'") { + state = "squote"; + i += 1; + continue; + } + if (ch === '"') { + state = "dquote"; + i += 1; + continue; + } + if (ch === "`") { + stack.push({ kind: "template" }); + state = "template"; + i += 1; + continue; + } + if (ch === "/") { + const isRegex = + lastSignificant === "" || + REGEX_PREV_CHARS.has(lastSignificant) || + REGEX_PREV_WORDS.has(lastWord); + if (isRegex) { + state = "regex"; + i += 1; + continue; + } + // else: division — falls through to plain-code handling below. + } + + mask[i] = 1; + const top = stack[stack.length - 1]; + if (ch === "{" && top && top.kind === "exprCode") { + top.depth += 1; + } else if (ch === "}" && top && top.kind === "exprCode") { + top.depth -= 1; + if (top.depth === 0) { + stack.pop(); + state = "template"; + } + } + noteCodeChar(ch); + i += 1; + continue; + } + + if (state === "squote" || state === "dquote") { + const quote = state === "squote" ? "'" : '"'; + if (ch === "\\") { + i += 2; + continue; + } + if (ch === quote) { + state = "code"; + noteCodeChar(quote); + i += 1; + continue; + } + if (ch === "\n") { + // Unterminated string — bail back to code rather than eat the file. + state = "code"; + i += 1; + continue; + } + i += 1; + continue; + } + + if (state === "template") { + if (ch === "\\") { + i += 2; + continue; + } + if (ch === "`") { + stack.pop(); + state = "code"; + i += 1; + continue; + } + if (ch === "$" && text[i + 1] === "{") { + stack.push({ kind: "exprCode", depth: 1 }); + state = "code"; + i += 2; + continue; + } + i += 1; + continue; + } + + if (state === "lcomment") { + if (ch === "\n") { + state = "code"; + i += 1; + continue; + } + i += 1; + continue; + } + + if (state === "bcomment") { + if (ch === "*" && text[i + 1] === "/") { + state = "code"; + i += 2; + continue; + } + i += 1; + continue; + } + + if (state === "regex") { + if (ch === "\\") { + i += 2; + continue; + } + if (ch === "[") { + state = "regexclass"; + i += 1; + continue; + } + if (ch === "/") { + i += 1; + while (i < n && /[a-z]/i.test(text[i])) i += 1; + state = "code"; + continue; + } + if (ch === "\n") { + // Unterminated regex — bail back to code rather than eat the file. + state = "code"; + i += 1; + continue; + } + i += 1; + continue; + } + + if (state === "regexclass") { + if (ch === "\\") { + i += 2; + continue; + } + if (ch === "]") { + state = "regex"; + i += 1; + continue; + } + i += 1; + continue; + } + + // Unreachable, but never silently drop a character. + i += 1; + } + + return mask; +} + +/** Yield regex matches whose full span lies entirely in plain-code region. */ +function* matchAllCode(text, mask, pattern) { + for (const m of text.matchAll(pattern)) { + const start = m.index; + const end = start + m[0].length; + let allCode = true; + for (let i = start; i < end; i++) { + if (!mask[i]) { + allCode = false; + break; + } + } + if (allCode) yield m; + } +} + +/** Index of the `)` matching the `(` at `openIndex`, counting only plain-code parens. */ +function matchParen(text, mask, openIndex, filePath) { + let depth = 0; + for (let i = openIndex; i < text.length; i++) { + if (!mask[i]) continue; + if (text[i] === "(") depth += 1; + else if (text[i] === ")") { + depth -= 1; + if (depth === 0) return i; + } + } + throw new Error(`${filePath}: פענוח נכשל — סוגר לא נסגר`); +} + +function buildLineStarts(text) { + const starts = [0]; + for (let i = 0; i < text.length; i++) { + if (text[i] === "\n") starts.push(i + 1); + } + return starts; +} + +function lineOfOffset(lineStarts, idx) { + let lo = 0; + let hi = lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (lineStarts[mid] <= idx) lo = mid; + else hi = mid - 1; + } + return lo; // 0-based line number +} + +const STATUS_WORD_RE = /\bstatus\b/; +const JOBS_REGISTER_RE = /jobs\.register\s*\(/g; +const ISSUES_UPDATE_RE = /issues\.update\s*\(/g; +const SYNC_CASE_STATUS_LITERAL_RE = /^\s*["']sync-case-status["']/; + +/** + * Scan one file's source text and return a list of violation objects + * ({ file, line, message }). Throws if a `(` is never closed (see + * matchParen) — the caller must NOT treat that as "no violations". + */ +function scanFile(filePath, text) { + const mask = classify(text); + const lines = text.split("\n"); + const lineStarts = buildLineStarts(text); + const rel = filePath; + + const allowedRanges = []; + for (const m of matchAllCode(text, mask, JOBS_REGISTER_RE)) { + const openIdx = m.index + m[0].length - 1; + const after = text.slice(openIdx + 1, openIdx + 60); + if (SYNC_CASE_STATUS_LITERAL_RE.test(after)) { + const closeIdx = matchParen(text, mask, openIdx, rel); + allowedRanges.push([openIdx, closeIdx]); + } + } + + const violations = []; + for (const m of matchAllCode(text, mask, ISSUES_UPDATE_RE)) { + const openIdx = m.index + m[0].length - 1; + const closeIdx = matchParen(text, mask, openIdx, rel); + const content = text.slice(openIdx + 1, closeIdx); + if (!STATUS_WORD_RE.test(content)) continue; // not a status-write call + + const inAllowedRange = allowedRanges.some( + ([s, e]) => openIdx >= s && openIdx <= e, + ); + if (inAllowedRange) continue; + + const callStartLine = lineOfOffset(lineStarts, m.index); + const callEndLine = lineOfOffset(lineStarts, closeIdx); + + let noqaMatch; + for (let ln = callStartLine - 1; ln <= callEndLine && !noqaMatch; ln += 1) { + if (ln < 0 || ln >= lines.length) continue; + const m2 = NOQA_RE.exec(lines[ln]); + if (m2) noqaMatch = m2; + } + + const reportLine = callStartLine + 1; // 1-based for humans + if (noqaMatch) { + const reason = (noqaMatch[1] ?? "").trim(); + if (reason.length >= 3) continue; // justified exception — allowed + violations.push({ + file: rel, + line: reportLine, + message: "noqa: INT9 ללא נימוק — חריגה חייבת להיות מנומקת בכתב (AC3)", + }); + continue; + } + + violations.push({ + file: rel, + line: reportLine, + message: + `issues.update({status}) מחוץ למסלול sync-case-status (INV-INT9). ` + + `ראה ${SPEC_REF}. חריגה מוצדקת → // noqa: INT9 — <נימוק>.`, + }); + } + + return violations; +} + +// --------------------------------------------------------------------- +// File collection +// --------------------------------------------------------------------- + +function walk(dir, out) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (SKIP_DIRS.has(entry.name)) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, out); + } else if (SOURCE_EXT.has(entry.name.slice(entry.name.lastIndexOf(".")))) { + out.push(full); + } + } + return out; +} + +function collectDefault() { + const srcDir = join(REPO_ROOT, "src"); + return walk(srcDir, []); +} + +function collectFromArgs(args) { + const out = []; + for (const a of args) { + const p = resolve(a); + const st = statSync(p); + if (st.isDirectory()) { + walk(p, out); + } else { + out.push(p); + } + } + return out; +} + +function runGate(files) { + const violations = []; + for (const abs of files) { + const rel = relative(REPO_ROOT, abs); + const text = readFileSync(abs, "utf-8"); + violations.push(...scanFile(rel, text)); + } + return violations; +} + +function reportGate(violations, scannedCount) { + if (violations.length > 0) { + process.stderr.write( + `✗ INV-INT9 gate — issue.status write-ownership violated ` + + `(${violations.length} finding(s)):\n\n`, + ); + for (const v of violations) { + process.stderr.write(` • ${v.file}:${v.line}: ${v.message}\n`); + } + process.stderr.write(`\nSee ${SPEC_REF}.\n`); + return 1; + } + process.stdout.write( + `✓ INV-INT9 gate: מסלול-כתיבה יחיד ל-issue.status ` + + `(${scannedCount} קבצים נסרקו).\n`, + ); + return 0; +} + +// --------------------------------------------------------------------- +// Self-test +// --------------------------------------------------------------------- + +const SELF_TEST_EXPECTED = { + "violating.ts": 1, + "allowed-inside-job.ts": 0, + "noqa-justified.ts": 0, + "noqa-bare.ts": 1, + "non-status-update.ts": 0, +}; + +function runSelfTest() { + const fixturesDir = join(REPO_ROOT, "scripts", "fixtures", "int9"); + let pass = 0; + let total = 0; + const failures = []; + + for (const [name, expected] of Object.entries(SELF_TEST_EXPECTED)) { + total += 1; + const abs = join(fixturesDir, name); + let violations; + try { + const text = readFileSync(abs, "utf-8"); + violations = scanFile(name, text); + } catch (err) { + failures.push( + `${name}: ציפינו ${expected} הפרות, אבל הסריקה זרקה שגיאה: ${String(err)}`, + ); + continue; + } + if (violations.length === expected) { + pass += 1; + } else { + failures.push( + `${name}: ציפינו ${expected} הפרות, התקבלו ${violations.length}` + + (violations.length + ? `:\n${violations.map((v) => ` - ${v.file}:${v.line}: ${v.message}`).join("\n")}` + : ""), + ); + } + } + + if (failures.length > 0) { + process.stderr.write("✗ INV-INT9 self-test נכשל:\n\n"); + for (const f of failures) process.stderr.write(` • ${f}\n`); + return 1; + } + process.stdout.write( + `✓ INV-INT9 self-test: ${pass}/${total} פיקסצ'רים תואמים לציפייה.\n`, + ); + return 0; +} + +// --------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------- + +function main(argv) { + if (argv.includes("--self-test")) { + return runSelfTest(); + } + const pathArgs = argv.filter((a) => !a.startsWith("--")); + const files = + pathArgs.length > 0 ? collectFromArgs(pathArgs) : collectDefault(); + let violations; + try { + violations = runGate(files); + } catch (err) { + process.stderr.write(`✗ INV-INT9 gate — ${String(err.message ?? err)}\n`); + return 1; + } + return reportGate(violations, files.length); +} + +// `exitCode` ולא `process.exit()` — ‏stdout/stderr אל pipe (וזה בדיוק המצב ב-CI) +// אסינכרוניים ב-POSIX, ו-`process.exit` היה עלול לקטוע את דוח-ההפרות עצמו. +process.exitCode = main(process.argv.slice(2));