All checks were successful
INV-INT9 issue.status write-ownership / int9-guard (pull_request) Successful in 10s
ל-`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) <noreply@anthropic.com>
535 lines
14 KiB
JavaScript
535 lines
14 KiB
JavaScript
#!/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 — <reason, at least 3 characters>
|
||
* 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 <path>... # 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));
|