Compare commits
5 Commits
a6bd02c237
...
chore/618-
| Author | SHA1 | Date | |
|---|---|---|---|
| 76db4aadb5 | |||
| c6c0b76914 | |||
| b24d816cc0 | |||
| 314745d7a7 | |||
| 510f276717 |
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": "biome format --write src/",
|
||||||
"format:check": "biome format src/",
|
"format:check": "biome format src/",
|
||||||
"biome": "biome check 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": {
|
"dependencies": {
|
||||||
"@paperclipai/plugin-sdk": "^2026.722.0",
|
"@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));
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
labelFor,
|
labelFor,
|
||||||
pickSyncTargetIssue,
|
pickSyncTargetIssue,
|
||||||
resolveIssueStatus,
|
resolveIssueStatus,
|
||||||
|
resolveStatusLabel,
|
||||||
type SyncCandidate,
|
type SyncCandidate,
|
||||||
} from "./sync-target.ts";
|
} from "./sync-target.ts";
|
||||||
|
|
||||||
@@ -336,3 +337,53 @@ test("רגרסיה: companyId של המועמד-הנבחר שורד את pickSyn
|
|||||||
assert.equal(result.target?.id, "issue-b");
|
assert.equal(result.target?.id, "issue-b");
|
||||||
assert.equal(result.target?.companyId, "company-b");
|
assert.equal(result.target?.companyId, "company-b");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("resolveStatusLabel: סטטוס מוכר → תווית מה-SSOT, בלי fallback", () => {
|
||||||
|
assert.deepEqual(resolveStatusLabel(STATUS_MODEL, "new"), {
|
||||||
|
label: "חדש",
|
||||||
|
fallback: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveStatusLabel: סטטוס שאינו במודל → warning-worthy fallback + ערך גולמי, בלי זריקה", () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
resolveStatusLabel(STATUS_MODEL, "totally_unknown_status"),
|
||||||
|
);
|
||||||
|
assert.deepEqual(resolveStatusLabel(STATUS_MODEL, "totally_unknown_status"), {
|
||||||
|
label: "totally_unknown_status",
|
||||||
|
fallback: "unknown_status",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveStatusLabel: מודל לא-זמין (null) → model_unavailable, נבדל מסטטוס-לא-מוכר", () => {
|
||||||
|
assert.deepEqual(resolveStatusLabel(null, "processing"), {
|
||||||
|
label: "processing",
|
||||||
|
fallback: "model_unavailable",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("רגרסיה #616: כל הסטטוסים שהיו במפה הקשיחה ועודם חיים מקבלים תווית מה-SSOT", () => {
|
||||||
|
const survivingKeys = [
|
||||||
|
"new",
|
||||||
|
"processing",
|
||||||
|
"documents_ready",
|
||||||
|
"outcome_set",
|
||||||
|
"direction_approved",
|
||||||
|
"qa_review",
|
||||||
|
"drafted",
|
||||||
|
"exported",
|
||||||
|
"reviewed",
|
||||||
|
"final",
|
||||||
|
].filter((key) => STATUS_MODEL.some((s) => s.key === key));
|
||||||
|
|
||||||
|
for (const key of survivingKeys) {
|
||||||
|
const result = resolveStatusLabel(STATUS_MODEL, key);
|
||||||
|
assert.equal(result.fallback, null, `${key}: expected no fallback`);
|
||||||
|
assert.equal(
|
||||||
|
typeof result.label,
|
||||||
|
"string",
|
||||||
|
`${key}: expected string label`,
|
||||||
|
);
|
||||||
|
assert.notEqual(result.label, "", `${key}: expected non-empty label`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -115,3 +115,36 @@ export function labelFor(
|
|||||||
): string | null {
|
): string | null {
|
||||||
return statuses.find((s) => s.key === caseStatus)?.label ?? null;
|
return statuses.find((s) => s.key === caseStatus)?.label ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** למה נפלנו לערך-הגולמי, או `null` כשהסטטוס מוכר. הקורא **חייב** לדווח על ערך לא-null. */
|
||||||
|
export type StatusLabelFallback = "unknown_status" | "model_unavailable" | null;
|
||||||
|
|
||||||
|
export interface StatusLabelResolution {
|
||||||
|
label: string;
|
||||||
|
fallback: StatusLabelFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* התווית להצגה עבור `caseStatus`, יחד עם **סיבת** נפילה-לגולמי אם הייתה.
|
||||||
|
*
|
||||||
|
* ההחלטה מופרדת כאן מהדיווח (`ctx.logger`) בכוונה: `worker.ts` אינו ניתן
|
||||||
|
* ל-import בטסט (הוא מריץ `runWorker(...)` ברמת-המודול), ולכן ההיגיון שחייב
|
||||||
|
* כיסוי-טסט חי במודול הטהור הזה — בדיוק כמו `pickSyncTargetIssue` (#446)
|
||||||
|
* ו-`resolveIssueStatus` (#604). legal-ai issue #616.
|
||||||
|
*
|
||||||
|
* `statuses === null` = מודל-הסטטוסים לא נטען כלל (כשל-רשת) — נבדל מסטטוס
|
||||||
|
* שנטען ולא נמצא, כי הן שתי תקלות שונות עם אבחון שונה.
|
||||||
|
*/
|
||||||
|
export function resolveStatusLabel(
|
||||||
|
statuses: readonly StatusModelEntry[] | null,
|
||||||
|
caseStatus: string,
|
||||||
|
): StatusLabelResolution {
|
||||||
|
if (statuses === null) {
|
||||||
|
return { label: caseStatus, fallback: "model_unavailable" };
|
||||||
|
}
|
||||||
|
const label = labelFor(statuses, caseStatus);
|
||||||
|
if (label === null) {
|
||||||
|
return { label: caseStatus, fallback: "unknown_status" };
|
||||||
|
}
|
||||||
|
return { label, fallback: null };
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
resolveApiBaseFrom,
|
resolveApiBaseFrom,
|
||||||
runJobHandler,
|
runJobHandler,
|
||||||
} from "./company-scope.js";
|
} from "./company-scope.js";
|
||||||
|
import type { StatusModelEntry } from "./legal-api.js";
|
||||||
import { LegalApi } from "./legal-api.js";
|
import { LegalApi } from "./legal-api.js";
|
||||||
import {
|
import {
|
||||||
formatSyncRunSummary,
|
formatSyncRunSummary,
|
||||||
@@ -23,12 +24,18 @@ import {
|
|||||||
labelFor,
|
labelFor,
|
||||||
pickSyncTargetIssue,
|
pickSyncTargetIssue,
|
||||||
resolveIssueStatus,
|
resolveIssueStatus,
|
||||||
|
resolveStatusLabel,
|
||||||
type SyncCandidate,
|
type SyncCandidate,
|
||||||
} from "./sync-target.js";
|
} from "./sync-target.js";
|
||||||
|
|
||||||
// Hoisted so onWebhook can access the context after setup() completes.
|
// Hoisted so onWebhook can access the context after setup() completes.
|
||||||
let pluginCtx: PluginContext | null = null;
|
let pluginCtx: PluginContext | null = null;
|
||||||
|
|
||||||
|
// Hoisted for the same reason as pluginCtx: onWebhook is a sibling of setup()
|
||||||
|
// and has no closure over the LegalApi instance created there. Same instance,
|
||||||
|
// not a second one — see legal-ai issue #616.
|
||||||
|
let legalApi: LegalApi | null = null;
|
||||||
|
|
||||||
// Per-company CEO agent IDs (shared between setup and onWebhook).
|
// Per-company CEO agent IDs (shared between setup and onWebhook).
|
||||||
const CEO_AGENT_IDS: Record<string, string> = {
|
const CEO_AGENT_IDS: Record<string, string> = {
|
||||||
"42a7acd0-30c5-4cbd-ac97-7424f65df294":
|
"42a7acd0-30c5-4cbd-ac97-7424f65df294":
|
||||||
@@ -103,6 +110,7 @@ const plugin = definePlugin({
|
|||||||
// Lazy: the URL is fetched on the first request, from inside a handler
|
// Lazy: the URL is fetched on the first request, from inside a handler
|
||||||
// that has company context — not here. See resolveApiBase() above.
|
// that has company context — not here. See resolveApiBase() above.
|
||||||
const api = new LegalApi(() => resolveApiBase(ctx));
|
const api = new LegalApi(() => resolveApiBase(ctx));
|
||||||
|
legalApi = api;
|
||||||
|
|
||||||
ctx.logger.info("Legal AI plugin starting");
|
ctx.logger.info("Legal AI plugin starting");
|
||||||
|
|
||||||
@@ -113,7 +121,7 @@ const plugin = definePlugin({
|
|||||||
{
|
{
|
||||||
displayName: "רשימת תיקי ערר",
|
displayName: "רשימת תיקי ערר",
|
||||||
description:
|
description:
|
||||||
"List all appeal cases in the legal system. Returns case number, title, and status (new/in_progress/drafted/reviewed/final).",
|
"List all appeal cases in the legal system. Returns case number, title, and status (canonical case-status keys — see GET /api/status-model).",
|
||||||
parametersSchema: {
|
parametersSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {},
|
properties: {},
|
||||||
@@ -254,10 +262,16 @@ const plugin = definePlugin({
|
|||||||
type: "string",
|
type: "string",
|
||||||
description: "Case number (e.g. 123/24)",
|
description: "Case number (e.g. 123/24)",
|
||||||
},
|
},
|
||||||
|
// ללא enum בכוונה: הרישום קורה ב-setup(), שאין לו הקשר-חברה
|
||||||
|
// ולכן אינו יכול לשלוף את /api/status-model (ראה resolveApiBase
|
||||||
|
// למעלה) — והעתק סטטי היה נסחף שוב. המניפסט (manifest.ts) כבר
|
||||||
|
// מגדיר את השדה כמחרוזת חופשית, והוולידציה האמיתית היא
|
||||||
|
// server-side (case_status_model.py + השומר הקדימה-בלבד
|
||||||
|
// ב-tools/cases.py). legal-ai issue #616.
|
||||||
status: {
|
status: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["new", "in_progress", "drafted", "reviewed", "final"],
|
description:
|
||||||
description: "New case status",
|
"New case status — a canonical key from GET /api/status-model",
|
||||||
},
|
},
|
||||||
title: { type: "string" },
|
title: { type: "string" },
|
||||||
subject: { type: "string" },
|
subject: { type: "string" },
|
||||||
@@ -1488,7 +1502,7 @@ const plugin = definePlugin({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await handleCaseStatusWebhook(pluginCtx, input);
|
await handleCaseStatusWebhook(pluginCtx, legalApi, input);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// מחיקת סמן-האידמפוטנטיות **לפני** שהשגיאה יוצאת — אחרת מסירה
|
// מחיקת סמן-האידמפוטנטיות **לפני** שהשגיאה יוצאת — אחרת מסירה
|
||||||
// חוזרת של אותו webhook תוך 5 דק' תדולג בשקט כ"כבר נשלח", למרות
|
// חוזרת של אותו webhook תוך 5 דק' תדולג בשקט כ"כבר נשלח", למרות
|
||||||
@@ -1520,6 +1534,7 @@ const plugin = definePlugin({
|
|||||||
*/
|
*/
|
||||||
async function handleCaseStatusWebhook(
|
async function handleCaseStatusWebhook(
|
||||||
pluginCtx: PluginContext,
|
pluginCtx: PluginContext,
|
||||||
|
legalApi: LegalApi | null,
|
||||||
input: PluginWebhookInput,
|
input: PluginWebhookInput,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { endpointKey, parsedBody } = input;
|
const { endpointKey, parsedBody } = input;
|
||||||
@@ -1703,30 +1718,48 @@ async function handleCaseStatusWebhook(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status label map (Hebrew)
|
// התוויות נגזרות מ-`GET /api/status-model` — אותו SSOT שהג'וב
|
||||||
const statusLabels: Record<string, string> = {
|
// `sync-case-status` כבר צורך (#604). המפה הקשיחה שהייתה כאן כבר סטתה:
|
||||||
new: "📂 תיק חדש",
|
// חסרו בה מפתחות חדשים מהמודל, והיו בה מפתחות מתים שכבר לא קיימים בו.
|
||||||
uploading: "📤 העלאת מסמכים",
|
// legal-ai issue #616.
|
||||||
processing: "⚙️ עיבוד מסמכים",
|
let statuses: StatusModelEntry[] | null = null;
|
||||||
documents_ready: "📁 מסמכים מוכנים",
|
if (!legalApi) {
|
||||||
outcome_set: "🎯 תוצאה הוזנה",
|
// לא אמור לקרות (מושם ב-setup, כמו pluginCtx) — אבל אם קרה, זו נפילה
|
||||||
brainstorming: "💡 סיעור מוחות",
|
// לערך-גולמי שחייבת עקבה, בדיוק כמו כשל-הרשת שמתחתיה.
|
||||||
direction_approved: "✅ כיוון אושר",
|
pluginCtx.logger.error(
|
||||||
drafting: "✍️ כתיבה בתהליך",
|
"onWebhook: LegalApi unavailable — labels fall back to raw keys",
|
||||||
qa_review: "🔍 בדיקת איכות",
|
{ caseNumber, newStatus },
|
||||||
in_progress: "🔄 בעבודה",
|
);
|
||||||
drafted: "✍️ טיוטה מוכנה",
|
} else {
|
||||||
qa_failed: "❌ QA נכשל",
|
try {
|
||||||
exported: "📄 יוצא ל-DOCX",
|
statuses = (await legalApi.getStatusModel()).statuses;
|
||||||
reviewed: "✅ נבדק",
|
} catch (err) {
|
||||||
final: "🎯 סופי",
|
pluginCtx.logger.error(
|
||||||
};
|
"onWebhook: status-model fetch failed — labels fall back to raw keys",
|
||||||
const label = statusLabels[newStatus] ?? newStatus;
|
{ caseNumber, newStatus, error: String(err) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resolved = resolveStatusLabel(statuses, newStatus);
|
||||||
|
if (resolved.fallback === "unknown_status") {
|
||||||
|
// לא בליעה שקטה: הסטטוס נטען ופשוט אינו במודל הקנוני.
|
||||||
|
pluginCtx.logger.warn(
|
||||||
|
"onWebhook: status not in canonical status-model — using raw key as label",
|
||||||
|
{ caseNumber, oldStatus, newStatus },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// `model_unavailable` כבר דווח כ-error למעלה — אין הכפלת-דיווח.
|
||||||
|
const label = resolved.label;
|
||||||
|
// `oldStatus` מתורגם אף הוא, אך **בלי** לוג: הוא ריק לגיטימית בתיק-חדש
|
||||||
|
// ועשוי להיות מפתח היסטורי שכבר אינו במודל — warn עליו היה רעש כרוני.
|
||||||
|
const oldLabel = oldStatus
|
||||||
|
? resolveStatusLabel(statuses, oldStatus).label
|
||||||
|
: oldStatus;
|
||||||
|
|
||||||
// Post a Hebrew status comment on the linked issue
|
// Post a Hebrew status comment on the linked issue
|
||||||
await pluginCtx.issues.createComment(
|
await pluginCtx.issues.createComment(
|
||||||
linkedIssueId,
|
linkedIssueId,
|
||||||
`**עדכון סטטוס תיק ${caseNumber}:** ${label} (היה: ${oldStatus})`,
|
`**עדכון סטטוס תיק ${caseNumber}:** ${label} (היה: ${oldLabel})`,
|
||||||
companyId,
|
companyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user