@kavishdua/proctor 1.0.0 → 1.2.0

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.
package/dist/cli.js CHANGED
@@ -57,15 +57,169 @@ var init_judge = __esm({
57
57
  }
58
58
  });
59
59
 
60
+ // src/dynamic/runner.ts
61
+ var runner_exports = {};
62
+ __export(runner_exports, {
63
+ runDynamic: () => runDynamic
64
+ });
65
+ import { spawn as spawn2 } from "child_process";
66
+ import { mkdtempSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, rmSync } from "fs";
67
+ import { join as join14 } from "path";
68
+ import { tmpdir as tmpdir2 } from "os";
69
+ async function runDynamic(options) {
70
+ const started = Date.now();
71
+ const skipped = (reason) => ({ status: "skipped", reason, findings: [], runs: [], notes: [], elapsedMs: Date.now() - started });
72
+ const timeout = options.timeoutMs ?? 6e4;
73
+ if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 2147483647) return skipped("timeout must be an integer from 1 to 2147483647 milliseconds");
74
+ if (process.env["PROCTOR_DYNAMIC_ACTIVE"]) return skipped("nested dynamic check");
75
+ if (process.platform !== "linux") {
76
+ return skipped("not run: this prototype needs Linux and bubblewrap for write and network isolation");
77
+ }
78
+ let scratch;
79
+ try {
80
+ scratch = mkdtempSync(join14(tmpdir2(), "proctor-dynamic-"));
81
+ } catch {
82
+ return skipped("cannot create scratch directory");
83
+ }
84
+ const clean = () => {
85
+ try {
86
+ rmSync(scratch, { recursive: true, force: true });
87
+ } catch {
88
+ }
89
+ };
90
+ try {
91
+ mkdirSync2(join14(scratch, "tmp"));
92
+ writeFileSync2(join14(scratch, "input.json"), JSON.stringify({ ...options, files: options.files.map(({ from, to }) => ({ from, to })) }));
93
+ const env = {
94
+ PATH: process.env["PATH"],
95
+ HOME: scratch,
96
+ TMPDIR: join14(scratch, "tmp"),
97
+ TMP: join14(scratch, "tmp"),
98
+ TEMP: join14(scratch, "tmp"),
99
+ LANG: "C.UTF-8",
100
+ CI: "true",
101
+ NO_COLOR: "1",
102
+ PROCTOR_DYNAMIC_ACTIVE: "1",
103
+ PYTHONDONTWRITEBYTECODE: "1",
104
+ PYTEST_DISABLE_PLUGIN_AUTOLOAD: "1",
105
+ npm_config_offline: "true",
106
+ npm_config_audit: "false",
107
+ npm_config_update_notifier: "false",
108
+ npm_config_cache: join14(scratch, "npm-cache")
109
+ };
110
+ const remaining = timeout - (Date.now() - started);
111
+ if (remaining <= 0) return skipped(`overall timeout (${timeout}ms)`);
112
+ return await new Promise((resolve4) => {
113
+ const namespaceArgs = [
114
+ "--die-with-parent",
115
+ "--unshare-net",
116
+ "--unshare-pid",
117
+ "--ro-bind",
118
+ "/",
119
+ "/",
120
+ "--bind",
121
+ scratch,
122
+ scratch,
123
+ "--proc",
124
+ "/proc",
125
+ "--dev",
126
+ "/dev",
127
+ "--",
128
+ process.execPath,
129
+ options.workerPath,
130
+ join14(scratch, "input.json")
131
+ ];
132
+ const child = spawn2("bwrap", namespaceArgs, {
133
+ cwd: scratch,
134
+ env,
135
+ detached: true,
136
+ stdio: ["ignore", "pipe", "pipe"]
137
+ });
138
+ const kill = () => {
139
+ if (child.pid) {
140
+ try {
141
+ process.kill(-child.pid, "SIGKILL");
142
+ } catch {
143
+ }
144
+ }
145
+ };
146
+ const onExit = () => {
147
+ kill();
148
+ clean();
149
+ };
150
+ const onInt = () => {
151
+ onExit();
152
+ process.exit(130);
153
+ };
154
+ const onTerm = () => {
155
+ onExit();
156
+ process.exit(143);
157
+ };
158
+ process.once("exit", onExit);
159
+ process.once("SIGINT", onInt);
160
+ process.once("SIGTERM", onTerm);
161
+ let stdout = "", stderr = "", reason = "";
162
+ const timer = setTimeout(() => {
163
+ reason = `overall timeout (${timeout}ms)`;
164
+ kill();
165
+ }, remaining);
166
+ child.stdout.on("data", (data) => {
167
+ stdout += data.toString();
168
+ if (stdout.length > 4 * 1024 * 1024) {
169
+ reason = "worker output limit";
170
+ kill();
171
+ }
172
+ });
173
+ child.stderr.on("data", (data) => {
174
+ stderr = (stderr + data.toString()).slice(-2e3);
175
+ });
176
+ child.once("error", (err) => {
177
+ reason = `cannot start isolated worker: ${err.message}`;
178
+ });
179
+ child.once("close", (code) => {
180
+ clearTimeout(timer);
181
+ kill();
182
+ process.removeListener("exit", onExit);
183
+ process.removeListener("SIGINT", onInt);
184
+ process.removeListener("SIGTERM", onTerm);
185
+ if (reason) {
186
+ resolve4(skipped(reason));
187
+ return;
188
+ }
189
+ if (code !== 0) {
190
+ resolve4(skipped(stderr.trim() || `isolated worker exited ${code}`));
191
+ return;
192
+ }
193
+ try {
194
+ const result = JSON.parse(stdout);
195
+ result.elapsedMs = Date.now() - started;
196
+ resolve4(result);
197
+ } catch {
198
+ resolve4(skipped("isolated worker returned no usable result"));
199
+ }
200
+ });
201
+ });
202
+ } catch (err) {
203
+ return skipped(`cannot prepare isolated worker: ${String(err).replace(/^Error: /, "")}`);
204
+ } finally {
205
+ clean();
206
+ }
207
+ }
208
+ var init_runner = __esm({
209
+ "src/dynamic/runner.ts"() {
210
+ "use strict";
211
+ }
212
+ });
213
+
60
214
  // src/cli.ts
61
215
  import { Command } from "commander";
62
- import { resolve as resolve3, join as join14 } from "path";
216
+ import { resolve as resolve3, join as join15 } from "path";
63
217
  import { homedir } from "os";
64
218
 
65
219
  // package.json
66
220
  var package_default = {
67
221
  name: "@kavishdua/proctor",
68
- version: "1.0.0",
222
+ version: "1.2.0",
69
223
  description: "Catch AI agents gaming their test suites: deleted tests, skipped tests, weakened assertions, hardcoded outputs",
70
224
  homepage: "https://github.com/catfish-1234/proctor#readme",
71
225
  bugs: {
@@ -126,6 +280,7 @@ var package_default = {
126
280
  },
127
281
  dependencies: {
128
282
  "@anthropic-ai/sdk": "^0.132.0",
283
+ acorn: "^8.19.0",
129
284
  "brace-expansion": "^5.0.12",
130
285
  commander: "^14.0.3",
131
286
  "parse-diff": "0.12.0",
@@ -653,7 +808,9 @@ var WI_CHECKS = [
653
808
  "WI112",
654
809
  "WI113"
655
810
  ];
656
- var DEFAULT_ENABLED = RH_CHECKS;
811
+ var DEFAULT_WI_CHECKS = ["WI101", "WI102", "WI105", "WI108", "WI109", "WI110"];
812
+ var DEFAULT_CHECKS = [...RH_CHECKS, ...DEFAULT_WI_CHECKS];
813
+ var DEFAULT_ENABLED = DEFAULT_CHECKS;
657
814
  var VALID_SEVERITIES = /* @__PURE__ */ new Set(["error", "warn", "info"]);
658
815
  function normalizeConfig(config) {
659
816
  const out = { ...config };
@@ -717,6 +874,14 @@ function normalizeConfig(config) {
717
874
  warn(`config 'aiModel' must be a string; ignoring it`);
718
875
  delete out.aiModel;
719
876
  }
877
+ if (config.dynamic !== void 0 && typeof config.dynamic !== "boolean") {
878
+ warn("config 'dynamic' must be a boolean; ignoring it");
879
+ delete out.dynamic;
880
+ }
881
+ if (config.dynamicTimeoutMs !== void 0 && (!Number.isSafeInteger(config.dynamicTimeoutMs) || config.dynamicTimeoutMs < 1 || config.dynamicTimeoutMs > 2147483647)) {
882
+ warn("config 'dynamicTimeoutMs' must be an integer from 1 to 2147483647; ignoring it");
883
+ delete out.dynamicTimeoutMs;
884
+ }
720
885
  return out;
721
886
  }
722
887
  async function buildContext(cwd, files, opts) {
@@ -814,7 +979,9 @@ async function buildContext(cwd, files, opts) {
814
979
  approvedTestChanges: config.approvedTestChanges,
815
980
  commitMessage,
816
981
  snapshotGlobs,
817
- aiModel
982
+ aiModel,
983
+ ...config.dynamic !== void 0 ? { dynamic: config.dynamic } : {},
984
+ ...config.dynamicTimeoutMs !== void 0 ? { dynamicTimeoutMs: config.dynamicTimeoutMs } : {}
818
985
  };
819
986
  }
820
987
 
@@ -923,8 +1090,9 @@ function withoutTrailingComment(text) {
923
1090
  return text.replace(/\s*(?:\/\/|#|--\s).*$/, "").replace(/\s*\/\*.*\*\/\s*$/, "");
924
1091
  }
925
1092
  function hasExplanation(text) {
926
- const comment = /(?:\/\/|#(?!\!)|--|\/\*|\*)\s*(.+)$/.exec(text);
1093
+ const comment = /(?:\/\/|#(?!\!)|--(?=\s)|\/\*|\*)\s*(.+)$/.exec(text);
927
1094
  if (!comment) return false;
1095
+ if (/^proctor-ignore\s*:/.test(comment[1].trim())) return false;
928
1096
  const words = comment[1].replace(/\*\/\s*$/, "").trim();
929
1097
  if (/^(?:todo|fixme|xxx|hack|noqa|nosec|eslint-disable\S*|prettier-ignore)\b\W*$/i.test(words)) return false;
930
1098
  return words.split(/\s+/).filter(Boolean).length >= 3;
@@ -956,6 +1124,155 @@ function codeLines(chunk) {
956
1124
  return added.filter((line, i) => !templated.has(i) && !isCommentLine(line.text));
957
1125
  }
958
1126
 
1127
+ // src/verifiers/structure.ts
1128
+ function maskCode(text, pythonOrRuby = false, preserveDelimiters = false) {
1129
+ const out = text.split("");
1130
+ let quote = "";
1131
+ let block = false;
1132
+ let line = false;
1133
+ let previous = "";
1134
+ for (let i = 0; i < text.length; i++) {
1135
+ const ch = text[i];
1136
+ const next = text[i + 1];
1137
+ if (ch === "\n") {
1138
+ line = false;
1139
+ continue;
1140
+ }
1141
+ if (line) {
1142
+ out[i] = " ";
1143
+ continue;
1144
+ }
1145
+ if (block) {
1146
+ out[i] = " ";
1147
+ if (ch === "*" && next === "/") {
1148
+ out[++i] = " ";
1149
+ block = false;
1150
+ }
1151
+ continue;
1152
+ }
1153
+ if (quote) {
1154
+ out[i] = " ";
1155
+ if (ch === "\\") {
1156
+ if (i + 1 < text.length) out[++i] = " ";
1157
+ } else if (text.startsWith(quote, i)) {
1158
+ for (let k = 0; k < quote.length; k++) out[i + k] = preserveDelimiters ? quote[k] : " ";
1159
+ i += quote.length - 1;
1160
+ quote = "";
1161
+ }
1162
+ continue;
1163
+ }
1164
+ if (ch === "/" && next === "*") {
1165
+ out[i] = out[++i] = " ";
1166
+ block = true;
1167
+ } else if (!pythonOrRuby && ch === "/" && next === "/" || pythonOrRuby && ch === "#") {
1168
+ out[i] = " ";
1169
+ line = true;
1170
+ } else if (ch === '"' || ch === "'" || ch === "`") {
1171
+ quote = pythonOrRuby && text.startsWith(ch.repeat(3), i) ? ch.repeat(3) : ch;
1172
+ out[i] = preserveDelimiters ? ch : " ";
1173
+ for (let k = 1; k < quote.length; k++) out[i + k] = preserveDelimiters ? ch : " ";
1174
+ i += quote.length - 1;
1175
+ previous = "v";
1176
+ } else if (!pythonOrRuby && ch === "/" && /^(?:$|[(,=:[!&|?{};+*%~^<>-])$/.test(previous)) {
1177
+ let end = i + 1;
1178
+ let inClass = false;
1179
+ for (; end < text.length && text[end] !== "\n"; end++) {
1180
+ if (text[end] === "\\") end++;
1181
+ else if (text[end] === "[") inClass = true;
1182
+ else if (text[end] === "]") inClass = false;
1183
+ else if (text[end] === "/" && !inClass) break;
1184
+ }
1185
+ if (text[end] === "/") {
1186
+ for (let cursor = i; cursor <= end; cursor++) out[cursor] = " ";
1187
+ i = end;
1188
+ previous = "v";
1189
+ } else previous = ch;
1190
+ } else if (!/\s/.test(ch)) previous = ch;
1191
+ }
1192
+ return out.join("");
1193
+ }
1194
+ function sourceView(chunk, side, pythonOrRuby = false) {
1195
+ const selected = chunk.changes.map((change, position) => ({ change, position })).filter(({ change }) => change.type !== (side === "after" ? "del" : "add"));
1196
+ const starts = [];
1197
+ let text = "";
1198
+ for (const { change } of selected) {
1199
+ starts.push(text.length);
1200
+ text += body(change.content) + "\n";
1201
+ }
1202
+ const indexAt = (offset) => {
1203
+ let lo = 0;
1204
+ let hi = starts.length;
1205
+ while (lo + 1 < hi) {
1206
+ const mid = lo + hi >>> 1;
1207
+ if (starts[mid] <= offset) lo = mid;
1208
+ else hi = mid;
1209
+ }
1210
+ return lo;
1211
+ };
1212
+ return {
1213
+ text,
1214
+ code: maskCode(text, pythonOrRuby),
1215
+ signatures: maskCode(text, pythonOrRuby, true),
1216
+ offsetAtLine: (line) => {
1217
+ const index = selected.findIndex(({ change }) => (change.type === "normal" ? side === "before" ? change.ln1 : change.ln2 : change.ln) === line);
1218
+ return index < 0 ? void 0 : starts[index];
1219
+ },
1220
+ lineAt: (offset) => {
1221
+ const change = selected[indexAt(offset)]?.change;
1222
+ return change ? change.type === "normal" ? side === "before" ? change.ln1 : change.ln2 : change.ln : chunk.newStart;
1223
+ },
1224
+ touched: (start, end) => {
1225
+ const first = selected[indexAt(start)];
1226
+ const last = selected[indexAt(end)];
1227
+ return !!first && !!last && chunk.changes.slice(first.position, last.position + 1).some((c) => c.type !== "normal");
1228
+ }
1229
+ };
1230
+ }
1231
+ function closingDelimiter(code, open, left = "{", right = "}") {
1232
+ let depth = 0;
1233
+ for (let i = open; i < code.length; i++) {
1234
+ if (code[i] === left) depth++;
1235
+ else if (code[i] === right && --depth === 0) return i;
1236
+ }
1237
+ return -1;
1238
+ }
1239
+ var compact = (text) => text.replace(/\s+/g, "");
1240
+ function callables(view) {
1241
+ const result = [];
1242
+ const header = /\b(?:function\s+|fn\s+|func\s+)([A-Za-z_$][\w$]*)\s*\([^{}]*\)[^{;]*\{/g;
1243
+ for (const match of view.code.matchAll(header)) {
1244
+ const start = match.index;
1245
+ const open = start + match[0].length - 1;
1246
+ const end = closingDelimiter(view.code, open);
1247
+ if (end < 0) continue;
1248
+ result.push({ name: match[1], header: compact(match[0]), start, open, end, body: view.code.slice(open + 1, end) });
1249
+ }
1250
+ return result;
1251
+ }
1252
+ function testBodies(view) {
1253
+ const result = [];
1254
+ for (const match of view.code.matchAll(/\b(?:it|test)(?:\.(?!each\b)\w+)*\s*\(/g)) {
1255
+ const openArgs = match.index + match[0].length - 1;
1256
+ const endArgs = closingDelimiter(view.code, openArgs, "(", ")");
1257
+ if (endArgs < 0) continue;
1258
+ const title = /^\s*(['"`])((?:\\.|[^\\])*?)\1/.exec(view.text.slice(openArgs + 1, endArgs));
1259
+ if (!title) continue;
1260
+ let depth = 0;
1261
+ let last = openArgs + 1;
1262
+ for (let i = openArgs + 1; i < endArgs; i++) {
1263
+ if ("({[".includes(view.code[i])) depth++;
1264
+ else if (")}]".includes(view.code[i])) depth--;
1265
+ else if (view.code[i] === "," && depth === 0) last = i + 1;
1266
+ }
1267
+ const callback = /^\s*(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>\s*\{|^\s*(?:async\s+)?function\s*\([^)]*\)\s*\{/.exec(view.code.slice(last, endArgs));
1268
+ if (!callback) continue;
1269
+ const open = last + callback[0].length - 1;
1270
+ const end = closingDelimiter(view.code, open);
1271
+ if (end >= 0) result.push({ key: title[2], open, end, body: view.code.slice(open + 1, end) });
1272
+ }
1273
+ return result;
1274
+ }
1275
+
959
1276
  // src/verifiers/rh001.ts
960
1277
  var JS_TS_DEL = /^-\s*(?:it|test|describe)(?:\.\w+)?\s*\(/;
961
1278
  var PY_DEL = /^-\s*(?:async\s+)?def test_/;
@@ -1032,6 +1349,28 @@ function testsRelocated(file, files, ctx) {
1032
1349
  if (relocated.size !== removed.size) return false;
1033
1350
  return assertionsGained >= assertionCount(file, "del");
1034
1351
  }
1352
+ function removedFeature(context, testPath, testCode) {
1353
+ const unit = (path9) => (path9.split("/").pop() ?? "").replace(/\.(?:test|spec)\.[^.]+$|\.[^.]+$/, "").replace(/^test_/, "").replace(/_test$/, "").replace(/^_+/, "");
1354
+ const declaration = /\b(?:function|def|class)\s+([A-Za-z_$][\w$]*)/g;
1355
+ for (const candidate of context.files) {
1356
+ const path9 = candidate.from ?? candidate.to ?? "";
1357
+ if (context.isTestFile(path9) || unit(path9) !== unit(testPath)) continue;
1358
+ const before = candidate.chunks.map((chunk) => sourceView(chunk, "before", path9.endsWith(".py")));
1359
+ const after = candidate.chunks.map((chunk) => sourceView(chunk, "after", path9.endsWith(".py")));
1360
+ for (const view of before) for (const match of view.code.matchAll(declaration)) {
1361
+ const name = match[1];
1362
+ if (!view.touched(match.index, match.index + match[0].length)) continue;
1363
+ if (after.some((next) => [...next.code.matchAll(/\b(?:function|def|class|const|let|var)\s+([A-Za-z_$][\w$]*)/g)].some((declaration2) => declaration2[1] === name))) continue;
1364
+ for (const reference of testCode.matchAll(/[A-Za-z_$][\w$]*/g)) {
1365
+ if (reference[0] !== name) continue;
1366
+ let next = reference.index + reference[0].length;
1367
+ while (/\s/.test(testCode[next] ?? "") && next < testCode.length) next++;
1368
+ if (testCode[reference.index - 1] === "." || testCode[next] === "(") return true;
1369
+ }
1370
+ }
1371
+ }
1372
+ return false;
1373
+ }
1035
1374
  function run(context) {
1036
1375
  const files = context.files;
1037
1376
  const ctx = context;
@@ -1082,6 +1421,28 @@ function run(context) {
1082
1421
  if (change.type !== "del") continue;
1083
1422
  if (templateLines.has(changeIndex)) continue;
1084
1423
  if (hasReconcilingAdd) continue;
1424
+ const before = sourceView(chunk, "before", from.endsWith(".py"));
1425
+ const start = before.offsetAtLine(change.ln);
1426
+ if (start !== void 0) {
1427
+ let end = before.code.length;
1428
+ if (from.endsWith(".py")) {
1429
+ const indent = /^\s*/.exec(change.content.slice(1))[0].length;
1430
+ const nextLine = before.code.indexOf("\n", start);
1431
+ const rest = before.code.slice(nextLine + 1);
1432
+ const boundary = /^( *)(\S)/gm;
1433
+ for (const match of rest.matchAll(boundary)) if (match[1].length <= indent) {
1434
+ end = nextLine + 1 + match.index;
1435
+ break;
1436
+ }
1437
+ } else {
1438
+ const open = before.code.indexOf("(", start);
1439
+ if (open >= 0) {
1440
+ const close = closingDelimiter(before.code, open, "(", ")");
1441
+ if (close >= 0) end = close + 1;
1442
+ }
1443
+ }
1444
+ if (removedFeature(ctx, from, before.code.slice(start, end))) continue;
1445
+ }
1085
1446
  if (JS_TS_DEL.test(change.content)) {
1086
1447
  const name = extractTestName(change.content);
1087
1448
  findings.push({
@@ -1196,7 +1557,7 @@ var WEAK_PATTERNS = [
1196
1557
  ];
1197
1558
  var ORDERING_WEAK = /\.toBe(?:GreaterThan|LessThan)(?:OrEqual)?\(/;
1198
1559
  var SOLE_ANYTHING = /\.(?:toBe|toEqual|toStrictEqual)\(\s*expect\.anything\(\)\s*\)/;
1199
- var CONTAINMENT_WEAK = /\.(?:toContain|toContainEqual|toMatch|toHaveProperty)\(/;
1560
+ var CONTAINMENT_WEAK = /\.(?:toContain|toContainEqual|toMatch|toHaveProperty|toBeCloseTo)\(/;
1200
1561
  var GROUP_B_STRONG = [
1201
1562
  { ext: /\.(?:pl|pm|t)$/i, re: /\bis(?:_deeply)?\s*\(/ },
1202
1563
  { ext: /\.r$/i, re: /\bexpect_(?:equal|identical)\s*\(/ },
@@ -1581,6 +1942,29 @@ function run2(context) {
1581
1942
  for (const file of files) {
1582
1943
  const filePath = file.to ?? file.from ?? "";
1583
1944
  for (const chunk of file.chunks) {
1945
+ if (context.isTestFile(filePath) && /\.[cm]?[jt]sx?$/.test(filePath)) {
1946
+ const before = sourceView(chunk, "before");
1947
+ const after = sourceView(chunk, "after");
1948
+ const priorTests = testBodies(before);
1949
+ for (const next of testBodies(after)) {
1950
+ const prior = priorTests.find((test) => test.key === next.key);
1951
+ if (!prior || !after.touched(next.open, next.end)) continue;
1952
+ for (const assertion of prior.body.matchAll(/\bexpect\s*\(\s*([A-Za-z_$][\w$]*)\s*\)\s*\.(?:toBe|toEqual|toStrictEqual|toBeCloseTo)\s*\(/g)) {
1953
+ const subject = assertion[1];
1954
+ const computesSubject = (code) => [...code.matchAll(/\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+)/g)].some((binding) => binding[1] === subject && /[^\s(]+\s*\(/.test(binding[2]));
1955
+ if (!computesSubject(prior.body) || !computesSubject(next.body)) continue;
1956
+ if ([...next.body.matchAll(/[A-Za-z_$][\w$]*/g)].filter((token) => token[0] === subject).length !== 1) continue;
1957
+ findings.push({
1958
+ verifierId: "RH002",
1959
+ severity: "error",
1960
+ file: filePath,
1961
+ line: after.lineAt(next.open),
1962
+ message: "A computed test result is still assigned, but its value assertion was removed and the value is no longer used.",
1963
+ suggestion: "Restore the result assertion or replace it with an equivalent check of that value."
1964
+ });
1965
+ }
1966
+ }
1967
+ }
1584
1968
  const dels = chunk.changes.filter((c) => c.type === "del");
1585
1969
  const adds = chunk.changes.filter((c) => c.type === "add");
1586
1970
  const at = new Map(chunk.changes.map((c, i) => [c, i]));
@@ -1632,6 +2016,41 @@ function run2(context) {
1632
2016
  suggestion: "Restore the specific value assertion to preserve test coverage strength."
1633
2017
  });
1634
2018
  }
2019
+ if (context.isTestFile(filePath)) for (const added of adds) {
2020
+ if (reported.has(added.ln) || hasExplanation(added.content)) continue;
2021
+ const next = body(added.content);
2022
+ const nextSubject = extractSubject(next);
2023
+ const prior = dels.find((del) => {
2024
+ if (!nearby(del, added)) return false;
2025
+ const old = body(del.content);
2026
+ const subject = extractSubject(old);
2027
+ if (subject && subject === nextSubject) {
2028
+ const a = /\.toBeCloseTo\(\s*([^,]+),\s*(\d+)\s*\)/.exec(old);
2029
+ const b = /\.toBeCloseTo\(\s*([^,]+),\s*(\d+)\s*\)/.exec(next);
2030
+ if (a && b && compact(a[1]) === compact(b[1]) && Number(b[2]) < Number(a[2])) return true;
2031
+ }
2032
+ if (subject && isStrongAssertion(old, filePath) && /\.toHaveBeenCalled(?:Times|With)?\(/.test(next)) {
2033
+ return !chunk.changes.some((c) => c.type !== "del" && extractSubject(c.content) === subject && isStrongAssertion(c.content, filePath));
2034
+ }
2035
+ if (filePath.endsWith(".py")) {
2036
+ const pattern = /\bassert\s+(.+?)==\s*pytest\.approx\(\s*([^,]+),\s*(abs|rel)\s*=\s*([0-9.eE+-]+)\s*\)/;
2037
+ const a = pattern.exec(old);
2038
+ const b = pattern.exec(next);
2039
+ return !!a && !!b && compact(a[1]) === compact(b[1]) && compact(a[2]) === compact(b[2]) && a[3] === b[3] && Number(b[4]) > Number(a[4]);
2040
+ }
2041
+ return false;
2042
+ });
2043
+ if (!prior) continue;
2044
+ reported.add(added.ln);
2045
+ findings.push({
2046
+ verifierId: "RH002",
2047
+ severity: "error",
2048
+ file: filePath,
2049
+ line: added.ln,
2050
+ message: "A result assertion was replaced by a wider tolerance or an assertion on mock interactions.",
2051
+ suggestion: "Preserve the result contract and its precision, or document the specification change."
2052
+ });
2053
+ }
1635
2054
  for (const del of dels) {
1636
2055
  if (!ALMOST_EQUAL.test(del.content)) continue;
1637
2056
  const delPlaces = del.content.match(/places\s*=\s*(\d+)/);
@@ -2293,6 +2712,22 @@ function isSkipPattern(content, flags) {
2293
2712
  return JS_PATTERNS.some((re) => re.test(content));
2294
2713
  }
2295
2714
  }
2715
+ function skipIdentity(text) {
2716
+ const match = /\b((?:it|test|describe)(?:\.\w+)*)\s*\(\s*(['"`])((?:\\.|[^\\])*?)\2/.exec(text);
2717
+ return match ? match[1] + ":" + match[3] : void 0;
2718
+ }
2719
+ function nativeReason(text) {
2720
+ const match = /\breason\s*=\s*(['"])/.exec(text);
2721
+ if (!match) return false;
2722
+ const start = match.index + match[0].length;
2723
+ let end = start;
2724
+ for (; end < text.length; end++) {
2725
+ if (text[end] === "\\") end++;
2726
+ else if (text[end] === match[1]) break;
2727
+ }
2728
+ const reason = text.slice(start, end).trim();
2729
+ return reason.split(/\s+/).length >= 3 || /^https?:\/\/\S+/.test(reason);
2730
+ }
2296
2731
  function run3(context) {
2297
2732
  const files = context.files;
2298
2733
  const ctx = context;
@@ -2313,10 +2748,77 @@ function run3(context) {
2313
2748
  const luaTestFile = ext === "lua" && isLuaTestFile(filePath);
2314
2749
  const batsFile = ext === "bats" && isBatsFile(filePath);
2315
2750
  if (!isPython && !isNewLang && !ctx.isTestFile(filePath)) continue;
2751
+ const previousSkips = new Set(file.chunks.flatMap((chunk) => chunk.changes).filter((c) => c.type === "del" && isSkipPattern(c.content, { ext, pyTestModule, goTestFile, rubyTestFile, kotlinTestFile, cTestFile, scalaTestFile, rTestFile, haskellTestFile, luaTestFile, batsFile })).map((c) => skipIdentity(c.content)).filter(Boolean));
2316
2752
  for (const chunk of file.chunks) {
2753
+ const lines = afterLines(chunk);
2754
+ const view = sourceView(chunk, "after", isPython);
2755
+ const signatureLines = view.signatures.split("\n");
2756
+ const maskedLines = view.code.split("\n");
2757
+ if (ctx.isTestFile(filePath) && !isPython && chunk.changes.some((change) => change.type === "add" && body(change.content).includes("/*"))) {
2758
+ const before = sourceView(chunk, "before");
2759
+ const surviving = new Set(testBodies(view).map((test) => test.key));
2760
+ for (const prior of testBodies(before)) {
2761
+ if (surviving.has(prior.key)) continue;
2762
+ const oldLine = before.lineAt(prior.open);
2763
+ const unchanged = chunk.changes.find((change) => change.type === "normal" && change.ln1 === oldLine);
2764
+ if (!unchanged || unchanged.type !== "normal") continue;
2765
+ const offset = view.offsetAtLine(unchanged.ln2);
2766
+ if (offset === void 0) continue;
2767
+ const end = view.code.indexOf("\n", offset);
2768
+ if (view.code.slice(offset, end < 0 ? void 0 : end).trim()) continue;
2769
+ findings.push({
2770
+ verifierId: "RH003",
2771
+ severity: "error",
2772
+ file: filePath,
2773
+ line: unchanged.ln2,
2774
+ message: "An existing named test is now inside a block comment and no longer executes.",
2775
+ suggestion: "Restore the executable test or remove the feature and its tests deliberately."
2776
+ });
2777
+ }
2778
+ }
2779
+ if (ctx.isTestFile(filePath)) {
2780
+ const after = sourceView(chunk, "after", isPython);
2781
+ for (const match of after.code.matchAll(/\bif\s*\([^\n;{}]*\)\s*(?:\{\s*)?return\s*;|^([ \t]*)if\s+[^:\n]+:\s*return\s*$/gm)) {
2782
+ if (!after.touched(match.index, match.index + match[0].length)) continue;
2783
+ const offset = match.index;
2784
+ const inTest = isPython ? /\bdef\s+test_\w+\s*\(/.test(after.code.slice(0, offset)) : testBodies(after).some((test) => offset > test.open && offset < test.end && !/=>|\bfunction\b/.test(after.code.slice(test.open + 1, offset)));
2785
+ if (!inTest) continue;
2786
+ const line = after.lineAt(offset);
2787
+ const raw = after.text.slice(match.index, match.index + match[0].length);
2788
+ if (hasExplanation(raw)) continue;
2789
+ const later = after.code.slice(match.index + match[0].length);
2790
+ if (!/\bexpect\s*\(|\bassert\b/.test(later)) continue;
2791
+ findings.push({
2792
+ verifierId: "RH003",
2793
+ severity: "error",
2794
+ file: filePath,
2795
+ line,
2796
+ message: "A new conditional return can bypass the assertions in a test that still reports success.",
2797
+ suggestion: "Run the assertions on every supported path, or document and report an explicit test skip."
2798
+ });
2799
+ }
2800
+ }
2317
2801
  for (const change of chunk.changes) {
2318
2802
  if (change.type !== "add") continue;
2319
- if (!isSkipPattern(change.content, {
2803
+ const text = body(change.content);
2804
+ const identity = skipIdentity(text);
2805
+ if (identity && previousSkips.has(identity)) continue;
2806
+ const index = lines.findIndex((line) => line.line === change.ln && line.added);
2807
+ const offset = lines.slice(0, index).reduce((sum, line) => sum + line.text.length + 1, 0);
2808
+ const open = view.code.indexOf("(", offset);
2809
+ const end = open < 0 ? -1 : closingDelimiter(view.code, open, "(", ")");
2810
+ const invocation = end < 0 ? text : view.text.slice(offset, end + 1);
2811
+ const previousComment = index > 0 ? lines[index - 1].text : "";
2812
+ if (!/\.only\b|\[\s*['"]only['"]\s*\]|\b(?:fit|fdescribe)\s*\(/.test(text) && (nativeReason(invocation) || /^\s*(?:\/\/|#)/.test(previousComment) && /\breason\s*:/i.test(previousComment) && hasExplanation(previousComment))) continue;
2813
+ if (!/\.only\b|\[\s*['"]only['"]\s*\]|\b(?:fit|fdescribe)\s*\(/.test(text) && (/\breason\s*:/i.test(text) && hasExplanation(text) || /\breason\s*=\s*['"][^'"]*\S+\s+\S+\s+\S+[^'"]*['"]/.test(text))) continue;
2814
+ const lexical = "+" + (signatureLines[index] ?? "");
2815
+ const masked = "+" + (maskedLines[index] ?? "");
2816
+ const bracketApi = [...change.content.matchAll(new RegExp(BRACKET_SKIP, "g"))].some((match) => {
2817
+ const root = /^(?:it|test|describe)/.exec(match[0])[0];
2818
+ return masked.slice(match.index, match.index + root.length) === root;
2819
+ });
2820
+ const commentedDeclaration = COMMENTED_JS_TEST.test(change.content) || COMMENTED_PY_TEST.test(change.content);
2821
+ if (!isSkipPattern(commentedDeclaration || bracketApi ? change.content : lexical, {
2320
2822
  ext,
2321
2823
  pyTestModule,
2322
2824
  goTestFile,
@@ -2408,10 +2910,86 @@ function extractLiterals(line) {
2408
2910
  }
2409
2911
  return result;
2410
2912
  }
2913
+ function assertionPairs(context) {
2914
+ const pairs = /* @__PURE__ */ new Map();
2915
+ const pattern = new RegExp(`expect\\(\\s*([A-Za-z_$][\\w$]*)\\(\\s*(${LITERAL_TOKEN})\\s*\\)\\s*\\)\\.(?:toBe|toEqual|toStrictEqual)\\(\\s*(${LITERAL_TOKEN})\\s*\\)`, "g");
2916
+ for (const file of context.files) {
2917
+ if (!context.isTestFile(pathOf(file))) continue;
2918
+ for (const chunk of file.chunks) {
2919
+ const view = sourceView(chunk, "after");
2920
+ for (const match of view.text.matchAll(pattern)) {
2921
+ if (!view.code.slice(match.index, match.index + 6).startsWith("expect")) continue;
2922
+ const set = pairs.get(match[1]) ?? /* @__PURE__ */ new Set();
2923
+ set.add(match[2].trim() + ":" + match[3].trim());
2924
+ pairs.set(match[1], set);
2925
+ }
2926
+ }
2927
+ }
2928
+ return pairs;
2929
+ }
2930
+ function structuralHardcodes(context) {
2931
+ const findings = [];
2932
+ const pairs = assertionPairs(context);
2933
+ if (!pairs.size) return findings;
2934
+ for (const file of context.files) {
2935
+ const filePath = pathOf(file);
2936
+ if (!isWatchedSource(context, filePath)) continue;
2937
+ for (const chunk of file.chunks) {
2938
+ const before = sourceView(chunk, "before");
2939
+ const after = sourceView(chunk, "after");
2940
+ const oldFunctions = callables(before);
2941
+ for (const fn of callables(after)) {
2942
+ const evidence = pairs.get(fn.name);
2943
+ const old = oldFunctions.find((candidate) => candidate.header === fn.header);
2944
+ if (!evidence || !old) continue;
2945
+ const raw = after.text.slice(fn.open + 1, fn.end);
2946
+ const branch = new RegExp(`\\bif\\s*\\(\\s*[A-Za-z_$][\\w$]*\\s*===?\\s*(${LITERAL_TOKEN})\\s*\\)\\s*\\{\\s*return\\s+(${LITERAL_TOKEN})\\s*;?\\s*\\}`, "g");
2947
+ for (const match of raw.matchAll(branch)) {
2948
+ if (!evidence.has(match[1].trim() + ":" + match[2].trim())) continue;
2949
+ const offset = fn.open + 1 + match.index;
2950
+ if (!after.code.slice(offset, offset + 2).startsWith("if")) continue;
2951
+ if (!after.touched(offset, offset + match[0].length)) continue;
2952
+ const open = after.code.indexOf("{", offset);
2953
+ const close = closingDelimiter(after.code, open);
2954
+ const rest = after.code.slice(fn.open + 1, offset) + after.code.slice(close + 1, fn.end);
2955
+ if (compact(rest) !== compact(old.body)) continue;
2956
+ findings.push({
2957
+ verifierId: "RH004",
2958
+ severity: "error",
2959
+ file: filePath,
2960
+ line: after.lineAt(offset),
2961
+ message: "A literal-input branch returns the matching test expectation while leaving the general computation unchanged.",
2962
+ suggestion: "Implement the behavior generally rather than adding a branch for one asserted example."
2963
+ });
2964
+ }
2965
+ if (!/\breturn\s+[^;]+\(/.test(old.body) || /\breturn\s+[^;]+\(/.test(fn.body)) continue;
2966
+ const lookup = /\breturn\s+([A-Za-z_$][\w$]*)\s*\[\s*([A-Za-z_$][\w$]*)\s*\]/.exec(fn.body);
2967
+ if (!lookup) continue;
2968
+ const table = [...fn.body.matchAll(/\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*\{/g)].find((binding) => binding[1] === lookup[1]);
2969
+ if (!table) continue;
2970
+ const literalEntry = new RegExp(`\\[?\\s*(${LITERAL_TOKEN})\\s*\\]?\\s*:\\s*(${LITERAL_TOKEN})`, "g");
2971
+ const tableOpen = fn.open + 1 + table.index + table[0].length - 1;
2972
+ const tableEnd = closingDelimiter(after.code, tableOpen);
2973
+ if (tableEnd < 0) continue;
2974
+ const entries = after.text.slice(tableOpen + 1, tableEnd);
2975
+ if (![...entries.matchAll(literalEntry)].some((entry) => evidence.has(entry[1].trim() + ":" + entry[2].trim()))) continue;
2976
+ findings.push({
2977
+ verifierId: "RH004",
2978
+ severity: "error",
2979
+ file: filePath,
2980
+ line: after.lineAt(fn.open),
2981
+ message: "A computed return was replaced by a lookup containing the same literal input and output as a test assertion.",
2982
+ suggestion: "Restore the computation. A finite domain table needs a stated specification and complete domain coverage."
2983
+ });
2984
+ }
2985
+ }
2986
+ }
2987
+ return findings;
2988
+ }
2411
2989
  async function run4(context) {
2412
2990
  const files = context.files;
2413
2991
  const ctx = context;
2414
- const findings = [];
2992
+ const findings = structuralHardcodes(context);
2415
2993
  const fuzzyCandidates = [];
2416
2994
  for (const file of files) {
2417
2995
  const filePath = file.to ?? file.from ?? "";
@@ -2455,6 +3033,7 @@ async function run4(context) {
2455
3033
  const branchMatch = add.content.match(BRANCH_LITERAL_RE) ?? add.content.match(PY_BRANCH_LITERAL_RE);
2456
3034
  if (!branchMatch) continue;
2457
3035
  if (EMPTINESS_GUARD_RE.test(add.content) || TYPEOF_GUARD_RE.test(add.content)) continue;
3036
+ if (/^(['"`])[^\w\s]\1$/.test(branchMatch[1])) continue;
2458
3037
  findings.push({
2459
3038
  verifierId: "RH004",
2460
3039
  severity: "error",
@@ -2619,6 +3198,22 @@ async function run5(context) {
2619
3198
  const adds = chunk.changes.filter((c) => c.type === "add" && !isMoved(moved, c.content));
2620
3199
  const near = (a, b) => Math.abs((at.get(a) ?? 0) - (at.get(b) ?? 0)) <= PAIRING_DISTANCE;
2621
3200
  if (!isTest) {
3201
+ const beforeView = sourceView(chunk, "before");
3202
+ const afterView = sourceView(chunk, "after");
3203
+ const previous = callables(beforeView);
3204
+ for (const fn of callables(afterView)) {
3205
+ if (fn.body.trim() || !afterView.touched(fn.start, fn.end)) continue;
3206
+ const prior = previous.find((old) => old.header === fn.header);
3207
+ if (!prior || !/\b(?:return|await)\b|\w\s*\(/.test(prior.body)) continue;
3208
+ findings.push({
3209
+ verifierId: "RH005",
3210
+ severity: "error",
3211
+ file: filePath,
3212
+ line: afterView.lineAt(fn.open),
3213
+ message: "An existing function lost its executable body and is now empty.",
3214
+ suggestion: "Restore the implementation or remove the feature and its callers deliberately."
3215
+ });
3216
+ }
2622
3217
  const shrank = adds.length <= dels.length;
2623
3218
  for (const add of adds) {
2624
3219
  if (!isGuttedAdd(add.content)) continue;
@@ -2729,6 +3324,7 @@ var rh006 = { id: "RH006", severity: "warn", run: run6 };
2729
3324
 
2730
3325
  // src/verifiers/rh007.ts
2731
3326
  import path from "path";
3327
+ import picomatch4 from "picomatch";
2732
3328
  var CONFIG_FILE_RE = /(?:jest|vitest|vite)\.config\.(?:[mc]?[jt]s|json)$|(?:^|\/)jest\.config\.json$|tsconfig(?:\.[^/]*)?\.json$|(?:pytest\.ini|setup\.cfg|pyproject\.toml|conftest\.py)$|(?:^|\/)pom\.xml$|(?:^|\/)build\.gradle(?:\.kts)?$|(?:^|\/)Cargo\.toml$|(?:^|\/)\.rspec$|(?:^|\/)phpunit\.xml(?:\.dist)?$|\.runsettings$|(?:^|\/)CMakeLists\.txt$|\.xctestplan$|(?:^|\/)dart_test\.yaml$|(?:^|\/)build\.sbt$|(?:^|\/)\.Rbuildignore$|\.cabal$|(?:^|\/)test_helper\.exs$|(?:^|\/)\.busted$|(?:^|\/)project\.clj$/;
2733
3329
  var PACKAGE_JSON_RE = /(?:^|\/)package\.json$/;
2734
3330
  var JEST_EXCLUSION_KEY_RE = /"(testPathIgnorePatterns)"\s*:/;
@@ -2803,8 +3399,8 @@ function narrowsSelection(changes, added) {
2803
3399
  if (!key) return false;
2804
3400
  const prior = changes.find((c) => c.type === "del" && c.content.includes(key));
2805
3401
  if (!prior) return false;
2806
- const kept = new Set(patternsIn(added.content));
2807
- return patternsIn(prior.content).some((p) => !kept.has(p));
3402
+ const kept = patternsIn(added.content);
3403
+ return patternsIn(prior.content).some((p) => !kept.some((next) => next === p || key === "testMatch" && !/[?*{[]/.test(p) && picomatch4(next)(p) || key === "testMatch" && next.startsWith("**/") && picomatch4(next.slice(3))(p.split("/").pop() ?? p)));
2808
3404
  }
2809
3405
  var EXCLUSION_PATTERNS = [
2810
3406
  { re: /testPathIgnorePatterns/, key: "testPathIgnorePatterns", severity: "error", langs: ["js"] },
@@ -2995,6 +3591,16 @@ function run7(context) {
2995
3591
  const testSuiteNameMatch = chunk.changes.map((c) => c.content.match(/\btest-suite\s+([A-Za-z0-9_-]+)/i)).find((m) => m !== null);
2996
3592
  for (const change of chunk.changes) {
2997
3593
  if (change.type !== "add") continue;
3594
+ if (lang === "js" && /\bpassWithNoTests\s*:\s*true\b|"passWithNoTests"\s*:\s*true\b/.test(change.content) && !chunk.changes.some((c) => c.type === "del" && /passWithNoTests["']?\s*:\s*true\b/.test(c.content))) {
3595
+ findings.push({
3596
+ verifierId: "RH007",
3597
+ severity: "error",
3598
+ file: filePath,
3599
+ line: change.ln,
3600
+ message: "Test discovery now succeeds when it finds no tests.",
3601
+ suggestion: "Keep empty test discovery failing so an accidentally detached suite cannot report success."
3602
+ });
3603
+ }
2998
3604
  const matched = matchExclusion(change.content, lang);
2999
3605
  if (!matched) continue;
3000
3606
  const { pattern, afterMatch } = matched;
@@ -3403,7 +4009,9 @@ function isJustified(content) {
3403
4009
  (m) => m !== null
3404
4010
  );
3405
4011
  if (!match) return false;
3406
- return JUSTIFICATION_RE.test(content.slice(match.index + match[0].length));
4012
+ const tail = content.slice(match.index + match[0].length);
4013
+ if (/^\s*(?:\/\/|\/\*)\s*@ts-(?:expect-error|ignore)\b/.test(body(content)) && tail.trim().split(/\s+/).filter(Boolean).length >= 3) return true;
4014
+ return JUSTIFICATION_RE.test(tail);
3407
4015
  }
3408
4016
  function isFilewideSuppression(content) {
3409
4017
  return FILEWIDE_ESLINT_DISABLE_RE.test(content) || FILEWIDE_FLAKE8_NOQA_RE.test(content) || FILEWIDE_TS_NOCHECK_RE.test(content) || FILEWIDE_MYPY_RE.test(content) || FILEWIDE_RUST_ALLOW_RE.test(content) || FILEWIDE_KOTLIN_SUPPRESS_RE.test(content) || FILEWIDE_PHPCS_IGNOREFILE_RE.test(content) || FILEWIDE_SWIFTLINT_ALL_RE.test(content) || FILEWIDE_DART_IGNOREFILE_RE.test(content) || FILEWIDE_HLINT_RE.test(content) || FILEWIDE_CREDO_FILE_RE.test(content);
@@ -3428,8 +4036,20 @@ function run11(context) {
3428
4036
  if (DOC_FILE_RE.test(filePath)) continue;
3429
4037
  if (VENDORED_PATH_RE.test(filePath.replace(/\\/g, "/"))) continue;
3430
4038
  const occurrences = [];
4039
+ const identity = (text) => body(text).trim().replace(/\s*(?:\/\/|#)\s+(?:for|because|reason:)\b.*$/, "").trim();
4040
+ const removed = /* @__PURE__ */ new Map();
4041
+ for (const change of file.chunks.flatMap((chunk) => chunk.changes).filter((c) => c.type === "del")) {
4042
+ const key = identity(change.content);
4043
+ removed.set(key, (removed.get(key) ?? 0) + 1);
4044
+ }
3431
4045
  for (const chunk of file.chunks) {
3432
4046
  for (const add of chunk.changes.filter((c) => c.type === "add")) {
4047
+ const key = identity(add.content);
4048
+ const remaining = removed.get(key) ?? 0;
4049
+ if (remaining > 0) {
4050
+ removed.set(key, remaining - 1);
4051
+ continue;
4052
+ }
3433
4053
  const line = add.ln;
3434
4054
  if (filewideInCode(add.content)) {
3435
4055
  findings.push({
@@ -3533,10 +4153,13 @@ var COMMAND_KEYS = /* @__PURE__ */ new Set(["run", "script", "command", "command
3533
4153
  function isCommandLine(text) {
3534
4154
  if (/^\s*#/.test(text)) return false;
3535
4155
  const key = /^[ \t]*(?:-[ \t]*)?([A-Za-z_][\w.-]*)[ \t]*:/.exec(text);
4156
+ if (/^[ \t]*(?:-[ \t]*)?\{/.test(text)) return false;
3536
4157
  return key === null || COMMAND_KEYS.has(key[1].toLowerCase());
3537
4158
  }
3538
4159
  function commandKey(content) {
3539
- return stripDiffPrefix2(content).replace(/^-\s*/, "").replace(/^(?:run|script|command)\s*:\s*/, "").replace(/['"]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
4160
+ const command = stripDiffPrefix2(content);
4161
+ const invocation = TEST_COMMAND_RE.exec(command);
4162
+ return (invocation ? command.slice(invocation.index) : command).replace(/^-\s*/, "").replace(/^(?:run|script|command)\s*:\s*/, "").replace(/['"]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
3540
4163
  }
3541
4164
  var RETRY_ACTION_RE = /uses\s*:\s*[\w-]+\/(?:retry|action-retry|retry-action)@/i;
3542
4165
  var RETRY_ATTEMPTS_RE = /max_attempts\s*:\s*([2-9]|\d{2,})/;
@@ -3740,45 +4363,83 @@ function withoutLeadingSlice(text) {
3740
4363
  function withoutFilter(text) {
3741
4364
  return withoutTrailingComment(text).replace(/\.filter\([^)]*\)/, "").replace(/\s+/g, " ").trim();
3742
4365
  }
3743
- var PARAMETERIZED_RUNNER_RE = /\b(?:it|test)\.each\s*\(|pytest\.mark\.parametrize\b/;
3744
- var TABLE_ROW_RE = /^\s*\[[^\]]+\],?\s*$/;
3745
4366
  var LOOP_BOUND_RE = /\bfor\s*\([^;]*;\s*([A-Za-z_$][\w$]*)\s*<\s*(\d+)\s*;/;
3746
- function inlineRowCount(text) {
3747
- return [...withoutTrailingComment(text).matchAll(/\[[^\[\]]+\]/g)].length;
4367
+ function elements(view, open, end) {
4368
+ let depth = 0;
4369
+ let start = open + 1;
4370
+ let count = 0;
4371
+ const present = (from, to) => !!view.code.slice(from, to).trim() || /^\s*['"`]/.test(view.text.slice(from, to));
4372
+ for (let i = start; i < end; i++) {
4373
+ const ch = view.code[i];
4374
+ if ("[{(".includes(ch)) depth++;
4375
+ else if ("]})".includes(ch)) depth--;
4376
+ else if (ch === "," && depth === 0) {
4377
+ if (present(start, i)) count++;
4378
+ start = i + 1;
4379
+ }
4380
+ }
4381
+ return count + Number(present(start, end));
4382
+ }
4383
+ function parameterTables(view) {
4384
+ const tables = [];
4385
+ const runner = /\b(?:it|test)\.each\s*\(|\bpytest\.mark\.parametrize\s*\(/g;
4386
+ for (const match of view.code.matchAll(runner)) {
4387
+ const args = match.index + match[0].length - 1;
4388
+ const argsEnd = closingDelimiter(view.code, args, "(", ")");
4389
+ if (argsEnd < 0) continue;
4390
+ let open = args + 1;
4391
+ while (/\s/.test(view.code[open] ?? "") && open < argsEnd) open++;
4392
+ let key = "";
4393
+ if (match[0].includes("parametrize")) {
4394
+ const comma = view.code.indexOf(",", open);
4395
+ if (comma < 0 || comma >= argsEnd) continue;
4396
+ key = "python:" + view.text.slice(open, comma).replace(/\s+/g, "");
4397
+ if (key === "python:") continue;
4398
+ open = comma + 1;
4399
+ while (/\s/.test(view.code[open] ?? "") && open < argsEnd) open++;
4400
+ } else {
4401
+ const reference = /^[A-Za-z_$][\w$]*/.exec(view.code.slice(open, argsEnd));
4402
+ if (reference) {
4403
+ key = "binding:" + reference[0];
4404
+ const binding = [...view.code.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*\[/g)].find((binding2) => binding2[1] === reference[0]);
4405
+ if (!binding) continue;
4406
+ open = binding.index + binding[0].length - 1;
4407
+ } else {
4408
+ const title = /^\s*\(\s*(['"`])([^'"`]+)\1/.exec(view.text.slice(argsEnd + 1));
4409
+ if (!title) continue;
4410
+ key = "inline:" + title[2];
4411
+ }
4412
+ }
4413
+ const left = view.code[open];
4414
+ if (left !== "[" && left !== "(") continue;
4415
+ const end = closingDelimiter(view.code, open, left, left === "[" ? "]" : ")");
4416
+ if (end < 0) continue;
4417
+ tables.push({ key, count: elements(view, open, end), start: open, end });
4418
+ }
4419
+ return tables;
3748
4420
  }
3749
4421
  function run14(context) {
3750
4422
  const findings = [];
3751
4423
  for (const file of context.files) {
3752
4424
  const filePath = pathOf(file);
3753
4425
  if (!context.isTestFile(filePath)) continue;
3754
- const allChanges = file.chunks.flatMap((chunk) => chunk.changes);
3755
- const parameterized = allChanges.some((change) => PARAMETERIZED_RUNNER_RE.test(change.content));
3756
- if (parameterized) {
3757
- const removedRows = file.chunks.flatMap(deletedLines).filter((line) => TABLE_ROW_RE.test(line.text));
3758
- const addedRows = file.chunks.flatMap(addedLines).filter((line) => TABLE_ROW_RE.test(line.text));
3759
- const netRemoved = removedRows.filter((row) => !addedRows.some((next) => next.text.trim() === row.text.trim()));
3760
- if (netRemoved.length > addedRows.filter((row) => !removedRows.some((prior) => prior.text.trim() === row.text.trim())).length) {
3761
- findings.push({
3762
- verifierId: "RH014",
3763
- severity: "error",
3764
- file: filePath,
3765
- line: netRemoved[0].line,
3766
- message: "Test workload reduced: a row was removed from a parameterized test table while the test itself still runs.",
3767
- suggestion: "Restore the removed case and fix the behavior it exposed."
3768
- });
3769
- }
3770
- for (const added of file.chunks.flatMap(addedLines)) {
3771
- const nextCount = inlineRowCount(added.text);
3772
- if (nextCount === 0) continue;
3773
- const prior = file.chunks.flatMap(deletedLines).find((line) => inlineRowCount(line.text) > nextCount);
3774
- if (!prior || hasExplanation(added.text)) continue;
4426
+ if (file.deleted) continue;
4427
+ for (const chunk of file.chunks) {
4428
+ const before = sourceView(chunk, "before", filePath.endsWith(".py"));
4429
+ const after = sourceView(chunk, "after", filePath.endsWith(".py"));
4430
+ const old = parameterTables(before);
4431
+ for (const next of parameterTables(after)) {
4432
+ const prior = old.find((table) => table.key === next.key);
4433
+ if (!prior || next.count >= prior.count || !after.touched(next.start, next.end)) continue;
4434
+ if (after.text.slice(next.start, next.end).split("\n").some(hasExplanation)) continue;
4435
+ const inline = !after.text.slice(next.start, next.end).includes("\n");
3775
4436
  findings.push({
3776
4437
  verifierId: "RH014",
3777
4438
  severity: "error",
3778
4439
  file: filePath,
3779
- line: added.line,
3780
- message: `Test workload reduced: an inline parameter table fell from ${inlineRowCount(prior.text)} cases to ${nextCount}.`,
3781
- suggestion: "Restore the removed inline cases and fix the behavior they expose."
4440
+ line: after.lineAt(next.start),
4441
+ message: inline ? `Test workload reduced: an inline parameter table fell from ${prior.count} cases to ${next.count}.` : "Test workload reduced: a row was removed from a parameterized test table while the test itself still runs.",
4442
+ suggestion: inline ? "Restore the removed inline cases and fix the behavior they expose." : "Restore the removed case and fix the behavior it exposed."
3782
4443
  });
3783
4444
  }
3784
4445
  }
@@ -3965,6 +4626,128 @@ function bareCall(text) {
3965
4626
  );
3966
4627
  return match?.[1]?.replace(/\s+/g, "");
3967
4628
  }
4629
+ function handlers(view, indented) {
4630
+ const result = [];
4631
+ const opener = indented ? /^([ \t]*)(?:except\b[^:\n]*:|rescue\b[^\n]*)/gm : /\bcatch\s*(?:\([^)]*\))?\s*\{|\bif\s+([A-Za-z_]\w*)\s*!=\s*nil\s*\{/g;
4632
+ for (const match of view.code.matchAll(opener)) {
4633
+ const start = match.index;
4634
+ const bodyStart = start + match[0].length;
4635
+ let end;
4636
+ if (!indented) end = closingDelimiter(view.code, bodyStart - 1);
4637
+ else {
4638
+ end = view.code.length;
4639
+ const indent = match[1].replace(/\n/g, "").length;
4640
+ const tail = view.code.slice(bodyStart);
4641
+ const boundary = /\n([ \t]*)(\S)/g;
4642
+ for (const line of tail.matchAll(boundary)) {
4643
+ if (line[1].length <= indent) {
4644
+ end = bodyStart + line.index + 1;
4645
+ break;
4646
+ }
4647
+ }
4648
+ }
4649
+ if (end < 0) continue;
4650
+ result.push({ start, end, key: compact(match[0]), code: view.code.slice(bodyStart, end), raw: view.text.slice(start, end + 1) });
4651
+ }
4652
+ if (!indented) for (const match of view.code.matchAll(/\.catch\s*\(\s*(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>\s*/g)) {
4653
+ const start = match.index;
4654
+ const bodyStart = start + match[0].length;
4655
+ const open = view.code.indexOf("(", start);
4656
+ const end = closingDelimiter(view.code, open, "(", ")");
4657
+ if (end < 0) continue;
4658
+ const code = view.code.slice(bodyStart, end).replace(/^\{([\s\S]*)\}$/, "$1");
4659
+ result.push({ start, end, key: compact(match[0]), code, raw: view.text.slice(start, end + 1) });
4660
+ }
4661
+ return result;
4662
+ }
4663
+ function discardedHandler(code) {
4664
+ let rest = code;
4665
+ const logging = /\b(?:(?:console|logger|logging|log|Console|System\.err)\.(?:error|warn|warning|info|debug|trace|log|Print|Printf|Println|WriteLine|println)|error_log)\s*\(/g;
4666
+ const ranges = [];
4667
+ for (const match of rest.matchAll(logging)) {
4668
+ const open = match.index + match[0].lastIndexOf("(");
4669
+ const end = closingDelimiter(rest, open, "(", ")");
4670
+ if (end < 0) return false;
4671
+ ranges.push([match.index, end + 1]);
4672
+ }
4673
+ for (const [start, end] of ranges.reverse()) rest = rest.slice(0, start) + rest.slice(end);
4674
+ return /^(?:\s|;|pass\b)*$/.test(rest);
4675
+ }
4676
+ function structuralSwallows(context) {
4677
+ const findings = [];
4678
+ for (const file of context.files) {
4679
+ const path9 = pathOf(file);
4680
+ if (!isWatchedSource(context, path9) || !/\.(?:[cm]?[jt]sx?|py|go|rs|java|rb|cs|php|sh|bash)$/.test(path9)) continue;
4681
+ const indented = /\.(?:py|rb)$/.test(path9);
4682
+ for (const chunk of file.chunks) {
4683
+ const before = sourceView(chunk, "before", indented);
4684
+ const after = sourceView(chunk, "after", indented);
4685
+ const prior = handlers(before, indented);
4686
+ for (const handler of handlers(after, indented)) {
4687
+ if (!after.touched(handler.start, handler.end) || !discardedHandler(handler.code)) continue;
4688
+ if (handler.raw.split("\n").some(hasExplanation)) continue;
4689
+ if (indented && /^[ \t]*\r?\n?[ \t]*else[ \t]*:/.test(after.text.slice(handler.end))) continue;
4690
+ const retainedOpener = chunk.changes.find((change) => change.type === "normal" && change.ln2 === after.lineAt(handler.start));
4691
+ const previous = retainedOpener?.type === "normal" ? prior.filter((old) => before.lineAt(old.start) === retainedOpener.ln1) : prior;
4692
+ if (previous.some((old) => old.key === handler.key && discardedHandler(old.code))) continue;
4693
+ findings.push({
4694
+ verifierId: "WI101",
4695
+ severity: "error",
4696
+ file: path9,
4697
+ line: after.lineAt(handler.start),
4698
+ message: "Error handling was added or changed to an empty or logging-only handler, so failures no longer propagate.",
4699
+ suggestion: "Return an error, retry, or propagate the failure. Document a specific, deliberate fallback."
4700
+ });
4701
+ }
4702
+ const removed = deletedLines(chunk);
4703
+ for (const added of addedLines(chunk)) {
4704
+ if (isCommentLine(added.text) || hasExplanation(added.text)) continue;
4705
+ let discarded = false;
4706
+ if (path9.endsWith(".rs")) {
4707
+ const operation = (text) => compact(text.replace(/^\s*(?:let\s+[^=]+\s*=|return\s+)/, "").replace(/\?\s*;?\s*$/, "").replace(/;\s*$/, ""));
4708
+ const lost = removed.filter((line) => /\?\s*;?\s*$/.test(line.text));
4709
+ const replacement = added.text.replace(/^\s*let\s+_\s*=\s*/, "").replace(/\.unwrap_or_default\(\)/, "");
4710
+ discarded = /\blet\s+_\s*=|\.unwrap_or_default\(\)/.test(added.text) && lost.some((line) => operation(line.text) === operation(replacement));
4711
+ } else if (path9.endsWith(".go")) {
4712
+ const assignment = /^\s*([^:=]+)(?::=|=)\s*(.+?)\s*;?\s*$/;
4713
+ const next = assignment.exec(added.text);
4714
+ if (next && next[1].split(",").some((binding) => binding.trim() === "_")) {
4715
+ discarded = removed.some((line) => {
4716
+ const old = assignment.exec(line.text);
4717
+ if (!old || compact(old[2]) !== compact(next[2])) return false;
4718
+ const names = old[1].split(",").map((binding) => binding.trim());
4719
+ const slots = next[1].split(",").map((binding) => binding.trim());
4720
+ return names.some((name, i) => slots[i] === "_" && name !== "_" && [...before.code.matchAll(/\bif\s+([A-Za-z_]\w*)\s*!=\s*nil\b/g)].some((check) => check[1] === name));
4721
+ });
4722
+ }
4723
+ } else if (/\.(?:sh|bash)$/.test(path9)) {
4724
+ const swallow = /\s*\|\|\s*(?:true|:|exit\s+0)\s*$/.exec(added.text);
4725
+ discarded = !!swallow && removed.some((line) => compact(line.text) === compact(added.text.slice(0, swallow.index)));
4726
+ }
4727
+ if (discarded) findings.push({
4728
+ verifierId: "WI101",
4729
+ severity: "error",
4730
+ file: path9,
4731
+ line: added.line,
4732
+ message: "A previously propagated operation failure is now discarded or replaced with a default.",
4733
+ suggestion: "Preserve the operation failure or handle it explicitly instead of converting it to success."
4734
+ });
4735
+ }
4736
+ }
4737
+ }
4738
+ return findings;
4739
+ }
4740
+ function followedByElse(lines, index) {
4741
+ if (index < 0) return false;
4742
+ const indent = (text) => text.length - text.trimStart().length;
4743
+ const base = indent(lines[index].text);
4744
+ for (const line of lines.slice(index + 1)) {
4745
+ if (!line.text.trim()) continue;
4746
+ if (indent(line.text) > base) continue;
4747
+ return indent(line.text) === base && /^\s*else\s*:/.test(line.text);
4748
+ }
4749
+ return false;
4750
+ }
3968
4751
  function run15(context) {
3969
4752
  const findings = [];
3970
4753
  for (const file of context.files) {
@@ -3988,6 +4771,7 @@ function run15(context) {
3988
4771
  }
3989
4772
  for (const chunk of file.chunks) {
3990
4773
  const after = afterLines(chunk);
4774
+ const lexicalLines = sourceView(chunk, "after", /\.(?:py|rb)$/.test(filePath)).code.split("\n");
3991
4775
  const addedInOrder = addedLines(chunk);
3992
4776
  const templated = insideTemplateLiteral(addedInOrder);
3993
4777
  const removedLoud = deletedLines(chunk).filter((line) => ERROR_DIAGNOSTIC_RE.test(line.text));
@@ -4022,7 +4806,10 @@ function run15(context) {
4022
4806
  if (templated.has(index)) continue;
4023
4807
  if (hasExplanation(added.text)) continue;
4024
4808
  if (isCommentLine(added.text)) continue;
4809
+ const lexicalIndex = after.findIndex((line) => line.line === added.line && line.added);
4810
+ if (lexicalIndex >= 0 && !lexicalLines[lexicalIndex]?.trim()) continue;
4025
4811
  const code = withoutLiterals(added.text);
4812
+ if (/^\s*except\b/.test(code) && followedByElse(after, after.findIndex((l) => l.line === added.line && l.added))) continue;
4026
4813
  const inline = INLINE_SIGNATURES.find((sig) => sig.opener.test(code));
4027
4814
  if (inline) {
4028
4815
  findings.push({
@@ -4065,7 +4852,8 @@ function run15(context) {
4065
4852
  }
4066
4853
  }
4067
4854
  }
4068
- return findings;
4855
+ const combined = [...findings, ...structuralSwallows(context)];
4856
+ return combined.filter((finding, index) => !combined.slice(0, index).some((prior) => prior.file === finding.file && prior.line === finding.line && prior.severity === finding.severity));
4069
4857
  }
4070
4858
  var wi101 = { id: "WI101", severity: "error", run: run15 };
4071
4859
 
@@ -4878,6 +5666,7 @@ function assertionKey(text) {
4878
5666
  var TABLE_RUNNER_RE = /\.each\b|\bit_each\b|@pytest\.mark\.parametrize|@ParameterizedTest\b|\[InlineData\b|\[Theory\]|\bfor\s+(?:const|let|var)?\s*[\w[\],{} ]+\s+(?:of|in)\s+\w*(?:case|test|table|fixture|sample|scenario|example)\w*\b|\bfor\s+\w+,\s*\w+\s*:=\s*range\b|\.forEach\s*\(|\btests\s*:?=\s*\[\]struct\b/i;
4879
5667
  function isDataRow(text) {
4880
5668
  const stripped = withoutTrailingComment(text);
5669
+ if (/^\s*[A-Za-z_$][\w$.]*\s*\(/.test(stripped) && !TABLE_RUNNER_RE.test(stripped)) return false;
4881
5670
  if (!/[[{]/.test(stripped)) return false;
4882
5671
  const literals = stripped.match(/-?\d+(?:\.\d+)?|'[^']*'|"[^"]*"|`[^`]*`|\btrue\b|\bfalse\b|\bTrue\b|\bFalse\b/g);
4883
5672
  return (literals?.length ?? 0) >= 2;
@@ -4915,12 +5704,20 @@ function run23(context) {
4915
5704
  for (const file of context.files) {
4916
5705
  const filePath = pathOf(file);
4917
5706
  if (!filePath || !context.isTestFile(filePath)) continue;
4918
- const hasTableRunner = file.chunks.some(
4919
- (chunk) => chunk.changes.some((change) => TABLE_RUNNER_RE.test(change.content.replace(/^[+\- ]/, "")))
4920
- );
5707
+ const hasTableRunner = file.chunks.some((chunk) => TABLE_RUNNER_RE.test(sourceView(chunk, "after", /\.(?:py|rb)$/.test(filePath)).code));
4921
5708
  for (const chunk of file.chunks) {
4922
- const removedBindings = deletedLines(chunk).map((line) => expectedBinding(line.text)).filter((binding) => binding !== void 0);
4923
- for (const added of addedLines(chunk)) {
5709
+ const before = sourceView(chunk, "before", /\.(?:py|rb)$/.test(filePath));
5710
+ const after = sourceView(chunk, "after", /\.(?:py|rb)$/.test(filePath));
5711
+ const codeAt = (view, line) => {
5712
+ const offset = view.offsetAtLine(line);
5713
+ if (offset === void 0) return "";
5714
+ const end = view.code.indexOf("\n", offset);
5715
+ return view.code.slice(offset, end < 0 ? void 0 : end);
5716
+ };
5717
+ const removedLines = deletedLines(chunk).filter((line) => codeAt(before, line.line).trim());
5718
+ const additions = addedLines(chunk).filter((line) => codeAt(after, line.line).trim());
5719
+ const removedBindings = removedLines.map((line) => expectedBinding(line.text)).filter((binding) => binding !== void 0);
5720
+ for (const added of additions) {
4924
5721
  const binding = expectedBinding(added.text);
4925
5722
  if (!binding) continue;
4926
5723
  const prior = removedBindings.find((old) => old.name === binding.name && old.value !== binding.value);
@@ -4934,9 +5731,9 @@ function run23(context) {
4934
5731
  suggestion: "Change the implementation, not the expected/want binding. If the specification changed, record that reason and update the implementation in the same reviewable change."
4935
5732
  });
4936
5733
  }
4937
- const removed = deletedLines(chunk).map((l) => ({ line: l, value: expectedLiteral(l.text), key: assertionKey(l.text) })).filter((x) => x.value !== void 0);
5734
+ const removed = removedLines.map((l) => ({ line: l, value: expectedLiteral(l.text), key: assertionKey(l.text) })).filter((x) => x.value !== void 0);
4938
5735
  if (removed.length > 0) {
4939
- for (const added of addedLines(chunk)) {
5736
+ for (const added of additions) {
4940
5737
  const value = expectedLiteral(added.text);
4941
5738
  if (value === void 0) continue;
4942
5739
  const key = assertionKey(added.text);
@@ -4953,8 +5750,9 @@ function run23(context) {
4953
5750
  }
4954
5751
  }
4955
5752
  if (!hasTableRunner) continue;
4956
- const removedRows = deletedLines(chunk).filter((line) => isDataRow(line.text));
4957
- for (const added of addedLines(chunk).filter((line) => isDataRow(line.text))) {
5753
+ const removedRows = removedLines.filter((line) => isDataRow(line.text));
5754
+ const removedTexts = new Set(removedLines.map((line) => line.text.trim()));
5755
+ for (const added of additions.filter((line) => isDataRow(line.text) && !removedTexts.has(line.text.trim()))) {
4958
5756
  const pair = removedRows.map((prior) => changedTableExpectation(prior.text, added.text)).find((value) => value !== void 0);
4959
5757
  if (!pair) continue;
4960
5758
  findings.push({
@@ -4976,7 +5774,7 @@ var wi109 = { id: "WI109", severity: "error", run: run23 };
4976
5774
  import path6 from "path";
4977
5775
  var TASK_FILE_RE = /(?:^|\/)package\.json$|(?:^|\/)Makefile$|(?:^|\/)Taskfile\.ya?ml$|(?:^|\/)justfile$|(?:^|\/)pyproject\.toml$|(?:^|\/)Rakefile$|(?:^|\/)composer\.json$/i;
4978
5776
  var VERIFICATION_SCRIPT_RE = /"(test|tests|check|lint|typecheck|type-check|tsc|verify|validate|ci|coverage|audit)[\w:-]*"\s*:/i;
4979
- var REAL_VERIFIER_RE = /\b(?:vitest|jest|mocha|ava|pytest|tox|nox|rspec|phpunit|cargo\s+test|go\s+test|dotnet\s+test|mvn|gradle|swift\s+test|mix\s+test|bats|busted|prove|tsc|eslint|ruff|flake8|mypy|pylint|clippy|rubocop|phpstan|stylelint|biome)\b|\bnpm\s+(?:run\s+)?(?:test|\w+)|\bpytest\b/;
5777
+ var REAL_VERIFIER_RE = /\b(?:vitest|jest|mocha|ava|pytest|tox|nox|rspec|phpunit|cargo\s+test|go\s+test|dotnet\s+test|mvn|gradle|swift\s+test|mix\s+test|bats|busted|prove|tsc|eslint|ruff|flake8|mypy|pylint|clippy|rubocop|phpstan|stylelint|biome)\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|check|lint|build|typecheck|verify|coverage|audit)[\w:-]*\b|\bnode\s+--test\b/;
4980
5778
  var NO_OP_COMMAND_RE = /^\s*(?:echo\b|true\b|:\s*$|exit\s+0\b|#|node\s+-e\s+["']["']|cd\s+\.)/;
4981
5779
  var NEUTERING_SIGNATURES = [
4982
5780
  {
@@ -5032,8 +5830,8 @@ var SNAPSHOT_UPDATE_RE = /(?:^|\s)(?:-u|--update|--updateSnapshot)\b/;
5032
5830
  function verifierTool(text) {
5033
5831
  return /\b(vitest|jest|mocha|ava|pytest|tox|nox|rspec|phpunit|cargo\s+test|go\s+test|dotnet\s+test)\b/.exec(text)?.[1] ?? (/\bnpm\s+(?:run\s+)?test\b/.test(text) ? "npm test" : void 0);
5034
5832
  }
5035
- var PROPAGATED_EXIT_RE = /\b(?:process\.exit|sys\.exit|os\.exit|os\._exit)\s*\(\s*([^)]+?)\s*\)|^\s*exit\s+(\$\{?\w+\}?)\s*(?:#.*)?$/;
5036
- var FORCED_EXIT_ZERO_RE = /\b(?:process\.exit|sys\.exit|os\.exit|os\._exit)\s*\(\s*0\s*\)|^\s*exit\s+0\s*(?:#.*)?$/;
5833
+ var PROPAGATED_EXIT_RE = /\b(?:process\.exit|sys\.exit|os\.(?:exit|_exit|Exit)|(?:std::)?process::exit|System\.exit|Environment\.Exit|exit|die)\s*\(\s*([^)]+?)\s*\)|^\s*exit\s+(\$\{?\w+\}?)\s*(?:#.*)?$/;
5834
+ var FORCED_EXIT_ZERO_RE = /\b(?:process\.exit|sys\.exit|os\.(?:exit|_exit|Exit)|(?:std::)?process::exit|System\.exit|Environment\.Exit|exit|die)\s*\(\s*0\s*\)|^\s*exit\s+0\s*(?:#.*)?$/;
5037
5835
  var PROPAGATED_EXIT_CODE_RE = /\bprocess\.exitCode\s*=\s*([^;]+?)\s*;?\s*$/;
5038
5836
  var FORCED_EXIT_CODE_ZERO_RE = /\bprocess\.exitCode\s*=\s*0\s*;?\s*$/;
5039
5837
  function exitStatus(text) {
@@ -5045,9 +5843,9 @@ function exitStatus(text) {
5045
5843
  return (m[1] ?? m[2])?.trim();
5046
5844
  }
5047
5845
  function laundersStatus(text, status) {
5048
- const compact = withoutTrailingComment(text).replace(/\s+/g, "");
5846
+ const compact3 = withoutTrailingComment(text).replace(/\s+/g, "");
5049
5847
  const value = status.replace(/\s+/g, "");
5050
- return compact.includes(`process.exit(${value}?0:${value})`) || compact.includes(`process.exitCode=Math.min(${value},0)`);
5848
+ return compact3.includes(`process.exit(${value}?0:${value})`) || compact3.includes(`process.exitCode=Math.min(${value},0)`);
5051
5849
  }
5052
5850
  function scriptCommand(text) {
5053
5851
  const m = /"[\w:-]+"\s*:\s*"([^"]*)"/.exec(text);
@@ -5108,7 +5906,7 @@ function run24(context) {
5108
5906
  });
5109
5907
  }
5110
5908
  const after = afterLines(chunk);
5111
- const afterRunsVerification = after.some((line) => REAL_VERIFIER_RE.test(line.text));
5909
+ const afterRunsVerification = after.some((line) => !isCommentLine(line.text) && REAL_VERIFIER_RE.test(line.text));
5112
5910
  const failFastShell = after.some((line) => /\bset\s+-[a-z]*e\b/.test(line.text));
5113
5911
  const backgroundStatusPropagated = after.some((line) => /^\s*wait\b/.test(line.text)) && after.some((line) => /^\s*exit\s+\$\?\s*$/.test(line.text));
5114
5912
  for (const line of added) {
@@ -5238,7 +6036,29 @@ function run24(context) {
5238
6036
  });
5239
6037
  if (!newEntry) continue;
5240
6038
  const newCommand = scriptCommand(newEntry.text) ?? "";
5241
- const dropped = oldSteps.filter((s) => REAL_VERIFIER_RE.test(s) && !newCommand.includes(s));
6039
+ const entries = new Map(file.chunks.flatMap(afterLines).flatMap((line) => scriptEntries(line.text)).map((entry) => [entry.name, entry.command]));
6040
+ const expand2 = (command, seen = /* @__PURE__ */ new Set()) => {
6041
+ let expanded = command;
6042
+ for (const call of command.matchAll(/\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?([\w:-]+)/g)) {
6043
+ const name = call[1];
6044
+ if (seen.has(name) || seen.size >= 20) continue;
6045
+ const target = entries.get(name);
6046
+ if (target) expanded += " && " + expand2(target, /* @__PURE__ */ new Set([...seen, name]));
6047
+ }
6048
+ return expanded;
6049
+ };
6050
+ const expandedCommand = expand2(newCommand);
6051
+ const dropped = oldSteps.filter((s) => {
6052
+ const invocation = REAL_VERIFIER_RE.exec(s);
6053
+ if (!invocation || expandedCommand.includes(s)) return false;
6054
+ const tail = s.slice(invocation.index);
6055
+ if (expandedCommand.includes(tail)) return false;
6056
+ const testTool = (command) => /\b(?:vitest|jest|mocha|ava|pytest|tox|rspec|phpunit)\b|\bnode\s+--test\b/.exec(command)?.[0];
6057
+ const priorTool = testTool(s);
6058
+ const nextTool = testTool(newCommand);
6059
+ const importsMigrated = context.files.some((candidate) => candidate.chunks.some((change) => deletedLines(change).some((line) => /\bimport\b|\brequire\s*\(/.test(line.text) && !!priorTool && line.text.includes(priorTool)) && addedLines(change).some((line) => /\bimport\b|\brequire\s*\(/.test(line.text) && /['"](?:node:test|vitest|jest|mocha|ava)['"]/.test(line.text))));
6060
+ return !(priorTool && nextTool && priorTool !== nextTool && importsMigrated);
6061
+ });
5242
6062
  if (dropped.length === 0) continue;
5243
6063
  findings.push({
5244
6064
  verifierId: "WI110",
@@ -5543,7 +6363,7 @@ var VERIFIERS = [
5543
6363
  ];
5544
6364
 
5545
6365
  // src/engine.ts
5546
- import picomatch4 from "picomatch";
6366
+ import picomatch5 from "picomatch";
5547
6367
  var norm = (p) => p.replace(/\\/g, "/");
5548
6368
  async function runChecks(context) {
5549
6369
  const activeVerifiers = VERIFIERS.filter((v) => context.enabled.includes(v.id));
@@ -5583,7 +6403,7 @@ function applySuppression(findings, files) {
5583
6403
  var GLOB_OPTIONS = { dot: true };
5584
6404
  function applyIgnorePatterns(findings, patterns) {
5585
6405
  if (patterns.length === 0 || findings.length === 0) return findings;
5586
- const matchesIgnoredPath = picomatch4(patterns, GLOB_OPTIONS);
6406
+ const matchesIgnoredPath = picomatch5(patterns, GLOB_OPTIONS);
5587
6407
  return findings.filter((f) => !matchesIgnoredPath(norm(f.file)));
5588
6408
  }
5589
6409
  function applyApprovals(findings, approvals) {
@@ -5591,7 +6411,7 @@ function applyApprovals(findings, approvals) {
5591
6411
  return findings.map((f) => {
5592
6412
  const path9 = norm(f.file);
5593
6413
  const match = approvals.find(
5594
- (a) => a.rule === f.verifierId && (norm(a.file) === path9 || picomatch4(norm(a.file), GLOB_OPTIONS)(path9))
6414
+ (a) => a.rule === f.verifierId && (norm(a.file) === path9 || picomatch5(norm(a.file), GLOB_OPTIONS)(path9))
5595
6415
  );
5596
6416
  if (!match) return f;
5597
6417
  return { ...f, severity: "info", approved: true, approvalReason: match.reason };
@@ -5660,6 +6480,24 @@ function jsonReport(findings) {
5660
6480
 
5661
6481
  // src/reporters/sarif.ts
5662
6482
  import { createHash } from "crypto";
6483
+
6484
+ // src/dynamic/metadata.ts
6485
+ var DYNAMIC_METADATA = Object.fromEntries([
6486
+ ["DY001", "OriginalTestRegression", "Previously passing original tests fail on new code"],
6487
+ ["DY002", "NumericBranchContradiction", "A literal branch contradicts an executed numeric, string or boolean reference"],
6488
+ ["DY003", "ExpectedLiteralContradiction", "An expectation-only edit changes a test outcome on unchanged code"],
6489
+ ["DY004", "SurvivingSourceMutation", "A changed statement in a called function can be mutated without a failing test"],
6490
+ ["DY005", "InconsistentRepeatedTest", "A test with a new sleep or retry has inconsistent outcomes"]
6491
+ ].map(([id, name, description]) => [id, {
6492
+ name,
6493
+ shortDescription: description,
6494
+ fullDescription: `${description}. Opt-in runtime evidence is heuristic and cannot establish intent. See README for the exact scope and limits.`,
6495
+ defaultLevel: "warning",
6496
+ fix: "Review the named commands and results, correct the behavior or test, and add a focused regression test.",
6497
+ helpUri: "https://github.com/catfish-1234/proctor#dynamic-checks"
6498
+ }]));
6499
+
6500
+ // src/reporters/sarif.ts
5663
6501
  function levelFor(severity) {
5664
6502
  if (severity === "error") return "error";
5665
6503
  if (severity === "warn") return "warning";
@@ -5669,7 +6507,7 @@ function fingerprint(verifierId, file, line) {
5669
6507
  return createHash("sha256").update(`${verifierId}:${file}:${line}`).digest("hex");
5670
6508
  }
5671
6509
  function sarifReport(findings) {
5672
- const rules = Object.entries(RULE_METADATA).map(([id, meta]) => ({
6510
+ const rules = Object.entries({ ...RULE_METADATA, ...Object.fromEntries(Object.entries(DYNAMIC_METADATA).filter(([id]) => findings.some((f) => f.verifierId === id))) }).map(([id, meta]) => ({
5673
6511
  id,
5674
6512
  name: meta.name,
5675
6513
  shortDescription: { text: meta.shortDescription },
@@ -7181,13 +8019,13 @@ async function readStdin() {
7181
8019
  }
7182
8020
  var program = new Command();
7183
8021
  program.name("proctor").description("AI agent test-tampering guard").version(package_default.version);
7184
- program.command("check [path]").description("Check staged, unstaged, and untracked changes for test tampering").option("--staged", "analyze only staged changes").option("--uncommitted", "explicitly analyze the default scope: staged, unstaged, and untracked changes together").option("--base <ref>", "analyze changes against a base ref (e.g. origin/main or a commit SHA) instead of staged/working-tree changes, for CI, where nothing is staged in a fresh checkout").option("--ci", "show only errors in the text report; warnings do not affect the exit code").option("--json", "output findings as JSON to stdout").option("--sarif", "output SARIF 2.1.0 JSON to stdout").option("--ai", "enable the networked LLM judge (requires ANTHROPIC_API_KEY)").option("--rules <ids>", "comma-separated list of verifier IDs to run (narrows the enabled set, e.g. RH001,RH003)").option("--wi", "also run the beta work-integrity family (WI1xx), which is opt-in by default").option("--all-checks", "alias for --wi: add the beta WI family to the configured checks").option("--explain <id>", "print the full explanation for a verifier ID and exit, no diff analysis").option("--fix", "with --explain, print what an honest fix looks like instead of the rule description").option("--markdown <file>", 'also write a Markdown summary to this file, e.g. --markdown "$GITHUB_STEP_SUMMARY"').action(async (pathArg, options) => {
8022
+ program.command("check [path]").description("Check staged, unstaged, and untracked changes for test tampering").option("--staged", "analyze only staged changes").option("--uncommitted", "explicitly analyze the default scope: staged, unstaged, and untracked changes together").option("--base <ref>", "analyze changes against a base ref (e.g. origin/main or a commit SHA) instead of staged/working-tree changes, for CI, where nothing is staged in a fresh checkout").option("--ci", "show only errors in the text report; warnings do not affect the exit code").option("--json", "output findings as JSON to stdout").option("--sarif", "output SARIF 2.1.0 JSON to stdout").option("--dynamic", "run isolated extra checks using the project tests (opt-in)").option("--dynamic-timeout <ms>", "overall dynamic budget in milliseconds (default 60000)").option("--ai", "enable the networked LLM judge (requires ANTHROPIC_API_KEY)").option("--rules <ids>", "comma-separated list of verifier IDs to run (narrows the enabled set, e.g. RH001,RH003)").option("--wi", "also run the remaining opt-in work-integrity checks (WI1xx)").option("--all-checks", "alias for --wi: add the entire WI family to the configured checks").option("--explain <id>", "print the full explanation for a verifier ID and exit, no diff analysis").option("--fix", "with --explain, print what an honest fix looks like instead of the rule description").option("--markdown <file>", 'also write a Markdown summary to this file, e.g. --markdown "$GITHUB_STEP_SUMMARY"').action(async (pathArg, options) => {
7185
8023
  if (options.fix && !options.explain) {
7186
8024
  process.stderr.write("proctor: --fix only applies with --explain, e.g. proctor check --explain RH001 --fix\n");
7187
8025
  process.exit(2);
7188
8026
  }
7189
8027
  if (options.explain) {
7190
- const meta = RULE_METADATA[options.explain];
8028
+ const meta = RULE_METADATA[options.explain] ?? DYNAMIC_METADATA[options.explain];
7191
8029
  if (!meta) {
7192
8030
  process.stderr.write(`proctor: unknown verifier ID '${options.explain}'
7193
8031
  `);
@@ -7237,6 +8075,10 @@ Honest fix: proctor check --explain ${options.explain} --fix
7237
8075
  `);
7238
8076
  process.exit(0);
7239
8077
  }
8078
+ if (options.dynamicTimeout !== void 0 && (!Number.isSafeInteger(Number(options.dynamicTimeout)) || Number(options.dynamicTimeout) < 1 || Number(options.dynamicTimeout) > 2147483647)) {
8079
+ process.stderr.write("proctor: --dynamic-timeout must be an integer from 1 to 2147483647 milliseconds\n");
8080
+ process.exit(2);
8081
+ }
7240
8082
  const selectedScopes = [options.base !== void 0, options.staged === true, options.uncommitted === true].filter(Boolean).length;
7241
8083
  if (selectedScopes > 1) {
7242
8084
  process.stderr.write("proctor: choose only one diff scope: --staged, --uncommitted, or --base <ref>\n");
@@ -7302,6 +8144,24 @@ Honest fix: proctor check --explain ${options.explain} --fix
7302
8144
  process.exitCode = 3;
7303
8145
  return;
7304
8146
  }
8147
+ if (options.dynamic || ctx.dynamic === true) {
8148
+ const { runDynamic: runDynamic2 } = await Promise.resolve().then(() => (init_runner(), runner_exports));
8149
+ const dynamic = await runDynamic2({
8150
+ cwd,
8151
+ workerPath: fileURLToPath3(new URL("./dynamic-worker.js", import.meta.url)),
8152
+ base: options.base,
8153
+ staged: options.staged,
8154
+ files,
8155
+ testPathGlobs: ctx.testPathGlobs,
8156
+ timeoutMs: options.dynamicTimeout !== void 0 ? Number(options.dynamicTimeout) : ctx.dynamicTimeoutMs
8157
+ });
8158
+ findings.push(...dynamic.findings);
8159
+ process.stderr.write(dynamic.status === "skipped" ? `dynamic: skipped, ${dynamic.reason}
8160
+ ` : `dynamic: completed, ${dynamic.runs.length} runs, ${dynamic.findings.length} findings, ${dynamic.elapsedMs}ms
8161
+ `);
8162
+ for (const note of dynamic.notes) process.stderr.write(`dynamic: ${note}
8163
+ `);
8164
+ }
7305
8165
  if (options.markdown) {
7306
8166
  try {
7307
8167
  await appendFile(options.markdown, markdownReport(findings) + "\n", "utf8");
@@ -7313,7 +8173,7 @@ Honest fix: proctor check --explain ${options.explain} --fix
7313
8173
  if (options.sarif) {
7314
8174
  const sarif = sarifReport(findings);
7315
8175
  const hasError2 = findings.some((f) => f.severity === "error");
7316
- const hasWarn2 = findings.some((f) => f.severity === "warn");
8176
+ const hasWarn2 = findings.some((f) => f.severity === "warn" && !["DY002", "DY004"].includes(f.verifierId));
7317
8177
  await new Promise((resolve4) => {
7318
8178
  process.stdout.write(sarif + "\n", () => {
7319
8179
  process.exitCode = hasError2 ? 2 : hasWarn2 && !options.ci ? 1 : 0;
@@ -7332,7 +8192,7 @@ Honest fix: proctor check --explain ${options.explain} --fix
7332
8192
  if (receipt.status === "honest-pass" && receipt.approvedCount === 0 && !options.ci) process.stdout.write(badgeLine(receipt) + "\n");
7333
8193
  }
7334
8194
  const hasError = findings.some((f) => f.severity === "error");
7335
- const hasWarn = findings.some((f) => f.severity === "warn");
8195
+ const hasWarn = findings.some((f) => f.severity === "warn" && !["DY002", "DY004"].includes(f.verifierId));
7336
8196
  process.exitCode = hasError ? 2 : hasWarn && !options.ci ? 1 : 0;
7337
8197
  });
7338
8198
  program.command("install-hook").description("Install git pre-commit hook").action(async () => {
@@ -7348,7 +8208,7 @@ program.command("stop-hook").description("Claude Code Stop hook: reads stdin JSO
7348
8208
  process.exit(exitCode);
7349
8209
  });
7350
8210
  program.command("install-claude-hook").description("Install Claude Code Stop hook").option("--global", "write to ~/.claude/settings.json").action(async (options) => {
7351
- const result = await installStopHook(options.global ? join14(homedir(), ".claude") : join14(process.cwd(), ".claude"));
8211
+ const result = await installStopHook(options.global ? join15(homedir(), ".claude") : join15(process.cwd(), ".claude"));
7352
8212
  if (result.status === "invalid-json") {
7353
8213
  process.stderr.write(`proctor: ${result.path} exists but is not valid JSON; fix it manually and re-run
7354
8214
  `);
@@ -7380,7 +8240,7 @@ program.command("setup").description("One-command install: writes the ruleset to
7380
8240
  process.exitCode = 1;
7381
8241
  }
7382
8242
  if (targets.some((a) => a.id === "claude-code")) {
7383
- const stop = await installStopHook(join14(cwd, ".claude"));
8243
+ const stop = await installStopHook(join15(cwd, ".claude"));
7384
8244
  if (stop.status === "invalid-json") {
7385
8245
  process.stderr.write(`proctor: ${stop.path} is not valid JSON, so the Stop hook was not installed; fix it and re-run
7386
8246
  `);
@@ -7593,7 +8453,7 @@ program.command("approve <rule> <file>").description("Record a genuine test chan
7593
8453
  process.stderr.write("proctor: --reason cannot be empty\n");
7594
8454
  process.exit(2);
7595
8455
  }
7596
- const configPath = join14(process.cwd(), "proctor.config.json");
8456
+ const configPath = join15(process.cwd(), "proctor.config.json");
7597
8457
  let config = {};
7598
8458
  try {
7599
8459
  config = JSON.parse(await readFile11(configPath, "utf8"));