diff --git a/package.json b/package.json index 9be39cb..6c8326f 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "format": "biome format --write src/", "format:check": "biome format src/", "biome": "biome check src/", - "biome:fix": "biome check --write src/" + "biome:fix": "biome check --write src/", + "int9:guard": "node scripts/int9-guard.mjs", + "int9:guard:self-test": "node scripts/int9-guard.mjs --self-test" }, "dependencies": { "@paperclipai/plugin-sdk": "^2026.722.0", 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..811e666 --- /dev/null +++ b/scripts/int9-guard.mjs @@ -0,0 +1,546 @@ +#!/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 + * + * NOT WIRED INTO CI YET: the Gitea Actions runner on this server is + * registered at *repository* scope for `legal-ai` only + * (`action_runner.repo_id = 6`), so any job queued for `plugin-legal-ai` is + * never dispatched — measured on legal-ai issue #618, run 3200 / job 3250 + * sat in `queued` with `started_at: 1970-01-01`. Until a runner is + * registered for this repo, run this by hand: + * npm run int9:guard + * npm run int9:guard:self-test + * The workflow file itself (`.gitea/workflows/int9-guard.yaml`) lives in a + * separate, deliberately-blocked PR, so merging it does not leave a + * permanently-pending check on every future PR in this repo. + * + * 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));