Compare commits
4 Commits
fa1bda524d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a5fdd9162 | |||
| 76db4aadb5 | |||
| c6c0b76914 | |||
| b24d816cc0 |
41
.gitea/workflows/int9-guard.yaml
Normal file
41
.gitea/workflows/int9-guard.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
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 — <reason>` (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.
|
||||
#
|
||||
# ⚠️ חסום למיזוג. אין היום runner ל-Gitea Actions בהיקף הריפו הזה: ה-runner
|
||||
# היחיד על nautilus רשום בהיקף-repo ל-`legal-ai` בלבד (`action_runner.repo_id = 6`),
|
||||
# ולכן job שנפתח כאן לא נאסף לעולם — נמדד ב-legal-ai #618: ריצה 3200 / job 3250
|
||||
# ישבה ב-`queued` עם `started_at: 1970-01-01` בזמן שה-runner היה `busy: false`.
|
||||
# ה-PR הזה נשאר פתוח **בכוונה** ומשמש כמדידה: ביום שיירשם runner לריפו הזה הוא
|
||||
# יהפוך לירוק מעצמו, וזו תהיה הראיה ש-AC2 של #618 סופק. עד אז השער רץ ידנית:
|
||||
# `npm run int9:guard` · `npm run int9:guard:self-test`.
|
||||
|
||||
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
|
||||
@@ -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",
|
||||
|
||||
16
scripts/fixtures/int9/allowed-inside-job.ts
Normal file
16
scripts/fixtures/int9/allowed-inside-job.ts
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
15
scripts/fixtures/int9/non-status-update.ts
Normal file
15
scripts/fixtures/int9/non-status-update.ts
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
9
scripts/fixtures/int9/noqa-bare.ts
Normal file
9
scripts/fixtures/int9/noqa-bare.ts
Normal file
@@ -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);
|
||||
}
|
||||
9
scripts/fixtures/int9/noqa-justified.ts
Normal file
9
scripts/fixtures/int9/noqa-justified.ts
Normal file
@@ -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);
|
||||
}
|
||||
16
scripts/fixtures/int9/violating.ts
Normal file
16
scripts/fixtures/int9/violating.ts
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
546
scripts/int9-guard.mjs
Normal file
546
scripts/int9-guard.mjs
Normal file
@@ -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 — <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
|
||||
*
|
||||
* 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));
|
||||
Reference in New Issue
Block a user