#!/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));