mergejudge-runner 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +7 -1
  2. package/dist/cli.js +249 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -63,7 +63,7 @@ mergejudge-runner run --concurrency 2 # capped by the concurrency registered wit
63
63
  - Network errors, 5xx and 429 back off exponentially with jitter; `Retry-After` is honoured.
64
64
  - `SIGINT`/`SIGTERM`: stop polling, give in-flight jobs 30 s to finish, then kill the engine and nack them ("runner shutting down") and exit. A second signal exits immediately.
65
65
  - The access token is rotated through `/token/rotate` when it has < 14 days left (or when a response carries an `x-mergejudge-rotate` header). The new token is written to the config file (atomic rename) **before** it is used.
66
- - Modes: `diff`, `context` and `deep`. **Deep on a runner is a single pass**: the Worker builds a temporary index of the repo (nothing stored, no Cloud AI), looks up callers, importers and imports of the changed code, and sends them as context with the job. The engine still runs with no tools and no checkout, so it cannot browse further. If the repo is too big to index, the review falls back to `context` and says so. Runners older than 0.3.0 do not advertise `deep`, so they are never offered such jobs; upgrade and restart to receive them. With `runner.code_source: local` the runner reads its own clone instead; see below.
66
+ - Modes: `diff`, `context` and `deep`. **Deep on a runner is a single pass**: the Worker builds a temporary index of the repo (nothing stored, no Cloud AI), looks up callers, importers and imports of the changed code (two hops out, hop 1 first), plus files that reference strings, object keys and env var names the diff changed or removed, and sends them as context with the job. The engine still runs with no tools and no checkout, so it cannot browse further. If the repo is too big to index, the review falls back to `context` and says so. Runners older than 0.3.0 do not advertise `deep`, so they are never offered such jobs; upgrade and restart to receive them. With `runner.code_source: local` the runner reads its own clone instead; see below.
67
67
 
68
68
  ## Local code (deep mode without code leaving your machine) — runner 0.4.0
69
69
 
@@ -121,3 +121,9 @@ Logs go to stderr.
121
121
  - **Token storage.** The runner token (`mjr_a_...`) is a bearer credential for your installation's review jobs. It lives only in the 0600 config file; it is never logged (logs hold job ids, repo/PR numbers, durations and sizes; a redaction filter masks token shapes as a safety net) and never passed to the engine. Revoke or rotate it in the panel if a machine is lost. The runner follows no redirects, so the token cannot be forwarded to another origin.
122
122
  - **What the runner sees.** Job payloads contain the PR diff, title/body, the repo's policy files and (context mode) related files. Nothing is written to disk by the runner except the config file.
123
123
  - The `claude` process runs as you. Run the runner under a dedicated OS user if you do not want PR text processed in your main account.
124
+
125
+ ## Changelog
126
+
127
+ - **0.4.1** (2026-10-16): local-code deep reviews use the new retrieval from MergeJudge #82: two-hop importers/imports and a repo search for changed string literals, keys and env names, within fixed budgets. No config or protocol changes.
128
+ - **0.4.0**: local code source (`runner.code_source: local`).
129
+ - **0.3.0**: deep mode on the runner (single pass, Worker-built context).
package/dist/cli.js CHANGED
@@ -28065,7 +28065,18 @@ ${c.text}`.slice(0, 1500);
28065
28065
 
28066
28066
  // ../../src/deepretrieve.ts
28067
28067
  var DEEP_RETRIEVE_DEFAULTS = { max_bytes: 8e4, max_files: 60, max_symbols: 8, max_callers: 5, max_file_bytes: 12e3 };
28068
- var MAX_IMPORTERS = 6;
28068
+ var DEEP_HOP_DEFAULTS = {
28069
+ max_importers: 6,
28070
+ max_hop2_importers: 6,
28071
+ max_hop2_imports: 4,
28072
+ max_examine: 40,
28073
+ max_literals: 12,
28074
+ max_literal_files: 8,
28075
+ literal_common_files: 25,
28076
+ max_scan_bytes: 8e6,
28077
+ literal_piece_bytes: 6e3,
28078
+ max_ms: 3e3
28079
+ };
28069
28080
  function snapshotText(snap, path2) {
28070
28081
  const f = snap.files[path2];
28071
28082
  if (!f) return null;
@@ -28084,8 +28095,109 @@ function snapshotSource(snap) {
28084
28095
  searchSymbol: async (sym) => [...new Set(findReferences(snap, sym, 80).map((h) => h.path))]
28085
28096
  };
28086
28097
  }
28098
+ var baseName = (p) => p.replace(/\.[^./]+$/, "").split("/").pop();
28099
+ var escapeRe2 = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28100
+ var wholeToken = (lit) => new RegExp(`(?<![\\w$])${escapeRe2(lit)}(?![\\w$])`);
28101
+ var COMMON_LITERALS = /* @__PURE__ */ new Set([
28102
+ "use strict",
28103
+ "utf8",
28104
+ "utf-8",
28105
+ "string",
28106
+ "number",
28107
+ "object",
28108
+ "boolean",
28109
+ "function",
28110
+ "undefined",
28111
+ "default",
28112
+ "error",
28113
+ "data",
28114
+ "json",
28115
+ "true",
28116
+ "false",
28117
+ "null",
28118
+ "none",
28119
+ "type",
28120
+ "name",
28121
+ "value",
28122
+ "values",
28123
+ "test",
28124
+ "index",
28125
+ "main",
28126
+ "node",
28127
+ "content-type",
28128
+ "application/json",
28129
+ "text/plain",
28130
+ "text/html",
28131
+ "GET",
28132
+ "POST",
28133
+ "PUT",
28134
+ "DELETE",
28135
+ "PATCH",
28136
+ "HEAD",
28137
+ "message",
28138
+ "status",
28139
+ "result",
28140
+ "results",
28141
+ "items",
28142
+ "length",
28143
+ "key",
28144
+ "keys",
28145
+ "path",
28146
+ "file",
28147
+ "text",
28148
+ "info",
28149
+ "warn",
28150
+ "debug",
28151
+ "require",
28152
+ "exports",
28153
+ "module",
28154
+ "string",
28155
+ "return",
28156
+ "async",
28157
+ "await",
28158
+ "const",
28159
+ "this",
28160
+ "self",
28161
+ "class"
28162
+ ]);
28163
+ function distinct(t) {
28164
+ let sc = Math.min(t.length, 24) / 4;
28165
+ if (/[._:/-]/.test(t)) sc += 3;
28166
+ if (/[a-z][A-Z]/.test(t) || /^[A-Z0-9_]{4,}$/.test(t)) sc += 2;
28167
+ if (/\d/.test(t)) sc += 1;
28168
+ return sc;
28169
+ }
28170
+ function patchLiterals(patch, max = DEEP_HOP_DEFAULTS.max_literals) {
28171
+ if (!patch) return [];
28172
+ const seen = /* @__PURE__ */ new Map();
28173
+ const note = (text, kind, removed) => {
28174
+ if (text.length < 3 || text.length > 80 || /\s/.test(text) || COMMON_LITERALS.has(text) || /^[\d._-]+$/.test(text)) return;
28175
+ if (/^[./]|^@[\w-]+\/|^node:/.test(text)) return;
28176
+ const e = seen.get(text) ?? { removed: false, added: false, kind };
28177
+ if (removed) e.removed = true;
28178
+ else e.added = true;
28179
+ if (kind === "env") e.kind = "env";
28180
+ seen.set(text, e);
28181
+ };
28182
+ for (const line of patch.split("\n")) {
28183
+ if (!/^[+-]/.test(line) || /^(\+\+\+|---)/.test(line) || line.length > 600) continue;
28184
+ const removed = line[0] === "-";
28185
+ const l = line.slice(1);
28186
+ if (/^\s*(?:import\b|export\s[^'"]*\sfrom\b|from\s+\S+\s+import\b)|\brequire\s*\(/.test(l)) continue;
28187
+ for (const m of l.matchAll(/process\.env\.([A-Za-z_]\w*)|import\.meta\.env\.([A-Za-z_]\w*)|\benv\.([A-Z_][A-Z0-9_]*)|(?:process\.env|os\.environ|env)\[\s*['"]([A-Za-z_]\w*)['"]\s*\]|os\.(?:getenv|environ\.get)\(\s*['"]([A-Za-z_]\w*)['"]/g))
28188
+ note(m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5], "env", removed);
28189
+ for (const m of l.matchAll(/(["'`])((?:\\.|(?!\1)[^\\$])*?)\1/g)) note(m[2], "string", removed);
28190
+ for (const m of l.matchAll(/(?:^|[{,(])\s*["']?([A-Za-z_$][\w$-]{4,})["']?\s*:(?!:)/g)) note(m[1], "key", removed);
28191
+ for (const m of l.matchAll(/\.([a-z][\w$]*[A-Z][\w$]*)\b/g)) note(m[1], "key", removed);
28192
+ }
28193
+ const out = [...seen].map(([text, e]) => ({ text, kind: e.kind, side: e.removed && e.added ? "both" : e.removed ? "removed" : "added" }));
28194
+ const rank = (x) => (x.side === "removed" ? 4 : x.side === "added" ? 2 : 0) + (x.kind === "env" ? 3 : 0) + distinct(x.text);
28195
+ return out.sort((a, b) => rank(b) - rank(a) || (a.text < b.text ? -1 : 1)).slice(0, max);
28196
+ }
28087
28197
  async function retrieveDeepContext(snap, files, opts = {}, log = () => {
28088
28198
  }) {
28199
+ const t0 = Date.now();
28200
+ const { max_importers, max_hop2_importers, max_hop2_imports, max_examine, max_literals, max_literal_files, literal_common_files, max_scan_bytes, literal_piece_bytes, max_ms } = { ...DEEP_HOP_DEFAULTS, ...opts };
28089
28201
  const o = { ...DEEP_RETRIEVE_DEFAULTS, ...opts };
28090
28202
  const src = snapshotSource(snap);
28091
28203
  const g = await gatherContext(files, src, o, log);
@@ -28093,26 +28205,138 @@ async function retrieveDeepContext(snap, files, opts = {}, log = () => {
28093
28205
  let bytes = g.bytes;
28094
28206
  const seen = /* @__PURE__ */ new Set([...pieces.map((p) => p.path), ...files.map((f) => f.path)]);
28095
28207
  const symbols = [...new Set(files.flatMap((f) => changedSymbols(f.patch)))];
28096
- let added = 0;
28208
+ const timeUp = () => Date.now() - t0 > max_ms;
28209
+ const full = () => o.max_bytes - bytes < 512 || pieces.length >= o.max_files;
28210
+ const stats = { hop1: 0, literal: 0, hop2: 0, scanned: 0, timedOut: false };
28211
+ const addPiece = (path2, text, kind, why, re, cap0 = o.max_file_bytes) => {
28212
+ const cap = Math.min(o.max_bytes - bytes, cap0);
28213
+ const truncated = text.length > cap;
28214
+ const lines = text.split("\n");
28215
+ const hit = re ? lines.flatMap((l, i) => re.test(l) ? [[i + 1, i + 1]] : []) : [];
28216
+ const content = truncated ? excerpt(text, hit.length ? hit : [[1, 40]], cap) : text;
28217
+ pieces.push({ path: path2, kind, why: why + (truncated ? ", excerpt around relevant lines" : ""), content, truncated });
28218
+ bytes += content.length;
28219
+ seen.add(path2);
28220
+ return true;
28221
+ };
28222
+ const symRe = (names) => new RegExp(names.map((n) => escapeRe2(n)).join("|"));
28223
+ const rankCandidates = (cands, needles) => {
28224
+ const out = [];
28225
+ const uniq = [...new Set(cands)].filter((c) => !seen.has(c) && !o.skip?.(c) && !isSensitivePath(c)).sort();
28226
+ const re = needles.length ? symRe(needles) : null;
28227
+ for (const c of uniq.slice(0, max_examine)) {
28228
+ if (timeUp()) {
28229
+ stats.timedOut = true;
28230
+ break;
28231
+ }
28232
+ const text = snapshotText(snap, c);
28233
+ if (text !== null) out.push({ path: c, text, rel: re ? re.test(text) : false });
28234
+ }
28235
+ return out.sort((a, b) => Number(b.rel) - Number(a.rel));
28236
+ };
28237
+ const hop1 = g.pieces.filter((x) => x.kind === "caller").map((x) => ({
28238
+ path: x.path,
28239
+ via: files.find((f) => importersOf(snap, f.path).includes(x.path))?.path ?? files[0]?.path ?? "a changed file"
28240
+ }));
28097
28241
  for (const f of files) {
28098
- if (f.status === "removed") continue;
28099
- for (const imp of importersOf(snap, f.path)) {
28100
- if (added >= MAX_IMPORTERS || seen.has(imp) || o.skip?.(imp)) continue;
28101
- const text = snapshotText(snap, imp);
28102
- if (text === null) continue;
28103
- const room = o.max_bytes - bytes;
28104
- if (room < 512) return { pieces, bytes };
28105
- const hit = text.split("\n").flatMap((l, i) => l.includes(f.path.replace(/\.[^./]+$/, "").split("/").pop()) || symbols.some((s) => l.includes(s)) ? [[i + 1, i + 1]] : []);
28106
- const cap = Math.min(room, o.max_file_bytes);
28107
- const truncated = text.length > cap;
28108
- const content = truncated ? excerpt(text, hit.length ? hit : [[1, 40]], cap) : text;
28109
- pieces.push({ path: imp, kind: "caller", why: `imports ${f.path}${truncated ? ", excerpt around relevant lines" : ""}`, content, truncated });
28110
- bytes += content.length;
28111
- seen.add(imp);
28112
- added++;
28113
- }
28114
- }
28115
- log(`deep: ${pieces.length} piece(s), ${bytes} bytes retrieved from the index`);
28242
+ if (f.status === "removed" || full() || timeUp()) continue;
28243
+ const needles = [baseName(f.path), ...symbols];
28244
+ for (const c of rankCandidates(importersOf(snap, f.path), needles)) {
28245
+ if (stats.hop1 >= max_importers || full() || seen.has(c.path)) continue;
28246
+ addPiece(c.path, c.text, "caller", `imports ${f.path}`, symRe(needles));
28247
+ hop1.push({ path: c.path, via: f.path });
28248
+ stats.hop1++;
28249
+ }
28250
+ }
28251
+ if (!full() && !timeUp() && max_literals > 0 && max_literal_files > 0) {
28252
+ const lits = /* @__PURE__ */ new Map();
28253
+ for (const f of files) for (const l of patchLiterals(f.patch, max_literals)) {
28254
+ const e = lits.get(l.text);
28255
+ if (e) {
28256
+ e.from.push(f.path);
28257
+ if (e.side !== l.side) e.side = "both";
28258
+ } else lits.set(l.text, { ...l, from: [f.path] });
28259
+ }
28260
+ const list = [...lits.values()].slice(0, max_literals);
28261
+ if (list.length) {
28262
+ const changed = new Set(files.map((f) => f.path));
28263
+ const res = list.map((l) => wholeToken(l.text));
28264
+ const hits = list.map(() => /* @__PURE__ */ new Map());
28265
+ const common = list.map(() => false);
28266
+ let scanned = 0;
28267
+ scan: for (const [path2, fe] of Object.entries(snap.files)) {
28268
+ if (changed.has(path2) || o.skip?.(path2) || isSensitivePath(path2)) continue;
28269
+ if (timeUp()) {
28270
+ stats.timedOut = true;
28271
+ break;
28272
+ }
28273
+ for (const c of fe.chunks) {
28274
+ scanned += c.text.length;
28275
+ if (scanned > max_scan_bytes) break scan;
28276
+ for (let i = 0; i < list.length; i++) {
28277
+ if (common[i] || !c.text.includes(list[i].text) || !res[i].test(c.text)) continue;
28278
+ const m = hits[i];
28279
+ if (!m.has(path2) && m.size >= literal_common_files) {
28280
+ common[i] = true;
28281
+ m.clear();
28282
+ continue;
28283
+ }
28284
+ const lines = m.get(path2) ?? [];
28285
+ c.text.split("\n").forEach((l, j) => {
28286
+ if (l.includes(list[i].text) && res[i].test(l)) lines.push(c.start + j);
28287
+ });
28288
+ m.set(path2, lines);
28289
+ }
28290
+ }
28291
+ }
28292
+ stats.scanned = scanned;
28293
+ const byFile = /* @__PURE__ */ new Map();
28294
+ list.forEach((l, i) => {
28295
+ for (const [path2, lines] of hits[i]) {
28296
+ const e = byFile.get(path2) ?? { score: 0, lits: [], lines: [] };
28297
+ e.score += (l.side === "removed" ? 3 : l.side === "both" ? 1 : 2) + distinct(l.text) / 10;
28298
+ e.lits.push(i);
28299
+ e.lines.push(...lines);
28300
+ byFile.set(path2, e);
28301
+ }
28302
+ });
28303
+ const near = new Set(hop1.map((h) => h.path));
28304
+ const ranked = [...byFile].filter(([p]) => !seen.has(p)).sort((a, b) => b[1].score - a[1].score || Number(near.has(b[0])) - Number(near.has(a[0])) || (a[0] < b[0] ? -1 : 1));
28305
+ for (const [path2, e] of ranked) {
28306
+ if (stats.literal >= max_literal_files || full()) break;
28307
+ const text = snapshotText(snap, path2);
28308
+ if (text === null) continue;
28309
+ const names = e.lits.slice(0, 3).map((i) => `${JSON.stringify(list[i].text)} (${list[i].side === "removed" ? "removed" : list[i].side === "added" ? "added" : "changed"} in ${list[i].from[0]})`);
28310
+ const re = symRe(e.lits.map((i) => list[i].text));
28311
+ addPiece(path2, text, "caller", `references literal ${names.join(", ")} changed in this PR; may depend on the old or new value`, re, literal_piece_bytes);
28312
+ stats.literal++;
28313
+ }
28314
+ }
28315
+ }
28316
+ if (!full() && !timeUp() && (max_hop2_importers > 0 || max_hop2_imports > 0)) {
28317
+ for (const h of hop1) {
28318
+ if (stats.hop2 >= max_hop2_importers || full()) break;
28319
+ const needles = [baseName(h.path), ...symbols];
28320
+ for (const c of rankCandidates(importersOf(snap, h.path), needles)) {
28321
+ if (stats.hop2 >= max_hop2_importers || full()) break;
28322
+ addPiece(c.path, c.text, "caller", `imports ${h.path}, which imports ${h.via} (2 hops from the changed file)`, symRe(needles));
28323
+ stats.hop2++;
28324
+ }
28325
+ }
28326
+ let imp2 = 0;
28327
+ const pathSet = new Set(Object.keys(snap.files));
28328
+ for (const p of g.pieces.filter((x) => x.kind === "import")) {
28329
+ if (imp2 >= max_hop2_imports || full() || timeUp()) break;
28330
+ const targets = (snap.files[p.path]?.imports ?? []).map((spec) => resolveImport(p.path, spec, pathSet)).filter((t) => !!t);
28331
+ for (const c of rankCandidates(targets, symbols)) {
28332
+ if (imp2 >= max_hop2_imports || full()) break;
28333
+ addPiece(c.path, c.text, "import", `imported by ${p.path}, which a changed file imports (2 hops)`, symbols.length ? symRe(symbols) : null);
28334
+ imp2++;
28335
+ stats.hop2++;
28336
+ }
28337
+ }
28338
+ }
28339
+ log(`deep: ${pieces.length} piece(s), ${bytes} bytes retrieved from the index (hop1 importers ${stats.hop1}, literal refs ${stats.literal}, hop2 ${stats.hop2}, ${stats.scanned} bytes scanned, ${Date.now() - t0} ms${stats.timedOut ? ", time budget hit" : ""})`);
28116
28340
  return { pieces, bytes };
28117
28341
  }
28118
28342
 
@@ -30172,13 +30396,14 @@ function scanSecrets(patch, path2 = "") {
30172
30396
  function findSecrets(patch, path2 = "") {
30173
30397
  return scanSecrets(patch, path2).blocked;
30174
30398
  }
30175
- var CODE_AUTO_NEVER_GLOBS = [".github/**", "**/.env*"];
30399
+ var CODE_AUTO_NEVER_GLOBS = [".github/**", "**/.env*", "_headers", "**/_headers"];
30400
+ var ALWAYS_REVIEW_GLOBS = ["_headers", "**/_headers"];
30176
30401
  var CODE_AUTO_NEVER_EXT = /* @__PURE__ */ new Set(["sql", "sh", "bash", "zsh", "ps1", "wasm"]);
30177
30402
  function codeAutoBlocker(file2, paths, changed, ca) {
30178
30403
  if (file2.status === "removed" || file2.status === "renamed" || file2.previousPath) return "code file deleted or renamed (needs human review)";
30179
30404
  if (file2.status !== "added" && file2.status !== "modified") return `code file with status ${file2.status} (needs human review)`;
30180
30405
  if (paths.some((p) => matchesAny(p, CODE_AUTO_NEVER_GLOBS) || CODE_AUTO_NEVER_EXT.has(p.split(".").pop().toLowerCase())))
30181
- return "code file type never eligible for lanes.code_auto (CI, env, shell, SQL, wasm)";
30406
+ return "code file type never eligible for lanes.code_auto (CI, env, headers, shell, SQL, wasm)";
30182
30407
  if (ca.paths.length && !matchesAny(file2.path, ca.paths)) return "code file outside lanes.code_auto.paths";
30183
30408
  if (changed > ca.max_lines) return `code file with ${changed} changed lines (lanes.code_auto.max_lines ${ca.max_lines})`;
30184
30409
  return void 0;
@@ -30199,6 +30424,7 @@ function classifyFileInner(file2, cfg) {
30199
30424
  const secrets = findSecrets(file2.patch, file2.path);
30200
30425
  if (secrets.length) raise("block", `possible secret in added lines (${secrets.join(", ")})`);
30201
30426
  if (paths.some((p) => matchesAny(p, cfg.lanes.protected))) raise("review", "protected path");
30427
+ else if (paths.some((p) => matchesAny(p, ALWAYS_REVIEW_GLOBS))) raise("review", "protected path (hosting headers file)");
30202
30428
  const ca = cfg.lanes.code_auto;
30203
30429
  const codeCandidate = !!ca?.enabled && paths.some(isCodeFile);
30204
30430
  if (!codeCandidate && paths.some(isCodeFile)) raise("review", "code file (always needs human review)");
@@ -32284,7 +32510,7 @@ function installAutostart(d, bundle) {
32284
32510
  }
32285
32511
 
32286
32512
  // src/version.ts
32287
- var VERSION = "0.4.0";
32513
+ var VERSION = "0.4.1";
32288
32514
  var MODES = ["diff", "context", "deep"];
32289
32515
  var FEATURES = ["local-code"];
32290
32516
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergejudge-runner",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Run MergeJudge reviews on your own machine with your own Claude Code login (runner protocol /runner/v1)",